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
@@ -3,44 +3,18 @@
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  /**
5
5
  * The `exec` unit runner — the ONE place a frozen workflow spawns a shell
6
- * command as a unit. The invariants it exists to hold:
6
+ * command as a unit: argv-only (never a shell string), non-blocking,
7
+ * detached with a SIGTERM→SIGKILL ladder against the whole process group,
8
+ * cwd-contained by a resolved-path recheck, resource-bounded (timeout,
9
+ * output bytes, context size), and allowlisted-environment (see
10
+ * {@link childEnv}). `env` values reaching this module are already resolved
11
+ * from `env:` bindings by NAME — the caller scrubs the outcome with
12
+ * `redactUnitOutcome` before anything is journaled. A LEAF module by
13
+ * layering (Node built-ins, `core/spawn-env`, `core/subprocess`, `core/warn`,
14
+ * the import-free `workflows/resource-limits` only).
7
15
  *
8
- * - ARGV, NEVER A SHELL STRING. {@link IrExecSpec.command} is an argv ARRAY
9
- * and the format has no shell-string spelling at all; the child is spawned
10
- * directly, so `;`, `|`, `&&`, `$(…)`, backticks, `>` and `*` are inert
11
- * literal argument BYTES. A workflow that wants a pipeline names the
12
- * interpreter itself (`["bash", "-lc", "a | b"]`), visibly in frontmatter.
13
- * - NON-BLOCKING. Everything on this path is async. A synchronous call here
14
- * blocks the event loop and with it every concurrently-scheduled unit, the
15
- * run's lease heartbeat, and abort handling.
16
- * - NO LEAKED CHILDREN. {@link runManagedSubprocess} spawns DETACHED and runs
17
- * a SIGTERM→SIGKILL ladder against the whole process group, so `--timeout`
18
- * and Ctrl-C really do stop a running command and its descendants.
19
- * - CONTAINMENT. `exec.cwd` is relative and `..`-free by construction (parser
20
- * and frozen-plan decoder), which is necessary but not sufficient: a
21
- * subdirectory can be a symlink. The RESOLVED path is therefore re-checked
22
- * against the RESOLVED base immediately before spawning.
23
- * - BOUNDED SPEND. A command is arbitrary code with no resource discipline of
24
- * its own, so each resource it spends on akm's behalf has a ceiling in
25
- * `workflows/resource-limits.ts`: wall clock ({@link DEFAULT_EXEC_TIMEOUT_MS}
26
- * or the authored `timeout:`), retained output
27
- * ({@link WORKFLOW_MAX_EXEC_OUTPUT_BYTES} per pipe) and the context
28
- * environment ({@link execContextLimits}, checked BEFORE the spawn so an
29
- * oversized artifact yields an actionable akm error, not a bare `E2BIG`).
30
- * - ALLOWLISTED ENVIRONMENT. The child does NOT inherit akm's environment: it
31
- * starts EMPTY and receives exactly {@link EXEC_DEFAULT_ENV_PASSTHROUGH}
32
- * plus the unit's `exec.passEnv`, then the resolved `env:` bindings, then
33
- * the engine-authored `AKM_*` context. See {@link childEnv}.
34
- *
35
- * Secrets: `env` values reaching this module are already resolved from `env:`
36
- * bindings by NAME (`resolveEnvBinding`) — the plan never carries inline secrets
37
- * and the input hash only ever carries names. The caller scrubs the outcome with
38
- * `redactUnitOutcome` BEFORE anything is journaled, which is why this module may
39
- * return raw stdout/stderr diagnostics without knowing anything about redaction.
40
- *
41
- * Layering: a LEAF. Node built-ins, `core/spawn-env`, `core/subprocess`,
42
- * `core/warn` and the import-free `workflows/resource-limits` (plus erased
43
- * types) only, so the executor can consume it without opening an import cycle.
16
+ * See docs/architecture/decisions/0003-child-env-allowlist-and-provenance.md
17
+ * for the full per-invariant design history.
44
18
  *
45
19
  * @module workflows/exec/exec-unit
46
20
  */
@@ -65,58 +39,17 @@ import { execContextLimits, utf8Bytes, WORKFLOW_EXEC_OUTPUT_TRUNCATED_MARKER, WO
65
39
  const EXEC_STDERR_DIAGNOSTIC_CLIP = WORKFLOW_UNIT_DIAGNOSTIC_CLIP - 500;
66
40
  /**
67
41
  * The DEFAULT environment allowlist for an exec unit's child — the single
68
- * definition of the EXEC list (the docs describe it, the tests assert against
69
- * it, and `exec.passEnv` extends it per unit). The win32 process-creation
70
- * names are not re-spelled here: they are spread from
71
- * {@link WIN32_SPAWN_ENV_FLOOR}, which owns them.
72
- *
73
- * The child starts from an EMPTY environment and receives only these names,
74
- * matching how an agent harness child is already built
75
- * (`profile.envPassthrough` → `collectAllowlistedEnv`) — and literally
76
- * extending the same {@link COMMON_SPAWN_ENV_PASSTHROUGH} baseline those
77
- * profiles start from, so the two child-spawn allowlists share one floor.
78
- * Every entry earns its place by being load-bearing for ordinary commands on
79
- * some supported platform:
80
- *
81
- * - `PATH` — command resolution. Without it only an absolute `argv[0]`
82
- * can ever be spawned.
83
- * - `HOME` — the config/cache root essentially every toolchain reads
84
- * (git, npm, bun, cargo, ssh). Absent, tools fall back to
85
- * `/` or fail outright.
86
- * - `USER`, `LOGNAME` — process identity; git and ssh read them to attribute
87
- * and authenticate.
88
- * - `SHELL` — read by tools that re-exec a login shell for the user's
89
- * own environment (an explicit `["bash", "-lc", …]` argv
90
- * does not need it, but `git`'s pagers/editors do).
91
- * - `LANG`, `LC_ALL`, `LC_CTYPE` — text encoding. Without a locale a command
92
- * falls back to the C locale and mangles non-ASCII stdout,
93
- * which IS this unit's artifact.
94
- * - `TERM` — some CLIs abort or emit raw escape bytes with no TERM.
95
- * - `TZ` — timestamps a command prints would otherwise silently
96
- * switch to the host default.
97
- * - `TMPDIR` — POSIX scratch space; absent, tools write to `/tmp` or
98
- * fail on read-only hosts.
99
- * - {@link WIN32_SPAWN_ENV_FLOOR} — what Windows itself requires of any
100
- * child (process creation, command resolution, and the
101
- * win32 analogues of `HOME`/`TMPDIR`). Spread in rather
102
- * than re-spelled: `spawnEnvNamesFor` appends the same
103
- * names on win32, and two hand-written copies of one OS
104
- * requirement is exactly how a floor drifts.
105
- * - `APPDATA`, `LOCALAPPDATA` — Windows config/cache roots (npm, bun, git).
106
- * - `ProgramData`, `ProgramFiles` — machine-wide install roots that Windows
107
- * toolchain shims resolve against. These four are NOT on
108
- * the floor (a child is creatable without them), so an
109
- * arbitrary shell command asks for them here.
110
- * - `AKM_EVENT_SOURCE` — provenance, never a secret: an exec unit that calls
111
- * `akm` must record machine traffic rather than user
112
- * demand, exactly as the agent passthrough list does
113
- * (`integrations/agent/profiles.ts`, DRIFT-6).
42
+ * definition of the EXEC list; `exec.passEnv` extends it per unit. Extends
43
+ * {@link COMMON_SPAWN_ENV_PASSTHROUGH} (the same baseline agent-harness
44
+ * children use) plus POSIX/Windows names load-bearing for ordinary commands
45
+ * (PATH, HOME, USER/LOGNAME, SHELL, locale, TERM, TZ, TMPDIR, the
46
+ * {@link WIN32_SPAWN_ENV_FLOOR}, Windows toolchain roots) and
47
+ * `AKM_EVENT_SOURCE` (provenance, DRIFT-6). Deliberately ABSENT and reachable
48
+ * only through `exec.passEnv` / `env:`: credentials, cloud/CI vars, and the
49
+ * proxy family.
114
50
  *
115
- * Deliberately ABSENT and reachable only through `exec.passEnv` / `env:`:
116
- * credentials of every kind, cloud/CI vars, and the proxy
117
- * family (`HTTP_PROXY` & friends) — proxy URLs routinely embed credentials,
118
- * which is why akm's redaction policy already treats URL-shaped passthrough
119
- * values as credential-bearing.
51
+ * See docs/architecture/decisions/0003-child-env-allowlist-and-provenance.md
52
+ * for the per-entry rationale and why the default is an allowlist at all.
120
53
  */
121
54
  export const EXEC_DEFAULT_ENV_PASSTHROUGH = [
122
55
  // PATH, HOME, USER, LANG, LC_ALL, TERM, TMPDIR, AKM_EVENT_SOURCE
@@ -139,57 +72,19 @@ export const EXEC_DEFAULT_ENV_PASSTHROUGH = [
139
72
  /**
140
73
  * Run one exec unit and map its process outcome onto the dispatch vocabulary:
141
74
  * non-zero exit → `non_zero_exit`, wall-clock expiry → `timeout`, cancellation
142
- * → `aborted`, a child that never started → `spawn_failed`. All four are
143
- * pre-existing `AgentFailureReason` members, so `retry.on` keeps working
144
- * unchanged. The out-of-taxonomy `exec_cwd_escape`, `exec_output_limit`,
145
- * `exec_context_too_large` and `exec_capture_incomplete` are deliberate: each is
146
- * tampering, a runaway, an authoring bug, or work that ALREADY RAN — never a
147
- * transient — so no `retry.on` value can ever re-dispatch one.
148
- *
149
- * ## An INCOMPLETE STDOUT capture is a failure, never a partial artifact
150
- *
151
- * `exitCode === 0` is not on its own proof that stdout was fully read: a pipe
152
- * can error, and the stream-drain timeout can fire while the command LEADER has
153
- * already exited 0 because a background descendant still holds the stdout fd
154
- * open. Both leave a PREFIX, and promoting it would hand the next step, the gate
155
- * judge and `steps.<id>.output` a silently truncated artifact. So the unit fails
156
- * instead, through the same shared classifier (`streamCaptureFailure`) the agent
157
- * spawn path uses.
158
- *
159
- * Only STDOUT is fatal here. stderr is a diagnostic channel that never
160
- * contributes to the artifact, so a stderr drain that did not finish leaves the
161
- * unit's actual result — a completed command, a fully captured stdout — intact;
162
- * failing it would throw away a valid artifact over a lost log tail. The agent
163
- * path classifies both pipes because there stderr genuinely feeds its
164
- * diagnostics; the difference lives in the CALLERS, not in the shared
165
- * classifier.
166
- *
167
- * The stdout reason is `exec_capture_incomplete`, deliberately OUTSIDE the
168
- * `retry.on` taxonomy, for the same reason `journal_write_failed` is: the
169
- * command RAN TO COMPLETION and exited 0 — what failed is akm's record of it. A
170
- * retryable reason here would let `retry.on: [spawn_failed]` re-dispatch
171
- * byte-identical argv for a command that already deployed, already published,
172
- * already migrated. `spawn_failed` keeps its documented meaning — the child
173
- * never started.
75
+ * → `aborted`, a child that never started → `spawn_failed` (all pre-existing
76
+ * `AgentFailureReason` members, so `retry.on` keeps working). The
77
+ * out-of-taxonomy `exec_cwd_escape`, `exec_output_limit`,
78
+ * `exec_context_too_large` and `exec_capture_incomplete` are deliberate: each
79
+ * is tampering, a runaway, an authoring bug, or work that ALREADY RAN — never
80
+ * a transient — so no `retry.on` value can ever re-dispatch one. An
81
+ * INCOMPLETE stdout capture is always a failure, never a partial artifact;
82
+ * output OVERFLOW past {@link WORKFLOW_MAX_EXEC_OUTPUT_BYTES} does not fail a
83
+ * command that otherwise passed unless the unit declared an `output:` schema
84
+ * (a truncated JSON prefix cannot parse).
174
85
  *
175
- * ## Output OVERFLOW does not fail a command that passed
176
- *
177
- * Crossing {@link WORKFLOW_MAX_EXEC_OUTPUT_BYTES} is a different condition: the
178
- * reader DID drain the pipe to its end, it just stopped RETAINING, so the child
179
- * never blocked and its exit code is real. Failing a passing-but-chatty test
180
- * suite over its log volume would be a tripwire, so overflow splits by what the
181
- * unit PROMISED about its output:
182
- *
183
- * - NO declared `output:` schema → success, with the artifact carrying a
184
- * {@link WORKFLOW_EXEC_OUTPUT_TRUNCATED_MARKER} block naming both byte
185
- * counts, so truncated text can never pass for complete text.
186
- * - a declared `output:` schema → `exec_output_limit`: stdout must parse as
187
- * EXACTLY one JSON value, a truncated prefix cannot, and promoting it would
188
- * corrupt every downstream reference to the typed artifact.
189
- *
190
- * stderr overflow never fails anything: stderr is a diagnostic channel, and
191
- * {@link EXEC_STDERR_DIAGNOSTIC_CLIP} already bounds and marks what reaches the
192
- * journal.
86
+ * See docs/architecture/decisions/0003-child-env-allowlist-and-provenance.md
87
+ * for the full capture/overflow reasoning.
193
88
  */
194
89
  export async function runExecUnit(input) {
195
90
  const cwd = await resolveExecCwd(input);
@@ -201,7 +96,7 @@ export async function runExecUnit(input) {
201
96
  const result = await runManagedSubprocess([...input.exec.command], {
202
97
  capture: true,
203
98
  cwd: cwd.path,
204
- env: childEnv(input.exec, input.env, input.context),
99
+ env: childEnv(input.exec, input.env, input.context, input.eventSource),
205
100
  timeoutMs: input.timeoutMs,
206
101
  // stdout IS this unit's artifact, so RETENTION is BOUNDED: an unbounded
207
102
  // capture is memory the akm process spends on a command's behalf with no
@@ -457,40 +352,25 @@ async function isExistingDirectory(candidate) {
457
352
  }
458
353
  }
459
354
  /**
460
- * The child's environment, in three layers with fixed precedence:
461
- *
462
- * 1. the BASE {@link EXEC_DEFAULT_ENV_PASSTHROUGH} plus the unit's
463
- * `exec.passEnv` names;
464
- * 2. the unit's resolved `env:` bindings;
465
- * 3. the engine-authored `AKM_*` context, LAST so a workflow-supplied binding
466
- * can never shadow the ids/item the engine is telling the command the
467
- * truth about.
468
- *
469
- * ## Why the default is an allowlist
470
- *
471
- * Not because it stops an attacker: a command that runs at all can read the
472
- * same credentials off disk that the environment would have handed it, and a
473
- * workflow source is executed code either way (`docs/guides/run-workflows.md`,
474
- * "workflow sources are executed code"). The allowlist earns its place for
475
- * three narrower, real reasons:
476
- *
477
- * - it bounds ACCIDENTAL exposure — the ambient shell of whoever ran
478
- * `akm workflow run` (or the CI job that did) routinely carries tokens for
479
- * unrelated services, and a third-party workflow step that merely prints
480
- * its environment, or a tool that ships one in a crash report, should not
481
- * get them for free;
482
- * - it makes the environment surface EXPLICIT and REVIEWABLE — what a
483
- * command can see is this constant plus lines in the frontmatter diff,
484
- * rather than "whatever the invoking shell happened to export";
485
- * - it matches the convention akm already applies to spawned children —
486
- * `profile.envPassthrough` in `integrations/agent/spawn.ts` has always
487
- * built agent-harness children this way, and the SAME
488
- * {@link collectAllowlistedEnv} does it here, so there is one mechanism to
489
- * review instead of two.
355
+ * The child's environment, in three layers with fixed precedence: (1) the
356
+ * BASE — {@link EXEC_DEFAULT_ENV_PASSTHROUGH} plus the unit's `exec.passEnv`
357
+ * names; (2) the unit's resolved `env:` bindings; (3) the engine-authored
358
+ * `AKM_*` context, LAST so a workflow-supplied binding can never shadow the
359
+ * ids/item the engine is telling the command the truth about.
490
360
  *
361
+ * See docs/architecture/decisions/0003-child-env-allowlist-and-provenance.md
362
+ * for why the default is an allowlist rather than full inheritance.
491
363
  */
492
- function childEnv(exec, bindings, context) {
364
+ function childEnv(exec, bindings, context, eventSource) {
493
365
  const env = collectAllowlistedEnv(execAllowlist(exec));
366
+ // F-1 (spec §5.2 point 2): applied to the allowlisted BASE only when the
367
+ // ambient passthrough above left the name absent — an ambient
368
+ // AKM_EVENT_SOURCE already collected into `env` still wins, and this runs
369
+ // strictly BEFORE the bindings/context overlays below, so an authored
370
+ // `env:` binding (or the engine-authored context) still wins too.
371
+ if (eventSource !== undefined && env.AKM_EVENT_SOURCE === undefined) {
372
+ env.AKM_EVENT_SOURCE = eventSource;
373
+ }
494
374
  for (const [name, value] of Object.entries(bindings ?? {}))
495
375
  env[name] = value;
496
376
  for (const [name, value] of Object.entries(context ?? {}))
@@ -67,8 +67,24 @@ function warnLoweringNotices(...groups) {
67
67
  }
68
68
  }
69
69
  }
70
- /** Build a gate judge from the normalized frozen target without consulting live config. */
71
- export function frozenSummaryJudge(target, signal, dispatcher, owner) {
70
+ /**
71
+ * Build a gate judge from the normalized frozen target without consulting live
72
+ * config.
73
+ *
74
+ * `eventSource` (P1b spec §5.2 point 2, gap closed — code review): the task
75
+ * runner's resolved provenance event source, threaded through exactly like
76
+ * `executeStepSubgraph`'s units so a workflow-task run's judge dispatch is not
77
+ * the one dispatch left silently unstamped. Spread onto the built
78
+ * {@link UnitDispatchRequest} the same way every other optional field here is
79
+ * — `undefined` for the manual `akm workflow step complete` judge
80
+ * (`runtime/runs.ts`, which passes no 5th argument) and for `akm workflow run`
81
+ * (no task context), so both stay byte-identical. When present, it flows
82
+ * through the same `forwardedDispatchEventSource` precedence gate every other
83
+ * "command"-kind dispatch uses (`unit-dispatch.ts`): the module doc above
84
+ * already establishes a judge request never carries an authored `env:`
85
+ * binding, so the gate always forwards it here.
86
+ */
87
+ export function frozenSummaryJudge(target, signal, dispatcher, owner, eventSource) {
72
88
  if (!target)
73
89
  return null;
74
90
  const dispatch = withDispatchRedaction(dispatcher ?? dispatchWorkflowExecution);
@@ -89,6 +105,7 @@ export function frozenSummaryJudge(target, signal, dispatcher, owner) {
89
105
  ...commonRequest,
90
106
  frozenTarget: target,
91
107
  timeoutMs: target.runner.timeoutMs ?? null,
108
+ ...(eventSource !== undefined ? { eventSource } : {}),
92
109
  };
93
110
  // Lowering and authorization precede every live credential/passthrough
94
111
  // sample. The injected dispatcher may be a test seam, but it receives the
@@ -8,31 +8,12 @@
8
8
  * persistence through the serialized writer queue, and `workflow_unit_*`
9
9
  * events for observability.
10
10
  *
11
- * Data flow (workflow-format-unification, spec §2.3): there is NO
12
- * interpolation language. A unit's instructions are the step's body prose
13
- * BYTE-EXACT prose is never scanned for reference syntax, so a literal `${{`
14
- * in a body is content, not grammar. Data reaches the unit as ATTACHED
15
- * STRUCTURED CONTEXT rather than string splices: `buildUnitPrompt`
16
- * (`exec/step-work.ts`) wraps the verbatim instructions with JSON blocks for
17
- * the run params, a map unit's item + index, and the artifacts named by the
18
- * step's `inputs:`. Because nothing is ever substituted INTO the prose, the P1
19
- * `{{item}}` re-scan injection class is structurally impossible.
20
- *
21
- * References survive only in the three whole-value FRONTMATTER positions the
22
- * closed two-root grammar occupies (`program/expressions.ts`): `map.over`,
23
- * `route.input`, and each `inputs[]` entry. They resolve ONCE per step against
24
- * `{ params, stepOutputs }`. There is NO ambient key search: a
25
- * `steps.<id>.output.<path>` reference addresses INTO that step's recorded
26
- * output explicitly.
27
- *
28
- * Step outputs (`steps.<id>.output…`): every engine-executed step journals a
29
- * promoted ARTIFACT under `evidence.output` — the solo unit's result/text, the
30
- * collect reducer's per-item array, or the vote reducer's winner — and that
31
- * artifact is what the reference scope exposes ({@link projectStepOutput}).
32
- * The documented addressing (`steps.discover.output.files`) therefore resolves
33
- * against real step results, never the raw evidence envelope (peer review R1).
34
- * Steps completed manually (no `output` key in their evidence) expose their
35
- * recorded evidence object as-is.
11
+ * Data flow: there is no interpolation language — a unit's instructions are
12
+ * the step's body prose byte-exact, and data reaches it as attached
13
+ * structured context instead. References resolve once per step, only in the
14
+ * closed frontmatter positions, against the promoted step-output artifact.
15
+ * See docs/architecture/decisions/0001-no-interpolation-attached-structured-context.md
16
+ * for the full design history (peer review R1, the P1 injection class it closes).
36
17
  *
37
18
  * Empty free-text outputs (peer review): a SUCCESSFUL schemaless unit that
38
19
  * returns the empty string is normalized to "no output" — {@link dispatchUnit}
@@ -139,6 +120,10 @@ import { assertFrozenExecutableIdentity } from "../../execution/executable-ident
139
120
  import { withWorkflowRunsConnection, withWorkflowRunsRepo, } from "../../storage/repositories/workflow-runs-repository.js";
140
121
  import { materializeFrozenWorkflowEnvironment } from "../ir/environment-v4.js";
141
122
  import { WORKFLOW_UNIT_DIAGNOSTIC_CLIP } from "../resource-limits.js";
123
+ // The ONE child-workflow drive (P3b §3.2) — publishes and drives a
124
+ // `child-workflow`-targeted unit; this module's dispatch seam is its only
125
+ // production caller.
126
+ import { driveChildWorkflowUnit } from "./child-workflow.js";
142
127
  // The ONE dispatch redaction contract, shared with the gate-judge path
143
128
  // (exec/frozen-judge.ts). Consumers import the leaf directly — this module is
144
129
  // not a second front door onto the seam.
@@ -563,6 +548,11 @@ async function runUnit(input) {
563
548
  ...(env ? { env } : {}),
564
549
  ...(sensitiveValues ? { sensitiveValues } : {}),
565
550
  ...(input.signal ? { signal: input.signal } : {}),
551
+ // F-1 (spec §5.2 point 2): forwarded to exec-unit.ts's childEnv for a
552
+ // "script"/"shell" unit, and to dispatchWorkflowExecution's
553
+ // dispatchLoweredExecutionRequest eventSource option (unit-dispatch.ts)
554
+ // for a "command" unit — both arms observe it.
555
+ ...(ctx.eventSource !== undefined ? { eventSource: ctx.eventSource } : {}),
566
556
  };
567
557
  // One content-derived unit id is retained across every retry; the append-only
568
558
  // attempt table supplies the 1-based attempt identity.
@@ -700,7 +690,17 @@ function journaledUnitResultJson(outcome) {
700
690
  async function prepareAttemptWorktree(input) {
701
691
  if (input.worktreeBase === undefined)
702
692
  return { ok: true, request: input.request };
703
- const created = await createUnitWorktree(input.worktreeBase, input.ctx.runId, input.attemptId, input.workUnit.frozenTarget.gitCommitOid);
693
+ const created = await createUnitWorktree(input.worktreeBase, input.ctx.runId, input.attemptId,
694
+ // A child-workflow target (P3a, schema-v4.ts) carries no gitCommitOid of
695
+ // its own — it is a composition target, never a worktree-isolated exec
696
+ // one. This arm IS reachable — a step that composes a child workflow and
697
+ // also declares `isolation: worktree` gets a worktree prepared here
698
+ // (worktree prep runs ahead of dispatch), but the child executor
699
+ // (child-workflow.ts, P3b §3.2) never dispatches through it: driving a
700
+ // child publishes and drives a RUN, not a command/exec unit, so the
701
+ // prepared worktree is simply unused by the drive. This ternary keeps the
702
+ // field access total over the frozen-target union either way.
703
+ input.workUnit.frozenTarget.kind === "child-workflow" ? undefined : input.workUnit.frozenTarget.gitCommitOid);
704
704
  if (created.preservedLeftover !== undefined) {
705
705
  warn(`Workflow unit ${input.attemptId}: a previous attempt left uncollected work in its isolation worktree; ` +
706
706
  `preserved at ${created.preservedLeftover}`);
@@ -831,7 +831,27 @@ async function dispatchJournaledAttempt(input) {
831
831
  attempt: durableAttempt.attempt,
832
832
  dispatchId: durableAttempt.dispatch_id,
833
833
  };
834
- const dispatched = await dispatchUnit(request, dispatcher);
834
+ // P3b §3.2: the ONE dispatch-seam branch. A `child-workflow`-targeted unit
835
+ // never reaches `UnitDispatcher` — it is routed to the child executor
836
+ // instead (src/workflows/exec/child-workflow.ts), which publishes the
837
+ // child idempotently and drives it with the SAME engine
838
+ // (`runWorkflowSteps`) the top-level path uses. Placed HERE — after
839
+ // `reserveJournaledDispatch` claims this attempt row, before
840
+ // `finishJournaledDispatch`/the worktree epilogue below — so a
841
+ // child-workflow unit is journaled exactly like any other unit, and a
842
+ // crash between reservation and child publication leaves a `running`
843
+ // parent row with no child, recovered by resume (which re-dispatches the
844
+ // parent unit and republishes the child idempotently).
845
+ const dispatched = request.frozenTarget.kind === "child-workflow"
846
+ ? await driveChildWorkflowUnit({
847
+ request,
848
+ target: request.frozenTarget,
849
+ ctx,
850
+ childParams: workUnit.childParams ?? {},
851
+ inputHash: input.inputHash,
852
+ dispatcher,
853
+ })
854
+ : await dispatchUnit(request, dispatcher);
835
855
  // Credential and passthrough values are intentionally sampled only AFTER
836
856
  // the default dispatcher has authorized/lowered the frozen request and
837
857
  // materialized credentials at its terminal dispatch boundary. Custom test
@@ -1070,6 +1090,7 @@ export const defaultUnitDispatcher = async (request, feedback) => {
1070
1090
  ...(request.schema ? { hasOutputSchema: true } : {}),
1071
1091
  timeoutMs: request.timeoutMs,
1072
1092
  ...(request.signal ? { signal: request.signal } : {}),
1093
+ ...(request.eventSource !== undefined ? { eventSource: request.eventSource } : {}),
1073
1094
  });
1074
1095
  }
1075
1096
  finally {
@@ -1094,6 +1115,7 @@ export const defaultUnitDispatcher = async (request, feedback) => {
1094
1115
  ...(request.schema ? { hasOutputSchema: true } : {}),
1095
1116
  timeoutMs: request.timeoutMs,
1096
1117
  ...(request.signal ? { signal: request.signal } : {}),
1118
+ ...(request.eventSource !== undefined ? { eventSource: request.eventSource } : {}),
1097
1119
  });
1098
1120
  }
1099
1121
  return dispatchWorkflowExecution(request, feedback);
@@ -23,6 +23,18 @@
23
23
  * strings. It is purely advisory — it NEVER blocks a run and NEVER mutates
24
24
  * params — and is surfaced when a run starts. False positives and false
25
25
  * negatives are expected; it is a nudge, not a scanner.
26
+ *
27
+ * ## Reused as `akm task explain`'s redaction check
28
+ *
29
+ * `src/commands/tasks/explain.ts` reuses this same heuristic (via its own
30
+ * `isSecretShapedValue` wrapper) to decide which task-input values to print
31
+ * as `"<redacted>"` instead of in full. That reuse does NOT upgrade this
32
+ * detector into a hard guarantee: `explain`'s redaction is exactly as
33
+ * best-effort as the warnings above — a short, low-entropy, or
34
+ * unusually-named credential that this function does not flag prints
35
+ * UNREDACTED there too. Do not describe either surface as "secret-free by
36
+ * construction"; describe it as "secret-shaped values are redacted on a
37
+ * best-effort basis."
26
38
  */
27
39
  /**
28
40
  * Substrings that, when present in a param KEY (case-insensitive), suggest the
@@ -4,63 +4,15 @@
4
4
  /**
5
5
  * Engine-driven workflow execution — the `akm workflow run`
6
6
  * start/resume/execute path, and the single execution surface for a run: akm
7
- * walks the frozen plan and dispatches every unit itself.
7
+ * walks the frozen plan and dispatches every unit itself. Every step
8
+ * advances through `completeWorkflowStep` (never a direct step-row write),
9
+ * the plan is read from its frozen `plan_json` row rather than live source,
10
+ * a run lease enforces one driving engine invocation at a time, gate loops
11
+ * are bounded, and the SDK dispatch registry is drained in a `finally` on
12
+ * every exit path so no child process keeps the event loop open.
8
13
  *
9
- * Invariant (plan §*Never bypass the gate spine*): every step advances
10
- * through `completeWorkflowStep`, never by writing step rows directly, so the
11
- * summary-validation gate and run-state derivation stay authoritative. A gate
12
- * rejection (SummaryValidationFailure) STOPS the engine and surfaces the
13
- * corrective feedback — a gate is a gate, even for the engine.
14
- *
15
- * Artifact-judging gates (redesign addendum, R2): when a step declares
16
- * completion criteria, the engine hands the gate a summary BUILT FROM the
17
- * step's promoted artifact (canonical JSON, clipped, prefixed with a one-line
18
- * unit count — `buildArtifactSummary`) instead of the machine-prose execution
19
- * summary, so the judge evaluates real results. Each engine-driven judge call
20
- * is journaled as a unit row (`node_id "<stepId>.gate"`, `unit_id
21
- * "<stepId>.gate:l<loop>"`, runner "llm", result_json = the verdict) through
22
- * the writer queue — it is an LLM call like any other. Human approvals are
23
- * never cached: a blocked gate stays blocked.
24
- *
25
- * Bounded gate loops (`gate.max_loops`, addendum R2): a rejection on a step
26
- * with maxLoops > 1 re-executes the step subgraph with the judge's feedback +
27
- * missing[] threaded into every unit prompt (`gateFeedback` on
28
- * StepExecutionContext) — the feedback changes each unit's input hash, so the
29
- * loop re-dispatches naturally instead of reusing the rejected rows. After
30
- * maxLoops rejections the engine stops with the gate feedback, exactly like
31
- * the one-shot case. A typed-artifact schema mismatch feeds the same loop
32
- * (the validation errors are the feedback; no judge ran, so no gate unit is
33
- * journaled for that attempt) — only the FINAL loop's mismatch fails the run.
34
- * A step whose subgraph is an `exec` unit is judged but NEVER looped
35
- * (`effectiveGateMaxLoops`): its argv cannot read the feedback, so a second
36
- * loop would only re-run the identical side effect.
37
- *
38
- * Frozen plan (redesign addendum, R1): the plan graph is read from the run
39
- * row (`plan_json`, persisted by `startWorkflowRun` under migration 006) with
40
- * a `plan_hash` integrity check — the workflow asset file is NEVER re-read
41
- * for an in-flight run, so a mid-run asset edit cannot change behavior.
42
- * Durable-row resume: re-invoking a partially-executed run re-dispatches only
43
- * work that never completed.
44
- *
45
- * Run lease (redesign addendum, R2): exactly one engine invocation drives a
46
- * run at a time. The lease (random holder id + 90s expiry on the run row) is
47
- * acquired before any dispatch, renewed between steps, and released in a
48
- * `finally` unless a failed run retains it as forensic state; a second
49
- * `workflow run` on a live-leased run refuses up front,
50
- * and an expired lease is claimable (crash recovery). While the lease is
51
- * live, any competing spine advance is refused — the engine owns the run while
52
- * driving (enforced inside `completeWorkflowStep`).
53
- *
54
- * Process-lifecycle contract (owner finding 4 — no leaked handles): the SDK
55
- * dispatch path caches `opencode serve` CHILD PROCESSES in a per-env registry
56
- * for reuse across units. Each live child is an OS handle that keeps Bun's
57
- * event loop open; the registry's own teardown is wired only to
58
- * `process.once('exit')`, which never fires while a child holds the loop open.
59
- * That deadlock hangs a one-shot CLI (`akm workflow run` has no `process.exit`
60
- * on success — it relies on the loop draining). The engine therefore DRAINS
61
- * the dispatch registry ({@link disposeDispatchResources}) in its run `finally`,
62
- * on EVERY exit path, so the process exits cleanly the moment the run resolves.
63
- * The drain is synchronous, idempotent, and a no-op when no SDK server started.
14
+ * See docs/architecture/decisions/0011-engine-run-loop-invariants.md for the
15
+ * full design history behind each of these invariants.
64
16
  */
65
17
  import { randomUUID } from "node:crypto";
66
18
  import { UsageError } from "../../core/errors.js";
@@ -373,7 +325,11 @@ function workflowSummaryJudge(options, stepPlan, signal, owner) {
373
325
  return options.summaryJudge;
374
326
  // The judge dispatches under the REAL run/step identity; the per-loop gate row
375
327
  // identity is threaded in per call by the completion path that journals it.
376
- return frozenSummaryJudge(stepPlan.gate.frozenJudge, signal, options.dispatcher ?? defaultUnitDispatcher, owner);
328
+ // eventSource (gap closed, code review): the judge's dispatch is a "command"
329
+ // request like any other exec/agent/sdk unit, so it goes through the same
330
+ // provenance thread `executeStepSubgraph` uses below — undefined for every
331
+ // non-task caller, byte-identical.
332
+ return frozenSummaryJudge(stepPlan.gate.frozenJudge, signal, options.dispatcher ?? defaultUnitDispatcher, owner, options.eventSource);
377
333
  }
378
334
  /**
379
335
  * Seed the lifetime unit cap AND the budget ceilings from the journal so
@@ -477,7 +433,10 @@ async function recoverGateLoopState(runId, stepPlan) {
477
433
  * The kinds that FINISHED the step (completed / failed / gate-exhausted) — the
478
434
  * ONE `maxSteps` consumption for its whole gate loop. An abort and a judge
479
435
  * outage leave the step unfinished and consume nothing: the next invocation
480
- * still owes the work.
436
+ * still owes the work. A blocked child is the SAME shape as a judge outage
437
+ * (P3b §3.4) — a gate is a gate for a child workflow too, so it is likewise
438
+ * NOT in this set: `akm workflow resume` is what clears it, never an
439
+ * automatic in-step re-dispatch.
481
440
  */
482
441
  const STEP_FINISHED_KINDS = new Set(["advanced", "failed", "gate-exhausted"]);
483
442
  /**
@@ -510,6 +469,9 @@ async function executeStepSubgraph(ctx, loop) {
510
469
  ...(plan.budget ? { budget: plan.budget } : {}),
511
470
  gateLoop,
512
471
  ...(gateFeedback ? { gateFeedback } : {}),
472
+ // F-1 (spec §5.2 point 2): threaded to an exec unit's child env;
473
+ // undefined for every non-task caller (byte-identical, RunWorkflowOptions doc).
474
+ ...(options.eventSource !== undefined ? { eventSource: options.eventSource } : {}),
513
475
  // The heartbeat's signal is the effective dispatch signal: a lost
514
476
  // lease (or a caller abort) aborts in-flight units promptly.
515
477
  ...(dispatchSignal ? { signal: dispatchSignal } : {}),
@@ -633,6 +595,29 @@ async function runStepGateLoop(ctx, gate, totals) {
633
595
  executed[executed.length - 1] = { ...executed[executed.length - 1], summary: finalize.summary };
634
596
  return outcome({ kind: "judge-failed", judgeFailure: { stepId: step.id, message: finalize.summary } });
635
597
  }
598
+ if (finalize.kind === "child-blocked") {
599
+ // P3b §3.4: a composed child workflow is blocked. Like judge-failed,
600
+ // NO gate loop was consumed and the step does not count against
601
+ // maxSteps. `result.childBlocked` (set by reduceStepOutcomes off the
602
+ // failed unit's live-only childRun field) carries the identity
603
+ // finalizeExecutedStep's own return value deliberately omits.
604
+ executed[executed.length - 1] = { ...executed[executed.length - 1], summary: finalize.summary };
605
+ const child = result.childBlocked;
606
+ return outcome({
607
+ kind: "child-blocked",
608
+ ...(child
609
+ ? {
610
+ childBlocked: {
611
+ stepId: step.id,
612
+ childRunId: child.childRunId,
613
+ childRef: child.childRef,
614
+ resume: `akm workflow resume ${child.childRunId}`,
615
+ resumeParentCommand: `akm workflow resume ${next.run.id} && akm workflow run ${next.run.id}`,
616
+ },
617
+ }
618
+ : {}),
619
+ });
620
+ }
636
621
  if (finalize.kind === "failed") {
637
622
  // A route-failure was pushed as ok:true (the units succeeded); reflect
638
623
  // the deterministic route failure in the executed report.
@@ -679,6 +664,7 @@ liveEvidence) {
679
664
  const executed = [];
680
665
  let gateRejection;
681
666
  let judgeFailure;
667
+ let childBlocked;
682
668
  let aborted = false;
683
669
  const maxSteps = options.maxSteps ?? Number.POSITIVE_INFINITY;
684
670
  // The `maxSteps` budget counts DISTINCT spine steps that finished processing
@@ -820,10 +806,12 @@ liveEvidence) {
820
806
  gateRejection = outcome.gateRejection;
821
807
  if (outcome.judgeFailure)
822
808
  judgeFailure = outcome.judgeFailure;
809
+ if (outcome.childBlocked)
810
+ childBlocked = outcome.childBlocked;
823
811
  if (STEP_FINISHED_KINDS.has(outcome.kind))
824
812
  stepsProcessed += 1;
825
813
  // Only an advance leaves the spine walkable; every other kind ends this
826
- // invocation (failure, exhausted gate, judge outage, abort).
814
+ // invocation (failure, exhausted gate, judge outage, blocked child, abort).
827
815
  if (outcome.kind !== "advanced")
828
816
  break;
829
817
  next = await getNextWorkflowStep(next.run.id);
@@ -839,6 +827,7 @@ liveEvidence) {
839
827
  ...(finalState.run.status === "completed" ? { done: true } : {}),
840
828
  ...(gateRejection ? { gateRejection } : {}),
841
829
  ...(judgeFailure ? { judgeFailure } : {}),
830
+ ...(childBlocked ? { childBlocked } : {}),
842
831
  ...(aborted ? { aborted: true } : {}),
843
832
  };
844
833
  }