259 lines
11 KiB
TypeScript
259 lines
11 KiB
TypeScript
import { afterEach, describe, expect, it } from 'vitest';
|
|
import Fastify from 'fastify';
|
|
import fs from 'node:fs';
|
|
import os from 'node:os';
|
|
import path from 'node:path';
|
|
import { AgentRunRegistry } from '../../src/local/agent-run-registry.js';
|
|
import { agentRunsRoutes } from '../../src/local/routes/agent-runs.js';
|
|
|
|
const tempDirs: string[] = [];
|
|
|
|
function createRegistry(): { registry: AgentRunRegistry; file: string } {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-agent-runs-'));
|
|
tempDirs.push(dir);
|
|
const file = path.join(dir, 'agent-runs.json');
|
|
return { registry: new AgentRunRegistry(file), file };
|
|
}
|
|
|
|
function createTwoWorkerRoom(registry: AgentRunRegistry) {
|
|
const room = registry.createRoom({
|
|
workspaceIds: ['alpha', 'beta'],
|
|
source: 'fleet',
|
|
title: 'Research room',
|
|
task: 'Research both workspaces',
|
|
capabilities: { cancel: true },
|
|
});
|
|
const alpha = registry.createWorker({
|
|
parentRunId: room.id,
|
|
workspaceId: 'alpha',
|
|
source: 'fleet',
|
|
executor: { kind: 'waggle_agent', personaId: 'researcher' },
|
|
title: 'Alpha researcher',
|
|
task: room.task,
|
|
capabilities: { cancel: true, pause: true, resume: true },
|
|
});
|
|
const beta = registry.createWorker({
|
|
parentRunId: room.id,
|
|
workspaceId: 'beta',
|
|
source: 'fleet',
|
|
executor: { kind: 'waggle_agent', personaId: 'writer' },
|
|
title: 'Beta writer',
|
|
task: room.task,
|
|
capabilities: { cancel: true },
|
|
});
|
|
return { room, alpha, beta };
|
|
}
|
|
|
|
afterEach(() => {
|
|
for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true });
|
|
});
|
|
|
|
describe('AgentRunRegistry', () => {
|
|
it('persists completed Rooms and complete worker snapshots atomically', () => {
|
|
const { registry, file } = createRegistry();
|
|
const { room, alpha, beta } = createTwoWorkerRoom(registry);
|
|
registry.update(alpha.id, { status: 'running' });
|
|
registry.update(beta.id, { status: 'running' });
|
|
registry.update(alpha.id, { status: 'completed', result: { summary: 'Alpha done' } });
|
|
registry.update(beta.id, { status: 'completed', result: { summary: 'Beta done' } });
|
|
|
|
const restored = new AgentRunRegistry(file);
|
|
expect(restored.get(alpha.id)?.status).toBe('completed');
|
|
expect(restored.get(beta.id)?.result?.summary).toBe('Beta done');
|
|
expect(restored.get(room.id)?.status).toBe('completed');
|
|
expect(restored.snapshot().runs).toHaveLength(3);
|
|
});
|
|
|
|
it('enforces one workspace per worker and unique run ids', () => {
|
|
const { registry } = createRegistry();
|
|
const { room, alpha, beta } = createTwoWorkerRoom(registry);
|
|
expect(new Set([room.id, alpha.id, beta.id]).size).toBe(3);
|
|
expect(alpha.kind).toBe('worker');
|
|
expect(alpha.workspaceId).toBe('alpha');
|
|
expect(() => registry.createWorker({
|
|
parentRunId: room.id,
|
|
workspaceId: 'outside-room',
|
|
source: 'fleet',
|
|
executor: { kind: 'waggle_agent' },
|
|
title: 'Invalid',
|
|
task: 'Invalid',
|
|
})).toThrow(/not part of Room/);
|
|
});
|
|
|
|
it('rejects illegal terminal transitions and derives a partial Room result', () => {
|
|
const { registry } = createRegistry();
|
|
const { room, alpha, beta } = createTwoWorkerRoom(registry);
|
|
registry.update(alpha.id, { status: 'running' });
|
|
registry.update(beta.id, { status: 'running' });
|
|
registry.update(alpha.id, { status: 'completed' });
|
|
registry.update(beta.id, { status: 'failed', result: { error: 'provider failed' } });
|
|
|
|
expect(registry.get(room.id)?.status).toBe('completed');
|
|
expect(registry.get(room.id)?.result?.summary).toContain('1/2 participants completed');
|
|
expect(() => registry.update(alpha.id, { status: 'running' })).toThrow(/Illegal run transition/);
|
|
});
|
|
|
|
it('replays monotonic full-snapshot events with workspace filtering', () => {
|
|
const { registry } = createRegistry();
|
|
const { alpha } = createTwoWorkerRoom(registry);
|
|
const before = registry.snapshot().lastSeq;
|
|
registry.update(alpha.id, { status: 'running', progress: { message: 'Reading files' } });
|
|
registry.update(alpha.id, { progress: { message: 'Writing result' } });
|
|
|
|
const replay = registry.eventsSince(before, { workspaceId: 'alpha' });
|
|
expect(replay.resetRequired).toBe(false);
|
|
expect(replay.events.map((event) => event.seq)).toEqual(
|
|
[...replay.events.map((event) => event.seq)].sort((a, b) => a - b),
|
|
);
|
|
expect(replay.events.at(-1)?.run.progress?.message).toBe('Writing result');
|
|
expect(replay.events.every((event) =>
|
|
event.run.kind === 'room' || event.run.workspaceId === 'alpha',
|
|
)).toBe(true);
|
|
});
|
|
|
|
it('cancels only the selected same-Room worker', async () => {
|
|
const { registry } = createRegistry();
|
|
const room = registry.createRoom({
|
|
workspaceIds: ['alpha'], source: 'fleet', title: 'Pair', task: 'Run twice',
|
|
});
|
|
const first = registry.createWorker({
|
|
parentRunId: room.id, workspaceId: 'alpha', source: 'fleet',
|
|
executor: { kind: 'waggle_agent' }, title: 'First', task: 'One',
|
|
status: 'running', capabilities: { cancel: true },
|
|
});
|
|
const second = registry.createWorker({
|
|
parentRunId: room.id, workspaceId: 'alpha', source: 'fleet',
|
|
executor: { kind: 'waggle_agent' }, title: 'Second', task: 'Two',
|
|
status: 'running', capabilities: { cancel: true },
|
|
});
|
|
let cancelled = '';
|
|
registry.registerControls(first.id, { cancel: ({ run }) => { cancelled = run.id; } });
|
|
registry.registerControls(second.id, { cancel: () => { throw new Error('wrong worker'); } });
|
|
|
|
await registry.control(first.id, 'cancel');
|
|
expect(cancelled).toBe(first.id);
|
|
expect(registry.get(first.id)?.status).toBe('cancelled');
|
|
expect(registry.get(second.id)?.status).toBe('running');
|
|
});
|
|
|
|
it('uses one truthful Room-level controller for a shared workflow', async () => {
|
|
const { registry } = createRegistry();
|
|
const room = registry.createRoom({
|
|
workspaceIds: ['alpha'], source: 'agent_group', title: 'Shared workflow', task: 'Work together',
|
|
capabilities: { cancel: true },
|
|
});
|
|
const first = registry.createWorker({
|
|
parentRunId: room.id, workspaceId: 'alpha', source: 'agent_group',
|
|
executor: { kind: 'waggle_agent' }, title: 'First', task: room.task,
|
|
capabilities: { cancel: false },
|
|
});
|
|
const second = registry.createWorker({
|
|
parentRunId: room.id, workspaceId: 'alpha', source: 'agent_group',
|
|
executor: { kind: 'waggle_agent' }, title: 'Second', task: room.task,
|
|
capabilities: { cancel: false },
|
|
});
|
|
let calls = 0;
|
|
registry.registerControls(room.id, {
|
|
cancel: () => {
|
|
calls++;
|
|
registry.update(first.id, { status: 'cancelled' });
|
|
registry.update(second.id, { status: 'cancelled' });
|
|
},
|
|
});
|
|
|
|
const cancelled = await registry.control(room.id, 'cancel');
|
|
expect(calls).toBe(1);
|
|
expect(cancelled.status).toBe('cancelled');
|
|
expect(registry.get(first.id)?.status).toBe('cancelled');
|
|
expect(registry.get(second.id)?.status).toBe('cancelled');
|
|
await expect(registry.control(first.id, 'cancel')).rejects.toThrow(/already cancelled/);
|
|
});
|
|
|
|
it('issues ephemeral worker credentials and revokes them at terminal state', () => {
|
|
const { registry, file } = createRegistry();
|
|
const room = registry.createRoom({
|
|
workspaceIds: ['alpha'], source: 'external_tool', title: 'External', task: 'Collaborate',
|
|
});
|
|
const worker = registry.createWorker({
|
|
parentRunId: room.id, workspaceId: 'alpha', source: 'external_tool',
|
|
executor: { kind: 'external_tool', toolId: 'codex' }, title: 'Codex', task: room.task,
|
|
status: 'running',
|
|
});
|
|
|
|
const token = registry.issueCredential(worker.id);
|
|
expect(token.length).toBeGreaterThanOrEqual(32);
|
|
expect(registry.authenticateCredential(token)?.id).toBe(worker.id);
|
|
expect(registry.authenticateCredential('x'.repeat(43))).toBeUndefined();
|
|
expect(fs.readFileSync(file, 'utf8')).not.toContain(token);
|
|
|
|
registry.update(worker.id, { status: 'completed' });
|
|
expect(registry.authenticateCredential(token)).toBeUndefined();
|
|
expect(() => registry.issueCredential(room.id)).toThrow(/Worker run not found/);
|
|
});
|
|
|
|
it('marks internal work interrupted after restart and reconciles external pids separately', () => {
|
|
const { registry, file } = createRegistry();
|
|
const internalRoom = registry.createRoom({
|
|
workspaceIds: ['alpha'], source: 'fleet', title: 'Internal', task: 'Run', status: 'running',
|
|
});
|
|
const internal = registry.createWorker({
|
|
parentRunId: internalRoom.id, workspaceId: 'alpha', source: 'fleet',
|
|
executor: { kind: 'waggle_agent' }, title: 'Internal', task: 'Run', status: 'running',
|
|
});
|
|
const externalRoom = registry.createRoom({
|
|
workspaceIds: ['alpha'], source: 'external_tool', title: 'External', task: 'Run', status: 'running',
|
|
});
|
|
const external = registry.createWorker({
|
|
parentRunId: externalRoom.id, workspaceId: 'alpha', source: 'external_tool',
|
|
executor: { kind: 'external_tool', toolId: 'codex', pid: 4242 },
|
|
title: 'Codex', task: 'Run', status: 'running',
|
|
});
|
|
|
|
const restored = new AgentRunRegistry(file);
|
|
expect(restored.get(internal.id)?.status).toBe('interrupted');
|
|
expect(restored.get(external.id)?.status).toBe('running');
|
|
expect(restored.reconcileExternalProcesses(new Set())).toBe(1);
|
|
expect(restored.get(external.id)?.status).toBe('interrupted');
|
|
});
|
|
});
|
|
|
|
describe('agent run routes', () => {
|
|
it('creates a Room, exposes snapshot/replay, and returns honest control errors', async () => {
|
|
const { registry } = createRegistry();
|
|
const server = Fastify({ logger: false });
|
|
server.decorate('agentRunRegistry', registry);
|
|
server.decorate('workspaceManager', { get: (id: string) => id === 'alpha' ? { id } : undefined } as never);
|
|
await server.register(agentRunsRoutes);
|
|
|
|
const created = await server.inject({
|
|
method: 'POST',
|
|
url: '/api/rooms',
|
|
payload: {
|
|
workspaceIds: ['alpha'],
|
|
source: 'fleet',
|
|
title: 'Test room',
|
|
task: 'Do the work',
|
|
},
|
|
});
|
|
expect(created.statusCode).toBe(201);
|
|
const roomId = (created.json() as { run: { id: string } }).run.id;
|
|
|
|
const snapshot = await server.inject({ method: 'GET', url: '/api/agent-runs/snapshot?workspaceId=alpha' });
|
|
expect(snapshot.statusCode).toBe(200);
|
|
expect((snapshot.json() as { runs: unknown[] }).runs).toHaveLength(1);
|
|
|
|
const events = await server.inject({ method: 'GET', url: '/api/agent-runs/events?since=0' });
|
|
expect(events.statusCode).toBe(200);
|
|
expect((events.json() as { events: unknown[] }).events.length).toBeGreaterThan(0);
|
|
|
|
const unsupported = await server.inject({
|
|
method: 'POST',
|
|
url: `/api/agent-runs/${roomId}/control`,
|
|
payload: { action: 'pause' },
|
|
});
|
|
expect(unsupported.statusCode).toBe(409);
|
|
expect(unsupported.json().message).toMatch(/not supported/);
|
|
await server.close();
|
|
});
|
|
});
|