Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions apps/mcp-server/src/handlers/claims.ts
Original file line number Diff line number Diff line change
@@ -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<T extends ProposalReadyRow>(
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];
}
75 changes: 75 additions & 0 deletions apps/mcp-server/test/handlers/claims.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading