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
@@ -0,0 +1,79 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * `akm task run`'s own declared flag names, exposed as a dependency-free leaf
6
+ * module so `src/tasks/source/task-source-v4.ts` (the parser) can reject a
7
+ * declared `inputs:` name that collides with one, without creating an import
8
+ * cycle back through the CLI layer.
9
+ *
10
+ * Code-review finding (docs/plans/specs/p2b-input-bindings.md, P2b review
11
+ * round 2): `schedulerInputFlagTail` (`../tasks/scheduler-binding.ts`) and
12
+ * `akm task run <id> --<name> <value>` both let an authored/declared input
13
+ * NAME collide with a flag `akm task run` already binds to itself
14
+ * (`--bundle`, `--scheduled`, …). `parseTaskInputFlags`
15
+ * (`../commands/tasks/tasks-cli.ts`) always treats those names as its OWN
16
+ * flags, never as input flags — so a colliding name is either silently
17
+ * absorbed into the wrong flag (`--bundle other-bundle` re-targets which
18
+ * bundle the task loads from) or left as an orphaned positional token that
19
+ * throws `Unexpected positional task argument`. Rejecting the collision at
20
+ * DECLARATION time (in `parseInputDeclarations`) closes every path that can
21
+ * ever reach it — a bare `akm task run --<name>`, `akm task explain
22
+ * --<name>`, and a `schedule[i].inputs` entry (whose keys are already
23
+ * checked against the declared contract, so a name banned here can never
24
+ * appear there either) — instead of special-casing each caller separately.
25
+ *
26
+ * `src/commands/tasks/tasks-cli.ts` re-exports `TASK_RUN_VALUE_FLAGS` /
27
+ * `TASK_RUN_BOOLEAN_FLAGS` from here unchanged, so this module is the single
28
+ * source of truth both the CLI's own argv scanner and the source parser read
29
+ * — the two can never drift apart. `TASK_RUN_SELF_DIAGNOSED_FLAGS` (below)
30
+ * covers the third category the scanner sets cannot express: a name the CLI
31
+ * claims by REJECTING it rather than by declaring it. This file imports
32
+ * nothing, and nothing it
33
+ * exports depends on IO, config, or any other `src/tasks/**` module, so it
34
+ * can be imported from either side of that boundary without participating in
35
+ * a cycle.
36
+ */
37
+ /** Every VALUE-taking flag `akm task run` declares (GLOBAL_OUTPUT_ARGS' value flags plus `--bundle`). */
38
+ export const TASK_RUN_VALUE_FLAGS = ["bundle", "format", "detail", "shape", "output"];
39
+ /** Every BOOLEAN flag `akm task run` declares, including citty's `--no-` negations of the boolean pair above. */
40
+ export const TASK_RUN_BOOLEAN_FLAGS = [
41
+ "scheduled",
42
+ "quiet",
43
+ "verbose",
44
+ "help",
45
+ "no-quiet",
46
+ "no-verbose",
47
+ ];
48
+ /**
49
+ * Flag names `akm task` claims WITHOUT declaring them as citty args, so they
50
+ * appear in neither list above and must be reserved separately.
51
+ *
52
+ * `target` is the only member: the 0.9 rename of `akm task --target` to
53
+ * `--bundle` (S8.4) is self-diagnosed rather than merely dropped —
54
+ * `rejectRetiredTaskTargetFlag` (`../commands/tasks/tasks-cli.ts`) throws the
55
+ * rename hint for a `--target` in ANY spelling, bare or `--target=<value>`,
56
+ * and the generic pre-dispatch gate exempts the name on every `task`
57
+ * subcommand precisely so that handler can answer (`../cli/unknown-flags.ts`'s
58
+ * `SELF_DIAGNOSED_FLAGS`). That rejection runs before `parseTaskInputFlags`
59
+ * ever scans argv, so a declared input named `target` is unreachable through
60
+ * every spelling of its own flag — the exact value-misrouting hole the union
61
+ * below exists to close, just reached from the CLI's diagnostic side instead
62
+ * of from its declared-arg side (0.9.2 review round 2). Rejecting the
63
+ * DECLARATION keeps the rename hint intact for everyone typing the retired
64
+ * spelling while making the unusable declaration impossible to author, rather
65
+ * than trading one silent failure for another.
66
+ *
67
+ * It deliberately stays OUT of `TASK_RUN_VALUE_FLAGS` /
68
+ * `TASK_RUN_BOOLEAN_FLAGS`: those two are `parseTaskInputFlags`' own scanner
69
+ * sets (a value flag makes the scanner swallow the following token), and
70
+ * `--target` must keep reaching the rename diagnostic rather than being
71
+ * silently consumed as one of `akm task run`'s own flags.
72
+ */
73
+ export const TASK_RUN_SELF_DIAGNOSED_FLAGS = ["target"];
74
+ /** The union of all three lists above, for a single membership check against a candidate input name. */
75
+ export const TASK_RUN_RESERVED_FLAG_NAMES = new Set([
76
+ ...TASK_RUN_VALUE_FLAGS,
77
+ ...TASK_RUN_BOOLEAN_FLAGS,
78
+ ...TASK_RUN_SELF_DIAGNOSED_FLAGS,
79
+ ]);
@@ -50,14 +50,23 @@ export function assertWorkflowMarkdownName(name) {
50
50
  throw new UsageError(`akm workflow create is markdown-only: it emits Markdown and cannot create "${name}". ` +
51
51
  `Use a plain name (no ".yaml"/".yml" suffix), or author a peer GitHub-shaped ".yml" workflow directly.`);
52
52
  }
53
+ // 0.9.2 review round 2 (P4 sweep criterion-21 caveat, spec
54
+ // docs/plans/specs/p4-deletions-closeout.md §4.1 row B-49): this return's
55
+ // third member was `stashDir` through 0.9.1 and leaked verbatim into the
56
+ // `akm workflow create` JSON envelope (Stable per STABILITY.md) via
57
+ // `output("workflow-create", { ok: true, ...result })`
58
+ // (src/commands/workflow-cli.ts). Renamed to `bundleDir` here — the same
59
+ // value-preserving field rename `RunTaskOptions.stashDir` got in P1b (see
60
+ // src/tasks/run/task-result.ts's header, F-3) — so the shipped envelope now
61
+ // matches current bundle vocabulary. BREAKING vs 0.9.1's envelope shape.
53
62
  export function createWorkflowAsset(input) {
54
63
  assertWorkflowMarkdownName(input.name);
55
64
  const config = loadConfig();
56
65
  const resolvedTarget = resolveWriteTarget(config);
57
66
  const target = prepareWriteTargetForMutation(resolvedTarget, { allowedAdapters: ["akm", "akm-workflow"] });
58
- const stashDir = target.source.path;
67
+ const bundleDir = target.source.path;
59
68
  const standaloneWorkflowBundle = target.source.adapterId === "akm-workflow";
60
- const typeRoot = standaloneWorkflowBundle ? stashDir : path.join(stashDir, "workflows");
69
+ const typeRoot = standaloneWorkflowBundle ? bundleDir : path.join(bundleDir, "workflows");
61
70
  const normalizedName = normalizeWorkflowName(input.name);
62
71
  const conceptId = standaloneWorkflowBundle ? normalizedName : `workflows/${normalizedName}`;
63
72
  const assetPath = path.join(typeRoot, `${normalizedName}.md`);
@@ -73,7 +82,7 @@ export function createWorkflowAsset(input) {
73
82
  // target would silently clobber or shadow the existing asset.
74
83
  const shadowing = findExistingWorkflowPaths(typeRoot, normalizedName).find((p) => p !== assetPath);
75
84
  if (shadowing !== undefined) {
76
- throw new UsageError(`Workflow "${normalizedName}" already exists as ${path.relative(stashDir, shadowing)} — the ` +
85
+ throw new UsageError(`Workflow "${normalizedName}" already exists as ${path.relative(bundleDir, shadowing)} — the ` +
77
86
  `\`${conceptId}\` ref resolves to that file, so creating this one would shadow it. ` +
78
87
  `Remove or rename the existing file first, or create the workflow under a different name.`, "RESOURCE_ALREADY_EXISTS");
79
88
  }
@@ -81,7 +90,7 @@ export function createWorkflowAsset(input) {
81
90
  throw new UsageError(`Workflow "${normalizedName}" already exists. Re-run with --force to overwrite it.`, "RESOURCE_ALREADY_EXISTS");
82
91
  }
83
92
  const content = input.from
84
- ? readWorkflowSource(input.from, stashDir)
93
+ ? readWorkflowSource(input.from, bundleDir)
85
94
  : (input.content ?? buildWorkflowTemplate(normalizedName));
86
95
  const sourcePath = input.from ?? `workflows/${normalizedName}.md`;
87
96
  validateWorkflowContent(content, sourcePath);
@@ -96,10 +105,10 @@ export function createWorkflowAsset(input) {
96
105
  return {
97
106
  ref,
98
107
  path: assetPath,
99
- stashDir,
108
+ bundleDir,
100
109
  };
101
110
  }
102
- function readWorkflowSource(source, stashDir) {
111
+ function readWorkflowSource(source, bundleDir) {
103
112
  const resolved = path.resolve(source);
104
113
  let stat;
105
114
  try {
@@ -114,9 +123,9 @@ function readWorkflowSource(source, stashDir) {
114
123
  // The user is allowed to import any readable file as a workflow body, but
115
124
  // an import from outside the stash is unusual enough to warn about. Anyone
116
125
  // running `akm workflow create --from /etc/passwd` deserves a heads-up.
117
- if (!isWithin(resolved, stashDir)) {
126
+ if (!isWithin(resolved, bundleDir)) {
118
127
  warn(`Importing workflow content from outside the stash: ${resolved}\n ` +
119
- `If this was unintentional, abort and re-run with a --from path inside ${stashDir}.`);
128
+ `If this was unintentional, abort and re-run with a --from path inside ${bundleDir}.`);
120
129
  }
121
130
  return fs.readFileSync(resolved, "utf8");
122
131
  }
@@ -0,0 +1,34 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * A child workflow run's idempotency key (spec
6
+ * docs/plans/specs/p3a-plan-v5-child-freeze.md §3.4, rows A-17…A-19). Pure:
7
+ * no IO, no config, no clock, no randomness — imports exactly node:crypto
8
+ * and canonicalJson.
9
+ *
10
+ * P3a has no production caller: P3b's child executor derives this key from
11
+ * the parent unit's `hashVersion` 7 input hash and passes it to
12
+ * `publishChildWorkflowRun` (src/storage/repositories/workflow-runs-repository.ts,
13
+ * Lane C) as the `(parent_run_id, invocation_key)` idempotency pair.
14
+ */
15
+ import { createHash } from "node:crypto";
16
+ import { canonicalJson } from "../ir/plan-hash.js";
17
+ /**
18
+ * `sha256hex("akm.workflow.child-invocation\0v1\0" + canonicalJson({parentRunId, parentUnitId, unitInputHash}))`.
19
+ *
20
+ * The `\0v1\0` here is this helper's OWN vocabulary version, deliberately
21
+ * independent of `hashVersion`: `unitInputHash` enters this preimage as an
22
+ * opaque value, so this key's preimage does not change when the unit-hash
23
+ * vocabulary itself bumps.
24
+ */
25
+ export function computeChildInvocationKey(input) {
26
+ return createHash("sha256")
27
+ .update("akm.workflow.child-invocation\0v1\0")
28
+ .update(canonicalJson({
29
+ parentRunId: input.parentRunId,
30
+ parentUnitId: input.parentUnitId,
31
+ unitInputHash: input.unitInputHash,
32
+ }))
33
+ .digest("hex");
34
+ }
@@ -0,0 +1,370 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * The child workflow executor (P3b, spec docs/plans/specs/p3b-child-executor.md
6
+ * §3). `driveChildWorkflowUnit` is the ONE place a `child-workflow`-targeted
7
+ * unit is published (idempotently) and driven: no second executor, no second
8
+ * scheduler, no second journal writer. It is reached from the ONE dispatch
9
+ * seam in `native-executor.ts`'s `dispatchJournaledAttempt` (§3.2).
10
+ *
11
+ * Ordered algorithm (§3.3): (1) re-verify the embedded child plan's integrity;
12
+ * (2) validate the resolved `with:` bindings against the child's declared
13
+ * `params:`; (3) derive the deterministic invocation key; (4) publish the
14
+ * child run idempotently (`publishChildWorkflowRun`, P3a); (5) read the
15
+ * published row's status; (6) drive it with the SAME engine the top-level path
16
+ * uses (`runWorkflowSteps`) unless it is already terminal-for-this-invocation
17
+ * (`blocked`/`failed`, rows A-22/A-23); (7) map the child's FINAL status
18
+ * through §3.4's table onto this unit's outcome.
19
+ *
20
+ * ## Why `runWorkflowSteps` is reached through a LAZY dynamic import, not a
21
+ * static one (B-N5, and the §7 preservation-gate contingency)
22
+ *
23
+ * `native-executor.ts` must import `driveChildWorkflowUnit` FROM this file
24
+ * (the dispatch seam calls it inline, §3.2) — that edge is fixed. `run-
25
+ * workflow.ts` imports `native-executor.ts` (existing, load-bearing:
26
+ * `executeStepPlan`). If this file ALSO imported `runWorkflowSteps` from
27
+ * `./run-workflow` STATICALLY, the three edges would close a static cycle
28
+ * (native-executor.ts -> child-workflow.ts -> run-workflow.ts ->
29
+ * native-executor.ts), which `tests/architecture/import-cycle-ratchet.test.ts`
30
+ * (shrink-only, EMPTY baseline — an absolute gate) forbids outright; adding an
31
+ * entry to admit it is not an option the ratchet allows. Per this spec's own
32
+ * §7 checklist ("if the ratchet objects, the drive is reached through an
33
+ * injected function value, the pattern `ir/freeze-v4.ts`'s `ChildFreezeFn`
34
+ * already establishes"), the drive is instead reached through
35
+ * {@link driveWithRealEngine}'s `await import("./run-workflow.js")` — a
36
+ * DYNAMIC import, invisible to the static-graph cycle ratchet by design (its
37
+ * own doc: "dynamic `import()` is excluded because it is the repo's
38
+ * sanctioned lazy-loading escape hatch"), registered in
39
+ * `DYNAMIC_IMPORT_BASELINE` (scripts/lint-import-cycles.ts) as a genuine
40
+ * lazy-load: the vast majority of workflow runs compose no child at all, so
41
+ * loading `run-workflow.ts`'s full engine (lease heartbeat, retry loop) is
42
+ * deferred until a `child-workflow` unit is actually dispatched. Bun/Node
43
+ * cache a module on first dynamic import, so this costs nothing on repeat
44
+ * calls, and it resolves the SAME module namespace object a test's
45
+ * `import * as runWorkflowModule from "./run-workflow.js"` holds — a
46
+ * `spyOn(runWorkflowModule, "runWorkflowSteps")` is therefore observed
47
+ * exactly as if this module had imported it statically. Unlike a registered
48
+ * function value (which would depend on `run-workflow.ts` having already
49
+ * been loaded by SOME OTHER file — fragile for a test file exercising this
50
+ * seam in isolation), a dynamic import always resolves correctly regardless
51
+ * of what the rest of the process has loaded. This is the ONLY runtime
52
+ * indirection in the whole drive: no second executor is created, and
53
+ * `driveRun` itself is never exported (B-N5's "no second executor" holds).
54
+ */
55
+ import { randomUUID } from "node:crypto";
56
+ import { UsageError } from "../../core/errors.js";
57
+ import { withWorkflowRunsRepo } from "../../storage/repositories/workflow-runs-repository.js";
58
+ import { validateWorkflowParams } from "../ir/params.js";
59
+ import { canonicalPlanJson, computePlanHash } from "../ir/plan-hash.js";
60
+ import { frozenStepRows } from "../runtime/plan-classifier.js";
61
+ import { workflowRunExportedResult } from "../runtime/run-outputs.js";
62
+ import { computeChildInvocationKey } from "./child-invocation.js";
63
+ /**
64
+ * The real engine's `runWorkflowSteps`, reached ONLY through a dynamic
65
+ * import — see the module doc for why. The return value is deliberately
66
+ * unused by the caller: this module always RE-READS the child run row from
67
+ * the repository afterward (spec step 7) rather than trusting the driver's
68
+ * return value, so no result shape needs to be shared across the seam.
69
+ */
70
+ async function driveWithRealEngine(options) {
71
+ const { runWorkflowSteps } = await import("./run-workflow.js");
72
+ await runWorkflowSteps(options);
73
+ }
74
+ function errorMessage(err) {
75
+ return err instanceof Error ? err.message : String(err);
76
+ }
77
+ /** `acquireRunLease`'s exact refusal shape (run-workflow.ts) — matched by text, since this module cannot import that private helper. */
78
+ function isLeaseBusyError(err) {
79
+ return err instanceof UsageError && err.message.includes("is already being driven by engine");
80
+ }
81
+ /** §3.4's exact `child_workflow_failed` message. */
82
+ function childWorkflowFailedMessage(input) {
83
+ return (`Child workflow run ${input.childRunId} (${input.childRef}) failed at step "${input.childStepId}". ` +
84
+ `Inspect it with \`akm workflow status ${input.childRunId}\`; the parent run's step ` +
85
+ `"${input.parentStepId}" cannot advance until it succeeds.`);
86
+ }
87
+ /**
88
+ * Steps 1-3 (spec §3.3): integrity re-check, param validation, and the
89
+ * deterministic invocation key. Returns either the key or an already-shaped
90
+ * `child_workflow_publish_failed` outcome.
91
+ */
92
+ function precheckAndDeriveInvocationKey(input) {
93
+ const { request, target, ctx, childParams, inputHash } = input;
94
+ // Step 1 — integrity re-check (row A-10).
95
+ const recomputedPlanHash = computePlanHash(target.frozenPlan);
96
+ if (recomputedPlanHash !== target.planHash) {
97
+ return {
98
+ ok: false,
99
+ outcome: {
100
+ unitId: request.unitId,
101
+ ok: false,
102
+ failureReason: "child_workflow_publish_failed",
103
+ error: `Workflow step "${request.stepId}" composes child workflow ${target.ref}, but its embedded plan's ` +
104
+ `recomputed hash (${recomputedPlanHash}) does not match the frozen target's planHash (${target.planHash}). ` +
105
+ "The frozen plan has been corrupted or tampered with.",
106
+ },
107
+ };
108
+ }
109
+ // Step 2 — resolved params against the child's declared param schemas (row A-11).
110
+ const paramErrors = validateWorkflowParams(target.frozenPlan, childParams);
111
+ if (paramErrors.length > 0) {
112
+ return {
113
+ ok: false,
114
+ outcome: {
115
+ unitId: request.unitId,
116
+ ok: false,
117
+ failureReason: "child_workflow_publish_failed",
118
+ error: `Workflow step "${request.stepId}" composes child workflow ${target.ref}, but the resolved params do not ` +
119
+ `satisfy its declared param schemas:\n${paramErrors.map((e) => ` - ${e}`).join("\n")}`,
120
+ },
121
+ };
122
+ }
123
+ // Step 3 — the deterministic invocation key (B-N8: parentUnitId is request.unitId, the parent unit's journalBaseId).
124
+ return {
125
+ ok: true,
126
+ invocationKey: computeChildInvocationKey({
127
+ parentRunId: ctx.runId,
128
+ parentUnitId: request.unitId,
129
+ unitInputHash: inputHash,
130
+ }),
131
+ };
132
+ }
133
+ /**
134
+ * Step 4/5 (spec §3.3): publish the child run idempotently and return the
135
+ * pre-drive status read (the returned row IS that read). B-N16: no
136
+ * transaction open on this connection — this seam is reached from
137
+ * dispatchJournaledAttempt, outside resumeWorkflowRun's and
138
+ * completeWorkflowStep's own transactions.
139
+ */
140
+ async function publishChildRun(input, invocationKey) {
141
+ const { request, target, ctx, childParams } = input;
142
+ try {
143
+ const parentRow = await withWorkflowRunsRepo((repo) => repo.getRunById(ctx.runId));
144
+ if (!parentRow) {
145
+ throw new Error(`parent run ${ctx.runId} was not found`);
146
+ }
147
+ const now = new Date().toISOString();
148
+ const childRunId = randomUUID();
149
+ const childRow = await withWorkflowRunsRepo((repo) => repo.publishChildWorkflowRun({
150
+ parentRunId: ctx.runId,
151
+ spawnedByUnitId: request.unitId,
152
+ invocationKey,
153
+ run: {
154
+ id: childRunId,
155
+ workflowRef: target.ref,
156
+ scopeKey: parentRow.scope_key,
157
+ workflowEntryId: null,
158
+ workflowTitle: target.frozenPlan.title,
159
+ paramsJson: JSON.stringify(childParams),
160
+ currentStepId: target.frozenPlan.steps[0]?.stepId ?? null,
161
+ createdAt: now,
162
+ updatedAt: now,
163
+ agentHarness: parentRow.agent_harness,
164
+ agentSessionId: parentRow.agent_session_id,
165
+ checkinArmedAt: now,
166
+ },
167
+ steps: frozenStepRows(target.frozenPlan).map((row) => ({ ...row, runId: childRunId })),
168
+ planJson: canonicalPlanJson(target.frozenPlan),
169
+ planHash: target.planHash,
170
+ }));
171
+ return { ok: true, childRow };
172
+ }
173
+ catch (err) {
174
+ return {
175
+ ok: false,
176
+ outcome: {
177
+ unitId: request.unitId,
178
+ ok: false,
179
+ failureReason: "child_workflow_publish_failed",
180
+ error: `Workflow step "${request.stepId}" could not publish child workflow run for ${target.ref}: ${errorMessage(err)}`,
181
+ },
182
+ };
183
+ }
184
+ }
185
+ /**
186
+ * Step 6 (spec §3.3): drive the published child run with the real engine,
187
+ * unless it is already terminal-for-this-invocation (`blocked`/`failed`,
188
+ * rows A-22/A-23 — never re-driven, no lease taken). Returns the FINAL row
189
+ * (re-read after the drive) or an already-shaped `child_workflow_busy` /
190
+ * `child_workflow_drive_failed` outcome.
191
+ */
192
+ async function driveChildRun(input, childRow) {
193
+ const { request, target, ctx } = input;
194
+ const shouldDrive = childRow.status !== "blocked" && childRow.status !== "failed";
195
+ if (!shouldDrive) {
196
+ return { ok: true, finalRow: childRow };
197
+ }
198
+ const driveOptions = {
199
+ target: childRow.id,
200
+ ...(ctx.signal ? { signal: ctx.signal } : {}),
201
+ ...(ctx.dispatcher ? { dispatcher: ctx.dispatcher } : {}),
202
+ ...(ctx.maxConcurrency !== undefined ? { maxConcurrency: ctx.maxConcurrency } : {}),
203
+ ...(ctx.eventSource !== undefined ? { eventSource: ctx.eventSource } : {}),
204
+ // B-N6: a no-op, distinct from the real registry drain — the PARENT's
205
+ // own `finally` remains the single owner of the process-lifecycle
206
+ // drain for the whole process (row A-24).
207
+ disposeDispatchResources: () => { },
208
+ // B-N7: deliberately no maxSteps, no maxRetries (rows A-25, A-26).
209
+ };
210
+ try {
211
+ // The re-read is INSIDE the same try as the drive (code-review round 4,
212
+ // finding 1; Review log R1): every throw between here and a mapped
213
+ // UnitOutcome — the drive itself, OR this immediately-following
214
+ // getRunById — must be caught. Left to escape, it skips past
215
+ // dispatchJournaledAttempt's finishJournaledDispatch (no try/catch
216
+ // wraps this seam there by design), so the parent's reserved attempt
217
+ // row is never finished; the throw then propagates through runUnit
218
+ // into concurrentMap's worker (src/core/concurrent.ts), which SWALLOWS
219
+ // it and leaves the unit's outcome slot `undefined`, which
220
+ // executeStepPlanInConnection then maps to the false diagnostic
221
+ // "unit was not dispatched (aborted or scheduler failure)" — losing
222
+ // the real cause and leaving the composing attempt row stuck
223
+ // `running` forever (unrecoverable by inspection; a resume + re-drive
224
+ // reproduces the identical false diagnostic).
225
+ await driveWithRealEngine(driveOptions);
226
+ const finalRow = (await withWorkflowRunsRepo((repo) => repo.getRunById(childRow.id))) ?? childRow;
227
+ return { ok: true, finalRow };
228
+ }
229
+ catch (err) {
230
+ if (isLeaseBusyError(err)) {
231
+ return {
232
+ ok: false,
233
+ outcome: {
234
+ unitId: request.unitId,
235
+ ok: false,
236
+ failureReason: "child_workflow_busy",
237
+ error: errorMessage(err),
238
+ childRun: {
239
+ runId: childRow.id,
240
+ ref: childRow.workflow_ref,
241
+ status: childRow.status,
242
+ currentStepId: childRow.current_step_id,
243
+ },
244
+ },
245
+ };
246
+ }
247
+ // EVERY other throw is mapped here too — never rethrown. §3.5's
248
+ // original premise ("classified by the existing dispatch_error
249
+ // handling") was false: no handling exists at this seam
250
+ // (dispatchJournaledAttempt awaits this call with no try of its own),
251
+ // so an uncaught throw here escaped all the way into the scheduler and
252
+ // was silently swallowed (R1, above). Reachable causes include the
253
+ // child's own LeaseHeartbeat.assertAlive() firing mid-drive, a Lane B
254
+ // UsageError out of the child's own completeWorkflowStep (e.g.
255
+ // WORKFLOW_OUTPUT_INVALID), requireExecutableWorkflowPlan rejecting a
256
+ // tampered child plan_json, and the child's status changing between
257
+ // this function's own step 5 read and the drive's internal
258
+ // getNextWorkflowStep re-read — none of which match
259
+ // isLeaseBusyError's text. child_workflow_drive_failed is a SIBLING of
260
+ // child_workflow_publish_failed (row A-10…A-12): same shape, same
261
+ // errorMessage(err) content, but naming the child run id and ref
262
+ // (already known at this point, unlike the publish arm above) since
263
+ // driving — not publishing — is what failed.
264
+ return {
265
+ ok: false,
266
+ outcome: {
267
+ unitId: request.unitId,
268
+ ok: false,
269
+ failureReason: "child_workflow_drive_failed",
270
+ error: `Workflow step "${request.stepId}" composes child workflow run ${childRow.id} (${target.ref}), ` +
271
+ `but driving it failed: ${errorMessage(err)}`,
272
+ childRun: {
273
+ runId: childRow.id,
274
+ ref: childRow.workflow_ref,
275
+ status: childRow.status,
276
+ currentStepId: childRow.current_step_id,
277
+ },
278
+ },
279
+ };
280
+ }
281
+ }
282
+ /**
283
+ * `driveChildWorkflowUnit` — the ONE child drive (spec §3.3). Every failure
284
+ * before step 6 (publication) produces `child_workflow_publish_failed`.
285
+ */
286
+ export async function driveChildWorkflowUnit(input) {
287
+ const { request, target, ctx } = input;
288
+ const precheck = precheckAndDeriveInvocationKey(input);
289
+ if (!precheck.ok) {
290
+ return precheck.outcome;
291
+ }
292
+ const published = await publishChildRun(input, precheck.invocationKey);
293
+ if (!published.ok) {
294
+ return published.outcome;
295
+ }
296
+ const { childRow } = published;
297
+ const driven = await driveChildRun(input, childRow);
298
+ if (!driven.ok) {
299
+ return driven.outcome;
300
+ }
301
+ const { finalRow } = driven;
302
+ const childRunSummary = {
303
+ runId: finalRow.id,
304
+ ref: finalRow.workflow_ref,
305
+ status: finalRow.status,
306
+ currentStepId: finalRow.current_step_id,
307
+ };
308
+ // A-28/A-29: the child did not reach a terminal state, and the parent's
309
+ // own dispatch signal is what aborted it — checked against the RE-READ
310
+ // status (not the signal alone) so an already-terminal child is never
311
+ // misreported as aborted.
312
+ if (finalRow.status === "active" && ctx.signal?.aborted) {
313
+ return {
314
+ unitId: request.unitId,
315
+ ok: false,
316
+ failureReason: "aborted",
317
+ error: `Child workflow run ${finalRow.id} (${finalRow.workflow_ref}) was not driven to completion: ` +
318
+ "the parent workflow invocation was interrupted.",
319
+ childRun: childRunSummary,
320
+ };
321
+ }
322
+ switch (finalRow.status) {
323
+ case "completed":
324
+ return {
325
+ unitId: request.unitId,
326
+ ok: true,
327
+ result: workflowRunExportedResult(finalRow),
328
+ childRun: childRunSummary,
329
+ };
330
+ case "failed": {
331
+ const childStepId = finalRow.current_step_id ?? "(unknown)";
332
+ return {
333
+ unitId: request.unitId,
334
+ ok: false,
335
+ failureReason: "child_workflow_failed",
336
+ error: childWorkflowFailedMessage({
337
+ childRunId: finalRow.id,
338
+ childRef: finalRow.workflow_ref,
339
+ childStepId,
340
+ parentStepId: request.stepId,
341
+ }),
342
+ childRun: childRunSummary,
343
+ };
344
+ }
345
+ case "blocked":
346
+ return {
347
+ unitId: request.unitId,
348
+ ok: false,
349
+ failureReason: "child_workflow_blocked",
350
+ error: `Child workflow run ${finalRow.id} (${finalRow.workflow_ref}) is blocked at its own step ` +
351
+ `"${finalRow.current_step_id ?? "(unknown)"}". Inspect it with \`akm workflow status ${finalRow.id}\`.`,
352
+ childRun: childRunSummary,
353
+ };
354
+ default:
355
+ // The child's own gate loop exhausted without reaching a terminal
356
+ // status (a genuine gate rejection on the child's own step, never
357
+ // reached by this phase's fixtures — B-N7 forwards no maxSteps/
358
+ // maxRetries, so nothing else can leave a driven child non-terminal
359
+ // without an abort). Treated conservatively as a failure so the
360
+ // parent never silently advances on an unresolved child.
361
+ return {
362
+ unitId: request.unitId,
363
+ ok: false,
364
+ failureReason: "child_workflow_failed",
365
+ error: `Child workflow run ${finalRow.id} (${finalRow.workflow_ref}) did not reach a terminal state ` +
366
+ `(status: ${finalRow.status}). Inspect it with \`akm workflow status ${finalRow.id}\`.`,
367
+ childRun: childRunSummary,
368
+ };
369
+ }
370
+ }