441 lines
16 KiB
TypeScript
441 lines
16 KiB
TypeScript
/**
|
|
* Task 5 — executor in-flight revocation recheck (+ 2C-1 metadata fix).
|
|
*
|
|
* Uses MaestroA2aExecutor directly with injectable sleep/now/pollMs deps,
|
|
* a minimal fake Repository, and a fake ExecutionEventBus — no HTTP server needed.
|
|
*
|
|
* Tests:
|
|
* A1. Delegation revoked after first poll → executor publishes terminal `canceled`,
|
|
* calls eventBus.finished(), calls cancelA2aLinkedJob(jobId).
|
|
* A2. Happy path: live delegation → job completes normally (no premature cancel).
|
|
* B. Initial submitted Task metadata: a2aDelegationId = delegation.id,
|
|
* a2aGrantId = grantId (different values, different semantics).
|
|
*/
|
|
|
|
import { describe, it, expect } from 'vitest';
|
|
import { MaestroA2aExecutor } from './executor.js';
|
|
import { A2aTaskReconciler } from './reconciler.js';
|
|
import type { ExecutionEventBus } from '@a2a-js/sdk/server';
|
|
import type { TaskStatusUpdateEvent } from '@a2a-js/sdk';
|
|
|
|
// ── Fake helpers ──────────────────────────────────────────────────────────────
|
|
|
|
function makeFakeRepo(opts: {
|
|
delegationId: string;
|
|
grantId: string;
|
|
/** number of getJob calls after which delegation switches to revoked (0 = revoked from the start of poll 1) */
|
|
revokeAfterPollN?: number;
|
|
/** poll number after which job status becomes 'succeeded' */
|
|
succeedAfterPollN?: number;
|
|
}) {
|
|
const {
|
|
delegationId,
|
|
grantId,
|
|
revokeAfterPollN = Infinity,
|
|
succeedAfterPollN = Infinity,
|
|
} = opts;
|
|
|
|
let pollCount = 0;
|
|
const cancelCalls: string[] = [];
|
|
|
|
const baseDelegRow = {
|
|
id: delegationId,
|
|
userId: 'user-1',
|
|
clientId: 'client-1',
|
|
grantId,
|
|
grantedSpaceIds: ['space-1'],
|
|
grantedSkills: ['research'],
|
|
audience: null as string | null,
|
|
expiresAt: null as string | null,
|
|
revokedAt: null as string | null,
|
|
};
|
|
|
|
const repo = {
|
|
// computeEffectiveScope → resolveOrgIds needs these
|
|
getUserById: (_id: string) => ({
|
|
id: 'user-1', status: 'active', role: 'user' as const, name: 'Test User',
|
|
}),
|
|
listUserGiteaOrgs: (_userId: string) => [] as Array<{ orgId: string }>,
|
|
listUserLocalOrgs: (_userId: string) => [] as Array<{ orgId: string }>,
|
|
listSpaces: async (_opts: unknown) => [{ id: 'space-1' }],
|
|
getSpaceA2aSkills: (_spaceId: string) => ['research'],
|
|
|
|
// Job creation
|
|
createLocalTask: async (_opts: unknown) => ({ id: 1, workspacePath: null }),
|
|
createJob: async (_opts: unknown) => undefined,
|
|
getLatestJobForIssue: async (_repoName: string, _issueNum: number) => ({
|
|
id: 'job-test-1',
|
|
status: 'queued',
|
|
currentActivity: null,
|
|
errorSummary: null,
|
|
abortReason: null,
|
|
worktreePath: null,
|
|
}),
|
|
addAuditLog: async (..._args: unknown[]) => undefined,
|
|
|
|
// Poll loop: getJob
|
|
getJob: async (_id: string) => {
|
|
pollCount++;
|
|
const status = pollCount > succeedAfterPollN ? 'succeeded' : 'running';
|
|
return {
|
|
id: 'job-test-1',
|
|
status,
|
|
currentActivity: null,
|
|
errorSummary: null,
|
|
abortReason: null,
|
|
worktreePath: null,
|
|
};
|
|
},
|
|
|
|
// Poll loop: revocation recheck (called right after getJob in the same iteration)
|
|
getA2aDelegationByGrantId: (_grantId: string) => {
|
|
// pollCount was just incremented by getJob in the same iteration.
|
|
// After revokeAfterPollN polls, delegation is revoked.
|
|
if (pollCount > revokeAfterPollN) {
|
|
return { ...baseDelegRow, revokedAt: '2026-07-02T00:00:00.000Z' };
|
|
}
|
|
return { ...baseDelegRow };
|
|
},
|
|
|
|
// Job cancel — executor uses cancelA2aLinkedJob (covers queued/retry/running/etc.)
|
|
cancelA2aLinkedJob: (jobId: string) => {
|
|
cancelCalls.push(jobId);
|
|
return true;
|
|
},
|
|
|
|
// handleTerminal (succeeded path)
|
|
getLocalTask: async (_id: number) => ({ id: 1, workspacePath: null }),
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} as any;
|
|
|
|
return {
|
|
repo,
|
|
cancelCalls,
|
|
getPollCount: () => pollCount,
|
|
};
|
|
}
|
|
|
|
/** Fake eventBus that collects all published events. Uses object reference to avoid stale closure. */
|
|
function makeFakeEventBus() {
|
|
const events: unknown[] = [];
|
|
const tracker = { finishedCount: 0 };
|
|
|
|
const eventBus: ExecutionEventBus = {
|
|
publish: (event: unknown) => { events.push(event); },
|
|
finished: () => { tracker.finishedCount++; },
|
|
};
|
|
|
|
const statusEvents = () =>
|
|
events.filter(
|
|
(e): e is TaskStatusUpdateEvent =>
|
|
typeof e === 'object' &&
|
|
e !== null &&
|
|
(e as Record<string, unknown>).kind === 'status-update',
|
|
);
|
|
|
|
return { eventBus, events, tracker, statusEvents };
|
|
}
|
|
|
|
function makeFakeRequestContext(opts: {
|
|
grantId: string;
|
|
delegationId: string;
|
|
taskId?: string;
|
|
contextId?: string;
|
|
}) {
|
|
const { grantId, delegationId, taskId = 'a2a-task-1', contextId = 'ctx-1' } = opts;
|
|
|
|
const principal = {
|
|
actingUserId: 'user-1',
|
|
clientId: 'client-1',
|
|
grantId,
|
|
delegation: {
|
|
id: delegationId,
|
|
userId: 'user-1',
|
|
clientId: 'client-1',
|
|
grantId,
|
|
grantedSpaceIds: ['space-1'],
|
|
grantedSkills: ['research'],
|
|
expiresAt: null,
|
|
revokedAt: null,
|
|
},
|
|
};
|
|
|
|
return {
|
|
taskId,
|
|
contextId,
|
|
context: { user: { a2aPrincipal: principal } },
|
|
userMessage: {
|
|
parts: [{ kind: 'text', text: 'do research' }],
|
|
metadata: { skillId: 'research' },
|
|
},
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} as any;
|
|
}
|
|
|
|
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
|
|
describe('A2A executor revocation recheck (Task 5)', () => {
|
|
// Fixed clock for determinism
|
|
const nowFn = () => '2026-07-02T00:00:00.000Z';
|
|
|
|
it('A1: revoked delegation after first poll → executor cancels and publishes terminal canceled', async () => {
|
|
const GRANT_ID = 'grant-revoke-test';
|
|
const DELEG_ID = 'deleg-pk-001';
|
|
|
|
const { repo, cancelCalls } = makeFakeRepo({
|
|
delegationId: DELEG_ID,
|
|
grantId: GRANT_ID,
|
|
// delegation is live for poll #1 (pollCount=1 <= 1),
|
|
// revoked starting poll #2 (pollCount=2 > 1)
|
|
revokeAfterPollN: 1,
|
|
});
|
|
const { eventBus, tracker, statusEvents } = makeFakeEventBus();
|
|
const requestContext = makeFakeRequestContext({ grantId: GRANT_ID, delegationId: DELEG_ID });
|
|
|
|
const executor = new MaestroA2aExecutor(repo, {
|
|
pollMs: 0,
|
|
sleep: async () => {},
|
|
now: nowFn,
|
|
maxPollIterations: 10,
|
|
});
|
|
|
|
await executor.execute(requestContext, eventBus);
|
|
|
|
// Must have called cancelA2aLinkedJob (covers all non-terminal statuses including retry)
|
|
expect(cancelCalls).toContain('job-test-1');
|
|
|
|
// Must have published a terminal 'canceled' status update
|
|
const allStatusEvents = statusEvents();
|
|
const terminalEvents = allStatusEvents.filter(e => e.final === true);
|
|
expect(terminalEvents.length).toBeGreaterThanOrEqual(1);
|
|
const canceledEvent = terminalEvents.find(e => (e.status as Record<string, unknown>)?.state === 'canceled');
|
|
expect(canceledEvent).toBeDefined();
|
|
expect((canceledEvent!.status as Record<string, unknown>).state).toBe('canceled');
|
|
|
|
// Must have called eventBus.finished() exactly once
|
|
expect(tracker.finishedCount).toBe(1);
|
|
});
|
|
|
|
it('A2: live delegation throughout → job completes normally without premature cancel', async () => {
|
|
const GRANT_ID = 'grant-live-test';
|
|
const DELEG_ID = 'deleg-pk-002';
|
|
|
|
const { repo, cancelCalls } = makeFakeRepo({
|
|
delegationId: DELEG_ID,
|
|
grantId: GRANT_ID,
|
|
revokeAfterPollN: Infinity, // delegation stays live forever
|
|
succeedAfterPollN: 2, // job succeeds after 2 polls
|
|
});
|
|
const { eventBus, tracker, statusEvents } = makeFakeEventBus();
|
|
const requestContext = makeFakeRequestContext({ grantId: GRANT_ID, delegationId: DELEG_ID });
|
|
|
|
const executor = new MaestroA2aExecutor(repo, {
|
|
pollMs: 0,
|
|
sleep: async () => {},
|
|
now: nowFn,
|
|
maxPollIterations: 10,
|
|
});
|
|
|
|
await executor.execute(requestContext, eventBus);
|
|
|
|
// Must NOT have called cancelA2aLinkedJob — delegation is still live
|
|
expect(cancelCalls).toHaveLength(0);
|
|
|
|
// Must have published 'completed' terminal event (not 'canceled')
|
|
const terminalEvents = statusEvents().filter(e => e.final === true);
|
|
expect(terminalEvents.length).toBeGreaterThanOrEqual(1);
|
|
const completedEvent = terminalEvents.find(
|
|
e => (e.status as Record<string, unknown>)?.state === 'completed',
|
|
);
|
|
expect(completedEvent).toBeDefined();
|
|
expect((completedEvent!.status as Record<string, unknown>).state).toBe('completed');
|
|
|
|
// No 'canceled' event in the terminal events
|
|
const canceledEvent = terminalEvents.find(
|
|
e => (e.status as Record<string, unknown>)?.state === 'canceled',
|
|
);
|
|
expect(canceledEvent).toBeUndefined();
|
|
|
|
// eventBus.finished() must have been called exactly once
|
|
expect(tracker.finishedCount).toBe(1);
|
|
});
|
|
|
|
it('B: initial submitted Task metadata has a2aDelegationId = delegation.id and a2aGrantId = grantId', async () => {
|
|
const GRANT_ID = 'grant-metadata-test';
|
|
const DELEG_ID = 'deleg-pk-real-003'; // deliberately different from GRANT_ID
|
|
|
|
const { repo } = makeFakeRepo({
|
|
delegationId: DELEG_ID,
|
|
grantId: GRANT_ID,
|
|
// revoke immediately so execute() exits quickly after the first poll
|
|
revokeAfterPollN: 0,
|
|
});
|
|
const { eventBus, events } = makeFakeEventBus();
|
|
const requestContext = makeFakeRequestContext({ grantId: GRANT_ID, delegationId: DELEG_ID });
|
|
|
|
const executor = new MaestroA2aExecutor(repo, {
|
|
pollMs: 0,
|
|
sleep: async () => {},
|
|
now: nowFn,
|
|
maxPollIterations: 10,
|
|
});
|
|
|
|
await executor.execute(requestContext, eventBus);
|
|
|
|
// The first published event must be the initial 'submitted' Task (kind: 'task')
|
|
const taskEvents = events.filter(
|
|
(e): e is Record<string, unknown> =>
|
|
typeof e === 'object' && e !== null && (e as Record<string, unknown>).kind === 'task',
|
|
);
|
|
expect(taskEvents.length).toBeGreaterThanOrEqual(1);
|
|
|
|
const initialTask = taskEvents[0];
|
|
const metadata = initialTask.metadata as Record<string, unknown>;
|
|
|
|
// a2aDelegationId must equal the real delegation PK (not grantId)
|
|
expect(metadata.a2aDelegationId).toBe(DELEG_ID);
|
|
// a2aGrantId must equal the OAuth grant id
|
|
expect(metadata.a2aGrantId).toBe(GRANT_ID);
|
|
// Both values must differ — verifies we're not using grantId for both
|
|
expect(metadata.a2aDelegationId).not.toBe(metadata.a2aGrantId);
|
|
});
|
|
});
|
|
|
|
// ── Task 6: Reconciler revocation sweep helpers ───────────────────────────────
|
|
|
|
const RECONCILER_TERMINAL_STATES = new Set(['completed', 'failed', 'canceled', 'rejected']);
|
|
|
|
function makeReconcilerFakeRepo(opts: {
|
|
grantId: string;
|
|
delegationRevoked: boolean;
|
|
initialJobStatus?: string;
|
|
initialTaskState?: string;
|
|
}) {
|
|
let currentJobStatus = opts.initialJobStatus ?? 'running';
|
|
let currentPayload: Record<string, unknown> = {
|
|
contextId: 'ctx-r',
|
|
status: { state: opts.initialTaskState ?? 'working' },
|
|
metadata: { a2aGrantId: opts.grantId },
|
|
};
|
|
const cancelCalls: string[] = [];
|
|
|
|
const delegRow = {
|
|
id: 'deleg-r-001',
|
|
userId: 'user-1',
|
|
clientId: 'client-1',
|
|
grantId: opts.grantId,
|
|
grantedSpaceIds: ['space-1'],
|
|
grantedSkills: ['research'],
|
|
audience: null as string | null,
|
|
expiresAt: null as string | null,
|
|
revokedAt: opts.delegationRevoked ? '2026-07-01T00:00:00.000Z' : null as string | null,
|
|
};
|
|
|
|
const repo = {
|
|
listNonTerminalA2aTasks: (_limit: number) => {
|
|
const state = (currentPayload.status as Record<string, unknown> | undefined)?.state as string | undefined;
|
|
if (state && RECONCILER_TERMINAL_STATES.has(state)) return [];
|
|
return [{ id: 'a2a-task-r1', jobId: 'job-r1', payload: { ...currentPayload } }];
|
|
},
|
|
getA2aDelegationByGrantId: (_grantId: string) => delegRow,
|
|
cancelA2aLinkedJob: (jobId: string) => {
|
|
cancelCalls.push(jobId);
|
|
currentJobStatus = 'cancelled';
|
|
return true;
|
|
},
|
|
saveA2aTask: (row: { payload: object }) => {
|
|
currentPayload = { ...(row.payload as Record<string, unknown>) };
|
|
},
|
|
getJob: async (_id: string) => ({
|
|
id: 'job-r1',
|
|
status: currentJobStatus,
|
|
currentActivity: null,
|
|
errorSummary: null,
|
|
abortReason: null,
|
|
worktreePath: null,
|
|
}),
|
|
getLocalTask: async (id: number) => ({ id, workspacePath: null }),
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
} as any;
|
|
|
|
return {
|
|
repo,
|
|
cancelCalls,
|
|
getCurrentPayload: () => currentPayload,
|
|
getCurrentJobStatus: () => currentJobStatus,
|
|
};
|
|
}
|
|
|
|
// ── Task 6: Reconciler revocation sweep tests ─────────────────────────────────
|
|
|
|
describe('A2A reconciler revocation sweep (Task 6)', () => {
|
|
const nowFn = () => '2026-07-02T00:00:00.000Z';
|
|
|
|
it('R1: revoked delegation + running linked job → job cancelled and task finalized to canceled', async () => {
|
|
const GRANT_ID = 'grant-revoked-r1';
|
|
const { repo, cancelCalls, getCurrentPayload } = makeReconcilerFakeRepo({
|
|
grantId: GRANT_ID,
|
|
delegationRevoked: true,
|
|
initialJobStatus: 'running',
|
|
});
|
|
|
|
const reconciler = new A2aTaskReconciler({ repo, now: nowFn });
|
|
const result = await reconciler.reconcileOnce();
|
|
|
|
// Job must have been cancelled via cancelA2aLinkedJob
|
|
expect(cancelCalls).toContain('job-r1');
|
|
|
|
// Task payload must be finalized to A2A 'canceled' state
|
|
const savedState = (getCurrentPayload().status as Record<string, unknown>)?.state;
|
|
expect(savedState).toBe('canceled');
|
|
|
|
// Reconciler must report at least one finalization
|
|
expect(result.finalized).toBeGreaterThanOrEqual(1);
|
|
});
|
|
|
|
it('R2: live delegation → task left untouched (still working, job not cancelled)', async () => {
|
|
const GRANT_ID = 'grant-live-r2';
|
|
const { repo, cancelCalls, getCurrentPayload } = makeReconcilerFakeRepo({
|
|
grantId: GRANT_ID,
|
|
delegationRevoked: false,
|
|
initialJobStatus: 'running',
|
|
});
|
|
|
|
const reconciler = new A2aTaskReconciler({ repo, now: nowFn });
|
|
await reconciler.reconcileOnce();
|
|
|
|
// cancelA2aLinkedJob must NOT have been called
|
|
expect(cancelCalls).toHaveLength(0);
|
|
|
|
// Task must remain in its original non-terminal state (saveA2aTask was not called)
|
|
const state = (getCurrentPayload().status as Record<string, unknown>)?.state;
|
|
expect(state).toBe('working');
|
|
});
|
|
|
|
it('R3: idempotency — second reconcileOnce() after R1 makes no further changes', async () => {
|
|
const GRANT_ID = 'grant-revoked-r3';
|
|
const { repo, cancelCalls, getCurrentPayload } = makeReconcilerFakeRepo({
|
|
grantId: GRANT_ID,
|
|
delegationRevoked: true,
|
|
initialJobStatus: 'running',
|
|
});
|
|
|
|
const reconciler = new A2aTaskReconciler({ repo, now: nowFn });
|
|
|
|
// First run — equivalent to R1
|
|
await reconciler.reconcileOnce();
|
|
expect(cancelCalls).toHaveLength(1);
|
|
expect((getCurrentPayload().status as Record<string, unknown>)?.state).toBe('canceled');
|
|
|
|
// Second run — task is now terminal so listNonTerminalA2aTasks returns nothing
|
|
const result = await reconciler.reconcileOnce();
|
|
|
|
// No additional cancel calls
|
|
expect(cancelCalls).toHaveLength(1);
|
|
// Payload stays canceled
|
|
expect((getCurrentPayload().status as Record<string, unknown>)?.state).toBe('canceled');
|
|
// No new rows scanned or finalized
|
|
expect(result.finalized).toBe(0);
|
|
});
|
|
});
|