diff --git a/apps/mcp-server/src/handlers/proposals.ts b/apps/mcp-server/src/handlers/proposals.ts new file mode 100644 index 0000000..123267c --- /dev/null +++ b/apps/mcp-server/src/handlers/proposals.ts @@ -0,0 +1,292 @@ +import { + MAX_OPEN_PROPOSALS_PER_SCOUT, + TASK_THREAD_ERROR_CODES, + TaskThreadError, + type AgentRole, + type MemoryStore, +} from '@colony/core'; + +type ProposalActorRole = AgentRole | 'operator'; + +export interface ProposalHandlerContext { + agent: string; + session_id?: string; + now?: () => number; +} + +export interface TaskProposeHandlerInput { + repo_root: string; + branch: string; + summary: string; + rationale?: string; + touches_files?: string[]; + observationEvidenceIds?: number[]; +} + +export interface TaskApproveProposalHandlerInput { + taskId: number; +} + +export interface TaskProposeHandlerResult { + task_id: number; + proposal_status: 'proposed'; + open_proposal_count: number; +} + +export interface TaskApproveProposalHandlerResult { + task_id: number; + approved: boolean; + approved_by: string; +} + +export class ProposalHandlerError extends Error { + readonly code: string; + + constructor(code: string, message: string) { + super(message); + this.name = 'ProposalHandlerError'; + this.code = code; + } +} + +interface SqlRunResult { + changes: number; + lastInsertRowid: number | bigint; +} + +interface SqlStatement { + all(...args: unknown[]): Array>; + get(...args: unknown[]): Record | undefined; + run(...args: unknown[]): SqlRunResult; +} + +interface SqlDatabase { + prepare(sql: string): SqlStatement; +} + +interface StorageWithDb { + db: SqlDatabase; +} + +interface ActorProfile { + role: ProposalActorRole; + openProposalCount: number; +} + +interface ProposedTaskRow { + id: number; + created_by: string; + proposal_status: string | null; +} + +export function handleTaskPropose( + store: MemoryStore, + ctx: ProposalHandlerContext, + input: TaskProposeHandlerInput, +): TaskProposeHandlerResult { + return store.storage.transaction( + () => { + const db = rawDb(store); + assertProposalSchema(db); + const actor = loadActorProfile(store, ctx.agent); + if (actor.role === 'executor') { + throw new TaskThreadError( + TASK_THREAD_ERROR_CODES.EXECUTOR_CANNOT_PROPOSE, + 'executors cannot propose; scouts must provide evidence first', + ); + } + if (!input.observationEvidenceIds || input.observationEvidenceIds.length === 0) { + throw new TaskThreadError( + TASK_THREAD_ERROR_CODES.PROPOSAL_MISSING_EVIDENCE, + 'observationEvidenceIds must contain at least one evidence id', + ); + } + if (actor.openProposalCount >= MAX_OPEN_PROPOSALS_PER_SCOUT) { + throw new TaskThreadError( + TASK_THREAD_ERROR_CODES.PROPOSAL_CAP_EXCEEDED, + `scout ${ctx.agent} already has ${MAX_OPEN_PROPOSALS_PER_SCOUT} open proposals`, + ); + } + + const now = ctx.now?.() ?? Date.now(); + const taskId = insertProposedTask(db, { + repo_root: input.repo_root, + branch: input.branch, + title: input.summary, + created_by: ctx.agent, + observationEvidenceIds: input.observationEvidenceIds, + now, + }); + const openProposalCount = incrementOpenProposalCount(db, ctx.agent, now); + return { + task_id: taskId, + proposal_status: 'proposed', + open_proposal_count: openProposalCount, + }; + }, + { immediate: true }, + ); +} + +export function handleTaskApproveProposal( + store: MemoryStore, + ctx: ProposalHandlerContext, + input: TaskApproveProposalHandlerInput, +): TaskApproveProposalHandlerResult { + return store.storage.transaction( + () => { + const db = rawDb(store); + assertProposalSchema(db); + const actor = loadActorProfile(store, ctx.agent); + if (actor.role !== 'queen' && actor.role !== 'operator') { + throw new ProposalHandlerError( + 'APPROVAL_FORBIDDEN', + 'only queen or operator agents can approve proposals', + ); + } + + const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(input.taskId); + if (!row) { + throw new TaskThreadError( + TASK_THREAD_ERROR_CODES.TASK_NOT_FOUND, + `task ${input.taskId} not found`, + ); + } + + const task = normalizeProposedTaskRow(row); + const now = ctx.now?.() ?? Date.now(); + const result = db + .prepare( + `UPDATE tasks + SET proposal_status = 'approved', approved_by = ?, updated_at = ? + WHERE id = ? AND proposal_status = 'proposed'`, + ) + .run(ctx.agent, now, input.taskId); + if (result.changes > 0) { + decrementOpenProposalCount(db, task.created_by, now); + } + + return { + task_id: input.taskId, + approved: result.changes > 0, + approved_by: ctx.agent, + }; + }, + { immediate: true }, + ); +} + +function rawDb(store: MemoryStore): SqlDatabase { + return (store.storage as unknown as StorageWithDb).db; +} + +function assertProposalSchema(db: SqlDatabase): void { + const taskColumns = tableColumns(db, 'tasks'); + const profileColumns = tableColumns(db, 'agent_profiles'); + const missing = [ + ...missingColumns(taskColumns, ['proposal_status', 'approved_by', 'observation_evidence_ids']), + ...missingColumns(profileColumns, ['role', 'open_proposal_count']), + ]; + if (missing.length > 0) { + throw new ProposalHandlerError( + 'PROPOSAL_SCHEMA_MISSING', + `proposal handler schema missing columns: ${missing.join(', ')}`, + ); + } +} + +function tableColumns(db: SqlDatabase, table: 'tasks' | 'agent_profiles'): Set { + const rows = db.prepare(`PRAGMA table_info(${table})`).all(); + return new Set(rows.map((row) => String(row.name))); +} + +function missingColumns(columns: Set, required: string[]): string[] { + return required.filter((column) => !columns.has(column)); +} + +function loadActorProfile(store: MemoryStore, agent: string): ActorProfile { + const row = store.storage.getAgentProfile(agent) as + | ({ role?: unknown; open_proposal_count?: unknown } & Record) + | undefined; + return { + role: normalizeRole(row?.role), + openProposalCount: numberOrZero(row?.open_proposal_count), + }; +} + +function normalizeRole(value: unknown): ProposalActorRole { + if (value === 'scout' || value === 'executor' || value === 'queen' || value === 'operator') { + return value; + } + return 'executor'; +} + +function numberOrZero(value: unknown): number { + return typeof value === 'number' && Number.isFinite(value) ? value : 0; +} + +function insertProposedTask( + db: SqlDatabase, + args: { + repo_root: string; + branch: string; + title: string; + created_by: string; + observationEvidenceIds: number[]; + now: number; + }, +): number { + const result = db + .prepare( + `INSERT INTO tasks( + title, repo_root, branch, status, created_by, created_at, updated_at, + proposal_status, approved_by, observation_evidence_ids + ) VALUES (?, ?, ?, 'open', ?, ?, ?, 'proposed', NULL, ?)`, + ) + .run( + args.title, + args.repo_root, + args.branch, + args.created_by, + args.now, + args.now, + JSON.stringify(args.observationEvidenceIds), + ); + return Number(result.lastInsertRowid); +} + +function incrementOpenProposalCount(db: SqlDatabase, agent: string, now: number): number { + const result = db + .prepare( + `UPDATE agent_profiles + SET open_proposal_count = open_proposal_count + 1, + updated_at = ? + WHERE agent = ?`, + ) + .run(now, agent); + if (result.changes === 0) return 0; + const row = db + .prepare('SELECT open_proposal_count FROM agent_profiles WHERE agent = ?') + .get(agent); + return numberOrZero(row?.open_proposal_count); +} + +function decrementOpenProposalCount(db: SqlDatabase, agent: string, now: number): void { + db.prepare( + `UPDATE agent_profiles + SET open_proposal_count = CASE + WHEN open_proposal_count > 0 THEN open_proposal_count - 1 + ELSE 0 + END, + updated_at = ? + WHERE agent = ?`, + ).run(now, agent); +} + +function normalizeProposedTaskRow(row: Record): ProposedTaskRow { + return { + id: numberOrZero(row.id), + created_by: typeof row.created_by === 'string' ? row.created_by : '', + proposal_status: typeof row.proposal_status === 'string' ? row.proposal_status : null, + }; +} diff --git a/apps/mcp-server/test/handlers/proposals.test.ts b/apps/mcp-server/test/handlers/proposals.test.ts new file mode 100644 index 0000000..9e97991 --- /dev/null +++ b/apps/mcp-server/test/handlers/proposals.test.ts @@ -0,0 +1,187 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { defaultSettings } from '@colony/config'; +import { MAX_OPEN_PROPOSALS_PER_SCOUT, MemoryStore } from '@colony/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + handleTaskApproveProposal, + handleTaskPropose, + type ProposalHandlerContext, +} from '../../src/handlers/proposals.js'; + +interface SqlRunResult { + changes: number; + lastInsertRowid: number | bigint; +} + +interface SqlStatement { + all(...args: unknown[]): Array>; + get(...args: unknown[]): Record | undefined; + run(...args: unknown[]): SqlRunResult; +} + +interface SqlDatabase { + prepare(sql: string): SqlStatement; +} + +interface StorageWithDb { + db: SqlDatabase; +} + +let dir: string; +let store: MemoryStore; +let db: SqlDatabase; + +beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'colony-mcp-proposals-')); + store = new MemoryStore({ dbPath: join(dir, 'data.db'), settings: defaultSettings }); + db = (store.storage as unknown as StorageWithDb).db; + installProposalSchema(); +}); + +afterEach(() => { + store.close(); + rmSync(dir, { recursive: true, force: true }); +}); + +describe('proposal handlers', () => { + it('rejects missing observation evidence', () => { + seedProfile('scout-A', 'scout'); + + expect(() => + handleTaskPropose(store, ctx('scout-A'), { + repo_root: '/repo', + branch: 'proposal/missing-evidence', + summary: 'Add evidence-gated proposals', + }), + ).toThrowError(expect.objectContaining({ code: 'PROPOSAL_MISSING_EVIDENCE' })); + }); + + it('rejects scouts at the open proposal cap', () => { + seedProfile('scout-A', 'scout', MAX_OPEN_PROPOSALS_PER_SCOUT); + + expect(() => + handleTaskPropose(store, ctx('scout-A'), { + repo_root: '/repo', + branch: 'proposal/capped', + summary: 'Capped proposal', + observationEvidenceIds: [101], + }), + ).toThrowError(expect.objectContaining({ code: 'PROPOSAL_CAP_EXCEEDED' })); + }); + + it('rejects executor proposals', () => { + seedProfile('exec-A', 'executor'); + + expect(() => + handleTaskPropose(store, ctx('exec-A'), { + repo_root: '/repo', + branch: 'proposal/executor', + summary: 'Executor cannot propose', + observationEvidenceIds: [102], + }), + ).toThrowError(expect.objectContaining({ code: 'EXECUTOR_CANNOT_PROPOSE' })); + }); + + it('creates a proposed task thread and increments the scout counter', () => { + seedProfile('scout-A', 'scout'); + + const result = handleTaskPropose(store, ctx('scout-A'), { + repo_root: '/repo', + branch: 'proposal/happy', + summary: 'Happy proposal', + rationale: 'Evidence exists.', + touches_files: ['src/a.ts'], + observationEvidenceIds: [103, 104], + }); + + expect(result).toMatchObject({ proposal_status: 'proposed', open_proposal_count: 1 }); + const task = taskRow(result.task_id); + expect(task).toMatchObject({ + title: 'Happy proposal', + repo_root: '/repo', + branch: 'proposal/happy', + created_by: 'scout-A', + proposal_status: 'proposed', + approved_by: null, + }); + expect(JSON.parse(String(task.observation_evidence_ids))).toEqual([103, 104]); + expect(profileCount('scout-A')).toBe(1); + }); + + it('approves a proposed task and decrements the scout counter', () => { + seedProfile('scout-A', 'scout'); + seedProfile('queen-A', 'queen'); + const proposed = handleTaskPropose(store, ctx('scout-A'), { + repo_root: '/repo', + branch: 'proposal/approve', + summary: 'Approve me', + observationEvidenceIds: [105], + }); + + const result = handleTaskApproveProposal(store, ctx('queen-A'), { + taskId: proposed.task_id, + }); + + expect(result).toEqual({ + task_id: proposed.task_id, + approved: true, + approved_by: 'queen-A', + }); + expect(taskRow(proposed.task_id)).toMatchObject({ + proposal_status: 'approved', + approved_by: 'queen-A', + }); + expect(profileCount('scout-A')).toBe(0); + }); +}); + +function ctx(agent: string): ProposalHandlerContext { + return { agent, session_id: `${agent}-session`, now: () => 1_000 }; +} + +function installProposalSchema(): void { + addColumnIfMissing('tasks', 'proposal_status', 'TEXT'); + addColumnIfMissing('tasks', 'approved_by', 'TEXT'); + addColumnIfMissing('tasks', 'observation_evidence_ids', 'TEXT'); + addColumnIfMissing('agent_profiles', 'role', "TEXT NOT NULL DEFAULT 'executor'"); + addColumnIfMissing( + 'agent_profiles', + 'open_proposal_count', + 'INTEGER NOT NULL DEFAULT 0', + ); +} + +function addColumnIfMissing( + table: 'tasks' | 'agent_profiles', + column: string, + definition: string, +): void { + const columns = new Set( + db.prepare(`PRAGMA table_info(${table})`).all().map((row) => String(row.name)), + ); + if (!columns.has(column)) { + db.prepare(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`).run(); + } +} + +function seedProfile(agent: string, role: string, openProposalCount = 0): void { + db.prepare( + `INSERT INTO agent_profiles(agent, capabilities, updated_at, role, open_proposal_count) + VALUES (?, '{}', 1, ?, ?)`, + ).run(agent, role, openProposalCount); +} + +function profileCount(agent: string): number { + const row = db + .prepare('SELECT open_proposal_count FROM agent_profiles WHERE agent = ?') + .get(agent); + return Number(row?.open_proposal_count ?? 0); +} + +function taskRow(taskId: number): Record { + const row = db.prepare('SELECT * FROM tasks WHERE id = ?').get(taskId); + if (!row) throw new Error(`task ${taskId} not found`); + return row; +}