akm-cli 0.9.0 → 0.9.1-beta.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 +724 -0
- package/README.md +28 -63
- package/STABILITY.md +4 -2
- package/dist/cli/parse-args.js +7 -1
- package/dist/commands/agent/contribute-cli.js +1 -1
- package/dist/commands/env/child-env.js +14 -0
- package/dist/commands/feedback-cli.js +7 -1
- package/dist/commands/health/llm-usage.js +2 -1
- package/dist/commands/health/surfaces.js +4 -77
- package/dist/commands/health.js +65 -11
- package/dist/commands/improve/distill/quality-gate.js +6 -1
- package/dist/commands/improve/eligibility.js +7 -1
- package/dist/commands/improve/eval-cases.js +2 -0
- package/dist/commands/improve/improve.js +126 -10
- package/dist/commands/improve/locks.js +7 -0
- package/dist/commands/improve/memory/memory-improve.js +9 -0
- package/dist/commands/improve/run-context.js +5 -0
- package/dist/commands/improve/session-asset.js +4 -0
- package/dist/commands/lint/base-linter.js +31 -7
- package/dist/commands/lint/index.js +205 -51
- package/dist/commands/lint/types.js +22 -1
- package/dist/commands/proposal/repository.js +17 -1
- package/dist/commands/sources/add-cli.js +8 -2
- package/dist/commands/sources/info.js +12 -2
- package/dist/commands/sources/installed-stashes.js +6 -1
- package/dist/commands/sources/migration-help.js +12 -3
- package/dist/commands/sources/self-update.js +9 -1
- package/dist/commands/tasks/tasks.js +8 -2
- package/dist/commands/workflow-cli.js +17 -11
- package/dist/core/abort-deadline.js +28 -0
- package/dist/core/adapter/adapters/agent-skills-adapter.js +83 -5
- package/dist/core/adapter/adapters/akm-adapter.js +13 -10
- package/dist/core/adapter/adapters/akm-lint.js +78 -22
- package/dist/core/adapter/adapters/akm-task-adapter.js +43 -20
- package/dist/core/adapter/adapters/dotenv-adapter.js +21 -0
- package/dist/core/adapter/adapters/tool-dir-shared.js +5 -3
- package/dist/core/asset/frontmatter.js +10 -1
- package/dist/core/common.js +147 -9
- package/dist/core/concurrent.js +32 -0
- package/dist/core/config/config-io.js +5 -45
- package/dist/core/config/schema/engines.js +14 -3
- package/dist/core/config/schema/workflow.js +11 -0
- package/dist/core/errors.js +25 -0
- package/dist/core/events.js +30 -24
- package/dist/core/extra-params.js +11 -0
- package/dist/core/file-lock.js +7 -1
- package/dist/core/fs-txn.js +15 -2
- package/dist/core/improve-result.js +5 -0
- package/dist/core/json-schema.js +344 -9
- package/dist/core/loopback.js +89 -0
- package/dist/core/migration-operation.js +17 -2
- package/dist/core/path-access.js +107 -0
- package/dist/core/paths.js +16 -2
- package/dist/core/redaction.js +86 -18
- package/dist/core/spawn-env.js +234 -0
- package/dist/core/state-db-scope.js +134 -0
- package/dist/core/state-db.js +1 -0
- package/dist/core/subprocess.js +181 -37
- package/dist/core/write-provenance.js +85 -0
- package/dist/core/write-source.js +33 -2
- package/dist/indexer/db/graph-db.js +17 -6
- package/dist/indexer/ensure-index.js +10 -3
- package/dist/indexer/index-written-assets.js +17 -2
- package/dist/indexer/indexer.js +86 -21
- package/dist/indexer/passes/memory-inference.js +4 -0
- package/dist/indexer/search/db-search.js +25 -17
- package/dist/indexer/walk/walker.js +6 -1
- package/dist/integrations/agent/detect.js +13 -1
- package/dist/integrations/agent/engine-resolution.js +24 -11
- package/dist/integrations/agent/model-aliases.js +1 -1
- package/dist/integrations/agent/profiles.js +9 -1
- package/dist/integrations/agent/spawn.js +15 -87
- package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +21 -0
- package/dist/integrations/lockfile.js +55 -2
- package/dist/llm/client.js +14 -19
- package/dist/llm/embedder.js +23 -3
- package/dist/llm/embedders/remote.js +27 -2
- package/dist/output/html-render.js +40 -1
- package/dist/output/text/lint-format.js +17 -4
- package/dist/runtime.js +23 -1
- package/dist/scripts/akm-migrate-node.js +1714 -836
- package/dist/scripts/akm-migrate.js +1682 -804
- package/dist/setup/setup.js +22 -7
- package/dist/sources/providers/git-install.js +25 -2
- package/dist/sources/providers/git-stash.js +19 -0
- package/dist/sources/providers/git.js +1 -1
- package/dist/sources/snapshot-fetchers/content-extract.js +63 -1
- package/dist/sources/snapshot-fetchers/website-ingest.js +126 -20
- package/dist/storage/database.js +71 -7
- package/dist/storage/engines/sqlite-migrations.js +61 -2
- package/dist/storage/managed-db.js +19 -0
- package/dist/storage/repositories/index-connection.js +39 -4
- package/dist/storage/repositories/index-entries-repository.js +6 -1
- package/dist/storage/repositories/index-meta-repository.js +11 -0
- package/dist/storage/repositories/index-schema.js +17 -2
- package/dist/storage/repositories/index-vec-repository.js +43 -5
- package/dist/storage/repositories/workflow-runs-repository.js +66 -13
- package/dist/storage/sqlite-pragmas.js +12 -1
- package/dist/tasks/log-redaction.js +156 -0
- package/dist/tasks/parser.js +82 -5
- package/dist/tasks/runner.js +222 -17
- package/dist/tasks/scheduler-invocation.js +19 -0
- package/dist/tasks/schema.js +86 -1
- package/dist/text-import-hook.mjs +1 -1
- package/dist/workflows/concurrency-policy.js +95 -1
- package/dist/workflows/exec/dispatch-redaction.js +114 -0
- package/dist/workflows/exec/exec-unit.js +542 -0
- package/dist/workflows/exec/frozen-judge.js +114 -42
- package/dist/workflows/exec/native-executor.js +465 -238
- package/dist/workflows/exec/param-secrets.js +4 -3
- package/dist/workflows/exec/run-workflow.js +424 -219
- package/dist/workflows/exec/step-work.js +506 -167
- package/dist/workflows/exec/unit-dispatch.js +31 -1
- package/dist/workflows/exec/unit-writer.js +53 -13
- package/dist/workflows/exec/worktree.js +454 -41
- package/dist/workflows/ir/compile.js +26 -2
- package/dist/workflows/ir/freeze.js +82 -15
- package/dist/workflows/ir/schema.js +105 -20
- package/dist/workflows/parser.js +242 -19
- package/dist/workflows/program/schema.js +24 -0
- package/dist/workflows/renderer.js +32 -4
- package/dist/workflows/resource-limits.js +182 -0
- package/dist/workflows/runtime/runs.js +146 -6
- package/dist/workflows/validate-summary.js +17 -2
- package/docs/README.md +74 -32
- package/docs/migration/release-notes/0.9.0.md +2 -1
- package/docs/migration/v0.7-to-v0.8.md +2 -1
- package/docs/migration/v0.8-to-v0.9.md +3 -1
- package/docs/reference/README.md +11 -4
- package/docs/reference/bundle-types.md +19 -0
- package/docs/reference/cli.md +105 -16
- package/docs/reference/configuration.md +15 -2
- package/docs/reference/data-and-telemetry.md +30 -10
- package/docs/reference/supported-formats.md +50 -0
- package/docs/reference/workflow-schema.md +1014 -0
- package/docs/reference/workflows.md +37 -633
- package/package.json +13 -6
- package/schemas/akm-config.json +18 -5
- package/schemas/akm-task.json +27 -5
- package/schemas/akm-workflow.json +92 -13
|
@@ -10,14 +10,16 @@ import { resolveLlmEngineUse, } from "../../integrations/agent/engine-resolution
|
|
|
10
10
|
import { resolveLlmModel, resolveModel } from "../../integrations/agent/model-aliases.js";
|
|
11
11
|
import { getBuiltinAgentProfile } from "../../integrations/agent/profiles.js";
|
|
12
12
|
import { HARNESS_BY_ID } from "../../integrations/harnesses/index.js";
|
|
13
|
-
import { workflowMaxConcurrency } from "../concurrency-policy.js";
|
|
13
|
+
import { defaultLlmEngineConcurrency, defaultMapConcurrency, workflowMaxConcurrency } from "../concurrency-policy.js";
|
|
14
|
+
import { projectExecCore } from "../program/schema.js";
|
|
15
|
+
import { DEFAULT_EXEC_TIMEOUT_MS } from "../resource-limits.js";
|
|
14
16
|
import { compileWorkflowPlan } from "./compile.js";
|
|
15
17
|
import { decodeWorkflowPlanV3, WORKFLOW_IR_VERSION } from "./schema.js";
|
|
16
18
|
/**
|
|
17
19
|
* The only source-to-runtime boundary. Source compilation remains pure; engine
|
|
18
20
|
* selection and every dispatch-significant setting are resolved here once.
|
|
19
21
|
*/
|
|
20
|
-
export function compileResolveFreezeWorkflow(asset, inputConfig) {
|
|
22
|
+
export function compileResolveFreezeWorkflow(asset, inputConfig, options = {}) {
|
|
21
23
|
// Applied ONCE, before any resolution: every engine lookup below (selection,
|
|
22
24
|
// snapshots, the gate judge) then sees one config and needs no fallback
|
|
23
25
|
// awareness of its own.
|
|
@@ -26,11 +28,19 @@ export function compileResolveFreezeWorkflow(asset, inputConfig) {
|
|
|
26
28
|
// `defaults.engine` is the lowest-precedence selector, so a document- or
|
|
27
29
|
// unit-level `engine:` still wins and must not be reported as opencode's.
|
|
28
30
|
let usedFallbackEngine = false;
|
|
29
|
-
const preliminary = compilePlan(asset);
|
|
31
|
+
const preliminary = (options.compile ?? compilePlan)(asset);
|
|
30
32
|
const engines = {};
|
|
31
33
|
const maxConcurrency = frozenConcurrency(config);
|
|
34
|
+
const mapDefaultConcurrency = frozenMapDefaultConcurrency(config);
|
|
32
35
|
const documentDefaults = asset.document.defaults;
|
|
33
|
-
|
|
36
|
+
// Keyed by stepId, NOT by array position. Compile is 1:1 and order-preserving
|
|
37
|
+
// today, so `asset.document.steps[index]` happened to line up with
|
|
38
|
+
// `preliminary.steps[index]` — but nothing enforces that, and a compile pass
|
|
39
|
+
// that ever filtered or reordered steps would silently attribute one step's
|
|
40
|
+
// engine/model/timeout overrides to a different step. Every draft step
|
|
41
|
+
// already carries its `stepId`, so look the source up by it.
|
|
42
|
+
const sourceStepsById = new Map(asset.document.steps.map((step) => [step.id, step]));
|
|
43
|
+
const freezeInvocation = (unit, stepId) => {
|
|
34
44
|
const layers = [...(documentDefaults ? [documentDefaults] : []), ...(unit ? [unit] : [])];
|
|
35
45
|
const name = selectedEngine(config, layers);
|
|
36
46
|
if (!name)
|
|
@@ -44,19 +54,51 @@ export function compileResolveFreezeWorkflow(asset, inputConfig) {
|
|
|
44
54
|
addSnapshot(config, name, engines);
|
|
45
55
|
const model = exactModel(config, name, engine, layers);
|
|
46
56
|
const timeoutMs = effectiveTimeout(config, engine, layers);
|
|
47
|
-
|
|
57
|
+
// Merge llm overrides REGARDLESS of engine kind so a non-llm engine with
|
|
58
|
+
// overrides anywhere in its layer stack (unit `llm:` or document
|
|
59
|
+
// `defaults.llm`) is detected instead of silently dropped. SDK engines'
|
|
60
|
+
// legitimate LLM *fallback* (`llmEngine`) is a separate mechanism — it
|
|
61
|
+
// never contributes to `layers`, so it cannot false-positive here.
|
|
62
|
+
const llm = mergedLlmOverrides(layers);
|
|
48
63
|
if (engine.kind !== "llm" && llm !== undefined) {
|
|
49
|
-
throw new ConfigError(`Workflow engine "${name}" is an agent engine and cannot receive llm
|
|
64
|
+
throw new ConfigError(`Workflow step "${stepId}" uses engine "${name}", which is an agent engine and cannot receive llm: ` +
|
|
65
|
+
`overrides — llm: tuning (from the step's unit or defaults.llm) applies only to engines of kind "llm". ` +
|
|
66
|
+
`Remove the llm: block or select an LLM engine for this step.`, "INVALID_CONFIG_FILE");
|
|
50
67
|
}
|
|
51
68
|
return { engine: name, model, timeoutMs, ...(llm ? { llm } : {}) };
|
|
52
69
|
};
|
|
53
|
-
|
|
70
|
+
/**
|
|
71
|
+
* Resolve an exec unit's wall-clock budget at the single freeze boundary:
|
|
72
|
+
* unit `timeout:` → document `defaults.timeout` → {@link DEFAULT_EXEC_TIMEOUT_MS}.
|
|
73
|
+
* There is no engine layer to consult — an exec unit names no engine — and
|
|
74
|
+
* `null` (the author's `timeout: none`) is honored as genuinely unbounded,
|
|
75
|
+
* exactly like `effectiveTimeout`'s null.
|
|
76
|
+
*/
|
|
77
|
+
const freezeExec = (exec, unit) => {
|
|
78
|
+
const layers = [...(documentDefaults ? [documentDefaults] : []), ...(unit ? [unit] : [])];
|
|
79
|
+
const declared = layeredTimeout(layers);
|
|
80
|
+
const timeoutMs = declared === undefined ? DEFAULT_EXEC_TIMEOUT_MS : declared;
|
|
81
|
+
// The shared structural projection, plus the one thing freezing adds: the
|
|
82
|
+
// RESOLVED timeout. The default allowlist stays the ABSENCE of both env
|
|
83
|
+
// keys — one encoding per state, which is what keeps the canonical hash
|
|
84
|
+
// preimage stable. `command` is non-empty by parser construction.
|
|
85
|
+
const core = projectExecCore(exec);
|
|
86
|
+
return {
|
|
87
|
+
...core,
|
|
88
|
+
command: core.command,
|
|
89
|
+
timeoutMs,
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
const freezeUnit = (node, stepId, unit) => ({
|
|
54
93
|
kind: "unit",
|
|
55
94
|
id: node.id,
|
|
56
95
|
instructions: node.instructions,
|
|
57
96
|
templating: node.templating ?? "verbatim",
|
|
58
97
|
...(node.inputs && node.inputs.length > 0 ? { inputs: node.inputs } : {}),
|
|
59
|
-
|
|
98
|
+
// An exec unit dispatches a child process, so it freezes an exec spec and
|
|
99
|
+
// NO invocation — engine selection is skipped entirely, which is why an
|
|
100
|
+
// exec-only workflow runs on an install with no engines configured at all.
|
|
101
|
+
...(node.exec ? { exec: freezeExec(node.exec, unit) } : { invocation: freezeInvocation(unit, stepId) }),
|
|
60
102
|
...(node.schema ? { schema: node.schema } : {}),
|
|
61
103
|
...(node.retry ? { retry: node.retry } : {}),
|
|
62
104
|
onError: node.onError,
|
|
@@ -64,8 +106,8 @@ export function compileResolveFreezeWorkflow(asset, inputConfig) {
|
|
|
64
106
|
isolation: node.isolation ?? "none",
|
|
65
107
|
...(node.source ? { source: node.source } : {}),
|
|
66
108
|
});
|
|
67
|
-
const steps = preliminary.steps.map((step
|
|
68
|
-
const sourceStep =
|
|
109
|
+
const steps = preliminary.steps.map((step) => {
|
|
110
|
+
const sourceStep = sourceStepsById.get(step.stepId);
|
|
69
111
|
const sourceUnit = sourceStep?.map ? sourceStep.map.unit : sourceStep?.unit;
|
|
70
112
|
const root = step.root
|
|
71
113
|
? step.root.kind === "map"
|
|
@@ -73,12 +115,14 @@ export function compileResolveFreezeWorkflow(asset, inputConfig) {
|
|
|
73
115
|
kind: "map",
|
|
74
116
|
id: step.root.id,
|
|
75
117
|
over: step.root.over,
|
|
76
|
-
template: freezeUnit(step.root.template, sourceUnit),
|
|
77
|
-
concurrency:
|
|
118
|
+
template: freezeUnit(step.root.template, step.stepId, sourceUnit),
|
|
119
|
+
// `?? ` — not `||` — keeps an authored `concurrency: 1` (explicit
|
|
120
|
+
// opt-out, serial) distinguishable from an unset one (the default).
|
|
121
|
+
concurrency: step.root.concurrency ?? mapDefaultConcurrency,
|
|
78
122
|
reducer: step.root.reducer,
|
|
79
123
|
...(step.root.source ? { source: step.root.source } : {}),
|
|
80
124
|
}
|
|
81
|
-
: freezeUnit(step.root, sourceUnit)
|
|
125
|
+
: freezeUnit(step.root, step.stepId, sourceUnit)
|
|
82
126
|
: undefined;
|
|
83
127
|
const criteria = step.gate.criteria;
|
|
84
128
|
const judge = criteria.length === 0 ? null : freezeGateJudge(config, engines);
|
|
@@ -161,11 +205,24 @@ function exactModel(config, name, engine, layers) {
|
|
|
161
205
|
return resolveLlmModel(selected, name, config.modelAliases);
|
|
162
206
|
return resolveModel(selected, engine.platform, engine.modelAliases, config.modelAliases);
|
|
163
207
|
}
|
|
164
|
-
|
|
208
|
+
/**
|
|
209
|
+
* Newest-layer-first scan for a declared `timeoutMs` across authoring layers
|
|
210
|
+
* (document `defaults`, then the unit). Returns `undefined` when no layer
|
|
211
|
+
* declares one; an explicit `timeoutMs: null` ("unbounded") wins over deeper
|
|
212
|
+
* layers — hence `hasOwn`, not a value test. The ONE definition of authoring
|
|
213
|
+
* timeout precedence, shared by engine and exec freezing.
|
|
214
|
+
*/
|
|
215
|
+
function layeredTimeout(layers) {
|
|
165
216
|
for (let index = layers.length - 1; index >= 0; index--) {
|
|
166
217
|
if (Object.hasOwn(layers[index] ?? {}, "timeoutMs"))
|
|
167
218
|
return layers[index]?.timeoutMs ?? null;
|
|
168
219
|
}
|
|
220
|
+
return undefined;
|
|
221
|
+
}
|
|
222
|
+
function effectiveTimeout(config, engine, layers) {
|
|
223
|
+
const declared = layeredTimeout(layers);
|
|
224
|
+
if (declared !== undefined)
|
|
225
|
+
return declared;
|
|
169
226
|
if (Object.hasOwn(engine, "timeoutMs"))
|
|
170
227
|
return engine.timeoutMs ?? null;
|
|
171
228
|
if (engine.kind === "llm")
|
|
@@ -199,7 +256,7 @@ function addSnapshot(config, name, target) {
|
|
|
199
256
|
kind: "llm",
|
|
200
257
|
endpoint: engine.endpoint,
|
|
201
258
|
model: exactModel(config, name, engine, []),
|
|
202
|
-
concurrency: engine.concurrency
|
|
259
|
+
concurrency: defaultLlmEngineConcurrency(engine.endpoint, engine.concurrency),
|
|
203
260
|
...(engine.provider ? { provider: engine.provider } : {}),
|
|
204
261
|
...(resolved.credential ? { credential: resolved.credential } : {}),
|
|
205
262
|
...(engine.temperature !== undefined ? { temperature: engine.temperature } : {}),
|
|
@@ -251,3 +308,13 @@ function frozenConcurrency(config) {
|
|
|
251
308
|
const configured = config.workflow?.maxConcurrency;
|
|
252
309
|
return workflowMaxConcurrency(typeof configured === "number" && Number.isFinite(configured) ? configured : undefined);
|
|
253
310
|
}
|
|
311
|
+
/**
|
|
312
|
+
* Width to freeze into a `map` node that declared no `concurrency:`. Resolved
|
|
313
|
+
* HERE, at the single freeze boundary, so the number lands in `plan_json` and
|
|
314
|
+
* an in-flight run keeps the width it started with even if this default (or
|
|
315
|
+
* the config key) changes underneath it.
|
|
316
|
+
*/
|
|
317
|
+
function frozenMapDefaultConcurrency(config) {
|
|
318
|
+
const configured = config.workflow?.defaultMapConcurrency;
|
|
319
|
+
return defaultMapConcurrency(typeof configured === "number" ? configured : undefined);
|
|
320
|
+
}
|
|
@@ -2,22 +2,37 @@
|
|
|
2
2
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
3
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
4
|
import path from "node:path";
|
|
5
|
+
import { isContainedRelativePath } from "../../core/common.js";
|
|
5
6
|
import { UsageError } from "../../core/errors.js";
|
|
6
7
|
import { formatExtraParamsIssue, validateExtraParams } from "../../core/extra-params.js";
|
|
7
8
|
import { HARNESS_BY_ID } from "../../integrations/harnesses/index.js";
|
|
8
9
|
import { parseReference } from "../program/expressions.js";
|
|
9
10
|
import { PROGRAM_PARAM_NAME_PATTERN, PROGRAM_RETRY_REASONS, PROGRAM_STEP_ID_PATTERN } from "../program/schema.js";
|
|
10
|
-
import { jsonBytes, WORKFLOW_MAX_ENGINES, WORKFLOW_MAX_EXTRA_PARAMS_BYTES, WORKFLOW_MAX_INPUTS, WORKFLOW_MAX_INSTRUCTION_BYTES, WORKFLOW_MAX_JSON_DEPTH, WORKFLOW_MAX_MAP_EXPANSION, WORKFLOW_MAX_PARAMS, WORKFLOW_MAX_PLAN_BYTES, WORKFLOW_MAX_ROUTE_BRANCHES, WORKFLOW_MAX_SCHEMA_BYTES, WORKFLOW_MAX_STEPS, } from "../resource-limits.js";
|
|
11
|
+
import { jsonBytes, utf8Bytes, WORKFLOW_ENGINE_NAME_PATTERN, WORKFLOW_ENV_VAR_NAME_PATTERN, WORKFLOW_MAX_CONCURRENCY, WORKFLOW_MAX_ENGINE_NAME_LENGTH, WORKFLOW_MAX_ENGINES, WORKFLOW_MAX_EXEC_ARG_BYTES, WORKFLOW_MAX_EXEC_ARGV, WORKFLOW_MAX_EXEC_CWD_LENGTH, WORKFLOW_MAX_EXEC_PASS_ENV, WORKFLOW_MAX_EXTRA_PARAMS_BYTES, WORKFLOW_MAX_GATE_LOOPS, WORKFLOW_MAX_INPUTS, WORKFLOW_MAX_INSTRUCTION_BYTES, WORKFLOW_MAX_JSON_DEPTH, WORKFLOW_MAX_MAP_EXPANSION, WORKFLOW_MAX_PARAMS, WORKFLOW_MAX_PLAN_BYTES, WORKFLOW_MAX_RETRIES, WORKFLOW_MAX_ROUTE_BRANCHES, WORKFLOW_MAX_SCHEMA_BYTES, WORKFLOW_MAX_STEPS, WORKFLOW_MAX_TIMEOUT_MS, } from "../resource-limits.js";
|
|
11
12
|
/** The only executable persisted workflow plan format. */
|
|
12
13
|
export const WORKFLOW_IR_VERSION = 3;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
/**
|
|
15
|
+
* The {@link IrRuntimeKind} a frozen engine dispatches as — the value journaled
|
|
16
|
+
* in `workflow_run_units.runner` and read back by `status --units`.
|
|
17
|
+
*
|
|
18
|
+
* Lives here, beside the union, because the mapping is a property OF the union:
|
|
19
|
+
* spelled at each call site instead, a kind added to `FrozenEngineSnapshot`
|
|
20
|
+
* would type-check against whichever site happens to end with a default and be
|
|
21
|
+
* silently mis-journaled. Callers supply their own answer for the ABSENCE of an
|
|
22
|
+
* engine, which genuinely differs — a unit without one is an `exec` unit, while
|
|
23
|
+
* a gate without one is an llm judge.
|
|
24
|
+
*/
|
|
25
|
+
export function engineRuntimeKind(engine) {
|
|
26
|
+
return engine.kind === "llm" ? "llm" : engine.runnerKind;
|
|
27
|
+
}
|
|
28
|
+
// Shared dispatch-significant bounds now live in `../resource-limits` so the
|
|
29
|
+
// parser, the published JSON Schema, and this decoder enforce identical
|
|
30
|
+
// values. Re-exported here for existing importers (e.g. `commands/workflow-cli.ts`).
|
|
31
|
+
export { WORKFLOW_MAX_CONCURRENCY, WORKFLOW_MAX_GATE_LOOPS, WORKFLOW_MAX_RETRIES, WORKFLOW_MAX_TIMEOUT_MS };
|
|
16
32
|
export const WORKFLOW_MAX_UNITS = WORKFLOW_MAX_MAP_EXPANSION;
|
|
17
|
-
export const WORKFLOW_MAX_TIMEOUT_MS = 2 ** 31 - 1;
|
|
18
33
|
const MAX_LIST_ITEMS = 1024;
|
|
19
34
|
const MAX_STRING_LENGTH = 1_000_000;
|
|
20
|
-
const ENGINE_NAME_PATTERN =
|
|
35
|
+
const ENGINE_NAME_PATTERN = WORKFLOW_ENGINE_NAME_PATTERN;
|
|
21
36
|
/** Strictly decode persisted v3 data before it can drive a workflow. */
|
|
22
37
|
export function decodeWorkflowPlanV3(input, hooks = {}) {
|
|
23
38
|
if (!isRecord(input) || input.irVersion !== WORKFLOW_IR_VERSION)
|
|
@@ -34,7 +49,7 @@ export function decodeWorkflowPlanV3(input, hooks = {}) {
|
|
|
34
49
|
!Number.isInteger(plan.execution.maxConcurrency) ||
|
|
35
50
|
plan.execution.maxConcurrency < 1 ||
|
|
36
51
|
plan.execution.maxConcurrency > WORKFLOW_MAX_CONCURRENCY) {
|
|
37
|
-
fail(
|
|
52
|
+
fail(`execution.maxConcurrency must be an integer from 1 through ${WORKFLOW_MAX_CONCURRENCY}`);
|
|
38
53
|
}
|
|
39
54
|
assertKeys(plan.execution, ["maxConcurrency", "engines"], "execution");
|
|
40
55
|
if (!isRecord(plan.execution.engines))
|
|
@@ -62,8 +77,7 @@ export function decodeWorkflowPlanV3(input, hooks = {}) {
|
|
|
62
77
|
assertString(step.title, `step ${step.stepId} title`);
|
|
63
78
|
if (!!step.root === !!step.route)
|
|
64
79
|
fail(`step ${step.stepId} must contain exactly one of root or route`);
|
|
65
|
-
assertKeys(step, ["stepId", "title", "sequenceIndex", "
|
|
66
|
-
validateStringArray(step.dependsOn, `step ${step.stepId} dependsOn`, WORKFLOW_MAX_STEPS, true);
|
|
80
|
+
assertKeys(step, ["stepId", "title", "sequenceIndex", "root", "route", "outputSchema", "gate"], `step ${step.stepId}`);
|
|
67
81
|
if (step.outputSchema !== undefined)
|
|
68
82
|
validateSchema(step.outputSchema, `step ${step.stepId} outputSchema`);
|
|
69
83
|
if (step.root)
|
|
@@ -83,13 +97,6 @@ export function decodeWorkflowPlanV3(input, hooks = {}) {
|
|
|
83
97
|
if (targetIndex <= index)
|
|
84
98
|
fail(`route target ${target} must come after step ${step.stepId}`);
|
|
85
99
|
}
|
|
86
|
-
const dependencies = new Set();
|
|
87
|
-
for (const dependency of step.dependsOn ?? []) {
|
|
88
|
-
const dependencyIndex = stepIndex.get(dependency);
|
|
89
|
-
if (dependencyIndex === undefined || dependencyIndex >= index || dependencies.has(dependency))
|
|
90
|
-
fail(`step ${step.stepId} has an invalid dependency`);
|
|
91
|
-
dependencies.add(dependency);
|
|
92
|
-
}
|
|
93
100
|
validateStepExpressions(step, index, stepIndex);
|
|
94
101
|
}
|
|
95
102
|
for (const name of references) {
|
|
@@ -122,7 +129,7 @@ export function decodeWorkflowPlanV3(input, hooks = {}) {
|
|
|
122
129
|
}
|
|
123
130
|
function validateEngine(key, engine, references, hooks) {
|
|
124
131
|
if (!ENGINE_NAME_PATTERN.test(key) ||
|
|
125
|
-
key.length >
|
|
132
|
+
key.length > WORKFLOW_MAX_ENGINE_NAME_LENGTH ||
|
|
126
133
|
!isRecord(engine) ||
|
|
127
134
|
engine.name !== key ||
|
|
128
135
|
(engine.kind !== "llm" && engine.kind !== "agent"))
|
|
@@ -204,7 +211,7 @@ function validateEngine(key, engine, references, hooks) {
|
|
|
204
211
|
!(typeof engine.workspace === "string" || engine.workspace === null) ||
|
|
205
212
|
!Array.isArray(engine.envPassthrough) ||
|
|
206
213
|
engine.envPassthrough.length > MAX_LIST_ITEMS ||
|
|
207
|
-
!engine.envPassthrough.every((name) => typeof name === "string" &&
|
|
214
|
+
!engine.envPassthrough.every((name) => typeof name === "string" && WORKFLOW_ENV_VAR_NAME_PATTERN.test(name)) ||
|
|
208
215
|
new Set(engine.envPassthrough).size !== engine.envPassthrough.length ||
|
|
209
216
|
typeof engine.commandBuilder !== "string" ||
|
|
210
217
|
engine.commandBuilder !== engine.platform ||
|
|
@@ -255,6 +262,7 @@ function validateNode(node, stepId, references, nodeIds, hooks) {
|
|
|
255
262
|
"templating",
|
|
256
263
|
"inputs",
|
|
257
264
|
"invocation",
|
|
265
|
+
"exec",
|
|
258
266
|
"schema",
|
|
259
267
|
"retry",
|
|
260
268
|
"onError",
|
|
@@ -264,13 +272,13 @@ function validateNode(node, stepId, references, nodeIds, hooks) {
|
|
|
264
272
|
], `unit ${node.id}`);
|
|
265
273
|
if (typeof node.instructions !== "string" ||
|
|
266
274
|
!node.instructions ||
|
|
267
|
-
|
|
275
|
+
node.templating !== "verbatim" ||
|
|
268
276
|
(node.onError !== "fail" && node.onError !== "continue") ||
|
|
269
277
|
(node.isolation !== "none" && node.isolation !== "worktree"))
|
|
270
278
|
fail(`unit ${node.id} is invalid`);
|
|
271
279
|
if (node.id !== stepId && node.id !== `${stepId}.unit`)
|
|
272
280
|
fail(`unit ${node.id} does not belong to step ${stepId}`);
|
|
273
|
-
if (
|
|
281
|
+
if (utf8Bytes(node.instructions) > WORKFLOW_MAX_INSTRUCTION_BYTES)
|
|
274
282
|
fail(`unit ${node.id} instructions exceed the 256 KiB resource limit`);
|
|
275
283
|
if (node.schema !== undefined)
|
|
276
284
|
validateSchema(node.schema, `unit ${node.id} schema`);
|
|
@@ -278,8 +286,77 @@ function validateNode(node, stepId, references, nodeIds, hooks) {
|
|
|
278
286
|
validateStringArray(node.env, `unit ${node.id} env`, MAX_LIST_ITEMS, true);
|
|
279
287
|
validateStringArray(node.inputs, `unit ${node.id} inputs`, WORKFLOW_MAX_INPUTS, true);
|
|
280
288
|
validateSource(node.source, `unit ${node.id} source`);
|
|
289
|
+
// Exactly one dispatch mechanism per unit. A node carrying both would let a
|
|
290
|
+
// tampered plan smuggle a shell command past the engine-compatibility checks
|
|
291
|
+
// (which key off the invocation); one carrying neither has nothing to run.
|
|
292
|
+
if ((node.invocation === undefined) === (node.exec === undefined)) {
|
|
293
|
+
fail(`unit ${node.id} must declare exactly one of invocation or exec`);
|
|
294
|
+
}
|
|
295
|
+
if (node.exec !== undefined) {
|
|
296
|
+
validateExecSpec(node.exec, `unit ${node.id} exec`);
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
281
299
|
validateInvocation(node.invocation, references, hooks);
|
|
282
300
|
}
|
|
301
|
+
/**
|
|
302
|
+
* Strictly decode a frozen {@link IrExecSpec}. This is the corruption gate for
|
|
303
|
+
* a persisted plan: the argv bounds, the relative-and-contained `cwd`, and the
|
|
304
|
+
* timeout range are all re-checked here, because `plan_json` may have been
|
|
305
|
+
* hand-edited between freeze and dispatch.
|
|
306
|
+
*/
|
|
307
|
+
function validateExecSpec(value, label) {
|
|
308
|
+
if (!isRecord(value))
|
|
309
|
+
fail(`${label} must be an object`);
|
|
310
|
+
assertKeys(value, ["command", "cwd", "passEnv", "inheritEnv", "timeoutMs"], label);
|
|
311
|
+
validateExecEnvScope(value, label);
|
|
312
|
+
const command = value.command;
|
|
313
|
+
if (!Array.isArray(command) ||
|
|
314
|
+
command.length === 0 ||
|
|
315
|
+
command.length > WORKFLOW_MAX_EXEC_ARGV ||
|
|
316
|
+
!command.every((arg) => typeof arg === "string" && arg.length > 0 && utf8Bytes(arg) <= WORKFLOW_MAX_EXEC_ARG_BYTES)) {
|
|
317
|
+
fail(`${label}.command must be an argv array of 1 through ${WORKFLOW_MAX_EXEC_ARGV} bounded non-empty strings`);
|
|
318
|
+
}
|
|
319
|
+
if (value.cwd !== undefined) {
|
|
320
|
+
if (typeof value.cwd !== "string" ||
|
|
321
|
+
value.cwd.length === 0 ||
|
|
322
|
+
value.cwd.length > WORKFLOW_MAX_EXEC_CWD_LENGTH ||
|
|
323
|
+
!isContainedRelativePath(value.cwd)) {
|
|
324
|
+
fail(`${label}.cwd must be a relative path contained in the unit working directory`);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (!(value.timeoutMs === null ||
|
|
328
|
+
(Number.isSafeInteger(value.timeoutMs) &&
|
|
329
|
+
value.timeoutMs >= 1 &&
|
|
330
|
+
value.timeoutMs <= WORKFLOW_MAX_TIMEOUT_MS))) {
|
|
331
|
+
fail(`${label}.timeoutMs must be null or an integer from 1 through ${WORKFLOW_MAX_TIMEOUT_MS}`);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* The child's ENVIRONMENT SCOPE half of a frozen exec spec. Split out so
|
|
336
|
+
* {@link validateExecSpec} keeps its shape (and the src-fn-size ratchet stays
|
|
337
|
+
* shrink-only).
|
|
338
|
+
*
|
|
339
|
+
* Both keys are canonical-form-only: `inheritEnv` may only be `true` and
|
|
340
|
+
* `passEnv` may only be a non-empty deduplicated name list. A persisted `false`
|
|
341
|
+
* or `[]` means exactly what absence means, and admitting a second spelling of
|
|
342
|
+
* the default would give the same unit two different input hashes.
|
|
343
|
+
*/
|
|
344
|
+
function validateExecEnvScope(value, label) {
|
|
345
|
+
if (value.inheritEnv !== undefined && value.inheritEnv !== true) {
|
|
346
|
+
fail(`${label}.inheritEnv must be true when present (the allowlist default is the key's ABSENCE)`);
|
|
347
|
+
}
|
|
348
|
+
if (value.passEnv === undefined)
|
|
349
|
+
return;
|
|
350
|
+
const passEnv = value.passEnv;
|
|
351
|
+
if (!Array.isArray(passEnv) ||
|
|
352
|
+
passEnv.length === 0 ||
|
|
353
|
+
passEnv.length > WORKFLOW_MAX_EXEC_PASS_ENV ||
|
|
354
|
+
!passEnv.every((name) => typeof name === "string" && WORKFLOW_ENV_VAR_NAME_PATTERN.test(name)) ||
|
|
355
|
+
new Set(passEnv).size !== passEnv.length) {
|
|
356
|
+
fail(`${label}.passEnv must be 1 through ${WORKFLOW_MAX_EXEC_PASS_ENV} distinct environment variable names ` +
|
|
357
|
+
`matching ${WORKFLOW_ENV_VAR_NAME_PATTERN.source}`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
283
360
|
function validateGate(gate, stepId, references, nodeIds, hooks) {
|
|
284
361
|
if (!isRecord(gate) ||
|
|
285
362
|
gate.kind !== "gate" ||
|
|
@@ -331,6 +408,14 @@ function validateRoute(route, stepId) {
|
|
|
331
408
|
}
|
|
332
409
|
function assertUnitEngineCompatibility(node, engines) {
|
|
333
410
|
const unit = node.kind === "map" ? node.template : node;
|
|
411
|
+
// An exec unit names no engine at all, so there is no engine pairing to
|
|
412
|
+
// check. It is the ONE kind that may carry `env` + `isolation: worktree`
|
|
413
|
+
// without an engine behind it: it spawns a real child process, which is
|
|
414
|
+
// exactly the precondition those two features require. `validateNode` has
|
|
415
|
+
// already proven it carries no `invocation`, so nothing here can be bypassed
|
|
416
|
+
// by declaring both.
|
|
417
|
+
if (unit.exec !== undefined)
|
|
418
|
+
return;
|
|
334
419
|
const engine = unit.invocation ? engines[unit.invocation.engine] : undefined;
|
|
335
420
|
if (!engine)
|
|
336
421
|
return;
|