agent-relay 3.2.21 → 3.2.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/agent-relay-broker-darwin-arm64 +0 -0
- package/bin/agent-relay-broker-darwin-x64 +0 -0
- package/bin/agent-relay-broker-linux-arm64 +0 -0
- package/bin/agent-relay-broker-linux-x64 +0 -0
- package/dist/index.cjs +147 -12
- package/package.json +9 -9
- package/packages/acp-bridge/package.json +2 -2
- package/packages/brand/package.json +1 -1
- package/packages/cloud/package.json +2 -2
- package/packages/config/package.json +1 -1
- package/packages/hooks/package.json +4 -4
- package/packages/memory/package.json +2 -2
- package/packages/openclaw/package.json +2 -2
- package/packages/policy/package.json +2 -2
- package/packages/sdk/dist/workflows/cli.js +46 -2
- package/packages/sdk/dist/workflows/cli.js.map +1 -1
- package/packages/sdk/dist/workflows/file-db.d.ts +2 -0
- package/packages/sdk/dist/workflows/file-db.d.ts.map +1 -1
- package/packages/sdk/dist/workflows/file-db.js +20 -3
- package/packages/sdk/dist/workflows/file-db.js.map +1 -1
- package/packages/sdk/dist/workflows/runner.d.ts +6 -1
- package/packages/sdk/dist/workflows/runner.d.ts.map +1 -1
- package/packages/sdk/dist/workflows/runner.js +157 -11
- package/packages/sdk/dist/workflows/runner.js.map +1 -1
- package/packages/sdk/package.json +2 -2
- package/packages/sdk/src/__tests__/resume-fallback.test.ts +415 -0
- package/packages/sdk/src/workflows/cli.ts +53 -2
- package/packages/sdk/src/workflows/file-db.ts +22 -3
- package/packages/sdk/src/workflows/runner.ts +178 -11
- package/packages/sdk-py/pyproject.toml +1 -1
- package/packages/telemetry/package.json +1 -1
- package/packages/trajectory/package.json +2 -2
- package/packages/user-directory/package.json +2 -2
- package/packages/utils/package.json +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@agent-relay/sdk",
|
|
3
|
-
"version": "3.2.
|
|
3
|
+
"version": "3.2.22",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -112,7 +112,7 @@
|
|
|
112
112
|
"typescript": "^5.7.3"
|
|
113
113
|
},
|
|
114
114
|
"dependencies": {
|
|
115
|
-
"@agent-relay/config": "3.2.
|
|
115
|
+
"@agent-relay/config": "3.2.22",
|
|
116
116
|
"@relaycast/sdk": "^1.1.0",
|
|
117
117
|
"@sinclair/typebox": "^0.34.48",
|
|
118
118
|
"chalk": "^4.1.2",
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for resuming workflow execution from cached step outputs when the JSONL
|
|
3
|
+
* run database is missing or unavailable.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
7
|
+
import {
|
|
8
|
+
chmodSync,
|
|
9
|
+
mkdirSync,
|
|
10
|
+
mkdtempSync,
|
|
11
|
+
rmSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
} from 'node:fs';
|
|
14
|
+
import os from 'node:os';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import type { WorkflowDb } from '../workflows/runner.js';
|
|
17
|
+
import type { RelayYamlConfig, WorkflowRunRow, WorkflowStepRow } from '../workflows/types.js';
|
|
18
|
+
|
|
19
|
+
// ── Mock fetch ───────────────────────────────────────────────────────────────
|
|
20
|
+
|
|
21
|
+
const mockFetch = vi.fn().mockResolvedValue({
|
|
22
|
+
ok: true,
|
|
23
|
+
json: () => Promise.resolve({ data: { api_key: 'rk_live_test', workspace_id: 'ws-test' } }),
|
|
24
|
+
text: () => Promise.resolve(''),
|
|
25
|
+
});
|
|
26
|
+
vi.stubGlobal('fetch', mockFetch);
|
|
27
|
+
|
|
28
|
+
// ── Mock RelayCast SDK ───────────────────────────────────────────────────────
|
|
29
|
+
|
|
30
|
+
const mockRelaycastAgent = {
|
|
31
|
+
send: vi.fn().mockResolvedValue(undefined),
|
|
32
|
+
heartbeat: vi.fn().mockResolvedValue(undefined),
|
|
33
|
+
channels: {
|
|
34
|
+
create: vi.fn().mockResolvedValue(undefined),
|
|
35
|
+
join: vi.fn().mockResolvedValue(undefined),
|
|
36
|
+
invite: vi.fn().mockResolvedValue(undefined),
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const mockRelaycast = {
|
|
41
|
+
agents: {
|
|
42
|
+
register: vi.fn().mockResolvedValue({ token: 'token-1' }),
|
|
43
|
+
},
|
|
44
|
+
as: vi.fn().mockReturnValue(mockRelaycastAgent),
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
class MockRelayError extends Error {
|
|
48
|
+
code: string;
|
|
49
|
+
constructor(code: string, message: string, status = 400) {
|
|
50
|
+
super(message);
|
|
51
|
+
this.code = code;
|
|
52
|
+
this.name = 'RelayError';
|
|
53
|
+
(this as any).status = status;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
vi.mock('@relaycast/sdk', () => ({
|
|
58
|
+
RelayCast: vi.fn().mockImplementation(() => mockRelaycast),
|
|
59
|
+
RelayError: MockRelayError,
|
|
60
|
+
}));
|
|
61
|
+
|
|
62
|
+
// ── Mock AgentRelay ──────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
let waitForExitFn: (ms?: number) => Promise<'exited' | 'timeout' | 'released'>;
|
|
65
|
+
|
|
66
|
+
const mockAgent = {
|
|
67
|
+
name: 'test-agent-abc',
|
|
68
|
+
get waitForExit() { return waitForExitFn; },
|
|
69
|
+
get waitForIdle() { return vi.fn().mockImplementation(() => new Promise(() => {})); },
|
|
70
|
+
release: vi.fn().mockResolvedValue(undefined),
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const mockHuman = {
|
|
74
|
+
name: 'WorkflowRunner',
|
|
75
|
+
sendMessage: vi.fn().mockResolvedValue(undefined),
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const mockRelayInstance = {
|
|
79
|
+
spawnPty: vi.fn().mockImplementation(async ({ name, task }: { name: string; task?: string }) => {
|
|
80
|
+
const stepComplete = task?.match(/STEP_COMPLETE:([^\n]+)/)?.[1]?.trim();
|
|
81
|
+
const isReview = task?.includes('REVIEW_DECISION: APPROVE or REJECT');
|
|
82
|
+
const output = isReview
|
|
83
|
+
? 'REVIEW_DECISION: APPROVE\nREVIEW_REASON: looks good\n'
|
|
84
|
+
: stepComplete
|
|
85
|
+
? `STEP_COMPLETE:${stepComplete}\n`
|
|
86
|
+
: 'STEP_COMPLETE:unknown\n';
|
|
87
|
+
|
|
88
|
+
queueMicrotask(() => {
|
|
89
|
+
if (typeof mockRelayInstance.onWorkerOutput === 'function') {
|
|
90
|
+
mockRelayInstance.onWorkerOutput({ name, chunk: output });
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
return { ...mockAgent, name };
|
|
95
|
+
}),
|
|
96
|
+
human: vi.fn().mockReturnValue(mockHuman),
|
|
97
|
+
shutdown: vi.fn().mockResolvedValue(undefined),
|
|
98
|
+
onBrokerStderr: vi.fn().mockReturnValue(() => {}),
|
|
99
|
+
onWorkerOutput: null as ((frame: { name: string; chunk: string }) => void) | null,
|
|
100
|
+
onMessageReceived: null as any,
|
|
101
|
+
onAgentSpawned: null as any,
|
|
102
|
+
onAgentReleased: null as any,
|
|
103
|
+
onAgentExited: null as any,
|
|
104
|
+
onAgentIdle: null as any,
|
|
105
|
+
onDeliveryUpdate: null as any,
|
|
106
|
+
listAgentsRaw: vi.fn().mockResolvedValue([]),
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
vi.mock('../relay.js', () => ({
|
|
110
|
+
AgentRelay: vi.fn().mockImplementation(() => mockRelayInstance),
|
|
111
|
+
}));
|
|
112
|
+
|
|
113
|
+
// Import after mocking
|
|
114
|
+
const { WorkflowRunner } = await import('../workflows/runner.js');
|
|
115
|
+
const { JsonFileWorkflowDb } = await import('../workflows/file-db.js');
|
|
116
|
+
|
|
117
|
+
// ── Helpers ──────────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
function makeDb(): WorkflowDb {
|
|
120
|
+
const runs = new Map<string, WorkflowRunRow>();
|
|
121
|
+
const steps = new Map<string, WorkflowStepRow>();
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
insertRun: vi.fn(async (run: WorkflowRunRow) => {
|
|
125
|
+
runs.set(run.id, { ...run });
|
|
126
|
+
}),
|
|
127
|
+
updateRun: vi.fn(async (id: string, patch: Partial<WorkflowRunRow>) => {
|
|
128
|
+
const existing = runs.get(id);
|
|
129
|
+
if (existing) runs.set(id, { ...existing, ...patch });
|
|
130
|
+
}),
|
|
131
|
+
getRun: vi.fn(async (id: string) => {
|
|
132
|
+
const run = runs.get(id);
|
|
133
|
+
return run ? { ...run } : null;
|
|
134
|
+
}),
|
|
135
|
+
insertStep: vi.fn(async (step: WorkflowStepRow) => {
|
|
136
|
+
steps.set(step.id, { ...step });
|
|
137
|
+
}),
|
|
138
|
+
updateStep: vi.fn(async (id: string, patch: Partial<WorkflowStepRow>) => {
|
|
139
|
+
const existing = steps.get(id);
|
|
140
|
+
if (existing) steps.set(id, { ...existing, ...patch });
|
|
141
|
+
}),
|
|
142
|
+
getStepsByRunId: vi.fn(async (runId: string) => {
|
|
143
|
+
return [...steps.values()].filter((s) => s.runId === runId);
|
|
144
|
+
}),
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function makeResumeConfig(): RelayYamlConfig {
|
|
149
|
+
return {
|
|
150
|
+
version: '1',
|
|
151
|
+
name: 'test-resume-fallback',
|
|
152
|
+
swarm: { pattern: 'dag' },
|
|
153
|
+
agents: [
|
|
154
|
+
{ name: 'agent-a', cli: 'claude' },
|
|
155
|
+
],
|
|
156
|
+
workflows: [
|
|
157
|
+
{
|
|
158
|
+
name: 'default',
|
|
159
|
+
steps: [
|
|
160
|
+
{ name: 'step-a', agent: 'agent-a', task: 'Do step A' },
|
|
161
|
+
{ name: 'step-b', agent: 'agent-a', task: 'Do step B', dependsOn: ['step-a'] },
|
|
162
|
+
{ name: 'step-c', agent: 'agent-a', task: 'Do step C', dependsOn: ['step-b'] },
|
|
163
|
+
],
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
trajectories: false,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function makeTemplateConfig(): RelayYamlConfig {
|
|
171
|
+
return {
|
|
172
|
+
version: '1',
|
|
173
|
+
name: 'test-resume-template',
|
|
174
|
+
swarm: { pattern: 'dag' },
|
|
175
|
+
agents: [
|
|
176
|
+
{ name: 'agent-a', cli: 'claude' },
|
|
177
|
+
],
|
|
178
|
+
workflows: [
|
|
179
|
+
{
|
|
180
|
+
name: 'default',
|
|
181
|
+
steps: [
|
|
182
|
+
{ name: 'step-a', agent: 'agent-a', task: 'Generate input' },
|
|
183
|
+
{
|
|
184
|
+
name: 'step-b',
|
|
185
|
+
agent: 'agent-a',
|
|
186
|
+
task: 'Use cached value: {{steps.step-a.output}}',
|
|
187
|
+
dependsOn: ['step-a'],
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
trajectories: false,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function makeRunRow(runId: string, config: RelayYamlConfig, status: WorkflowRunRow['status'] = 'failed'): WorkflowRunRow {
|
|
197
|
+
const now = new Date().toISOString();
|
|
198
|
+
return {
|
|
199
|
+
id: runId,
|
|
200
|
+
workspaceId: 'ws-test',
|
|
201
|
+
workflowName: 'default',
|
|
202
|
+
pattern: config.swarm.pattern,
|
|
203
|
+
status,
|
|
204
|
+
config,
|
|
205
|
+
startedAt: now,
|
|
206
|
+
createdAt: now,
|
|
207
|
+
updatedAt: now,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function makeStepRow(
|
|
212
|
+
runId: string,
|
|
213
|
+
stepName: string,
|
|
214
|
+
task: string,
|
|
215
|
+
dependsOn: string[] = [],
|
|
216
|
+
status: WorkflowStepRow['status'] = 'pending',
|
|
217
|
+
output?: string
|
|
218
|
+
): WorkflowStepRow {
|
|
219
|
+
const now = new Date().toISOString();
|
|
220
|
+
return {
|
|
221
|
+
id: `${runId}-${stepName}`,
|
|
222
|
+
runId,
|
|
223
|
+
stepName,
|
|
224
|
+
agentName: 'agent-a',
|
|
225
|
+
stepType: 'agent',
|
|
226
|
+
status,
|
|
227
|
+
task,
|
|
228
|
+
dependsOn,
|
|
229
|
+
output,
|
|
230
|
+
retryCount: 0,
|
|
231
|
+
createdAt: now,
|
|
232
|
+
updatedAt: now,
|
|
233
|
+
startedAt: status !== 'pending' ? now : undefined,
|
|
234
|
+
completedAt: status === 'completed' ? now : undefined,
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function writeCachedOutput(tmpDir: string, runId: string, stepName: string, output: string): void {
|
|
239
|
+
const outputDir = path.join(tmpDir, '.agent-relay', 'step-outputs', runId);
|
|
240
|
+
mkdirSync(outputDir, { recursive: true });
|
|
241
|
+
writeFileSync(path.join(outputDir, `${stepName}.md`), output);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
245
|
+
|
|
246
|
+
describe('resume fallback to step-output cache', () => {
|
|
247
|
+
let db: WorkflowDb;
|
|
248
|
+
let runner: InstanceType<typeof WorkflowRunner>;
|
|
249
|
+
let tmpDir: string;
|
|
250
|
+
|
|
251
|
+
beforeEach(() => {
|
|
252
|
+
vi.clearAllMocks();
|
|
253
|
+
waitForExitFn = vi.fn().mockResolvedValue('exited');
|
|
254
|
+
mockRelayInstance.onWorkerOutput = null;
|
|
255
|
+
tmpDir = mkdtempSync(path.join(os.tmpdir(), 'resume-fallback-'));
|
|
256
|
+
db = makeDb();
|
|
257
|
+
runner = new WorkflowRunner({ db, workspaceId: 'ws-test', cwd: tmpDir });
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
afterEach(() => {
|
|
261
|
+
try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('should reconstruct run from step-output cache when JSONL missing', async () => {
|
|
265
|
+
const runId = 'resume-cache-run';
|
|
266
|
+
const config = makeResumeConfig();
|
|
267
|
+
writeCachedOutput(tmpDir, runId, 'step-a', 'cached-a');
|
|
268
|
+
writeCachedOutput(tmpDir, runId, 'step-b', 'cached-b');
|
|
269
|
+
|
|
270
|
+
const events: Array<{ type: string; stepName?: string }> = [];
|
|
271
|
+
runner.on((event) => {
|
|
272
|
+
if ('stepName' in event) {
|
|
273
|
+
events.push({ type: event.type, stepName: event.stepName });
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const run = await (runner as any).resume(runId, undefined, config);
|
|
278
|
+
expect(run.status, run.error).toBe('completed');
|
|
279
|
+
|
|
280
|
+
const startedSteps = events.filter((e) => e.type === 'step:started').map((e) => e.stepName);
|
|
281
|
+
expect(startedSteps).not.toContain('step-a');
|
|
282
|
+
expect(startedSteps).not.toContain('step-b');
|
|
283
|
+
expect(startedSteps).toContain('step-c');
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
it('should throw "not found" when neither JSONL nor cache exists', async () => {
|
|
287
|
+
const config = makeResumeConfig();
|
|
288
|
+
|
|
289
|
+
await expect((runner as any).resume('nonexistent-id', undefined, config)).rejects.toThrow('not found');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('should prefer JSONL database over step-output cache', async () => {
|
|
293
|
+
const runId = 'resume-db-run';
|
|
294
|
+
const config = makeResumeConfig();
|
|
295
|
+
const dbPath = path.join(tmpDir, '.agent-relay', 'workflow-runs.jsonl');
|
|
296
|
+
const fileDb = new JsonFileWorkflowDb(dbPath);
|
|
297
|
+
const dbRunner = new WorkflowRunner({ db: fileDb, workspaceId: 'ws-test', cwd: tmpDir });
|
|
298
|
+
|
|
299
|
+
await fileDb.insertRun(makeRunRow(runId, config));
|
|
300
|
+
await fileDb.insertStep(makeStepRow(runId, 'step-a', 'Do step A', [], 'failed'));
|
|
301
|
+
await fileDb.insertStep(makeStepRow(runId, 'step-b', 'Do step B', ['step-a'], 'pending'));
|
|
302
|
+
await fileDb.insertStep(makeStepRow(runId, 'step-c', 'Do step C', ['step-b'], 'pending'));
|
|
303
|
+
|
|
304
|
+
writeCachedOutput(tmpDir, runId, 'step-a', 'cached-a-from-fallback');
|
|
305
|
+
|
|
306
|
+
const events: Array<{ type: string; stepName?: string }> = [];
|
|
307
|
+
dbRunner.on((event) => {
|
|
308
|
+
if ('stepName' in event) {
|
|
309
|
+
events.push({ type: event.type, stepName: event.stepName });
|
|
310
|
+
}
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
const run = await dbRunner.resume(runId);
|
|
314
|
+
expect(run.status, run.error).toBe('completed');
|
|
315
|
+
|
|
316
|
+
const startedSteps = events.filter((e) => e.type === 'step:started').map((e) => e.stepName);
|
|
317
|
+
expect(startedSteps).toContain('step-a');
|
|
318
|
+
expect(startedSteps).toContain('step-b');
|
|
319
|
+
expect(startedSteps).toContain('step-c');
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
it('should handle empty step-output directory gracefully', async () => {
|
|
323
|
+
const runId = 'resume-empty-cache';
|
|
324
|
+
const config = makeResumeConfig();
|
|
325
|
+
mkdirSync(path.join(tmpDir, '.agent-relay', 'step-outputs', runId), { recursive: true });
|
|
326
|
+
|
|
327
|
+
const events: Array<{ type: string; stepName?: string }> = [];
|
|
328
|
+
runner.on((event) => {
|
|
329
|
+
if ('stepName' in event) {
|
|
330
|
+
events.push({ type: event.type, stepName: event.stepName });
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
const run = await (runner as any).resume(runId, undefined, config);
|
|
335
|
+
expect(run.status, run.error).toBe('completed');
|
|
336
|
+
|
|
337
|
+
const startedSteps = events.filter((e) => e.type === 'step:started').map((e) => e.stepName);
|
|
338
|
+
expect(startedSteps).toContain('step-a');
|
|
339
|
+
expect(startedSteps).toContain('step-b');
|
|
340
|
+
expect(startedSteps).toContain('step-c');
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it('should load cached output into step template variables', async () => {
|
|
344
|
+
const runId = 'resume-template-cache';
|
|
345
|
+
const config = makeTemplateConfig();
|
|
346
|
+
writeCachedOutput(tmpDir, runId, 'step-a', 'hello world');
|
|
347
|
+
|
|
348
|
+
const run = await (runner as any).resume(runId, undefined, config);
|
|
349
|
+
expect(run.status, run.error).toBe('completed');
|
|
350
|
+
|
|
351
|
+
const spawnedTasks = mockRelayInstance.spawnPty.mock.calls.map(
|
|
352
|
+
([args]) => (args as { task?: string }).task ?? ''
|
|
353
|
+
);
|
|
354
|
+
expect(spawnedTasks.some((task) => task.includes('Use cached value: hello world'))).toBe(true);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
it('should skip .report.json files when scanning step outputs', async () => {
|
|
358
|
+
const runId = 'resume-report-cache';
|
|
359
|
+
const config = makeResumeConfig();
|
|
360
|
+
const outputDir = path.join(tmpDir, '.agent-relay', 'step-outputs', runId);
|
|
361
|
+
mkdirSync(outputDir, { recursive: true });
|
|
362
|
+
writeFileSync(path.join(outputDir, 'step-a.md'), 'cached-a');
|
|
363
|
+
writeFileSync(path.join(outputDir, 'step-a.report.json'), '{"summary":"done"}');
|
|
364
|
+
writeFileSync(path.join(outputDir, 'step-b.report.json'), '{"summary":"metadata only"}');
|
|
365
|
+
|
|
366
|
+
const events: Array<{ type: string; stepName?: string }> = [];
|
|
367
|
+
runner.on((event) => {
|
|
368
|
+
if ('stepName' in event) {
|
|
369
|
+
events.push({ type: event.type, stepName: event.stepName });
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
const run = await (runner as any).resume(runId, undefined, config);
|
|
374
|
+
expect(run.status, run.error).toBe('completed');
|
|
375
|
+
|
|
376
|
+
const startedSteps = events.filter((e) => e.type === 'step:started').map((e) => e.stepName);
|
|
377
|
+
expect(startedSteps).not.toContain('step-a');
|
|
378
|
+
expect(startedSteps).toContain('step-b');
|
|
379
|
+
expect(startedSteps).toContain('step-c');
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
describe('file-db append diagnostics', () => {
|
|
384
|
+
let tmpDir: string;
|
|
385
|
+
|
|
386
|
+
beforeEach(() => {
|
|
387
|
+
vi.clearAllMocks();
|
|
388
|
+
tmpDir = mkdtempSync(path.join(os.tmpdir(), 'file-db-warn-'));
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
afterEach(() => {
|
|
392
|
+
try {
|
|
393
|
+
chmodSync(path.join(tmpDir, 'readonly'), 0o755);
|
|
394
|
+
} catch {}
|
|
395
|
+
try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
it('should warn once when append fails', async () => {
|
|
399
|
+
const readonlyDir = path.join(tmpDir, 'readonly');
|
|
400
|
+
mkdirSync(readonlyDir, { recursive: true });
|
|
401
|
+
chmodSync(readonlyDir, 0o555);
|
|
402
|
+
|
|
403
|
+
const dbPath = path.join(readonlyDir, 'workflow-runs.jsonl');
|
|
404
|
+
const fileDb = new JsonFileWorkflowDb(dbPath);
|
|
405
|
+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
|
|
406
|
+
const config = makeResumeConfig();
|
|
407
|
+
|
|
408
|
+
await fileDb.insertRun(makeRunRow('warn-run-1', config));
|
|
409
|
+
await fileDb.insertRun(makeRunRow('warn-run-2', config));
|
|
410
|
+
|
|
411
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
412
|
+
|
|
413
|
+
warnSpy.mockRestore();
|
|
414
|
+
});
|
|
415
|
+
});
|
|
@@ -52,6 +52,21 @@ type ExecuteOptions = {
|
|
|
52
52
|
previousRunId?: string;
|
|
53
53
|
};
|
|
54
54
|
|
|
55
|
+
/** Flags that consume the next argument as their value. Single source of truth for CLI parsing. */
|
|
56
|
+
const FLAGS_WITH_VALUES = new Set(['--resume', '--workflow', '--start-from', '--previous-run-id']);
|
|
57
|
+
|
|
58
|
+
function getYamlPathArg(args: string[]): string | undefined {
|
|
59
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
60
|
+
const arg = args[i];
|
|
61
|
+
if (arg.startsWith('--')) {
|
|
62
|
+
if (FLAGS_WITH_VALUES.has(arg)) i += 1;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
return arg;
|
|
66
|
+
}
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
55
70
|
interface RenderableTask {
|
|
56
71
|
output?: string;
|
|
57
72
|
title: string;
|
|
@@ -302,6 +317,7 @@ async function runWithListr(
|
|
|
302
317
|
|
|
303
318
|
async function main(): Promise<void> {
|
|
304
319
|
const args = process.argv.slice(2);
|
|
320
|
+
const yamlPath = getYamlPathArg(args);
|
|
305
321
|
|
|
306
322
|
if (args.length === 0 || args.includes('--help')) {
|
|
307
323
|
printUsage();
|
|
@@ -358,7 +374,37 @@ async function main(): Promise<void> {
|
|
|
358
374
|
break;
|
|
359
375
|
}
|
|
360
376
|
});
|
|
361
|
-
|
|
377
|
+
let result: RunnerResult;
|
|
378
|
+
try {
|
|
379
|
+
const resumeConfig = yamlPath ? await runner.parseYamlFile(yamlPath) : undefined;
|
|
380
|
+
if (resumeConfig) {
|
|
381
|
+
console.warn(
|
|
382
|
+
chalk.yellow(
|
|
383
|
+
'[workflow] warning: resuming with current config from disk — ' +
|
|
384
|
+
'if the workflow YAML changed since the original run, behaviour may differ'
|
|
385
|
+
)
|
|
386
|
+
);
|
|
387
|
+
}
|
|
388
|
+
result = await runner.resume(runId, undefined, resumeConfig);
|
|
389
|
+
} catch (err) {
|
|
390
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
391
|
+
const isRunNotFound = message.startsWith(`Run "${runId}" not found`);
|
|
392
|
+
if (isRunNotFound) {
|
|
393
|
+
if (fileDb.hasStepOutputs(runId)) {
|
|
394
|
+
console.error(
|
|
395
|
+
chalk.red(
|
|
396
|
+
`Error: ${message}. Step outputs exist for this run, but persisted run state is missing from ${dbPath}. ` +
|
|
397
|
+
`Use --start-from with --previous-run-id ${runId} to recover from the cached step outputs instead.`
|
|
398
|
+
)
|
|
399
|
+
);
|
|
400
|
+
} else {
|
|
401
|
+
console.error(chalk.red(`Error: ${message}`));
|
|
402
|
+
}
|
|
403
|
+
} else {
|
|
404
|
+
console.error(chalk.red(`Error: ${message}`));
|
|
405
|
+
}
|
|
406
|
+
process.exit(1);
|
|
407
|
+
}
|
|
362
408
|
|
|
363
409
|
if (result.status === 'completed') {
|
|
364
410
|
console.log(chalk.green('\nWorkflow completed successfully.'));
|
|
@@ -371,7 +417,6 @@ async function main(): Promise<void> {
|
|
|
371
417
|
}
|
|
372
418
|
|
|
373
419
|
// ── Normal / validate / dry-run mode ──────────────────────────────────────
|
|
374
|
-
const yamlPath = args[0];
|
|
375
420
|
let workflowName: string | undefined;
|
|
376
421
|
|
|
377
422
|
const workflowIdx = args.indexOf('--workflow');
|
|
@@ -391,6 +436,12 @@ async function main(): Promise<void> {
|
|
|
391
436
|
previousRunId = args[prevRunIdx + 1];
|
|
392
437
|
}
|
|
393
438
|
|
|
439
|
+
if (!yamlPath) {
|
|
440
|
+
console.error(chalk.red('Error: workflow YAML path is required'));
|
|
441
|
+
printUsage();
|
|
442
|
+
process.exit(1);
|
|
443
|
+
}
|
|
444
|
+
|
|
394
445
|
const isValidate = args.includes('--validate');
|
|
395
446
|
const isDryRun = !!process.env.DRY_RUN;
|
|
396
447
|
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { appendFileSync, mkdirSync, readFileSync } from 'node:fs';
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
4
|
import type { WorkflowRunRow, WorkflowStepRow } from './types.js';
|
|
@@ -24,6 +24,7 @@ export class JsonFileWorkflowDb implements WorkflowDb {
|
|
|
24
24
|
|
|
25
25
|
/** Whether the storage directory is writable. False = silent no-op mode. */
|
|
26
26
|
private readonly writable: boolean;
|
|
27
|
+
private appendFailedOnce = false;
|
|
27
28
|
|
|
28
29
|
constructor(filePath: string) {
|
|
29
30
|
this.filePath = filePath;
|
|
@@ -43,14 +44,32 @@ export class JsonFileWorkflowDb implements WorkflowDb {
|
|
|
43
44
|
return this.writable;
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
hasStepOutputs(runId: string): boolean {
|
|
48
|
+
try {
|
|
49
|
+
const dir = path.join(path.dirname(this.filePath), 'step-outputs', runId);
|
|
50
|
+
return existsSync(dir) && readdirSync(dir).length > 0;
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
46
56
|
// ── Private helpers ─────────────────────────────────────────────────────
|
|
47
57
|
|
|
48
58
|
private append(entry: DbEntry): void {
|
|
49
59
|
if (!this.writable) return;
|
|
50
60
|
try {
|
|
51
61
|
appendFileSync(this.filePath, JSON.stringify(entry) + '\n', 'utf8');
|
|
52
|
-
} catch {
|
|
53
|
-
|
|
62
|
+
} catch (err) {
|
|
63
|
+
if (!this.appendFailedOnce) {
|
|
64
|
+
this.appendFailedOnce = true;
|
|
65
|
+
console.warn(
|
|
66
|
+
'[workflow] warning: failed to write run state to ' +
|
|
67
|
+
this.filePath +
|
|
68
|
+
' — --resume will not be available for this run. Use --start-from instead. ' +
|
|
69
|
+
'Error: ' +
|
|
70
|
+
(err instanceof Error ? err.message : String(err))
|
|
71
|
+
);
|
|
72
|
+
}
|
|
54
73
|
}
|
|
55
74
|
}
|
|
56
75
|
|