akm-cli 0.9.11 → 0.9.13

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 (134) hide show
  1. package/CHANGELOG.md +227 -0
  2. package/STABILITY.md +6 -1
  3. package/dist/assets/hints/cli-hints-full.md +1 -1
  4. package/dist/assets/improve-strategies/consolidate.json +1 -1
  5. package/dist/assets/improve-strategies/default.json +1 -1
  6. package/dist/assets/improve-strategies/thorough.json +1 -2
  7. package/dist/assets/workflows/workflow-template.md +4 -0
  8. package/dist/cli/shared.js +16 -4
  9. package/dist/cli.js +15 -13
  10. package/dist/commands/agent/agent-dispatch.js +8 -0
  11. package/dist/commands/command/execution-source-loader.js +25 -22
  12. package/dist/commands/command/portable-template.js +4 -26
  13. package/dist/commands/config-cli.js +10 -4
  14. package/dist/commands/env/env-binding.js +10 -3
  15. package/dist/commands/env/env-cli.js +7 -0
  16. package/dist/commands/env/secret-cli.js +15 -4
  17. package/dist/commands/health/checks.js +186 -71
  18. package/dist/commands/health.js +16 -4
  19. package/dist/commands/improve/distill/quality-gate.js +2 -2
  20. package/dist/commands/improve/distill.js +28 -12
  21. package/dist/commands/improve/execution.js +1 -2
  22. package/dist/commands/improve/extract.js +82 -56
  23. package/dist/commands/improve/improve-strategies.js +26 -8
  24. package/dist/commands/improve/improve.js +14 -0
  25. package/dist/commands/improve/preparation.js +9 -6
  26. package/dist/commands/improve/reflect.js +61 -77
  27. package/dist/commands/lint/base-linter.js +10 -0
  28. package/dist/commands/lint/index.js +3 -1
  29. package/dist/commands/migrate-cli.js +6 -4
  30. package/dist/commands/proposal/drain-policies.js +22 -2
  31. package/dist/commands/proposal/drain.js +48 -6
  32. package/dist/commands/proposal/proposal-cli.js +1 -0
  33. package/dist/commands/proposal/repository.js +4 -4
  34. package/dist/commands/proposal/validators/proposal-quality-validators.js +23 -2
  35. package/dist/commands/proposal/validators/proposals.js +10 -19
  36. package/dist/commands/read/show.js +42 -31
  37. package/dist/commands/registry-cli.js +4 -2
  38. package/dist/commands/sources/init.js +4 -8
  39. package/dist/commands/sources/self-update.js +2 -2
  40. package/dist/commands/sources/source-clone.js +5 -7
  41. package/dist/commands/sources/sources-cli.js +3 -5
  42. package/dist/commands/tasks/tasks-cli.js +4 -12
  43. package/dist/commands/tasks/tasks.js +38 -35
  44. package/dist/commands/workflow-cli.js +17 -15
  45. package/dist/core/activation-policy.js +31 -3
  46. package/dist/core/adapter/execution-source.js +39 -11
  47. package/dist/core/asset/stash-meta.js +7 -41
  48. package/dist/core/common.js +8 -17
  49. package/dist/core/config/config-schema.js +3 -23
  50. package/dist/core/config/config-walker.js +56 -6
  51. package/dist/core/config/config.js +42 -17
  52. package/dist/core/config/legacy-source-shape-shim.js +79 -0
  53. package/dist/core/config/schema/embedding.js +2 -2
  54. package/dist/core/config/schema/engines.js +2 -2
  55. package/dist/core/config/schema/index-config.js +19 -21
  56. package/dist/core/config/schema/primitives.js +27 -10
  57. package/dist/core/config/schema/sources-bundles.js +1 -6
  58. package/dist/core/errors.js +4 -3
  59. package/dist/core/improve-types.js +17 -0
  60. package/dist/core/json-schema.js +1 -11
  61. package/dist/core/maintenance-barrier.js +17 -2
  62. package/dist/core/paths.js +12 -15
  63. package/dist/core/state/migrations.js +28 -0
  64. package/dist/core/state-db.js +28 -1
  65. package/dist/core/write-source.js +6 -6
  66. package/dist/indexer/bundle-identity-guard.js +3 -0
  67. package/dist/indexer/ensure-index.js +5 -0
  68. package/dist/indexer/indexer.js +11 -3
  69. package/dist/indexer/lookup/adapter-concept-owner.js +14 -3
  70. package/dist/indexer/passes/metadata.js +16 -5
  71. package/dist/indexer/search/search-fields.js +1 -30
  72. package/dist/integrations/agent/engine-resolution.js +15 -1
  73. package/dist/integrations/agent/model-map.js +16 -10
  74. package/dist/integrations/agent/prompts.js +13 -6
  75. package/dist/integrations/lockfile.js +22 -7
  76. package/dist/llm/client.js +28 -8
  77. package/dist/llm/embedders/remote.js +3 -2
  78. package/dist/llm/index-passes.js +3 -2
  79. package/dist/output/shapes/passthrough.js +9 -3
  80. package/dist/output/shapes.js +50 -3
  81. package/dist/output/text/proposal-format.js +5 -0
  82. package/dist/output/text/workflow-format.js +8 -1
  83. package/dist/scripts/akm-migrate-node.js +1737 -1392
  84. package/dist/scripts/akm-migrate.js +1736 -1391
  85. package/dist/setup/setup.js +14 -21
  86. package/dist/sources/include.js +150 -20
  87. package/dist/sources/providers/git-install.js +14 -12
  88. package/dist/sources/providers/git-provider.js +3 -3
  89. package/dist/sources/snapshot-fetchers/website-ingest.js +54 -16
  90. package/dist/sources/website-url.js +12 -4
  91. package/dist/storage/engines/sqlite-migrations.js +40 -10
  92. package/dist/storage/like-pattern.js +7 -0
  93. package/dist/storage/repositories/extract-sessions-repository.js +23 -0
  94. package/dist/storage/repositories/index-connection.js +27 -10
  95. package/dist/storage/repositories/index-entry-schema.js +19 -2
  96. package/dist/storage/repositories/index-schema.js +30 -9
  97. package/dist/storage/repositories/proposals-repository.js +2 -1
  98. package/dist/storage/repositories/task-history-repository.js +14 -7
  99. package/dist/storage/repositories/workflow-runs-repository.js +133 -11
  100. package/dist/storage/sqlite-read-snapshot.js +11 -9
  101. package/dist/tasks/backends/cron.js +34 -5
  102. package/dist/tasks/backends/launchd.js +23 -26
  103. package/dist/tasks/backends/schtasks.js +50 -3
  104. package/dist/tasks/frozen-script.js +2 -0
  105. package/dist/tasks/prepare/prepare.js +2 -7
  106. package/dist/tasks/prepare/script-capture.js +38 -6
  107. package/dist/tasks/schedule.js +154 -13
  108. package/dist/tasks/source/task-source-v3-frozen.js +0 -1
  109. package/dist/tasks/source/task-source-v4.js +0 -1
  110. package/dist/workflows/exec/child-workflow.js +2 -3
  111. package/dist/workflows/exec/exec-unit.js +3 -4
  112. package/dist/workflows/exec/run-workflow.js +20 -11
  113. package/dist/workflows/exec/step-work.js +76 -56
  114. package/dist/workflows/freeze/resolve-steps.js +19 -11
  115. package/dist/workflows/freeze/source-freeze.js +7 -0
  116. package/dist/workflows/freeze/targets/child-workflow.js +12 -18
  117. package/dist/workflows/freeze/targets/command.js +14 -2
  118. package/dist/workflows/ir/environment-v4.js +4 -2
  119. package/dist/workflows/ir/freeze-v4.js +2 -5
  120. package/dist/workflows/ir/plan-hash.js +0 -3
  121. package/dist/workflows/ir/schema-v4.js +14 -9
  122. package/dist/workflows/ir/schema.js +1 -3
  123. package/dist/workflows/parser.js +1 -1
  124. package/dist/workflows/resource-limits.js +35 -48
  125. package/dist/workflows/runtime/plan-classifier.js +89 -41
  126. package/dist/workflows/runtime/run-outputs.js +1 -21
  127. package/dist/workflows/runtime/runs.js +104 -154
  128. package/dist/workflows/source-files.js +28 -54
  129. package/dist/workflows/source-ir/program.js +2 -2
  130. package/dist/workflows/source-ir/semantics.js +5 -23
  131. package/docs/migration/v0.9.1-to-v0.9.2.md +20 -0
  132. package/docs/reference/cli.md +92 -17
  133. package/package.json +1 -1
  134. package/schemas/akm-config.json +5 -10
@@ -2,6 +2,7 @@
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import { UsageError } from "../../core/errors.js";
5
+ import { warnOnce } from "../../core/warn.js";
5
6
  import { decodeCanonicalPlan } from "../ir/plan-hash.js";
6
7
  import { WORKFLOW_IR_V5_VERSION } from "../ir/schema-v4.js";
7
8
  /** Validate that a live run carries exactly the current frozen-plan format. */
@@ -21,31 +22,33 @@ export function classifyWorkflowRunPlan(row) {
21
22
  support: "unsupported-version",
22
23
  irVersion: row.plan_ir_version,
23
24
  // §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.`,
29
- };
30
- }
31
- if (row.plan_ir_version !== WORKFLOW_IR_V5_VERSION) {
32
- return {
33
- support: "corrupt-plan",
34
- irVersion: null,
35
- error: `Workflow run ${runId} does not declare a supported workflow IR version.`,
25
+ // plans keep status/list/abandon working but can no longer execute. A
26
+ // version ABOVE the current one (#919) is a distinct situation never
27
+ // an "upgrade" problem so it gets its own text below rather than
28
+ // being folded into the pre-5 wording.
29
+ error: row.plan_ir_version < WORKFLOW_IR_V5_VERSION
30
+ ? // Issue 8: leads with the remedy available to a user who has
31
+ `Workflow run ${runId} was frozen as workflow plan irVersion ${row.plan_ir_version}; pre-irVersion-5 ` +
32
+ `plans cannot execute after the 0.9.2 upgrade. Run 'akm workflow abandon ${runId}' and start a new ` +
33
+ `run from the authored workflow to continue. 'akm workflow status' and 'akm workflow list' still ` +
34
+ `work on this run.`
35
+ : `Workflow run ${runId} was frozen with workflow plan irVersion ${row.plan_ir_version}, which this akm ` +
36
+ `(irVersion ${WORKFLOW_IR_V5_VERSION}) does not understand; it was probably written by a newer akm. ` +
37
+ `Complete it with that akm version, or run 'akm workflow abandon ${runId}' and start a new run from ` +
38
+ `the authored workflow. 'akm workflow status' and 'akm workflow list' still work on this run.`,
36
39
  };
37
40
  }
38
41
  try {
39
42
  return {
40
43
  support: "supported",
41
- irVersion: row.plan_ir_version,
44
+ irVersion: WORKFLOW_IR_V5_VERSION,
42
45
  plan: decodeCanonicalPlan(runId, row.plan_json, row.plan_hash, row.plan_ir_version),
43
46
  };
44
47
  }
45
48
  catch (cause) {
46
49
  return {
47
50
  support: "corrupt-plan",
48
- irVersion: row.plan_ir_version,
51
+ irVersion: row.plan_ir_version ?? null,
49
52
  error: cause instanceof Error ? cause.message : String(cause),
50
53
  };
51
54
  }
@@ -80,37 +83,82 @@ export function frozenStepRows(plan) {
80
83
  sequenceIndex: step.sequenceIndex,
81
84
  }));
82
85
  }
83
- /** Verify the durable spine still agrees with the decoded/hash-verified plan before any mutation. */
84
- export function assertWorkflowSpineMatchesPlan(plan, run, rows) {
85
- const expected = frozenStepRows(plan);
86
+ /**
87
+ * Verify the durable spine's STEP IDENTITY still agrees with the
88
+ * decoded/hash-verified plan: the same number of steps, and the same set of
89
+ * step ids. A published plan's own step ids are fixed forever at freeze
90
+ * time, so a mismatch here means the row set itself is wrong — genuine
91
+ * corruption, not something a later akm release could have caused by
92
+ * changing how a field is FORMATTED (that is
93
+ * {@link reconcileWorkflowSpineWithPlan}'s concern, issue 7).
94
+ */
95
+ function assertSpineIdentityMatchesPlan(runId, expected, rows) {
86
96
  if (rows.length !== expected.length)
87
- corruptSpine(run.id, "step count differs from the frozen plan");
88
- for (let index = 0; index < expected.length; index++) {
89
- const actual = rows[index];
90
- const planned = expected[index];
91
- // The length check above (corruptSpine returns `never`) guarantees both are
92
- // present; the guard narrows them and preserves the "missing row" message.
93
- if (!actual || !planned) {
94
- corruptSpine(run.id, `step row ${index} differs from the frozen plan (missing row)`);
95
- }
96
- if (actual.step_id !== planned.stepId ||
97
- actual.step_title !== planned.stepTitle ||
98
- actual.instructions !== planned.instructions ||
99
- actual.completion_json !== planned.completionJson ||
100
- actual.sequence_index !== planned.sequenceIndex) {
101
- const fields = [
102
- actual.step_id !== planned.stepId ? "step_id" : "",
103
- actual.step_title !== planned.stepTitle ? "step_title" : "",
104
- actual.instructions !== planned.instructions ? "instructions" : "",
105
- actual.completion_json !== planned.completionJson ? "completion_json" : "",
106
- actual.sequence_index !== planned.sequenceIndex ? "sequence_index" : "",
107
- ].filter(Boolean);
108
- corruptSpine(run.id, `step row ${index} differs from the frozen plan (${fields.join(", ")})`);
97
+ corruptSpine(runId, "step count differs from the frozen plan");
98
+ const expectedIds = new Set(expected.map((step) => step.stepId));
99
+ for (const row of rows) {
100
+ if (!expectedIds.has(row.step_id))
101
+ corruptSpine(runId, `step "${row.step_id}" is not in the frozen plan`);
102
+ }
103
+ }
104
+ /**
105
+ * Reconcile the durable spine's PURE-DERIVATION fields (title, instructions,
106
+ * completion criteria, sequence position) against the plan (issue 7).
107
+ *
108
+ * These are recomputed from the plan by {@link frozenStepRows} on every
109
+ * read; a later akm release changing how one of them is FORMATTED from the
110
+ * SAME plan data used to mark every in-flight run from the previous release
111
+ * "corrupt" the moment anything (`akm workflow status`, `resume`, a step
112
+ * completion) touched it. A mismatch here is warned about, once per run,
113
+ * rather than blocking the caller — never step identity (row count, which
114
+ * step ids exist), which stays a hard failure in
115
+ * {@link assertSpineIdentityMatchesPlan} because the plan cannot have
116
+ * produced a different step id for an already-frozen run.
117
+ *
118
+ * The durable row not the plan is what a driving agent actually acts
119
+ * on: `getNextWorkflowStep` reads `instructions` straight off this row
120
+ * (`toWorkflowRunStepState`/`projectNextResult` in `runtime/runs.ts`), and
121
+ * `exec/run-workflow.ts`/`exec/step-work.ts` dispatch from that result. So
122
+ * warning and proceeding with the STORED row (rather than rewriting it to
123
+ * match the plan's current formatting) is the conservative choice: the
124
+ * durable spine is the contract this run has been executing against since
125
+ * freeze, and a newer akm formatting the same plan data differently should
126
+ * not retroactively change an in-flight run's instructions out from under
127
+ * it mid-execution. This module has no write path for that anyway
128
+ * (`workflow_run_steps` is owned by
129
+ * `storage/repositories/workflow-runs-repository.ts`) — and it should not
130
+ * gain one for this purpose. Step
131
+ * identity (row count, which step ids exist) is the part that would
132
+ * actually desynchronize execution from the plan, which is exactly why
133
+ * {@link assertSpineIdentityMatchesPlan} keeps that a hard failure.
134
+ */
135
+ export function reconcileWorkflowSpineWithPlan(plan, run, rows) {
136
+ const expected = frozenStepRows(plan);
137
+ assertSpineIdentityMatchesPlan(run.id, expected, rows);
138
+ const expectedById = new Map(expected.map((step) => [step.stepId, step]));
139
+ const drifted = [];
140
+ for (const row of rows) {
141
+ const planned = expectedById.get(row.step_id);
142
+ if (!planned)
143
+ continue; // unreachable after assertSpineIdentityMatchesPlan; kept defensive.
144
+ if (row.step_title !== planned.stepTitle ||
145
+ row.instructions !== planned.instructions ||
146
+ row.completion_json !== planned.completionJson ||
147
+ row.sequence_index !== planned.sequenceIndex) {
148
+ drifted.push(row.step_id);
109
149
  }
110
150
  }
111
- if (run.current_step_id !== null && !expected.some((step) => step.stepId === run.current_step_id))
112
- corruptSpine(run.id, `current step ${run.current_step_id} is not in the frozen plan`);
151
+ if (drifted.length > 0) {
152
+ warnOnce(`workflow-spine-drift:${run.id}`, `Workflow run ${run.id}: durable step row(s) [${drifted.join(", ")}] no longer match the frozen plan's ` +
153
+ "title/instructions/completion-criteria/sequence derivation (an akm upgrade likely changed how one of " +
154
+ "these is formatted from the same plan data). Continuing with the stored row(s) as-is — this run keeps " +
155
+ "executing against the instructions it was frozen with, rather than having them rewritten mid-flight.");
156
+ }
157
+ }
158
+ export function assertRunStatusMatchesSpine(run, rows) {
113
159
  const current = run.current_step_id ? rows.find((row) => row.step_id === run.current_step_id) : undefined;
160
+ if (run.current_step_id !== null && !current)
161
+ corruptSpine(run.id, `current step ${run.current_step_id} is not in the frozen plan`);
114
162
  if (run.status === "active") {
115
163
  const firstPending = rows.find((row) => row.status === "pending");
116
164
  if (!current || current.status !== "pending" || firstPending?.step_id !== current.step_id)
@@ -19,13 +19,6 @@
19
19
  */
20
20
  import { validateJsonSchemaSubset } from "../../core/json-schema.js";
21
21
  import { parseReference, resolveReferenceString } from "../program/expressions.js";
22
- /** Mirrors `runs.ts`'s `WORKFLOW_EVIDENCE_TRUNCATED_MARKER` byte-for-byte — see this file's header for why it is reproduced, not imported. */
23
- const EVIDENCE_TRUNCATED_MARKER = "__akm_evidence_truncated__";
24
- function isTruncatedEvidenceValue(value) {
25
- return (typeof value === "object" &&
26
- value !== null &&
27
- value[EVIDENCE_TRUNCATED_MARKER] === true);
28
- }
29
22
  /** Project a step artifact out of its persisted evidence — mirrors `exec/step-work.ts`'s `projectStepOutput`. */
30
23
  function projectStepOutput(evidence) {
31
24
  return Object.hasOwn(evidence, "output") ? evidence.output : evidence;
@@ -60,22 +53,11 @@ export function resolveWorkflowRunOutputs(plan, steps) {
60
53
  errors.push(`output "${name}": "${declaration.from}" is not a valid step-output reference.`);
61
54
  continue;
62
55
  }
63
- const rootValue = Object.hasOwn(stepOutputs, parsed.expr.stepId) ? stepOutputs[parsed.expr.stepId] : undefined;
64
- if (isTruncatedEvidenceValue(rootValue)) {
65
- errors.push(`output "${name}" reads step "${parsed.expr.stepId}"'s artifact, which was truncated — it exceeded the ` +
66
- `evidence persistence cap and was not stored.`);
67
- continue;
68
- }
69
56
  const resolved = resolveReferenceString(declaration.from, scope);
70
57
  if (!resolved.ok) {
71
58
  errors.push(`output "${name}": ${resolved.error.message}`);
72
59
  continue;
73
60
  }
74
- if (isTruncatedEvidenceValue(resolved.value)) {
75
- errors.push(`output "${name}" reads step "${parsed.expr.stepId}"'s artifact, which was truncated — it exceeded the ` +
76
- `evidence persistence cap and was not stored.`);
77
- continue;
78
- }
79
61
  if (declaration.schema) {
80
62
  const schemaErrors = validateJsonSchemaSubset(resolved.value, declaration.schema);
81
63
  if (schemaErrors.length > 0) {
@@ -86,9 +68,7 @@ export function resolveWorkflowRunOutputs(plan, steps) {
86
68
  }
87
69
  outputs[name] = resolved.value;
88
70
  }
89
- if (errors.length > 0)
90
- return { ok: false, errors };
91
- return { ok: true, outputs };
71
+ return { outputs, errors };
92
72
  }
93
73
  /**
94
74
  * What a completed run EXPORTS: the resolved declared outputs, or
@@ -16,11 +16,11 @@ import { collectWorkflowWarnings } from "../ir/compile.js";
16
16
  import { compileResolveFreezeWorkflowV4 } from "../ir/freeze-v4.js";
17
17
  import { materializeWorkflowParameterFlags, validateWorkflowParams } from "../ir/params.js";
18
18
  import { canonicalPlanJson, computePlanHash } from "../ir/plan-hash.js";
19
- import { clip, utf8Bytes, WORKFLOW_EVIDENCE_TRUNCATION_PREVIEW_CHARS, WORKFLOW_MAX_EVIDENCE_JSON_BYTES, WORKFLOW_UNIT_DIAGNOSTIC_CLIP, } from "../resource-limits.js";
19
+ import { clip, WORKFLOW_UNIT_DIAGNOSTIC_CLIP } from "../resource-limits.js";
20
20
  import { validateStepSummary } from "../validate-summary.js";
21
21
  import { resolveAgentIdentity } from "./agent-identity.js";
22
22
  import { evaluateCheckin } from "./checkin.js";
23
- import { assertWorkflowSpineMatchesPlan, classifyWorkflowRunPlan, frozenStepRows, requireExecutableWorkflowPlan, } from "./plan-classifier.js";
23
+ import { assertRunStatusMatchesSpine, classifyWorkflowRunPlan, frozenStepRows, reconcileWorkflowSpineWithPlan, requireExecutableWorkflowPlan, } from "./plan-classifier.js";
24
24
  import { resolveWorkflowRunOutputs } from "./run-outputs.js";
25
25
  import { evaluateStaleUnits } from "./unit-checkin.js";
26
26
  import { canonicalizeWorkflowRefInput, loadWorkflowAsset, resolveWorkflowEntryId } from "./workflow-asset-loader.js";
@@ -192,9 +192,6 @@ export async function getWorkflowStatus(runId, opts) {
192
192
  return detail;
193
193
  });
194
194
  }
195
- export async function hasWorkflowRun(runId) {
196
- return withWorkflowRunsRepo((repo) => repo.hasRun(runId));
197
- }
198
195
  export async function listWorkflowRuns(input) {
199
196
  const scopeKey = getCurrentWorkflowScopeKey();
200
197
  const activeOnly = input?.activeOnly === true;
@@ -248,13 +245,15 @@ export async function listWorkflowRuns(input) {
248
245
  }
249
246
  export async function getNextWorkflowStep(specifier, params, options) {
250
247
  return withWorkflowRunsRepo(async (repo) => {
251
- const { run, autoStarted, startWarnings } = await resolveRunSpecifier(repo, specifier, params, options?.parameterFlags);
248
+ const { run, autoStarted, resumed, startWarnings } = await resolveRunSpecifier(repo, specifier, params, options?.parameterFlags, options?.newRun);
252
249
  const steps = readWorkflowRunSteps(repo, run.id);
253
250
  const plan = requireExecutableWorkflowPlan(run);
254
- assertWorkflowSpineMatchesPlan(plan, run, steps);
251
+ reconcileWorkflowSpineWithPlan(plan, run, steps);
252
+ assertRunStatusMatchesSpine(run, steps);
255
253
  return {
256
254
  ...projectNextResult(run, steps),
257
255
  ...(autoStarted ? { autoStarted: true } : {}),
256
+ ...(resumed ? { resumed: true } : {}),
258
257
  ...(startWarnings?.length ? { startWarnings } : {}),
259
258
  };
260
259
  });
@@ -291,14 +290,15 @@ function projectNextResult(run, steps) {
291
290
  }
292
291
  export async function resumeWorkflowRun(runId) {
293
292
  return withWorkflowRunsRepo((repo) => {
294
- const run = readWorkflowRun(repo, runId);
293
+ const run = readWorkflowRunOrPrefix(repo, runId);
295
294
  const storedPlan = requireExecutableWorkflowPlan(run);
296
295
  const steps = readWorkflowRunSteps(repo, run.id);
297
- assertWorkflowSpineMatchesPlan(storedPlan, run, steps);
296
+ reconcileWorkflowSpineWithPlan(storedPlan, run, steps);
298
297
  if (run.status === "completed") {
299
298
  throw new UsageError(`Workflow run ${run.id} is already completed and cannot be resumed.`);
300
299
  }
301
300
  if (run.status === "active") {
301
+ assertRunStatusMatchesSpine(run, steps);
302
302
  return buildWorkflowRunDetail(repo, run, steps);
303
303
  }
304
304
  // blocked or failed → flip back to active and re-open the current step so
@@ -312,6 +312,7 @@ export async function resumeWorkflowRun(runId) {
312
312
  });
313
313
  const updated = { ...run, status: "active", updated_at: now };
314
314
  const refreshedSteps = readWorkflowRunSteps(repo, run.id);
315
+ assertRunStatusMatchesSpine(updated, refreshedSteps);
315
316
  return buildWorkflowRunDetail(repo, updated, refreshedSteps);
316
317
  });
317
318
  }
@@ -325,7 +326,7 @@ export async function abandonWorkflowRun(runId) {
325
326
  return withWorkflowRunsRepo((repo) => {
326
327
  const now = new Date().toISOString();
327
328
  const run = repo.immediateTransaction((db) => {
328
- const current = readWorkflowRun(repo, runId);
329
+ const current = readWorkflowRunOrPrefix(repo, runId);
329
330
  if (current.status === "completed" || current.status === "failed") {
330
331
  throw new UsageError(`Workflow run ${current.id} is already ${current.status}.`);
331
332
  }
@@ -354,115 +355,6 @@ export async function abandonWorkflowRun(runId) {
354
355
  return detail;
355
356
  });
356
357
  }
357
- // ── Step-evidence persistence bound (issue C) ────────────────────────────────
358
- /**
359
- * Marker key stamped on every value this module replaced because it did not fit
360
- * in `workflow_run_steps.evidence_json`. It is deliberately ugly and unique so a
361
- * truncated value can NEVER be mistaken for real workflow data by a downstream
362
- * `steps.<id>.output…` reference, by `akm workflow status`, or by a human
363
- * reading the row.
364
- */
365
- export const WORKFLOW_EVIDENCE_TRUNCATED_MARKER = "__akm_evidence_truncated__";
366
- function truncatedEvidenceValue(json, what, limitBytes, withPreview) {
367
- return {
368
- [WORKFLOW_EVIDENCE_TRUNCATED_MARKER]: true,
369
- reason: `${what} exceeded the ${limitBytes}-byte evidence_json persistence cap and was NOT stored. ` +
370
- `The complete value existed only in the live step result; it cannot be recovered from this row. ` +
371
- `Reduce the step's fan-out or have it emit a reference (path, id) instead of inline bulk data.`,
372
- originalBytes: utf8Bytes(json),
373
- limitBytes,
374
- ...(withPreview ? { preview: json.slice(0, WORKFLOW_EVIDENCE_TRUNCATION_PREVIEW_CHARS) } : {}),
375
- };
376
- }
377
- /**
378
- * True when `value` is the {@link TruncatedEvidenceValue} envelope persisted in
379
- * place of an over-cap evidence entry.
380
- *
381
- * A LIVE invocation never sees one: the engine threads each step's complete
382
- * in-memory evidence to the rest of its own run. A RESUMED invocation rebuilds
383
- * the downstream scope from these rows, so `exec/step-work.ts` tests every
384
- * whole-value reference with this predicate — otherwise a reference INTO the
385
- * envelope reports a generic missing property and a reference AT it silently
386
- * hands the envelope to a unit as if it were the artifact.
387
- */
388
- export function isTruncatedEvidence(value) {
389
- return (typeof value === "object" &&
390
- value !== null &&
391
- value[WORKFLOW_EVIDENCE_TRUNCATED_MARKER] === true);
392
- }
393
- /**
394
- * Bound what a step's evidence costs in ONE SQLite row.
395
- *
396
- * `buildEvidence` (exec/step-work.ts) promotes `evidence.output` UNCLIPPED by
397
- * design: gates judge the full promoted artifact and the in-memory
398
- * {@link StepExecutionResult} carries it to the caller intact. Nothing bounded
399
- * the PERSISTED form, though — a `collect` reducer over an unbounded fan-out
400
- * can serialize to hundreds of megabytes. This is the write boundary, so the
401
- * bound lives here rather than in the shared step-semantics module.
402
- *
403
- * Over-cap values are REPLACED (largest top-level entry BY UTF-8 BYTES first —
404
- * the unit the cap is measured in — until the row fits) with a
405
- * {@link TruncatedEvidenceValue} envelope. Nothing is silently shortened: a
406
- * consumer either sees the real value or sees an object whose marker key says
407
- * the data is gone. `preview` is intentionally not shaped like the original, so
408
- * an expression reaching INTO a truncated artifact (`steps.x.output.files`)
409
- * cannot quietly resolve against a half-array; a resumed run's reference is
410
- * rejected by name through {@link isTruncatedEvidence}.
411
- *
412
- * Returns the JSON to persist plus the keys that were replaced (empty in the
413
- * overwhelmingly common case, where nothing is copied or re-serialized twice).
414
- */
415
- export function clipStepEvidenceForPersistence(evidence, limitBytes = WORKFLOW_MAX_EVIDENCE_JSON_BYTES) {
416
- if (!evidence)
417
- return { json: null, truncatedKeys: [] };
418
- // Throws exactly as the previous inline `JSON.stringify` did on unserializable
419
- // evidence — that contract is unchanged. Every stringify below operates on a
420
- // subtree of a value already proven serializable here.
421
- let json = JSON.stringify(evidence);
422
- if (json === undefined)
423
- return { json: null, truncatedKeys: [] };
424
- let bytes = utf8Bytes(json);
425
- if (bytes <= limitBytes)
426
- return { json, truncatedKeys: [] };
427
- const clipped = { ...evidence };
428
- const truncatedKeys = [];
429
- // Ordered by UTF-8 BYTES, the unit the cap itself is measured in: ordering by
430
- // `json.length` (UTF-16 code units) sacrifices the char-largest key rather
431
- // than the byte-largest one, so multibyte-heavy evidence loses extra keys the
432
- // cap never required.
433
- const bySizeDesc = Object.keys(evidence)
434
- .map((key) => {
435
- const json = JSON.stringify(evidence[key]) ?? "null";
436
- return { key, json, bytes: utf8Bytes(json) };
437
- })
438
- .sort((a, b) => b.bytes - a.bytes);
439
- for (const [index, entry] of bySizeDesc.entries()) {
440
- const envelope = truncatedEvidenceValue(entry.json, `Step evidence "${entry.key}"`, limitBytes, true);
441
- clipped[entry.key] = envelope;
442
- truncatedKeys.push(entry.key);
443
- // Track the row size arithmetically from the per-key sizes already computed
444
- // for the sort, so a run of replacements costs ONE whole-object
445
- // serialization rather than one per replaced key. The total is an ESTIMATE
446
- // — a key whose value is `undefined` is charged the `"null"` the sort used
447
- // but is OMITTED from the serialized row — so it decides only WHEN to
448
- // measure. Whether the row FITS is settled by an exact serialization every
449
- // time, the last key included, so an exhausted loop falls through to the
450
- // whole-object marker on measurement rather than on drift.
451
- bytes += utf8Bytes(JSON.stringify(envelope)) - entry.bytes;
452
- if (bytes > limitBytes && index < bySizeDesc.length - 1)
453
- continue;
454
- json = JSON.stringify(clipped);
455
- bytes = utf8Bytes(json);
456
- if (bytes <= limitBytes)
457
- return { json, truncatedKeys };
458
- }
459
- // Pathological shape (so many keys that even the envelopes overflow): persist
460
- // ONE whole-object marker. Still unambiguous, still bounded.
461
- return {
462
- json: JSON.stringify(truncatedEvidenceValue(JSON.stringify(evidence), "Step evidence", limitBytes, false)),
463
- truncatedKeys: Object.keys(evidence),
464
- };
465
- }
466
358
  export async function completeWorkflowStep(input) {
467
359
  // Read the step (read-only) up front so the LLM validation gate runs OUTSIDE
468
360
  // the write transaction — a slow/hung LLM must never hold a db write lock.
@@ -470,7 +362,8 @@ export async function completeWorkflowStep(input) {
470
362
  const run = readWorkflowRun(repo, input.runId);
471
363
  const storedPlan = requireExecutableWorkflowPlan(run);
472
364
  const steps = readWorkflowRunSteps(repo, run.id);
473
- assertWorkflowSpineMatchesPlan(storedPlan, run, steps);
365
+ reconcileWorkflowSpineWithPlan(storedPlan, run, steps);
366
+ assertRunStatusMatchesSpine(run, steps);
474
367
  if (run.status !== "active") {
475
368
  throw new UsageError(`Workflow run ${run.id} is ${run.status} and cannot be updated.`);
476
369
  }
@@ -535,11 +428,13 @@ export async function completeWorkflowStep(input) {
535
428
  return withWorkflowRunsRepo((repo) => {
536
429
  let updatedRun;
537
430
  let refreshedSteps = [];
431
+ let outputWarnings;
538
432
  repo.transaction(() => {
539
433
  const run = readWorkflowRun(repo, input.runId);
540
434
  const plan = requireExecutableWorkflowPlan(run);
541
435
  const spine = readWorkflowRunSteps(repo, run.id);
542
- assertWorkflowSpineMatchesPlan(plan, run, spine);
436
+ reconcileWorkflowSpineWithPlan(plan, run, spine);
437
+ assertRunStatusMatchesSpine(run, spine);
543
438
  if (run.status !== "active") {
544
439
  throw new UsageError(`Workflow run ${run.id} is ${run.status} and cannot be updated.`);
545
440
  }
@@ -560,26 +455,18 @@ export async function completeWorkflowStep(input) {
560
455
  if (input.signal?.aborted)
561
456
  throw interruptionReason(input.signal);
562
457
  const completedAt = new Date().toISOString();
563
- // Bound the single-row cost of the promoted artifact (issue C). The
564
- // caller's in-memory evidence object is never mutated a clipped COPY is
565
- // serialized so the live step result and the gate's artifact judging
566
- // keep the complete value. The DOWNSTREAM scope keeps it only because the
567
- // engine threads this same in-memory evidence forward (`driveRun` prefers
568
- // it over the re-read row): what the clip actually costs is a LATER
569
- // invocation, which has nothing but these rows to rebuild the scope from.
570
- const persistedEvidence = clipStepEvidenceForPersistence(input.evidence);
571
- if (persistedEvidence.truncatedKeys.length > 0) {
572
- warn(`Workflow run ${run.id} step "${input.stepId}": evidence exceeded the ` +
573
- `${WORKFLOW_MAX_EVIDENCE_JSON_BYTES}-byte persistence cap; ` +
574
- `${persistedEvidence.truncatedKeys.map((k) => `"${k}"`).join(", ")} ` +
575
- `${persistedEvidence.truncatedKeys.length === 1 ? "was" : "were"} stored as a truncation marker. ` +
576
- `The rest of THIS invocation still reads the complete value, but a run resumed from these rows will ` +
577
- `fail loudly when a later step references this step's output rather than read partial data.`);
578
- }
458
+ // The promoted artifact is persisted WHOLE, unclipped (issue C): a
459
+ // step artifact that does not fit some cap used to be replaced by a
460
+ // truncation marker at this exact write, and the run looked fine right
461
+ // up until a LATER invocation (a resume, or any downstream step
462
+ // referencing it) found the marker instead of the value and failed
463
+ // permanently, with every prior paid step now unrecoverable. Persisting
464
+ // the real value here is what makes it readable again on resume.
465
+ const evidenceJson = input.evidence ? JSON.stringify(input.evidence) : null;
579
466
  repo.updateStepCompletion({
580
467
  status: input.status,
581
468
  notes: input.notes?.trim() || null,
582
- evidenceJson: persistedEvidence.json,
469
+ evidenceJson,
583
470
  summary: summary || null,
584
471
  completedAt,
585
472
  runId: run.id,
@@ -597,12 +484,9 @@ export async function completeWorkflowStep(input) {
597
484
  let outputsJson; // undefined = untouched, keep the row's existing value
598
485
  if (state.status === "completed" && plan.outputs) {
599
486
  const resolved = resolveWorkflowRunOutputs(plan, refreshedSteps);
600
- if (!resolved.ok) {
601
- throw new UsageError(`Workflow run ${run.id} completed its final step but its declared outputs could not be resolved:\n` +
602
- resolved.errors.map((e) => ` - ${e}`).join("\n"), "WORKFLOW_OUTPUT_INVALID");
603
- }
604
487
  outputsJson = JSON.stringify(resolved.outputs);
605
488
  repo.setRunOutputs(run.id, outputsJson);
489
+ outputWarnings = resolved.errors;
606
490
  }
607
491
  // Re-arm the check-in on every state change: a healthy, progressing run
608
492
  // keeps pushing the stall window forward so the directive never fires.
@@ -625,6 +509,12 @@ export async function completeWorkflowStep(input) {
625
509
  };
626
510
  });
627
511
  const detail = buildWorkflowRunDetail(repo, updatedRun, refreshedSteps);
512
+ if (outputWarnings?.length) {
513
+ const messages = outputWarnings.map((e) => `Workflow run ${input.runId} declared ${e}`);
514
+ for (const message of messages)
515
+ warn(message);
516
+ detail.warnings = [...(detail.warnings ?? []), ...messages];
517
+ }
628
518
  // #11: emit `workflow_step_completed` ONLY for a genuine `completed`
629
519
  // transition; every other non-pending status (failed/skipped/blocked)
630
520
  // carries the honest `workflow_step_updated` name. The status is ALWAYS
@@ -642,10 +532,15 @@ export async function completeWorkflowStep(input) {
642
532
  return detail;
643
533
  });
644
534
  }
645
- async function resolveRunSpecifier(repo, specifier, params, parameterFlags) {
535
+ async function resolveRunSpecifier(repo, specifier, params, parameterFlags, forceNew) {
646
536
  const hasParameters = (params && Object.keys(params).length > 0) || (parameterFlags?.length ?? 0) > 0;
647
- const explicitRun = repo.getRunById(specifier);
537
+ const explicitRun = findRunByIdOrPrefix(repo, specifier);
648
538
  if (explicitRun) {
539
+ // `--new` starts a fresh run FROM A REF; it never makes sense against a
540
+ // run id (or an id prefix), which already names the run to act on (#919).
541
+ if (forceNew) {
542
+ throw new UsageError(`--new starts a fresh run from a workflow ref; "${specifier}" already names a run id.`, "INVALID_FLAG_VALUE");
543
+ }
649
544
  if (hasParameters) {
650
545
  throw new UsageError(`Workflow parameter flags can only be used when starting a new run, not with existing run id "${specifier}".`);
651
546
  }
@@ -655,7 +550,7 @@ async function resolveRunSpecifier(repo, specifier, params, parameterFlags) {
655
550
  const exactRef = specifier.trim();
656
551
  const parsedExact = parseBundleRef(exactRef);
657
552
  const qualifiedExact = parsedExact.bundle !== undefined && parsedExact.fragment === undefined;
658
- const detached = qualifiedExact ? repo.getActiveRunRowForScope(exactRef, scopeKey) : undefined;
553
+ const detached = qualifiedExact && !forceNew ? repo.getActiveRunRowForScope(exactRef, scopeKey) : undefined;
659
554
  let ref;
660
555
  try {
661
556
  ref = await canonicalizeWorkflowSpecifier(specifier);
@@ -663,24 +558,29 @@ async function resolveRunSpecifier(repo, specifier, params, parameterFlags) {
663
558
  catch (error) {
664
559
  if (detached) {
665
560
  if (hasParameters) {
666
- throw new UsageError(`Workflow parameter flags can only be set on a new run; ${specifier} is already active.`);
561
+ throw new UsageError(`Workflow parameter flags can only be set on a new run; ${specifier} is already active.`, "INVALID_FLAG_VALUE", `Pass --new to start a separate run, or run "akm workflow abandon ${detached.id}" to free up ${specifier} first.`);
667
562
  }
668
- return { run: detached, autoStarted: false };
563
+ return { run: detached, autoStarted: false, resumed: true };
669
564
  }
670
565
  if (error instanceof NotFoundError && !specifier.includes(":") && !specifier.includes("/")) {
671
566
  throw new NotFoundError(`Workflow run or workflow "${specifier}" not found.`, "WORKFLOW_NOT_FOUND");
672
567
  }
673
568
  throw error;
674
569
  }
675
- const active = repo.getActiveRunRowForScope(await workflowRunRefSet(ref, exactRef), scopeKey);
570
+ const active = forceNew ? undefined : repo.getActiveRunRowForScope(await workflowRunRefSet(ref, exactRef), scopeKey);
676
571
  if (active) {
677
572
  if (hasParameters) {
678
- throw new UsageError(`Workflow parameter flags can only be set on a new run; ${ref} is already active.`);
573
+ throw new UsageError(`Workflow parameter flags can only be set on a new run; ${ref} is already active.`, "INVALID_FLAG_VALUE", `Pass --new to start a separate run, or run "akm workflow abandon ${active.id}" to free up ${ref} first.`);
679
574
  }
680
- return { run: active, autoStarted: false };
575
+ return { run: active, autoStarted: false, resumed: true };
681
576
  }
577
+ // `force` (#485's own escape hatch) is what lets `--new` create a second
578
+ // active run for this (ref, scope) pair instead of the concurrency guard
579
+ // refusing it — the caller explicitly asked for a fresh run, so the guard
580
+ // that exists to catch an ACCIDENTAL second run does not apply (#919).
682
581
  const started = await startWorkflowRun(ref, params ?? {}, {
683
582
  ...(parameterFlags !== undefined ? { parameterFlags } : {}),
583
+ ...(forceNew ? { force: true } : {}),
684
584
  });
685
585
  return {
686
586
  run: readWorkflowRun(repo, started.run.id),
@@ -713,12 +613,57 @@ async function workflowRunRefSet(canonicalRef, exactRef) {
713
613
  }
714
614
  return [...refs];
715
615
  }
616
+ /**
617
+ * A workflow run id (or an accepted prefix of one, #919) is hex digits and
618
+ * hyphens, 8+ characters. A workflow ref always contains at least one
619
+ * character outside that set (a `/` path segment, at minimum), so this
620
+ * never mistakes a ref for an id — matching input is ALWAYS resolved as an
621
+ * id/prefix, never falls through to ref resolution.
622
+ */
623
+ const RUN_ID_PREFIX_PATTERN = /^[0-9a-f-]{8,}$/;
624
+ /** The run `specifier` names: by exact id, or by unique id prefix (#919) when it is id-shaped; `undefined` for a workflow ref. */
625
+ function findRunByIdOrPrefix(repo, specifier) {
626
+ const exact = repo.getRunById(specifier);
627
+ if (exact || !RUN_ID_PREFIX_PATTERN.test(specifier))
628
+ return exact;
629
+ return repo.getRunById(repo.resolveRunIdPrefix(specifier));
630
+ }
631
+ /** For verbs that take a run id/prefix OR a workflow ref (`status`): the run id, or `undefined` to fall through to ref resolution. */
632
+ export async function resolveWorkflowRunTarget(specifier) {
633
+ return withWorkflowRunsRepo((repo) => findRunByIdOrPrefix(repo, specifier)?.id);
634
+ }
635
+ /** User-facing read: accepts an id prefix. Internal reads use {@link readWorkflowRun} with an exact id. */
636
+ function readWorkflowRunOrPrefix(repo, specifier) {
637
+ const run = findRunByIdOrPrefix(repo, specifier);
638
+ if (!run)
639
+ throw new NotFoundError(`Workflow run "${specifier}" not found.`, "WORKFLOW_NOT_FOUND");
640
+ return reclaimOrphanedEngineLease(repo, run);
641
+ }
716
642
  function readWorkflowRun(repo, runId) {
717
643
  const run = repo.getRunById(runId);
718
644
  if (!run) {
719
645
  throw new NotFoundError(`Workflow run "${runId}" not found.`, "WORKFLOW_NOT_FOUND");
720
646
  }
721
- return run;
647
+ return reclaimOrphanedEngineLease(repo, run);
648
+ }
649
+ /**
650
+ * Self-heal a run's engine lease once it has expired — the orphaned-lease
651
+ * case where an engine crashed without releasing it — mirroring the
652
+ * maintenance barrier's self-reclaim of a wedged sentinel, applied at the
653
+ * points a caller actually asks "what is this run's state". Never touches a
654
+ * live lease: {@link WorkflowRunsRepository.reclaimExpiredEngineLease} is a
655
+ * compare-and-swap on the exact (holder, until) this call observed, so a
656
+ * lease renewed or re-acquired between the read and this write is left alone.
657
+ */
658
+ function reclaimOrphanedEngineLease(repo, run) {
659
+ const { engine_lease_holder: holder, engine_lease_until: until } = run;
660
+ if (!holder || !until)
661
+ return run;
662
+ const now = new Date().toISOString();
663
+ if (until >= now)
664
+ return run;
665
+ repo.reclaimExpiredEngineLease(run.id, holder, until, now);
666
+ return { ...run, engine_lease_holder: null, engine_lease_until: null };
722
667
  }
723
668
  function readWorkflowRunSteps(repo, runId) {
724
669
  return repo.getStepsForRun(runId);
@@ -832,8 +777,13 @@ function toWorkflowRunSummary(run) {
832
777
  executionSupport: plan.support,
833
778
  // Surface the engine lease (holder id + expiry — never workflow-authored
834
779
  // content) so `workflow run`/`status` show which native execution
835
- // invocation currently holds the run lease.
836
- ...(run.engine_lease_holder && run.engine_lease_until
780
+ // invocation currently holds the run lease. Gated on `until` still being
781
+ // in the future: a crashed engine's lease self-expires, and a run
782
+ // whose holder is provably gone must stop reading as engine-driven the
783
+ // instant that happens, not just once something next attempts to acquire
784
+ // it (`readWorkflowRun`/`readWorkflowRunOrPrefix` also reclaim the DB
785
+ // columns outright on the same condition).
786
+ ...(run.engine_lease_holder && run.engine_lease_until && run.engine_lease_until >= new Date().toISOString()
837
787
  ? { engineLease: { holder: run.engine_lease_holder, until: run.engine_lease_until } }
838
788
  : {}),
839
789
  // P3b (spec §4.5): all three optional and conditionally spread, so every
@@ -860,7 +810,7 @@ function assertLeaseAllowsSpineAdvance(run, leaseHolder) {
860
810
  return; // expired ⇒ claimable, not live
861
811
  throw new UsageError(`Workflow run ${run.id} is being driven by engine ${run.engine_lease_holder} ` +
862
812
  `(run lease expires ${run.engine_lease_until}). The engine owns the step spine while it runs — ` +
863
- `wait for it to finish or for the lease to expire before advancing steps manually.`);
813
+ `wait for it to finish or for the lease to expire before advancing steps manually.`, "RUN_LEASE_HELD");
864
814
  }
865
815
  function toWorkflowRunStepState(step) {
866
816
  return {