@agentproto/workflow-runtime 0.13.0 → 0.13.1

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
@@ -431,7 +431,11 @@ interface AgentStep {
431
431
  interface ArtifactStep {
432
432
  kind: "artifact";
433
433
  id: string;
434
- /** Artifact key — also becomes its filename under `artifactsDir` (sanitized). */
434
+ /** Artifact key — identifies this artifact for `workflow_artifact_get`/
435
+ * `workflow_publish`. Its on-disk filename under `artifactsDir` is
436
+ * `path`'s own basename (sanitized), not the key — see {@link
437
+ * ArtifactEntry.path} — unless another key's file shares the same
438
+ * basename, in which case the key disambiguates it (F42). */
435
439
  key: Selector<string> | string;
436
440
  /** Path to the source file. Relative to `$run.workspace`; MUST resolve
437
441
  * inside it (an absolute path or a `..`-escaping relative one throws). */
@@ -440,10 +444,15 @@ interface ArtifactStep {
440
444
  }
441
445
  /**
442
446
  * 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.
447
+ * file. `path` is `"artifacts/<basename>"` (F42: the declared source file's
448
+ * OWN basename, sanitized — e.g. `outputsFiles.pdf: {path: transcript.pdf}`
449
+ * ⇒ `artifacts/transcript.pdf` — keeping the extension, unlike the bare key),
450
+ * relative to the RUN WORKSPACE ROOT (`<runsRoot>/<runId>/`, the parent of
451
+ * `$run.workspace` itself) — never the original in-workspace location the
452
+ * file was read from. Two keys whose files share a basename get the SECOND
453
+ * one's name prefixed with its own sanitized key instead (deterministic,
454
+ * never a silent overwrite) — always read `path` back rather than assuming
455
+ * `artifacts/<key>` or `artifacts/<basename(path)>`.
447
456
  */
448
457
  interface ArtifactEntry {
449
458
  key: string;
@@ -691,6 +700,20 @@ interface StepCacheEntry {
691
700
  * §4 forbids two runs sharing a workspace) copies the file forward from
692
701
  * here into the new run's own `artifactsDir` instead of re-declaring it. */
693
702
  artifactsDirAtCache?: string;
703
+ /** Set only for a cacheable `tool`/`agent` step (see `hashResolvedInputs`/
704
+ * `buildCacheEntry` in `run-workflow.ts`) whenever the host wires a run
705
+ * workspace: the absolute `$run.workspace` / `_workflowFsRoot` path this
706
+ * entry was cached under. A hit in a LATER run (a different workspace —
707
+ * AIP-58 §4 forbids two runs sharing one) rewrites `output`'s path
708
+ * strings from here onto the new run's own, and relocates
709
+ * {@link workspaceFiles} the same way. */
710
+ workspaceAtCache?: string;
711
+ /** Workspace-relative files/directories (under {@link workspaceAtCache})
712
+ * this entry's `output` pointed at and that existed on disk when the
713
+ * entry was written — copied forward into a later cache-hit run's own
714
+ * workspace so a downstream step reading one of these paths finds the
715
+ * bytes there too, not just in the original (by-then-gone) run's. */
716
+ workspaceFiles?: readonly string[];
694
717
  }
695
718
  /** Opt-in journal for cacheable steps. Host-injected; file-backed in the runtime. */
696
719
  interface StepCache {
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';
@@ -1131,8 +1131,74 @@ function spentUsd(state) {
1131
1131
  for (const c of state.costBySession.values()) total += c;
1132
1132
  return total;
1133
1133
  }
1134
- function hashResolvedInputs(kind, resolved) {
1135
- return createHash("sha256").update(`${kind}\0${JSON.stringify(resolved) ?? "undefined"}`).digest("hex");
1134
+ var WORKSPACE_HASH_PLACEHOLDER = "\0$run.workspace\0";
1135
+ function hashResolvedInputs(kind, resolved, workspace) {
1136
+ const serialized = `${kind}\0${JSON.stringify(resolved) ?? "undefined"}`;
1137
+ const normalized = workspace ? serialized.split(workspace).join(WORKSPACE_HASH_PLACEHOLDER) : serialized;
1138
+ return createHash("sha256").update(normalized).digest("hex");
1139
+ }
1140
+ function rewriteWorkspacePaths(value, from, to) {
1141
+ if (typeof value === "string") return value.split(from).join(to);
1142
+ if (Array.isArray(value)) return value.map((v) => rewriteWorkspacePaths(v, from, to));
1143
+ if (value !== null && typeof value === "object" && value.constructor === Object) {
1144
+ const out = {};
1145
+ for (const [k, v] of Object.entries(value)) out[k] = rewriteWorkspacePaths(v, from, to);
1146
+ return out;
1147
+ }
1148
+ return value;
1149
+ }
1150
+ function collectWorkspaceCandidates(value, workspace, out) {
1151
+ if (typeof value === "string") {
1152
+ if (value === workspace || value.startsWith(`${workspace}/`)) {
1153
+ const rel = relative(workspace, value);
1154
+ if (rel !== "" && !rel.startsWith("..") && !isAbsolute(rel)) out.add(rel);
1155
+ }
1156
+ return;
1157
+ }
1158
+ if (Array.isArray(value)) {
1159
+ for (const v of value) collectWorkspaceCandidates(v, workspace, out);
1160
+ return;
1161
+ }
1162
+ if (value !== null && typeof value === "object" && value.constructor === Object) {
1163
+ for (const v of Object.values(value)) collectWorkspaceCandidates(v, workspace, out);
1164
+ }
1165
+ }
1166
+ async function collectWorkspaceFiles(out, workspace) {
1167
+ const candidates = /* @__PURE__ */ new Set();
1168
+ collectWorkspaceCandidates(out, workspace, candidates);
1169
+ const files = [];
1170
+ for (const rel of candidates) {
1171
+ try {
1172
+ await stat(join(workspace, rel));
1173
+ files.push(rel);
1174
+ } catch {
1175
+ }
1176
+ }
1177
+ return files;
1178
+ }
1179
+ async function buildCacheEntry(ctx, out, hash) {
1180
+ if (ctx.workspace === void 0) return { output: out, resolvedInputHash: hash };
1181
+ const workspaceFiles = await collectWorkspaceFiles(out, ctx.workspace);
1182
+ return {
1183
+ output: out,
1184
+ resolvedInputHash: hash,
1185
+ workspaceAtCache: ctx.workspace,
1186
+ ...workspaceFiles.length > 0 ? { workspaceFiles } : {}
1187
+ };
1188
+ }
1189
+ async function relocateCachedOutput(ctx, entry) {
1190
+ const from = entry.workspaceAtCache;
1191
+ const to = ctx.workspace;
1192
+ if (from === void 0 || to === void 0 || from === to) return entry.output;
1193
+ for (const rel of entry.workspaceFiles ?? []) {
1194
+ const dest = join(to, rel);
1195
+ try {
1196
+ await mkdir(dirname(dest), { recursive: true });
1197
+ await cp(join(from, rel), dest, { recursive: true });
1198
+ } catch {
1199
+ }
1200
+ }
1201
+ return rewriteWorkspacePaths(entry.output, from, to);
1136
1202
  }
1137
1203
  function stepJournalKey(ctx, step) {
1138
1204
  return `${ctx.cacheKey}\0${step.id}\0${step.kind}${ctx.cacheKeySuffix ?? ""}`;
@@ -1149,10 +1215,10 @@ function completeStep(ctx, stepId, out) {
1149
1215
  }
1150
1216
  async function readStepCache(ctx, step, resolvedInputs) {
1151
1217
  const key = stepJournalKey(ctx, step);
1152
- const hash = hashResolvedInputs(step.kind, resolvedInputs);
1218
+ const hash = hashResolvedInputs(step.kind, resolvedInputs, ctx.workspace);
1153
1219
  const entry = await ctx.cache.get(key);
1154
1220
  if (entry !== void 0 && entry.resolvedInputHash === hash) {
1155
- return { hit: true, output: entry.output };
1221
+ return { hit: true, output: await relocateCachedOutput(ctx, entry) };
1156
1222
  }
1157
1223
  return { hit: false, key, hash };
1158
1224
  }
@@ -1509,7 +1575,7 @@ async function execStepBody(step, ctx, item, index) {
1509
1575
  if (c.hit) return cacheHit(ctx, step, c.output);
1510
1576
  ctx.onStepStart?.(step.id);
1511
1577
  const out = await runIt();
1512
- await ctx.cache.set(c.key, { output: out, resolvedInputHash: c.hash });
1578
+ await ctx.cache.set(c.key, await buildCacheEntry(ctx, out, c.hash));
1513
1579
  return out;
1514
1580
  }
1515
1581
  case "transform":
@@ -1681,7 +1747,8 @@ async function execStepBody(step, ctx, item, index) {
1681
1747
  cacheKey: ctx.cacheKey,
1682
1748
  runGateCommand: ctx.runGateCommand,
1683
1749
  onGateReport: ctx.onGateReport,
1684
- spawned: ctx.spawned
1750
+ spawned: ctx.spawned,
1751
+ usedArtifactNames: ctx.usedArtifactNames
1685
1752
  });
1686
1753
  return child.output;
1687
1754
  }
@@ -1697,7 +1764,7 @@ async function execStepBody(step, ctx, item, index) {
1697
1764
  const c = await readStepCache(ctx, step, resolved);
1698
1765
  if (c.hit) return cacheHit(ctx, step, c.output);
1699
1766
  const out = await execAgentStep(step, ctx, b);
1700
- await ctx.cache.set(c.key, { output: out, resolvedInputHash: c.hash });
1767
+ await ctx.cache.set(c.key, await buildCacheEntry(ctx, out, c.hash));
1701
1768
  return out;
1702
1769
  }
1703
1770
  case "gate":
@@ -1706,11 +1773,24 @@ async function execStepBody(step, ctx, item, index) {
1706
1773
  return execArtifactStep(step, ctx, b);
1707
1774
  }
1708
1775
  }
1709
- function sanitizeArtifactKey(key) {
1710
- const cleaned = key.replace(/[^a-zA-Z0-9._-]/g, "_");
1776
+ function sanitizeArtifactFilename(name) {
1777
+ const cleaned = name.replace(/[^a-zA-Z0-9._-]/g, "_");
1711
1778
  return cleaned.length > 0 ? cleaned : "artifact";
1712
1779
  }
1713
- async function copyIntoArtifacts(stepId, key, rawPath, workspace, artifactsDir, contentType) {
1780
+ function reserveArtifactDestName(rawPath, key, usedNames) {
1781
+ const base = sanitizeArtifactFilename(basename(rawPath));
1782
+ let candidate = base;
1783
+ if (usedNames.has(candidate)) {
1784
+ const keyPart = sanitizeArtifactFilename(key);
1785
+ candidate = `${keyPart}-${base}`;
1786
+ for (let n = 2; usedNames.has(candidate); n++) {
1787
+ candidate = `${keyPart}-${n}-${base}`;
1788
+ }
1789
+ }
1790
+ usedNames.add(candidate);
1791
+ return candidate;
1792
+ }
1793
+ async function copyIntoArtifacts(stepId, key, rawPath, workspace, artifactsDir, contentType, usedNames) {
1714
1794
  const abs = isAbsolute(rawPath) ? resolve(rawPath) : resolve(workspace, rawPath);
1715
1795
  const rel = relative(workspace, abs);
1716
1796
  if (rel.startsWith("..") || isAbsolute(rel)) {
@@ -1718,7 +1798,7 @@ async function copyIntoArtifacts(stepId, key, rawPath, workspace, artifactsDir,
1718
1798
  }
1719
1799
  const buf = await readFile(abs);
1720
1800
  const sha256 = createHash("sha256").update(buf).digest("hex");
1721
- const destName = sanitizeArtifactKey(key);
1801
+ const destName = reserveArtifactDestName(rawPath, key, usedNames);
1722
1802
  await mkdir(artifactsDir, { recursive: true });
1723
1803
  await writeFile(join(artifactsDir, destName), buf);
1724
1804
  return {
@@ -1742,12 +1822,13 @@ async function execArtifactStep(step, ctx, b) {
1742
1822
  const resolvedInputs = { key, path: rawPath };
1743
1823
  const cacheOn = ctx.cache !== void 0 && ctx.cacheKey !== void 0;
1744
1824
  const journalKey = cacheOn ? stepJournalKey(ctx, step) : void 0;
1745
- const hash = cacheOn ? hashResolvedInputs(step.kind, resolvedInputs) : void 0;
1825
+ const hash = cacheOn ? hashResolvedInputs(step.kind, resolvedInputs, ctx.workspace) : void 0;
1746
1826
  if (cacheOn) {
1747
1827
  const entry = await ctx.cache.get(journalKey);
1748
1828
  if (entry !== void 0 && entry.resolvedInputHash === hash) {
1749
1829
  const out2 = entry.output;
1750
- const destName = sanitizeArtifactKey(key);
1830
+ const destName = out2.path.slice("artifacts/".length);
1831
+ ctx.usedArtifactNames?.add(destName);
1751
1832
  const destAbs = join(artifactsDir, destName);
1752
1833
  const srcAbs = join(entry.artifactsDirAtCache ?? artifactsDir, destName);
1753
1834
  if (srcAbs !== destAbs) {
@@ -1762,7 +1843,7 @@ async function execArtifactStep(step, ctx, b) {
1762
1843
  }
1763
1844
  }
1764
1845
  ctx.onStepStart?.(step.id);
1765
- const out = await copyIntoArtifacts(step.id, key, rawPath, workspace, artifactsDir, contentType);
1846
+ const out = await copyIntoArtifacts(step.id, key, rawPath, workspace, artifactsDir, contentType, ctx.usedArtifactNames);
1766
1847
  if (cacheOn) {
1767
1848
  await ctx.cache.set(journalKey, { output: out, resolvedInputHash: hash, artifactsDirAtCache: artifactsDir });
1768
1849
  }
@@ -1780,7 +1861,7 @@ async function checkOutputsFiles(outputsFiles, ctx, workflowId, lastStepId) {
1780
1861
  const abs = isAbsolute(rawPath) ? rawPath : resolve(ctx.workspace, rawPath);
1781
1862
  let out;
1782
1863
  try {
1783
- out = await copyIntoArtifacts(lastStepId ?? workflowId, key, abs, ctx.workspace, ctx.artifactsDir, contract.contentType);
1864
+ out = await copyIntoArtifacts(lastStepId ?? workflowId, key, abs, ctx.workspace, ctx.artifactsDir, contract.contentType, ctx.usedArtifactNames);
1784
1865
  } catch {
1785
1866
  if (contract.required === true) {
1786
1867
  throw new MissingArtifactError(key, lastStepId);
@@ -1811,7 +1892,11 @@ async function runFinally(steps, ctx, bodyFailed) {
1811
1892
  async function runWorkflowInner(workflow, input, hooks, maxTotalCostUsd) {
1812
1893
  const state = { input, steps: {}, costBySession: /* @__PURE__ */ new Map(), maxTotalCostUsd, cachedHits: /* @__PURE__ */ new Set() };
1813
1894
  const ownsScope = hooks.spawned === void 0;
1814
- const ctx = { state, ...hooks, ...ownsScope ? { spawned: [] } : {} };
1895
+ const ctx = {
1896
+ state,
1897
+ ...hooks,
1898
+ ...ownsScope ? { spawned: [], usedArtifactNames: /* @__PURE__ */ new Set() } : {}
1899
+ };
1815
1900
  let lastId;
1816
1901
  let bodyFailed = false;
1817
1902
  try {