mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-06 19:58:43 +02:00
feat: add handoff workflow checkpoint recovery
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,297 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
import type { RunTurnCommand, WorkflowCheckpointSavedEvent, WorkflowCheckpointResume } from '@shared/contracts/sidecar';
|
||||
import { SCRATCHPAD_PROJECT_ID } from '@shared/domain/project';
|
||||
import type { ChatMessageRecord, SessionRecord } from '@shared/domain/session';
|
||||
import {
|
||||
createSessionRunRecord,
|
||||
type RunTimelineEventRecord,
|
||||
type SessionRunRecord,
|
||||
} from '@shared/domain/runTimeline';
|
||||
|
||||
mock.module('electron', () => {
|
||||
const electronMock = {
|
||||
app: {
|
||||
isPackaged: false,
|
||||
getAppPath: () => 'C:\\workspace\\personal\\repositories\\aryx',
|
||||
getPath: () => 'C:\\workspace\\personal\\repositories\\aryx\\tests\\fixtures',
|
||||
},
|
||||
dialog: {
|
||||
showOpenDialog: async () => ({ canceled: true, filePaths: [] }),
|
||||
},
|
||||
shell: {
|
||||
openPath: async () => '',
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...electronMock,
|
||||
default: electronMock,
|
||||
};
|
||||
});
|
||||
|
||||
mock.module('keytar', () => ({
|
||||
default: {
|
||||
getPassword: async () => null,
|
||||
setPassword: async () => undefined,
|
||||
deletePassword: async () => false,
|
||||
},
|
||||
}));
|
||||
|
||||
const { AryxAppService } = await import('@main/AryxAppService');
|
||||
|
||||
describe('AryxAppService workflow checkpointing', () => {
|
||||
test('records workflow checkpoint recovery snapshots from turn-scoped events', async () => {
|
||||
const service = new AryxAppService();
|
||||
const { session, run } = createRunningSession();
|
||||
const checkpointEvent: WorkflowCheckpointSavedEvent = {
|
||||
type: 'workflow-checkpoint-saved',
|
||||
requestId: run.requestId,
|
||||
sessionId: session.id,
|
||||
workflowSessionId: run.requestId,
|
||||
checkpointId: 'checkpoint-1',
|
||||
storePath: 'C:\\Users\\tester\\AppData\\Local\\Aryx\\workflow-checkpoints\\session-1\\turn-1',
|
||||
stepNumber: 2,
|
||||
};
|
||||
|
||||
const internals = service as unknown as {
|
||||
workflowCheckpointRecoveries: Map<string, unknown>;
|
||||
handleTurnScopedEvent: (
|
||||
workspace: { sessions: SessionRecord[] },
|
||||
sessionId: string,
|
||||
event: WorkflowCheckpointSavedEvent,
|
||||
) => void | Promise<void>;
|
||||
};
|
||||
|
||||
await internals.handleTurnScopedEvent({ sessions: [session] }, session.id, checkpointEvent);
|
||||
|
||||
expect(internals.workflowCheckpointRecoveries.get(run.requestId)).toEqual({
|
||||
workflowSessionId: run.requestId,
|
||||
checkpointId: 'checkpoint-1',
|
||||
storePath: checkpointEvent.storePath,
|
||||
stepNumber: 2,
|
||||
sessionMessages: session.messages,
|
||||
runEvents: run.events,
|
||||
});
|
||||
});
|
||||
|
||||
test('retries a checkpointed turn with resume metadata after sidecar exit', async () => {
|
||||
const service = new AryxAppService();
|
||||
const { session, run } = createRunningSession();
|
||||
const workspace = { sessions: [session] };
|
||||
const checkpointRecovery = {
|
||||
workflowSessionId: run.requestId,
|
||||
checkpointId: 'checkpoint-7',
|
||||
storePath: 'C:\\Users\\tester\\AppData\\Local\\Aryx\\workflow-checkpoints\\session-1\\turn-1',
|
||||
stepNumber: 7,
|
||||
sessionMessages: structuredClone(session.messages),
|
||||
runEvents: structuredClone(run.events),
|
||||
};
|
||||
const invocations: RunTurnCommand[] = [];
|
||||
|
||||
session.messages.push({
|
||||
id: 'msg-partial',
|
||||
role: 'assistant',
|
||||
authorName: 'Primary',
|
||||
content: 'Partial output after the checkpoint.',
|
||||
createdAt: '2026-04-01T12:00:05.000Z',
|
||||
pending: true,
|
||||
});
|
||||
run.events = [
|
||||
...run.events,
|
||||
{
|
||||
id: 'run-event-extra',
|
||||
kind: 'message',
|
||||
occurredAt: '2026-04-01T12:00:05.000Z',
|
||||
status: 'running',
|
||||
messageId: 'msg-partial',
|
||||
content: 'Partial output after the checkpoint.',
|
||||
} satisfies RunTimelineEventRecord,
|
||||
];
|
||||
session.pendingUserInput = {
|
||||
id: 'user-input-1',
|
||||
status: 'pending',
|
||||
requestedAt: '2026-04-01T12:00:05.000Z',
|
||||
question: 'Need more detail?',
|
||||
choices: ['Yes', 'No'],
|
||||
allowFreeform: true,
|
||||
};
|
||||
|
||||
(
|
||||
service as unknown as {
|
||||
workflowCheckpointRecoveries: Map<string, unknown>;
|
||||
sidecar: {
|
||||
runTurn: (
|
||||
command: RunTurnCommand,
|
||||
) => Promise<ChatMessageRecord[]>;
|
||||
};
|
||||
persistAndBroadcast: (workspace: unknown) => Promise<void>;
|
||||
emitRunUpdated: (sessionId: string, occurredAt: string, run: SessionRunRecord) => void;
|
||||
runSidecarTurnWithCheckpointRecovery: (
|
||||
workspace: unknown,
|
||||
session: SessionRecord,
|
||||
requestId: string,
|
||||
createCommand: (resumeFromCheckpoint?: WorkflowCheckpointResume) => RunTurnCommand,
|
||||
onDelta: () => Promise<void>,
|
||||
onActivity: () => Promise<void>,
|
||||
onApproval: () => Promise<void>,
|
||||
onUserInput: () => Promise<void>,
|
||||
onMcpOAuthRequired: () => Promise<void>,
|
||||
onExitPlanMode: () => Promise<void>,
|
||||
onMessageReclassified: () => Promise<void>,
|
||||
onTurnScopedEvent: () => Promise<void>,
|
||||
) => Promise<ChatMessageRecord[]>;
|
||||
}
|
||||
).workflowCheckpointRecoveries.set(run.requestId, checkpointRecovery);
|
||||
(
|
||||
service as unknown as {
|
||||
sidecar: {
|
||||
runTurn: (command: RunTurnCommand) => Promise<ChatMessageRecord[]>;
|
||||
};
|
||||
}
|
||||
).sidecar = {
|
||||
runTurn: async (command: RunTurnCommand) => {
|
||||
invocations.push(structuredClone(command));
|
||||
if (invocations.length === 1) {
|
||||
throw new Error('The .NET sidecar exited unexpectedly with code 1.');
|
||||
}
|
||||
|
||||
return [];
|
||||
},
|
||||
};
|
||||
(
|
||||
service as unknown as {
|
||||
persistAndBroadcast: (workspace: unknown) => Promise<void>;
|
||||
emitRunUpdated: (sessionId: string, occurredAt: string, run: SessionRunRecord) => void;
|
||||
}
|
||||
).persistAndBroadcast = async () => undefined;
|
||||
(
|
||||
service as unknown as {
|
||||
emitRunUpdated: (sessionId: string, occurredAt: string, run: SessionRunRecord) => void;
|
||||
}
|
||||
).emitRunUpdated = () => undefined;
|
||||
|
||||
const result = await (
|
||||
service as unknown as {
|
||||
runSidecarTurnWithCheckpointRecovery: (
|
||||
workspace: unknown,
|
||||
session: SessionRecord,
|
||||
requestId: string,
|
||||
createCommand: (resumeFromCheckpoint?: WorkflowCheckpointResume) => RunTurnCommand,
|
||||
onDelta: () => Promise<void>,
|
||||
onActivity: () => Promise<void>,
|
||||
onApproval: () => Promise<void>,
|
||||
onUserInput: () => Promise<void>,
|
||||
onMcpOAuthRequired: () => Promise<void>,
|
||||
onExitPlanMode: () => Promise<void>,
|
||||
onMessageReclassified: () => Promise<void>,
|
||||
onTurnScopedEvent: () => Promise<void>,
|
||||
) => Promise<ChatMessageRecord[]>;
|
||||
}
|
||||
).runSidecarTurnWithCheckpointRecovery(
|
||||
workspace,
|
||||
session,
|
||||
run.requestId,
|
||||
(resumeFromCheckpoint?: WorkflowCheckpointResume): RunTurnCommand => ({
|
||||
type: 'run-turn',
|
||||
requestId: run.requestId,
|
||||
sessionId: session.id,
|
||||
projectPath: 'C:\\scratchpad',
|
||||
workspaceKind: 'scratchpad',
|
||||
mode: 'interactive',
|
||||
messageMode: 'enqueue',
|
||||
pattern: createPattern(),
|
||||
messages: session.messages,
|
||||
resumeFromCheckpoint,
|
||||
}),
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(invocations).toHaveLength(2);
|
||||
expect(invocations[0]?.resumeFromCheckpoint).toBeUndefined();
|
||||
expect(invocations[1]?.resumeFromCheckpoint).toEqual({
|
||||
workflowSessionId: run.requestId,
|
||||
checkpointId: 'checkpoint-7',
|
||||
storePath: checkpointRecovery.storePath,
|
||||
});
|
||||
expect(invocations[1]?.messages).toEqual(checkpointRecovery.sessionMessages);
|
||||
expect(session.messages).toEqual(checkpointRecovery.sessionMessages);
|
||||
expect(session.pendingUserInput).toBeUndefined();
|
||||
expect(session.runs[0]?.events).toEqual(checkpointRecovery.runEvents);
|
||||
});
|
||||
});
|
||||
|
||||
function createRunningSession(): { session: SessionRecord; run: SessionRunRecord } {
|
||||
const pattern = createPattern();
|
||||
const run = createSessionRunRecord({
|
||||
requestId: 'turn-1',
|
||||
project: {
|
||||
id: SCRATCHPAD_PROJECT_ID,
|
||||
path: 'C:\\scratchpad',
|
||||
},
|
||||
workingDirectory: 'C:\\scratchpad',
|
||||
workspaceKind: 'scratchpad',
|
||||
pattern,
|
||||
triggerMessageId: 'msg-user-1',
|
||||
startedAt: '2026-04-01T12:00:00.000Z',
|
||||
});
|
||||
const session: SessionRecord = {
|
||||
id: 'session-1',
|
||||
projectId: SCRATCHPAD_PROJECT_ID,
|
||||
patternId: pattern.id,
|
||||
title: 'Checkpoint session',
|
||||
createdAt: '2026-04-01T12:00:00.000Z',
|
||||
updatedAt: '2026-04-01T12:00:00.000Z',
|
||||
status: 'running',
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-user-1',
|
||||
role: 'user',
|
||||
authorName: 'You',
|
||||
content: 'Continue the workflow.',
|
||||
createdAt: '2026-04-01T12:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'msg-assistant-1',
|
||||
role: 'assistant',
|
||||
authorName: 'Primary',
|
||||
content: 'Working on it.',
|
||||
createdAt: '2026-04-01T12:00:01.000Z',
|
||||
pending: true,
|
||||
},
|
||||
],
|
||||
runs: [run],
|
||||
};
|
||||
|
||||
return { session, run };
|
||||
}
|
||||
|
||||
function createPattern() {
|
||||
return {
|
||||
id: 'pattern-handoff',
|
||||
name: 'Checkpointing flow',
|
||||
description: '',
|
||||
mode: 'handoff' as const,
|
||||
availability: 'available' as const,
|
||||
maxIterations: 4,
|
||||
agents: [
|
||||
{
|
||||
id: 'agent-1',
|
||||
name: 'Primary',
|
||||
description: '',
|
||||
instructions: 'Help with the request.',
|
||||
model: 'gpt-5.4',
|
||||
},
|
||||
],
|
||||
createdAt: '2026-04-01T00:00:00.000Z',
|
||||
updatedAt: '2026-04-01T00:00:00.000Z',
|
||||
};
|
||||
}
|
||||
@@ -2,7 +2,12 @@ import { EventEmitter } from 'node:events';
|
||||
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
import type { RunTurnCommand, SidecarCapabilities, WorkflowDiagnosticEvent } from '@shared/contracts/sidecar';
|
||||
import type {
|
||||
RunTurnCommand,
|
||||
SidecarCapabilities,
|
||||
WorkflowCheckpointSavedEvent,
|
||||
WorkflowDiagnosticEvent,
|
||||
} from '@shared/contracts/sidecar';
|
||||
|
||||
class FakeReadableStream extends EventEmitter {
|
||||
setEncoding(_encoding: BufferEncoding): void {}
|
||||
@@ -238,4 +243,96 @@ describe('SidecarClient', () => {
|
||||
spawnedProcesses[0]!.completeExit();
|
||||
await dispose;
|
||||
});
|
||||
|
||||
test('routes workflow checkpoint events through the turn-scoped callback', async () => {
|
||||
spawnedProcesses.length = 0;
|
||||
const client = new SidecarClient();
|
||||
const checkpoints: WorkflowCheckpointSavedEvent[] = [];
|
||||
const command: RunTurnCommand = {
|
||||
type: 'run-turn',
|
||||
requestId: 'turn-1',
|
||||
sessionId: 'session-1',
|
||||
projectPath: 'C:\\workspace\\project',
|
||||
pattern: {
|
||||
id: 'pattern-1',
|
||||
name: 'Handoff',
|
||||
description: '',
|
||||
mode: 'handoff',
|
||||
availability: 'available',
|
||||
maxIterations: 1,
|
||||
agents: [
|
||||
{
|
||||
id: 'agent-1',
|
||||
name: 'Primary',
|
||||
description: '',
|
||||
instructions: 'Help with the request.',
|
||||
model: 'gpt-5.4',
|
||||
},
|
||||
],
|
||||
createdAt: '2026-04-01T00:00:00.000Z',
|
||||
updatedAt: '2026-04-01T00:00:00.000Z',
|
||||
},
|
||||
messages: [],
|
||||
};
|
||||
|
||||
const turn = client.runTurn(
|
||||
command,
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
async () => undefined,
|
||||
async (event) => {
|
||||
if (event.type === 'workflow-checkpoint-saved') {
|
||||
checkpoints.push(event);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
await Promise.resolve();
|
||||
expect(spawnedProcesses).toHaveLength(1);
|
||||
|
||||
spawnedProcesses[0]!.emitStdout(
|
||||
`${JSON.stringify({
|
||||
type: 'workflow-checkpoint-saved',
|
||||
requestId: command.requestId,
|
||||
sessionId: command.sessionId,
|
||||
workflowSessionId: 'turn-1',
|
||||
checkpointId: 'checkpoint-1',
|
||||
storePath: 'C:\\Users\\tester\\AppData\\Local\\Aryx\\workflow-checkpoints\\session-1\\turn-1',
|
||||
stepNumber: 2,
|
||||
} satisfies WorkflowCheckpointSavedEvent)}\n`,
|
||||
);
|
||||
spawnedProcesses[0]!.emitStdout(
|
||||
`${JSON.stringify({
|
||||
type: 'turn-complete',
|
||||
requestId: command.requestId,
|
||||
sessionId: command.sessionId,
|
||||
messages: [],
|
||||
cancelled: false,
|
||||
})}\n`,
|
||||
);
|
||||
spawnedProcesses[0]!.emitStdout(
|
||||
`${JSON.stringify({
|
||||
type: 'command-complete',
|
||||
requestId: command.requestId,
|
||||
})}\n`,
|
||||
);
|
||||
|
||||
await expect(turn).resolves.toEqual([]);
|
||||
expect(checkpoints).toEqual([
|
||||
expect.objectContaining({
|
||||
type: 'workflow-checkpoint-saved',
|
||||
workflowSessionId: 'turn-1',
|
||||
checkpointId: 'checkpoint-1',
|
||||
stepNumber: 2,
|
||||
}),
|
||||
]);
|
||||
|
||||
const dispose = client.dispose();
|
||||
spawnedProcesses[0]!.completeExit();
|
||||
await dispose;
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user