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
@@ -83,6 +83,75 @@ export function readTextFileWithLimit(filePath, maxBytes, label = "File") {
83
83
  * don't fail on those mounts. Windows does not support opening a
84
84
  * directory for fsync, so the directory-sync step is skipped there.
85
85
  */
86
+ /**
87
+ * Strip JavaScript-style comments from a JSON string (JSONC support).
88
+ * Handles `//` line comments and `/* *​/` block comments while preserving
89
+ * comment-like sequences inside quoted strings.
90
+ */
91
+ export function stripJsonComments(text) {
92
+ let result = "";
93
+ let i = 0;
94
+ let inString = false;
95
+ while (i < text.length) {
96
+ if (inString) {
97
+ if (text[i] === "\\") {
98
+ result += text[i] + (text[i + 1] ?? "");
99
+ i += 2;
100
+ continue;
101
+ }
102
+ if (text[i] === '"') {
103
+ inString = false;
104
+ }
105
+ result += text[i];
106
+ i++;
107
+ continue;
108
+ }
109
+ if (text[i] === '"') {
110
+ inString = true;
111
+ result += text[i];
112
+ i++;
113
+ continue;
114
+ }
115
+ if (text[i] === "/" && text[i + 1] === "/") {
116
+ while (i < text.length && text[i] !== "\n")
117
+ i++;
118
+ continue;
119
+ }
120
+ if (text[i] === "/" && text[i + 1] === "*") {
121
+ i += 2;
122
+ while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
123
+ i++;
124
+ i += 2;
125
+ continue;
126
+ }
127
+ result += text[i];
128
+ i++;
129
+ }
130
+ return result;
131
+ }
132
+ /**
133
+ * The mode to rewrite an existing USER-owned file with.
134
+ *
135
+ * {@link writeFileAtomic} creates its temp file with an explicit mode and
136
+ * chmods it, so it needs one — and its 0600 default is right for akm's own
137
+ * state but wrong for a user's asset, where rewriting must never change
138
+ * permissions. Returns the file's current mode, or the umask-derived default
139
+ * `fs.writeFileSync` would have produced for a new file.
140
+ */
141
+ export function existingFileMode(filePath) {
142
+ try {
143
+ return fs.statSync(filePath).mode & 0o777;
144
+ }
145
+ catch {
146
+ // Absent (a new asset) or unreadable: fall back to the default create mode.
147
+ try {
148
+ return 0o666 & ~process.umask();
149
+ }
150
+ catch {
151
+ return 0o644;
152
+ }
153
+ }
154
+ }
86
155
  export function writeFileAtomic(target, content, mode) {
87
156
  const tmp = `${target}.tmp.${process.pid}.${crypto.randomBytes(8).toString("hex")}`;
88
157
  const data = typeof content === "string" ? Buffer.from(content) : content;
@@ -226,7 +295,11 @@ function readStashDirFromConfig() {
226
295
  try {
227
296
  const configPath = getConfigPath();
228
297
  const text = readTextFileWithLimit(configPath, MAX_CONFIG_FILE_BYTES, "Config file");
229
- const raw = JSON.parse(text);
298
+ // The config loader accepts JSONC, so a commented config.json is valid and
299
+ // in use. Parsing it raw here threw, the catch swallowed it, and every
300
+ // caller silently fell back — operating on the wrong bundle or failing with
301
+ // STASH_DIR_NOT_FOUND despite a perfectly good config.
302
+ const raw = JSON.parse(stripJsonComments(text));
230
303
  if (typeof raw !== "object" || raw === null)
231
304
  return undefined;
232
305
  // 0.9.0 config-shape cutover (spec §10.1): the primary stash is the
@@ -292,13 +365,49 @@ export function hasErrnoCode(error, code) {
292
365
  return false;
293
366
  return error.code === code;
294
367
  }
368
+ /**
369
+ * True when `value` is a RELATIVE path that cannot leave its base directory:
370
+ * no absolute form (POSIX `/`, Windows `\` or a `C:` drive prefix), no `~`
371
+ * home expansion, and no `..` segment under either separator.
372
+ *
373
+ * This is the SYNTACTIC half of containment — cheap, string-only, usable at
374
+ * authoring time before any directory exists. It is deliberately paired with
375
+ * (never a substitute for) {@link isWithin}, which resolves symlinks against a
376
+ * real base at use time. Workflow `exec` units run both: the parser and the
377
+ * frozen-plan decoder reject uncontained spellings, and the executor re-checks
378
+ * the resolved path before spawning.
379
+ */
380
+ export function isContainedRelativePath(value) {
381
+ if (value === "" || value.startsWith("/") || value.startsWith("\\") || value.startsWith("~"))
382
+ return false;
383
+ if (/^[A-Za-z]:/.test(value))
384
+ return false;
385
+ return !value.split(/[/\\]+/).includes("..");
386
+ }
295
387
  export function isWithin(candidate, root) {
296
- const resolvedRoot = safeRealpath(root);
297
- const resolvedCandidate = safeRealpath(candidate);
298
- const normalizedRoot = normalizeFsPathForComparison(resolvedRoot);
299
- const normalizedCandidate = normalizeFsPathForComparison(resolvedCandidate);
300
- const rel = path.relative(normalizedRoot, normalizedCandidate);
301
- return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
388
+ return isContainedResolvedPath(safeRealpath(candidate), safeRealpath(root));
389
+ }
390
+ /**
391
+ * {@link isWithin} for callers that must not block the event loop (e.g. the
392
+ * workflow exec dispatch path, which runs once per fan-out unit). Same
393
+ * comparison, same normalization, same nearest-existing-ancestor fallback —
394
+ * only the realpath syscalls are awaited.
395
+ */
396
+ export async function isWithinAsync(candidate, root) {
397
+ return isContainedResolvedPath(await safeRealpathAsync(candidate), await safeRealpathAsync(root));
398
+ }
399
+ /** The containment comparison shared by {@link isWithin} and {@link isWithinAsync}. */
400
+ function isContainedResolvedPath(resolvedCandidate, resolvedRoot) {
401
+ const rel = path.relative(normalizeFsPathForComparison(resolvedRoot), normalizeFsPathForComparison(resolvedCandidate));
402
+ if (rel === "")
403
+ return true;
404
+ if (path.isAbsolute(rel))
405
+ return false;
406
+ // Compare the first SEGMENT, not a string prefix: `..data` and `...v2` are
407
+ // legal directory names, and only a leading `..` segment means the candidate
408
+ // climbed out of the root. Both separators, because `path.relative` answers
409
+ // in the host's spelling while callers may hold either.
410
+ return rel.split(/[/\\]+/)[0] !== "..";
302
411
  }
303
412
  /**
304
413
  * Resolve symlinks on `p`, walking up to the closest existing ancestor when
@@ -335,6 +444,30 @@ export function safeRealpath(p) {
335
444
  }
336
445
  }
337
446
  }
447
+ /** {@link safeRealpath}'s async twin — awaited syscalls, identical walk-up. */
448
+ export async function safeRealpathAsync(p) {
449
+ const resolved = path.resolve(p);
450
+ try {
451
+ return await fs.promises.realpath(resolved);
452
+ }
453
+ catch {
454
+ const suffix = [];
455
+ let current = resolved;
456
+ for (;;) {
457
+ const parent = path.dirname(current);
458
+ if (parent === current)
459
+ return resolved;
460
+ suffix.unshift(path.basename(current));
461
+ current = parent;
462
+ try {
463
+ return path.join(await fs.promises.realpath(current), ...suffix);
464
+ }
465
+ catch {
466
+ // parent also doesn't exist; keep walking up
467
+ }
468
+ }
469
+ }
470
+ }
338
471
  function normalizeFsPathForComparison(value) {
339
472
  return process.platform === "win32" ? value.toLowerCase() : value;
340
473
  }
@@ -718,14 +851,19 @@ export function stringArray(value) {
718
851
  * Return true if a process with the given PID is currently alive.
719
852
  * Uses `process.kill(pid, 0)` which does not deliver a signal but
720
853
  * throws ESRCH when the process does not exist.
854
+ *
855
+ * EPERM means the process EXISTS but belongs to another uid, so it must be
856
+ * reported alive. Treating it as dead let a lock held by a live process in a
857
+ * shared data dir (agent sandboxes, containers, service accounts — a
858
+ * configuration managed-db.ts explicitly supports) be reclaimed as stale.
721
859
  */
722
860
  export function isProcessAlive(pid) {
723
861
  try {
724
862
  process.kill(pid, 0);
725
863
  return true;
726
864
  }
727
- catch {
728
- return false;
865
+ catch (err) {
866
+ return err?.code === "EPERM";
729
867
  }
730
868
  }
731
869
  /**
@@ -1,6 +1,32 @@
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
+ /**
5
+ * Serialize `fn` behind every task previously enqueued under `key`: an
6
+ * in-process keyed promise chain (Bun is single-threaded, so this is a
7
+ * sufficient — and free — admission control for per-key mutual exclusion).
8
+ *
9
+ * `chains` is the caller's own module-state map, so independent subsystems
10
+ * (the unit-writer's per-database write queue, the worktree module's per-repo
11
+ * git lock) never share chains. A failed task rejects its OWN caller but
12
+ * never wedges the chain, and a drained tail deletes its map entry so a
13
+ * long-lived process does not retain one settled promise per key it ever
14
+ * touched.
15
+ */
16
+ export function serializeByKey(chains, key, fn) {
17
+ const tail = chains.get(key) ?? Promise.resolve();
18
+ const run = tail.then(() => fn());
19
+ // Keep the chain alive regardless of individual outcomes.
20
+ const settled = run.then(() => undefined, () => undefined);
21
+ chains.set(key, settled);
22
+ // If another task was enqueued in the meantime the map now holds ITS tail,
23
+ // and this check leaves it alone.
24
+ void settled.then(() => {
25
+ if (chains.get(key) === settled)
26
+ chains.delete(key);
27
+ });
28
+ return run;
29
+ }
4
30
  /**
5
31
  * Maps over items concurrently with a pool size limit.
6
32
  * Uses Promise.allSettled semantics — one failure does not cancel others.
@@ -11,6 +37,12 @@
11
37
  * preempt those too). Unclaimed items stay `undefined` in the result,
12
38
  * indistinguishable from individual failures by design: callers already
13
39
  * treat `undefined` as "no result".
40
+ *
41
+ * A thrown `fn` is SWALLOWED (its slot stays `undefined`) — a caller that
42
+ * must report failure detail, or distinguish "threw" from "never claimed",
43
+ * catches inside `fn` and returns an explicit outcome value instead. This is
44
+ * the OPPOSITE of {@link serializeByKey} above, whose failures reject their
45
+ * own caller.
14
46
  */
15
47
  export async function concurrentMap(items, fn, concurrency = 1, opts) {
16
48
  const results = new Array(items.length).fill(undefined);
@@ -15,7 +15,7 @@
15
15
  import fs from "node:fs";
16
16
  import path from "node:path";
17
17
  import { sleepSync } from "../../runtime.js";
18
- import { MAX_CONFIG_FILE_BYTES, readTextFileWithLimit, writeFileAtomic } from "../common.js";
18
+ import { MAX_CONFIG_FILE_BYTES, readTextFileWithLimit, stripJsonComments, writeFileAtomic } from "../common.js";
19
19
  import { ConfigError } from "../errors.js";
20
20
  import { createLockPayload, probeLock, reclaimStaleLock, releaseLock, tryAcquireLockSync } from "../file-lock.js";
21
21
  import { getCacheDir, getConfigDir } from "../paths.js";
@@ -233,48 +233,8 @@ export function withConfigLock(fn) {
233
233
  }
234
234
  }
235
235
  /**
236
- * Strip JavaScript-style comments from a JSON string (JSONC support).
237
- * Handles `//` line comments and `/* *​/` block comments while preserving
238
- * comment-like sequences inside quoted strings.
236
+ * Re-exported from core/common.ts, where it now lives so `resolveStashDir`
237
+ * can strip comments too without importing this module (common cannot depend
238
+ * on config-io config-io already depends on common).
239
239
  */
240
- export function stripJsonComments(text) {
241
- let result = "";
242
- let i = 0;
243
- let inString = false;
244
- while (i < text.length) {
245
- if (inString) {
246
- if (text[i] === "\\") {
247
- result += text[i] + (text[i + 1] ?? "");
248
- i += 2;
249
- continue;
250
- }
251
- if (text[i] === '"') {
252
- inString = false;
253
- }
254
- result += text[i];
255
- i++;
256
- continue;
257
- }
258
- if (text[i] === '"') {
259
- inString = true;
260
- result += text[i];
261
- i++;
262
- continue;
263
- }
264
- if (text[i] === "/" && text[i + 1] === "/") {
265
- while (i < text.length && text[i] !== "\n")
266
- i++;
267
- continue;
268
- }
269
- if (text[i] === "/" && text[i + 1] === "*") {
270
- i += 2;
271
- while (i < text.length && !(text[i] === "*" && text[i + 1] === "/"))
272
- i++;
273
- i += 2;
274
- continue;
275
- }
276
- result += text[i];
277
- i++;
278
- }
279
- return result;
280
- }
240
+ export { stripJsonComments } from "../common.js";
@@ -14,7 +14,18 @@ import { z } from "zod";
14
14
  // `typeof import("./config-schema")` — routing through config-types would mint
15
15
  // a config-schema ↔ config-types type cycle that collapses inference.
16
16
  import { HARNESS_AGENT_DISPATCH_IDS, VALID_HARNESS_IDS } from "../../../integrations/harnesses/ids.js";
17
+ import { WORKFLOW_MAX_TIMEOUT_MS } from "../../../workflows/resource-limits.js";
17
18
  import { chatCompletionsEndpoint, ENV_REFERENCE_PATTERN, ExtraParamsSchema, engineName, LlmCapabilitiesSchema, ModelAliasMapSchema, nonEmptyString, positiveInt, } from "./primitives.js";
19
+ /**
20
+ * Engine-config timeouts share the workflow ceiling.
21
+ *
22
+ * 0.9.1 bounded workflow-authored timeouts (parser) and frozen invocations
23
+ * (decoder) at 2^31-1, but the third source — `engines.<name>.timeoutMs` — was
24
+ * validated only as a positive integer. A larger value passed config validation
25
+ * and then failed EVERY run of that engine with an unlocated "Invalid frozen
26
+ * workflow plan: invocation is invalid". Reject it where the value is written.
27
+ */
28
+ const timeoutMsField = z.union([positiveInt.max(WORKFLOW_MAX_TIMEOUT_MS), z.null()]).optional();
18
29
  // ── Connection configs (LLM) ────────────────────────────────────────────────
19
30
  /**
20
31
  * OpenAI-compatible connection fields shared by named LLM engines and bounded
@@ -32,7 +43,7 @@ export const LlmConnectionConfigSchema = z
32
43
  apiKey: z.string().optional(),
33
44
  temperature: z.number().finite().optional(),
34
45
  maxTokens: positiveInt.optional(),
35
- timeoutMs: z.union([positiveInt, z.null()]).optional(),
46
+ timeoutMs: timeoutMsField,
36
47
  concurrency: positiveInt.optional(),
37
48
  capabilities: LlmCapabilitiesSchema.optional(),
38
49
  extraParams: ExtraParamsSchema.optional(),
@@ -56,7 +67,7 @@ const LlmEngineSchema = z
56
67
  apiKey: z.string().regex(ENV_REFERENCE_PATTERN, `apiKey must be $VAR or \${VAR}`).optional(),
57
68
  temperature: z.number().finite().optional(),
58
69
  maxTokens: positiveInt.optional(),
59
- timeoutMs: z.union([positiveInt, z.null()]).optional(),
70
+ timeoutMs: timeoutMsField,
60
71
  concurrency: positiveInt.optional(),
61
72
  supportsJsonSchema: z.boolean().optional(),
62
73
  extraParams: ExtraParamsSchema.optional(),
@@ -80,7 +91,7 @@ const AgentEngineSchema = z
80
91
  args: z.array(z.string()).optional(),
81
92
  workspace: nonEmptyString.optional(),
82
93
  model: nonEmptyString.optional(),
83
- timeoutMs: z.union([positiveInt, z.null()]).optional(),
94
+ timeoutMs: timeoutMsField,
84
95
  modelAliases: ModelAliasMapSchema.optional(),
85
96
  llmEngine: engineName.optional(),
86
97
  })
@@ -19,10 +19,21 @@ import { engineName, positiveInt } from "./primitives.js";
19
19
  * `[1, WORKFLOW_MAX_CONCURRENCY_CEILING]` (64). Values above the ceiling
20
20
  * are clamped, not rejected, so a config shared across machines with wildly
21
21
  * different core counts never hard-fails validation.
22
+ *
23
+ * `defaultMapConcurrency` is the width a `map` step freezes when it declares no
24
+ * `concurrency:` of its own:
25
+ * - UNSET → `DEFAULT_MAP_CONCURRENCY` (4) from
26
+ * `src/workflows/concurrency-policy.ts`.
27
+ * - SET → the explicit positive integer, CLAMPED to `[1, 64]`. Setting it
28
+ * to `1` restores the pre-0.9.1 serial-by-default fan-out for every
29
+ * workflow on this install. It is a floor for authoring only: it never
30
+ * raises a step above `maxConcurrency`, the engine's concurrency, or the
31
+ * host CPU cap, and it never overrides an authored `map.concurrency`.
22
32
  */
23
33
  export const WorkflowConfigSchema = z
24
34
  .object({
25
35
  maxConcurrency: positiveInt.optional(),
36
+ defaultMapConcurrency: positiveInt.optional(),
26
37
  /** Named LLM or agent engine frozen into every criteria-bearing gate. */
27
38
  judgeEngine: engineName.optional(),
28
39
  })
@@ -9,6 +9,7 @@ const CONFIG_HINTS = {
9
9
  STASH_DIR_NOT_FOUND: "Run `akm setup` to create and configure your bundle, or configure a defaultBundle path.",
10
10
  STASH_DIR_NOT_A_DIRECTORY: "The configured default bundle path exists but isn't a directory. Update it to point at a folder.",
11
11
  STASH_DIR_UNREADABLE: "Check the path exists and your user has read permission, or update the default bundle path.",
12
+ DATA_DIR_UNREADABLE: "The data directory is not readable by the user running akm. Check its owner and mode, or point AKM_DATA_DIR / XDG_DATA_HOME somewhere this user owns.",
12
13
  EMBEDDING_NOT_CONFIGURED: 'Run `akm config set embedding \'{"endpoint":"...","model":"..."}\'` to enable embeddings.',
13
14
  LLM_NOT_CONFIGURED: 'Run `akm setup` or configure an `engines` entry with `kind: "llm"`, then select it with `defaults.llmEngine`.',
14
15
  TEST_ISOLATION_MISSING: "Under bun test, when AKM_BUNDLE_DIR is set you MUST also set XDG_DATA_HOME (or AKM_DATA_DIR) and XDG_STATE_HOME (or AKM_STATE_DIR) to temp directories so the test does not touch the developer's real ~/.local/share/akm or ~/.local/state/akm.",
@@ -131,3 +132,27 @@ export function rethrowIfTestIsolationError(err) {
131
132
  throw err;
132
133
  }
133
134
  }
135
+ /**
136
+ * Unreadable-data-dir guard helper — the #791 sibling of the test-isolation
137
+ * pair above, and it exists for the same reason.
138
+ *
139
+ * `DATA_DIR_UNREADABLE` says "this path is there and I am not allowed to read
140
+ * it". It is raised by `assertIndexPathReadable` and friends precisely so a
141
+ * permission fault stops being indistinguishable from "nothing indexed yet".
142
+ * That distinction is destroyed again the moment a best-effort `catch` around
143
+ * the open collapses it into the same `null`/`[]`/`0` the absent case returns —
144
+ * which is how `akm search` came to answer "No search index available. Run
145
+ * 'akm index'" at exit 0 for a populated index sitting right there on disk.
146
+ *
147
+ * Call `rethrowIfDataDirUnreadable(err)` from any catch block that returns a
148
+ * fallback value after touching a data-dir path. Absent stays absent; a fault
149
+ * the operator has to fix keeps travelling.
150
+ */
151
+ export function isDataDirUnreadableError(err) {
152
+ return err instanceof ConfigError && err.code === "DATA_DIR_UNREADABLE";
153
+ }
154
+ export function rethrowIfDataDirUnreadable(err) {
155
+ if (isDataDirUnreadableError(err)) {
156
+ throw err;
157
+ }
158
+ }
@@ -4,6 +4,7 @@
4
4
  import { insertEvent, readStateEvents } from "../storage/repositories/events-repository.js";
5
5
  import { rethrowIfTestIsolationError } from "./errors.js";
6
6
  import { getStateDbPath, openStateDatabase, withStateDb } from "./state-db.js";
7
+ import { borrowScopedStateDb } from "./state-db-scope.js";
7
8
  import { error } from "./warn.js";
8
9
  /**
9
10
  * Resolve the state.db path from context:
@@ -28,36 +29,41 @@ function resolveNow(ctx) {
28
29
  * function writes directly to that handle without opening or closing the DB.
29
30
  * This eliminates per-event open/migrate/close overhead for high-frequency
30
31
  * callers such as `akmImprove`.
32
+ *
33
+ * The same fast path is taken IMPLICITLY inside a `withStateDbScope` /
34
+ * `withWorkflowRunsConnection` scope (`core/state-db-scope.ts`): the ambient
35
+ * scoped handle for this event's resolved `dbPath` is borrowed, so a workflow
36
+ * step's `workflow_unit_started` / `workflow_unit_finished` pair rides the same
37
+ * connection its journal rows do instead of opening state.db twice per unit.
38
+ * The scope owns that handle's lifetime; `appendEvent` never closes a borrowed
39
+ * connection.
31
40
  */
32
41
  export function appendEvent(input, ctx) {
33
42
  const now = resolveNow(ctx);
34
43
  const ts = new Date(now()).toISOString();
35
- // Fast path: caller provided a long-lived connection use it directly.
36
- if (ctx?.db) {
37
- try {
38
- insertEvent(ctx.db, {
39
- eventType: input.eventType,
40
- ts,
41
- ref: input.ref,
42
- metadata: input.metadata,
43
- });
44
+ const row = { eventType: input.eventType, ts, ref: input.ref, metadata: input.metadata };
45
+ // One try covers EVERY path — including resolving the state.db path, which
46
+ // reads the environment and throws where no data dir can be derived — so the
47
+ // best-effort contract ("a write failure never propagates") holds no matter
48
+ // which handle this event lands on.
49
+ try {
50
+ // Fast path: an explicitly supplied long-lived connection. Resolution is
51
+ // skipped outright, which is what "`dbPath` is ignored when `db` is
52
+ // provided" has to mean for a caller that already holds an open handle.
53
+ if (ctx?.db) {
54
+ insertEvent(ctx.db, row);
55
+ return;
44
56
  }
45
- catch (err) {
46
- error(`akm: appendEvent failed: ${String(err)}`);
57
+ const dbPath = resolveDbPath(ctx);
58
+ // The ambient scoped handle for this path, when a scope is open — borrowed
59
+ // exactly like the explicit one, and never closed here.
60
+ const borrowed = borrowScopedStateDb(dbPath);
61
+ if (borrowed) {
62
+ insertEvent(borrowed, row);
63
+ return;
47
64
  }
48
- return;
49
- }
50
- // Default path: open, insert, close.
51
- const dbPath = resolveDbPath(ctx);
52
- try {
53
- withStateDb((db) => {
54
- insertEvent(db, {
55
- eventType: input.eventType,
56
- ts,
57
- ref: input.ref,
58
- metadata: input.metadata,
59
- });
60
- }, { path: dbPath });
65
+ // Default path: open, insert, close.
66
+ withStateDb((db) => insertEvent(db, row), { path: dbPath });
61
67
  }
62
68
  catch (err) {
63
69
  // Never mask the bun-test isolation guard as a silent "events failed".
@@ -33,8 +33,16 @@ export function validateExtraParams(value) {
33
33
  return [{ path: [], message: "must be an object" }];
34
34
  }
35
35
  const issues = [];
36
+ // A self-referential YAML anchor (`extraParams: &a { nested: *a }`) resolves
37
+ // to a genuinely cyclic object — the yaml package's alias-count guard does not
38
+ // catch cycles — so an unguarded walk overflowed the stack with a RangeError
39
+ // that escaped task parsing. Track visited containers and stop at a revisit.
40
+ const seen = new WeakSet();
36
41
  const visit = (entry, path) => {
37
42
  if (Array.isArray(entry)) {
43
+ if (seen.has(entry))
44
+ return;
45
+ seen.add(entry);
38
46
  entry.forEach((child, index) => {
39
47
  visit(child, [...path, index]);
40
48
  });
@@ -42,6 +50,9 @@ export function validateExtraParams(value) {
42
50
  }
43
51
  if (!entry || typeof entry !== "object")
44
52
  return;
53
+ if (seen.has(entry))
54
+ return;
55
+ seen.add(entry);
45
56
  for (const [key, child] of Object.entries(entry)) {
46
57
  const normalized = normalizeExtraParamKey(key);
47
58
  if (path.length === 0 && PROTECTED_TOP_LEVEL_KEYS.has(normalized)) {
@@ -139,7 +139,13 @@ export function probeLock(lockPath, opts) {
139
139
  try {
140
140
  snapshot = readLockSnapshot(lockPath);
141
141
  }
142
- catch {
142
+ catch (error) {
143
+ // A permission error means the lock may be genuinely HELD by someone we
144
+ // cannot see. Reporting it as stale would invite a caller to steal a live
145
+ // lease (#791) — `unreadable` stays for the corrupt/undecodable case.
146
+ const code = error?.code;
147
+ if (code === "EACCES" || code === "EPERM")
148
+ return { state: "inaccessible", code };
143
149
  return { state: "stale", reason: "unreadable" };
144
150
  }
145
151
  if (!snapshot)
@@ -81,7 +81,11 @@ export function txnFileHash(filePath) {
81
81
  return txnHash(fs.readFileSync(filePath));
82
82
  }
83
83
  export function fsyncTxnFile(filePath) {
84
- const fd = fs.openSync(filePath, "r");
84
+ // Open for WRITE. Windows implements fsync as FlushFileBuffers, which
85
+ // requires write access on the handle — a read-only descriptor fails with
86
+ // EACCES/EPERM, so every proposal accept and reject failed on that platform.
87
+ // POSIX accepts "r+" here just as readily as "r".
88
+ const fd = fs.openSync(filePath, "r+");
85
89
  try {
86
90
  fs.fsyncSync(fd);
87
91
  }
@@ -91,7 +95,16 @@ export function fsyncTxnFile(filePath) {
91
95
  }
92
96
  export function fsyncTxnDir(dirPath) {
93
97
  try {
94
- fsyncTxnFile(dirPath);
98
+ // Read-only, unlike {@link fsyncTxnFile}: a directory cannot be opened for
99
+ // write on POSIX (EISDIR), and on Windows this whole operation is
100
+ // unsupported anyway and falls into the catch.
101
+ const fd = fs.openSync(dirPath, "r");
102
+ try {
103
+ fs.fsyncSync(fd);
104
+ }
105
+ finally {
106
+ fs.closeSync(fd);
107
+ }
95
108
  }
96
109
  catch {
97
110
  // Directory fsync is unavailable on some platforms.
@@ -40,6 +40,7 @@ const COMMON_FIELDS = [
40
40
  "cycleMetrics",
41
41
  "runId",
42
42
  "sync",
43
+ "writtenPaths",
43
44
  "terminated",
44
45
  ];
45
46
  const V2_FIELDS = new Set([...COMMON_FIELDS, "strategy", "strategyFilteredRefs"]);
@@ -83,10 +84,14 @@ function validateCommon(value) {
83
84
  "extract",
84
85
  "coverageGaps",
85
86
  "deadUrls",
87
+ "writtenPaths",
86
88
  ]) {
87
89
  if (value[field] !== undefined && !Array.isArray(value[field]))
88
90
  fail(`${field} must be an array`);
89
91
  }
92
+ if (Array.isArray(value.writtenPaths) && value.writtenPaths.some((entry) => typeof entry !== "string")) {
93
+ fail("writtenPaths must be an array of strings");
94
+ }
90
95
  for (const field of [
91
96
  "cyclesRun",
92
97
  "evalCasesWritten",