agent-relay 3.2.18 → 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 (76) 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 +233 -55
  6. package/dist/src/cli/commands/cloud.d.ts +1 -9
  7. package/dist/src/cli/commands/cloud.d.ts.map +1 -1
  8. package/dist/src/cli/commands/cloud.js +326 -323
  9. package/dist/src/cli/commands/cloud.js.map +1 -1
  10. package/dist/src/cli/commands/connect.d.ts.map +1 -1
  11. package/dist/src/cli/commands/connect.js +6 -10
  12. package/dist/src/cli/commands/connect.js.map +1 -1
  13. package/package.json +16 -10
  14. package/packages/acp-bridge/package.json +2 -2
  15. package/packages/brand/README.md +36 -0
  16. package/packages/brand/brand.css +226 -0
  17. package/packages/brand/package.json +20 -0
  18. package/packages/cloud/dist/api-client.d.ts +33 -0
  19. package/packages/cloud/dist/api-client.d.ts.map +1 -0
  20. package/packages/cloud/dist/api-client.js +123 -0
  21. package/packages/cloud/dist/api-client.js.map +1 -0
  22. package/packages/cloud/dist/auth.d.ts +13 -0
  23. package/packages/cloud/dist/auth.d.ts.map +1 -0
  24. package/packages/cloud/dist/auth.js +248 -0
  25. package/packages/cloud/dist/auth.js.map +1 -0
  26. package/packages/cloud/dist/index.d.ts +5 -0
  27. package/packages/cloud/dist/index.d.ts.map +1 -0
  28. package/packages/cloud/dist/index.js +5 -0
  29. package/packages/cloud/dist/index.js.map +1 -0
  30. package/packages/cloud/dist/types.d.ts +73 -0
  31. package/packages/cloud/dist/types.d.ts.map +1 -0
  32. package/packages/cloud/dist/types.js +19 -0
  33. package/packages/cloud/dist/types.js.map +1 -0
  34. package/packages/cloud/dist/workflows.d.ts +34 -0
  35. package/packages/cloud/dist/workflows.d.ts.map +1 -0
  36. package/packages/cloud/dist/workflows.js +389 -0
  37. package/packages/cloud/dist/workflows.js.map +1 -0
  38. package/packages/cloud/package.json +44 -0
  39. package/packages/cloud/src/api-client.ts +169 -0
  40. package/packages/cloud/src/auth.ts +314 -0
  41. package/packages/cloud/src/index.ts +41 -0
  42. package/packages/cloud/src/types.ts +97 -0
  43. package/packages/cloud/src/workflows.ts +539 -0
  44. package/packages/cloud/tsconfig.json +21 -0
  45. package/packages/config/package.json +1 -1
  46. package/packages/hooks/package.json +4 -4
  47. package/packages/memory/package.json +2 -2
  48. package/packages/openclaw/package.json +2 -2
  49. package/packages/policy/package.json +2 -2
  50. package/packages/sdk/dist/workflows/__tests__/e2big-and-verify.test.d.ts +2 -0
  51. package/packages/sdk/dist/workflows/__tests__/e2big-and-verify.test.d.ts.map +1 -0
  52. package/packages/sdk/dist/workflows/__tests__/e2big-and-verify.test.js +62 -0
  53. package/packages/sdk/dist/workflows/__tests__/e2big-and-verify.test.js.map +1 -0
  54. package/packages/sdk/dist/workflows/cli.js +46 -2
  55. package/packages/sdk/dist/workflows/cli.js.map +1 -1
  56. package/packages/sdk/dist/workflows/file-db.d.ts +2 -0
  57. package/packages/sdk/dist/workflows/file-db.d.ts.map +1 -1
  58. package/packages/sdk/dist/workflows/file-db.js +20 -3
  59. package/packages/sdk/dist/workflows/file-db.js.map +1 -1
  60. package/packages/sdk/dist/workflows/runner.d.ts +10 -1
  61. package/packages/sdk/dist/workflows/runner.d.ts.map +1 -1
  62. package/packages/sdk/dist/workflows/runner.js +233 -50
  63. package/packages/sdk/dist/workflows/runner.js.map +1 -1
  64. package/packages/sdk/package.json +2 -2
  65. package/packages/sdk/src/__tests__/resume-fallback.test.ts +415 -0
  66. package/packages/sdk/src/__tests__/workflow-runner.test.ts +73 -2
  67. package/packages/sdk/src/workflows/__tests__/e2big-and-verify.test.ts +117 -0
  68. package/packages/sdk/src/workflows/cli.ts +53 -2
  69. package/packages/sdk/src/workflows/file-db.ts +22 -3
  70. package/packages/sdk/src/workflows/runner.ts +283 -49
  71. package/packages/sdk-py/pyproject.toml +1 -1
  72. package/packages/sdk-swift/Sources/AgentRelaySDK/RelayObserver.swift +2 -0
  73. package/packages/telemetry/package.json +1 -1
  74. package/packages/trajectory/package.json +2 -2
  75. package/packages/user-directory/package.json +2 -2
  76. package/packages/utils/package.json +2 -2
@@ -5,8 +5,9 @@
5
5
  */
6
6
  import { spawn as cpSpawn } from 'node:child_process';
7
7
  import { randomBytes } from 'node:crypto';
8
- import { createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync, } from 'node:fs';
9
- import { readFile, writeFile } from 'node:fs/promises';
8
+ import { createWriteStream, existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, renameSync, statSync, writeFileSync, } from 'node:fs';
9
+ import { readFile, writeFile, unlink } from 'node:fs/promises';
10
+ import { tmpdir } from 'node:os';
10
11
  import path from 'node:path';
11
12
  import chalk from 'chalk';
12
13
  import { parse as parseYaml } from 'yaml';
@@ -114,6 +115,7 @@ export class WorkflowRunner {
114
115
  activeReviewers = new Map();
115
116
  /** Structured CLI session reports captured during the current run, keyed by step name. */
116
117
  agentReports = new Map();
118
+ static PTY_TASK_ARG_SIZE_LIMIT = 2 * 1024 * 1024; // 2 MB
117
119
  constructor(options = {}) {
118
120
  this.db = options.db ?? new InMemoryWorkflowDb();
119
121
  this.workspaceId = options.workspaceId ?? 'local';
@@ -1461,34 +1463,46 @@ export class WorkflowRunner {
1461
1463
  });
1462
1464
  }
1463
1465
  /** Resume a previously paused or partially completed run. */
1464
- async resume(runId, vars) {
1466
+ async resume(runId, vars, config) {
1465
1467
  // Set up abort controller early so callers can abort() even during setup
1466
1468
  this.abortController = new AbortController();
1467
1469
  this.paused = false;
1468
- const run = await this.db.getRun(runId);
1470
+ let run = await this.db.getRun(runId);
1471
+ let stepStates = new Map();
1469
1472
  if (!run) {
1470
- throw new Error(`Run "${runId}" not found`);
1473
+ const reconstructed = this.reconstructRunFromCache(runId, config);
1474
+ if (!reconstructed) {
1475
+ throw new Error(`Run "${runId}" not found (no database entry or cached step outputs)`);
1476
+ }
1477
+ this.log('[resume] Reconstructing run from cached step outputs (workflow-runs.jsonl missing)');
1478
+ run = reconstructed.run;
1479
+ stepStates = reconstructed.stepStates;
1480
+ await this.db.insertRun(run);
1481
+ for (const [, state] of stepStates) {
1482
+ await this.db.insertStep(state.row);
1483
+ }
1471
1484
  }
1472
1485
  this.persistRunIdHint(runId);
1473
1486
  if (run.status !== 'running' && run.status !== 'failed') {
1474
1487
  throw new Error(`Run "${runId}" is in status "${run.status}" and cannot be resumed`);
1475
1488
  }
1476
- const config = vars ? this.resolveVariables(run.config, vars) : run.config;
1489
+ const resolvedConfig = vars ? this.resolveVariables(run.config, vars) : run.config;
1477
1490
  // Resolve path definitions (same as execute()) so workdir lookups work on resume
1478
- const pathResult = this.resolvePathDefinitions(config.paths, this.cwd);
1491
+ const pathResult = this.resolvePathDefinitions(resolvedConfig.paths, this.cwd);
1479
1492
  if (pathResult.errors.length > 0) {
1480
1493
  throw new Error(`Path validation failed:\n ${pathResult.errors.join('\n ')}`);
1481
1494
  }
1482
1495
  this.resolvedPaths = pathResult.resolved;
1483
- const workflows = config.workflows ?? [];
1496
+ const workflows = resolvedConfig.workflows ?? [];
1484
1497
  const workflow = workflows.find((w) => w.name === run.workflowName);
1485
1498
  if (!workflow) {
1486
1499
  throw new Error(`Workflow "${run.workflowName}" not found in stored config`);
1487
1500
  }
1488
- const existingSteps = await this.db.getStepsByRunId(runId);
1489
- const stepStates = new Map();
1490
- for (const stepRow of existingSteps) {
1491
- stepStates.set(stepRow.stepName, { row: stepRow });
1501
+ if (stepStates.size === 0) {
1502
+ const existingSteps = await this.db.getStepsByRunId(runId);
1503
+ for (const stepRow of existingSteps) {
1504
+ stepStates.set(stepRow.stepName, { row: stepRow });
1505
+ }
1492
1506
  }
1493
1507
  // Reset failed steps to pending for retry
1494
1508
  for (const [, state] of stepStates) {
@@ -1507,7 +1521,7 @@ export class WorkflowRunner {
1507
1521
  return this.runWorkflowCore({
1508
1522
  run,
1509
1523
  workflow,
1510
- config,
1524
+ config: resolvedConfig,
1511
1525
  stepStates,
1512
1526
  isResume: true,
1513
1527
  });
@@ -2783,6 +2797,7 @@ export class WorkflowRunner {
2783
2797
  let ownerOutput;
2784
2798
  let ownerElapsed;
2785
2799
  let completionReason;
2800
+ let promptTaskText;
2786
2801
  if (usesDedicatedOwner) {
2787
2802
  const result = await this.executeSupervisedAgentStep(step, { specialist: effectiveSpecialist, owner: effectiveOwner, reviewer: reviewDef }, resolvedTask, timeoutMs);
2788
2803
  specialistOutput = result.specialistOutput;
@@ -2823,13 +2838,19 @@ export class WorkflowRunner {
2823
2838
  : undefined,
2824
2839
  });
2825
2840
  const output = typeof spawnResult === 'string' ? spawnResult : spawnResult.output;
2841
+ promptTaskText =
2842
+ typeof spawnResult === 'string'
2843
+ ? effectiveOwner.interactive === false
2844
+ ? undefined
2845
+ : ownerTask
2846
+ : spawnResult.promptTaskText ?? ownerTask;
2826
2847
  lastExitCode = typeof spawnResult === 'string' ? undefined : spawnResult.exitCode;
2827
2848
  lastExitSignal = typeof spawnResult === 'string' ? undefined : spawnResult.exitSignal;
2828
2849
  ownerElapsed = Date.now() - ownerStartTime;
2829
2850
  this.log(`[${step.name}] Owner "${effectiveOwner.name}" exited`);
2830
2851
  if (usesOwnerFlow) {
2831
2852
  try {
2832
- const completionDecision = this.resolveOwnerCompletionDecision(step, output, output, ownerTask, resolvedTask);
2853
+ const completionDecision = this.resolveOwnerCompletionDecision(step, output, output, promptTaskText ?? ownerTask, promptTaskText ?? ownerTask);
2833
2854
  completionReason = completionDecision.completionReason;
2834
2855
  }
2835
2856
  catch (error) {
@@ -2864,7 +2885,7 @@ export class WorkflowRunner {
2864
2885
  // Self-owned interactive steps still need verification fallback so
2865
2886
  // explicit OWNER_DECISION output is not mandatory for the happy path.
2866
2887
  if (step.verification && (!usesOwnerFlow || !usesDedicatedOwner) && !completionReason) {
2867
- const verificationResult = this.runVerification(step.verification, specialistOutput, step.name, effectiveOwner.interactive === false ? undefined : resolvedTask);
2888
+ const verificationResult = this.runVerification(step.verification, specialistOutput, step.name, promptTaskText);
2868
2889
  completionReason = verificationResult.completionReason;
2869
2890
  }
2870
2891
  // Retry-style owner decisions are control-flow signals, not terminal success states.
@@ -3120,7 +3141,8 @@ export class WorkflowRunner {
3120
3141
  detail: `Worker ${workerRuntimeName} exited`,
3121
3142
  raw: { worker: workerRuntimeName, exitCode: result.exitCode, exitSignal: result.exitSignal },
3122
3143
  });
3123
- if (step.verification?.type === 'output_contains' && result.output.includes(step.verification.value)) {
3144
+ if (step.verification?.type === 'output_contains' &&
3145
+ this.outputContainsVerificationToken(result.output, step.verification.value, result.promptTaskText)) {
3124
3146
  this.log(`[${step.name}] Verification gate observed: output contains ${JSON.stringify(step.verification.value)}`);
3125
3147
  }
3126
3148
  })
@@ -3163,8 +3185,9 @@ export class WorkflowRunner {
3163
3185
  const ownerElapsed = Date.now() - ownerStartTime;
3164
3186
  const ownerOutput = ownerResultObj.output;
3165
3187
  this.log(`[${step.name}] Owner "${supervised.owner.name}" exited`);
3166
- const specialistOutput = (await workerPromise).output;
3167
- const completionDecision = this.resolveOwnerCompletionDecision(step, ownerOutput, specialistOutput, supervisorTask, resolvedTask);
3188
+ const workerResultObj = await workerPromise;
3189
+ const specialistOutput = workerResultObj.output;
3190
+ const completionDecision = this.resolveOwnerCompletionDecision(step, ownerOutput, specialistOutput, ownerResultObj.promptTaskText ?? supervisorTask, workerResultObj.promptTaskText ?? specialistTask);
3168
3191
  return {
3169
3192
  specialistOutput,
3170
3193
  ownerOutput,
@@ -3379,6 +3402,10 @@ export class WorkflowRunner {
3379
3402
  }
3380
3403
  hasOwnerCompletionMarker(step, output, injectedTaskText) {
3381
3404
  const marker = `STEP_COMPLETE:${step.name}`;
3405
+ const strippedOutput = this.stripInjectedTaskEcho(output, injectedTaskText);
3406
+ if (strippedOutput.includes(marker)) {
3407
+ return true;
3408
+ }
3382
3409
  const taskHasMarker = injectedTaskText.includes(marker);
3383
3410
  const first = output.indexOf(marker);
3384
3411
  if (first === -1) {
@@ -3451,6 +3478,49 @@ export class WorkflowRunner {
3451
3478
  .filter((line) => patterns.every((pattern) => !pattern.test(line)))
3452
3479
  .join('\n');
3453
3480
  }
3481
+ stripInjectedTaskEcho(output, injectedTaskText) {
3482
+ if (!injectedTaskText) {
3483
+ return output;
3484
+ }
3485
+ const candidates = [
3486
+ injectedTaskText,
3487
+ injectedTaskText.replace(/\r\n/g, '\n'),
3488
+ injectedTaskText.replace(/\n/g, '\r\n'),
3489
+ ].filter((candidate, index, all) => candidate.length > 0 && all.indexOf(candidate) === index);
3490
+ for (const candidate of candidates) {
3491
+ const start = output.indexOf(candidate);
3492
+ if (start !== -1) {
3493
+ return output.slice(0, start) + output.slice(start + candidate.length);
3494
+ }
3495
+ }
3496
+ return output;
3497
+ }
3498
+ outputContainsVerificationToken(output, token, injectedTaskText) {
3499
+ if (!token) {
3500
+ return false;
3501
+ }
3502
+ return this.stripInjectedTaskEcho(output, injectedTaskText).includes(token);
3503
+ }
3504
+ prepareInteractiveSpawnTask(agentName, taskText) {
3505
+ if (Buffer.byteLength(taskText, 'utf8') <= WorkflowRunner.PTY_TASK_ARG_SIZE_LIMIT) {
3506
+ return {
3507
+ spawnTaskText: taskText,
3508
+ promptTaskText: taskText,
3509
+ };
3510
+ }
3511
+ const taskTmpDir = mkdtempSync(path.join(tmpdir(), 'relay-pty-task-'));
3512
+ const taskTmpFile = path.join(taskTmpDir, `${agentName}-${Date.now()}.txt`);
3513
+ writeFileSync(taskTmpFile, taskText, { encoding: 'utf8', mode: 0o600, flag: 'wx' });
3514
+ const promptTaskText = `TASK_FILE:${taskTmpFile}\n` +
3515
+ 'Read that file completely before taking any action.\n' +
3516
+ 'Treat the file contents as the full workflow task and follow them exactly.\n' +
3517
+ 'Do not ask for the task again.';
3518
+ return {
3519
+ spawnTaskText: promptTaskText,
3520
+ promptTaskText,
3521
+ taskTmpFile,
3522
+ };
3523
+ }
3454
3524
  firstMeaningfulLine(output) {
3455
3525
  return output
3456
3526
  .split('\n')
@@ -4044,6 +4114,7 @@ export class WorkflowRunner {
4044
4114
  '(b) outputting the exact text "/exit" on its own line as a fallback. ' +
4045
4115
  'Do not wait for further input — terminate immediately after finishing. ' +
4046
4116
  'Do NOT spawn sub-agents unless the task explicitly requires it.';
4117
+ const preparedTask = this.prepareInteractiveSpawnTask(agentName, taskWithExit);
4047
4118
  // Register PTY output listener before spawning so we capture everything
4048
4119
  this.ptyOutputBuffers.set(agentName, []);
4049
4120
  // Open a log file so `agents:logs <name>` works for workflow-spawned agents
@@ -4077,7 +4148,7 @@ export class WorkflowRunner {
4077
4148
  model: agentDef.constraints?.model,
4078
4149
  args: interactiveSpawnPolicy.args,
4079
4150
  channels: agentChannels,
4080
- task: taskWithExit,
4151
+ task: preparedTask.spawnTaskText,
4081
4152
  idleThresholdSecs: agentDef.constraints?.idleThresholdSecs,
4082
4153
  cwd: agentCwd,
4083
4154
  });
@@ -4159,7 +4230,7 @@ export class WorkflowRunner {
4159
4230
  // Register agent handle for hub-mediated nudging
4160
4231
  this.activeAgentHandles.set(agentName, agent);
4161
4232
  // Wait for agent to exit, with idle nudging if configured
4162
- exitResult = await this.waitForExitWithIdleNudging(agent, agentDef, step, timeoutMs, options.preserveOnIdle ?? this.shouldPreserveIdleSupervisor(agentDef, step, options.evidenceRole));
4233
+ exitResult = await this.waitForExitWithIdleNudging(agent, agentDef, step, timeoutMs, preparedTask.promptTaskText, options.preserveOnIdle ?? this.shouldPreserveIdleSupervisor(agentDef, step, options.evidenceRole));
4163
4234
  // Stop heartbeat now that agent has exited
4164
4235
  stopHeartbeat?.();
4165
4236
  if (exitResult === 'timeout') {
@@ -4169,7 +4240,7 @@ export class WorkflowRunner {
4169
4240
  let timeoutRecovered = false;
4170
4241
  if (step.verification) {
4171
4242
  const ptyOutput = (this.ptyOutputBuffers.get(agentName) ?? []).join('');
4172
- const verificationResult = this.runVerification(step.verification, ptyOutput, step.name, undefined, { allowFailure: true });
4243
+ const verificationResult = this.runVerification(step.verification, ptyOutput, step.name, preparedTask.promptTaskText, { allowFailure: true });
4173
4244
  if (verificationResult.passed) {
4174
4245
  this.log(`[${step.name}] Agent timed out but verification passed — treating as complete`);
4175
4246
  this.postToChannel(`**[${step.name}]** Agent idle after completing work — verification passed, releasing`);
@@ -4216,6 +4287,9 @@ export class WorkflowRunner {
4216
4287
  this.unregisterWorker(agentName);
4217
4288
  this.supervisedRuntimeAgents.delete(agentName);
4218
4289
  this.runtimeStepAgents.delete(agentName);
4290
+ if (preparedTask.taskTmpFile) {
4291
+ await unlink(preparedTask.taskTmpFile).catch(() => undefined);
4292
+ }
4219
4293
  }
4220
4294
  let output;
4221
4295
  if (ptyChunks.length > 0) {
@@ -4243,6 +4317,7 @@ export class WorkflowRunner {
4243
4317
  output,
4244
4318
  exitCode: agent?.exitCode,
4245
4319
  exitSignal: agent?.exitSignal,
4320
+ promptTaskText: preparedTask.promptTaskText,
4246
4321
  };
4247
4322
  }
4248
4323
  // ── Idle nudging ────────────────────────────────────────────────────────
@@ -4288,7 +4363,7 @@ export class WorkflowRunner {
4288
4363
  * Wait for agent exit with idle detection and nudging.
4289
4364
  * If no idle nudge config is set, falls through to simple waitForExit.
4290
4365
  */
4291
- async waitForExitWithIdleNudging(agent, agentDef, step, timeoutMs, preserveIdleSupervisor = false) {
4366
+ async waitForExitWithIdleNudging(agent, agentDef, step, timeoutMs, promptTaskText, preserveIdleSupervisor = false) {
4292
4367
  const nudgeConfig = this.currentConfig?.swarm.idleNudge;
4293
4368
  if (!nudgeConfig) {
4294
4369
  if (preserveIdleSupervisor) {
@@ -4309,22 +4384,10 @@ export class WorkflowRunner {
4309
4384
  ]);
4310
4385
  if (result.kind === 'idle' && result.result === 'idle') {
4311
4386
  // Check verification before treating idle as complete.
4312
- // Mirror runVerification's double-occurrence guard: if the task text
4313
- // contains the token (from the prompt instruction), require a second
4314
- // occurrence from the agent's actual output to avoid false positives.
4315
4387
  if (step.verification && step.verification.type === 'output_contains') {
4316
4388
  const token = step.verification.value;
4317
4389
  const ptyOutput = (this.ptyOutputBuffers.get(agent.name) ?? []).join('');
4318
- const taskText = step.task ?? '';
4319
- const taskHasToken = taskText.includes(token);
4320
- let verificationPassed = true;
4321
- if (taskHasToken) {
4322
- const first = ptyOutput.indexOf(token);
4323
- verificationPassed = first !== -1 && ptyOutput.includes(token, first + token.length);
4324
- }
4325
- else {
4326
- verificationPassed = ptyOutput.includes(token);
4327
- }
4390
+ const verificationPassed = this.outputContainsVerificationToken(ptyOutput, token, promptTaskText);
4328
4391
  if (!verificationPassed) {
4329
4392
  // The broker fires agent_idle only once per idle transition.
4330
4393
  // If the agent is still working (will produce output then idle again),
@@ -4504,22 +4567,8 @@ export class WorkflowRunner {
4504
4567
  };
4505
4568
  switch (check.type) {
4506
4569
  case 'output_contains': {
4507
- // Guard against false positives: the PTY captures the injected task text
4508
- // verbatim, so if the verification token appears in the task itself the
4509
- // check would pass immediately without the agent doing any real work.
4510
- // When the task contains the token, require a SECOND occurrence — one
4511
- // from the task injection and one from the agent's actual response.
4512
4570
  const token = check.value;
4513
- const taskHasToken = injectedTaskText ? injectedTaskText.includes(token) : false;
4514
- if (taskHasToken) {
4515
- const first = output.indexOf(token);
4516
- const hasSecond = first !== -1 && output.includes(token, first + token.length);
4517
- if (!hasSecond) {
4518
- return fail(`Verification failed for "${stepName}": output does not contain "${token}" ` +
4519
- `(token found only in task injection — agent must output it explicitly)`);
4520
- }
4521
- }
4522
- else if (!output.includes(token)) {
4571
+ if (!this.outputContainsVerificationToken(output, token, injectedTaskText)) {
4523
4572
  return fail(`Verification failed for "${stepName}": output does not contain "${token}"`);
4524
4573
  }
4525
4574
  break;
@@ -5096,8 +5145,15 @@ export class WorkflowRunner {
5096
5145
  .replace(/-+/g, '-')
5097
5146
  .slice(0, 32);
5098
5147
  }
5148
+ /** Validate that a runId is safe for use in file paths (no traversal). */
5149
+ validateRunId(runId) {
5150
+ if (/[/\\]|^\.\.?$/.test(runId) || runId.includes('..')) {
5151
+ throw new Error(`Invalid runId: "${runId}" contains path traversal characters`);
5152
+ }
5153
+ }
5099
5154
  /** Directory for persisted step outputs: .agent-relay/step-outputs/{runId}/ */
5100
5155
  getStepOutputDir(runId) {
5156
+ this.validateRunId(runId);
5101
5157
  return path.join(this.cwd, '.agent-relay', 'step-outputs', runId);
5102
5158
  }
5103
5159
  /** Persist step output to disk and post full output as a channel message. */
@@ -5182,6 +5238,133 @@ export class WorkflowRunner {
5182
5238
  return undefined;
5183
5239
  }
5184
5240
  }
5241
+ /** Match the best workflow from config given a set of cached step names. */
5242
+ matchWorkflowFromCache(workflows, cachedStepNames) {
5243
+ if (workflows.length === 1)
5244
+ return workflows[0];
5245
+ if (cachedStepNames.size === 0) {
5246
+ // No cached steps to disambiguate — ambiguous when multiple workflows exist
5247
+ this.log('[resume] Multiple workflows in config with empty cache — cannot disambiguate');
5248
+ return null;
5249
+ }
5250
+ // Score each workflow by how many cached steps match, excluding those with unknown steps
5251
+ const scored = workflows
5252
+ .map((candidate) => ({
5253
+ workflow: candidate,
5254
+ matchedSteps: candidate.steps.filter((step) => cachedStepNames.has(step.name)).length,
5255
+ unknownSteps: [...cachedStepNames].filter((name) => !candidate.steps.some((step) => step.name === name)).length,
5256
+ }))
5257
+ .filter((candidate) => candidate.unknownSteps === 0)
5258
+ .sort((a, b) => b.matchedSteps - a.matchedSteps);
5259
+ return scored[0]?.workflow ?? null;
5260
+ }
5261
+ reconstructRunFromCache(runId, config) {
5262
+ const stepOutputDir = this.getStepOutputDir(runId);
5263
+ if (!existsSync(stepOutputDir))
5264
+ return null;
5265
+ let resumeConfig = config ?? this.currentConfig;
5266
+ if (!resumeConfig) {
5267
+ // Attempt to load config from relay.yaml on disk (resume() may call before runWorkflowCore sets currentConfig)
5268
+ const yamlPath = path.join(this.cwd, 'relay.yaml');
5269
+ if (existsSync(yamlPath)) {
5270
+ try {
5271
+ const raw = readFileSync(yamlPath, 'utf-8');
5272
+ resumeConfig = this.parseYamlString(raw, yamlPath);
5273
+ }
5274
+ catch {
5275
+ return null;
5276
+ }
5277
+ }
5278
+ else {
5279
+ return null;
5280
+ }
5281
+ }
5282
+ let entries;
5283
+ try {
5284
+ entries = readdirSync(stepOutputDir, { withFileTypes: true });
5285
+ }
5286
+ catch {
5287
+ return null;
5288
+ }
5289
+ const cachedStepNames = new Set(entries
5290
+ .filter((entry) => entry.isFile() && entry.name.endsWith('.md'))
5291
+ .map((entry) => entry.name.slice(0, -3))
5292
+ .filter(Boolean));
5293
+ const workflows = resumeConfig.workflows ?? [];
5294
+ if (workflows.length === 0)
5295
+ return null;
5296
+ // Empty cache directory is valid — all steps will be re-run
5297
+ const workflow = this.matchWorkflowFromCache(workflows, cachedStepNames);
5298
+ if (!workflow)
5299
+ return null;
5300
+ // Use actual file modification times from cached outputs instead of synthetic timestamps
5301
+ const stepMtimes = new Map();
5302
+ let earliestMtime = Date.now();
5303
+ for (const stepName of cachedStepNames) {
5304
+ try {
5305
+ const mdPath = path.join(stepOutputDir, `${stepName}.md`);
5306
+ const reportPath = path.join(stepOutputDir, `${stepName}.report.json`);
5307
+ const mdStat = existsSync(mdPath) ? statSync(mdPath) : null;
5308
+ const reportStat = existsSync(reportPath) ? statSync(reportPath) : null;
5309
+ // Use the latest mtime between .md and .report.json
5310
+ const mtime = Math.max(mdStat?.mtimeMs ?? 0, reportStat?.mtimeMs ?? 0);
5311
+ if (mtime > 0) {
5312
+ stepMtimes.set(stepName, new Date(mtime).toISOString());
5313
+ if (mtime < earliestMtime)
5314
+ earliestMtime = mtime;
5315
+ }
5316
+ }
5317
+ catch {
5318
+ // Fall back to current time if stat fails
5319
+ }
5320
+ }
5321
+ const fallbackTime = new Date().toISOString();
5322
+ const completedSteps = new Set(workflow.steps.filter((step) => cachedStepNames.has(step.name)).map((step) => step.name));
5323
+ // Heuristic: mark the first eligible non-completed step as failed (the likely failure point)
5324
+ const failedStepName = workflow.steps.find((step) => !completedSteps.has(step.name) && (step.dependsOn ?? []).every((dep) => completedSteps.has(dep)))?.name;
5325
+ const runStartedAt = new Date(earliestMtime).toISOString();
5326
+ const run = {
5327
+ id: runId,
5328
+ workspaceId: this.workspaceId,
5329
+ workflowName: workflow.name,
5330
+ pattern: resumeConfig.swarm.pattern,
5331
+ status: 'failed',
5332
+ config: resumeConfig,
5333
+ startedAt: runStartedAt,
5334
+ createdAt: runStartedAt,
5335
+ updatedAt: fallbackTime,
5336
+ };
5337
+ const stepStates = new Map();
5338
+ for (const step of workflow.steps) {
5339
+ const isNonAgent = step.type === 'deterministic' || step.type === 'worktree' || step.type === 'integration';
5340
+ const cachedOutput = completedSteps.has(step.name) ? this.loadStepOutput(runId, step.name) : undefined;
5341
+ const status = completedSteps.has(step.name) ? 'completed' : step.name === failedStepName ? 'failed' : 'pending';
5342
+ const stepRow = {
5343
+ id: this.generateId(),
5344
+ runId,
5345
+ stepName: step.name,
5346
+ agentName: isNonAgent ? null : (step.agent ?? null),
5347
+ stepType: isNonAgent ? step.type : 'agent',
5348
+ status,
5349
+ task: step.type === 'deterministic'
5350
+ ? (step.command ?? '')
5351
+ : step.type === 'worktree'
5352
+ ? (step.branch ?? '')
5353
+ : step.type === 'integration'
5354
+ ? (`${step.integration}.${step.action}`)
5355
+ : (step.task ?? ''),
5356
+ dependsOn: step.dependsOn ?? [],
5357
+ output: cachedOutput,
5358
+ error: status === 'failed' ? 'Recovered from cached step outputs' : undefined,
5359
+ completedAt: status === 'completed' ? (stepMtimes.get(step.name) ?? fallbackTime) : undefined,
5360
+ retryCount: 0,
5361
+ createdAt: stepMtimes.get(step.name) ?? fallbackTime,
5362
+ updatedAt: stepMtimes.get(step.name) ?? fallbackTime,
5363
+ };
5364
+ stepStates.set(step.name, { row: stepRow });
5365
+ }
5366
+ return { run, stepStates };
5367
+ }
5185
5368
  /** Get or create the worker logs directory (.agent-relay/team/worker-logs) */
5186
5369
  getWorkerLogsDir() {
5187
5370
  const logsDir = path.join(this.cwd, '.agent-relay', 'team', 'worker-logs');