maestro/src/bridge/a2a/reconciler.test.ts
oss-sync b1292e34b2
Some checks failed
CI / build-and-test (push) Has been cancelled
sync: update from private repo (edc775f2)
2026-07-06 01:04:12 +00:00

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);
});
});