@agentproto/workflow-runtime 0.13.0 → 0.14.0

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/README.md CHANGED
@@ -301,9 +301,13 @@ try {
301
301
 
302
302
  This is a manual retry, not a resumable run object — `runWorkflow` has no
303
303
  notion of "the run that failed"; the cacheKey is just a namespace the caller
304
- re-supplies. A first-class `run.retry`/`run.replay` verb that resumes a named
305
- run without the caller re-threading `workflow`/`input`/`cacheKey` by hand is
306
- AIP-58 P5, not implemented here.
304
+ re-supplies. A first-class `run.retry` verb that resumes a named run without
305
+ the caller re-threading `workflow`/`input`/`cacheKey` by hand — AIP-58 P5 —
306
+ is a HOST concern, not something this transport-agnostic package implements
307
+ itself: see `@agentproto/runtime`'s `WorkflowRunner.retry()` / the
308
+ `workflow_retry` MCP tool, which owns runId allocation and an always-on
309
+ internal journal (so it works even when the original run never passed a
310
+ `cacheKey` at all — every step it runs is journaled internally either way).
307
311
 
308
312
  ### Run workspace (AIP-58 §4)
309
313
 
@@ -347,12 +351,18 @@ steps:
347
351
 
348
352
  `path` is read relative to `$run.workspace` (absolute paths, and any path
349
353
  that would resolve OUTSIDE the workspace, throw). The step hashes
350
- (`sha256`) and sizes the file, copies it to `artifactsDir/<sanitized key>`,
351
- and binds/report an `ArtifactEntry` — `{ key, path: "artifacts/<key>", sha256,
352
- size, stepId, contentType? }` (`path` here is relative to the RUN WORKSPACE
353
- ROOT, the parent of `$run.workspace` itself — not the source location).
354
- Pass `onArtifact` to `runWorkflow` to observe every one recorded, cache hit
355
- or fresh.
354
+ (`sha256`) and sizes the file, copies it to `artifactsDir/<basename of path,
355
+ sanitized>` — `path: "briefs/latest.md"` above lands at
356
+ `artifacts/latest.md`, keeping the extension, NOT the bare key
357
+ (`artifacts/brief`) — and binds/reports an `ArtifactEntry` — `{ key, path:
358
+ "artifacts/<basename>", sha256, size, stepId, contentType? }` (`path` here is
359
+ relative to the RUN WORKSPACE ROOT, the parent of `$run.workspace` itself —
360
+ not the source location; always read it back rather than assuming a name).
361
+ If two keys' files share a basename, the second one claimed is prefixed with
362
+ its own sanitized key (`artifacts/<key>-<basename>`) instead of silently
363
+ overwriting the first — deterministic, same result run to run. Pass
364
+ `onArtifact` to `runWorkflow` to observe every one recorded, cache hit or
365
+ fresh.
356
366
 
357
367
  A declarative WORKFLOW.md manifest may instead declare **`outputsFiles`**
358
368
  (AIP-16, amended by AIP-58 §4 with `required`) at the top level — checked
@@ -400,11 +410,38 @@ relocates (copies) the file from the ORIGINAL run's `artifactsDir` into the
400
410
  CURRENT run's own before returning — the two runs' workspaces stay fully
401
411
  disjoint on disk; only the *bytes* are reused, exactly the same way
402
412
  `run.replay`'s journal-sourced step reuse is specified to work (AIP-58 §6).
403
- This is a deliberate, narrower scope than "any cacheable step's output might
404
- reference a workspace file" — a plain cacheable `tool`/`agent` step whose
405
- output happens to name a path is NOT relocated automatically; route a
406
- step's file-shaped output through `kind: "artifact"` (or `outputsFiles`) to
407
- get cache/replay continuity for it.
413
+
414
+ A plain cacheable `tool`/`agent` step gets the same treatment, not a narrower
415
+ one (this used to be a documented gap — see "Cache key vs. the run
416
+ workspace" below for why it had to stop being one):
417
+
418
+ - **Hashing ignores the workspace's identity.** `hashResolvedInputs`
419
+ replaces every occurrence of `ctx.workspace` inside the serialized
420
+ resolved input/prompt (including trailing subpaths, e.g.
421
+ `<workspace>/cleaned/out.txt`) with a stable placeholder before hashing.
422
+ Two runs of the same logical step under the same `cacheKey` hash
423
+ identically even though AIP-58 §4 gives each one a fresh, disjoint
424
+ workspace directory — without this, ANY step whose input/prompt names
425
+ `$run.workspace` / `{{run.workspace}}` / `_workflowFsRoot` could never hit
426
+ the journal at all.
427
+ - **A hit relocates forward.** On a hit, every workspace-relative file/
428
+ directory the entry recorded (`StepCacheEntry.workspaceFiles` — collected,
429
+ best-effort, from every string in the step's output that resolved to a
430
+ real path under the ORIGINAL run's workspace) is copied into the matching
431
+ path under the CURRENT run's own; the recorded output's path strings are
432
+ then rewritten from the original workspace onto the current one. A
433
+ downstream step whose input reads one of those paths (`$steps.<id>.path`)
434
+ finds the bytes there, not just the (by-then-gone) original run's.
435
+ Best-effort, same posture as the `artifact` step's own relocation: a
436
+ source already swept by a host's `scratch/` retention policy is not this
437
+ run's problem to recover.
438
+ - **Still no cross-step content hashing.** This closes the *workspace-path*
439
+ gap only — the journal still hashes each step's own resolved inputs, not
440
+ a transitive hash of everything upstream. A step whose resolved input is
441
+ an opaque reference that stays textually identical across runs (a fixed
442
+ relative filename, an id) replays from cache on that basis alone,
443
+ regardless of whether the value behind that reference changed upstream —
444
+ same limitation any resolved-input-hash cache has, workspace or not.
408
445
 
409
446
  ### Step lifecycle callbacks
410
447
 
package/dist/index.d.ts CHANGED
@@ -74,12 +74,14 @@ type FanOutOutcome<T = unknown> = {
74
74
  /** `err.message` if the throw was an `Error`, else `String(err)`. */
75
75
  readonly error: string;
76
76
  } | {
77
- /** Never started: the fan-out's spawn circuit breaker opened first
78
- * (see {@link MapStep.maxConsecutiveSpawnFailures}). */
77
+ /** Never started: either the fan-out's spawn circuit breaker opened
78
+ * first (see {@link MapStep.maxConsecutiveSpawnFailures}), or the run
79
+ * was cancelled while earlier items were still in flight. */
79
80
  readonly status: "skipped";
80
81
  readonly index: number;
81
82
  readonly item: unknown;
82
- /** `circuit-open: <first error of the failure streak>`. */
83
+ /** `circuit-open: <first error of the failure streak>`, or
84
+ * `run-cancelled: run cancelled`. */
83
85
  readonly reason: string;
84
86
  };
85
87
  /**
@@ -431,7 +433,11 @@ interface AgentStep {
431
433
  interface ArtifactStep {
432
434
  kind: "artifact";
433
435
  id: string;
434
- /** Artifact key — also becomes its filename under `artifactsDir` (sanitized). */
436
+ /** Artifact key — identifies this artifact for `workflow_artifact_get`/
437
+ * `workflow_publish`. Its on-disk filename under `artifactsDir` is
438
+ * `path`'s own basename (sanitized), not the key — see {@link
439
+ * ArtifactEntry.path} — unless another key's file shares the same
440
+ * basename, in which case the key disambiguates it (F42). */
435
441
  key: Selector<string> | string;
436
442
  /** Path to the source file. Relative to `$run.workspace`; MUST resolve
437
443
  * inside it (an absolute path or a `..`-escaping relative one throws). */
@@ -440,10 +446,15 @@ interface ArtifactStep {
440
446
  }
441
447
  /**
442
448
  * AIP-58 §4/§1 `ArtifactEntry` — one run-scoped copy of a declared output
443
- * file. `path` is always `"artifacts/<sanitized key>"`, relative to the RUN
444
- * WORKSPACE ROOT (`<runsRoot>/<runId>/`, the parent of `$run.workspace`
445
- * itself) — never the original in-workspace location the file was read
446
- * from.
449
+ * file. `path` is `"artifacts/<basename>"` (F42: the declared source file's
450
+ * OWN basename, sanitized — e.g. `outputsFiles.pdf: {path: transcript.pdf}`
451
+ * ⇒ `artifacts/transcript.pdf` — keeping the extension, unlike the bare key),
452
+ * relative to the RUN WORKSPACE ROOT (`<runsRoot>/<runId>/`, the parent of
453
+ * `$run.workspace` itself) — never the original in-workspace location the
454
+ * file was read from. Two keys whose files share a basename get the SECOND
455
+ * one's name prefixed with its own sanitized key instead (deterministic,
456
+ * never a silent overwrite) — always read `path` back rather than assuming
457
+ * `artifacts/<key>` or `artifacts/<basename(path)>`.
447
458
  */
448
459
  interface ArtifactEntry {
449
460
  key: string;
@@ -516,6 +527,12 @@ type GateCommandRunner = (spec: {
516
527
  args: readonly string[];
517
528
  cwd: string;
518
529
  timeoutMs?: number;
530
+ /** The run's cancel signal — a cancelled run kills an in-flight gate
531
+ * command's child process the same way it kills an agent step's session.
532
+ * A host-injected runner should pass this through to its own subprocess
533
+ * spawn (e.g. `child_process.execFile`'s `signal` option) so the process
534
+ * is signalled, not merely orphaned. */
535
+ signal?: AbortSignal;
519
536
  }) => Promise<GateCommandResult>;
520
537
  /** One `kind: "gate"` command attempt's outcome, reported as it happens
521
538
  * (every attempt, not just the last) — the `gate-report` lifecycle event. */
@@ -691,6 +708,20 @@ interface StepCacheEntry {
691
708
  * §4 forbids two runs sharing a workspace) copies the file forward from
692
709
  * here into the new run's own `artifactsDir` instead of re-declaring it. */
693
710
  artifactsDirAtCache?: string;
711
+ /** Set only for a cacheable `tool`/`agent` step (see `hashResolvedInputs`/
712
+ * `buildCacheEntry` in `run-workflow.ts`) whenever the host wires a run
713
+ * workspace: the absolute `$run.workspace` / `_workflowFsRoot` path this
714
+ * entry was cached under. A hit in a LATER run (a different workspace —
715
+ * AIP-58 §4 forbids two runs sharing one) rewrites `output`'s path
716
+ * strings from here onto the new run's own, and relocates
717
+ * {@link workspaceFiles} the same way. */
718
+ workspaceAtCache?: string;
719
+ /** Workspace-relative files/directories (under {@link workspaceAtCache})
720
+ * this entry's `output` pointed at and that existed on disk when the
721
+ * entry was written — copied forward into a later cache-hit run's own
722
+ * workspace so a downstream step reading one of these paths finds the
723
+ * bytes there too, not just in the original (by-then-gone) run's. */
724
+ workspaceFiles?: readonly string[];
694
725
  }
695
726
  /** Opt-in journal for cacheable steps. Host-injected; file-backed in the runtime. */
696
727
  interface StepCache {
@@ -710,13 +741,16 @@ interface StepSkippedInfo {
710
741
  /** `"branch-not-taken"`: the step sits in an untaken `branch` arm.
711
742
  * `"circuit-open"`: a `map`/`pipeline` item never started because the
712
743
  * fan-out's spawn circuit breaker opened
713
- * ({@link MapStep.maxConsecutiveSpawnFailures}). */
714
- reason: "branch-not-taken" | "circuit-open";
744
+ * ({@link MapStep.maxConsecutiveSpawnFailures}). `"run-cancelled"`: a
745
+ * `map`/`pipeline` item never started because the run was cancelled
746
+ * (`RunWorkflowArgs.signal` aborted) while earlier items were still in
747
+ * flight. */
748
+ reason: "branch-not-taken" | "circuit-open" | "run-cancelled";
715
749
  /** Id of the authored step whose decision skipped the step — the `branch`
716
- * step, or (circuit-open) the `map`/`pipeline` step. */
750
+ * step, or (circuit-open/run-cancelled) the `map`/`pipeline` step. */
717
751
  branchId: string;
718
- /** `"circuit-open"` only: the first error of the spawn-failure streak
719
- * that tripped the breaker. */
752
+ /** `"circuit-open"`: the first error of the spawn-failure streak that
753
+ * tripped the breaker. `"run-cancelled"`: a fixed "run cancelled" message. */
720
754
  message?: string;
721
755
  }
722
756
  /** What `onStepFailed` reports. */
@@ -875,6 +909,18 @@ declare class AgentSpawnError extends Error {
875
909
  readonly cause: unknown;
876
910
  constructor(stepId: string, cause: unknown);
877
911
  }
912
+ /**
913
+ * Thrown instead of dispatching a step whose run was cancelled
914
+ * (`ctx.signal.aborted`) — checked BEFORE any work for the step begins (see
915
+ * {@link execStepBody}'s entry guard), so a cancel stops the run from
916
+ * starting any further step, including a sibling within the SAME stage/
917
+ * fan-out, not just a later one. Never journaled: it's thrown before a
918
+ * cacheable step's `cache.set` call, so `workflow_retry` re-executes it.
919
+ */
920
+ declare class WorkflowCancelledError extends Error {
921
+ readonly stepId: string;
922
+ constructor(stepId: string);
923
+ }
878
924
  declare function runWorkflow(args: RunWorkflowArgs): Promise<WorkflowRunResult>;
879
925
 
880
926
  /**
@@ -1157,4 +1203,4 @@ declare class NodeFsPort implements FsPort {
1157
1203
  lock(): Promise<FsLockHandle>;
1158
1204
  }
1159
1205
 
1160
- export { type AgentHarness, AgentInputRequiredError, type AgentRefResolution, type AgentSandboxRef, type AgentSessionHost, AgentSpawnError, type AgentStep, type AgentStepFields, type ApprovalDecision, type ApprovalRequest, type ApprovalStep, type ArtifactEntry, type ArtifactStep, type Bindings, type BranchStep, type CompileWorkflowOptions, DEFAULT_MAX_CONSECUTIVE_SPAWN_FAILURES, type FanOutOutcome, type GateCommandResult, type GateCommandRunner, type GateReportEvent, type GateStep, type GroupStep, type HarnessKnowledgeSelector, type InputRequiredRequest, type KnowledgeAppliedRecord, type LoopStep, type MapStep, type MaterializedKnowledge, MissingArtifactError, NodeFsPort, type OutputSchemaLike, type OutputsFileContract, type ParallelStep, type PipelineStep, type ResumeRequest, type RunStep, type RunWorkflowArgs, type RuntimeWorkflow, type SchemaValidationIssue, type Selector, type StepCache, type StepCacheEntry, type StepFailedInfo, type StepHookInfo, StepOutcomeError, type StepSkippedInfo, type SubworkflowStep, type SuspendStep, type TolerantFanOutResult, type ToolStep, type TransformStep, WorkflowCompileError, type WorkflowInputValidation, type WorkflowRunResult, WorkflowSuspendedError, buildAgentStep, compileWorkflow, compileWorkflowManifest, evalPredicate, interpolateTemplate, isCompilableJsonSchema, materializeKnowledge, normalizeWorkflowInputsSchema, resolveKnowledgeSelectors, resolveRef, resolveRefPrefixed, resolveValue, runWorkflow, validateAgainstJsonSchema, validateWorkflowInput };
1206
+ export { type AgentHarness, AgentInputRequiredError, type AgentRefResolution, type AgentSandboxRef, type AgentSessionHost, AgentSpawnError, type AgentStep, type AgentStepFields, type ApprovalDecision, type ApprovalRequest, type ApprovalStep, type ArtifactEntry, type ArtifactStep, type Bindings, type BranchStep, type CompileWorkflowOptions, DEFAULT_MAX_CONSECUTIVE_SPAWN_FAILURES, type FanOutOutcome, type GateCommandResult, type GateCommandRunner, type GateReportEvent, type GateStep, type GroupStep, type HarnessKnowledgeSelector, type InputRequiredRequest, type KnowledgeAppliedRecord, type LoopStep, type MapStep, type MaterializedKnowledge, MissingArtifactError, NodeFsPort, type OutputSchemaLike, type OutputsFileContract, type ParallelStep, type PipelineStep, type ResumeRequest, type RunStep, type RunWorkflowArgs, type RuntimeWorkflow, type SchemaValidationIssue, type Selector, type StepCache, type StepCacheEntry, type StepFailedInfo, type StepHookInfo, StepOutcomeError, type StepSkippedInfo, type SubworkflowStep, type SuspendStep, type TolerantFanOutResult, type ToolStep, type TransformStep, WorkflowCancelledError, WorkflowCompileError, type WorkflowInputValidation, type WorkflowRunResult, WorkflowSuspendedError, buildAgentStep, compileWorkflow, compileWorkflowManifest, evalPredicate, interpolateTemplate, isCompilableJsonSchema, materializeKnowledge, normalizeWorkflowInputsSchema, resolveKnowledgeSelectors, resolveRef, resolveRefPrefixed, resolveValue, runWorkflow, validateAgainstJsonSchema, validateWorkflowInput };
package/dist/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { runTool } from '@agentproto/driver';
2
2
  import { createHash } from 'crypto';
3
3
  import { execFile } from 'child_process';
4
- import { stat, readFile, mkdir, writeFile, appendFile, readdir, copyFile } from 'fs/promises';
4
+ import { stat, readFile, mkdir, writeFile, appendFile, readdir, copyFile, cp } from 'fs/promises';
5
5
  import { join, dirname, isAbsolute, basename, resolve, relative } from 'path';
6
6
  import { z } from 'zod';
7
7
  import { assertKnownStepRefs } from '@agentproto/workflow';
@@ -1014,6 +1014,14 @@ var AgentSpawnError = class extends Error {
1014
1014
  stepId;
1015
1015
  cause;
1016
1016
  };
1017
+ var WorkflowCancelledError = class extends Error {
1018
+ constructor(stepId) {
1019
+ super(`step '${stepId}': run cancelled \u2014 not started`);
1020
+ this.stepId = stepId;
1021
+ this.name = "WorkflowCancelledError";
1022
+ }
1023
+ stepId;
1024
+ };
1017
1025
  function withReleaseScope(ctx) {
1018
1026
  return { ...ctx, spawned: [] };
1019
1027
  }
@@ -1079,9 +1087,9 @@ function spawnBreaker(threshold) {
1079
1087
  }
1080
1088
  };
1081
1089
  }
1082
- function skipUnstartedItems(ctx, fanOutId, from, items, bodiesOf, reason, results) {
1090
+ function skipUnstartedItems(ctx, fanOutId, from, items, bodiesOf, kind, reason, results) {
1083
1091
  for (let idx = from; idx < items.length; idx++) {
1084
- results[idx] = { status: "skipped", index: idx, item: items[idx], reason: `circuit-open: ${reason}` };
1092
+ results[idx] = { status: "skipped", index: idx, item: items[idx], reason: `${kind}: ${reason}` };
1085
1093
  const itemCtx = withIndexedHooks(ctx, idx);
1086
1094
  if (!itemCtx.onStepSkipped) continue;
1087
1095
  let bodies;
@@ -1091,7 +1099,7 @@ function skipUnstartedItems(ctx, fanOutId, from, items, bodiesOf, reason, result
1091
1099
  continue;
1092
1100
  }
1093
1101
  for (const id of new Set(skippableStepIds(bodies))) {
1094
- itemCtx.onStepSkipped(id, { reason: "circuit-open", branchId: fanOutId, message: reason });
1102
+ itemCtx.onStepSkipped(id, { reason: kind, branchId: fanOutId, message: reason });
1095
1103
  }
1096
1104
  }
1097
1105
  }
@@ -1131,8 +1139,74 @@ function spentUsd(state) {
1131
1139
  for (const c of state.costBySession.values()) total += c;
1132
1140
  return total;
1133
1141
  }
1134
- function hashResolvedInputs(kind, resolved) {
1135
- return createHash("sha256").update(`${kind}\0${JSON.stringify(resolved) ?? "undefined"}`).digest("hex");
1142
+ var WORKSPACE_HASH_PLACEHOLDER = "\0$run.workspace\0";
1143
+ function hashResolvedInputs(kind, resolved, workspace) {
1144
+ const serialized = `${kind}\0${JSON.stringify(resolved) ?? "undefined"}`;
1145
+ const normalized = workspace ? serialized.split(workspace).join(WORKSPACE_HASH_PLACEHOLDER) : serialized;
1146
+ return createHash("sha256").update(normalized).digest("hex");
1147
+ }
1148
+ function rewriteWorkspacePaths(value, from, to) {
1149
+ if (typeof value === "string") return value.split(from).join(to);
1150
+ if (Array.isArray(value)) return value.map((v) => rewriteWorkspacePaths(v, from, to));
1151
+ if (value !== null && typeof value === "object" && value.constructor === Object) {
1152
+ const out = {};
1153
+ for (const [k, v] of Object.entries(value)) out[k] = rewriteWorkspacePaths(v, from, to);
1154
+ return out;
1155
+ }
1156
+ return value;
1157
+ }
1158
+ function collectWorkspaceCandidates(value, workspace, out) {
1159
+ if (typeof value === "string") {
1160
+ if (value === workspace || value.startsWith(`${workspace}/`)) {
1161
+ const rel = relative(workspace, value);
1162
+ if (rel !== "" && !rel.startsWith("..") && !isAbsolute(rel)) out.add(rel);
1163
+ }
1164
+ return;
1165
+ }
1166
+ if (Array.isArray(value)) {
1167
+ for (const v of value) collectWorkspaceCandidates(v, workspace, out);
1168
+ return;
1169
+ }
1170
+ if (value !== null && typeof value === "object" && value.constructor === Object) {
1171
+ for (const v of Object.values(value)) collectWorkspaceCandidates(v, workspace, out);
1172
+ }
1173
+ }
1174
+ async function collectWorkspaceFiles(out, workspace) {
1175
+ const candidates = /* @__PURE__ */ new Set();
1176
+ collectWorkspaceCandidates(out, workspace, candidates);
1177
+ const files = [];
1178
+ for (const rel of candidates) {
1179
+ try {
1180
+ await stat(join(workspace, rel));
1181
+ files.push(rel);
1182
+ } catch {
1183
+ }
1184
+ }
1185
+ return files;
1186
+ }
1187
+ async function buildCacheEntry(ctx, out, hash) {
1188
+ if (ctx.workspace === void 0) return { output: out, resolvedInputHash: hash };
1189
+ const workspaceFiles = await collectWorkspaceFiles(out, ctx.workspace);
1190
+ return {
1191
+ output: out,
1192
+ resolvedInputHash: hash,
1193
+ workspaceAtCache: ctx.workspace,
1194
+ ...workspaceFiles.length > 0 ? { workspaceFiles } : {}
1195
+ };
1196
+ }
1197
+ async function relocateCachedOutput(ctx, entry) {
1198
+ const from = entry.workspaceAtCache;
1199
+ const to = ctx.workspace;
1200
+ if (from === void 0 || to === void 0 || from === to) return entry.output;
1201
+ for (const rel of entry.workspaceFiles ?? []) {
1202
+ const dest = join(to, rel);
1203
+ try {
1204
+ await mkdir(dirname(dest), { recursive: true });
1205
+ await cp(join(from, rel), dest, { recursive: true });
1206
+ } catch {
1207
+ }
1208
+ }
1209
+ return rewriteWorkspacePaths(entry.output, from, to);
1136
1210
  }
1137
1211
  function stepJournalKey(ctx, step) {
1138
1212
  return `${ctx.cacheKey}\0${step.id}\0${step.kind}${ctx.cacheKeySuffix ?? ""}`;
@@ -1149,10 +1223,10 @@ function completeStep(ctx, stepId, out) {
1149
1223
  }
1150
1224
  async function readStepCache(ctx, step, resolvedInputs) {
1151
1225
  const key = stepJournalKey(ctx, step);
1152
- const hash = hashResolvedInputs(step.kind, resolvedInputs);
1226
+ const hash = hashResolvedInputs(step.kind, resolvedInputs, ctx.workspace);
1153
1227
  const entry = await ctx.cache.get(key);
1154
1228
  if (entry !== void 0 && entry.resolvedInputHash === hash) {
1155
- return { hit: true, output: entry.output };
1229
+ return { hit: true, output: await relocateCachedOutput(ctx, entry) };
1156
1230
  }
1157
1231
  return { hit: false, key, hash };
1158
1232
  }
@@ -1267,7 +1341,7 @@ ${inner(b2)}` };
1267
1341
  } : void 0;
1268
1342
  let sessionId;
1269
1343
  if (step.adapter) {
1270
- if (ctx.signal?.aborted) throw new Error(`step '${step.id}': run cancelled \u2014 not spawning`);
1344
+ if (ctx.signal?.aborted) throw new WorkflowCancelledError(step.id);
1271
1345
  try {
1272
1346
  sessionId = await ctx.agents.spawn(resolveSel(step.adapter, b), {
1273
1347
  cwd,
@@ -1379,7 +1453,7 @@ function defaultRunGateCommand(spec) {
1379
1453
  execFile(
1380
1454
  spec.command,
1381
1455
  [...spec.args],
1382
- { cwd: spec.cwd, timeout: spec.timeoutMs, maxBuffer: 10 * 1024 * 1024 },
1456
+ { cwd: spec.cwd, timeout: spec.timeoutMs, maxBuffer: 10 * 1024 * 1024, signal: spec.signal },
1383
1457
  (err, stdout, stderr) => {
1384
1458
  if (!err) {
1385
1459
  resolve2({ exitCode: 0, stdout, stderr });
@@ -1419,6 +1493,7 @@ async function execGateStep(step, ctx, b) {
1419
1493
  const maxAttempts = Math.max(1, step.retry?.maxAttempts ?? 1);
1420
1494
  let last;
1421
1495
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1496
+ if (ctx.signal?.aborted) throw new WorkflowCancelledError(step.id);
1422
1497
  if (attempt > 1 && step.onFail?.reprompt) {
1423
1498
  if (!ctx.agents) {
1424
1499
  throw new Error(`step '${step.id}': on_fail.reprompt requires a host agents implementation`);
@@ -1450,8 +1525,10 @@ Please address the findings above, then reply when done.`
1450
1525
  command: step.command,
1451
1526
  args,
1452
1527
  cwd,
1453
- timeoutMs: step.timeoutMs
1528
+ timeoutMs: step.timeoutMs,
1529
+ signal: ctx.signal
1454
1530
  });
1531
+ if (ctx.signal?.aborted) throw new WorkflowCancelledError(step.id);
1455
1532
  const report = await resolveGateReport(cmdResult, cwd, step.reportPath);
1456
1533
  const ok = cmdResult.exitCode === 0;
1457
1534
  last = { ok, exitCode: cmdResult.exitCode, report };
@@ -1485,6 +1562,7 @@ async function execStep(step, ctx, item, index) {
1485
1562
  }
1486
1563
  async function execStepBody(step, ctx, item, index) {
1487
1564
  const { state, signal } = ctx;
1565
+ if (signal?.aborted) throw new WorkflowCancelledError(step.id);
1488
1566
  const b = view(ctx, item, index);
1489
1567
  if (step.kind !== "agent" && step.kind !== "tool" && step.kind !== "artifact") {
1490
1568
  ctx.onStepStart?.(step.id);
@@ -1509,7 +1587,7 @@ async function execStepBody(step, ctx, item, index) {
1509
1587
  if (c.hit) return cacheHit(ctx, step, c.output);
1510
1588
  ctx.onStepStart?.(step.id);
1511
1589
  const out = await runIt();
1512
- await ctx.cache.set(c.key, { output: out, resolvedInputHash: c.hash });
1590
+ await ctx.cache.set(c.key, await buildCacheEntry(ctx, out, c.hash));
1513
1591
  return out;
1514
1592
  }
1515
1593
  case "transform":
@@ -1544,15 +1622,20 @@ async function execStepBody(step, ctx, item, index) {
1544
1622
  }
1545
1623
  };
1546
1624
  const worker = async () => {
1547
- while (!failed && breaker.open === void 0 && next < arr.length) {
1625
+ while (!failed && breaker.open === void 0 && signal?.aborted !== true && next < arr.length) {
1548
1626
  const idx = next++;
1549
1627
  await runItem(idx);
1550
1628
  }
1551
1629
  };
1552
1630
  await Promise.all(Array.from({ length: Math.min(parallelism, arr.length) }, () => worker()));
1631
+ if (signal?.aborted === true && next < arr.length) {
1632
+ if (!tolerant) throw new WorkflowCancelledError(step.id);
1633
+ skipUnstartedItems(ctx, step.id, next, arr, (el, idx) => [step.body(el, idx, view(ctx, el, idx))], "run-cancelled", "run cancelled", results);
1634
+ return tolerantResult(results, breaker.open);
1635
+ }
1553
1636
  if (!tolerant) return results;
1554
1637
  if (breaker.open !== void 0) {
1555
- skipUnstartedItems(ctx, step.id, next, arr, (el, idx) => [step.body(el, idx, view(ctx, el, idx))], breaker.open, results);
1638
+ skipUnstartedItems(ctx, step.id, next, arr, (el, idx) => [step.body(el, idx, view(ctx, el, idx))], "circuit-open", breaker.open, results);
1556
1639
  }
1557
1640
  return tolerantResult(results, breaker.open);
1558
1641
  }
@@ -1587,12 +1670,26 @@ async function execStepBody(step, ctx, item, index) {
1587
1670
  }
1588
1671
  };
1589
1672
  const worker = async () => {
1590
- while ((!tolerant || breaker.open === void 0) && next < items.length) {
1673
+ while ((!tolerant || breaker.open === void 0) && signal?.aborted !== true && next < items.length) {
1591
1674
  const idx = next++;
1592
1675
  await runItem(idx);
1593
1676
  }
1594
1677
  };
1595
1678
  await Promise.all(Array.from({ length: Math.min(cap, items.length) }, () => worker()));
1679
+ if (signal?.aborted === true && next < items.length) {
1680
+ if (!tolerant) throw new WorkflowCancelledError(step.id);
1681
+ skipUnstartedItems(
1682
+ ctx,
1683
+ step.id,
1684
+ next,
1685
+ items,
1686
+ (el, idx) => step.stages.map((stage) => stage(el, idx, void 0, view(ctx, el, idx))),
1687
+ "run-cancelled",
1688
+ "run cancelled",
1689
+ results
1690
+ );
1691
+ return tolerantResult(results, breaker.open);
1692
+ }
1596
1693
  if (!tolerant) return results;
1597
1694
  if (breaker.open !== void 0) {
1598
1695
  skipUnstartedItems(
@@ -1601,6 +1698,7 @@ async function execStepBody(step, ctx, item, index) {
1601
1698
  next,
1602
1699
  items,
1603
1700
  (el, idx) => step.stages.map((stage) => stage(el, idx, void 0, view(ctx, el, idx))),
1701
+ "circuit-open",
1604
1702
  breaker.open,
1605
1703
  results
1606
1704
  );
@@ -1681,7 +1779,8 @@ async function execStepBody(step, ctx, item, index) {
1681
1779
  cacheKey: ctx.cacheKey,
1682
1780
  runGateCommand: ctx.runGateCommand,
1683
1781
  onGateReport: ctx.onGateReport,
1684
- spawned: ctx.spawned
1782
+ spawned: ctx.spawned,
1783
+ usedArtifactNames: ctx.usedArtifactNames
1685
1784
  });
1686
1785
  return child.output;
1687
1786
  }
@@ -1697,7 +1796,7 @@ async function execStepBody(step, ctx, item, index) {
1697
1796
  const c = await readStepCache(ctx, step, resolved);
1698
1797
  if (c.hit) return cacheHit(ctx, step, c.output);
1699
1798
  const out = await execAgentStep(step, ctx, b);
1700
- await ctx.cache.set(c.key, { output: out, resolvedInputHash: c.hash });
1799
+ await ctx.cache.set(c.key, await buildCacheEntry(ctx, out, c.hash));
1701
1800
  return out;
1702
1801
  }
1703
1802
  case "gate":
@@ -1706,11 +1805,24 @@ async function execStepBody(step, ctx, item, index) {
1706
1805
  return execArtifactStep(step, ctx, b);
1707
1806
  }
1708
1807
  }
1709
- function sanitizeArtifactKey(key) {
1710
- const cleaned = key.replace(/[^a-zA-Z0-9._-]/g, "_");
1808
+ function sanitizeArtifactFilename(name) {
1809
+ const cleaned = name.replace(/[^a-zA-Z0-9._-]/g, "_");
1711
1810
  return cleaned.length > 0 ? cleaned : "artifact";
1712
1811
  }
1713
- async function copyIntoArtifacts(stepId, key, rawPath, workspace, artifactsDir, contentType) {
1812
+ function reserveArtifactDestName(rawPath, key, usedNames) {
1813
+ const base = sanitizeArtifactFilename(basename(rawPath));
1814
+ let candidate = base;
1815
+ if (usedNames.has(candidate)) {
1816
+ const keyPart = sanitizeArtifactFilename(key);
1817
+ candidate = `${keyPart}-${base}`;
1818
+ for (let n = 2; usedNames.has(candidate); n++) {
1819
+ candidate = `${keyPart}-${n}-${base}`;
1820
+ }
1821
+ }
1822
+ usedNames.add(candidate);
1823
+ return candidate;
1824
+ }
1825
+ async function copyIntoArtifacts(stepId, key, rawPath, workspace, artifactsDir, contentType, usedNames) {
1714
1826
  const abs = isAbsolute(rawPath) ? resolve(rawPath) : resolve(workspace, rawPath);
1715
1827
  const rel = relative(workspace, abs);
1716
1828
  if (rel.startsWith("..") || isAbsolute(rel)) {
@@ -1718,7 +1830,7 @@ async function copyIntoArtifacts(stepId, key, rawPath, workspace, artifactsDir,
1718
1830
  }
1719
1831
  const buf = await readFile(abs);
1720
1832
  const sha256 = createHash("sha256").update(buf).digest("hex");
1721
- const destName = sanitizeArtifactKey(key);
1833
+ const destName = reserveArtifactDestName(rawPath, key, usedNames);
1722
1834
  await mkdir(artifactsDir, { recursive: true });
1723
1835
  await writeFile(join(artifactsDir, destName), buf);
1724
1836
  return {
@@ -1742,12 +1854,13 @@ async function execArtifactStep(step, ctx, b) {
1742
1854
  const resolvedInputs = { key, path: rawPath };
1743
1855
  const cacheOn = ctx.cache !== void 0 && ctx.cacheKey !== void 0;
1744
1856
  const journalKey = cacheOn ? stepJournalKey(ctx, step) : void 0;
1745
- const hash = cacheOn ? hashResolvedInputs(step.kind, resolvedInputs) : void 0;
1857
+ const hash = cacheOn ? hashResolvedInputs(step.kind, resolvedInputs, ctx.workspace) : void 0;
1746
1858
  if (cacheOn) {
1747
1859
  const entry = await ctx.cache.get(journalKey);
1748
1860
  if (entry !== void 0 && entry.resolvedInputHash === hash) {
1749
1861
  const out2 = entry.output;
1750
- const destName = sanitizeArtifactKey(key);
1862
+ const destName = out2.path.slice("artifacts/".length);
1863
+ ctx.usedArtifactNames?.add(destName);
1751
1864
  const destAbs = join(artifactsDir, destName);
1752
1865
  const srcAbs = join(entry.artifactsDirAtCache ?? artifactsDir, destName);
1753
1866
  if (srcAbs !== destAbs) {
@@ -1762,7 +1875,7 @@ async function execArtifactStep(step, ctx, b) {
1762
1875
  }
1763
1876
  }
1764
1877
  ctx.onStepStart?.(step.id);
1765
- const out = await copyIntoArtifacts(step.id, key, rawPath, workspace, artifactsDir, contentType);
1878
+ const out = await copyIntoArtifacts(step.id, key, rawPath, workspace, artifactsDir, contentType, ctx.usedArtifactNames);
1766
1879
  if (cacheOn) {
1767
1880
  await ctx.cache.set(journalKey, { output: out, resolvedInputHash: hash, artifactsDirAtCache: artifactsDir });
1768
1881
  }
@@ -1780,7 +1893,7 @@ async function checkOutputsFiles(outputsFiles, ctx, workflowId, lastStepId) {
1780
1893
  const abs = isAbsolute(rawPath) ? rawPath : resolve(ctx.workspace, rawPath);
1781
1894
  let out;
1782
1895
  try {
1783
- out = await copyIntoArtifacts(lastStepId ?? workflowId, key, abs, ctx.workspace, ctx.artifactsDir, contract.contentType);
1896
+ out = await copyIntoArtifacts(lastStepId ?? workflowId, key, abs, ctx.workspace, ctx.artifactsDir, contract.contentType, ctx.usedArtifactNames);
1784
1897
  } catch {
1785
1898
  if (contract.required === true) {
1786
1899
  throw new MissingArtifactError(key, lastStepId);
@@ -1811,7 +1924,11 @@ async function runFinally(steps, ctx, bodyFailed) {
1811
1924
  async function runWorkflowInner(workflow, input, hooks, maxTotalCostUsd) {
1812
1925
  const state = { input, steps: {}, costBySession: /* @__PURE__ */ new Map(), maxTotalCostUsd, cachedHits: /* @__PURE__ */ new Set() };
1813
1926
  const ownsScope = hooks.spawned === void 0;
1814
- const ctx = { state, ...hooks, ...ownsScope ? { spawned: [] } : {} };
1927
+ const ctx = {
1928
+ state,
1929
+ ...hooks,
1930
+ ...ownsScope ? { spawned: [], usedArtifactNames: /* @__PURE__ */ new Set() } : {}
1931
+ };
1815
1932
  let lastId;
1816
1933
  let bodyFailed = false;
1817
1934
  try {
@@ -1865,6 +1982,6 @@ function compileWorkflowManifest(source, opts) {
1865
1982
  return compileWorkflow(handle, opts);
1866
1983
  }
1867
1984
 
1868
- export { AgentInputRequiredError, AgentSpawnError, DEFAULT_MAX_CONSECUTIVE_SPAWN_FAILURES, MissingArtifactError, NodeFsPort, StepOutcomeError, WorkflowCompileError, WorkflowSuspendedError, buildAgentStep, compileWorkflow, compileWorkflowManifest, evalPredicate, interpolateTemplate, isCompilableJsonSchema, materializeKnowledge, normalizeWorkflowInputsSchema, resolveKnowledgeSelectors, resolveRef, resolveRefPrefixed, resolveValue, runWorkflow, validateAgainstJsonSchema, validateWorkflowInput };
1985
+ export { AgentInputRequiredError, AgentSpawnError, DEFAULT_MAX_CONSECUTIVE_SPAWN_FAILURES, MissingArtifactError, NodeFsPort, StepOutcomeError, WorkflowCancelledError, WorkflowCompileError, WorkflowSuspendedError, buildAgentStep, compileWorkflow, compileWorkflowManifest, evalPredicate, interpolateTemplate, isCompilableJsonSchema, materializeKnowledge, normalizeWorkflowInputsSchema, resolveKnowledgeSelectors, resolveRef, resolveRefPrefixed, resolveValue, runWorkflow, validateAgainstJsonSchema, validateWorkflowInput };
1869
1986
  //# sourceMappingURL=index.mjs.map
1870
1987
  //# sourceMappingURL=index.mjs.map