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
package/dist/workflows/parser.js
CHANGED
|
@@ -33,10 +33,12 @@
|
|
|
33
33
|
import { LineCounter, parseDocument } from "yaml";
|
|
34
34
|
import { parseFrontmatterBlock } from "../core/asset/frontmatter.js";
|
|
35
35
|
import { parseMarkdownToc } from "../core/asset/markdown.js";
|
|
36
|
+
import { isContainedRelativePath } from "../core/common.js";
|
|
36
37
|
import { formatExtraParamsIssue, validateExtraParams } from "../core/extra-params.js";
|
|
38
|
+
import { checkJsonSchemaDefinition, JSON_SCHEMA_SUBSET_SUPPORTED_KEYWORDS } from "../core/json-schema.js";
|
|
37
39
|
import { parseReference } from "./program/expressions.js";
|
|
38
40
|
import { PROGRAM_ISOLATION_KINDS, PROGRAM_ON_ERROR, PROGRAM_PARAM_NAME_PATTERN, PROGRAM_REDUCERS, PROGRAM_RETRY_REASONS, PROGRAM_STEP_ID_PATTERN, } from "./program/schema.js";
|
|
39
|
-
import { jsonBytes, utf8Bytes, WORKFLOW_MAX_EXTRA_PARAMS_BYTES, WORKFLOW_MAX_INPUTS, WORKFLOW_MAX_MAP_EXPANSION, WORKFLOW_MAX_PARAMS, WORKFLOW_MAX_ROUTE_BRANCHES, WORKFLOW_MAX_SCHEMA_BYTES, WORKFLOW_MAX_SOURCE_BYTES, WORKFLOW_MAX_STEPS, } from "./resource-limits.js";
|
|
41
|
+
import { jsonBytes, utf8Bytes, WORKFLOW_ENGINE_NAME_PATTERN, WORKFLOW_ENV_VAR_NAME_PATTERN, WORKFLOW_MAX_CONCURRENCY, WORKFLOW_MAX_ENGINE_NAME_LENGTH, 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_MAP_EXPANSION, WORKFLOW_MAX_PARAMS, WORKFLOW_MAX_RETRIES, WORKFLOW_MAX_ROUTE_BRANCHES, WORKFLOW_MAX_SCHEMA_BYTES, WORKFLOW_MAX_SOURCE_BYTES, WORKFLOW_MAX_STEPS, WORKFLOW_MAX_TIMEOUT_MS, } from "./resource-limits.js";
|
|
40
42
|
import { WORKFLOW_SCHEMA_VERSION, } from "./schema.js";
|
|
41
43
|
import { runSemanticChecks } from "./validator.js";
|
|
42
44
|
/** Envelope keys every AKM markdown asset carries ($ref'd from schemas/akm-asset-envelope.json). */
|
|
@@ -59,7 +61,10 @@ const TOP_LEVEL_KEYS = [...ENVELOPE_KEYS, ...WORKFLOW_KEYS];
|
|
|
59
61
|
const DEFAULTS_KEYS = ["engine", "model", "timeout", "on_error", "llm"];
|
|
60
62
|
const BUDGET_KEYS = ["max_tokens", "max_units"];
|
|
61
63
|
const STEP_KEYS = ["id", "unit", "map", "route", "inputs", "output", "gate"];
|
|
62
|
-
const UNIT_KEYS = ["engine", "model", "llm", "timeout", "retry", "on_error", "output", "env", "isolation"];
|
|
64
|
+
const UNIT_KEYS = ["exec", "engine", "model", "llm", "timeout", "retry", "on_error", "output", "env", "isolation"];
|
|
65
|
+
const EXEC_KEYS = ["command", "cwd", "pass_env", "inherit_env"];
|
|
66
|
+
/** Unit keys that name an ENGINE dispatch and therefore cannot appear beside `exec:`. */
|
|
67
|
+
const UNIT_ENGINE_KEYS = ["engine", "model", "llm"];
|
|
63
68
|
const MAP_KEYS = ["over", "concurrency", "reducer", "unit"];
|
|
64
69
|
const ROUTE_KEYS = ["input", "when", "default"];
|
|
65
70
|
const RETRY_KEYS = ["max", "on"];
|
|
@@ -422,6 +427,10 @@ function parseParams(ctx, raw) {
|
|
|
422
427
|
ctx.err(["params", paramName], `Param "${paramName}" must be a JSON Schema object (e.g. { type: string }).`);
|
|
423
428
|
continue;
|
|
424
429
|
}
|
|
430
|
+
if (jsonBytes(value) > WORKFLOW_MAX_SCHEMA_BYTES) {
|
|
431
|
+
ctx.err(["params", paramName], `Param "${paramName}" schema exceeds the 256 KiB resource limit.`);
|
|
432
|
+
}
|
|
433
|
+
checkSchemaDefinition(ctx, value, ["params", paramName], `Param "${paramName}" schema`);
|
|
425
434
|
params[paramName] = value;
|
|
426
435
|
}
|
|
427
436
|
return Object.keys(params).length > 0 ? params : undefined;
|
|
@@ -437,10 +446,9 @@ function parseDefaults(ctx, raw) {
|
|
|
437
446
|
checkUnknownKeys(ctx, raw, path, DEFAULTS_KEYS, `"defaults"`);
|
|
438
447
|
const defaults = {};
|
|
439
448
|
if (raw.engine !== undefined) {
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
ctx.err([...path, "engine"], `"defaults.engine" must be a non-empty engine name.`);
|
|
449
|
+
const engine = parseEngineName(ctx, raw.engine, [...path, "engine"], `"defaults.engine"`);
|
|
450
|
+
if (engine !== undefined)
|
|
451
|
+
defaults.engine = engine;
|
|
444
452
|
}
|
|
445
453
|
if (raw.model !== undefined) {
|
|
446
454
|
if (typeof raw.model === "string" && raw.model.trim() !== "")
|
|
@@ -605,11 +613,24 @@ function parseUnit(ctx, raw, path, stepLabel) {
|
|
|
605
613
|
}
|
|
606
614
|
checkUnknownKeys(ctx, raw, path, UNIT_KEYS, `${stepLabel} "unit"`);
|
|
607
615
|
const unit = { source: ctx.refAt(path) };
|
|
616
|
+
if (raw.exec !== undefined) {
|
|
617
|
+
const exec = parseExec(ctx, raw.exec, [...path, "exec"], stepLabel);
|
|
618
|
+
if (exec !== undefined)
|
|
619
|
+
unit.exec = exec;
|
|
620
|
+
// An exec unit dispatches no engine call, so every engine-selection key is
|
|
621
|
+
// a contradiction rather than a harmless extra. Reported per key so the
|
|
622
|
+
// author sees exactly which line to delete.
|
|
623
|
+
for (const key of UNIT_ENGINE_KEYS) {
|
|
624
|
+
if (raw[key] === undefined)
|
|
625
|
+
continue;
|
|
626
|
+
ctx.err([...path, key], `${stepLabel} "unit" declares both "exec" and "${key}". An exec unit runs a shell command and never ` +
|
|
627
|
+
`reaches an engine, so "${key}" would have no effect — remove one of the two.`);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
608
630
|
if (raw.engine !== undefined) {
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
ctx.err([...path, "engine"], `${stepLabel} "engine" must be a non-empty engine name.`);
|
|
631
|
+
const engine = parseEngineName(ctx, raw.engine, [...path, "engine"], `${stepLabel} "engine"`);
|
|
632
|
+
if (engine !== undefined)
|
|
633
|
+
unit.engine = engine;
|
|
613
634
|
}
|
|
614
635
|
if (raw.model !== undefined) {
|
|
615
636
|
if (typeof raw.model === "string" && raw.model.trim() !== "")
|
|
@@ -634,7 +655,16 @@ function parseUnit(ctx, raw, path, stepLabel) {
|
|
|
634
655
|
unit.output = output;
|
|
635
656
|
if (raw.env !== undefined) {
|
|
636
657
|
if (Array.isArray(raw.env) && raw.env.every((entry) => typeof entry === "string" && entry.trim() !== "")) {
|
|
637
|
-
|
|
658
|
+
const envRefs = raw.env.map((entry) => entry.trim());
|
|
659
|
+
// Same decoder uniqueness requirement as `inputs` above: duplicates linted
|
|
660
|
+
// clean and then failed the run with an unlocated frozen-plan error.
|
|
661
|
+
const duplicate = envRefs.find((ref, i) => envRefs.indexOf(ref) !== i);
|
|
662
|
+
if (duplicate !== undefined) {
|
|
663
|
+
ctx.err([...path, "env"], `${stepLabel} "env" contains a duplicate entry: "${duplicate}".`);
|
|
664
|
+
}
|
|
665
|
+
else {
|
|
666
|
+
unit.env = envRefs;
|
|
667
|
+
}
|
|
638
668
|
}
|
|
639
669
|
else {
|
|
640
670
|
ctx.err([...path, "env"], `${stepLabel} "env" must be a list of non-empty env asset refs.`);
|
|
@@ -645,6 +675,124 @@ function parseUnit(ctx, raw, path, stepLabel) {
|
|
|
645
675
|
unit.isolation = isolation;
|
|
646
676
|
return unit;
|
|
647
677
|
}
|
|
678
|
+
/**
|
|
679
|
+
* Parse `unit.exec` — the argv-array shell-command surface.
|
|
680
|
+
*
|
|
681
|
+
* Deliberately NO shell-string spelling: the child is spawned directly from
|
|
682
|
+
* this array, so shell metacharacters are inert literal bytes and the whole
|
|
683
|
+
* quoting/injection class is structurally absent. An author who wants a
|
|
684
|
+
* pipeline writes the interpreter explicitly (`["bash", "-lc", "…"]`), which
|
|
685
|
+
* keeps that decision visible in the frontmatter diff.
|
|
686
|
+
*/
|
|
687
|
+
function parseExec(ctx, raw, path, stepLabel) {
|
|
688
|
+
if (!isPlainRecord(raw)) {
|
|
689
|
+
ctx.err(path, `${stepLabel} "exec" must be a mapping with a "command" argv list.`);
|
|
690
|
+
return undefined;
|
|
691
|
+
}
|
|
692
|
+
checkUnknownKeys(ctx, raw, path, EXEC_KEYS, `${stepLabel} "exec"`);
|
|
693
|
+
const command = parseExecCommand(ctx, raw.command, [...path, "command"], stepLabel);
|
|
694
|
+
if (command === undefined)
|
|
695
|
+
return undefined;
|
|
696
|
+
const exec = { command };
|
|
697
|
+
const cwd = parseExecCwd(ctx, raw.cwd, [...path, "cwd"], stepLabel);
|
|
698
|
+
if (cwd !== undefined)
|
|
699
|
+
exec.cwd = cwd;
|
|
700
|
+
const passEnv = parseExecPassEnv(ctx, raw.pass_env, [...path, "pass_env"], stepLabel);
|
|
701
|
+
if (passEnv !== undefined)
|
|
702
|
+
exec.passEnv = passEnv;
|
|
703
|
+
if (raw.inherit_env !== undefined) {
|
|
704
|
+
if (typeof raw.inherit_env !== "boolean") {
|
|
705
|
+
ctx.err([...path, "inherit_env"], `${stepLabel} "exec.inherit_env" must be true or false. true gives the command akm's whole environment ` +
|
|
706
|
+
`instead of the default allowlist; omit it (or write false) to keep the allowlist.`);
|
|
707
|
+
}
|
|
708
|
+
else if (raw.inherit_env) {
|
|
709
|
+
exec.inheritEnv = true;
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
return exec;
|
|
713
|
+
}
|
|
714
|
+
/**
|
|
715
|
+
* `pass_env:` — extra parent-process env var NAMES the child may see on top of
|
|
716
|
+
* the default allowlist. NAMES ONLY: a value would be a plaintext secret in the
|
|
717
|
+
* frozen plan, which is exactly what `env:` bindings exist to avoid.
|
|
718
|
+
*/
|
|
719
|
+
function parseExecPassEnv(ctx, raw, path, stepLabel) {
|
|
720
|
+
if (raw === undefined)
|
|
721
|
+
return undefined;
|
|
722
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
723
|
+
ctx.err(path, `${stepLabel} "exec.pass_env" must be a non-empty list of environment variable NAMES to copy through from ` +
|
|
724
|
+
`akm's own environment, e.g. pass_env: [CARGO_HOME]. Values never appear here — use "env:" bindings for those.`);
|
|
725
|
+
return undefined;
|
|
726
|
+
}
|
|
727
|
+
if (raw.length > WORKFLOW_MAX_EXEC_PASS_ENV) {
|
|
728
|
+
ctx.err(path, `${stepLabel} "exec.pass_env" must have at most ${WORKFLOW_MAX_EXEC_PASS_ENV} entries. A command needing ` +
|
|
729
|
+
`more than that wants "inherit_env: true", which says so explicitly.`);
|
|
730
|
+
return undefined;
|
|
731
|
+
}
|
|
732
|
+
const names = [];
|
|
733
|
+
for (const [index, entry] of raw.entries()) {
|
|
734
|
+
if (typeof entry !== "string" || !WORKFLOW_ENV_VAR_NAME_PATTERN.test(entry)) {
|
|
735
|
+
ctx.err(path, `${stepLabel} "exec.pass_env[${index}]" must be an environment variable name matching ` +
|
|
736
|
+
`${WORKFLOW_ENV_VAR_NAME_PATTERN.source}.`);
|
|
737
|
+
return undefined;
|
|
738
|
+
}
|
|
739
|
+
if (names.includes(entry)) {
|
|
740
|
+
ctx.err(path, `${stepLabel} "exec.pass_env" lists "${entry}" more than once.`);
|
|
741
|
+
return undefined;
|
|
742
|
+
}
|
|
743
|
+
names.push(entry);
|
|
744
|
+
}
|
|
745
|
+
return names;
|
|
746
|
+
}
|
|
747
|
+
/** The argv array itself: 1..WORKFLOW_MAX_EXEC_ARGV bounded non-empty strings. */
|
|
748
|
+
function parseExecCommand(ctx, raw, path, stepLabel) {
|
|
749
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
750
|
+
ctx.err(path, `${stepLabel} "exec" requires "command": a non-empty argv list, e.g. command: ["bun", "run", "test:unit"]. ` +
|
|
751
|
+
`A single shell string is not accepted — the command is spawned directly, never through a shell.`);
|
|
752
|
+
return undefined;
|
|
753
|
+
}
|
|
754
|
+
if (raw.length > WORKFLOW_MAX_EXEC_ARGV) {
|
|
755
|
+
ctx.err(path, `${stepLabel} "exec.command" must have at most ${WORKFLOW_MAX_EXEC_ARGV} entries.`);
|
|
756
|
+
return undefined;
|
|
757
|
+
}
|
|
758
|
+
const argv = [];
|
|
759
|
+
for (const [index, entry] of raw.entries()) {
|
|
760
|
+
if (typeof entry !== "string" || entry === "") {
|
|
761
|
+
ctx.err(path, `${stepLabel} "exec.command[${index}]" must be a non-empty string.`);
|
|
762
|
+
return undefined;
|
|
763
|
+
}
|
|
764
|
+
if (utf8Bytes(entry) > WORKFLOW_MAX_EXEC_ARG_BYTES) {
|
|
765
|
+
ctx.err(path, `${stepLabel} "exec.command[${index}]" exceeds ${WORKFLOW_MAX_EXEC_ARG_BYTES} bytes.`);
|
|
766
|
+
return undefined;
|
|
767
|
+
}
|
|
768
|
+
argv.push(entry);
|
|
769
|
+
}
|
|
770
|
+
return argv;
|
|
771
|
+
}
|
|
772
|
+
/**
|
|
773
|
+
* The optional relative `cwd:`. Rejected here (statically) for absolute paths
|
|
774
|
+
* and `..` segments; containment against the resolved base directory is
|
|
775
|
+
* re-checked at dispatch, so a symlinked subdirectory cannot escape either.
|
|
776
|
+
*/
|
|
777
|
+
function parseExecCwd(ctx, raw, path, stepLabel) {
|
|
778
|
+
if (raw === undefined)
|
|
779
|
+
return undefined;
|
|
780
|
+
if (typeof raw !== "string" || raw.trim() === "") {
|
|
781
|
+
ctx.err(path, `${stepLabel} "exec.cwd" must be a non-empty relative path inside the unit's working directory.`);
|
|
782
|
+
return undefined;
|
|
783
|
+
}
|
|
784
|
+
const value = raw.trim();
|
|
785
|
+
if (value.length > WORKFLOW_MAX_EXEC_CWD_LENGTH) {
|
|
786
|
+
ctx.err(path, `${stepLabel} "exec.cwd" exceeds ${WORKFLOW_MAX_EXEC_CWD_LENGTH} characters.`);
|
|
787
|
+
return undefined;
|
|
788
|
+
}
|
|
789
|
+
if (!isContainedRelativePath(value)) {
|
|
790
|
+
ctx.err(path, `${stepLabel} "exec.cwd" (${JSON.stringify(value)}) must be a RELATIVE path inside the unit's working ` +
|
|
791
|
+
`directory — absolute paths, Windows drive letters, "~", and ".." segments are rejected.`);
|
|
792
|
+
return undefined;
|
|
793
|
+
}
|
|
794
|
+
return value;
|
|
795
|
+
}
|
|
648
796
|
function parseMap(ctx, raw, path, stepLabel) {
|
|
649
797
|
if (!isPlainRecord(raw)) {
|
|
650
798
|
ctx.err(path, `${stepLabel} "map" must be a mapping with an "over" key.`);
|
|
@@ -661,11 +809,14 @@ function parseMap(ctx, raw, path, stepLabel) {
|
|
|
661
809
|
}
|
|
662
810
|
let concurrency;
|
|
663
811
|
if (raw.concurrency !== undefined) {
|
|
664
|
-
if (typeof raw.concurrency === "number" &&
|
|
812
|
+
if (typeof raw.concurrency === "number" &&
|
|
813
|
+
Number.isInteger(raw.concurrency) &&
|
|
814
|
+
raw.concurrency > 0 &&
|
|
815
|
+
raw.concurrency <= WORKFLOW_MAX_CONCURRENCY) {
|
|
665
816
|
concurrency = raw.concurrency;
|
|
666
817
|
}
|
|
667
818
|
else {
|
|
668
|
-
ctx.err([...path, "concurrency"], `${stepLabel} "concurrency" must be
|
|
819
|
+
ctx.err([...path, "concurrency"], `${stepLabel} "concurrency" must be an integer from 1 through ${WORKFLOW_MAX_CONCURRENCY}.`);
|
|
669
820
|
}
|
|
670
821
|
}
|
|
671
822
|
const reducer = parseEnumField(ctx, raw.reducer, [...path, "reducer"], `${stepLabel} "reducer"`, PROGRAM_REDUCERS);
|
|
@@ -717,6 +868,13 @@ function parseRoute(ctx, raw, path, stepLabel, stepIndex, routeChecks) {
|
|
|
717
868
|
return;
|
|
718
869
|
}
|
|
719
870
|
const match = String(branch.match);
|
|
871
|
+
// The frozen-plan decoder requires every `when` key to be non-empty, so an
|
|
872
|
+
// empty match parsed and linted clean and then failed the run with an
|
|
873
|
+
// unlocated "Invalid frozen workflow plan". Reject it here, at the line.
|
|
874
|
+
if (match === "") {
|
|
875
|
+
ctx.errAtLine(matchLine, `${stepLabel} "when[${i}].match" must not be empty.`);
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
720
878
|
if (typeof branch.step !== "string" || branch.step.trim() === "") {
|
|
721
879
|
ctx.err([...branchPath, "step"], `${stepLabel} "when[${i}].step" must be a step id string.`);
|
|
722
880
|
return;
|
|
@@ -759,12 +917,21 @@ function parseInputs(ctx, raw, path, stepLabel) {
|
|
|
759
917
|
ctx.err(path, `${stepLabel} "inputs" must contain at most ${WORKFLOW_MAX_INPUTS} entries.`);
|
|
760
918
|
}
|
|
761
919
|
const out = [];
|
|
920
|
+
// The frozen-plan decoder requires uniqueness (validateStringArray(..., true)),
|
|
921
|
+
// so duplicates parsed and linted clean and then failed the run with an
|
|
922
|
+
// unlocated "Invalid frozen workflow plan". Reject them here, on the entry.
|
|
923
|
+
const seen = new Set();
|
|
762
924
|
raw.forEach((entry, i) => {
|
|
763
925
|
if (typeof entry !== "string" || entry.trim() === "") {
|
|
764
926
|
ctx.err([...path, i], `${stepLabel} "inputs[${i}]" must be a non-empty reference string.`);
|
|
765
927
|
return;
|
|
766
928
|
}
|
|
767
929
|
const value = entry.trim();
|
|
930
|
+
if (seen.has(value)) {
|
|
931
|
+
ctx.err([...path, i], `${stepLabel} "inputs[${i}]" duplicates an earlier entry: "${value}".`);
|
|
932
|
+
return;
|
|
933
|
+
}
|
|
934
|
+
seen.add(value);
|
|
768
935
|
checkReferenceSyntax(ctx, value, [...path, i], `${stepLabel} "inputs[${i}]"`);
|
|
769
936
|
out.push(value);
|
|
770
937
|
});
|
|
@@ -778,11 +945,14 @@ function parseGate(ctx, raw, path, stepLabel) {
|
|
|
778
945
|
checkUnknownKeys(ctx, raw, path, GATE_KEYS, `${stepLabel} "gate"`);
|
|
779
946
|
const gate = {};
|
|
780
947
|
if (raw.max_loops !== undefined) {
|
|
781
|
-
if (typeof raw.max_loops === "number" &&
|
|
948
|
+
if (typeof raw.max_loops === "number" &&
|
|
949
|
+
Number.isInteger(raw.max_loops) &&
|
|
950
|
+
raw.max_loops >= 1 &&
|
|
951
|
+
raw.max_loops <= WORKFLOW_MAX_GATE_LOOPS) {
|
|
782
952
|
gate.maxLoops = raw.max_loops;
|
|
783
953
|
}
|
|
784
954
|
else {
|
|
785
|
-
ctx.err([...path, "max_loops"], `${stepLabel} "gate.max_loops" must be an integer
|
|
955
|
+
ctx.err([...path, "max_loops"], `${stepLabel} "gate.max_loops" must be an integer from 1 through ${WORKFLOW_MAX_GATE_LOOPS}.`);
|
|
786
956
|
}
|
|
787
957
|
}
|
|
788
958
|
return gate;
|
|
@@ -790,6 +960,26 @@ function parseGate(ctx, raw, path, stepLabel) {
|
|
|
790
960
|
// ---------------------------------------------------------------------------
|
|
791
961
|
// Field helpers
|
|
792
962
|
// ---------------------------------------------------------------------------
|
|
963
|
+
/**
|
|
964
|
+
* Engine names must already satisfy the frozen-plan grammar
|
|
965
|
+
* (`WORKFLOW_ENGINE_NAME_PATTERN`, max 63 chars) at parse time — the decoder
|
|
966
|
+
* enforces the same bound on persisted plans, and a name that only fails there
|
|
967
|
+
* surfaces as an unlocated "Invalid frozen workflow plan" at `workflow run`.
|
|
968
|
+
*/
|
|
969
|
+
function parseEngineName(ctx, raw, path, label) {
|
|
970
|
+
if (typeof raw !== "string" || raw.trim() === "") {
|
|
971
|
+
ctx.err(path, `${label} must be a non-empty engine name.`);
|
|
972
|
+
return undefined;
|
|
973
|
+
}
|
|
974
|
+
const name = raw.trim();
|
|
975
|
+
if (!WORKFLOW_ENGINE_NAME_PATTERN.test(name) || name.length > WORKFLOW_MAX_ENGINE_NAME_LENGTH) {
|
|
976
|
+
ctx.err(path, `${label} has an invalid engine name ${JSON.stringify(name)}. Engine names are lowercase words of letters ` +
|
|
977
|
+
`and digits separated by single dashes, starting with a letter (e.g. "code-review-llm"), at most ` +
|
|
978
|
+
`${WORKFLOW_MAX_ENGINE_NAME_LENGTH} characters.`);
|
|
979
|
+
return undefined;
|
|
980
|
+
}
|
|
981
|
+
return name;
|
|
982
|
+
}
|
|
793
983
|
function parseRetry(ctx, raw, path, stepLabel) {
|
|
794
984
|
if (raw === undefined)
|
|
795
985
|
return undefined;
|
|
@@ -799,8 +989,8 @@ function parseRetry(ctx, raw, path, stepLabel) {
|
|
|
799
989
|
}
|
|
800
990
|
checkUnknownKeys(ctx, raw, path, RETRY_KEYS, `${stepLabel} "retry"`);
|
|
801
991
|
let ok = true;
|
|
802
|
-
if (!(typeof raw.max === "number" && Number.isInteger(raw.max) && raw.max >= 0)) {
|
|
803
|
-
ctx.err([...path, "max"], `${stepLabel} "retry.max" is required and must be
|
|
992
|
+
if (!(typeof raw.max === "number" && Number.isInteger(raw.max) && raw.max >= 0 && raw.max <= WORKFLOW_MAX_RETRIES)) {
|
|
993
|
+
ctx.err([...path, "max"], `${stepLabel} "retry.max" is required and must be an integer from 0 through ${WORKFLOW_MAX_RETRIES}.`);
|
|
804
994
|
ok = false;
|
|
805
995
|
}
|
|
806
996
|
const on = [];
|
|
@@ -826,7 +1016,7 @@ function parseTimeoutField(ctx, raw, path, label) {
|
|
|
826
1016
|
return undefined;
|
|
827
1017
|
if (typeof raw === "number") {
|
|
828
1018
|
if (Number.isInteger(raw) && raw > 0)
|
|
829
|
-
return raw;
|
|
1019
|
+
return checkTimeoutCeiling(ctx, raw, path, label, String(raw));
|
|
830
1020
|
ctx.err(path, `${label} has a non-positive timeout ${JSON.stringify(raw)}. ${TIMEOUT_HINT}.`);
|
|
831
1021
|
return undefined;
|
|
832
1022
|
}
|
|
@@ -849,7 +1039,20 @@ function parseTimeoutField(ctx, raw, path, label) {
|
|
|
849
1039
|
ctx.err(path, `${label} has a non-positive timeout "${raw}". Use a positive duration or "none".`);
|
|
850
1040
|
return undefined;
|
|
851
1041
|
}
|
|
852
|
-
return timeoutMs;
|
|
1042
|
+
return checkTimeoutCeiling(ctx, timeoutMs, path, label, raw);
|
|
1043
|
+
}
|
|
1044
|
+
/**
|
|
1045
|
+
* Timeouts freeze into `IrInvocation.timeoutMs`, whose decoder bound is
|
|
1046
|
+
* `WORKFLOW_MAX_TIMEOUT_MS` (setTimeout's 32-bit signed ceiling). Enforce the
|
|
1047
|
+
* same ceiling here so an oversized duration fails with a line anchor instead
|
|
1048
|
+
* of an unlocated decode error at `workflow run`.
|
|
1049
|
+
*/
|
|
1050
|
+
function checkTimeoutCeiling(ctx, timeoutMs, path, label, raw) {
|
|
1051
|
+
if (timeoutMs <= WORKFLOW_MAX_TIMEOUT_MS)
|
|
1052
|
+
return timeoutMs;
|
|
1053
|
+
ctx.err(path, `${label} has a timeout "${raw}" above the maximum of ${WORKFLOW_MAX_TIMEOUT_MS} ms (about 24.8 days). ` +
|
|
1054
|
+
`Use a shorter duration or "none" for no timeout.`);
|
|
1055
|
+
return undefined;
|
|
853
1056
|
}
|
|
854
1057
|
function parseEnumField(ctx, raw, path, label, allowed) {
|
|
855
1058
|
if (raw === undefined)
|
|
@@ -938,8 +1141,28 @@ function parseSchemaObject(ctx, raw, path, label) {
|
|
|
938
1141
|
if (jsonBytes(raw) > WORKFLOW_MAX_SCHEMA_BYTES) {
|
|
939
1142
|
ctx.err(path, `${label} exceeds the 256 KiB resource limit.`);
|
|
940
1143
|
}
|
|
1144
|
+
checkSchemaDefinition(ctx, raw, path, label);
|
|
941
1145
|
return raw;
|
|
942
1146
|
}
|
|
1147
|
+
/**
|
|
1148
|
+
* Validate an author-declared schema AS a schema (`output:` and `params`
|
|
1149
|
+
* declarations). The runtime enforces only a JSON Schema subset
|
|
1150
|
+
* (`core/json-schema.ts`); a typo'd `type` or a keyword the subset ignores
|
|
1151
|
+
* would silently constrain nothing at run time — a gate depending on a no-op
|
|
1152
|
+
* schema is worse than a loud failure here, so both are parse ERRORS.
|
|
1153
|
+
*/
|
|
1154
|
+
function checkSchemaDefinition(ctx, schema, path, label) {
|
|
1155
|
+
for (const issue of checkJsonSchemaDefinition(schema)) {
|
|
1156
|
+
const issuePath = [...path, ...issue.path];
|
|
1157
|
+
if (issue.kind === "unsupported") {
|
|
1158
|
+
ctx.err(issuePath, `${label} (at ${issue.pointer}): ${issue.message}. Supported JSON Schema keywords: ` +
|
|
1159
|
+
`${JSON_SCHEMA_SUBSET_SUPPORTED_KEYWORDS}.`);
|
|
1160
|
+
}
|
|
1161
|
+
else {
|
|
1162
|
+
ctx.err(issuePath, `${label} is not a valid JSON Schema (at ${issue.pointer}): ${issue.message}.`);
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
943
1166
|
function checkReferenceSyntax(ctx, text, path, label) {
|
|
944
1167
|
const result = parseReference(text);
|
|
945
1168
|
if (!result.ok)
|
|
@@ -49,3 +49,27 @@ export const PROGRAM_STEP_ID_PATTERN = /^[A-Za-z_][A-Za-z0-9_-]*$/;
|
|
|
49
49
|
* identifiers (no dots/dashes).
|
|
50
50
|
*/
|
|
51
51
|
export const PROGRAM_PARAM_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
52
|
+
/**
|
|
53
|
+
* The ONE structural projection of an exec spec — ONE encoding per state.
|
|
54
|
+
*
|
|
55
|
+
* Every layer that carries an exec forward derives from this: the compiled
|
|
56
|
+
* draft, the frozen plan (which layers `timeoutMs` on top), and the summary
|
|
57
|
+
* `akm workflow show` prints. Written out per layer instead, a field added to
|
|
58
|
+
* {@link ProgramExec} reaches whichever copies were remembered and silently
|
|
59
|
+
* vanishes from the rest — and nothing catches it, because every key here is
|
|
60
|
+
* optional, so an omitted spread is not a type error. What it would cost:
|
|
61
|
+
* either the frozen plan loses a field the author wrote (and the canonical
|
|
62
|
+
* hash preimage stops matching the authored intent), or `show` describes
|
|
63
|
+
* something other than what runs.
|
|
64
|
+
*
|
|
65
|
+
* The ABSENCE of both env keys is the default allowlist. The parser rejects an
|
|
66
|
+
* empty `pass_env` outright, so no layer has to distinguish absent from empty.
|
|
67
|
+
*/
|
|
68
|
+
export function projectExecCore(exec) {
|
|
69
|
+
return {
|
|
70
|
+
command: [...exec.command],
|
|
71
|
+
...(exec.cwd ? { cwd: exec.cwd } : {}),
|
|
72
|
+
...(exec.passEnv && exec.passEnv.length > 0 ? { passEnv: [...exec.passEnv] } : {}),
|
|
73
|
+
...(exec.inheritEnv ? { inheritEnv: true } : {}),
|
|
74
|
+
};
|
|
75
|
+
}
|
|
@@ -6,12 +6,13 @@
|
|
|
6
6
|
* unification). One format now: reads the frontmatter+body document via
|
|
7
7
|
* `parseWorkflow` and projects the validated `WorkflowDocument` down to the
|
|
8
8
|
* public `ShowResponse` shape and into search hints for the indexer,
|
|
9
|
-
* including a compact per-step orchestration summary (engine/model
|
|
10
|
-
* `map.over` reference, route table) when the step declares one.
|
|
9
|
+
* including a compact per-step orchestration summary (engine/model or an exec
|
|
10
|
+
* unit's argv, `map.over` reference, route table) when the step declares one.
|
|
11
11
|
*/
|
|
12
12
|
import { displayRef } from "../core/asset/resolve-ref.js";
|
|
13
13
|
import { UsageError } from "../core/errors.js";
|
|
14
14
|
import { parseWorkflow } from "./parser.js";
|
|
15
|
+
import { projectExecCore } from "./program/schema.js";
|
|
15
16
|
function shellQuote(value) {
|
|
16
17
|
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
17
18
|
}
|
|
@@ -65,16 +66,43 @@ function projectParameters(document) {
|
|
|
65
66
|
* compiler does (per-unit override wins), `fanOut.over` carries the raw
|
|
66
67
|
* reference string, and `route` carries the explicit input + branch table.
|
|
67
68
|
* Returns undefined when the step declares nothing worth summarizing.
|
|
69
|
+
*
|
|
70
|
+
* ## exec units
|
|
71
|
+
*
|
|
72
|
+
* An `exec` unit runs a shell command and names NO engine — the parser rejects
|
|
73
|
+
* `engine`/`model`/`llm` alongside `exec:`. Merging `defaults.engine` into its
|
|
74
|
+
* summary would make `show` state something untrue about what will run, so the
|
|
75
|
+
* two fields are suppressed and the argv is projected instead, under `exec`
|
|
76
|
+
* (field presence carries the dispatch kind, exactly like `fanOut`/`route`
|
|
77
|
+
* carry the step kind).
|
|
78
|
+
*
|
|
79
|
+
* `timeoutMs` still merges the defaults: an exec unit really does inherit
|
|
80
|
+
* `defaults.timeout`, so that number stays true for it.
|
|
81
|
+
*
|
|
82
|
+
* The argv is shown IN FULL, never clipped. It is authored literally in the
|
|
83
|
+
* asset — the `${{ … }}` interpolation language is gone, so nothing in it is
|
|
84
|
+
* resolved from the environment, from a secret ref, or from a prior step's
|
|
85
|
+
* output — which makes it (a) safe to display, since every byte is already
|
|
86
|
+
* visible in the workflow file `show` is rendering, and (b) pointless to clip:
|
|
87
|
+
* the whole finding this projection answers is `show` describing something
|
|
88
|
+
* other than what runs, and a truncated argv is that same bug in miniature.
|
|
89
|
+
* The parser bounds it anyway (`WORKFLOW_MAX_EXEC_ARGV` entries of
|
|
90
|
+
* `WORKFLOW_MAX_EXEC_ARG_BYTES`), and it is far smaller than the step
|
|
91
|
+
* `instructions` this same projection already carries whole.
|
|
68
92
|
*/
|
|
69
93
|
function summarizeStepOrchestration(step, defaults) {
|
|
70
94
|
const unit = step.unit ?? step.map?.unit;
|
|
71
|
-
const
|
|
72
|
-
const
|
|
95
|
+
const exec = unit?.exec;
|
|
96
|
+
const engine = exec ? undefined : (unit?.engine ?? defaults?.engine);
|
|
97
|
+
const model = exec ? undefined : (unit?.model ?? defaults?.model);
|
|
73
98
|
const timeoutMs = unit?.timeoutMs !== undefined ? unit.timeoutMs : defaults?.timeoutMs;
|
|
74
99
|
const summary = {
|
|
75
100
|
...(engine !== undefined ? { engine } : {}),
|
|
76
101
|
...(model !== undefined ? { model } : {}),
|
|
77
102
|
...(timeoutMs !== undefined ? { timeoutMs } : {}),
|
|
103
|
+
// Same projection the draft and the frozen plan use, so what `show` prints
|
|
104
|
+
// cannot drift from what runs.
|
|
105
|
+
...(exec ? { exec: projectExecCore(exec) } : {}),
|
|
78
106
|
...(step.map
|
|
79
107
|
? {
|
|
80
108
|
fanOut: {
|
|
@@ -14,6 +14,188 @@ export const WORKFLOW_MAX_JSON_DEPTH = 64;
|
|
|
14
14
|
export const WORKFLOW_MAX_MAP_EXPANSION = 10_000;
|
|
15
15
|
/** Max declared `inputs:` reference strings on one unit/map step. */
|
|
16
16
|
export const WORKFLOW_MAX_INPUTS = 64;
|
|
17
|
+
// ── Dispatch-significant bounds shared across validation layers ──────────────
|
|
18
|
+
//
|
|
19
|
+
// Defined ONCE here so the three enforcement layers cannot drift:
|
|
20
|
+
// 1. the parser (`../parser.ts`) — line-anchored authoring-time errors,
|
|
21
|
+
// 2. the published JSON Schema (`schemas/akm-workflow.json`) — mirrored
|
|
22
|
+
// `maximum`/`pattern`/`maxLength` values, pinned against these constants
|
|
23
|
+
// by `tests/integration/workflows/schema-drift.test.ts`,
|
|
24
|
+
// 3. the strict frozen-plan decoder (`./ir/schema.ts`) — the corruption
|
|
25
|
+
// gate for persisted plans.
|
|
26
|
+
// A bound enforced only by the decoder surfaces as a terse, unlocated
|
|
27
|
+
// "Invalid frozen workflow plan" at `workflow run` — after lint and
|
|
28
|
+
// `workflow create` already said the document was fine.
|
|
29
|
+
/** Max per-step map fan-out concurrency (also the run-level concurrency ceiling). */
|
|
30
|
+
export const WORKFLOW_MAX_CONCURRENCY = 64;
|
|
31
|
+
/** Max evaluator-optimizer gate loops per step. */
|
|
32
|
+
export const WORKFLOW_MAX_GATE_LOOPS = 100;
|
|
33
|
+
/** Max retry attempts per unit. */
|
|
34
|
+
export const WORKFLOW_MAX_RETRIES = 100;
|
|
35
|
+
/** Max timeout in milliseconds (setTimeout's 32-bit signed ceiling: 2^31-1, ~24.8 days). */
|
|
36
|
+
export const WORKFLOW_MAX_TIMEOUT_MS = 2 ** 31 - 1;
|
|
37
|
+
/** Engine names: lowercase dash-separated runs of letters/digits, starting with a letter. */
|
|
38
|
+
export const WORKFLOW_ENGINE_NAME_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
|
|
39
|
+
export const WORKFLOW_MAX_ENGINE_NAME_LENGTH = 63;
|
|
40
|
+
// ── exec (shell) unit bounds ─────────────────────────────────────────────────
|
|
41
|
+
/**
|
|
42
|
+
* Max entries in an exec unit's `command:` argv array. Generous for a real
|
|
43
|
+
* command line, small enough that a corrupted plan cannot ask the OS to spawn
|
|
44
|
+
* a megabyte of arguments.
|
|
45
|
+
*/
|
|
46
|
+
export const WORKFLOW_MAX_EXEC_ARGV = 64;
|
|
47
|
+
/** Max UTF-8 bytes of ONE argv entry (well under every platform's ARG_MAX per-arg limit). */
|
|
48
|
+
export const WORKFLOW_MAX_EXEC_ARG_BYTES = 4096;
|
|
49
|
+
/** Max characters of an exec unit's relative `cwd:`. */
|
|
50
|
+
export const WORKFLOW_MAX_EXEC_CWD_LENGTH = 1024;
|
|
51
|
+
/**
|
|
52
|
+
* Max entries in an exec unit's `pass_env:` list.
|
|
53
|
+
*
|
|
54
|
+
* `pass_env` is the "one or two more toolchain variables" escape hatch, not a
|
|
55
|
+
* second way to spell `inherit_env:` — a workflow reaching for more than this
|
|
56
|
+
* many names wants full inheritance and should say so where a reviewer can see
|
|
57
|
+
* it.
|
|
58
|
+
*/
|
|
59
|
+
export const WORKFLOW_MAX_EXEC_PASS_ENV = 32;
|
|
60
|
+
/**
|
|
61
|
+
* Grammar for an env var NAME in `pass_env:`. Matches the frozen-plan
|
|
62
|
+
* `envPassthrough` grammar in `ir/schema.ts` so both allowlist surfaces accept
|
|
63
|
+
* exactly the same identifiers.
|
|
64
|
+
*/
|
|
65
|
+
export const WORKFLOW_ENV_VAR_NAME_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
|
66
|
+
/**
|
|
67
|
+
* Default wall-clock timeout for an exec unit that declares no `timeout:` and
|
|
68
|
+
* inherits no document `defaults.timeout`.
|
|
69
|
+
*
|
|
70
|
+
* 10 minutes matches `DEFAULT_LLM_TIMEOUT_MS`. Unlike an agent harness (which
|
|
71
|
+
* owns its own lifetime, hence `DEFAULT_AGENT_TIMEOUT_MS === null`), a shell
|
|
72
|
+
* command has NO lifetime discipline of its own: an unbounded default would let
|
|
73
|
+
* a hung `npm install` or an interactive prompt wedge a workflow run forever.
|
|
74
|
+
* Authors who genuinely need an unbounded command write `timeout: none`.
|
|
75
|
+
*/
|
|
76
|
+
export const DEFAULT_EXEC_TIMEOUT_MS = 600_000;
|
|
77
|
+
/**
|
|
78
|
+
* Max BYTES of ONE captured pipe an exec unit RETAINS in memory (stdout and
|
|
79
|
+
* stderr are retained separately).
|
|
80
|
+
*
|
|
81
|
+
* This is a RETENTION cap, not a permission to run. Without it the capture is
|
|
82
|
+
* bounded only by the command's exit or the wall timeout, so a command that
|
|
83
|
+
* writes continuously (`yes`, a verbose test loop) grows a string in the akm
|
|
84
|
+
* process until the host runs out of memory — and the default budget gives it
|
|
85
|
+
* ten minutes to do so. What the cap buys is a BOUND where there was none: the
|
|
86
|
+
* retained prefix is promoted into the unit's outcome text and every outcome is
|
|
87
|
+
* held until the step reduces, so the worst case is (units in the STEP × this
|
|
88
|
+
* cap) rather than unbounded — the step's width, not the in-flight width, is
|
|
89
|
+
* what sizes it, up to {@link WORKFLOW_MAX_MAP_EXPANSION}.
|
|
90
|
+
*
|
|
91
|
+
* On reaching the cap the reader switches to DRAIN-AND-DISCARD: it keeps
|
|
92
|
+
* pulling from the pipe (so the child never blocks on backpressure) and stops
|
|
93
|
+
* RETAINING. The command therefore runs to completion and its real exit code
|
|
94
|
+
* stands — a passing-but-chatty test suite is no longer failed over log volume.
|
|
95
|
+
* What overflow costs is honesty about the artifact, and that is paid two ways
|
|
96
|
+
* in `exec/exec-unit.ts`:
|
|
97
|
+
*
|
|
98
|
+
* - no declared `output:` schema → the unit succeeds and its artifact carries
|
|
99
|
+
* an unmistakable {@link WORKFLOW_EXEC_OUTPUT_TRUNCATED_MARKER} block naming
|
|
100
|
+
* the total and retained byte counts, so truncated text can never be
|
|
101
|
+
* mistaken for the whole output;
|
|
102
|
+
* - a declared `output:` schema → the unit fails `exec_output_limit`, because
|
|
103
|
+
* validating a truncated JSON prefix is meaningless and promoting it would
|
|
104
|
+
* corrupt every downstream reference. That is the residual failure the cap
|
|
105
|
+
* genuinely justifies.
|
|
106
|
+
*
|
|
107
|
+
* 8 MiB is deliberately generous — 8× the whole-row evidence cap
|
|
108
|
+
* ({@link WORKFLOW_MAX_EVIDENCE_JSON_BYTES}), so any output that could survive
|
|
109
|
+
* persistence intact fits many times over, and an ordinary full test/build log
|
|
110
|
+
* is nowhere near it.
|
|
111
|
+
*/
|
|
112
|
+
export const WORKFLOW_MAX_EXEC_OUTPUT_BYTES = 8 * 1024 * 1024;
|
|
113
|
+
/**
|
|
114
|
+
* Marker stamped on an exec artifact that was RETAINED ONLY IN PART because the
|
|
115
|
+
* command wrote past {@link WORKFLOW_MAX_EXEC_OUTPUT_BYTES}.
|
|
116
|
+
*
|
|
117
|
+
* Deliberately ugly and unique, exactly like `WORKFLOW_EVIDENCE_TRUNCATED_MARKER`
|
|
118
|
+
* (`runtime/runs.ts`) — the same idiom for the same reason: a truncated value
|
|
119
|
+
* must NEVER be mistakable for a complete one by a downstream
|
|
120
|
+
* `steps.<id>.output` reference, by a gate judge, by `akm workflow status`, or
|
|
121
|
+
* by a human reading the row. The artifact is TEXT here rather than a JSON
|
|
122
|
+
* value, so the marker is appended as a trailing block instead of replacing the
|
|
123
|
+
* value with an envelope: the retained prefix is still genuinely useful (it is
|
|
124
|
+
* the head of a real log), and the block says exactly how much is missing.
|
|
125
|
+
*/
|
|
126
|
+
export const WORKFLOW_EXEC_OUTPUT_TRUNCATED_MARKER = "__akm_exec_output_truncated__";
|
|
127
|
+
// Per-var: Win32 `SetEnvironmentVariable` caps one variable at 32 767 UTF-16
|
|
128
|
+
// code units; measuring UTF-8 bytes is conservative in the right direction.
|
|
129
|
+
// Total: akm's own share of the `CreateProcess` `lpEnvironment` block, which it
|
|
130
|
+
// shares with the allowlist, the unit's `env:` bindings and the argv.
|
|
131
|
+
const EXEC_CONTEXT_LIMITS_WIN32 = {
|
|
132
|
+
perVarBytes: 32_767,
|
|
133
|
+
totalBytes: 64_000,
|
|
134
|
+
source: "Windows caps one environment variable at 32 767 characters (SetEnvironmentVariable)",
|
|
135
|
+
};
|
|
136
|
+
// Per-var: 75% of Linux's `MAX_ARG_STRLEN` (32 pages = 131 072 bytes), leaving
|
|
137
|
+
// margin for the name, `=`, NUL and the kernel's own accounting — the guard must
|
|
138
|
+
// never reject a spawn the platform would have accepted.
|
|
139
|
+
// Total: half of macOS's 256 KiB `ARG_MAX` (the tightest supported total), so
|
|
140
|
+
// the other half remains for the argv, the allowlist and the `env:` bindings.
|
|
141
|
+
const EXEC_CONTEXT_LIMITS_POSIX = {
|
|
142
|
+
perVarBytes: 96 * 1024,
|
|
143
|
+
totalBytes: 128 * 1024,
|
|
144
|
+
source: "Linux caps one argv/environ string at MAX_ARG_STRLEN (32 pages = 131 072 bytes) and macOS caps argv+environ at ARG_MAX (256 KiB)",
|
|
145
|
+
};
|
|
146
|
+
/**
|
|
147
|
+
* The `AKM_*` context ceilings for THIS platform (or an explicitly named one,
|
|
148
|
+
* which is how the tests drive both branches deterministically).
|
|
149
|
+
*/
|
|
150
|
+
export function execContextLimits(platform = process.platform) {
|
|
151
|
+
return platform === "win32" ? EXEC_CONTEXT_LIMITS_WIN32 : EXEC_CONTEXT_LIMITS_POSIX;
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Max characters of the per-unit human diagnostic — the `error`/stderr text
|
|
155
|
+
* journaled on a failed unit row and rendered by `akm workflow status --units`.
|
|
156
|
+
*
|
|
157
|
+
* ONE constant for the write side (`exec/native-executor.ts`, which clips before
|
|
158
|
+
* journaling) and the read side (`runtime/runs.ts`, which clips whatever a row
|
|
159
|
+
* already holds), so a diagnostic can never be stored larger than the surface
|
|
160
|
+
* that displays it. Long enough for a real stack trace or a compiler's error
|
|
161
|
+
* block; short enough that a runaway command cannot turn the journal into its
|
|
162
|
+
* log file.
|
|
163
|
+
*/
|
|
164
|
+
export const WORKFLOW_UNIT_DIAGNOSTIC_CLIP = 2_000;
|
|
165
|
+
/**
|
|
166
|
+
* Truncate to `max` chars with an ellipsis marker.
|
|
167
|
+
*
|
|
168
|
+
* Lives with the bounds rather than with either caller: the write side
|
|
169
|
+
* (`exec/step-work.ts`) and the read side (`runtime/runs.ts`) clip against the
|
|
170
|
+
* same constants, and `runtime/runs.ts` cannot take the helper from
|
|
171
|
+
* `exec/step-work.ts` — that module imports `runtime/runs.ts`.
|
|
172
|
+
*/
|
|
173
|
+
export function clip(text, max) {
|
|
174
|
+
return text.length > max ? `${text.slice(0, max)}…` : text;
|
|
175
|
+
}
|
|
176
|
+
// ── Persistence bounds ───────────────────────────────────────────────────────
|
|
177
|
+
/**
|
|
178
|
+
* Max serialized size of one `workflow_run_steps.evidence_json` row value.
|
|
179
|
+
*
|
|
180
|
+
* The promoted step artifact (`evidence.output`) is deliberately NOT clipped
|
|
181
|
+
* when it is built — gates judge the full artifact and downstream
|
|
182
|
+
* `steps.<id>.output` references need it intact — but a `collect`
|
|
183
|
+
* reducer over a fan-out bounded only by {@link WORKFLOW_MAX_MAP_EXPANSION}
|
|
184
|
+
* (10 000 units, each contributing up to a full unit result) would otherwise
|
|
185
|
+
* write an unbounded blob into a single SQLite row. Persistence is therefore
|
|
186
|
+
* bounded here, at the write boundary, by
|
|
187
|
+
* `clipStepEvidenceForPersistence` (`runtime/runs.ts`), which replaces
|
|
188
|
+
* oversized values with an explicitly-marked truncation envelope rather than
|
|
189
|
+
* silently shortening them.
|
|
190
|
+
*
|
|
191
|
+
* 1 MiB is deliberately generous: it is 4× the per-instruction cap and half the
|
|
192
|
+
* whole-plan cap, so no realistic authored workflow reaches it, while a runaway
|
|
193
|
+
* fan-out is still bounded to something SQLite and `akm workflow status` can
|
|
194
|
+
* handle.
|
|
195
|
+
*/
|
|
196
|
+
export const WORKFLOW_MAX_EVIDENCE_JSON_BYTES = 1024 * 1024;
|
|
197
|
+
/** Chars of the original value retained (as a marked preview) in a truncation envelope. */
|
|
198
|
+
export const WORKFLOW_EVIDENCE_TRUNCATION_PREVIEW_CHARS = 1000;
|
|
17
199
|
export function utf8Bytes(value) {
|
|
18
200
|
return Buffer.byteLength(value, "utf8");
|
|
19
201
|
}
|