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.
- package/CHANGELOG.md +493 -0
- package/STABILITY.md +23 -5
- package/dist/assets/hints/cli-hints-full.md +12 -7
- package/dist/assets/tasks/core/extract.yml +3 -5
- package/dist/assets/tasks/core/improve.yml +3 -5
- package/dist/assets/tasks/core/index-refresh.yml +3 -5
- package/dist/assets/tasks/core/sync.yml +3 -5
- package/dist/assets/tasks/core/version-check.yml +3 -5
- package/dist/assets/tasks/improve/akm-graph-refresh-weekly.yml +3 -5
- package/dist/assets/tasks/improve/akm-improve-catchup.yml +6 -6
- package/dist/assets/tasks/improve/akm-improve-consolidate.yml +3 -5
- package/dist/assets/tasks/improve/akm-improve-frequent.yml +3 -5
- package/dist/assets/tasks/improve/akm-improve-nightly.yml +3 -5
- package/dist/cli/unknown-flags.js +12 -1
- package/dist/cli.js +8 -1
- package/dist/commands/command/command-execution.js +23 -2
- package/dist/commands/health/improve-metrics.js +38 -0
- package/dist/commands/health/windows.js +8 -4
- package/dist/commands/health.js +8 -4
- package/dist/commands/lint/index.js +1 -1
- package/dist/commands/migrate-cli.js +130 -24
- package/dist/commands/proposal/validators/proposal-validators.js +7 -2
- package/dist/commands/tasks/explain.js +304 -0
- package/dist/commands/tasks/tasks-cli.js +185 -3
- package/dist/commands/tasks/tasks.js +265 -52
- package/dist/commands/workflow/plan.js +159 -0
- package/dist/commands/workflow-cli.js +94 -2
- package/dist/core/activation-policy.js +2 -12
- package/dist/core/adapter/adapters/akm-lint.js +7 -4
- package/dist/core/adapter/adapters/akm-metadata.js +26 -14
- package/dist/core/adapter/adapters/akm-task-adapter.js +13 -10
- package/dist/core/errors.js +45 -0
- package/dist/core/json-schema.js +15 -5
- package/dist/core/state/migrations.js +57 -0
- package/dist/core/state-db.js +16 -14
- package/dist/core/subprocess.js +47 -13
- package/dist/execution/guarded-source.js +44 -0
- package/dist/execution/input-contract.js +250 -0
- package/dist/execution/target-ref.js +63 -0
- package/dist/indexer/usage/usage-events.js +14 -3
- package/dist/integrations/agent/execution-lowering.js +12 -1
- package/dist/output/shapes/passthrough.js +2 -0
- package/dist/output/text/helpers.js +1 -1
- package/dist/output/text/migrate.js +12 -3
- package/dist/output/text/workflow-format.js +192 -10
- package/dist/output/text/workflow.js +2 -1
- package/dist/runtime.js +1 -0
- package/dist/scripts/akm-migrate-node.js +11838 -10118
- package/dist/scripts/akm-migrate.js +11828 -10117
- package/dist/setup/steps/tasks.js +34 -17
- package/dist/storage/repositories/task-history-repository.js +5 -1
- package/dist/storage/repositories/workflow-runs-repository.js +144 -6
- package/dist/tasks/backends/launchd.js +31 -84
- package/dist/tasks/embedded.js +13 -7
- package/dist/tasks/model/invocation.js +4 -0
- package/dist/tasks/prepare/prepare-script-target.js +9 -0
- package/dist/tasks/prepare/prepare-support.js +154 -0
- package/dist/tasks/prepare/prepare.js +117 -0
- package/dist/tasks/prepare/prepared-execution.js +4 -0
- package/dist/tasks/prepare/script-capture.js +80 -0
- package/dist/tasks/run/attempt-lifecycle.js +165 -0
- package/dist/tasks/run/load-task.js +117 -0
- package/dist/tasks/run/provenance.js +20 -0
- package/dist/tasks/run/run-command-task.js +92 -0
- package/dist/tasks/run/run-native-task.js +222 -0
- package/dist/tasks/run/run-task.js +99 -0
- package/dist/tasks/run/run-workflow-task.js +222 -0
- package/dist/tasks/run/task-history.js +134 -0
- package/dist/tasks/run/task-log.js +179 -0
- package/dist/tasks/run/task-result.js +19 -0
- package/dist/tasks/scheduler-binding.js +66 -2
- package/dist/tasks/scheduler-invocation.js +63 -3
- package/dist/tasks/scheduler-sync.js +77 -14
- package/dist/tasks/source/bounded-document.js +455 -0
- package/dist/tasks/source/parse-task-source.js +59 -0
- package/dist/tasks/source/project-v4.js +62 -0
- package/dist/tasks/source/task-input-diagnostics.js +36 -0
- package/dist/tasks/source/task-source-v4.js +626 -0
- package/dist/tasks/source-v3.js +10 -733
- package/dist/tasks/task-run-reserved-flags.js +79 -0
- package/dist/workflows/authoring/authoring.js +17 -8
- package/dist/workflows/exec/child-invocation.js +34 -0
- package/dist/workflows/exec/child-workflow.js +370 -0
- package/dist/workflows/exec/exec-unit.js +50 -170
- package/dist/workflows/exec/frozen-judge.js +19 -2
- package/dist/workflows/exec/native-executor.js +49 -27
- package/dist/workflows/exec/param-secrets.js +12 -0
- package/dist/workflows/exec/run-workflow.js +48 -59
- package/dist/workflows/exec/step-work.js +222 -80
- package/dist/workflows/exec/unit-dispatch.js +72 -0
- package/dist/workflows/freeze/child-output-references.js +94 -0
- package/dist/workflows/freeze/environment.js +174 -0
- package/dist/workflows/freeze/identity.js +22 -0
- package/dist/workflows/freeze/resolve-steps.js +78 -0
- package/dist/workflows/freeze/source-freeze.js +57 -0
- package/dist/workflows/freeze/step-values.js +68 -0
- package/dist/workflows/freeze/targets/child-workflow.js +206 -0
- package/dist/workflows/freeze/targets/command.js +81 -0
- package/dist/workflows/freeze/targets/script.js +57 -0
- package/dist/workflows/freeze/targets/shell.js +31 -0
- package/dist/workflows/freeze/targets/task.js +179 -0
- package/dist/workflows/freeze/task-bindings.js +180 -0
- package/dist/workflows/ir/compile.js +59 -11
- package/dist/workflows/ir/environment-v4.js +3 -3
- package/dist/workflows/ir/freeze-v4.js +41 -7
- package/dist/workflows/ir/params.js +58 -131
- package/dist/workflows/ir/plan-hash.js +3 -3
- package/dist/workflows/ir/schema-v4.js +246 -17
- package/dist/workflows/parser.js +74 -2
- package/dist/workflows/program/schema.js +5 -2
- package/dist/workflows/resource-limits.js +20 -0
- package/dist/workflows/runtime/plan-classifier.js +24 -7
- package/dist/workflows/runtime/run-outputs.js +103 -0
- package/dist/workflows/runtime/runs.js +114 -9
- package/dist/workflows/runtime/workflow-asset-loader.js +14 -6
- package/dist/workflows/source-files.js +5 -5
- package/dist/workflows/source-ir/compare.js +17 -0
- package/dist/workflows/source-ir/compile.js +7 -3
- package/dist/workflows/source-ir/github-yaml.js +64 -17
- package/dist/workflows/source-ir/schema.js +69 -21
- package/dist/workflows/source-ir/semantics.js +7 -25
- package/dist/workflows/source-ir/triggers.js +79 -0
- package/dist/workflows/source-ir/uses.js +33 -7
- package/docs/migration/README.md +1 -1
- package/docs/migration/release-notes/0.9.2.md +87 -11
- package/docs/migration/release-notes/README.md +3 -2
- package/docs/migration/v0.8-to-v0.9.md +13 -11
- package/docs/migration/v0.9.0-troubleshooting.md +20 -13
- package/docs/migration/v0.9.1-to-v0.9.2.md +598 -49
- package/docs/reference/README.md +1 -1
- package/docs/reference/cli.md +140 -46
- package/docs/reference/configuration.md +6 -5
- package/docs/reference/supported-formats.md +9 -5
- package/docs/reference/tasks.md +338 -75
- package/docs/reference/workflow-schema.md +290 -16
- package/docs/reference/workflows.md +57 -7
- package/package.json +1 -1
- package/schemas/akm-task.json +173 -118
- package/schemas/akm-workflow.json +28 -0
- package/dist/tasks/runner.js +0 -941
- package/dist/tasks/runtime-v3.js +0 -281
- package/dist/workflows/ir/source-freeze-v4.js +0 -506
- 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
|
|
67
|
+
const bundleDir = target.source.path;
|
|
59
68
|
const standaloneWorkflowBundle = target.source.adapterId === "akm-workflow";
|
|
60
|
-
const typeRoot = standaloneWorkflowBundle ?
|
|
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(
|
|
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,
|
|
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
|
-
|
|
108
|
+
bundleDir,
|
|
100
109
|
};
|
|
101
110
|
}
|
|
102
|
-
function readWorkflowSource(source,
|
|
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,
|
|
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 ${
|
|
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
|
+
}
|