akm-cli 0.9.7 → 0.9.8-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 (130) hide show
  1. package/CHANGELOG.md +310 -0
  2. package/dist/commands/env/env.js +2 -21
  3. package/dist/commands/health/checks.js +0 -25
  4. package/dist/commands/health/data-dir-usage.js +165 -0
  5. package/dist/commands/health/improve-metrics.js +8 -34
  6. package/dist/commands/health/windows.js +0 -4
  7. package/dist/commands/health.js +18 -37
  8. package/dist/commands/improve/consolidate/eligibility.js +11 -5
  9. package/dist/commands/improve/extract.js +36 -32
  10. package/dist/commands/improve/memory/memory-belief.js +15 -5
  11. package/dist/commands/improve/memory/memory-contradiction-detect.js +16 -25
  12. package/dist/commands/improve/memory/memory-improve.js +7 -19
  13. package/dist/commands/improve/preparation.js +52 -36
  14. package/dist/commands/improve/reflect.js +13 -17
  15. package/dist/commands/lint/base-linter.js +42 -2
  16. package/dist/commands/lint/index.js +4 -5
  17. package/dist/commands/migrate/config-extra-params.js +61 -0
  18. package/dist/commands/migrate/dead-residue.js +113 -0
  19. package/dist/commands/migrate/stale-txn.js +49 -0
  20. package/dist/commands/migrate-cli.js +42 -1
  21. package/dist/commands/proposal/proposal.js +1 -21
  22. package/dist/commands/proposal/repository.js +0 -4
  23. package/dist/commands/proposal/validators/proposal-quality-validators.js +0 -6
  24. package/dist/commands/read/curate.js +4 -18
  25. package/dist/commands/read/show.js +2 -1
  26. package/dist/commands/sources/installed-stashes.js +5 -1
  27. package/dist/commands/sources/self-update.js +38 -1
  28. package/dist/commands/sources/sources-cli.js +17 -1
  29. package/dist/commands/tasks/tasks.js +0 -8
  30. package/dist/commands/url-checker.js +20 -28
  31. package/dist/commands/workflow-cli.js +11 -61
  32. package/dist/core/adapter/adapters/agent-skills-adapter.js +2 -6
  33. package/dist/core/adapter/adapters/akm-adapter.js +2 -3
  34. package/dist/core/adapter/adapters/akm-lint.js +2 -20
  35. package/dist/core/adapter/adapters/akm-task-adapter.js +2 -6
  36. package/dist/core/adapter/adapters/akm-workflow-adapter.js +2 -6
  37. package/dist/core/adapter/adapters/dotenv-adapter.js +2 -22
  38. package/dist/core/adapter/adapters/generic-files-adapter.js +3 -9
  39. package/dist/core/adapter/adapters/llm-wiki-adapter.js +2 -6
  40. package/dist/core/adapter/adapters/okf-adapter.js +7 -18
  41. package/dist/core/adapter/adapters/shared.js +2 -0
  42. package/dist/core/adapter/adapters/tool-dir-shared.js +3 -9
  43. package/dist/core/adapter/adapters/website-snapshot-adapter.js +2 -6
  44. package/dist/core/adapter/validate-context.js +1 -3
  45. package/dist/core/asset/asset-placement.js +1 -3
  46. package/dist/core/asset/frontmatter.js +96 -0
  47. package/dist/core/common.js +66 -2
  48. package/dist/core/config/config-io.js +21 -8
  49. package/dist/core/config/config-walker.js +6 -10
  50. package/dist/core/config/config.js +11 -8
  51. package/dist/core/extra-params.js +17 -13
  52. package/dist/core/improve-result.js +1 -3
  53. package/dist/core/json-schema.js +9 -11
  54. package/dist/core/state/migrations.js +74 -2
  55. package/dist/core/state-db.js +2 -1
  56. package/dist/core/warn.js +15 -0
  57. package/dist/execution/executable-identity.js +1 -3
  58. package/dist/execution/guarded-source.js +1 -6
  59. package/dist/indexer/bundle-identity-guard.js +6 -1
  60. package/dist/indexer/db/graph-db.js +139 -154
  61. package/dist/indexer/ensure-index.js +11 -19
  62. package/dist/indexer/graph/graph-boost.js +23 -34
  63. package/dist/indexer/graph/graph-extraction.js +12 -2
  64. package/dist/indexer/indexer.js +43 -22
  65. package/dist/indexer/lookup/adapter-concept-owner.js +12 -9
  66. package/dist/indexer/passes/dir-staleness.js +30 -9
  67. package/dist/indexer/passes/memory-inference.js +7 -2
  68. package/dist/indexer/scan/drain-dir.js +2 -4
  69. package/dist/indexer/search/db-search.js +3 -3
  70. package/dist/indexer/search/fts-query.js +10 -15
  71. package/dist/indexer/search/search-source.js +0 -13
  72. package/dist/indexer/usage/usage-events.js +9 -1
  73. package/dist/indexer/walk/walker.js +11 -6
  74. package/dist/integrations/harnesses/claude/result-extractor.js +30 -15
  75. package/dist/integrations/harnesses/codex/result-extractor.js +43 -6
  76. package/dist/integrations/harnesses/copilot/result-extractor.js +39 -12
  77. package/dist/integrations/harnesses/gemini/result-extractor.js +40 -12
  78. package/dist/integrations/harnesses/index.js +0 -4
  79. package/dist/integrations/harnesses/openhands/result-extractor.js +51 -19
  80. package/dist/integrations/harnesses/pi/result-extractor.js +45 -12
  81. package/dist/integrations/lockfile.js +0 -14
  82. package/dist/integrations/session-logs/index.js +0 -81
  83. package/dist/llm/client.js +0 -12
  84. package/dist/llm/memory-infer.js +1 -3
  85. package/dist/llm/usage-telemetry.js +1 -3
  86. package/dist/registry/create-provider-registry.js +4 -0
  87. package/dist/registry/factory.js +4 -0
  88. package/dist/registry/resolve.js +23 -8
  89. package/dist/runtime.js +0 -12
  90. package/dist/scripts/akm-migrate-node.js +179 -248
  91. package/dist/scripts/akm-migrate.js +179 -248
  92. package/dist/setup/setup.js +6 -12
  93. package/dist/sources/providers/git-install.js +7 -2
  94. package/dist/sources/providers/tar-utils.js +1 -7
  95. package/dist/sources/snapshot-fetchers/content-extract.js +0 -17
  96. package/dist/storage/managed-db.js +20 -7
  97. package/dist/storage/repositories/events-repository.js +0 -81
  98. package/dist/storage/repositories/index-entries-repository.js +24 -28
  99. package/dist/storage/repositories/index-fts-repository.js +24 -30
  100. package/dist/storage/repositories/index-meta-repository.js +6 -4
  101. package/dist/storage/repositories/index-schema.js +16 -1
  102. package/dist/storage/repositories/index-utility-repository.js +0 -57
  103. package/dist/storage/repositories/index-vec-repository.js +25 -27
  104. package/dist/storage/repositories/proposals-repository.js +4 -1
  105. package/dist/storage/repositories/task-history-repository.js +9 -3
  106. package/dist/tasks/backends/launchd.js +8 -18
  107. package/dist/tasks/resolve-akm-bin.js +15 -0
  108. package/dist/tasks/run/task-history.js +21 -31
  109. package/dist/tasks/scheduler-sync.js +1 -6
  110. package/dist/tasks/source/bounded-document.js +1 -14
  111. package/dist/tasks/source/task-to-v3.js +10 -2
  112. package/dist/workflows/exec/child-workflow.js +1 -1
  113. package/dist/workflows/exec/native-executor.js +2 -2
  114. package/dist/workflows/exec/step-work.js +5 -17
  115. package/dist/workflows/exec/worktree.js +40 -6
  116. package/dist/workflows/freeze/task-bindings.js +2 -4
  117. package/dist/workflows/ir/compile.js +3 -14
  118. package/dist/workflows/ir/schema-v4.js +4 -6
  119. package/dist/workflows/ir/schema.js +2 -5
  120. package/dist/workflows/parser.js +23 -26
  121. package/dist/workflows/source-files.js +8 -13
  122. package/dist/workflows/source-ir/schema.js +1 -19
  123. package/docs/migration/v0.9.0-troubleshooting.md +12 -2
  124. package/docs/migration/v0.9.1-to-v0.9.2.md +18 -4
  125. package/docs/reference/cli.md +3 -1
  126. package/docs/reference/data-and-telemetry.md +1 -0
  127. package/docs/reference/tasks.md +11 -0
  128. package/docs/reference/workflow-schema.md +24 -0
  129. package/package.json +2 -9
  130. package/dist/workflows/source-ir/compare.js +0 -17
@@ -25,6 +25,7 @@ import fs from "node:fs";
25
25
  import os from "node:os";
26
26
  import path from "node:path";
27
27
  import launchdTemplate from "../../assets/backends/launchd-template.xml" with { type: "text" };
28
+ import { hasErrnoCode } from "../../core/common.js";
28
29
  import { ConfigError } from "../../core/errors.js";
29
30
  import { getTaskLogDir } from "../../core/paths.js";
30
31
  import { resolveAkmInvocation } from "../resolve-akm-bin.js";
@@ -851,22 +852,6 @@ export function parseLaunchdLoadedLabels(output) {
851
852
  }
852
853
  return labels;
853
854
  }
854
- function countCharacter(value, needle) {
855
- let count = 0;
856
- for (const character of value)
857
- if (character === needle)
858
- count += 1;
859
- return count;
860
- }
861
- function hasUnsafeLaunchdControlCharacter(output) {
862
- for (let index = 0; index < output.length; index += 1) {
863
- const code = output.charCodeAt(index);
864
- if (code <= 8 || code === 11 || code === 12 || (code >= 14 && code <= 31) || (code >= 127 && code <= 159)) {
865
- return true;
866
- }
867
- }
868
- return false;
869
- }
870
855
  function readDisabledLabels(exec) {
871
856
  try {
872
857
  const result = exec.run(["launchctl", "print-disabled", `gui/${exec.uid()}`]);
@@ -933,8 +918,13 @@ function defaultLaunchdFs() {
933
918
  try {
934
919
  return fs.readdirSync(dir);
935
920
  }
936
- catch {
937
- return [];
921
+ catch (error) {
922
+ // Genuinely absent is an empty listing; anything else (e.g. EACCES) is
923
+ // not "no plists" — surfacing it as such could make inspectBindings
924
+ // think there is nothing to reconcile.
925
+ if (hasErrnoCode(error, "ENOENT"))
926
+ return [];
927
+ throw new ConfigError(`Unable to read LaunchAgents directory at "${dir}".`, "INVALID_CONFIG_FILE");
938
928
  }
939
929
  },
940
930
  exists(file) {
@@ -152,7 +152,22 @@ function samePath(left, right) {
152
152
  };
153
153
  return normalize(left) === normalize(right);
154
154
  }
155
+ // #901: the global root can't change within a process lifetime, and every
156
+ // `resolveAkmInvocation()` call in a scheduler-sync process re-derives the
157
+ // same `nodePath` — so this spawns `npm root --global` at most once per
158
+ // process instead of once per call. Keyed by nodePath (rather than a bare
159
+ // once-only flag) so a differently-invoked probe later in the same process
160
+ // still gets its own answer instead of a stale one.
161
+ let cachedNpmGlobalRoot;
155
162
  function resolveNpmGlobalRoot(nodePath, env) {
163
+ if (cachedNpmGlobalRoot && cachedNpmGlobalRoot.nodePath === nodePath) {
164
+ return cachedNpmGlobalRoot.value;
165
+ }
166
+ const value = resolveNpmGlobalRootUncached(nodePath, env);
167
+ cachedNpmGlobalRoot = { nodePath, value };
168
+ return value;
169
+ }
170
+ function resolveNpmGlobalRootUncached(nodePath, env) {
156
171
  const npmCli = resolveAssociatedNpmCli(nodePath);
157
172
  if (!npmCli)
158
173
  return undefined;
@@ -14,20 +14,15 @@
14
14
  *
15
15
  * D8 result-vocabulary re-code (why, and the WRITE side's exact shape): see
16
16
  * docs/architecture/decisions/0005-task-result-vocabulary-and-legacy-read-mapping.md.
17
- * The READ side's mapping rule is a PERMANENT invariant kept here, not moved
18
- * a maintainer touching `taskHistoryRowToResult` needs it right here, and
19
- * row B-51 (docs/plans/specs/p4-deletions-closeout.md) makes deleting it a
20
- * review-blocking violation: it reads rows written by every previous
21
- * release, forever.
22
- *
23
- * A legacy row (no `targetVocab` marker written before D8) maps:
24
- * `"prompt"` -> `{kind:"command", engine}`, `"command"` -> `{kind:"shell"}`,
25
- * `"workflow"` unchanged, anything else (including the new vocabulary's own
26
- * "shell"/"script"/"prompt" written WITHOUT a marker, which no production
27
- * writer ever does) -> `"unknown"`. The P0-pinned null fallbacks survive:
28
- * workflow `ref` falls back to `""`, the command/prompt arm's `engine`
29
- * falls back to `null`. A row carrying `targetVocab: 2` reads `target_kind`
30
- * directly in the current vocabulary — no mapping needed.
17
+ * The READ side used to carry a permanent legacy-vocabulary mapping here
18
+ * (row B-51 of docs/plans/specs/p4-deletions-closeout.md called deleting it
19
+ * "review-blocking") — SUPERSEDED: `task_history` is DB-owned data, so the
20
+ * remap is now a one-time schema migration
21
+ * (`025-task-history-vocabulary-backfill` in src/core/state/migrations.ts)
22
+ * instead of a read-side shim run forever. Every row this function sees now
23
+ * carries the current vocabulary's `target_kind` strings; the P0-pinned null
24
+ * fallbacks still apply (workflow `ref` falls back to `""`, the command
25
+ * arm's `engine` falls back to `null`).
31
26
  *
32
27
  * A DAG leaf with respect to the rest of src/tasks/run/**: this module
33
28
  * imports TaskRunResult/TaskRunStatus's TYPE from ./task-result but no VALUE
@@ -37,6 +32,7 @@
37
32
  */
38
33
  import { rethrowIfTestIsolationError } from "../../core/errors.js";
39
34
  import { withStateDb } from "../../core/state-db.js";
35
+ import { warn } from "../../core/warn.js";
40
36
  import { decodeTaskHistoryMetadata, finalizeTaskHistoryAttempt, getTaskHistory, getTaskHistoryRuns, queryTaskHistory, upsertTaskHistory, } from "../../storage/repositories/task-history-repository.js";
41
37
  /** Append (or finalize a reserved attempt into) one task_history row. */
42
38
  export function appendHistory(result, historyReserved = false) {
@@ -67,7 +63,11 @@ export function appendHistory(result, historyReserved = false) {
67
63
  }
68
64
  catch (error) {
69
65
  rethrowIfTestIsolationError(error);
70
- // History recording is fully best-effort and must not alter CLI output.
66
+ // History recording must not alter the task's own result/exit status
67
+ // the task already ran and its outcome is independent of whether we can
68
+ // persist a history row. But silence would make a failed write
69
+ // indistinguishable from the task never having run, so warn.
70
+ warn(`task history: failed to record history for task ${result.id}: ${error instanceof Error ? error.message : String(error)}`);
71
71
  }
72
72
  }
73
73
  export function readTaskHistory(options = {}) {
@@ -111,32 +111,22 @@ function decodeTaskHistoryRows(rows) {
111
111
  * Convert a `TaskHistoryRow` from state.db back to a `TaskRunResult` shape
112
112
  * that callers of `readTaskHistory()` expect.
113
113
  *
114
- * D8 read boundary (spec §5.3): branches on the decoded metadata's
115
- * `targetVocab` marker see the module header's table.
114
+ * Reads `target_kind` directly in the current (post-D8) vocabulary the
115
+ * `025-task-history-vocabulary-backfill` state migration rewrites every
116
+ * legacy-vocabulary row before this ever runs against it.
116
117
  */
117
118
  function taskHistoryRowToResult(row) {
118
119
  const meta = decodeTaskHistoryMetadata(row.metadata_json);
119
- const marked = meta.targetVocab === 2;
120
120
  const target = (() => {
121
121
  switch (row.target_kind) {
122
122
  case "workflow":
123
- // PRESERVED for both vintages (incl. the null-ref fallback).
124
123
  return { kind: "workflow", ref: row.target_ref ?? "" };
125
124
  case "command":
126
- // NEW vocabulary: a prepared command (agent/LLM) result.
127
- // LEGACY vocabulary: the native shell/script arm's shared string.
128
- return marked ? { kind: "command", engine: meta.engine ?? null } : { kind: "shell" };
125
+ return { kind: "command", engine: meta.engine ?? null };
129
126
  case "shell":
130
- // Only the NEW vocabulary ever writes this string; an unmarked
131
- // "shell" row is unreachable from any production writer.
132
- return marked ? { kind: "shell" } : { kind: "unknown" };
127
+ return { kind: "shell" };
133
128
  case "script":
134
- // Only the NEW vocabulary ever writes this string; an unmarked
135
- // "script" row is unreachable from any production writer.
136
- return marked ? { kind: "script" } : { kind: "unknown" };
137
- case "prompt":
138
- // Only LEGACY rows (pre-P1b) ever wrote this string.
139
- return marked ? { kind: "unknown" } : { kind: "command", engine: meta.engine ?? null };
129
+ return { kind: "script" };
140
130
  default:
141
131
  return { kind: "unknown" };
142
132
  }
@@ -6,6 +6,7 @@ import { createHash } from "node:crypto";
6
6
  import path from "node:path";
7
7
  import { loadAdapterExecutionSource } from "../commands/command/execution-source-loader.js";
8
8
  import { makeBundleRef } from "../core/asset/asset-ref.js";
9
+ import { compareCodePoints, toPosix } from "../core/common.js";
9
10
  import { UsageError } from "../core/errors.js";
10
11
  import { canonicalizeWorkflowName, WORKFLOW_EXTENSIONS } from "../core/recognition-util.js";
11
12
  import { captureGuardedDirectoryManifest, captureGuardedExecutionSource, GuardedExecutionSourceCollector, } from "../execution/guarded-source.js";
@@ -742,12 +743,6 @@ function freezeOperation(operation) {
742
743
  function sameInvocation(left, right) {
743
744
  return left.length === right.length && left.every((value, index) => value === right[index]);
744
745
  }
745
- function compareCodePoints(left, right) {
746
- return left < right ? -1 : left > right ? 1 : 0;
747
- }
748
- function toPosix(value) {
749
- return value.replaceAll("\\", "/");
750
- }
751
746
  // Retain the concrete error in this module's public dependency graph so callers
752
747
  // can continue to identify ownership failures without importing an adapter.
753
748
  export { WorkflowSourceRejectionError };
@@ -47,6 +47,7 @@ import fs from "node:fs";
47
47
  import path from "node:path";
48
48
  import { types as utilTypes } from "node:util";
49
49
  import { isAlias, isMap, isScalar, isSeq, LineCounter, parseDocument } from "yaml";
50
+ import { wellFormedUnicode } from "../../core/common.js";
50
51
  import { UsageError } from "../../core/errors.js";
51
52
  import { DURATION_UNITS, parseDuration } from "../../core/time.js";
52
53
  import { EXECUTION_MAX_TIMEOUT_MS } from "../../execution/limits.js";
@@ -67,20 +68,6 @@ export function own(value, key) {
67
68
  export function utf8Bytes(value) {
68
69
  return new TextEncoder().encode(value).byteLength;
69
70
  }
70
- export function wellFormedUnicode(value) {
71
- for (let index = 0; index < value.length; index += 1) {
72
- const code = value.charCodeAt(index);
73
- if (code >= 0xd800 && code <= 0xdbff) {
74
- const next = value.charCodeAt(index + 1);
75
- if (!(next >= 0xdc00 && next <= 0xdfff))
76
- return false;
77
- index += 1;
78
- }
79
- else if (code >= 0xdc00 && code <= 0xdfff)
80
- return false;
81
- }
82
- return true;
83
- }
84
71
  /** The one per-field error funnel both grammars render through, distinguished only by `ctx.sourceLabel` (D2-N4). */
85
72
  export function sourceError(ctx, fieldPath, detail) {
86
73
  const dotted = fieldPath.length === 0
@@ -112,6 +112,12 @@ function base(input) {
112
112
  ...(input.containmentRoot ? { containmentRoot: input.containmentRoot } : {}),
113
113
  };
114
114
  }
115
+ /**
116
+ * #902: the one blocker with an unambiguous remedy. The sibling shell-safety
117
+ * reasons need a case-by-case judgement and stay reason-only.
118
+ */
119
+ const ARGV_ARRAY_BLOCK_DETAIL = "Manual conversion required: an array `command:` has no safe v3 `run:` string. Rewrite it by hand as " +
120
+ "`run:` (string) plus `shell:` — see docs/migration/v0.9.1-to-v0.9.2.md for the full v2 to v4 field mapping.";
115
121
  function blocked(input, reason, detail) {
116
122
  return Object.freeze({ status: "blocked", ...base(input), reason, ...(detail ? { detail } : {}) });
117
123
  }
@@ -389,8 +395,10 @@ export function planLegacyTaskDataToV3(input, data) {
389
395
  catch (cause) {
390
396
  return blocked(input, "invalid-v2-task", cause instanceof Error ? cause.message : String(cause));
391
397
  }
392
- if (isReason(migrated))
393
- return blocked(input, migrated);
398
+ if (isReason(migrated)) {
399
+ const detail = migrated === "argv-array-has-no-portable-shell-string" ? ARGV_ARRAY_BLOCK_DETAIL : undefined;
400
+ return blocked(input, migrated, detail);
401
+ }
394
402
  const after = Buffer.from(stringifyYaml(migrated), "utf8");
395
403
  try {
396
404
  parseTaskV3Yaml({
@@ -284,7 +284,7 @@ async function driveChildRun(input, childRow) {
284
284
  * before step 6 (publication) produces `child_workflow_publish_failed`.
285
285
  */
286
286
  export async function driveChildWorkflowUnit(input) {
287
- const { request, target, ctx } = input;
287
+ const { request, ctx } = input;
288
288
  const precheck = precheckAndDeriveInvocationKey(input);
289
289
  if (!precheck.ok) {
290
290
  return precheck.outcome;
@@ -708,7 +708,7 @@ async function prepareAttemptWorktree(input) {
708
708
  }
709
709
  return { ok: true, request: { ...input.request, cwd: created.path }, worktreePath: created.path };
710
710
  }
711
- async function reserveJournaledDispatch(input, request, worktreePath, startedAt) {
711
+ async function reserveJournaledDispatch(input, worktreePath, startedAt) {
712
712
  const { plan, workUnit, ctx, attemptId, inputHash } = input;
713
713
  let durableAttempt;
714
714
  await enqueueUnitWrite(async () => {
@@ -799,7 +799,7 @@ async function dispatchJournaledAttempt(input) {
799
799
  const startedAt = new Date().toISOString();
800
800
  let durableAttempt;
801
801
  try {
802
- durableAttempt = await reserveJournaledDispatch(input, request, worktreePath, startedAt);
802
+ durableAttempt = await reserveJournaledDispatch(input, worktreePath, startedAt);
803
803
  }
804
804
  catch (err) {
805
805
  // A failed dispatch-row insert means NOTHING dispatched (the row is the
@@ -20,7 +20,7 @@ import { UsageError } from "../../core/errors.js";
20
20
  import { validateJsonSchemaSubset } from "../../core/json-schema.js";
21
21
  import { canonicalInputJson, validateInputs } from "../../execution/input-contract.js";
22
22
  import { withWorkflowRunsRepo, } from "../../storage/repositories/workflow-runs-repository.js";
23
- import { canonicalJson as canonicalJsonString } from "../ir/plan-hash.js";
23
+ import { canonicalJson } from "../ir/plan-hash.js";
24
24
  import { parseReference, resolveReferenceString, } from "../program/expressions.js";
25
25
  import { clip, WORKFLOW_UNIT_DIAGNOSTIC_CLIP } from "../resource-limits.js";
26
26
  import { completeWorkflowStep, isTruncatedEvidence, } from "../runtime/runs.js";
@@ -463,7 +463,7 @@ function buildExecContextEnv(args) {
463
463
  function computeUnitInputHash(ctx, item) {
464
464
  return createHash("sha256")
465
465
  .update("akm.workflow.unit\0v7\0")
466
- .update(canonicalJsonString({
466
+ .update(canonicalJson({
467
467
  hashVersion: 7,
468
468
  role: "unit",
469
469
  stepId: ctx.plan.stepId,
@@ -855,20 +855,8 @@ export function unitOutcomeFromRow(unitId, row, hasSchema) {
855
855
  ...(row.tokens !== null ? { tokens: row.tokens } : {}),
856
856
  };
857
857
  }
858
- /** Stable stringify (sorted object keys, recursively) so equal values vote together. */
859
- export function canonicalJson(value) {
860
- return JSON.stringify(sortKeys(value));
861
- }
862
- function sortKeys(value) {
863
- if (Array.isArray(value))
864
- return value.map(sortKeys);
865
- if (value && typeof value === "object") {
866
- return Object.fromEntries(Object.entries(value)
867
- .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
868
- .map(([k, v]) => [k, sortKeys(v)]));
869
- }
870
- return value;
871
- }
858
+ /** Re-exported so existing importers (`tests/workflows/fuzz/*`) keep resolving; canonical impl lives in `../ir/plan-hash`. */
859
+ export { canonicalJson };
872
860
  // ── Gate-feedback recovery (PURE) ────────────────────────────────────────────
873
861
  //
874
862
  // A gate rejection is journaled as `<stepId>.gate:l<loop>` with result_json
@@ -1431,7 +1419,7 @@ export async function finalizeExecutedStep(input) {
1431
1419
  // fields are unchanged).
1432
1420
  inputHash: createHash("sha256")
1433
1421
  .update("akm.workflow.gate\0v7\0")
1434
- .update(canonicalJsonString({
1422
+ .update(canonicalJson({
1435
1423
  hashVersion: 7,
1436
1424
  dispatch: gateTarget,
1437
1425
  invocation: null,
@@ -71,7 +71,22 @@ import { isProcessAlive, isWithinAsync, safeRealpathAsync } from "../../core/com
71
71
  import { serializeByKey } from "../../core/concurrent.js";
72
72
  import { runManagedSubprocess } from "../../core/subprocess.js";
73
73
  import { warn } from "../../core/warn.js";
74
- const GIT_TIMEOUT_MS = 30_000;
74
+ /**
75
+ * Timeout for every `git worktree add|prune|remove|status` call this module
76
+ * makes. Was 30s; raised to 120s after #891 confirmed a real product gap:
77
+ * these calls run under {@link withRepoWorktreeLock}, a per-process,
78
+ * per-repository queue, so a machine also busy with OTHER git-heavy work
79
+ * (other akm runs, other agents' worktrees, a loaded CI runner) can genuinely
80
+ * push one `git worktree remove` past 30s without anything being stuck —
81
+ * plain CPU/fork/IO contention. At 30s that showed up as `{ removed: false,
82
+ * error: "... timed out after 30000ms" }` on a perfectly healthy op: a clean
83
+ * worktree left retained, or a create failing a unit outright, purely because
84
+ * the box was busy. 120s matches the same trade already made for
85
+ * {@link GIT_PUSH_TIMEOUT_MS} (`core/write-source.ts`,
86
+ * `sources/providers/git-stash.ts`) for the same class of administrative git
87
+ * call under load; a call that is truly hung (not just slow) is still caught.
88
+ */
89
+ const GIT_TIMEOUT_MS = 120_000;
75
90
  /** Directory under `os.tmpdir()` that owns every run's worktree roots. */
76
91
  export const WORKTREES_DIR_NAME = "akm-worktrees";
77
92
  /**
@@ -89,12 +104,10 @@ function gitExitError(args, code, stderr, stdout) {
89
104
  return `git ${args.join(" ")} exited ${code}${detail ? `: ${detail}` : ""}`;
90
105
  }
91
106
  /**
92
- * Run one git command asynchronously; `ok` = exit 0. Never throws (spawn
93
- * errors and the 30 s timeout → ok: false). Async so a git lock wait parks a
94
- * promise instead of blocking the event loop; repo-mutating callers must hold
95
- * {@link withRepoWorktreeLock}.
107
+ * Run one git command asynchronously via the real `git` binary; `ok` = exit
108
+ * 0. Never throws (spawn errors and the timeout → ok: false).
96
109
  */
97
- async function git(cwd, args) {
110
+ async function realGitExecutor(cwd, args) {
98
111
  const result = await runManagedSubprocess(["git", "-C", cwd, ...args], {
99
112
  capture: true,
100
113
  timeoutMs: GIT_TIMEOUT_MS,
@@ -114,6 +127,27 @@ async function git(cwd, args) {
114
127
  }
115
128
  return { ok: true, stdout: result.stdout };
116
129
  }
130
+ let gitExecutor = realGitExecutor;
131
+ /**
132
+ * TEST-ONLY seam (#891): swap the executor every repo-mutating git call in
133
+ * this module goes through. Lets a test prove those calls genuinely
134
+ * interleave with other event-loop work — the property the old "count
135
+ * setInterval ticks during real git calls" test asserted before it was
136
+ * deleted for timing on the real scheduler instead of on behavior — using a
137
+ * fake, deterministically-timed async git in place of the real subprocess.
138
+ * Call with `undefined` to restore the real spawn-based executor.
139
+ */
140
+ export function setGitExecutorForTesting(executor) {
141
+ gitExecutor = executor ?? realGitExecutor;
142
+ }
143
+ /**
144
+ * Run one git command asynchronously; `ok` = exit 0. Never throws. Async so a
145
+ * git lock wait parks a promise instead of blocking the event loop;
146
+ * repo-mutating callers must hold {@link withRepoWorktreeLock}.
147
+ */
148
+ async function git(cwd, args) {
149
+ return gitExecutor(cwd, args);
150
+ }
117
151
  /**
118
152
  * Synchronous git for the two read-only probes that run before any unit is in
119
153
  * flight ({@link isGitAvailable}, {@link assertGitWorkTree}). They take no
@@ -20,15 +20,13 @@
20
20
  * full design history, including why re-binding cannot reuse the
21
21
  * shape-driven normalizer (a code-review finding).
22
22
  */
23
+ import { isRecord } from "../../core/common.js";
23
24
  import { UsageError } from "../../core/errors.js";
24
25
  import { validateInputs } from "../../execution/input-contract.js";
25
26
  import { parseReference } from "../program/expressions.js";
26
27
  function inputBindingInvalid(message) {
27
28
  return new UsageError(message, "INPUT_BINDING_INVALID");
28
29
  }
29
- function isPlainObject(value) {
30
- return typeof value === "object" && value !== null && !Array.isArray(value);
31
- }
32
30
  function unknownBindingNameError(stepId, targetRef, name, declaredNames) {
33
31
  return inputBindingInvalid(`Workflow step ${stepId} targets ${targetRef} with.${name}, which is not a declared input. ` +
34
32
  `Declared inputs: ${declaredNames.length > 0 ? declaredNames.join(", ") : "(none)"}.`);
@@ -152,7 +150,7 @@ function finalizeBindings(stepId, targetRef, contract, byName) {
152
150
  * reinterpreted as a literal.
153
151
  */
154
152
  function normalizeOneEntry(stepId, targetRef, name, value, schema, earlierStepIds, declaredParamNames) {
155
- if (!isPlainObject(value) || !Object.hasOwn(value, "from")) {
153
+ if (!isRecord(value) || !Object.hasOwn(value, "from")) {
156
154
  return Object.freeze({ kind: "literal", name, value });
157
155
  }
158
156
  const keys = Object.keys(value);
@@ -25,6 +25,7 @@
25
25
  * Returns accumulated `WorkflowError`s rather than throwing. Pure and
26
26
  * deterministic: the same document always compiles to the same plan.
27
27
  */
28
+ import { compareCodePoints } from "../../core/common.js";
28
29
  import { formatReference, parseReference } from "../program/expressions.js";
29
30
  import { projectExecCore } from "../program/schema.js";
30
31
  import { sourceStepInstructions, sourceStepProgramUnit, sourceStepRef } from "../source-ir/program.js";
@@ -114,9 +115,6 @@ function sortedOutputs(outputs) {
114
115
  }
115
116
  return sorted;
116
117
  }
117
- function compareCodePoints(left, right) {
118
- return left < right ? -1 : left > right ? 1 : 0;
119
- }
120
118
  function compileStep(step, sequenceIndex, defaults, resolved) {
121
119
  const gate = {
122
120
  kind: "gate",
@@ -269,14 +267,12 @@ function checkOutputReference(name, text, check) {
269
267
  * (human + JSON output, via `core/adapter/adapters/akm-lint.ts#
270
268
  * workflowCompileWarnings`) and as `warn()` lines at `workflow run`.
271
269
  *
272
- * A. A unit/map step with NO step-level `output:` schema carries its units'
273
- * raw results as an untyped artifact — permitted, but worth flagging.
274
- * B. A `params.<name>` reference (in `map.over`/`route.input`) to an
270
+ * A. A `params.<name>` reference (in `map.over`/`route.input`) to an
275
271
  * UNDECLARED param, but ONLY when the document declares a `params:`
276
272
  * block — a likely typo. Prose can no longer carry param references at
277
273
  * all (it is never scanned), so this warning's surface shrinks to the
278
274
  * two whole-value fields that can legally contain one.
279
- * C. `gate.max_loops` above 1 on an `exec` step. The engine judges such a
275
+ * B. `gate.max_loops` above 1 on an `exec` step. The engine judges such a
280
276
  * step but never loops it (`exec/step-work.ts#effectiveGateMaxLoops`):
281
277
  * a frozen argv cannot read the judge's feedback, so a second loop would
282
278
  * only re-run the identical command — and its side effects. The declared
@@ -299,13 +295,6 @@ export function collectWorkflowWarnings(input) {
299
295
  `can still fail the step.`,
300
296
  });
301
297
  }
302
- if ((step.map || step.route === undefined) && step.output === undefined) {
303
- warnings.push({
304
- line: step.source.start,
305
- message: `Step "${step.id}" declares no \`output:\` schema — its unit results are carried as an untyped ` +
306
- `artifact (permitted). Add an \`output:\` JSON Schema to type and validate the step artifact.`,
307
- });
308
- }
309
298
  if (declaredParams) {
310
299
  const declaredList = [...declaredParams].join(", ");
311
300
  const scan = (text, label) => {
@@ -10,6 +10,7 @@
10
10
  */
11
11
  import { createHash } from "node:crypto";
12
12
  import path from "node:path";
13
+ import { compareCodePoints } from "../../core/common.js";
13
14
  import { UsageError } from "../../core/errors.js";
14
15
  import { decodeFrozenExecutableIdentity } from "../../execution/executable-identity.js";
15
16
  import { INPUT_NAME_PATTERN } from "../../execution/input-contract.js";
@@ -38,7 +39,7 @@ export const WORKFLOW_IR_V5_VERSION = 5;
38
39
  * freeze-time `COMPOSITION_INVALID` gates live in
39
40
  * `src/workflows/freeze/targets/child-workflow.ts` (Lane B).
40
41
  */
41
- export function decodeWorkflowPlanV4(input, hooks = {}, depth = 0, budget = { embeddedBytes: 0 }) {
42
+ export function decodeWorkflowPlanV4(input, depth = 0, budget = { embeddedBytes: 0 }) {
42
43
  const raw = record(input, "plan");
43
44
  if (raw.irVersion !== WORKFLOW_IR_V5_VERSION)
44
45
  fail("irVersion must be 5");
@@ -51,7 +52,7 @@ export function decodeWorkflowPlanV4(input, hooks = {}, depth = 0, budget = { em
51
52
  planExtraKeys: ["sourceReadSet", "outputs"],
52
53
  unitExtraKeys: ["frozenTarget", "environment"],
53
54
  gateExtraKeys: ["frozenJudge"],
54
- }, hooks);
55
+ });
55
56
  const rawSteps = raw.steps;
56
57
  const stepIds = new Set(rawSteps.map((rawStep) => rawStep.stepId));
57
58
  const requiredSources = [];
@@ -232,7 +233,7 @@ function decodeChildWorkflowTarget(target, unit, depth, budget) {
232
233
  fail(`unit ${unit.id} child workflow contentHash does not match its frozen dispatch`);
233
234
  }
234
235
  const childDepth = depth + 1;
235
- const frozenPlan = decodeWorkflowPlanV4(target.frozenPlan, {}, childDepth, budget);
236
+ const frozenPlan = decodeWorkflowPlanV4(target.frozenPlan, childDepth, budget);
236
237
  const embeddedPlanJson = canonicalJsonLocal(frozenPlan);
237
238
  const actualPlanHash = sha256(embeddedPlanJson);
238
239
  if (actualPlanHash !== planHash) {
@@ -711,9 +712,6 @@ function digest(value, label) {
711
712
  function sha256(value) {
712
713
  return createHash("sha256").update(value).digest("hex");
713
714
  }
714
- function compareCodePoints(left, right) {
715
- return left < right ? -1 : left > right ? 1 : 0;
716
- }
717
715
  function canonicalJsonLocal(value) {
718
716
  const sort = (entry) => {
719
717
  if (Array.isArray(entry))
@@ -1,7 +1,7 @@
1
1
  // This Source Code Form is subject to the terms of the Mozilla Public
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
- import { isContainedRelativePath } from "../../core/common.js";
4
+ import { isContainedRelativePath, isRecord } from "../../core/common.js";
5
5
  import { UsageError } from "../../core/errors.js";
6
6
  import { parseReference } from "../program/expressions.js";
7
7
  import { PROGRAM_PARAM_NAME_PATTERN, PROGRAM_RETRY_REASONS, PROGRAM_STEP_ID_PATTERN } from "../program/schema.js";
@@ -13,7 +13,7 @@ export { WORKFLOW_MAX_CONCURRENCY, WORKFLOW_MAX_TIMEOUT_MS };
13
13
  const MAX_LIST_ITEMS = 1024;
14
14
  const MAX_STRING_LENGTH = 1_000_000;
15
15
  /** Validate the current durable execution graph. */
16
- export function validateWorkflowPlanStructure(input, options, hooks = {}) {
16
+ export function validateWorkflowPlanStructure(input, options) {
17
17
  if (!isRecord(input) || input.irVersion !== options.expectedVersion) {
18
18
  fail(`irVersion must be ${options.expectedVersion}`);
19
19
  }
@@ -351,9 +351,6 @@ function assertString(value, label) {
351
351
  if (typeof value !== "string" || value.length === 0 || value.length > MAX_STRING_LENGTH)
352
352
  fail(`${label} must be a non-empty bounded string`);
353
353
  }
354
- function isRecord(value) {
355
- return typeof value === "object" && value !== null && !Array.isArray(value);
356
- }
357
354
  function fail(message) {
358
355
  throw new UsageError(`Invalid frozen workflow plan: ${message}.`);
359
356
  }