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:
gavrielc
2026-04-08 23:34:09 +03:00
parent 90acff28ad
commit 3f0451b7b0
15 changed files with 1267 additions and 0 deletions

View File

@@ -0,0 +1,55 @@
import Database from 'better-sqlite3';
const SESSION_DB_PATH = '/workspace/session.db';
let _db: Database.Database | null = null;
export function getSessionDb(): Database.Database {
if (!_db) {
_db = new Database(process.env.SESSION_DB_PATH || SESSION_DB_PATH);
_db.pragma('journal_mode = WAL');
_db.pragma('foreign_keys = ON');
}
return _db;
}
/** For tests — opens an in-memory DB with session schema. */
export function initTestSessionDb(): Database.Database {
_db = new Database(':memory:');
_db.pragma('foreign_keys = ON');
_db.exec(`
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
);
`);
return _db;
}
export function closeSessionDb(): void {
_db?.close();
_db = null;
}

View File

@@ -0,0 +1,5 @@
export { getSessionDb, initTestSessionDb, closeSessionDb } from './connection.js';
export { getPendingMessages, markProcessing, markCompleted, markFailed, getMessageIn, findQuestionResponse } from './messages-in.js';
export type { MessageInRow } from './messages-in.js';
export { writeMessageOut, getUndeliveredMessages, markDelivered } from './messages-out.js';
export type { MessageOutRow, WriteMessageOut } from './messages-out.js';

View File

@@ -0,0 +1,65 @@
import { getSessionDb } from './connection.js';
export interface MessageInRow {
id: string;
kind: string;
timestamp: string;
status: string;
status_changed: string | null;
process_after: string | null;
recurrence: string | null;
tries: number;
platform_id: string | null;
channel_type: string | null;
thread_id: string | null;
content: string;
}
/** Fetch all pending messages that are due for processing. */
export function getPendingMessages(): MessageInRow[] {
return getSessionDb()
.prepare(
`SELECT * FROM messages_in
WHERE status = 'pending'
AND (process_after IS NULL OR process_after <= datetime('now'))
ORDER BY timestamp ASC`,
)
.all() as MessageInRow[];
}
/** Mark messages as processing. */
export function markProcessing(ids: string[]): void {
if (ids.length === 0) return;
const db = getSessionDb();
const stmt = db.prepare("UPDATE messages_in SET status = 'processing', status_changed = datetime('now'), tries = tries + 1 WHERE id = ?");
db.transaction(() => {
for (const id of ids) stmt.run(id);
})();
}
/** Mark messages as completed. */
export function markCompleted(ids: string[]): void {
if (ids.length === 0) return;
const db = getSessionDb();
const stmt = db.prepare("UPDATE messages_in SET status = 'completed', status_changed = datetime('now') WHERE id = ?");
db.transaction(() => {
for (const id of ids) stmt.run(id);
})();
}
/** Mark a single message as failed. */
export function markFailed(id: string): void {
getSessionDb().prepare("UPDATE messages_in SET status = 'failed', status_changed = datetime('now') WHERE id = ?").run(id);
}
/** Get a message by ID. */
export function getMessageIn(id: string): MessageInRow | undefined {
return getSessionDb().prepare('SELECT * FROM messages_in WHERE id = ?').get(id) as MessageInRow | undefined;
}
/** Find a pending response to a question (by questionId in content). */
export function findQuestionResponse(questionId: string): MessageInRow | undefined {
return getSessionDb()
.prepare("SELECT * FROM messages_in WHERE status = 'pending' AND content LIKE ?")
.get(`%"questionId":"${questionId}"%`) as MessageInRow | undefined;
}

View File

@@ -0,0 +1,62 @@
import { getSessionDb } from './connection.js';
export interface MessageOutRow {
id: string;
in_reply_to: string | null;
timestamp: string;
delivered: number;
deliver_after: string | null;
recurrence: string | null;
kind: string;
platform_id: string | null;
channel_type: string | null;
thread_id: string | null;
content: string;
}
export interface WriteMessageOut {
id: string;
in_reply_to?: string | null;
deliver_after?: string | null;
recurrence?: string | null;
kind: string;
platform_id?: string | null;
channel_type?: string | null;
thread_id?: string | null;
content: string;
}
/** Write a new outbound message. */
export function writeMessageOut(msg: WriteMessageOut): void {
getSessionDb()
.prepare(
`INSERT INTO messages_out (id, in_reply_to, timestamp, delivered, deliver_after, recurrence, kind, platform_id, channel_type, thread_id, content)
VALUES (@id, @in_reply_to, datetime('now'), 0, @deliver_after, @recurrence, @kind, @platform_id, @channel_type, @thread_id, @content)`,
)
.run({
in_reply_to: null,
deliver_after: null,
recurrence: null,
platform_id: null,
channel_type: null,
thread_id: null,
...msg,
});
}
/** Get undelivered messages (for host polling). */
export function getUndeliveredMessages(): MessageOutRow[] {
return getSessionDb()
.prepare(
`SELECT * FROM messages_out
WHERE delivered = 0
AND (deliver_after IS NULL OR deliver_after <= datetime('now'))
ORDER BY timestamp ASC`,
)
.all() as MessageOutRow[];
}
/** Mark a message as delivered. */
export function markDelivered(id: string): void {
getSessionDb().prepare('UPDATE messages_out SET delivered = 1 WHERE id = ?').run(id);
}

51
src/db/agent-groups.ts Normal file
View 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
View 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
View 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
View 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';

View 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);
}

View 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
);
`);
},
};

View 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
View 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
View 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);
}

64
src/log.ts Normal file
View File

@@ -0,0 +1,64 @@
const LEVELS = { debug: 20, info: 30, warn: 40, error: 50, fatal: 60 } as const;
type Level = keyof typeof LEVELS;
const COLORS: Record<Level, string> = {
debug: '\x1b[34m',
info: '\x1b[32m',
warn: '\x1b[33m',
error: '\x1b[31m',
fatal: '\x1b[41m\x1b[37m',
};
const KEY_COLOR = '\x1b[35m';
const MSG_COLOR = '\x1b[36m';
const RESET = '\x1b[39m';
const FULL_RESET = '\x1b[0m';
const threshold = LEVELS[(process.env.LOG_LEVEL as Level) || 'info'] ?? LEVELS.info;
function formatErr(err: unknown): string {
if (err instanceof Error) {
return `{ type: "${err.constructor.name}", message: "${err.message}", stack: ${err.stack} }`;
}
return JSON.stringify(err);
}
function formatData(data: Record<string, unknown>): string {
const parts: string[] = [];
for (const [k, v] of Object.entries(data)) {
parts.push(`${KEY_COLOR}${k}${RESET}=${k === 'err' ? formatErr(v) : JSON.stringify(v)}`);
}
return parts.length ? ' ' + parts.join(' ') : '';
}
function ts(): string {
const d = new Date();
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
const ss = String(d.getSeconds()).padStart(2, '0');
const ms = String(d.getMilliseconds()).padStart(3, '0');
return `${hh}:${mm}:${ss}.${ms}`;
}
function emit(level: Level, msg: string, data?: Record<string, unknown>): void {
if (LEVELS[level] < threshold) return;
const tag = `${COLORS[level]}${level.toUpperCase()}${level === 'fatal' ? FULL_RESET : RESET}`;
const stream = LEVELS[level] >= LEVELS.warn ? process.stderr : process.stdout;
stream.write(`[${ts()}] ${tag} ${MSG_COLOR}${msg}${RESET}${data ? formatData(data) : ''}\n`);
}
export const log = {
debug: (msg: string, data?: Record<string, unknown>) => emit('debug', msg, data),
info: (msg: string, data?: Record<string, unknown>) => emit('info', msg, data),
warn: (msg: string, data?: Record<string, unknown>) => emit('warn', msg, data),
error: (msg: string, data?: Record<string, unknown>) => emit('error', msg, data),
fatal: (msg: string, data?: Record<string, unknown>) => emit('fatal', msg, data),
};
process.on('uncaughtException', (err) => {
log.fatal('Uncaught exception', { err });
process.exit(1);
});
process.on('unhandledRejection', (reason) => {
log.error('Unhandled rejection', { err: reason });
});

90
src/types-v2.ts Normal file
View File

@@ -0,0 +1,90 @@
// ── Central DB entities ──
export interface AgentGroup {
id: string;
name: string;
folder: string;
is_admin: number; // 0 | 1
agent_provider: string | null;
container_config: string | null; // JSON: { additionalMounts, timeout }
created_at: string;
}
export interface MessagingGroup {
id: string;
channel_type: string;
platform_id: string;
name: string | null;
is_group: number; // 0 | 1
admin_user_id: string | null;
created_at: string;
}
export interface MessagingGroupAgent {
id: string;
messaging_group_id: string;
agent_group_id: string;
trigger_rules: string | null; // JSON: { pattern, mentionOnly, excludeSenders, includeSenders }
response_scope: 'all' | 'triggered' | 'allowlisted';
session_mode: 'shared' | 'per-thread';
priority: number;
created_at: string;
}
export interface Session {
id: string;
agent_group_id: string;
messaging_group_id: string | null;
thread_id: string | null;
agent_provider: string | null;
status: 'active' | 'closed';
container_status: 'running' | 'idle' | 'stopped';
last_active: string | null;
created_at: string;
}
// ── Session DB entities ──
export type MessageInKind = 'chat' | 'chat-sdk' | 'task' | 'webhook' | 'system';
export type MessageInStatus = 'pending' | 'processing' | 'completed' | 'failed';
export interface MessageIn {
id: string;
kind: MessageInKind;
timestamp: string;
status: MessageInStatus;
status_changed: string | null;
process_after: string | null;
recurrence: string | null;
tries: number;
platform_id: string | null;
channel_type: string | null;
thread_id: string | null;
content: string; // JSON blob
}
export interface MessageOut {
id: string;
in_reply_to: string | null;
timestamp: string;
delivered: number; // 0 | 1
deliver_after: string | null;
recurrence: string | null;
kind: string;
platform_id: string | null;
channel_type: string | null;
thread_id: string | null;
content: string; // JSON blob
}
// ── Pending questions (central DB) ──
export interface PendingQuestion {
question_id: string;
session_id: string;
message_out_id: string;
platform_id: string | null;
channel_type: string | null;
thread_id: string | null;
created_at: string;
}