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.
Files changed (140) hide show
  1. package/CHANGELOG.md +724 -0
  2. package/README.md +28 -63
  3. package/STABILITY.md +4 -2
  4. package/dist/cli/parse-args.js +7 -1
  5. package/dist/commands/agent/contribute-cli.js +1 -1
  6. package/dist/commands/env/child-env.js +14 -0
  7. package/dist/commands/feedback-cli.js +7 -1
  8. package/dist/commands/health/llm-usage.js +2 -1
  9. package/dist/commands/health/surfaces.js +4 -77
  10. package/dist/commands/health.js +65 -11
  11. package/dist/commands/improve/distill/quality-gate.js +6 -1
  12. package/dist/commands/improve/eligibility.js +7 -1
  13. package/dist/commands/improve/eval-cases.js +2 -0
  14. package/dist/commands/improve/improve.js +126 -10
  15. package/dist/commands/improve/locks.js +7 -0
  16. package/dist/commands/improve/memory/memory-improve.js +9 -0
  17. package/dist/commands/improve/run-context.js +5 -0
  18. package/dist/commands/improve/session-asset.js +4 -0
  19. package/dist/commands/lint/base-linter.js +31 -7
  20. package/dist/commands/lint/index.js +205 -51
  21. package/dist/commands/lint/types.js +22 -1
  22. package/dist/commands/proposal/repository.js +17 -1
  23. package/dist/commands/sources/add-cli.js +8 -2
  24. package/dist/commands/sources/info.js +12 -2
  25. package/dist/commands/sources/installed-stashes.js +6 -1
  26. package/dist/commands/sources/migration-help.js +12 -3
  27. package/dist/commands/sources/self-update.js +9 -1
  28. package/dist/commands/tasks/tasks.js +8 -2
  29. package/dist/commands/workflow-cli.js +17 -11
  30. package/dist/core/abort-deadline.js +28 -0
  31. package/dist/core/adapter/adapters/agent-skills-adapter.js +83 -5
  32. package/dist/core/adapter/adapters/akm-adapter.js +13 -10
  33. package/dist/core/adapter/adapters/akm-lint.js +78 -22
  34. package/dist/core/adapter/adapters/akm-task-adapter.js +43 -20
  35. package/dist/core/adapter/adapters/dotenv-adapter.js +21 -0
  36. package/dist/core/adapter/adapters/tool-dir-shared.js +5 -3
  37. package/dist/core/asset/frontmatter.js +10 -1
  38. package/dist/core/common.js +147 -9
  39. package/dist/core/concurrent.js +32 -0
  40. package/dist/core/config/config-io.js +5 -45
  41. package/dist/core/config/schema/engines.js +14 -3
  42. package/dist/core/config/schema/workflow.js +11 -0
  43. package/dist/core/errors.js +25 -0
  44. package/dist/core/events.js +30 -24
  45. package/dist/core/extra-params.js +11 -0
  46. package/dist/core/file-lock.js +7 -1
  47. package/dist/core/fs-txn.js +15 -2
  48. package/dist/core/improve-result.js +5 -0
  49. package/dist/core/json-schema.js +344 -9
  50. package/dist/core/loopback.js +89 -0
  51. package/dist/core/migration-operation.js +17 -2
  52. package/dist/core/path-access.js +107 -0
  53. package/dist/core/paths.js +16 -2
  54. package/dist/core/redaction.js +86 -18
  55. package/dist/core/spawn-env.js +234 -0
  56. package/dist/core/state-db-scope.js +134 -0
  57. package/dist/core/state-db.js +1 -0
  58. package/dist/core/subprocess.js +181 -37
  59. package/dist/core/write-provenance.js +85 -0
  60. package/dist/core/write-source.js +33 -2
  61. package/dist/indexer/db/graph-db.js +17 -6
  62. package/dist/indexer/ensure-index.js +10 -3
  63. package/dist/indexer/index-written-assets.js +17 -2
  64. package/dist/indexer/indexer.js +86 -21
  65. package/dist/indexer/passes/memory-inference.js +4 -0
  66. package/dist/indexer/search/db-search.js +25 -17
  67. package/dist/indexer/walk/walker.js +6 -1
  68. package/dist/integrations/agent/detect.js +13 -1
  69. package/dist/integrations/agent/engine-resolution.js +24 -11
  70. package/dist/integrations/agent/model-aliases.js +1 -1
  71. package/dist/integrations/agent/profiles.js +9 -1
  72. package/dist/integrations/agent/spawn.js +15 -87
  73. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +21 -0
  74. package/dist/integrations/lockfile.js +55 -2
  75. package/dist/llm/client.js +14 -19
  76. package/dist/llm/embedder.js +23 -3
  77. package/dist/llm/embedders/remote.js +27 -2
  78. package/dist/output/html-render.js +40 -1
  79. package/dist/output/text/lint-format.js +17 -4
  80. package/dist/runtime.js +23 -1
  81. package/dist/scripts/akm-migrate-node.js +1714 -836
  82. package/dist/scripts/akm-migrate.js +1682 -804
  83. package/dist/setup/setup.js +22 -7
  84. package/dist/sources/providers/git-install.js +25 -2
  85. package/dist/sources/providers/git-stash.js +19 -0
  86. package/dist/sources/providers/git.js +1 -1
  87. package/dist/sources/snapshot-fetchers/content-extract.js +63 -1
  88. package/dist/sources/snapshot-fetchers/website-ingest.js +126 -20
  89. package/dist/storage/database.js +71 -7
  90. package/dist/storage/engines/sqlite-migrations.js +61 -2
  91. package/dist/storage/managed-db.js +19 -0
  92. package/dist/storage/repositories/index-connection.js +39 -4
  93. package/dist/storage/repositories/index-entries-repository.js +6 -1
  94. package/dist/storage/repositories/index-meta-repository.js +11 -0
  95. package/dist/storage/repositories/index-schema.js +17 -2
  96. package/dist/storage/repositories/index-vec-repository.js +43 -5
  97. package/dist/storage/repositories/workflow-runs-repository.js +66 -13
  98. package/dist/storage/sqlite-pragmas.js +12 -1
  99. package/dist/tasks/log-redaction.js +156 -0
  100. package/dist/tasks/parser.js +82 -5
  101. package/dist/tasks/runner.js +222 -17
  102. package/dist/tasks/scheduler-invocation.js +19 -0
  103. package/dist/tasks/schema.js +86 -1
  104. package/dist/text-import-hook.mjs +1 -1
  105. package/dist/workflows/concurrency-policy.js +95 -1
  106. package/dist/workflows/exec/dispatch-redaction.js +114 -0
  107. package/dist/workflows/exec/exec-unit.js +542 -0
  108. package/dist/workflows/exec/frozen-judge.js +114 -42
  109. package/dist/workflows/exec/native-executor.js +465 -238
  110. package/dist/workflows/exec/param-secrets.js +4 -3
  111. package/dist/workflows/exec/run-workflow.js +424 -219
  112. package/dist/workflows/exec/step-work.js +506 -167
  113. package/dist/workflows/exec/unit-dispatch.js +31 -1
  114. package/dist/workflows/exec/unit-writer.js +53 -13
  115. package/dist/workflows/exec/worktree.js +454 -41
  116. package/dist/workflows/ir/compile.js +26 -2
  117. package/dist/workflows/ir/freeze.js +82 -15
  118. package/dist/workflows/ir/schema.js +105 -20
  119. package/dist/workflows/parser.js +242 -19
  120. package/dist/workflows/program/schema.js +24 -0
  121. package/dist/workflows/renderer.js +32 -4
  122. package/dist/workflows/resource-limits.js +182 -0
  123. package/dist/workflows/runtime/runs.js +146 -6
  124. package/dist/workflows/validate-summary.js +17 -2
  125. package/docs/README.md +74 -32
  126. package/docs/migration/release-notes/0.9.0.md +2 -1
  127. package/docs/migration/v0.7-to-v0.8.md +2 -1
  128. package/docs/migration/v0.8-to-v0.9.md +3 -1
  129. package/docs/reference/README.md +11 -4
  130. package/docs/reference/bundle-types.md +19 -0
  131. package/docs/reference/cli.md +105 -16
  132. package/docs/reference/configuration.md +15 -2
  133. package/docs/reference/data-and-telemetry.md +30 -10
  134. package/docs/reference/supported-formats.md +50 -0
  135. package/docs/reference/workflow-schema.md +1014 -0
  136. package/docs/reference/workflows.md +37 -633
  137. package/package.json +13 -6
  138. package/schemas/akm-config.json +18 -5
  139. package/schemas/akm-task.json +27 -5
  140. package/schemas/akm-workflow.json +92 -13
@@ -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
- if (typeof raw.engine === "string" && raw.engine.trim() !== "")
441
- defaults.engine = raw.engine.trim();
442
- else
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
- if (typeof raw.engine === "string" && raw.engine.trim() !== "")
610
- unit.engine = raw.engine.trim();
611
- else
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
- unit.env = raw.env.map((entry) => entry.trim());
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" && Number.isInteger(raw.concurrency) && raw.concurrency > 0) {
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 a positive integer.`);
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" && Number.isInteger(raw.max_loops) && raw.max_loops >= 1) {
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 >= 1.`);
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 a non-negative integer.`);
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 engine = unit?.engine ?? defaults?.engine;
72
- const model = unit?.model ?? defaults?.model;
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
  }