439 lines
17 KiB
TypeScript
439 lines
17 KiB
TypeScript
// @vitest-environment node
|
|
/**
|
|
* A2aTaskReconciler のユニットテスト。
|
|
* TDD: fake repo + tmp workspace で動作を検証。real DB・real worker は不要。
|
|
*/
|
|
import * as os from 'os';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import { A2aTaskReconciler } from './reconciler.js';
|
|
import type { Repository } from '../../db/repository.js';
|
|
|
|
// ─── fake repo ───────────────────────────────────────────────────────────────
|
|
|
|
type A2aTaskRow = { id: string; jobId: string | null; payload: Record<string, unknown> };
|
|
type SavedA2aTask = {
|
|
id: string;
|
|
contextId: string | null;
|
|
jobId: string | null;
|
|
localTaskId: number | null;
|
|
payload: object;
|
|
delegationId?: string;
|
|
grantId?: string;
|
|
actingUserId?: string;
|
|
};
|
|
|
|
function makeJobStub(status: string, extra: Record<string, unknown> = {}) {
|
|
return {
|
|
id: 'job-1',
|
|
status,
|
|
errorSummary: null,
|
|
abortReason: null,
|
|
worktreePath: null,
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
function buildFakeRepo(opts: {
|
|
nonTerminalRows: A2aTaskRow[];
|
|
jobs: Map<string, ReturnType<typeof makeJobStub> | null>;
|
|
localTasks?: Map<number, { workspacePath: string | null }>;
|
|
}) {
|
|
const store = new Map<string, SavedA2aTask>();
|
|
|
|
// Pre-fill store from initial nonTerminalRows (so loadA2aTask works)
|
|
for (const row of opts.nonTerminalRows) {
|
|
store.set(row.id, {
|
|
id: row.id,
|
|
contextId: (row.payload.contextId as string | null) ?? null,
|
|
jobId: row.jobId,
|
|
localTaskId: null,
|
|
payload: row.payload,
|
|
});
|
|
}
|
|
|
|
const repo = {
|
|
listNonTerminalA2aTasks: vi.fn((): A2aTaskRow[] => {
|
|
// Return only tasks whose saved payload status is non-terminal
|
|
const terminal = new Set(['completed', 'failed', 'canceled', 'rejected']);
|
|
return opts.nonTerminalRows.filter(row => {
|
|
const saved = store.get(row.id);
|
|
if (!saved) return true;
|
|
const status = (saved.payload as Record<string, unknown>).status as
|
|
| Record<string, unknown>
|
|
| undefined;
|
|
return !status?.state || !terminal.has(status.state as string);
|
|
});
|
|
}),
|
|
|
|
getJob: vi.fn(async (id: string) => {
|
|
const j = opts.jobs.get(id);
|
|
return j === undefined ? null : j;
|
|
}),
|
|
|
|
getLocalTask: vi.fn(async (id: number) => {
|
|
return opts.localTasks?.get(id) ?? null;
|
|
}),
|
|
|
|
saveA2aTask: vi.fn((row: SavedA2aTask) => {
|
|
store.set(row.id, row);
|
|
// Also update the nonTerminalRows payload so subsequent listNonTerminalA2aTasks sees it
|
|
const orig = opts.nonTerminalRows.find(r => r.id === row.id);
|
|
if (orig) {
|
|
orig.payload = row.payload as Record<string, unknown>;
|
|
}
|
|
}),
|
|
|
|
// Revocation sweep: return a live delegation by default so existing tests are unaffected.
|
|
// Tests that want to exercise revocation should use a2a-revocation-e2e.test.ts instead.
|
|
getA2aDelegationByGrantId: vi.fn((grantId: string) => ({
|
|
id: 'deleg-fake',
|
|
userId: 'user-1',
|
|
clientId: 'client-fake',
|
|
grantId,
|
|
grantedSpaceIds: [] as string[],
|
|
grantedSkills: [] as string[],
|
|
audience: null as string | null,
|
|
expiresAt: null as string | null,
|
|
revokedAt: null as string | null,
|
|
})),
|
|
|
|
// Revocation sweep: cancel linked job (not exercised by existing tests)
|
|
cancelA2aLinkedJob: vi.fn((_jobId: string) => false),
|
|
|
|
// Expose store for assertions
|
|
_store: store,
|
|
} as unknown as Repository & { _store: Map<string, SavedA2aTask> };
|
|
|
|
return repo;
|
|
}
|
|
|
|
// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
function makeWorkingPayload(jobId: string, extra: Record<string, unknown> = {}) {
|
|
return {
|
|
id: 'task-1',
|
|
contextId: 'ctx-1',
|
|
kind: 'task',
|
|
status: { state: 'working', timestamp: '2024-01-01T00:00:00.000Z' },
|
|
metadata: {
|
|
maestroJobId: jobId,
|
|
maestroLocalTaskId: 42,
|
|
a2aDelegationId: null,
|
|
a2aGrantId: null,
|
|
a2aActingUserId: null,
|
|
},
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
const FIXED_NOW = '2026-07-02T10:00:00.000Z';
|
|
|
|
// ─── tests ───────────────────────────────────────────────────────────────────
|
|
|
|
describe('A2aTaskReconciler.reconcileOnce', () => {
|
|
let tmpDir: string;
|
|
|
|
beforeEach(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'reconciler-test-'));
|
|
});
|
|
|
|
afterEach(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
// ── Case 1: succeeded job + output file → completed + artifact ──────────
|
|
it('succeeded job + output/result.txt → completed with artifact', async () => {
|
|
// Arrange: create output/result.txt in a tmp workspace
|
|
const workspacePath = path.join(tmpDir, 'ws1');
|
|
fs.mkdirSync(path.join(workspacePath, 'output'), { recursive: true });
|
|
fs.writeFileSync(path.join(workspacePath, 'output', 'result.txt'), 'hello world');
|
|
|
|
const payload = makeWorkingPayload('job-1');
|
|
const row: A2aTaskRow = { id: 'task-1', jobId: 'job-1', payload };
|
|
|
|
const repo = buildFakeRepo({
|
|
nonTerminalRows: [row],
|
|
jobs: new Map([['job-1', makeJobStub('succeeded')]]),
|
|
localTasks: new Map([[42, { workspacePath }]]),
|
|
});
|
|
|
|
const reconciler = new A2aTaskReconciler({
|
|
repo,
|
|
now: () => FIXED_NOW,
|
|
});
|
|
|
|
// Act
|
|
const result = await reconciler.reconcileOnce();
|
|
|
|
// Assert counts
|
|
expect(result.scanned).toBe(1);
|
|
expect(result.finalized).toBe(1);
|
|
|
|
// Assert saved payload
|
|
expect(repo.saveA2aTask).toHaveBeenCalledOnce();
|
|
const saved = (repo as unknown as { _store: Map<string, SavedA2aTask> })._store.get('task-1')!;
|
|
const savedPayload = saved.payload as Record<string, unknown>;
|
|
|
|
// Status → completed
|
|
const status = savedPayload.status as Record<string, unknown>;
|
|
expect(status.state).toBe('completed');
|
|
expect(status.timestamp).toBe(FIXED_NOW);
|
|
|
|
// Artifacts → contains result.txt
|
|
const artifacts = savedPayload.artifacts as Array<Record<string, unknown>>;
|
|
expect(artifacts).toHaveLength(1);
|
|
expect(artifacts[0].name).toBe('result.txt');
|
|
const parts = artifacts[0].parts as Array<Record<string, unknown>>;
|
|
expect(parts[0].text).toBe('hello world');
|
|
});
|
|
|
|
// ── Case 2: getJob returns null → failed ───────────────────────────────
|
|
it('getJob returns null → task finalized as failed with "job not found"', async () => {
|
|
const payload = makeWorkingPayload('job-missing');
|
|
const row: A2aTaskRow = { id: 'task-2', jobId: 'job-missing', payload };
|
|
|
|
const repo = buildFakeRepo({
|
|
nonTerminalRows: [row],
|
|
jobs: new Map([['job-missing', null]]),
|
|
});
|
|
|
|
const reconciler = new A2aTaskReconciler({ repo, now: () => FIXED_NOW });
|
|
const result = await reconciler.reconcileOnce();
|
|
|
|
expect(result.finalized).toBe(1);
|
|
const saved = (repo as unknown as { _store: Map<string, SavedA2aTask> })._store.get('task-2')!;
|
|
const status = (saved.payload as Record<string, unknown>).status as Record<string, unknown>;
|
|
expect(status.state).toBe('failed');
|
|
// message should mention 'job not found'
|
|
const msg = status.message as Record<string, unknown>;
|
|
expect((msg.parts as Array<Record<string, unknown>>)[0].text).toContain('job not found');
|
|
});
|
|
|
|
// ── Case 3: running job → untouched ────────────────────────────────────
|
|
it('running job → task unchanged, finalized=0', async () => {
|
|
const payload = makeWorkingPayload('job-running');
|
|
const row: A2aTaskRow = { id: 'task-3', jobId: 'job-running', payload };
|
|
|
|
const repo = buildFakeRepo({
|
|
nonTerminalRows: [row],
|
|
jobs: new Map([['job-running', makeJobStub('running')]]),
|
|
});
|
|
|
|
const reconciler = new A2aTaskReconciler({ repo, now: () => FIXED_NOW });
|
|
const result = await reconciler.reconcileOnce();
|
|
|
|
expect(result.finalized).toBe(0);
|
|
expect(repo.saveA2aTask).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// ── Case 4: null jobId → skipped ───────────────────────────────────────
|
|
it('null jobId (just enqueued) → skipped', async () => {
|
|
const payload = makeWorkingPayload('irrelevant');
|
|
const row: A2aTaskRow = { id: 'task-4', jobId: null, payload };
|
|
|
|
const repo = buildFakeRepo({
|
|
nonTerminalRows: [row],
|
|
jobs: new Map(),
|
|
});
|
|
|
|
const reconciler = new A2aTaskReconciler({ repo, now: () => FIXED_NOW });
|
|
const result = await reconciler.reconcileOnce();
|
|
|
|
expect(result.scanned).toBe(1);
|
|
expect(result.finalized).toBe(0);
|
|
expect(repo.getJob).not.toHaveBeenCalled();
|
|
});
|
|
|
|
// ── Case 5: idempotent — completed task not revisited ──────────────────
|
|
it('reconcileOnce twice → completed task not finalized again (idempotent)', async () => {
|
|
const workspacePath = path.join(tmpDir, 'ws5');
|
|
fs.mkdirSync(path.join(workspacePath, 'output'), { recursive: true });
|
|
fs.writeFileSync(path.join(workspacePath, 'output', 'out.txt'), 'done');
|
|
|
|
const payload = makeWorkingPayload('job-5');
|
|
const row: A2aTaskRow = { id: 'task-5', jobId: 'job-5', payload };
|
|
|
|
const repo = buildFakeRepo({
|
|
nonTerminalRows: [row],
|
|
jobs: new Map([['job-5', makeJobStub('succeeded')]]),
|
|
localTasks: new Map([[42, { workspacePath }]]),
|
|
});
|
|
|
|
const reconciler = new A2aTaskReconciler({ repo, now: () => FIXED_NOW });
|
|
|
|
// First tick — should finalize
|
|
const r1 = await reconciler.reconcileOnce();
|
|
expect(r1.finalized).toBe(1);
|
|
|
|
// After first tick, the task's payload.status.state is 'completed'
|
|
// listNonTerminalA2aTasks mock will exclude it
|
|
const r2 = await reconciler.reconcileOnce();
|
|
expect(r2.scanned).toBe(0); // task no longer in non-terminal list
|
|
expect(r2.finalized).toBe(0);
|
|
|
|
// saveA2aTask called exactly once (first tick only)
|
|
expect(repo.saveA2aTask).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
// ── Case 6: delegation columns preserved ───────────────────────────────
|
|
it('delegation columns from payload.metadata are passed through to saveA2aTask', async () => {
|
|
const payload = makeWorkingPayload('job-deleg', {
|
|
metadata: {
|
|
maestroJobId: 'job-deleg',
|
|
maestroLocalTaskId: null,
|
|
a2aDelegationId: 'deleg-id-123',
|
|
a2aGrantId: 'grant-id-456',
|
|
a2aActingUserId: 'user-789',
|
|
},
|
|
});
|
|
const row: A2aTaskRow = { id: 'task-6', jobId: 'job-deleg', payload };
|
|
|
|
const repo = buildFakeRepo({
|
|
nonTerminalRows: [row],
|
|
jobs: new Map([['job-deleg', makeJobStub('failed', { errorSummary: 'oops' })]]),
|
|
});
|
|
|
|
const reconciler = new A2aTaskReconciler({ repo, now: () => FIXED_NOW });
|
|
await reconciler.reconcileOnce();
|
|
|
|
expect(repo.saveA2aTask).toHaveBeenCalledOnce();
|
|
const call = (repo.saveA2aTask as ReturnType<typeof vi.fn>).mock.calls[0][0] as SavedA2aTask;
|
|
expect(call.delegationId).toBe('deleg-id-123');
|
|
expect(call.grantId).toBe('grant-id-456');
|
|
expect(call.actingUserId).toBe('user-789');
|
|
|
|
// Status should be failed with errorSummary
|
|
const savedPayload = call.payload as Record<string, unknown>;
|
|
const status = savedPayload.status as Record<string, unknown>;
|
|
expect(status.state).toBe('failed');
|
|
const msg = status.message as Record<string, unknown>;
|
|
expect((msg.parts as Array<Record<string, unknown>>)[0].text).toBe('oops');
|
|
});
|
|
|
|
// ── Case 7: failed job → failed A2A task ───────────────────────────────
|
|
it('failed job → task finalized as failed', async () => {
|
|
const payload = makeWorkingPayload('job-fail');
|
|
const row: A2aTaskRow = { id: 'task-7', jobId: 'job-fail', payload };
|
|
|
|
const repo = buildFakeRepo({
|
|
nonTerminalRows: [row],
|
|
jobs: new Map([['job-fail', makeJobStub('failed', { errorSummary: 'something broke' })]]),
|
|
});
|
|
|
|
const reconciler = new A2aTaskReconciler({ repo, now: () => FIXED_NOW });
|
|
const result = await reconciler.reconcileOnce();
|
|
|
|
expect(result.finalized).toBe(1);
|
|
const saved = (repo as unknown as { _store: Map<string, SavedA2aTask> })._store.get('task-7')!;
|
|
const status = (saved.payload as Record<string, unknown>).status as Record<string, unknown>;
|
|
expect(status.state).toBe('failed');
|
|
const msg = status.message as Record<string, unknown>;
|
|
expect((msg.parts as Array<Record<string, unknown>>)[0].text).toBe('something broke');
|
|
});
|
|
|
|
// ── Case 8: per-row error doesn't abort full scan ──────────────────────
|
|
it('one bad row (throws) does not abort the rest', async () => {
|
|
const goodPayload = makeWorkingPayload('job-good');
|
|
const badPayload = makeWorkingPayload('job-bad');
|
|
|
|
const rows: A2aTaskRow[] = [
|
|
{ id: 'bad-task', jobId: 'job-bad', payload: badPayload },
|
|
{ id: 'good-task', jobId: 'job-good', payload: goodPayload },
|
|
];
|
|
|
|
const repo = buildFakeRepo({
|
|
nonTerminalRows: rows,
|
|
jobs: new Map([
|
|
['job-bad', makeJobStub('succeeded')], // will throw in getLocalTask
|
|
['job-good', makeJobStub('failed', { errorSummary: 'err' })],
|
|
]),
|
|
localTasks: new Map(), // no workspace → enumerateOutputArtifacts returns []
|
|
});
|
|
|
|
// Override getLocalTask to throw for bad-task
|
|
let callCount = 0;
|
|
(repo.getLocalTask as ReturnType<typeof vi.fn>).mockImplementation(async (_id: number) => {
|
|
callCount++;
|
|
if (callCount === 1) throw new Error('simulated DB error');
|
|
return null;
|
|
});
|
|
|
|
const reconciler = new A2aTaskReconciler({ repo, now: () => FIXED_NOW });
|
|
// Should not throw
|
|
const result = await reconciler.reconcileOnce();
|
|
|
|
// good-task should still be finalized
|
|
expect(result.scanned).toBe(2);
|
|
// bad-task errored, good-task succeeded
|
|
expect(result.finalized).toBe(1);
|
|
const goodSaved = (repo as unknown as { _store: Map<string, SavedA2aTask> })._store.get('good-task')!;
|
|
const status = (goodSaved.payload as Record<string, unknown>).status as Record<string, unknown>;
|
|
expect(status.state).toBe('failed');
|
|
});
|
|
// ── Case 9: waiting_human → input-required churn prevention ────────────────
|
|
it('waiting_human job: second tick does NOT re-write input-required (state-equality skip)', async () => {
|
|
const payload = makeWorkingPayload('job-waiting');
|
|
const rows: A2aTaskRow[] = [{ id: 'task-wh', jobId: 'job-waiting', payload }];
|
|
|
|
const repo = buildFakeRepo({
|
|
nonTerminalRows: rows,
|
|
jobs: new Map([['job-waiting', makeJobStub('waiting_human')]]),
|
|
localTasks: new Map(),
|
|
});
|
|
|
|
const reconciler = new A2aTaskReconciler({ repo, now: () => FIXED_NOW });
|
|
|
|
// First tick: working → input-required
|
|
const r1 = await reconciler.reconcileOnce();
|
|
expect(r1.finalized).toBe(1);
|
|
const saved1 = (repo as unknown as { _store: Map<string, SavedA2aTask> })._store.get('task-wh')!;
|
|
expect((saved1.payload as Record<string, unknown>).status).toMatchObject({ state: 'input-required' });
|
|
|
|
// Second tick: job still waiting_human, but A2A state is already input-required → skip
|
|
const r2 = await reconciler.reconcileOnce();
|
|
expect(r2.finalized).toBe(0);
|
|
|
|
// saveA2aTask was called exactly once across both ticks
|
|
expect((repo.saveA2aTask as ReturnType<typeof vi.fn>).mock.calls.length).toBe(1);
|
|
});
|
|
});
|
|
|
|
// ─── start / stop mechanics ─────────────────────────────────────────────────
|
|
|
|
describe('A2aTaskReconciler start/stop', () => {
|
|
it('start() calls reconcileOnce immediately and then on interval', async () => {
|
|
const repo = buildFakeRepo({ nonTerminalRows: [], jobs: new Map() });
|
|
const reconciler = new A2aTaskReconciler({ repo, intervalMs: 50, now: () => FIXED_NOW });
|
|
|
|
const spy = vi.spyOn(reconciler, 'reconcileOnce');
|
|
reconciler.start();
|
|
|
|
// Immediate call should have been triggered (async, small wait)
|
|
await new Promise(r => setTimeout(r, 10));
|
|
expect(spy).toHaveBeenCalledTimes(1);
|
|
|
|
// Wait for at least one interval tick
|
|
await new Promise(r => setTimeout(r, 80));
|
|
expect(spy.mock.calls.length).toBeGreaterThanOrEqual(2);
|
|
|
|
reconciler.stop();
|
|
});
|
|
|
|
it('stop() prevents further reconcileOnce calls', async () => {
|
|
const repo = buildFakeRepo({ nonTerminalRows: [], jobs: new Map() });
|
|
const reconciler = new A2aTaskReconciler({ repo, intervalMs: 50, now: () => FIXED_NOW });
|
|
|
|
const spy = vi.spyOn(reconciler, 'reconcileOnce');
|
|
reconciler.start();
|
|
await new Promise(r => setTimeout(r, 10));
|
|
reconciler.stop();
|
|
const callsAfterStop = spy.mock.calls.length;
|
|
|
|
// Wait past one interval to confirm no more calls
|
|
await new Promise(r => setTimeout(r, 80));
|
|
expect(spy.mock.calls.length).toBe(callsAfterStop);
|
|
});
|
|
});
|