v2 phase 1: foundation — types, DB layer, logging
Add the v2 data layer: typed interfaces, central DB with migration runner, per-entity CRUD, and agent-runner session DB operations. - src/log.ts: concise message-first logging API - src/types-v2.ts: AgentGroup, MessagingGroup, Session, MessageIn/Out - src/db/: connection (WAL), migration runner, 001-initial schema, CRUD for agent_groups, messaging_groups, sessions, pending_questions - container/agent-runner/src/db/: session DB connection, messages_in reads + status transitions, messages_out writes - 31 new tests, all 277 tests pass Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
51
src/db/agent-groups.ts
Normal file
51
src/db/agent-groups.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { AgentGroup } from '../types-v2.js';
|
||||
import { getDb } from './connection.js';
|
||||
|
||||
export function createAgentGroup(group: AgentGroup): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO agent_groups (id, name, folder, is_admin, agent_provider, container_config, created_at)
|
||||
VALUES (@id, @name, @folder, @is_admin, @agent_provider, @container_config, @created_at)`,
|
||||
)
|
||||
.run(group);
|
||||
}
|
||||
|
||||
export function getAgentGroup(id: string): AgentGroup | undefined {
|
||||
return getDb().prepare('SELECT * FROM agent_groups WHERE id = ?').get(id) as AgentGroup | undefined;
|
||||
}
|
||||
|
||||
export function getAgentGroupByFolder(folder: string): AgentGroup | undefined {
|
||||
return getDb().prepare('SELECT * FROM agent_groups WHERE folder = ?').get(folder) as AgentGroup | undefined;
|
||||
}
|
||||
|
||||
export function getAllAgentGroups(): AgentGroup[] {
|
||||
return getDb().prepare('SELECT * FROM agent_groups ORDER BY name').all() as AgentGroup[];
|
||||
}
|
||||
|
||||
export function getAdminAgentGroup(): AgentGroup | undefined {
|
||||
return getDb().prepare('SELECT * FROM agent_groups WHERE is_admin = 1 LIMIT 1').get() as AgentGroup | undefined;
|
||||
}
|
||||
|
||||
export function updateAgentGroup(
|
||||
id: string,
|
||||
updates: Partial<Pick<AgentGroup, 'name' | 'agent_provider' | 'container_config'>>,
|
||||
): void {
|
||||
const fields: string[] = [];
|
||||
const values: Record<string, unknown> = { id };
|
||||
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value !== undefined) {
|
||||
fields.push(`${key} = @${key}`);
|
||||
values[key] = value;
|
||||
}
|
||||
}
|
||||
if (fields.length === 0) return;
|
||||
|
||||
getDb()
|
||||
.prepare(`UPDATE agent_groups SET ${fields.join(', ')} WHERE id = @id`)
|
||||
.run(values);
|
||||
}
|
||||
|
||||
export function deleteAgentGroup(id: string): void {
|
||||
getDb().prepare('DELETE FROM agent_groups WHERE id = ?').run(id);
|
||||
}
|
||||
33
src/db/connection.ts
Normal file
33
src/db/connection.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
import { log } from '../log.js';
|
||||
|
||||
let _db: Database.Database | null = null;
|
||||
|
||||
export function getDb(): Database.Database {
|
||||
if (!_db) throw new Error('Database not initialized. Call initDb() first.');
|
||||
return _db;
|
||||
}
|
||||
|
||||
export function initDb(dbPath: string): Database.Database {
|
||||
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
||||
_db = new Database(dbPath);
|
||||
_db.pragma('journal_mode = WAL');
|
||||
_db.pragma('foreign_keys = ON');
|
||||
log.info('Central DB initialized', { path: dbPath });
|
||||
return _db;
|
||||
}
|
||||
|
||||
/** For tests only — creates an in-memory DB and runs migrations. */
|
||||
export function initTestDb(): Database.Database {
|
||||
_db = new Database(':memory:');
|
||||
_db.pragma('foreign_keys = ON');
|
||||
return _db;
|
||||
}
|
||||
|
||||
export function closeDb(): void {
|
||||
_db?.close();
|
||||
_db = null;
|
||||
}
|
||||
405
src/db/db-v2.test.ts
Normal file
405
src/db/db-v2.test.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
import {
|
||||
initTestDb,
|
||||
closeDb,
|
||||
runMigrations,
|
||||
createAgentGroup,
|
||||
getAgentGroup,
|
||||
getAgentGroupByFolder,
|
||||
getAllAgentGroups,
|
||||
getAdminAgentGroup,
|
||||
updateAgentGroup,
|
||||
deleteAgentGroup,
|
||||
createMessagingGroup,
|
||||
getMessagingGroup,
|
||||
getMessagingGroupByPlatform,
|
||||
getAllMessagingGroups,
|
||||
updateMessagingGroup,
|
||||
deleteMessagingGroup,
|
||||
createMessagingGroupAgent,
|
||||
getMessagingGroupAgents,
|
||||
getMessagingGroupAgent,
|
||||
updateMessagingGroupAgent,
|
||||
deleteMessagingGroupAgent,
|
||||
createSession,
|
||||
getSession,
|
||||
findSession,
|
||||
getSessionsByAgentGroup,
|
||||
getActiveSessions,
|
||||
getRunningSessions,
|
||||
updateSession,
|
||||
deleteSession,
|
||||
createPendingQuestion,
|
||||
getPendingQuestion,
|
||||
deletePendingQuestion,
|
||||
} from './index.js';
|
||||
|
||||
function now() {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
closeDb();
|
||||
});
|
||||
|
||||
// ── Migrations ──
|
||||
|
||||
describe('migrations', () => {
|
||||
it('should be idempotent', () => {
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
// Running again should not throw
|
||||
runMigrations(db);
|
||||
});
|
||||
|
||||
it('should track schema version', () => {
|
||||
const db = initTestDb();
|
||||
runMigrations(db);
|
||||
const row = db.prepare('SELECT MAX(version) as v FROM schema_version').get() as { v: number };
|
||||
expect(row.v).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Agent Groups ──
|
||||
|
||||
describe('agent groups', () => {
|
||||
const ag = () => ({
|
||||
id: 'ag-1',
|
||||
name: 'Test Agent',
|
||||
folder: 'test-agent',
|
||||
is_admin: 0,
|
||||
agent_provider: null,
|
||||
container_config: null,
|
||||
created_at: now(),
|
||||
});
|
||||
|
||||
it('should create and retrieve', () => {
|
||||
createAgentGroup(ag());
|
||||
const result = getAgentGroup('ag-1');
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.name).toBe('Test Agent');
|
||||
expect(result!.folder).toBe('test-agent');
|
||||
});
|
||||
|
||||
it('should find by folder', () => {
|
||||
createAgentGroup(ag());
|
||||
const result = getAgentGroupByFolder('test-agent');
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.id).toBe('ag-1');
|
||||
});
|
||||
|
||||
it('should list all', () => {
|
||||
createAgentGroup(ag());
|
||||
createAgentGroup({ ...ag(), id: 'ag-2', name: 'Another', folder: 'another' });
|
||||
expect(getAllAgentGroups()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should find admin group', () => {
|
||||
createAgentGroup(ag());
|
||||
createAgentGroup({ ...ag(), id: 'ag-admin', name: 'Admin', folder: 'admin', is_admin: 1 });
|
||||
const admin = getAdminAgentGroup();
|
||||
expect(admin).toBeDefined();
|
||||
expect(admin!.id).toBe('ag-admin');
|
||||
});
|
||||
|
||||
it('should update', () => {
|
||||
createAgentGroup(ag());
|
||||
updateAgentGroup('ag-1', { name: 'Updated' });
|
||||
expect(getAgentGroup('ag-1')!.name).toBe('Updated');
|
||||
});
|
||||
|
||||
it('should delete', () => {
|
||||
createAgentGroup(ag());
|
||||
deleteAgentGroup('ag-1');
|
||||
expect(getAgentGroup('ag-1')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should enforce unique folder', () => {
|
||||
createAgentGroup(ag());
|
||||
expect(() => createAgentGroup({ ...ag(), id: 'ag-dup' })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Messaging Groups ──
|
||||
|
||||
describe('messaging groups', () => {
|
||||
const mg = () => ({
|
||||
id: 'mg-1',
|
||||
channel_type: 'discord',
|
||||
platform_id: 'chan-123',
|
||||
name: 'General',
|
||||
is_group: 1,
|
||||
admin_user_id: 'user-1',
|
||||
created_at: now(),
|
||||
});
|
||||
|
||||
it('should create and retrieve', () => {
|
||||
createMessagingGroup(mg());
|
||||
const result = getMessagingGroup('mg-1');
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.channel_type).toBe('discord');
|
||||
});
|
||||
|
||||
it('should find by platform', () => {
|
||||
createMessagingGroup(mg());
|
||||
const result = getMessagingGroupByPlatform('discord', 'chan-123');
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.id).toBe('mg-1');
|
||||
});
|
||||
|
||||
it('should enforce unique channel_type + platform_id', () => {
|
||||
createMessagingGroup(mg());
|
||||
expect(() => createMessagingGroup({ ...mg(), id: 'mg-dup' })).toThrow();
|
||||
});
|
||||
|
||||
it('should update', () => {
|
||||
createMessagingGroup(mg());
|
||||
updateMessagingGroup('mg-1', { name: 'Updated' });
|
||||
expect(getMessagingGroup('mg-1')!.name).toBe('Updated');
|
||||
});
|
||||
|
||||
it('should delete', () => {
|
||||
createMessagingGroup(mg());
|
||||
deleteMessagingGroup('mg-1');
|
||||
expect(getMessagingGroup('mg-1')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Messaging Group Agents ──
|
||||
|
||||
describe('messaging group agents', () => {
|
||||
beforeEach(() => {
|
||||
createAgentGroup({
|
||||
id: 'ag-1',
|
||||
name: 'Agent',
|
||||
folder: 'agent',
|
||||
is_admin: 0,
|
||||
agent_provider: null,
|
||||
container_config: null,
|
||||
created_at: now(),
|
||||
});
|
||||
createMessagingGroup({
|
||||
id: 'mg-1',
|
||||
channel_type: 'discord',
|
||||
platform_id: 'chan-1',
|
||||
name: 'Gen',
|
||||
is_group: 1,
|
||||
admin_user_id: null,
|
||||
created_at: now(),
|
||||
});
|
||||
});
|
||||
|
||||
const mga = () => ({
|
||||
id: 'mga-1',
|
||||
messaging_group_id: 'mg-1',
|
||||
agent_group_id: 'ag-1',
|
||||
trigger_rules: null,
|
||||
response_scope: 'all' as const,
|
||||
session_mode: 'shared' as const,
|
||||
priority: 0,
|
||||
created_at: now(),
|
||||
});
|
||||
|
||||
it('should create and list by messaging group', () => {
|
||||
createMessagingGroupAgent(mga());
|
||||
const results = getMessagingGroupAgents('mg-1');
|
||||
expect(results).toHaveLength(1);
|
||||
expect(results[0].agent_group_id).toBe('ag-1');
|
||||
});
|
||||
|
||||
it('should order by priority descending', () => {
|
||||
createMessagingGroupAgent(mga());
|
||||
createAgentGroup({
|
||||
id: 'ag-2',
|
||||
name: 'Agent2',
|
||||
folder: 'agent2',
|
||||
is_admin: 0,
|
||||
agent_provider: null,
|
||||
container_config: null,
|
||||
created_at: now(),
|
||||
});
|
||||
createMessagingGroupAgent({ ...mga(), id: 'mga-2', agent_group_id: 'ag-2', priority: 10 });
|
||||
const results = getMessagingGroupAgents('mg-1');
|
||||
expect(results[0].agent_group_id).toBe('ag-2');
|
||||
expect(results[1].agent_group_id).toBe('ag-1');
|
||||
});
|
||||
|
||||
it('should enforce unique messaging_group + agent_group', () => {
|
||||
createMessagingGroupAgent(mga());
|
||||
expect(() => createMessagingGroupAgent({ ...mga(), id: 'mga-dup' })).toThrow();
|
||||
});
|
||||
|
||||
it('should update', () => {
|
||||
createMessagingGroupAgent(mga());
|
||||
updateMessagingGroupAgent('mga-1', { priority: 5 });
|
||||
expect(getMessagingGroupAgent('mga-1')!.priority).toBe(5);
|
||||
});
|
||||
|
||||
it('should delete', () => {
|
||||
createMessagingGroupAgent(mga());
|
||||
deleteMessagingGroupAgent('mga-1');
|
||||
expect(getMessagingGroupAgents('mg-1')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should enforce foreign key on agent_group_id', () => {
|
||||
expect(() => createMessagingGroupAgent({ ...mga(), agent_group_id: 'nonexistent' })).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Sessions ──
|
||||
|
||||
describe('sessions', () => {
|
||||
beforeEach(() => {
|
||||
createAgentGroup({
|
||||
id: 'ag-1',
|
||||
name: 'Agent',
|
||||
folder: 'agent',
|
||||
is_admin: 0,
|
||||
agent_provider: null,
|
||||
container_config: null,
|
||||
created_at: now(),
|
||||
});
|
||||
createMessagingGroup({
|
||||
id: 'mg-1',
|
||||
channel_type: 'discord',
|
||||
platform_id: 'chan-1',
|
||||
name: 'Gen',
|
||||
is_group: 1,
|
||||
admin_user_id: null,
|
||||
created_at: now(),
|
||||
});
|
||||
});
|
||||
|
||||
const sess = () => ({
|
||||
id: 'sess-1',
|
||||
agent_group_id: 'ag-1',
|
||||
messaging_group_id: 'mg-1',
|
||||
thread_id: null,
|
||||
agent_provider: null,
|
||||
status: 'active' as const,
|
||||
container_status: 'stopped' as const,
|
||||
last_active: null,
|
||||
created_at: now(),
|
||||
});
|
||||
|
||||
it('should create and retrieve', () => {
|
||||
createSession(sess());
|
||||
const result = getSession('sess-1');
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.agent_group_id).toBe('ag-1');
|
||||
});
|
||||
|
||||
it('should find by messaging group (shared, no thread)', () => {
|
||||
createSession(sess());
|
||||
const result = findSession('mg-1', null);
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.id).toBe('sess-1');
|
||||
});
|
||||
|
||||
it('should find by messaging group + thread', () => {
|
||||
createSession({ ...sess(), thread_id: 'thread-1' });
|
||||
expect(findSession('mg-1', 'thread-1')).toBeDefined();
|
||||
expect(findSession('mg-1', 'thread-2')).toBeUndefined();
|
||||
expect(findSession('mg-1', null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should only find active sessions', () => {
|
||||
createSession({ ...sess(), status: 'closed' });
|
||||
expect(findSession('mg-1', null)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should list by agent group', () => {
|
||||
createSession(sess());
|
||||
createSession({ ...sess(), id: 'sess-2', thread_id: 'thread-1' });
|
||||
expect(getSessionsByAgentGroup('ag-1')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should list active sessions', () => {
|
||||
createSession(sess());
|
||||
createSession({ ...sess(), id: 'sess-closed', status: 'closed', thread_id: 'thread-x' });
|
||||
expect(getActiveSessions()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should list running sessions', () => {
|
||||
createSession({ ...sess(), container_status: 'running' });
|
||||
createSession({ ...sess(), id: 'sess-idle', container_status: 'idle', thread_id: 'thread-1' });
|
||||
createSession({ ...sess(), id: 'sess-stopped', container_status: 'stopped', thread_id: 'thread-2' });
|
||||
expect(getRunningSessions()).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('should update', () => {
|
||||
createSession(sess());
|
||||
updateSession('sess-1', { container_status: 'running', last_active: now() });
|
||||
const result = getSession('sess-1')!;
|
||||
expect(result.container_status).toBe('running');
|
||||
expect(result.last_active).not.toBeNull();
|
||||
});
|
||||
|
||||
it('should delete', () => {
|
||||
createSession(sess());
|
||||
deleteSession('sess-1');
|
||||
expect(getSession('sess-1')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Pending Questions ──
|
||||
|
||||
describe('pending questions', () => {
|
||||
beforeEach(() => {
|
||||
createAgentGroup({
|
||||
id: 'ag-1',
|
||||
name: 'Agent',
|
||||
folder: 'agent',
|
||||
is_admin: 0,
|
||||
agent_provider: null,
|
||||
container_config: null,
|
||||
created_at: now(),
|
||||
});
|
||||
createSession({
|
||||
id: 'sess-1',
|
||||
agent_group_id: 'ag-1',
|
||||
messaging_group_id: null,
|
||||
thread_id: null,
|
||||
agent_provider: null,
|
||||
status: 'active',
|
||||
container_status: 'stopped',
|
||||
last_active: null,
|
||||
created_at: now(),
|
||||
});
|
||||
});
|
||||
|
||||
it('should create and retrieve', () => {
|
||||
createPendingQuestion({
|
||||
question_id: 'q-1',
|
||||
session_id: 'sess-1',
|
||||
message_out_id: 'msg-out-1',
|
||||
platform_id: 'chan-1',
|
||||
channel_type: 'discord',
|
||||
thread_id: null,
|
||||
created_at: now(),
|
||||
});
|
||||
const result = getPendingQuestion('q-1');
|
||||
expect(result).toBeDefined();
|
||||
expect(result!.session_id).toBe('sess-1');
|
||||
});
|
||||
|
||||
it('should delete', () => {
|
||||
createPendingQuestion({
|
||||
question_id: 'q-1',
|
||||
session_id: 'sess-1',
|
||||
message_out_id: 'msg-out-1',
|
||||
platform_id: null,
|
||||
channel_type: null,
|
||||
thread_id: null,
|
||||
created_at: now(),
|
||||
});
|
||||
deletePendingQuestion('q-1');
|
||||
expect(getPendingQuestion('q-1')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
37
src/db/index.ts
Normal file
37
src/db/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export { initDb, initTestDb, getDb, closeDb } from './connection.js';
|
||||
export { runMigrations } from './migrations/index.js';
|
||||
export {
|
||||
createAgentGroup,
|
||||
getAgentGroup,
|
||||
getAgentGroupByFolder,
|
||||
getAllAgentGroups,
|
||||
getAdminAgentGroup,
|
||||
updateAgentGroup,
|
||||
deleteAgentGroup,
|
||||
} from './agent-groups.js';
|
||||
export {
|
||||
createMessagingGroup,
|
||||
getMessagingGroup,
|
||||
getMessagingGroupByPlatform,
|
||||
getAllMessagingGroups,
|
||||
updateMessagingGroup,
|
||||
deleteMessagingGroup,
|
||||
createMessagingGroupAgent,
|
||||
getMessagingGroupAgents,
|
||||
getMessagingGroupAgent,
|
||||
updateMessagingGroupAgent,
|
||||
deleteMessagingGroupAgent,
|
||||
} from './messaging-groups.js';
|
||||
export {
|
||||
createSession,
|
||||
getSession,
|
||||
findSession,
|
||||
getSessionsByAgentGroup,
|
||||
getActiveSessions,
|
||||
getRunningSessions,
|
||||
updateSession,
|
||||
deleteSession,
|
||||
createPendingQuestion,
|
||||
getPendingQuestion,
|
||||
deletePendingQuestion,
|
||||
} from './sessions.js';
|
||||
98
src/db/messaging-groups.ts
Normal file
98
src/db/messaging-groups.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import type { MessagingGroup, MessagingGroupAgent } from '../types-v2.js';
|
||||
import { getDb } from './connection.js';
|
||||
|
||||
// ── Messaging Groups ──
|
||||
|
||||
export function createMessagingGroup(group: MessagingGroup): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO messaging_groups (id, channel_type, platform_id, name, is_group, admin_user_id, created_at)
|
||||
VALUES (@id, @channel_type, @platform_id, @name, @is_group, @admin_user_id, @created_at)`,
|
||||
)
|
||||
.run(group);
|
||||
}
|
||||
|
||||
export function getMessagingGroup(id: string): MessagingGroup | undefined {
|
||||
return getDb().prepare('SELECT * FROM messaging_groups WHERE id = ?').get(id) as MessagingGroup | undefined;
|
||||
}
|
||||
|
||||
export function getMessagingGroupByPlatform(channelType: string, platformId: string): MessagingGroup | undefined {
|
||||
return getDb()
|
||||
.prepare('SELECT * FROM messaging_groups WHERE channel_type = ? AND platform_id = ?')
|
||||
.get(channelType, platformId) as MessagingGroup | undefined;
|
||||
}
|
||||
|
||||
export function getAllMessagingGroups(): MessagingGroup[] {
|
||||
return getDb().prepare('SELECT * FROM messaging_groups ORDER BY name').all() as MessagingGroup[];
|
||||
}
|
||||
|
||||
export function updateMessagingGroup(
|
||||
id: string,
|
||||
updates: Partial<Pick<MessagingGroup, 'name' | 'is_group' | 'admin_user_id'>>,
|
||||
): void {
|
||||
const fields: string[] = [];
|
||||
const values: Record<string, unknown> = { id };
|
||||
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value !== undefined) {
|
||||
fields.push(`${key} = @${key}`);
|
||||
values[key] = value;
|
||||
}
|
||||
}
|
||||
if (fields.length === 0) return;
|
||||
|
||||
getDb()
|
||||
.prepare(`UPDATE messaging_groups SET ${fields.join(', ')} WHERE id = @id`)
|
||||
.run(values);
|
||||
}
|
||||
|
||||
export function deleteMessagingGroup(id: string): void {
|
||||
getDb().prepare('DELETE FROM messaging_groups WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
// ── Messaging Group Agents ──
|
||||
|
||||
export function createMessagingGroupAgent(mga: MessagingGroupAgent): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO messaging_group_agents (id, messaging_group_id, agent_group_id, trigger_rules, response_scope, session_mode, priority, created_at)
|
||||
VALUES (@id, @messaging_group_id, @agent_group_id, @trigger_rules, @response_scope, @session_mode, @priority, @created_at)`,
|
||||
)
|
||||
.run(mga);
|
||||
}
|
||||
|
||||
export function getMessagingGroupAgents(messagingGroupId: string): MessagingGroupAgent[] {
|
||||
return getDb()
|
||||
.prepare('SELECT * FROM messaging_group_agents WHERE messaging_group_id = ? ORDER BY priority DESC')
|
||||
.all(messagingGroupId) as MessagingGroupAgent[];
|
||||
}
|
||||
|
||||
export function getMessagingGroupAgent(id: string): MessagingGroupAgent | undefined {
|
||||
return getDb().prepare('SELECT * FROM messaging_group_agents WHERE id = ?').get(id) as
|
||||
| MessagingGroupAgent
|
||||
| undefined;
|
||||
}
|
||||
|
||||
export function updateMessagingGroupAgent(
|
||||
id: string,
|
||||
updates: Partial<Pick<MessagingGroupAgent, 'trigger_rules' | 'response_scope' | 'session_mode' | 'priority'>>,
|
||||
): void {
|
||||
const fields: string[] = [];
|
||||
const values: Record<string, unknown> = { id };
|
||||
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value !== undefined) {
|
||||
fields.push(`${key} = @${key}`);
|
||||
values[key] = value;
|
||||
}
|
||||
}
|
||||
if (fields.length === 0) return;
|
||||
|
||||
getDb()
|
||||
.prepare(`UPDATE messaging_group_agents SET ${fields.join(', ')} WHERE id = @id`)
|
||||
.run(values);
|
||||
}
|
||||
|
||||
export function deleteMessagingGroupAgent(id: string): void {
|
||||
getDb().prepare('DELETE FROM messaging_group_agents WHERE id = ?').run(id);
|
||||
}
|
||||
68
src/db/migrations/001-initial.ts
Normal file
68
src/db/migrations/001-initial.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
import type { Migration } from './index.js';
|
||||
|
||||
export const migration001: Migration = {
|
||||
version: 1,
|
||||
name: 'initial-v2-schema',
|
||||
up(db: Database.Database) {
|
||||
db.exec(`
|
||||
CREATE TABLE agent_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
folder TEXT NOT NULL UNIQUE,
|
||||
is_admin INTEGER DEFAULT 0,
|
||||
agent_provider TEXT,
|
||||
container_config TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE messaging_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_type TEXT NOT NULL,
|
||||
platform_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
is_group INTEGER DEFAULT 0,
|
||||
admin_user_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(channel_type, platform_id)
|
||||
);
|
||||
|
||||
CREATE TABLE messaging_group_agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id),
|
||||
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
|
||||
trigger_rules TEXT,
|
||||
response_scope TEXT DEFAULT 'all',
|
||||
session_mode TEXT DEFAULT 'shared',
|
||||
priority INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(messaging_group_id, agent_group_id)
|
||||
);
|
||||
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
|
||||
messaging_group_id TEXT REFERENCES messaging_groups(id),
|
||||
thread_id TEXT,
|
||||
agent_provider TEXT,
|
||||
status TEXT DEFAULT 'active',
|
||||
container_status TEXT DEFAULT 'stopped',
|
||||
last_active TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_sessions_agent_group ON sessions(agent_group_id);
|
||||
CREATE INDEX idx_sessions_lookup ON sessions(messaging_group_id, thread_id);
|
||||
|
||||
CREATE TABLE pending_questions (
|
||||
question_id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL REFERENCES sessions(id),
|
||||
message_out_id TEXT NOT NULL,
|
||||
platform_id TEXT,
|
||||
channel_type TEXT,
|
||||
thread_id TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
},
|
||||
};
|
||||
46
src/db/migrations/index.ts
Normal file
46
src/db/migrations/index.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import type Database from 'better-sqlite3';
|
||||
|
||||
import { log } from '../../log.js';
|
||||
import { migration001 } from './001-initial.js';
|
||||
|
||||
export interface Migration {
|
||||
version: number;
|
||||
name: string;
|
||||
up: (db: Database.Database) => void;
|
||||
}
|
||||
|
||||
const migrations: Migration[] = [migration001];
|
||||
|
||||
export function runMigrations(db: Database.Database): void {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
applied TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
const currentVersion =
|
||||
(db.prepare('SELECT MAX(version) as v FROM schema_version').get() as { v: number | null })?.v ?? 0;
|
||||
|
||||
const pending = migrations.filter((m) => m.version > currentVersion);
|
||||
if (pending.length === 0) return;
|
||||
|
||||
log.info('Running migrations', {
|
||||
from: currentVersion,
|
||||
to: pending[pending.length - 1].version,
|
||||
count: pending.length,
|
||||
});
|
||||
|
||||
for (const m of pending) {
|
||||
db.transaction(() => {
|
||||
m.up(db);
|
||||
db.prepare('INSERT INTO schema_version (version, name, applied) VALUES (?, ?, ?)').run(
|
||||
m.version,
|
||||
m.name,
|
||||
new Date().toISOString(),
|
||||
);
|
||||
})();
|
||||
log.info('Migration applied', { version: m.version, name: m.name });
|
||||
}
|
||||
}
|
||||
103
src/db/schema.ts
Normal file
103
src/db/schema.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* Reference copy of the current v2 schema.
|
||||
* Read this to understand the DB structure.
|
||||
* Actual creation is done by migrations — do not use this at runtime.
|
||||
*/
|
||||
|
||||
export const SCHEMA = `
|
||||
-- Agent workspaces: folder, skills, CLAUDE.md, container config
|
||||
CREATE TABLE agent_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
folder TEXT NOT NULL UNIQUE,
|
||||
is_admin INTEGER DEFAULT 0,
|
||||
agent_provider TEXT,
|
||||
container_config TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
|
||||
-- Platform groups/channels
|
||||
CREATE TABLE messaging_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
channel_type TEXT NOT NULL,
|
||||
platform_id TEXT NOT NULL,
|
||||
name TEXT,
|
||||
is_group INTEGER DEFAULT 0,
|
||||
admin_user_id TEXT,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(channel_type, platform_id)
|
||||
);
|
||||
|
||||
-- Which agent groups handle which messaging groups
|
||||
CREATE TABLE messaging_group_agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
messaging_group_id TEXT NOT NULL REFERENCES messaging_groups(id),
|
||||
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
|
||||
trigger_rules TEXT,
|
||||
response_scope TEXT DEFAULT 'all',
|
||||
session_mode TEXT DEFAULT 'shared',
|
||||
priority INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
UNIQUE(messaging_group_id, agent_group_id)
|
||||
);
|
||||
|
||||
-- Sessions: one folder = one session = one container when running
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
agent_group_id TEXT NOT NULL REFERENCES agent_groups(id),
|
||||
messaging_group_id TEXT REFERENCES messaging_groups(id),
|
||||
thread_id TEXT,
|
||||
agent_provider TEXT,
|
||||
status TEXT DEFAULT 'active',
|
||||
container_status TEXT DEFAULT 'stopped',
|
||||
last_active TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX idx_sessions_agent_group ON sessions(agent_group_id);
|
||||
CREATE INDEX idx_sessions_lookup ON sessions(messaging_group_id, thread_id);
|
||||
|
||||
-- Pending interactive questions
|
||||
CREATE TABLE pending_questions (
|
||||
question_id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL REFERENCES sessions(id),
|
||||
message_out_id TEXT NOT NULL,
|
||||
platform_id TEXT,
|
||||
channel_type TEXT,
|
||||
thread_id TEXT,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
`;
|
||||
|
||||
/**
|
||||
* Session DB schema — created fresh by the host for each session.
|
||||
*/
|
||||
export const SESSION_SCHEMA = `
|
||||
CREATE TABLE messages_in (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
timestamp TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'pending',
|
||||
status_changed TEXT,
|
||||
process_after TEXT,
|
||||
recurrence TEXT,
|
||||
tries INTEGER DEFAULT 0,
|
||||
platform_id TEXT,
|
||||
channel_type TEXT,
|
||||
thread_id TEXT,
|
||||
content TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE messages_out (
|
||||
id TEXT PRIMARY KEY,
|
||||
in_reply_to TEXT,
|
||||
timestamp TEXT NOT NULL,
|
||||
delivered INTEGER DEFAULT 0,
|
||||
deliver_after TEXT,
|
||||
recurrence TEXT,
|
||||
kind TEXT NOT NULL,
|
||||
platform_id TEXT,
|
||||
channel_type TEXT,
|
||||
thread_id TEXT,
|
||||
content TEXT NOT NULL
|
||||
);
|
||||
`;
|
||||
85
src/db/sessions.ts
Normal file
85
src/db/sessions.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import type { PendingQuestion, Session } from '../types-v2.js';
|
||||
import { getDb } from './connection.js';
|
||||
|
||||
// ── Sessions ──
|
||||
|
||||
export function createSession(session: Session): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO sessions (id, agent_group_id, messaging_group_id, thread_id, agent_provider, status, container_status, last_active, created_at)
|
||||
VALUES (@id, @agent_group_id, @messaging_group_id, @thread_id, @agent_provider, @status, @container_status, @last_active, @created_at)`,
|
||||
)
|
||||
.run(session);
|
||||
}
|
||||
|
||||
export function getSession(id: string): Session | undefined {
|
||||
return getDb().prepare('SELECT * FROM sessions WHERE id = ?').get(id) as Session | undefined;
|
||||
}
|
||||
|
||||
export function findSession(messagingGroupId: string, threadId: string | null): Session | undefined {
|
||||
if (threadId) {
|
||||
return getDb()
|
||||
.prepare('SELECT * FROM sessions WHERE messaging_group_id = ? AND thread_id = ? AND status = ?')
|
||||
.get(messagingGroupId, threadId, 'active') as Session | undefined;
|
||||
}
|
||||
return getDb()
|
||||
.prepare('SELECT * FROM sessions WHERE messaging_group_id = ? AND thread_id IS NULL AND status = ?')
|
||||
.get(messagingGroupId, 'active') as Session | undefined;
|
||||
}
|
||||
|
||||
export function getSessionsByAgentGroup(agentGroupId: string): Session[] {
|
||||
return getDb().prepare('SELECT * FROM sessions WHERE agent_group_id = ?').all(agentGroupId) as Session[];
|
||||
}
|
||||
|
||||
export function getActiveSessions(): Session[] {
|
||||
return getDb().prepare("SELECT * FROM sessions WHERE status = 'active'").all() as Session[];
|
||||
}
|
||||
|
||||
export function getRunningSessions(): Session[] {
|
||||
return getDb().prepare("SELECT * FROM sessions WHERE container_status IN ('running', 'idle')").all() as Session[];
|
||||
}
|
||||
|
||||
export function updateSession(
|
||||
id: string,
|
||||
updates: Partial<Pick<Session, 'status' | 'container_status' | 'last_active' | 'agent_provider'>>,
|
||||
): void {
|
||||
const fields: string[] = [];
|
||||
const values: Record<string, unknown> = { id };
|
||||
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (value !== undefined) {
|
||||
fields.push(`${key} = @${key}`);
|
||||
values[key] = value;
|
||||
}
|
||||
}
|
||||
if (fields.length === 0) return;
|
||||
|
||||
getDb()
|
||||
.prepare(`UPDATE sessions SET ${fields.join(', ')} WHERE id = @id`)
|
||||
.run(values);
|
||||
}
|
||||
|
||||
export function deleteSession(id: string): void {
|
||||
getDb().prepare('DELETE FROM sessions WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
// ── Pending Questions ──
|
||||
|
||||
export function createPendingQuestion(pq: PendingQuestion): void {
|
||||
getDb()
|
||||
.prepare(
|
||||
`INSERT INTO pending_questions (question_id, session_id, message_out_id, platform_id, channel_type, thread_id, created_at)
|
||||
VALUES (@question_id, @session_id, @message_out_id, @platform_id, @channel_type, @thread_id, @created_at)`,
|
||||
)
|
||||
.run(pq);
|
||||
}
|
||||
|
||||
export function getPendingQuestion(questionId: string): PendingQuestion | undefined {
|
||||
return getDb().prepare('SELECT * FROM pending_questions WHERE question_id = ?').get(questionId) as
|
||||
| PendingQuestion
|
||||
| undefined;
|
||||
}
|
||||
|
||||
export function deletePendingQuestion(questionId: string): void {
|
||||
getDb().prepare('DELETE FROM pending_questions WHERE question_id = ?').run(questionId);
|
||||
}
|
||||
Reference in New Issue
Block a user