akm-cli 0.9.2-alpha.4 → 0.9.2

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 (143) hide show
  1. package/CHANGELOG.md +493 -0
  2. package/STABILITY.md +23 -5
  3. package/dist/assets/hints/cli-hints-full.md +12 -7
  4. package/dist/assets/tasks/core/extract.yml +3 -5
  5. package/dist/assets/tasks/core/improve.yml +3 -5
  6. package/dist/assets/tasks/core/index-refresh.yml +3 -5
  7. package/dist/assets/tasks/core/sync.yml +3 -5
  8. package/dist/assets/tasks/core/version-check.yml +3 -5
  9. package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +3 -5
  10. package/dist/assets/tasks/improve/akm-improve-catchup.yml +6 -6
  11. package/dist/assets/tasks/improve/akm-improve-consolidate.yml +3 -5
  12. package/dist/assets/tasks/improve/akm-improve-frequent.yml +3 -5
  13. package/dist/assets/tasks/improve/akm-improve-nightly.yml +3 -5
  14. package/dist/cli/unknown-flags.js +12 -1
  15. package/dist/cli.js +8 -1
  16. package/dist/commands/command/command-execution.js +23 -2
  17. package/dist/commands/health/improve-metrics.js +38 -0
  18. package/dist/commands/health/windows.js +8 -4
  19. package/dist/commands/health.js +8 -4
  20. package/dist/commands/lint/index.js +1 -1
  21. package/dist/commands/migrate-cli.js +130 -24
  22. package/dist/commands/proposal/validators/proposal-validators.js +7 -2
  23. package/dist/commands/tasks/explain.js +304 -0
  24. package/dist/commands/tasks/tasks-cli.js +185 -3
  25. package/dist/commands/tasks/tasks.js +265 -52
  26. package/dist/commands/workflow/plan.js +159 -0
  27. package/dist/commands/workflow-cli.js +94 -2
  28. package/dist/core/activation-policy.js +2 -12
  29. package/dist/core/adapter/adapters/akm-lint.js +7 -4
  30. package/dist/core/adapter/adapters/akm-metadata.js +26 -14
  31. package/dist/core/adapter/adapters/akm-task-adapter.js +13 -10
  32. package/dist/core/errors.js +45 -0
  33. package/dist/core/json-schema.js +15 -5
  34. package/dist/core/state/migrations.js +57 -0
  35. package/dist/core/state-db.js +16 -14
  36. package/dist/core/subprocess.js +47 -13
  37. package/dist/execution/guarded-source.js +44 -0
  38. package/dist/execution/input-contract.js +250 -0
  39. package/dist/execution/target-ref.js +63 -0
  40. package/dist/indexer/usage/usage-events.js +14 -3
  41. package/dist/integrations/agent/execution-lowering.js +12 -1
  42. package/dist/output/shapes/passthrough.js +2 -0
  43. package/dist/output/text/helpers.js +1 -1
  44. package/dist/output/text/migrate.js +12 -3
  45. package/dist/output/text/workflow-format.js +192 -10
  46. package/dist/output/text/workflow.js +2 -1
  47. package/dist/runtime.js +1 -0
  48. package/dist/scripts/akm-migrate-node.js +11838 -10118
  49. package/dist/scripts/akm-migrate.js +11828 -10117
  50. package/dist/setup/steps/tasks.js +34 -17
  51. package/dist/storage/repositories/task-history-repository.js +5 -1
  52. package/dist/storage/repositories/workflow-runs-repository.js +144 -6
  53. package/dist/tasks/backends/launchd.js +31 -84
  54. package/dist/tasks/embedded.js +13 -7
  55. package/dist/tasks/model/invocation.js +4 -0
  56. package/dist/tasks/prepare/prepare-script-target.js +9 -0
  57. package/dist/tasks/prepare/prepare-support.js +154 -0
  58. package/dist/tasks/prepare/prepare.js +117 -0
  59. package/dist/tasks/prepare/prepared-execution.js +4 -0
  60. package/dist/tasks/prepare/script-capture.js +80 -0
  61. package/dist/tasks/run/attempt-lifecycle.js +165 -0
  62. package/dist/tasks/run/load-task.js +117 -0
  63. package/dist/tasks/run/provenance.js +20 -0
  64. package/dist/tasks/run/run-command-task.js +92 -0
  65. package/dist/tasks/run/run-native-task.js +222 -0
  66. package/dist/tasks/run/run-task.js +99 -0
  67. package/dist/tasks/run/run-workflow-task.js +222 -0
  68. package/dist/tasks/run/task-history.js +134 -0
  69. package/dist/tasks/run/task-log.js +179 -0
  70. package/dist/tasks/run/task-result.js +19 -0
  71. package/dist/tasks/scheduler-binding.js +66 -2
  72. package/dist/tasks/scheduler-invocation.js +63 -3
  73. package/dist/tasks/scheduler-sync.js +77 -14
  74. package/dist/tasks/source/bounded-document.js +455 -0
  75. package/dist/tasks/source/parse-task-source.js +59 -0
  76. package/dist/tasks/source/project-v4.js +62 -0
  77. package/dist/tasks/source/task-input-diagnostics.js +36 -0
  78. package/dist/tasks/source/task-source-v4.js +626 -0
  79. package/dist/tasks/source-v3.js +10 -733
  80. package/dist/tasks/task-run-reserved-flags.js +79 -0
  81. package/dist/workflows/authoring/authoring.js +17 -8
  82. package/dist/workflows/exec/child-invocation.js +34 -0
  83. package/dist/workflows/exec/child-workflow.js +370 -0
  84. package/dist/workflows/exec/exec-unit.js +50 -170
  85. package/dist/workflows/exec/frozen-judge.js +19 -2
  86. package/dist/workflows/exec/native-executor.js +49 -27
  87. package/dist/workflows/exec/param-secrets.js +12 -0
  88. package/dist/workflows/exec/run-workflow.js +48 -59
  89. package/dist/workflows/exec/step-work.js +222 -80
  90. package/dist/workflows/exec/unit-dispatch.js +72 -0
  91. package/dist/workflows/freeze/child-output-references.js +94 -0
  92. package/dist/workflows/freeze/environment.js +174 -0
  93. package/dist/workflows/freeze/identity.js +22 -0
  94. package/dist/workflows/freeze/resolve-steps.js +78 -0
  95. package/dist/workflows/freeze/source-freeze.js +57 -0
  96. package/dist/workflows/freeze/step-values.js +68 -0
  97. package/dist/workflows/freeze/targets/child-workflow.js +206 -0
  98. package/dist/workflows/freeze/targets/command.js +81 -0
  99. package/dist/workflows/freeze/targets/script.js +57 -0
  100. package/dist/workflows/freeze/targets/shell.js +31 -0
  101. package/dist/workflows/freeze/targets/task.js +179 -0
  102. package/dist/workflows/freeze/task-bindings.js +180 -0
  103. package/dist/workflows/ir/compile.js +59 -11
  104. package/dist/workflows/ir/environment-v4.js +3 -3
  105. package/dist/workflows/ir/freeze-v4.js +41 -7
  106. package/dist/workflows/ir/params.js +58 -131
  107. package/dist/workflows/ir/plan-hash.js +3 -3
  108. package/dist/workflows/ir/schema-v4.js +246 -17
  109. package/dist/workflows/parser.js +74 -2
  110. package/dist/workflows/program/schema.js +5 -2
  111. package/dist/workflows/resource-limits.js +20 -0
  112. package/dist/workflows/runtime/plan-classifier.js +24 -7
  113. package/dist/workflows/runtime/run-outputs.js +103 -0
  114. package/dist/workflows/runtime/runs.js +114 -9
  115. package/dist/workflows/runtime/workflow-asset-loader.js +14 -6
  116. package/dist/workflows/source-files.js +5 -5
  117. package/dist/workflows/source-ir/compare.js +17 -0
  118. package/dist/workflows/source-ir/compile.js +7 -3
  119. package/dist/workflows/source-ir/github-yaml.js +64 -17
  120. package/dist/workflows/source-ir/schema.js +69 -21
  121. package/dist/workflows/source-ir/semantics.js +7 -25
  122. package/dist/workflows/source-ir/triggers.js +79 -0
  123. package/dist/workflows/source-ir/uses.js +33 -7
  124. package/docs/migration/README.md +1 -1
  125. package/docs/migration/release-notes/0.9.2.md +87 -11
  126. package/docs/migration/release-notes/README.md +3 -2
  127. package/docs/migration/v0.8-to-v0.9.md +13 -11
  128. package/docs/migration/v0.9.0-troubleshooting.md +20 -13
  129. package/docs/migration/v0.9.1-to-v0.9.2.md +598 -49
  130. package/docs/reference/README.md +1 -1
  131. package/docs/reference/cli.md +140 -46
  132. package/docs/reference/configuration.md +6 -5
  133. package/docs/reference/supported-formats.md +9 -5
  134. package/docs/reference/tasks.md +338 -75
  135. package/docs/reference/workflow-schema.md +290 -16
  136. package/docs/reference/workflows.md +57 -7
  137. package/package.json +1 -1
  138. package/schemas/akm-task.json +173 -118
  139. package/schemas/akm-workflow.json +28 -0
  140. package/dist/tasks/runner.js +0 -941
  141. package/dist/tasks/runtime-v3.js +0 -281
  142. package/dist/workflows/ir/source-freeze-v4.js +0 -506
  143. package/dist/workflows/source-ir/ordering.js +0 -38
@@ -12,27 +12,48 @@ import { createHash } from "node:crypto";
12
12
  import path from "node:path";
13
13
  import { UsageError } from "../../core/errors.js";
14
14
  import { decodeFrozenExecutableIdentity } from "../../execution/executable-identity.js";
15
+ import { INPUT_NAME_PATTERN } from "../../execution/input-contract.js";
15
16
  import { canonicalResolvedExecutionRequest, decodeResolvedExecutionRequest, } from "../../execution/resolved-request.js";
16
17
  import { decodeExecutionSourceIdentity } from "../../execution/source.js";
17
18
  import { decodeFrozenRunnerSpec } from "../../integrations/agent/execution-lowering.js";
19
+ import { parseReference } from "../program/expressions.js";
20
+ import { PROGRAM_PARAM_NAME_PATTERN } from "../program/schema.js";
21
+ import { utf8Bytes, WORKFLOW_MAX_EMBEDDED_CHILD_PLAN_BYTES } from "../resource-limits.js";
18
22
  import { decodeWorkflowExecSpec, validateWorkflowPlanStructure, } from "./schema.js";
19
- export const WORKFLOW_IR_V4_VERSION = 4;
20
- /** Strict v4 corruption gate. No authored source or config is consulted here. */
21
- export function decodeWorkflowPlanV4(input, hooks = {}) {
23
+ export const WORKFLOW_IR_V5_VERSION = 5;
24
+ /**
25
+ * Strict v4 corruption gate. No authored source or config is consulted here.
26
+ *
27
+ * `depth` is the composition-depth recursion counter (0 at the root
28
+ * workflow plan; +1 for every embedded child-workflow plan), and `budget` is
29
+ * a MUTABLE, tree-wide running total of embedded child-plan bytes charged so
30
+ * far. Both are internal to the recursive child-workflow decode path below
31
+ * (`decodeChildWorkflowTarget` calls this function again on an embedded
32
+ * `frozenPlan`, sharing the SAME `budget` object across the whole recursion —
33
+ * mirroring `ChildCompositionContext.budget` on the freeze side) — every
34
+ * external caller decodes a plan at the top level and relies on the
35
+ * defaults. Re-enforces the composition depth bound AND the aggregate
36
+ * embedded-bytes bound at decode as corruption gates (spec docs/plans/specs/
37
+ * p3a-plan-v5-child-freeze.md §3.6 step 6, rows A-23, A-N6); the actionable
38
+ * freeze-time `COMPOSITION_INVALID` gates live in
39
+ * `src/workflows/freeze/targets/child-workflow.ts` (Lane B).
40
+ */
41
+ export function decodeWorkflowPlanV4(input, hooks = {}, depth = 0, budget = { embeddedBytes: 0 }) {
22
42
  const raw = record(input, "plan");
23
- if (raw.irVersion !== WORKFLOW_IR_V4_VERSION)
24
- fail("irVersion must be 4");
25
- assertKeys(raw, ["irVersion", "title", "params", "paramSchemas", "budget", "execution", "steps", "sourceReadSet"], "plan");
43
+ if (raw.irVersion !== WORKFLOW_IR_V5_VERSION)
44
+ fail("irVersion must be 5");
45
+ assertKeys(raw, ["irVersion", "title", "params", "paramSchemas", "budget", "execution", "steps", "sourceReadSet", "outputs"], "plan");
26
46
  if (!Object.hasOwn(raw, "sourceReadSet"))
27
47
  fail("sourceReadSet is required");
28
48
  const sourceReadSet = decodeSourceReadSet(raw.sourceReadSet);
29
49
  validateWorkflowPlanStructure(raw, {
30
- expectedVersion: WORKFLOW_IR_V4_VERSION,
31
- planExtraKeys: ["sourceReadSet"],
50
+ expectedVersion: WORKFLOW_IR_V5_VERSION,
51
+ planExtraKeys: ["sourceReadSet", "outputs"],
32
52
  unitExtraKeys: ["frozenTarget", "environment"],
33
53
  gateExtraKeys: ["frozenJudge"],
34
54
  }, hooks);
35
55
  const rawSteps = raw.steps;
56
+ const stepIds = new Set(rawSteps.map((rawStep) => rawStep.stepId));
36
57
  const requiredSources = [];
37
58
  const steps = rawSteps.map((rawStep, index) => {
38
59
  const step = rawStep;
@@ -46,28 +67,68 @@ export function decodeWorkflowPlanV4(input, hooks = {}) {
46
67
  const { template: _template, ...map } = rawRoot;
47
68
  return {
48
69
  ...map,
49
- template: decodeUnitV4(record(rawRoot.template, `map ${String(rawRoot.id)} template`), requiredSources),
70
+ template: decodeUnitV4(record(rawRoot.template, `map ${String(rawRoot.id)} template`), requiredSources, depth, budget),
50
71
  };
51
72
  })()
52
- : decodeUnitV4(rawRoot, requiredSources);
73
+ : decodeUnitV4(rawRoot, requiredSources, depth, budget);
53
74
  return Object.freeze({ ...step, root, gate });
54
75
  });
55
76
  assertRequiredSources(sourceReadSet, requiredSources);
77
+ const outputs = Object.hasOwn(raw, "outputs") ? decodeWorkflowOutputs(raw.outputs, stepIds) : undefined;
78
+ const { outputs: _rawOutputs, ...restRaw } = raw;
56
79
  return Object.freeze({
57
- ...raw,
58
- irVersion: WORKFLOW_IR_V4_VERSION,
80
+ ...restRaw,
81
+ irVersion: WORKFLOW_IR_V5_VERSION,
59
82
  sourceReadSet,
60
83
  steps,
84
+ ...(outputs ? { outputs } : {}),
61
85
  });
62
86
  }
63
- function decodeUnitV4(raw, requiredSources) {
87
+ /**
88
+ * Decode a plan's `outputs` (P3b, spec §4.2): a corruption gate mirroring
89
+ * {@link decodeInputBindings} — non-empty, sorted-unique keys (canonical wire
90
+ * order, same rule `inputBindings` enforces), each name matching the
91
+ * `params:` name pattern, each entry's closed key set, `from` re-parsing as a
92
+ * `stepOutput` reference whose step id is declared in `plan.steps`.
93
+ */
94
+ function decodeWorkflowOutputs(value, stepIds) {
95
+ const raw = record(value, "plan outputs");
96
+ const names = Object.keys(raw);
97
+ if (names.length === 0)
98
+ fail("plan outputs must be a non-empty object");
99
+ let prior;
100
+ const outputs = {};
101
+ for (const name of names) {
102
+ if (!PROGRAM_PARAM_NAME_PATTERN.test(name))
103
+ fail(`plan outputs has invalid name ${name}`);
104
+ if (prior !== undefined && compareCodePoints(prior, name) >= 0) {
105
+ fail("plan outputs must be sorted by unique name");
106
+ }
107
+ prior = name;
108
+ const entry = record(raw[name], `plan outputs.${name}`);
109
+ assertKeys(entry, ["from", "schema"], `plan outputs.${name}`);
110
+ if (typeof entry.from !== "string" || !entry.from)
111
+ fail(`plan outputs.${name} from is invalid`);
112
+ const parsed = parseReference(entry.from);
113
+ if (!parsed.ok || parsed.expr.kind !== "stepOutput") {
114
+ fail(`plan outputs.${name} from must be a steps.<id>.output reference`);
115
+ }
116
+ if (!stepIds.has(parsed.expr.stepId)) {
117
+ fail(`plan outputs.${name} from names step ${parsed.expr.stepId}, which is not in plan.steps`);
118
+ }
119
+ const schema = Object.hasOwn(entry, "schema") ? record(entry.schema, `plan outputs.${name} schema`) : undefined;
120
+ outputs[name] = Object.freeze({ from: entry.from, ...(schema ? { schema: Object.freeze(schema) } : {}) });
121
+ }
122
+ return Object.freeze(outputs);
123
+ }
124
+ function decodeUnitV4(raw, requiredSources, depth, budget) {
64
125
  const id = raw.id;
65
126
  if (!Object.hasOwn(raw, "frozenTarget"))
66
127
  fail(`unit ${id} frozenTarget is required`);
67
128
  if (!Object.hasOwn(raw, "environment"))
68
129
  fail(`unit ${id} environment is required`);
69
130
  const environment = decodeEnvironment(raw.environment, id);
70
- const frozenTarget = decodeFrozenTarget(raw.frozenTarget, raw, environment, requiredSources);
131
+ const frozenTarget = decodeFrozenTarget(raw.frozenTarget, raw, environment, requiredSources, depth, budget);
71
132
  const { frozenTarget: _target, environment: _environment, ...core } = raw;
72
133
  return Object.freeze({
73
134
  ...core,
@@ -109,7 +170,7 @@ function decodeGateV4(value, stepId, requiredSources) {
109
170
  frozenJudge: decodeCommandTarget(record(gate.frozenJudge, `gate ${stepId} frozenJudge`), identity, requiredSources),
110
171
  });
111
172
  }
112
- function decodeFrozenTarget(value, unit, environment, requiredSources) {
173
+ function decodeFrozenTarget(value, unit, environment, requiredSources, depth, budget) {
113
174
  const target = record(value, `unit ${unit.id} frozenTarget`);
114
175
  if (target.kind === "command")
115
176
  return decodeCommandTarget(target, unit, requiredSources);
@@ -117,10 +178,121 @@ function decodeFrozenTarget(value, unit, environment, requiredSources) {
117
178
  return decodeShellTarget(target, unit, environment);
118
179
  if (target.kind === "script")
119
180
  return decodeScriptTarget(target, unit, requiredSources);
181
+ if (target.kind === "child-workflow")
182
+ return decodeChildWorkflowTarget(target, unit, depth, budget);
120
183
  fail(`unit ${unit.id} frozenTarget has unsupported kind ${String(target.kind)}`);
121
184
  }
185
+ /**
186
+ * Composition-depth corruption bound (spec §3.6 step 6, §4.5, row A-23).
187
+ * Mirrors `WORKFLOW_MAX_COMPOSITION_DEPTH` (= 8), which Lane B adds to
188
+ * `src/workflows/resource-limits.ts` in a later commit as the freeze-time
189
+ * `COMPOSITION_INVALID` gate. Reproduced here — the decode-time corruption
190
+ * gate — rather than imported across the lane boundary, matching
191
+ * `tests/workflows/plan-v5-schema.test.ts`'s own header comment.
192
+ */
193
+ const CHILD_WORKFLOW_DECODE_MAX_DEPTH = 8;
194
+ /**
195
+ * Decode a `kind: "child-workflow"` frozen target (§3.5): the embedded
196
+ * COMPLETE child plan, re-verified against its own `planHash` and this
197
+ * target's own `contentHash` on every decode (rows A-20, A-21), recursively
198
+ * enforcing `irVersion` 5 (row A-22, via the recursive
199
+ * {@link decodeWorkflowPlanV4} call), the composition depth bound (row A-23),
200
+ * and the AGGREGATE embedded-plan-bytes bound (§3.6 step 6, A-N6) as the
201
+ * decoder recurses. `WORKFLOW_MAX_EMBEDDED_CHILD_PLAN_BYTES` — unlike the
202
+ * depth bound above — is imported directly from
203
+ * `src/workflows/resource-limits.ts` rather than reproduced: that constant
204
+ * was already Lane B's when this decode-time re-enforcement was added (no
205
+ * lane-ordering constraint left to honor), and re-deriving a byte cap by
206
+ * hand invites drift a depth integer does not. `budget` is charged with
207
+ * THIS child's own full canonical byte length AFTER it decodes (so any
208
+ * further-nested grandchildren it embeds have already charged themselves,
209
+ * mirroring the freeze-side `chargeEmbeddedBudget`'s post-recursion charge
210
+ * order in `src/workflows/freeze/targets/child-workflow.ts`) — a rejected
211
+ * plan therefore never partially charges the budget. `frozenPlan` is
212
+ * covered wholesale through `planHash`, so it is deliberately NOT
213
+ * re-serialized into `contentHash` (§3.5). No `requiredSources`
214
+ * contribution: unlike a command/script target's own referenced source, the
215
+ * child's transitive sources live in the child's OWN `sourceReadSet`,
216
+ * verified by the recursive decode call itself; the PARENT's
217
+ * `sourceReadSet` absorbing the child's files (row B-05) is a freeze-time
218
+ * concern (Lane B), not a decode-time structural one.
219
+ */
220
+ function decodeChildWorkflowTarget(target, unit, depth, budget) {
221
+ assertKeys(target, ["kind", "ref", "planHash", "frozenPlan", "contentHash", "via", "taskRef", "inputBindings"], `unit ${unit.id} child workflow target`);
222
+ if (typeof target.ref !== "string" || !target.ref)
223
+ fail(`unit ${unit.id} child workflow ref is invalid`);
224
+ const planHash = digest(target.planHash, `unit ${unit.id} child workflow planHash`);
225
+ if (target.via !== "direct" && target.via !== "task")
226
+ fail(`unit ${unit.id} child workflow via is invalid`);
227
+ const via = target.via;
228
+ if (via === "task") {
229
+ if (typeof target.taskRef !== "string" || !target.taskRef) {
230
+ fail(`unit ${unit.id} child workflow via "task" requires a taskRef`);
231
+ }
232
+ }
233
+ else if (target.taskRef !== undefined) {
234
+ fail(`unit ${unit.id} child workflow via "direct" cannot carry a taskRef`);
235
+ }
236
+ const taskRef = target.taskRef;
237
+ const inputBindings = decodeInputBindings(target.inputBindings, `unit ${unit.id} child workflow target`);
238
+ const contentHash = digest(target.contentHash, `unit ${unit.id} child workflow contentHash`);
239
+ const expectedContentHash = childWorkflowContentHash({ ref: target.ref, planHash, via, taskRef, inputBindings });
240
+ if (contentHash !== expectedContentHash) {
241
+ fail(`unit ${unit.id} child workflow contentHash does not match its frozen dispatch`);
242
+ }
243
+ const childDepth = depth + 1;
244
+ if (childDepth > CHILD_WORKFLOW_DECODE_MAX_DEPTH) {
245
+ fail(`unit ${unit.id} child workflow ${target.ref} exceeds the max composition depth of ${CHILD_WORKFLOW_DECODE_MAX_DEPTH}`);
246
+ }
247
+ const frozenPlan = decodeWorkflowPlanV4(target.frozenPlan, {}, childDepth, budget);
248
+ const embeddedPlanJson = canonicalJsonLocal(frozenPlan);
249
+ const actualPlanHash = sha256(embeddedPlanJson);
250
+ if (actualPlanHash !== planHash) {
251
+ fail(`unit ${unit.id} child workflow embedded plan does not match its frozen planHash`);
252
+ }
253
+ const projectedBytes = budget.embeddedBytes + utf8Bytes(embeddedPlanJson);
254
+ if (projectedBytes > WORKFLOW_MAX_EMBEDDED_CHILD_PLAN_BYTES) {
255
+ fail(`unit ${unit.id} child workflow ${target.ref} embedded plans total ${projectedBytes} bytes, over the ` +
256
+ `${WORKFLOW_MAX_EMBEDDED_CHILD_PLAN_BYTES}-byte limit`);
257
+ }
258
+ budget.embeddedBytes = projectedBytes;
259
+ return Object.freeze({
260
+ kind: "child-workflow",
261
+ ref: target.ref,
262
+ planHash,
263
+ frozenPlan,
264
+ contentHash,
265
+ via,
266
+ ...(taskRef !== undefined ? { taskRef } : {}),
267
+ ...(inputBindings ? { inputBindings } : {}),
268
+ });
269
+ }
270
+ /** §3.5's exact `contentHash` formula. */
271
+ function childWorkflowContentHash(fields) {
272
+ return createHash("sha256")
273
+ .update("akm.workflow.child-workflow\0v1\0")
274
+ .update(canonicalJsonLocal({
275
+ ref: fields.ref,
276
+ planHash: fields.planHash,
277
+ via: fields.via,
278
+ taskRef: fields.taskRef ?? null,
279
+ inputBindings: fields.inputBindings ?? null,
280
+ }))
281
+ .digest("hex");
282
+ }
122
283
  function decodeCommandTarget(target, unit, requiredSources) {
123
- assertKeys(target, ["kind", "ref", "contentHash", "request", "runner", "concurrency", "cwdIdentity", "executable", "gitCommitOid"], `unit ${unit.id} command target`);
284
+ assertKeys(target, [
285
+ "kind",
286
+ "ref",
287
+ "contentHash",
288
+ "request",
289
+ "runner",
290
+ "concurrency",
291
+ "cwdIdentity",
292
+ "executable",
293
+ "gitCommitOid",
294
+ "inputBindings",
295
+ ], `unit ${unit.id} command target`);
124
296
  if (target.ref !== null && typeof target.ref !== "string")
125
297
  fail(`unit ${unit.id} command target ref is invalid`);
126
298
  const request = decodeResolvedExecutionRequest(target.request);
@@ -171,6 +343,7 @@ function decodeCommandTarget(target, unit, requiredSources) {
171
343
  fail(`unit ${unit.id} non-CLI target cannot carry a host executable`);
172
344
  }
173
345
  const gitCommitOid = decodeGitCommitOid(target.gitCommitOid, unit);
346
+ const inputBindings = decodeInputBindings(target.inputBindings, `unit ${unit.id} command target`);
174
347
  // Force the shared request canonicalizer across every accepted wire request.
175
348
  canonicalResolvedExecutionRequest(request);
176
349
  return Object.freeze({
@@ -183,10 +356,11 @@ function decodeCommandTarget(target, unit, requiredSources) {
183
356
  ...(cwdIdentity ? { cwdIdentity } : {}),
184
357
  ...(executable ? { executable } : {}),
185
358
  ...(gitCommitOid ? { gitCommitOid } : {}),
359
+ ...(inputBindings ? { inputBindings } : {}),
186
360
  });
187
361
  }
188
362
  function decodeShellTarget(target, unit, environment) {
189
- assertKeys(target, ["kind", "contentHash", "exec", "cwdIdentity", "executable", "gitCommitOid"], `unit ${unit.id} shell target`);
363
+ assertKeys(target, ["kind", "contentHash", "exec", "cwdIdentity", "executable", "gitCommitOid", "inputBindings"], `unit ${unit.id} shell target`);
190
364
  const exec = decodeWorkflowExecSpec(target.exec, `unit ${unit.id} shell target exec`);
191
365
  const cwdIdentity = decodeDirectoryIdentity(target.cwdIdentity, unit.id);
192
366
  const executable = Object.hasOwn(target, "executable")
@@ -197,12 +371,16 @@ function decodeShellTarget(target, unit, environment) {
197
371
  }
198
372
  const gitCommitOid = decodeGitCommitOid(target.gitCommitOid, unit);
199
373
  const contentHash = digest(target.contentHash, `unit ${unit.id} shell contentHash`);
374
+ // inputBindings deliberately sits OUTSIDE this preimage (P2b A-N7): identity
375
+ // coverage for a task-composed unit comes from computeUnitInputHash's own
376
+ // frozenTarget field (step-work.ts), which hashes this whole target anyway.
200
377
  const expected = createHash("sha256")
201
378
  .update("akm.workflow.shell.v1\0")
202
379
  .update(canonicalJsonLocal({ exec, environment, cwdIdentity }))
203
380
  .digest("hex");
204
381
  if (contentHash !== expected)
205
382
  fail(`unit ${unit.id} shell contentHash does not match its frozen dispatch`);
383
+ const inputBindings = decodeInputBindings(target.inputBindings, `unit ${unit.id} shell target`);
206
384
  return Object.freeze({
207
385
  kind: "shell",
208
386
  contentHash,
@@ -210,6 +388,7 @@ function decodeShellTarget(target, unit, environment) {
210
388
  cwdIdentity,
211
389
  ...(executable ? { executable } : {}),
212
390
  ...(gitCommitOid ? { gitCommitOid } : {}),
391
+ ...(inputBindings ? { inputBindings } : {}),
213
392
  });
214
393
  }
215
394
  function decodeScriptTarget(target, unit, requiredSources) {
@@ -226,6 +405,7 @@ function decodeScriptTarget(target, unit, requiredSources) {
226
405
  "materialization",
227
406
  "executable",
228
407
  "gitCommitOid",
408
+ "inputBindings",
229
409
  ], `unit ${unit.id} script target`);
230
410
  const exec = decodeWorkflowExecSpec(target.exec, `unit ${unit.id} script target exec`);
231
411
  if (typeof target.ref !== "string" || !target.ref.includes("//"))
@@ -253,6 +433,7 @@ function decodeScriptTarget(target, unit, requiredSources) {
253
433
  ? decodeFrozenExecutableIdentity(target.executable, `unit ${unit.id} executable`)
254
434
  : undefined;
255
435
  const gitCommitOid = decodeGitCommitOid(target.gitCommitOid, unit);
436
+ const inputBindings = decodeInputBindings(target.inputBindings, `unit ${unit.id} script target`);
256
437
  requiredSources.push({ ref: target.ref, bundle: "", adapter: "", file: "", hash: contentHash });
257
438
  return Object.freeze({
258
439
  kind: "script",
@@ -267,7 +448,55 @@ function decodeScriptTarget(target, unit, requiredSources) {
267
448
  materialization: "ephemeral-0700-delete",
268
449
  ...(executable ? { executable } : {}),
269
450
  ...(gitCommitOid ? { gitCommitOid } : {}),
451
+ ...(inputBindings ? { inputBindings } : {}),
452
+ });
453
+ }
454
+ /**
455
+ * Decode a frozen target's `inputBindings` (P2b §3.2 A-N7): a composing
456
+ * step's `with:` normalized against the composed task's declared inputs.
457
+ * Closed `kind`, `INPUT_NAME_PATTERN` name, unique and sorted by name (never
458
+ * `[]` — absence is the identity-preserving default). A `literal` entry
459
+ * requires `value`; a `reference` entry requires a `parseReference`-valid
460
+ * `from` plus the declaration's bounded `schema` (§3.6's widened reference
461
+ * arm, `src/execution/input-contract.ts`).
462
+ */
463
+ function decodeInputBindings(value, label) {
464
+ if (value === undefined)
465
+ return undefined;
466
+ if (!Array.isArray(value) || value.length === 0)
467
+ fail(`${label} inputBindings must be a non-empty array`);
468
+ let prior;
469
+ const bindings = value.map((raw, index) => {
470
+ const binding = record(raw, `${label} inputBindings[${index}]`);
471
+ if (typeof binding.name !== "string" || !INPUT_NAME_PATTERN.test(binding.name)) {
472
+ fail(`${label} inputBindings[${index}] name is invalid`);
473
+ }
474
+ if (prior !== undefined && compareCodePoints(prior, binding.name) >= 0) {
475
+ fail(`${label} inputBindings must be sorted by unique name`);
476
+ }
477
+ prior = binding.name;
478
+ if (binding.kind === "literal") {
479
+ assertKeys(binding, ["kind", "name", "value"], `${label} inputBindings[${index}]`);
480
+ if (!Object.hasOwn(binding, "value"))
481
+ fail(`${label} inputBindings[${index}] literal binding requires value`);
482
+ return Object.freeze({ kind: "literal", name: binding.name, value: binding.value });
483
+ }
484
+ if (binding.kind === "reference") {
485
+ assertKeys(binding, ["kind", "name", "from", "schema"], `${label} inputBindings[${index}]`);
486
+ if (typeof binding.from !== "string" || !parseReference(binding.from).ok) {
487
+ fail(`${label} inputBindings[${index}] from is not a valid reference`);
488
+ }
489
+ const schema = record(binding.schema, `${label} inputBindings[${index}] schema`);
490
+ return Object.freeze({
491
+ kind: "reference",
492
+ name: binding.name,
493
+ from: binding.from,
494
+ schema: Object.freeze(schema),
495
+ });
496
+ }
497
+ return fail(`${label} inputBindings[${index}] has unsupported kind ${String(binding.kind)}`);
270
498
  });
499
+ return Object.freeze(bindings);
271
500
  }
272
501
  function decodeGitCommitOid(value, unit) {
273
502
  if (unit.isolation === "worktree") {
@@ -38,7 +38,7 @@ import { formatExtraParamsIssue, validateExtraParams } from "../core/extra-param
38
38
  import { checkJsonSchemaDefinition, JSON_SCHEMA_SUBSET_SUPPORTED_KEYWORDS } from "../core/json-schema.js";
39
39
  import { parseReference } from "./program/expressions.js";
40
40
  import { PROGRAM_ISOLATION_KINDS, PROGRAM_ON_ERROR, PROGRAM_PARAM_NAME_PATTERN, PROGRAM_REDUCERS, PROGRAM_RETRY_REASONS, PROGRAM_STEP_ID_PATTERN, } from "./program/schema.js";
41
- import { jsonBytes, utf8Bytes, WORKFLOW_ENGINE_NAME_PATTERN, WORKFLOW_ENV_VAR_NAME_PATTERN, WORKFLOW_MAX_CONCURRENCY, WORKFLOW_MAX_ENGINE_NAME_LENGTH, WORKFLOW_MAX_EXEC_ARG_BYTES, WORKFLOW_MAX_EXEC_ARGV, WORKFLOW_MAX_EXEC_CWD_LENGTH, WORKFLOW_MAX_EXEC_PASS_ENV, WORKFLOW_MAX_EXTRA_PARAMS_BYTES, WORKFLOW_MAX_GATE_LOOPS, WORKFLOW_MAX_INPUTS, WORKFLOW_MAX_MAP_EXPANSION, WORKFLOW_MAX_PARAMS, WORKFLOW_MAX_RETRIES, WORKFLOW_MAX_ROUTE_BRANCHES, WORKFLOW_MAX_SCHEMA_BYTES, WORKFLOW_MAX_SOURCE_BYTES, WORKFLOW_MAX_STEPS, WORKFLOW_MAX_TIMEOUT_MS, } from "./resource-limits.js";
41
+ import { jsonBytes, utf8Bytes, WORKFLOW_ENGINE_NAME_PATTERN, WORKFLOW_ENV_VAR_NAME_PATTERN, WORKFLOW_MAX_CONCURRENCY, WORKFLOW_MAX_ENGINE_NAME_LENGTH, WORKFLOW_MAX_EXEC_ARG_BYTES, WORKFLOW_MAX_EXEC_ARGV, WORKFLOW_MAX_EXEC_CWD_LENGTH, WORKFLOW_MAX_EXEC_PASS_ENV, WORKFLOW_MAX_EXTRA_PARAMS_BYTES, WORKFLOW_MAX_GATE_LOOPS, WORKFLOW_MAX_INPUTS, WORKFLOW_MAX_MAP_EXPANSION, WORKFLOW_MAX_OUTPUTS, WORKFLOW_MAX_PARAMS, WORKFLOW_MAX_RETRIES, WORKFLOW_MAX_ROUTE_BRANCHES, WORKFLOW_MAX_SCHEMA_BYTES, WORKFLOW_MAX_SOURCE_BYTES, WORKFLOW_MAX_STEPS, WORKFLOW_MAX_TIMEOUT_MS, } from "./resource-limits.js";
42
42
  import { WORKFLOW_SCHEMA_VERSION, } from "./schema.js";
43
43
  import { runSemanticChecks } from "./validator.js";
44
44
  /** Envelope keys every AKM markdown asset carries ($ref'd from schemas/akm-asset-envelope.json). */
@@ -56,7 +56,7 @@ const ENVELOPE_KEYS = [
56
56
  "status",
57
57
  "stale_after",
58
58
  ];
59
- const WORKFLOW_KEYS = ["params", "defaults", "budget", "steps"];
59
+ const WORKFLOW_KEYS = ["params", "outputs", "defaults", "budget", "steps"];
60
60
  const TOP_LEVEL_KEYS = [...ENVELOPE_KEYS, ...WORKFLOW_KEYS];
61
61
  const DEFAULTS_KEYS = ["engine", "model", "timeout", "on_error", "llm"];
62
62
  const BUDGET_KEYS = ["max_tokens", "max_units"];
@@ -70,6 +70,8 @@ const ROUTE_KEYS = ["input", "when", "default"];
70
70
  const RETRY_KEYS = ["max", "on"];
71
71
  const GATE_KEYS = ["max_loops"];
72
72
  const ROUTE_BRANCH_KEYS = ["match", "step"];
73
+ /** Closed key set of one `outputs:` entry — mirrors `params:`'s bare-schema shape, plus `from`. */
74
+ const OUTPUT_ENTRY_KEYS = ["from", "schema"];
73
75
  const ACTOR_STAMP_KEYS = ["by", "at"];
74
76
  const TIMEOUT_VALUE = /^(\d+)(ms|s|m)?$/;
75
77
  const TIMEOUT_HINT = `Use "<n>ms", "<n>s", "<n>m" (e.g. "10m"), or "none"`;
@@ -174,6 +176,7 @@ export function parseWorkflow(markdown, source) {
174
176
  const description = typeof root.description === "string" ? root.description : undefined;
175
177
  const tags = readTags(ctx, root.tags, frontmatterEndLine);
176
178
  const params = parseParams(ctx, root.params);
179
+ const outputs = parseOutputs(ctx, root.outputs);
177
180
  const defaults = parseDefaults(ctx, root.defaults);
178
181
  const budget = parseBudget(ctx, root.budget);
179
182
  const parsedSteps = parseSteps(ctx, root.steps);
@@ -220,6 +223,7 @@ export function parseWorkflow(markdown, source) {
220
223
  ...(description ? { description } : {}),
221
224
  ...(tags ? { tags } : {}),
222
225
  ...(params ? { params } : {}),
226
+ ...(outputs ? { outputs } : {}),
223
227
  ...(defaults ? { defaults } : {}),
224
228
  ...(budget ? { budget } : {}),
225
229
  steps,
@@ -423,6 +427,74 @@ function parseParams(ctx, raw) {
423
427
  }
424
428
  return Object.keys(params).length > 0 ? params : undefined;
425
429
  }
430
+ /**
431
+ * `outputs:` (P3b, spec §4.2): named, optionally schema-validated projections
432
+ * of step artifacts, exported when the run completes. Symmetrical with
433
+ * `parseParams` above (B-N4) — same authoring surface, same name grammar,
434
+ * same schema-subset validator, same per-schema byte bound — but each entry
435
+ * is a STRUCTURED `{from, schema?}` mapping rather than a bare JSON Schema.
436
+ *
437
+ * `from` is validated for GRAMMAR only here: it must parse and it must be a
438
+ * `steps.<id>.output(.<seg>)*` reference (never `params.<name>` — an output
439
+ * projects a step artifact, never a param, B-07). Whether the named step is
440
+ * actually DECLARED in this document is a semantic, cross-step check left to
441
+ * `ir/compile.ts`'s reference validation (B-06), mirroring how `inputs[]` /
442
+ * `map.over` / `route.input` already split "syntax here, semantics there".
443
+ */
444
+ function parseOutputs(ctx, raw) {
445
+ if (raw === undefined)
446
+ return undefined;
447
+ if (!isPlainRecord(raw)) {
448
+ ctx.err(["outputs"], `"outputs" must be a mapping of output name to { from, schema? } (e.g. report: { from: steps.summarize.output }).`);
449
+ return undefined;
450
+ }
451
+ if (Object.keys(raw).length > WORKFLOW_MAX_OUTPUTS) {
452
+ ctx.err(["outputs"], `"outputs" must contain at most ${WORKFLOW_MAX_OUTPUTS} entries.`);
453
+ }
454
+ const outputs = {};
455
+ for (const [outputName, value] of Object.entries(raw)) {
456
+ const path = ["outputs", outputName];
457
+ if (!PROGRAM_PARAM_NAME_PATTERN.test(outputName)) {
458
+ ctx.err(path, `Output name "${outputName}" is invalid. Use letters, digits, and underscores, starting with a letter or ` +
459
+ `underscore, so "steps.<child>.output.${outputName}" can address it.`);
460
+ continue;
461
+ }
462
+ if (!isPlainRecord(value)) {
463
+ ctx.err(path, `Output "${outputName}" must be a mapping with "from" (and optional "schema").`);
464
+ continue;
465
+ }
466
+ checkUnknownKeys(ctx, value, path, OUTPUT_ENTRY_KEYS, `output "${outputName}"`);
467
+ if (typeof value.from !== "string" || value.from.trim() === "") {
468
+ ctx.err([...path, "from"], `Output "${outputName}" must declare "from": a steps.<id>.output(.<seg>)* reference.`);
469
+ continue;
470
+ }
471
+ const parsedFrom = parseReference(value.from);
472
+ if (!parsedFrom.ok) {
473
+ ctx.err([...path, "from"], `Output "${outputName}" "from": ${parsedFrom.message}`);
474
+ continue;
475
+ }
476
+ if (parsedFrom.expr.kind !== "stepOutput") {
477
+ ctx.err([...path, "from"], `Output "${outputName}" "from" must reference a step output (steps.<id>.output...), not a param — an ` +
478
+ `output projects a step artifact, never a param (got "${value.from}").`);
479
+ continue;
480
+ }
481
+ const entry = { from: value.from };
482
+ if (value.schema !== undefined) {
483
+ if (!isPlainRecord(value.schema)) {
484
+ ctx.err([...path, "schema"], `Output "${outputName}" "schema" must be a JSON Schema object.`);
485
+ }
486
+ else {
487
+ if (jsonBytes(value.schema) > WORKFLOW_MAX_SCHEMA_BYTES) {
488
+ ctx.err([...path, "schema"], `Output "${outputName}" schema exceeds the 256 KiB resource limit.`);
489
+ }
490
+ checkSchemaDefinition(ctx, value.schema, [...path, "schema"], `Output "${outputName}" schema`);
491
+ entry.schema = value.schema;
492
+ }
493
+ }
494
+ outputs[outputName] = entry;
495
+ }
496
+ return Object.keys(outputs).length > 0 ? outputs : undefined;
497
+ }
426
498
  function parseDefaults(ctx, raw) {
427
499
  if (raw === undefined)
428
500
  return undefined;
@@ -46,9 +46,12 @@ export const PROGRAM_RETRY_REASONS = Object.keys(RETRY_REASON_SET);
46
46
  export const PROGRAM_STEP_ID_PATTERN = /^[A-Za-z_][A-Za-z0-9_-]*$/;
47
47
  /**
48
48
  * Param names must be `params.<ident>`-addressable, so they are plain
49
- * identifiers (no dots/dashes).
49
+ * identifiers (no dots/dashes). Re-exported from the shared input contract
50
+ * (`src/execution/input-contract.ts`'s `INPUT_NAME_PATTERN`) so task source
51
+ * v4's `inputs:` declarations share the exact same name pattern rather than a
52
+ * second, hand-maintained copy (P2a, D3-N1).
50
53
  */
51
- export const PROGRAM_PARAM_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
54
+ export { INPUT_NAME_PATTERN as PROGRAM_PARAM_NAME_PATTERN } from "../../execution/input-contract.js";
52
55
  /**
53
56
  * The ONE structural projection of an exec spec — ONE encoding per state.
54
57
  *
@@ -15,6 +15,8 @@ export const WORKFLOW_MAX_JSON_DEPTH = 64;
15
15
  export const WORKFLOW_MAX_MAP_EXPANSION = 10_000;
16
16
  /** Max declared `inputs:` reference strings on one unit/map step. */
17
17
  export const WORKFLOW_MAX_INPUTS = 64;
18
+ /** Max declared `outputs:` entries on one workflow document (P3b, spec §4.1). */
19
+ export const WORKFLOW_MAX_OUTPUTS = 64;
18
20
  // ── Dispatch-significant bounds shared across validation layers ──────────────
19
21
  //
20
22
  // Defined ONCE here so the three enforcement layers cannot drift:
@@ -201,3 +203,21 @@ export function utf8Bytes(value) {
201
203
  export function jsonBytes(value) {
202
204
  return utf8Bytes(JSON.stringify(value));
203
205
  }
206
+ // ── Recursive child-workflow composition bounds (spec docs/plans/specs/
207
+ // ── p3a-plan-v5-child-freeze.md §4.5, A-N6) ──────────────────────────────────
208
+ //
209
+ // Enforced ONCE, at freeze, before publication, in
210
+ // `src/workflows/freeze/targets/child-workflow.ts` — the ONE resolver both the
211
+ // direct `uses: workflows/<ref>` form and the task-wrapped form route through
212
+ // — and re-enforced as a corruption gate whenever a parent plan is DECODED
213
+ // (`src/workflows/ir/schema-v4.ts`'s recursive `decodeChildWorkflowTarget`).
214
+ // Full design history, including the rejected alternative for the byte cap:
215
+ // docs/architecture/decisions/0007-workflow-composition-bounds.md.
216
+ /** Max workflow composition depth (root = depth 0; a 9th descendant level fails). */
217
+ export const WORKFLOW_MAX_COMPOSITION_DEPTH = 8;
218
+ /**
219
+ * Max AGGREGATE canonical-JSON bytes of every embedded child plan in ONE root
220
+ * freeze (the sum across the whole composition tree, not per child).
221
+ * Deliberately HALF of {@link WORKFLOW_MAX_PLAN_BYTES} — see ADR 0007.
222
+ */
223
+ export const WORKFLOW_MAX_EMBEDDED_CHILD_PLAN_BYTES = 1024 * 1024;
@@ -3,7 +3,7 @@
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import { UsageError } from "../../core/errors.js";
5
5
  import { decodeCanonicalPlan } from "../ir/plan-hash.js";
6
- import { WORKFLOW_IR_V4_VERSION } from "../ir/schema-v4.js";
6
+ import { WORKFLOW_IR_V5_VERSION } from "../ir/schema-v4.js";
7
7
  /** Validate that a live run carries exactly the current frozen-plan format. */
8
8
  export function classifyWorkflowRunPlan(row) {
9
9
  const runId = row.id ?? "(unknown)";
@@ -16,14 +16,19 @@ export function classifyWorkflowRunPlan(row) {
16
16
  }
17
17
  if (row.plan_ir_version !== null &&
18
18
  row.plan_ir_version !== undefined &&
19
- row.plan_ir_version !== WORKFLOW_IR_V4_VERSION) {
19
+ row.plan_ir_version !== WORKFLOW_IR_V5_VERSION) {
20
20
  return {
21
21
  support: "unsupported-version",
22
22
  irVersion: row.plan_ir_version,
23
- error: `Workflow run ${runId} uses unsupported workflow IR version ${row.plan_ir_version}; this runtime supports only workflow IR version 4. Start a new run from the authored workflow.`,
23
+ // §3.2's exact complete-or-abandon policy string (A-N2): pre-irVersion-5
24
+ // plans keep status/list/abandon working but can no longer execute.
25
+ error: `Workflow run ${runId} was frozen as workflow plan irVersion ${row.plan_ir_version}; pre-irVersion-5 ` +
26
+ `plans cannot execute after the 0.9.2 upgrade. Complete them before upgrading, or run ` +
27
+ `'akm workflow abandon ${runId}' and start a new run from the authored workflow. ` +
28
+ `'akm workflow status' and 'akm workflow list' still work on this run.`,
24
29
  };
25
30
  }
26
- if (row.plan_ir_version !== WORKFLOW_IR_V4_VERSION) {
31
+ if (row.plan_ir_version !== WORKFLOW_IR_V5_VERSION) {
27
32
  return {
28
33
  support: "corrupt-plan",
29
34
  irVersion: null,
@@ -45,11 +50,20 @@ export function classifyWorkflowRunPlan(row) {
45
50
  };
46
51
  }
47
52
  }
48
- /** Reject any operation that requires a valid current frozen plan. */
53
+ /**
54
+ * Reject any operation that requires a valid current frozen plan. A
55
+ * non-current stored `plan_ir_version` (`unsupported-version`) fails closed
56
+ * under `WORKFLOW_IR_VERSION_UNSUPPORTED` (A-N2) — distinct from the
57
+ * `missing-plan` / `corrupt-plan` decode-corruption family, which keeps
58
+ * `INVALID_JSON_ARGUMENT`.
59
+ */
49
60
  export function requireExecutableWorkflowPlan(row) {
50
61
  const classified = classifyWorkflowRunPlan(row);
51
62
  if (classified.support === "supported")
52
63
  return classified.plan;
64
+ if (classified.support === "unsupported-version") {
65
+ throw new UsageError(classified.error, "WORKFLOW_IR_VERSION_UNSUPPORTED");
66
+ }
53
67
  throw new UsageError(classified.error, "INVALID_JSON_ARGUMENT");
54
68
  }
55
69
  /** Project persisted spine rows from the decoded plan, never from the mutable source asset. */
@@ -108,8 +122,11 @@ export function assertWorkflowSpineMatchesPlan(plan, run, rows) {
108
122
  }
109
123
  else if (run.status === "failed") {
110
124
  // `workflow abandon` marks the run failed while intentionally leaving its
111
- // current step pending so `resume` can reopen the same work.
112
- if (!current || (current.status !== "failed" && current.status !== "pending"))
125
+ // current step unchanged so `resume` can reopen the same work. An active
126
+ // run leaves a pending step; a blocked run leaves a blocked step; and an
127
+ // execution failure already carries a failed step. All three are honest
128
+ // failed-run spines that `resumeWorkflowRun` normalizes back to pending.
129
+ if (!current || (current.status !== "failed" && current.status !== "pending" && current.status !== "blocked"))
113
130
  corruptSpine(run.id, `${run.status} status does not match the current plan step`);
114
131
  }
115
132
  else if (run.status === "completed") {