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.
Files changed (34) hide show
  1. package/bin/agent-relay-broker-darwin-arm64 +0 -0
  2. package/bin/agent-relay-broker-darwin-x64 +0 -0
  3. package/bin/agent-relay-broker-linux-arm64 +0 -0
  4. package/bin/agent-relay-broker-linux-x64 +0 -0
  5. package/dist/index.cjs +147 -12
  6. package/package.json +9 -9
  7. package/packages/acp-bridge/package.json +2 -2
  8. package/packages/brand/package.json +1 -1
  9. package/packages/cloud/package.json +2 -2
  10. package/packages/config/package.json +1 -1
  11. package/packages/hooks/package.json +4 -4
  12. package/packages/memory/package.json +2 -2
  13. package/packages/openclaw/package.json +2 -2
  14. package/packages/policy/package.json +2 -2
  15. package/packages/sdk/dist/workflows/cli.js +46 -2
  16. package/packages/sdk/dist/workflows/cli.js.map +1 -1
  17. package/packages/sdk/dist/workflows/file-db.d.ts +2 -0
  18. package/packages/sdk/dist/workflows/file-db.d.ts.map +1 -1
  19. package/packages/sdk/dist/workflows/file-db.js +20 -3
  20. package/packages/sdk/dist/workflows/file-db.js.map +1 -1
  21. package/packages/sdk/dist/workflows/runner.d.ts +6 -1
  22. package/packages/sdk/dist/workflows/runner.d.ts.map +1 -1
  23. package/packages/sdk/dist/workflows/runner.js +157 -11
  24. package/packages/sdk/dist/workflows/runner.js.map +1 -1
  25. package/packages/sdk/package.json +2 -2
  26. package/packages/sdk/src/__tests__/resume-fallback.test.ts +415 -0
  27. package/packages/sdk/src/workflows/cli.ts +53 -2
  28. package/packages/sdk/src/workflows/file-db.ts +22 -3
  29. package/packages/sdk/src/workflows/runner.ts +178 -11
  30. package/packages/sdk-py/pyproject.toml +1 -1
  31. package/packages/telemetry/package.json +1 -1
  32. package/packages/trajectory/package.json +2 -2
  33. package/packages/user-directory/package.json +2 -2
  34. package/packages/utils/package.json +2 -2
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-relay/sdk",
3
- "version": "3.2.21",
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.21",
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
- const result = await runner.resume(runId);
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
- // Non-critical — workflow execution continues; resume won't be available.
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