From 20b2c96b1af736839b72f129a16acb0a3c904717 Mon Sep 17 00:00:00 2001 From: NagyVikt Date: Fri, 15 May 2026 11:56:11 +0200 Subject: [PATCH] Add scout claim handler --- apps/mcp-server/src/handlers/claims.ts | 47 ++++++++++++ apps/mcp-server/test/handlers/claims.test.ts | 75 ++++++++++++++++++++ 2 files changed, 122 insertions(+) create mode 100644 apps/mcp-server/src/handlers/claims.ts create mode 100644 apps/mcp-server/test/handlers/claims.test.ts diff --git a/apps/mcp-server/src/handlers/claims.ts b/apps/mcp-server/src/handlers/claims.ts new file mode 100644 index 0000000..d3f287d --- /dev/null +++ b/apps/mcp-server/src/handlers/claims.ts @@ -0,0 +1,47 @@ +import type { MemoryStore } from '@colony/core'; + +export type AgentRole = 'scout' | 'executor' | 'queen'; +export type ProposalStatus = 'proposed' | 'approved' | 'archived'; + +export const SCOUT_NO_CLAIM = 'SCOUT_NO_CLAIM'; + +export class ClaimsHandlerError extends Error { + readonly code: typeof SCOUT_NO_CLAIM; + + constructor(code: typeof SCOUT_NO_CLAIM, message: string) { + super(message); + this.name = 'ClaimsHandlerError'; + this.code = code; + } +} + +export interface ClaimActorContext { + agent?: string | null; + session_id?: string | null; +} + +export interface ProposalReadyRow { + proposal_status?: ProposalStatus | null; +} + +export function actorRole(store: MemoryStore, ctx: ClaimActorContext): AgentRole { + const agent = ctx.agent?.trim() || ctx.session_id?.trim(); + if (!agent) return 'executor'; + return store.storage.getAgentProfile(agent)?.role ?? 'executor'; +} + +export function enforceScoutNoClaim(store: MemoryStore, ctx: ClaimActorContext): void { + if (actorRole(store, ctx) !== 'scout') return; + throw new ClaimsHandlerError(SCOUT_NO_CLAIM, 'scouts cannot claim files; propose instead'); +} + +export function filterReadyForExecutor( + rows: readonly T[], + role: AgentRole, +): T[] { + if (role === 'scout') return []; + if (role === 'executor') { + return rows.filter((row) => row.proposal_status == null || row.proposal_status === 'approved'); + } + return [...rows]; +} diff --git a/apps/mcp-server/test/handlers/claims.test.ts b/apps/mcp-server/test/handlers/claims.test.ts new file mode 100644 index 0000000..27caafb --- /dev/null +++ b/apps/mcp-server/test/handlers/claims.test.ts @@ -0,0 +1,75 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { defaultSettings } from '@colony/config'; +import { MemoryStore } from '@colony/core'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + SCOUT_NO_CLAIM, + enforceScoutNoClaim, + filterReadyForExecutor, +} from '../../src/handlers/claims.js'; + +let dataDir: string; +let store: MemoryStore; + +beforeEach(() => { + dataDir = mkdtempSync(join(tmpdir(), 'colony-claims-handler-')); + store = new MemoryStore({ dbPath: join(dataDir, 'data.db'), settings: defaultSettings }); +}); + +afterEach(() => { + store.close(); + rmSync(dataDir, { recursive: true, force: true }); +}); + +describe('enforceScoutNoClaim', () => { + it('rejects scout actors before they claim files', () => { + store.storage.upsertAgentProfile({ + agent: 'scout-a', + capabilities: '{}', + role: 'scout', + updated_at: 1, + }); + + expect(() => enforceScoutNoClaim(store, { agent: 'scout-a' })).toThrow( + 'scouts cannot claim files; propose instead', + ); + expect(() => enforceScoutNoClaim(store, { agent: 'scout-a' })).toThrowError( + expect.objectContaining({ code: SCOUT_NO_CLAIM }), + ); + }); + + it('allows executors and unknown actors to claim files', () => { + store.storage.upsertAgentProfile({ + agent: 'exec-b', + capabilities: '{}', + role: 'executor', + updated_at: 1, + }); + + expect(() => enforceScoutNoClaim(store, { agent: 'exec-b' })).not.toThrow(); + expect(() => enforceScoutNoClaim(store, { agent: 'new-agent' })).not.toThrow(); + }); +}); + +describe('filterReadyForExecutor', () => { + const rows = [ + { id: 1, proposal_status: null }, + { id: 2, proposal_status: 'proposed' as const }, + { id: 3, proposal_status: 'approved' as const }, + { id: 4, proposal_status: 'archived' as const }, + ]; + + it('hides proposal work from scout actors', () => { + expect(filterReadyForExecutor(rows, 'scout')).toEqual([]); + }); + + it('shows only normal and approved proposal work to executors', () => { + expect(filterReadyForExecutor(rows, 'executor').map((row) => row.id)).toEqual([1, 3]); + }); + + it('leaves all work visible for queen actors', () => { + expect(filterReadyForExecutor(rows, 'queen')).toEqual(rows); + }); +});