// @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 }; 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 = {}) { return { id: 'job-1', status, errorSummary: null, abortReason: null, worktreePath: null, ...extra, }; } function buildFakeRepo(opts: { nonTerminalRows: A2aTaskRow[]; jobs: Map | null>; localTasks?: Map; }) { const store = new Map(); // 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).status as | Record | 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; } }), // 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 }; return repo; } // ─── helpers ───────────────────────────────────────────────────────────────── function makeWorkingPayload(jobId: string, extra: Record = {}) { 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 })._store.get('task-1')!; const savedPayload = saved.payload as Record; // Status → completed const status = savedPayload.status as Record; expect(status.state).toBe('completed'); expect(status.timestamp).toBe(FIXED_NOW); // Artifacts → contains result.txt const artifacts = savedPayload.artifacts as Array>; expect(artifacts).toHaveLength(1); expect(artifacts[0].name).toBe('result.txt'); const parts = artifacts[0].parts as Array>; 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 })._store.get('task-2')!; const status = (saved.payload as Record).status as Record; expect(status.state).toBe('failed'); // message should mention 'job not found' const msg = status.message as Record; expect((msg.parts as Array>)[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).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; const status = savedPayload.status as Record; expect(status.state).toBe('failed'); const msg = status.message as Record; expect((msg.parts as Array>)[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 })._store.get('task-7')!; const status = (saved.payload as Record).status as Record; expect(status.state).toBe('failed'); const msg = status.message as Record; expect((msg.parts as Array>)[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).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 })._store.get('good-task')!; const status = (goodSaved.payload as Record).status as Record; 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 })._store.get('task-wh')!; expect((saved1.payload as Record).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).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); }); });