diff --git a/packages/storage/src/migrations/013-scout-executor-proposals.ts b/packages/storage/src/migrations/013-scout-executor-proposals.ts new file mode 100644 index 0000000..d7d4dc2 --- /dev/null +++ b/packages/storage/src/migrations/013-scout-executor-proposals.ts @@ -0,0 +1,15 @@ +export const version = 13; +export const name = 'scout-executor-proposals'; + +export const sql = ` +ALTER TABLE tasks ADD COLUMN proposal_status TEXT CHECK(proposal_status IN ('proposed','approved','archived')); +ALTER TABLE tasks ADD COLUMN approved_by TEXT; +ALTER TABLE tasks ADD COLUMN observation_evidence_ids TEXT; + +ALTER TABLE agent_profiles ADD COLUMN role TEXT NOT NULL DEFAULT 'executor'; +ALTER TABLE agent_profiles ADD COLUMN open_proposal_count INTEGER NOT NULL DEFAULT 0; + +CREATE INDEX IF NOT EXISTS idx_task_threads_proposal_status + ON tasks(proposal_status) + WHERE proposal_status IS NOT NULL; +`; diff --git a/packages/storage/src/schema.ts b/packages/storage/src/schema.ts index 56117a2..73f65d3 100644 --- a/packages/storage/src/schema.ts +++ b/packages/storage/src/schema.ts @@ -76,8 +76,14 @@ CREATE TABLE IF NOT EXISTS tasks ( created_by TEXT NOT NULL, created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, + proposal_status TEXT CHECK(proposal_status IN ('proposed','approved','archived')), + approved_by TEXT, + observation_evidence_ids TEXT, UNIQUE(repo_root, branch) ); +CREATE INDEX IF NOT EXISTS idx_task_threads_proposal_status + ON tasks(proposal_status) + WHERE proposal_status IS NOT NULL; CREATE TABLE IF NOT EXISTS task_participants ( task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE, @@ -200,6 +206,8 @@ CREATE INDEX IF NOT EXISTS idx_reinforcements_proposal ON proposal_reinforcement CREATE TABLE IF NOT EXISTS agent_profiles ( agent TEXT PRIMARY KEY, capabilities TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'executor', + open_proposal_count INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL ); @@ -317,7 +325,7 @@ CREATE INDEX IF NOT EXISTS idx_task_run_attempts_status CREATE INDEX IF NOT EXISTS idx_task_run_attempts_parent ON task_run_attempts(parent_attempt_id); -INSERT OR IGNORE INTO schema_version(version) VALUES (12); +INSERT OR IGNORE INTO schema_version(version) VALUES (13); `; /** @@ -371,6 +379,31 @@ export const COLUMN_MIGRATIONS: ReadonlyArray<{ table: string; column: string; s column: 'error_message', sql: 'ALTER TABLE mcp_metrics ADD COLUMN error_message TEXT', }, + { + table: 'tasks', + column: 'proposal_status', + sql: "ALTER TABLE tasks ADD COLUMN proposal_status TEXT CHECK(proposal_status IN ('proposed','approved','archived'))", + }, + { + table: 'tasks', + column: 'approved_by', + sql: 'ALTER TABLE tasks ADD COLUMN approved_by TEXT', + }, + { + table: 'tasks', + column: 'observation_evidence_ids', + sql: 'ALTER TABLE tasks ADD COLUMN observation_evidence_ids TEXT', + }, + { + table: 'agent_profiles', + column: 'role', + sql: "ALTER TABLE agent_profiles ADD COLUMN role TEXT NOT NULL DEFAULT 'executor'", + }, + { + table: 'agent_profiles', + column: 'open_proposal_count', + sql: 'ALTER TABLE agent_profiles ADD COLUMN open_proposal_count INTEGER NOT NULL DEFAULT 0', + }, ]; export const POST_MIGRATION_SQL = ` @@ -381,4 +414,7 @@ CREATE INDEX IF NOT EXISTS idx_observations_task_kind_ts ON observations(task_id CREATE INDEX IF NOT EXISTS idx_observations_reply_to ON observations(reply_to); CREATE INDEX IF NOT EXISTS idx_summaries_scope_ts ON summaries(scope, ts DESC); CREATE INDEX IF NOT EXISTS idx_mcp_metrics_error_ts ON mcp_metrics(ok, error_code, ts DESC); +CREATE INDEX IF NOT EXISTS idx_task_threads_proposal_status + ON tasks(proposal_status) + WHERE proposal_status IS NOT NULL; `; diff --git a/packages/storage/src/storage.ts b/packages/storage/src/storage.ts index 46ef5a3..8daf22f 100644 --- a/packages/storage/src/storage.ts +++ b/packages/storage/src/storage.ts @@ -1580,6 +1580,9 @@ export class Storage { created_by: p.created_by, created_at: now, updated_at: now, + proposal_status: null, + approved_by: null, + observation_evidence_ids: null, }; } @@ -2579,11 +2582,14 @@ export class Storage { */ upsertAgentProfile(p: NewAgentProfile): void { const now = p.updated_at ?? Date.now(); + const existing = this.getAgentProfile(p.agent); + const role = p.role ?? existing?.role ?? 'executor'; + const openProposalCount = p.open_proposal_count ?? existing?.open_proposal_count ?? 0; this.db .prepare( - 'INSERT OR REPLACE INTO agent_profiles(agent, capabilities, updated_at) VALUES (?, ?, ?)', + 'INSERT OR REPLACE INTO agent_profiles(agent, capabilities, role, open_proposal_count, updated_at) VALUES (?, ?, ?, ?, ?)', ) - .run(p.agent, p.capabilities, now); + .run(p.agent, p.capabilities, role, openProposalCount, now); } getAgentProfile(agent: string): AgentProfileRow | undefined { diff --git a/packages/storage/src/types.ts b/packages/storage/src/types.ts index 2b97ee8..78b5228 100644 --- a/packages/storage/src/types.ts +++ b/packages/storage/src/types.ts @@ -51,6 +51,9 @@ export interface TaskRow { created_by: string; created_at: number; updated_at: number; + proposal_status: 'proposed' | 'approved' | 'archived' | null; + approved_by: string | null; + observation_evidence_ids: string | null; } export interface NewTask { @@ -222,12 +225,16 @@ export interface NewReinforcement { export interface AgentProfileRow { agent: string; capabilities: string; + role: 'scout' | 'executor' | 'queen'; + open_proposal_count: number; updated_at: number; } export interface NewAgentProfile { agent: string; capabilities: string; + role?: 'scout' | 'executor' | 'queen'; + open_proposal_count?: number; updated_at?: number; } diff --git a/packages/storage/test/agent-profiles.test.ts b/packages/storage/test/agent-profiles.test.ts index d1ad82e..34375fb 100644 --- a/packages/storage/test/agent-profiles.test.ts +++ b/packages/storage/test/agent-profiles.test.ts @@ -28,6 +28,25 @@ describe('agent profiles storage', () => { expect(row).toEqual({ agent: 'claude', capabilities: JSON.stringify({ ui_work: 0.9, api_work: 0.3 }), + role: 'executor', + open_proposal_count: 0, + updated_at: 1_000, + }); + }); + + it('upsert + get round-trips scout role and open proposal count', () => { + storage.upsertAgentProfile({ + agent: 'scout-a', + capabilities: '{}', + role: 'scout', + open_proposal_count: 2, + updated_at: 1_000, + }); + expect(storage.getAgentProfile('scout-a')).toEqual({ + agent: 'scout-a', + capabilities: '{}', + role: 'scout', + open_proposal_count: 2, updated_at: 1_000, }); }); @@ -36,6 +55,8 @@ describe('agent profiles storage', () => { storage.upsertAgentProfile({ agent: 'codex', capabilities: JSON.stringify({ api_work: 0.5 }), + role: 'scout', + open_proposal_count: 1, updated_at: 1_000, }); storage.upsertAgentProfile({ @@ -46,6 +67,8 @@ describe('agent profiles storage', () => { const row = storage.getAgentProfile('codex'); if (!row) throw new Error('expected codex profile'); expect(JSON.parse(row.capabilities)).toEqual({ api_work: 0.9, infra_work: 0.8 }); + expect(row.role).toBe('scout'); + expect(row.open_proposal_count).toBe(1); expect(row?.updated_at).toBe(2_000); }); diff --git a/packages/storage/test/migrations.test.ts b/packages/storage/test/migrations.test.ts new file mode 100644 index 0000000..658bb94 --- /dev/null +++ b/packages/storage/test/migrations.test.ts @@ -0,0 +1,60 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import Database from 'better-sqlite3'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { Storage } from '../src/index.js'; + +let dir: string; +let storage: Storage; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'colony-migrations-')); + storage = new Storage(join(dir, 'test.db')); +}); + +afterEach(() => { + storage.close(); + rmSync(dir, { recursive: true, force: true }); +}); + +describe('storage migrations', () => { + it('creates scout/executor proposal columns and defaults on a fresh DB', () => { + const db = new Database(join(dir, 'test.db')); + try { + expect(columnNames(db, 'tasks')).toEqual( + expect.arrayContaining(['proposal_status', 'approved_by', 'observation_evidence_ids']), + ); + expect(columnNames(db, 'agent_profiles')).toEqual( + expect.arrayContaining(['role', 'open_proposal_count']), + ); + + db.prepare('INSERT INTO agent_profiles(agent, capabilities, updated_at) VALUES (?, ?, ?)').run( + 'codex', + '{}', + 1_000, + ); + expect(db.prepare('SELECT role, open_proposal_count FROM agent_profiles').get()).toEqual({ + role: 'executor', + open_proposal_count: 0, + }); + expect(indexNames(db, 'tasks')).toContain('idx_task_threads_proposal_status'); + } finally { + db.close(); + } + }); +}); + +function columnNames(db: Database.Database, table: string): string[] { + return db + .prepare(`PRAGMA table_info(${table})`) + .all() + .map((row) => (row as { name: string }).name); +} + +function indexNames(db: Database.Database, table: string): string[] { + return db + .prepare(`PRAGMA index_list(${table})`) + .all() + .map((row) => (row as { name: string }).name); +}