akm-cli 0.9.11 → 0.9.13

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 (134) hide show
  1. package/CHANGELOG.md +227 -0
  2. package/STABILITY.md +6 -1
  3. package/dist/assets/hints/cli-hints-full.md +1 -1
  4. package/dist/assets/improve-strategies/consolidate.json +1 -1
  5. package/dist/assets/improve-strategies/default.json +1 -1
  6. package/dist/assets/improve-strategies/thorough.json +1 -2
  7. package/dist/assets/workflows/workflow-template.md +4 -0
  8. package/dist/cli/shared.js +16 -4
  9. package/dist/cli.js +15 -13
  10. package/dist/commands/agent/agent-dispatch.js +8 -0
  11. package/dist/commands/command/execution-source-loader.js +25 -22
  12. package/dist/commands/command/portable-template.js +4 -26
  13. package/dist/commands/config-cli.js +10 -4
  14. package/dist/commands/env/env-binding.js +10 -3
  15. package/dist/commands/env/env-cli.js +7 -0
  16. package/dist/commands/env/secret-cli.js +15 -4
  17. package/dist/commands/health/checks.js +186 -71
  18. package/dist/commands/health.js +16 -4
  19. package/dist/commands/improve/distill/quality-gate.js +2 -2
  20. package/dist/commands/improve/distill.js +28 -12
  21. package/dist/commands/improve/execution.js +1 -2
  22. package/dist/commands/improve/extract.js +82 -56
  23. package/dist/commands/improve/improve-strategies.js +26 -8
  24. package/dist/commands/improve/improve.js +14 -0
  25. package/dist/commands/improve/preparation.js +9 -6
  26. package/dist/commands/improve/reflect.js +61 -77
  27. package/dist/commands/lint/base-linter.js +10 -0
  28. package/dist/commands/lint/index.js +3 -1
  29. package/dist/commands/migrate-cli.js +6 -4
  30. package/dist/commands/proposal/drain-policies.js +22 -2
  31. package/dist/commands/proposal/drain.js +48 -6
  32. package/dist/commands/proposal/proposal-cli.js +1 -0
  33. package/dist/commands/proposal/repository.js +4 -4
  34. package/dist/commands/proposal/validators/proposal-quality-validators.js +23 -2
  35. package/dist/commands/proposal/validators/proposals.js +10 -19
  36. package/dist/commands/read/show.js +42 -31
  37. package/dist/commands/registry-cli.js +4 -2
  38. package/dist/commands/sources/init.js +4 -8
  39. package/dist/commands/sources/self-update.js +2 -2
  40. package/dist/commands/sources/source-clone.js +5 -7
  41. package/dist/commands/sources/sources-cli.js +3 -5
  42. package/dist/commands/tasks/tasks-cli.js +4 -12
  43. package/dist/commands/tasks/tasks.js +38 -35
  44. package/dist/commands/workflow-cli.js +17 -15
  45. package/dist/core/activation-policy.js +31 -3
  46. package/dist/core/adapter/execution-source.js +39 -11
  47. package/dist/core/asset/stash-meta.js +7 -41
  48. package/dist/core/common.js +8 -17
  49. package/dist/core/config/config-schema.js +3 -23
  50. package/dist/core/config/config-walker.js +56 -6
  51. package/dist/core/config/config.js +42 -17
  52. package/dist/core/config/legacy-source-shape-shim.js +79 -0
  53. package/dist/core/config/schema/embedding.js +2 -2
  54. package/dist/core/config/schema/engines.js +2 -2
  55. package/dist/core/config/schema/index-config.js +19 -21
  56. package/dist/core/config/schema/primitives.js +27 -10
  57. package/dist/core/config/schema/sources-bundles.js +1 -6
  58. package/dist/core/errors.js +4 -3
  59. package/dist/core/improve-types.js +17 -0
  60. package/dist/core/json-schema.js +1 -11
  61. package/dist/core/maintenance-barrier.js +17 -2
  62. package/dist/core/paths.js +12 -15
  63. package/dist/core/state/migrations.js +28 -0
  64. package/dist/core/state-db.js +28 -1
  65. package/dist/core/write-source.js +6 -6
  66. package/dist/indexer/bundle-identity-guard.js +3 -0
  67. package/dist/indexer/ensure-index.js +5 -0
  68. package/dist/indexer/indexer.js +11 -3
  69. package/dist/indexer/lookup/adapter-concept-owner.js +14 -3
  70. package/dist/indexer/passes/metadata.js +16 -5
  71. package/dist/indexer/search/search-fields.js +1 -30
  72. package/dist/integrations/agent/engine-resolution.js +15 -1
  73. package/dist/integrations/agent/model-map.js +16 -10
  74. package/dist/integrations/agent/prompts.js +13 -6
  75. package/dist/integrations/lockfile.js +22 -7
  76. package/dist/llm/client.js +28 -8
  77. package/dist/llm/embedders/remote.js +3 -2
  78. package/dist/llm/index-passes.js +3 -2
  79. package/dist/output/shapes/passthrough.js +9 -3
  80. package/dist/output/shapes.js +50 -3
  81. package/dist/output/text/proposal-format.js +5 -0
  82. package/dist/output/text/workflow-format.js +8 -1
  83. package/dist/scripts/akm-migrate-node.js +1737 -1392
  84. package/dist/scripts/akm-migrate.js +1736 -1391
  85. package/dist/setup/setup.js +14 -21
  86. package/dist/sources/include.js +150 -20
  87. package/dist/sources/providers/git-install.js +14 -12
  88. package/dist/sources/providers/git-provider.js +3 -3
  89. package/dist/sources/snapshot-fetchers/website-ingest.js +54 -16
  90. package/dist/sources/website-url.js +12 -4
  91. package/dist/storage/engines/sqlite-migrations.js +40 -10
  92. package/dist/storage/like-pattern.js +7 -0
  93. package/dist/storage/repositories/extract-sessions-repository.js +23 -0
  94. package/dist/storage/repositories/index-connection.js +27 -10
  95. package/dist/storage/repositories/index-entry-schema.js +19 -2
  96. package/dist/storage/repositories/index-schema.js +30 -9
  97. package/dist/storage/repositories/proposals-repository.js +2 -1
  98. package/dist/storage/repositories/task-history-repository.js +14 -7
  99. package/dist/storage/repositories/workflow-runs-repository.js +133 -11
  100. package/dist/storage/sqlite-read-snapshot.js +11 -9
  101. package/dist/tasks/backends/cron.js +34 -5
  102. package/dist/tasks/backends/launchd.js +23 -26
  103. package/dist/tasks/backends/schtasks.js +50 -3
  104. package/dist/tasks/frozen-script.js +2 -0
  105. package/dist/tasks/prepare/prepare.js +2 -7
  106. package/dist/tasks/prepare/script-capture.js +38 -6
  107. package/dist/tasks/schedule.js +154 -13
  108. package/dist/tasks/source/task-source-v3-frozen.js +0 -1
  109. package/dist/tasks/source/task-source-v4.js +0 -1
  110. package/dist/workflows/exec/child-workflow.js +2 -3
  111. package/dist/workflows/exec/exec-unit.js +3 -4
  112. package/dist/workflows/exec/run-workflow.js +20 -11
  113. package/dist/workflows/exec/step-work.js +76 -56
  114. package/dist/workflows/freeze/resolve-steps.js +19 -11
  115. package/dist/workflows/freeze/source-freeze.js +7 -0
  116. package/dist/workflows/freeze/targets/child-workflow.js +12 -18
  117. package/dist/workflows/freeze/targets/command.js +14 -2
  118. package/dist/workflows/ir/environment-v4.js +4 -2
  119. package/dist/workflows/ir/freeze-v4.js +2 -5
  120. package/dist/workflows/ir/plan-hash.js +0 -3
  121. package/dist/workflows/ir/schema-v4.js +14 -9
  122. package/dist/workflows/ir/schema.js +1 -3
  123. package/dist/workflows/parser.js +1 -1
  124. package/dist/workflows/resource-limits.js +35 -48
  125. package/dist/workflows/runtime/plan-classifier.js +89 -41
  126. package/dist/workflows/runtime/run-outputs.js +1 -21
  127. package/dist/workflows/runtime/runs.js +104 -154
  128. package/dist/workflows/source-files.js +28 -54
  129. package/dist/workflows/source-ir/program.js +2 -2
  130. package/dist/workflows/source-ir/semantics.js +5 -23
  131. package/docs/migration/v0.9.1-to-v0.9.2.md +20 -0
  132. package/docs/reference/cli.md +92 -17
  133. package/package.json +1 -1
  134. package/schemas/akm-config.json +5 -10
@@ -15,12 +15,12 @@ import { createRequire } from "node:module";
15
15
  import { ConfigError } from "../../core/errors.js";
16
16
  import { classifyPathAccess, describeInaccessiblePath } from "../../core/path-access.js";
17
17
  import { getDbPath } from "../../core/paths.js";
18
- import { warn } from "../../core/warn.js";
18
+ import { warn, warnOnce } from "../../core/warn.js";
19
19
  import { openDatabase } from "../database.js";
20
20
  import { openManagedDatabase } from "../managed-db.js";
21
21
  import { SQLITE_BUSY_TIMEOUT_MS } from "../sqlite-pragmas.js";
22
- import { openSqliteReadSnapshot } from "../sqlite-read-snapshot.js";
23
- import { isCanonicalIndexGeneration } from "./index-entry-schema.js";
22
+ import { openSqliteReadSnapshot, SqliteReadSnapshotUnavailableError } from "../sqlite-read-snapshot.js";
23
+ import { CANONICAL_INDEX_DB_VERSION, classifyIndexGeneration, isCanonicalIndexGeneration } from "./index-entry-schema.js";
24
24
  import { ensureSchema } from "./index-schema.js";
25
25
  import { loadVecExtension, warnIfVecMissing } from "./index-vec-repository.js";
26
26
  /**
@@ -130,15 +130,19 @@ export function openExistingDatabase(dbPath) {
130
130
  path: resolvedPath,
131
131
  init: (db) => {
132
132
  loadVecExtension(db);
133
- assertCanonicalIndexGeneration(db, resolvedPath);
133
+ warnIfNonCanonicalIndexGeneration(db, resolvedPath);
134
134
  },
135
135
  create: false,
136
136
  });
137
137
  }
138
- function assertCanonicalIndexGeneration(db, resolvedPath) {
138
+ function warnIfNonCanonicalIndexGeneration(db, resolvedPath) {
139
139
  if (isCanonicalIndexGeneration(db))
140
140
  return;
141
- throw new ConfigError(`Index database uses an incompatible derived schema: ${resolvedPath}.`, "INDEX_SCHEMA_INCOMPATIBLE");
141
+ const classification = classifyIndexGeneration(db);
142
+ warnOnce(`index-read-noncanonical:${resolvedPath}`, `Index database at ${resolvedPath} does not match this akm's derived schema (stored generation ` +
143
+ `${classification.storedVersion ?? "unknown"}; this binary understands ${CANONICAL_INDEX_DB_VERSION}). ` +
144
+ "Reading it as-is; a query that needs a table or column this generation lacks will fail on its own. " +
145
+ "Run 'akm index' to rebuild it for this binary.");
142
146
  }
143
147
  /**
144
148
  * Refuse to treat an UNREADABLE index as a missing one (#791).
@@ -160,6 +164,21 @@ export function assertIndexPathReadable(resolvedPath) {
160
164
  return;
161
165
  throw new ConfigError(`Index database exists but is not readable: ${describeInaccessiblePath(resolvedPath, code)}.`, "DATA_DIR_UNREADABLE");
162
166
  }
167
+ function openPlainReadonly(resolvedPath) {
168
+ return openDatabase(resolvedPath, { readonly: true, create: false });
169
+ }
170
+ function openIsolatedSnapshotOrFallBack(resolvedPath) {
171
+ try {
172
+ return openSqliteReadSnapshot(resolvedPath);
173
+ }
174
+ catch (error) {
175
+ if (!(error instanceof SqliteReadSnapshotUnavailableError))
176
+ throw error;
177
+ warnOnce(`index-read-snapshot-unavailable:${resolvedPath}`, `Could not take a non-mutating snapshot of ${resolvedPath} (${error.message}) — falling back to a plain ` +
178
+ "read-only open of the index database.");
179
+ return openPlainReadonly(resolvedPath);
180
+ }
181
+ }
163
182
  /**
164
183
  * Open an existing index for queries without changing the source database or
165
184
  * running schema initialization. The default path attaches read-only to the
@@ -173,9 +192,7 @@ export function openReadonlyExistingDatabase(dbPath, options) {
173
192
  assertIndexPathReadable(resolvedPath);
174
193
  if (classifyPathAccess(resolvedPath).access === "absent")
175
194
  return undefined;
176
- const db = options?.isolatedSnapshot
177
- ? openSqliteReadSnapshot(resolvedPath)
178
- : openDatabase(resolvedPath, { readonly: true, create: false });
195
+ const db = options?.isolatedSnapshot ? openIsolatedSnapshotOrFallBack(resolvedPath) : openPlainReadonly(resolvedPath);
179
196
  if (!db)
180
197
  return undefined;
181
198
  // This opener bypasses openManagedDatabase/applyStandardPragmas by design (no
@@ -187,7 +204,7 @@ export function openReadonlyExistingDatabase(dbPath, options) {
187
204
  // connection, so apply just that one.
188
205
  try {
189
206
  db.exec(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`);
190
- assertCanonicalIndexGeneration(db, resolvedPath);
207
+ warnIfNonCanonicalIndexGeneration(db, resolvedPath);
191
208
  return db;
192
209
  }
193
210
  catch (error) {
@@ -244,10 +244,27 @@ export function hasCanonicalEntrySchema(db) {
244
244
  return false;
245
245
  }
246
246
  }
247
- export function isCanonicalIndexGeneration(db) {
247
+ export function classifyIndexGeneration(db) {
248
+ let storedVersion;
248
249
  try {
249
250
  const row = db.prepare("SELECT value FROM index_meta WHERE key = 'version'").get();
250
- return row?.value === String(CANONICAL_INDEX_DB_VERSION) && hasCanonicalEntrySchema(db);
251
+ storedVersion = row?.value;
252
+ }
253
+ catch {
254
+ storedVersion = undefined;
255
+ }
256
+ if (storedVersion === String(CANONICAL_INDEX_DB_VERSION) && hasCanonicalEntrySchema(db)) {
257
+ return { status: "canonical", storedVersion };
258
+ }
259
+ const storedNumeric = storedVersion === undefined ? undefined : Number(storedVersion);
260
+ if (storedNumeric !== undefined && Number.isFinite(storedNumeric) && storedNumeric > CANONICAL_INDEX_DB_VERSION) {
261
+ return { status: "newer", storedVersion };
262
+ }
263
+ return { status: "older", storedVersion };
264
+ }
265
+ export function isCanonicalIndexGeneration(db) {
266
+ try {
267
+ return classifyIndexGeneration(db).status === "canonical";
251
268
  }
252
269
  catch {
253
270
  return false;
@@ -1,7 +1,18 @@
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 { CANONICAL_ENTRY_SCHEMA_SQL, CANONICAL_INDEX_DB_VERSION, isCanonicalIndexGeneration, } from "./index-entry-schema.js";
4
+ /**
5
+ * index.db schema and version stamps, kept in the
6
+ * storage layer. This isolates the one genuinely risky area (schema
7
+ * evolution) from the CRUD/FTS/vector queries.
8
+ *
9
+ * The meta accessors, embedding purge, and vec-availability probe that
10
+ * `ensureSchema` leans on live in the sibling `index-meta-repository` /
11
+ * `index-vec-repository` modules.
12
+ */
13
+ import { ConfigError } from "../../core/errors.js";
14
+ import { warn } from "../../core/warn.js";
15
+ import { CANONICAL_ENTRY_SCHEMA_SQL, CANONICAL_INDEX_DB_VERSION, classifyIndexGeneration, isCanonicalIndexGeneration, } from "./index-entry-schema.js";
5
16
  import { getMeta, setMeta } from "./index-meta-repository.js";
6
17
  import { isVecAvailable, purgeEmbeddings } from "./index-vec-repository.js";
7
18
  // ── Constants ───────────────────────────────────────────────────────────────
@@ -165,6 +176,15 @@ function rebuildIncompatibleIndexGeneration(db) {
165
176
  return;
166
177
  if (isCanonicalIndexGeneration(db))
167
178
  return;
179
+ const classification = classifyIndexGeneration(db);
180
+ if (classification.status === "newer") {
181
+ throw new ConfigError(`Index database was built by a newer akm (stored generation ${classification.storedVersion ?? "unknown"}; ` +
182
+ `this binary understands generation ${CANONICAL_INDEX_DB_VERSION}). Refusing to modify it — upgrade akm to ` +
183
+ "write to this index, or delete index.db to rebuild it from scratch with this binary.", "INDEX_SCHEMA_INCOMPATIBLE");
184
+ }
185
+ warn(`Index database generation ${classification.storedVersion ?? "unknown"} is older than this akm's generation ` +
186
+ `${CANONICAL_INDEX_DB_VERSION} — rebuilding the derived index (entries, FTS, embeddings, graph tables, ` +
187
+ "utility scores, and the LLM enrichment cache). This re-walks and re-indexes every source on the next run.");
168
188
  let vecResetPending = false;
169
189
  try {
170
190
  db.exec("DROP TABLE IF EXISTS entries_vec");
@@ -326,12 +346,16 @@ export function ensureSchema(db, embeddingDim) {
326
346
  // - When `embeddingDim` is a number, the caller explicitly asked for
327
347
  // that dim and owns the dim-change/backup/wipe semantics.
328
348
  const dimExplicit = embeddingDim !== undefined;
329
- const effectiveDim = embeddingDim ?? (Number(getMeta(db, "embeddingDim")) || EMBEDDING_DIM);
349
+ const requestedDim = embeddingDim ?? (Number(getMeta(db, "embeddingDim")) || EMBEDDING_DIM);
350
+ const effectiveDim = Number.isInteger(requestedDim) && requestedDim > 0 ? requestedDim : EMBEDDING_DIM;
351
+ if (effectiveDim !== requestedDim) {
352
+ warn(`Invalid embedding dimension ${requestedDim} — falling back to the default (${EMBEDDING_DIM}).`);
353
+ }
330
354
  if (isVecAvailable(db)) {
331
355
  // Check if stored embedding dimension differs from configured one
332
356
  if (dimExplicit) {
333
357
  const storedDim = getMeta(db, "embeddingDim");
334
- if (storedDim && storedDim !== String(embeddingDim)) {
358
+ if (storedDim && storedDim !== String(effectiveDim)) {
335
359
  // Stored vectors are incompatible with the new dimension. Drop the vec
336
360
  // table so the block below recreates it at the new width; the BLOB rows
337
361
  // go too. Regenerable from markdown — re-embedded by the next index.
@@ -340,9 +364,6 @@ export function ensureSchema(db, embeddingDim) {
340
364
  }
341
365
  const vecExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='entries_vec'").get();
342
366
  if (!vecExists) {
343
- if (!Number.isInteger(effectiveDim) || effectiveDim <= 0 || effectiveDim > 4096) {
344
- throw new Error(`Invalid embedding dimension: ${effectiveDim}`);
345
- }
346
367
  db.exec(`
347
368
  CREATE VIRTUAL TABLE entries_vec USING vec0(
348
369
  id INTEGER PRIMARY KEY,
@@ -351,7 +372,7 @@ export function ensureSchema(db, embeddingDim) {
351
372
  `);
352
373
  }
353
374
  if (dimExplicit) {
354
- setMeta(db, "embeddingDim", String(embeddingDim));
375
+ setMeta(db, "embeddingDim", String(effectiveDim));
355
376
  }
356
377
  }
357
378
  else {
@@ -361,11 +382,11 @@ export function ensureSchema(db, embeddingDim) {
361
382
  // changes, those stored BLOBs become silently incompatible.
362
383
  if (dimExplicit) {
363
384
  const storedDim = getMeta(db, "embeddingDim");
364
- if (storedDim && storedDim !== String(embeddingDim)) {
385
+ if (storedDim && storedDim !== String(effectiveDim)) {
365
386
  // JS-fallback path: no vec table, just clear the stale BLOB vectors.
366
387
  purgeEmbeddings(db);
367
388
  }
368
- setMeta(db, "embeddingDim", String(embeddingDim));
389
+ setMeta(db, "embeddingDim", String(effectiveDim));
369
390
  }
370
391
  }
371
392
  // Usage telemetry (usage_events) lives in state.db since Chunk-8 WI-8.3 —
@@ -13,6 +13,7 @@ import path from "node:path";
13
13
  import { stashDirFor } from "../../core/asset/asset-placement.js";
14
14
  import { bundleRefToString, isBundleSlug, parseBundleRef } from "../../core/asset/asset-ref.js";
15
15
  import { warnOnce } from "../../core/warn.js";
16
+ import { escapeLikePattern } from "../like-pattern.js";
16
17
  /** Serialize `Proposal.changes` for `metadata_json` (see {@link StoredFileChange}). */
17
18
  function changesToStored(changes) {
18
19
  return changes.map((c, i) => ({
@@ -390,7 +391,7 @@ export function getStateProposal(db, id, stashDir) {
390
391
  * `%` / `_` / `\` in the prefix are escaped so the LIKE pattern is literal.
391
392
  */
392
393
  export function listStateProposalIdsByPrefix(db, stashDir, idPrefix) {
393
- const escaped = idPrefix.replace(/[\\%_]/g, (ch) => `\\${ch}`);
394
+ const escaped = escapeLikePattern(idPrefix);
394
395
  const rows = db
395
396
  .prepare(`SELECT id FROM proposals
396
397
  WHERE stash_dir = ? AND status = 'pending' AND id LIKE ? ESCAPE '\\'
@@ -10,6 +10,7 @@
10
10
  * @module task-history-repository
11
11
  */
12
12
  import { isRecord } from "../../core/common.js";
13
+ import { warnOnce } from "../../core/warn.js";
13
14
  function metadataError(message) {
14
15
  throw new Error(`invalid task_history metadata_json: ${message}`);
15
16
  }
@@ -18,10 +19,6 @@ function validateDetail(value) {
18
19
  return;
19
20
  if (!isRecord(value))
20
21
  metadataError("detail must be an object or null");
21
- const allowed = new Set(["runId", "reason", "error", "exitCode"]);
22
- const unknown = Object.keys(value).filter((key) => !allowed.has(key));
23
- if (unknown.length > 0)
24
- metadataError(`unknown detail fields: ${unknown.sort().join(", ")}`);
25
22
  for (const field of ["runId", "reason", "error"]) {
26
23
  if (value[field] !== undefined && typeof value[field] !== "string")
27
24
  metadataError(`detail.${field} must be a string`);
@@ -60,7 +57,8 @@ export function decodeTaskHistoryMetadata(input) {
60
57
  if (!isRecord(parsed))
61
58
  metadataError("root must be an object");
62
59
  if (parsed.metadataVersion !== undefined && parsed.metadataVersion !== 2) {
63
- metadataError(`unsupported metadataVersion: ${String(parsed.metadataVersion)}`);
60
+ warnOnce(`task-history-metadata-version:${String(parsed.metadataVersion)}`, `task_history row has metadataVersion ${String(parsed.metadataVersion)}, newer than this akm's 2 — ` +
61
+ "decoding it best-effort as version 2 rather than rejecting the row.");
64
62
  }
65
63
  if (typeof parsed.durationMs !== "number")
66
64
  metadataError("durationMs must be a number");
@@ -69,13 +67,22 @@ export function decodeTaskHistoryMetadata(input) {
69
67
  metadataError("engine must be a string or null");
70
68
  }
71
69
  if (parsed.targetVocab !== undefined && parsed.targetVocab !== 2) {
72
- metadataError("targetVocab must be 2 when present");
70
+ warnOnce(`task-history-target-vocab:${String(parsed.targetVocab)}`, `task_history row has targetVocab ${String(parsed.targetVocab)}, newer than this akm's 2 falling back to ` +
71
+ "the legacy target_kind mapping rather than rejecting the row.");
73
72
  }
74
73
  validateDetail(detail);
74
+ const cleanDetail = detail
75
+ ? {
76
+ ...(detail.runId !== undefined ? { runId: detail.runId } : {}),
77
+ ...(detail.reason !== undefined ? { reason: detail.reason } : {}),
78
+ ...(detail.error !== undefined ? { error: detail.error } : {}),
79
+ ...(detail.exitCode !== undefined ? { exitCode: detail.exitCode } : {}),
80
+ }
81
+ : null;
75
82
  return {
76
83
  metadataVersion: 2,
77
84
  durationMs: parsed.durationMs,
78
- detail: detail ?? null,
85
+ detail: cleanDetail,
79
86
  ...(parsed.engine !== undefined ? { engine: parsed.engine } : {}),
80
87
  ...(parsed.targetVocab === 2 ? { targetVocab: 2 } : {}),
81
88
  };
@@ -2,9 +2,11 @@
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import { randomUUID } from "node:crypto";
5
- import { UsageError } from "../../core/errors.js";
5
+ import { NotFoundError, UsageError } from "../../core/errors.js";
6
6
  import { openStateDatabase, withImmediateTransaction } from "../../core/state-db.js";
7
7
  import { borrowScopedStateDb, withStateDbScope } from "../../core/state-db-scope.js";
8
+ import { sleepSync } from "../../runtime.js";
9
+ import { escapeLikePattern } from "../like-pattern.js";
8
10
  import { resolveStorageLocations } from "../locations.js";
9
11
  import { insertEventOnce, insertEventStrict } from "./events-repository.js";
10
12
  function assertAttemptReservationLease(input, run) {
@@ -20,6 +22,51 @@ function assertAttemptReservationLease(input, run) {
20
22
  throw new UsageError(`Workflow run ${input.runId} engine lease expired before durable dispatch reservation.`, "RESOURCE_ALREADY_EXISTS");
21
23
  }
22
24
  }
25
+ /**
26
+ * Whether `error` is one of the specific SQLite conditions a run-lease
27
+ * statement can throw under real cross-process contention on the same row:
28
+ * `SQLITE_BUSY`/`SQLITE_LOCKED` (both drivers), or the message text a
29
+ * transient contention blip has been observed producing, "database is
30
+ * locked", "disk I/O error", or "database disk image is malformed".
31
+ * Matching on this set alone is never sufficient to call something lease
32
+ * contention — see {@link WorkflowRunsRepository.acquireEngineLease}, which
33
+ * additionally requires a fresh read confirming a live lease before
34
+ * substituting the lease-held message for the original error.
35
+ */
36
+ function isLeaseContentionSqliteError(error) {
37
+ const code = error?.code;
38
+ if (code === "SQLITE_BUSY" || code === "SQLITE_LOCKED")
39
+ return true;
40
+ const message = error instanceof Error ? error.message : String(error);
41
+ return (message.includes("database is locked") ||
42
+ message.includes("disk I/O error") ||
43
+ message.includes("database disk image is malformed"));
44
+ }
45
+ const LEASE_RETRY_ATTEMPTS = 4;
46
+ const LEASE_RETRY_BASE_DELAY_MS = 15;
47
+ /**
48
+ * Retry a single lease statement across a short, bounded set of attempts when
49
+ * it throws one of {@link isLeaseContentionSqliteError}'s conditions —
50
+ * absorbing a blip that a fresh attempt on the same connection clears on its
51
+ * own. Any other error, or the same error surviving every attempt, propagates
52
+ * unchanged; this never converts a persistent failure into a false success.
53
+ */
54
+ function runLeaseStatementWithRetry(fn) {
55
+ let lastError;
56
+ for (let attempt = 0; attempt < LEASE_RETRY_ATTEMPTS; attempt += 1) {
57
+ try {
58
+ return fn();
59
+ }
60
+ catch (error) {
61
+ if (!isLeaseContentionSqliteError(error))
62
+ throw error;
63
+ lastError = error;
64
+ if (attempt < LEASE_RETRY_ATTEMPTS - 1)
65
+ sleepSync(LEASE_RETRY_BASE_DELAY_MS * 2 ** attempt);
66
+ }
67
+ }
68
+ throw lastError;
69
+ }
23
70
  /**
24
71
  * Repository owning every raw SQL statement against `workflow_runs` and
25
72
  * `workflow_run_steps`. It is DB-location-agnostic: the lifecycle helper
@@ -96,6 +143,19 @@ export class WorkflowRunsRepository {
96
143
  const row = this.db.prepare("SELECT 1 FROM workflow_runs WHERE id = ? LIMIT 1").get(runId);
97
144
  return !!row;
98
145
  }
146
+ /** The one run id starting with `prefix` (#919); `UsageError` on several, `NotFoundError` on none. */
147
+ resolveRunIdPrefix(prefix) {
148
+ const escaped = escapeLikePattern(prefix);
149
+ const rows = this.db
150
+ .prepare("SELECT id FROM workflow_runs WHERE id LIKE ? ESCAPE '\\' ORDER BY id ASC")
151
+ .all(`${escaped}%`);
152
+ if (rows.length === 1)
153
+ return rows[0].id;
154
+ if (rows.length > 1) {
155
+ throw new UsageError(`Ambiguous workflow run id prefix "${prefix}" — matches: ${rows.map((r) => r.id).join(", ")}`, "INVALID_FLAG_VALUE");
156
+ }
157
+ throw new NotFoundError(`Workflow run "${prefix}" not found.`, "WORKFLOW_NOT_FOUND");
158
+ }
99
159
  listRuns(filter) {
100
160
  const filters = [];
101
161
  const params = [];
@@ -351,25 +411,57 @@ export class WorkflowRunsRepository {
351
411
  * A live lease held by anyone (including a stale copy of the same holder)
352
412
  * is NOT reclaimable through this method; the single UPDATE is the whole
353
413
  * claim, so two racing invocations cannot both win.
414
+ *
415
+ * The UPDATE can throw instead of cleanly returning `changes: 0` under real
416
+ * cross-process contention on this row: a `SQLITE_BUSY`/`SQLITE_LOCKED`
417
+ * from two engines racing the same statement, occasionally surfacing as
418
+ * "database is locked" or even "database disk image is malformed" text that
419
+ * reads as corruption but is not. `runLeaseStatementWithRetry` absorbs a
420
+ * blip that a fresh attempt clears on its own. If it is still failing after
421
+ * every retry, the row is read fresh (a plain SELECT, far less likely to
422
+ * trip whatever the write hit) to get independent evidence of what is
423
+ * actually going on: a live lease there means this really was contention,
424
+ * so the caller gets the same lease-held message `akm workflow run` already
425
+ * shows for the clean (non-throwing) case, now with `RUN_LEASE_HELD`. No
426
+ * live lease — or the verifying read itself fails — means the error was
427
+ * never actually about the lease, so it is rethrown exactly as raised.
428
+ * Nothing here invents a diagnosis from error text alone or suppresses a
429
+ * genuine SQLite failure.
354
430
  */
355
431
  acquireEngineLease(runId, holder, until, now) {
356
- const result = this.db
357
- .prepare(`UPDATE workflow_runs
358
- SET engine_lease_holder = ?, engine_lease_until = ?
359
- WHERE id = ? AND status = 'active'
360
- AND (engine_lease_holder IS NULL OR engine_lease_until IS NULL OR engine_lease_until < ?)`)
361
- .run(holder, until, runId, now);
362
- return Number(result.changes) > 0;
432
+ try {
433
+ const result = runLeaseStatementWithRetry(() => this.db
434
+ .prepare(`UPDATE workflow_runs
435
+ SET engine_lease_holder = ?, engine_lease_until = ?
436
+ WHERE id = ? AND status = 'active'
437
+ AND (engine_lease_holder IS NULL OR engine_lease_until IS NULL OR engine_lease_until < ?)`)
438
+ .run(holder, until, runId, now));
439
+ return Number(result.changes) > 0;
440
+ }
441
+ catch (error) {
442
+ if (!isLeaseContentionSqliteError(error))
443
+ throw error;
444
+ const row = this.tryReadLeaseColumns(runId);
445
+ if (row?.engine_lease_holder && row.engine_lease_until && row.engine_lease_until >= now) {
446
+ throw new UsageError(`Workflow run ${runId} is already being driven by engine ${row.engine_lease_holder} ` +
447
+ `(run lease expires ${row.engine_lease_until}). A second \`akm workflow run\` would race it — ` +
448
+ `wait for that invocation to finish or for the lease to expire.`, "RUN_LEASE_HELD");
449
+ }
450
+ throw error;
451
+ }
363
452
  }
364
453
  /**
365
454
  * Extend the lease expiry — only while `holder` still owns it. Returns
366
455
  * false when the lease was lost (expired and claimed by another engine),
367
- * so the caller can stop driving instead of racing the new owner.
456
+ * so the caller can stop driving instead of racing the new owner. Wrapped
457
+ * in the same transient-error retry as {@link acquireEngineLease}; a
458
+ * renewal that still fails after retries is rethrown as-is (no confirmed
459
+ * "lost lease" diagnosis to substitute, unlike the acquire case above).
368
460
  */
369
461
  renewEngineLease(runId, holder, until) {
370
- const result = this.db
462
+ const result = runLeaseStatementWithRetry(() => this.db
371
463
  .prepare("UPDATE workflow_runs SET engine_lease_until = ? WHERE id = ? AND engine_lease_holder = ? AND status = 'active'")
372
- .run(until, runId, holder);
464
+ .run(until, runId, holder));
373
465
  return Number(result.changes) > 0;
374
466
  }
375
467
  /**
@@ -382,6 +474,36 @@ export class WorkflowRunsRepository {
382
474
  .prepare("UPDATE workflow_runs SET engine_lease_holder = NULL, engine_lease_until = NULL WHERE id = ? AND engine_lease_holder = ? AND status <> 'failed'")
383
475
  .run(runId, holder);
384
476
  }
477
+ /**
478
+ * Self-heal an engine lease its holder crashed without releasing: once
479
+ * `engine_lease_until` has passed, clear it so a read (`workflow status`,
480
+ * `workflow list`) stops reporting a run as engine-driven when the engine is
481
+ * long gone — mirroring the maintenance barrier's self-reclaim of a wedged
482
+ * sentinel (`tryAcquireMaintenanceBarrier`) rather than a bespoke mechanism.
483
+ * The WHERE clause repeats the exact (holder, until) snapshot the caller
484
+ * read, so a lease renewed or re-acquired in between never gets clobbered —
485
+ * same compare-and-swap shape as the claim above. Never touches a lease
486
+ * that is still live.
487
+ */
488
+ reclaimExpiredEngineLease(runId, holder, until, now) {
489
+ if (until >= now)
490
+ return false;
491
+ const result = this.db
492
+ .prepare(`UPDATE workflow_runs
493
+ SET engine_lease_holder = NULL, engine_lease_until = NULL
494
+ WHERE id = ? AND engine_lease_holder = ? AND engine_lease_until = ? AND engine_lease_until < ?`)
495
+ .run(runId, holder, until, now);
496
+ return Number(result.changes) > 0;
497
+ }
498
+ /** Best-effort lease-column read used only to confirm genuine contention after {@link acquireEngineLease} exhausts its retries. `undefined` on any failure — never a diagnosis, just "couldn't confirm". */
499
+ tryReadLeaseColumns(runId) {
500
+ try {
501
+ return (this.db.prepare("SELECT engine_lease_holder, engine_lease_until FROM workflow_runs WHERE id = ?").get(runId) ?? undefined);
502
+ }
503
+ catch {
504
+ return undefined;
505
+ }
506
+ }
385
507
  // ── durable v4 append-only dispatch attempts (migration 022) ─────────────
386
508
  getUnitAttempts(runId, unitId) {
387
509
  return this.db
@@ -13,7 +13,10 @@
13
13
  import fs from "node:fs";
14
14
  import os from "node:os";
15
15
  import path from "node:path";
16
+ import { sleepSync } from "../runtime.js";
16
17
  import { openDatabaseFinalizing } from "./database.js";
18
+ const SNAPSHOT_MAX_ATTEMPTS = 8;
19
+ const SNAPSHOT_BACKOFF_MS = 25;
17
20
  export class SqliteReadSnapshotUnavailableError extends Error {
18
21
  constructor(message) {
19
22
  super(message);
@@ -67,19 +70,17 @@ function fingerprintsEqual(left, right) {
67
70
  export function openSqliteReadSnapshot(dbPath) {
68
71
  if (!pathExists(dbPath))
69
72
  return undefined;
70
- if (pathExists(`${dbPath}-journal`)) {
71
- throw new SqliteReadSnapshotUnavailableError("an active SQLite rollback journal is present; a non-mutating point-in-time snapshot is unavailable");
72
- }
73
73
  const snapshotDir = fs.mkdtempSync(path.join(os.tmpdir(), "akm-sqlite-read-"));
74
74
  const snapshotPath = path.join(snapshotDir, "snapshot.db");
75
75
  let db;
76
76
  try {
77
77
  let copied = false;
78
- for (let attempt = 0; attempt < 3; attempt++) {
78
+ for (let attempt = 0; attempt < SNAPSHOT_MAX_ATTEMPTS; attempt++) {
79
+ if (attempt > 0)
80
+ sleepSync(SNAPSHOT_BACKOFF_MS * attempt);
79
81
  try {
80
- if (pathExists(`${dbPath}-journal`)) {
81
- throw new SqliteReadSnapshotUnavailableError("an active SQLite rollback journal appeared while taking the non-mutating snapshot");
82
- }
82
+ if (pathExists(`${dbPath}-journal`))
83
+ continue;
83
84
  const before = databaseFingerprint(dbPath);
84
85
  fs.copyFileSync(dbPath, snapshotPath);
85
86
  if (before.wal)
@@ -87,7 +88,7 @@ export function openSqliteReadSnapshot(dbPath) {
87
88
  else
88
89
  fs.rmSync(`${snapshotPath}-wal`, { force: true });
89
90
  const after = databaseFingerprint(dbPath);
90
- if (fingerprintsEqual(before, after)) {
91
+ if (fingerprintsEqual(before, after) && !pathExists(`${dbPath}-journal`)) {
91
92
  copied = true;
92
93
  break;
93
94
  }
@@ -99,7 +100,8 @@ export function openSqliteReadSnapshot(dbPath) {
99
100
  }
100
101
  }
101
102
  if (!copied) {
102
- throw new SqliteReadSnapshotUnavailableError("SQLite main/WAL files kept changing while taking the non-mutating snapshot");
103
+ throw new SqliteReadSnapshotUnavailableError(`SQLite main/WAL files did not settle after ${SNAPSHOT_MAX_ATTEMPTS} attempts with backoff — ` +
104
+ "a writer may be continuously active, or a hot rollback journal never cleared");
103
105
  }
104
106
  db = openDatabaseFinalizing(snapshotPath, { readonly: true, create: false });
105
107
  const closeSnapshot = db.close.bind(db);
@@ -29,6 +29,7 @@
29
29
  //
30
30
  // Tests inject a fake exec so unit tests don't touch the real crontab.
31
31
  import { spawnSync } from "node:child_process";
32
+ import { createHash } from "node:crypto";
32
33
  import path from "node:path";
33
34
  import { ConfigError } from "../../core/errors.js";
34
35
  import { getTaskLogDir } from "../../core/paths.js";
@@ -60,9 +61,16 @@ export function CRON_BACKEND(options = {}) {
60
61
  // Create the log directory before writing the crontab line — cron
61
62
  // appends with `>>` and the surrounding shell will fail the entire
62
63
  // entry if the parent directory doesn't exist.
63
- const cronLine = buildCronLine(task, [...(opts?.binding ?? akmArgv)], logDir, opts?.contextPath ?? defaultContextPath, opts?.target);
64
+ const cronLineParts = buildCronLineParts(task, [...(opts?.binding ?? akmArgv)], logDir, opts?.contextPath ?? defaultContextPath, opts?.target);
65
+ const cronLine = cronLineParts.line;
64
66
  assertPortableCronLine(cronLine);
65
67
  fsLike.ensureDir(logDir);
68
+ if (cronLineParts.wrapper) {
69
+ if (!fsLike.writeFile) {
70
+ throw new ConfigError("Cron backend needs to write a wrapper script for this task's long invocation, but the configured filesystem cannot write files.", "INVALID_CONFIG_FILE");
71
+ }
72
+ fsLike.writeFile(cronLineParts.wrapper.path, cronLineParts.wrapper.content);
73
+ }
66
74
  const existing = readCrontab(exec);
67
75
  const nativeId = schedulerBindingNativeId(task);
68
76
  const blocks = listBlocks(existing);
@@ -245,14 +253,35 @@ function isCronBindingSnapshot(value) {
245
253
  value.kind === CRON_SNAPSHOT &&
246
254
  typeof value.crontab === "string");
247
255
  }
248
- // ── helpers (exported for tests) ────────────────────────────────────────────
249
- export function buildCronLine(task, akmArgv, logDir, contextPath, _target) {
256
+ function buildCronLineParts(task, akmArgv, logDir, contextPath, _target) {
250
257
  const spec = parseSchedule(task.cron, "cron");
251
258
  const cronExpr = translateToCron(spec);
252
- const logPath = path.join(logDir, `${schedulerBindingNativeId(task)}.log`);
259
+ const nativeId = schedulerBindingNativeId(task);
260
+ const logPath = path.join(logDir, `${nativeId}.log`);
253
261
  const invocation = buildScheduledBindingInvocation(akmArgv, contextPath, task.invocation);
254
262
  const cmd = invocation.argv.map((part) => quoteForCron(part)).join(" ");
255
- return `${cronExpr} ${cmd} >> ${quoteForCron(logPath)} 2>&1`;
263
+ const directLine = `${cronExpr} ${cmd} >> ${quoteForCron(logPath)} 2>&1`;
264
+ if (Buffer.byteLength(directLine, "utf8") <= PORTABLE_CRON_LINE_LIMIT) {
265
+ return { line: directLine };
266
+ }
267
+ const content = cronWrapperScriptContent(invocation.argv);
268
+ const contentHash = createHash("sha256").update(content).digest("hex").slice(0, 16);
269
+ const wrapperPath = path.join(logDir, `${CRON_WRAPPER_PREFIX}${nativeId}-${contentHash}.sh`);
270
+ const line = `${cronExpr} sh ${quoteForCron(wrapperPath)} >> ${quoteForCron(logPath)} 2>&1`;
271
+ return { line, wrapper: { path: wrapperPath, content } };
272
+ }
273
+ const CRON_WRAPPER_PREFIX = ".akm-cron-wrapper-";
274
+ function quoteForShellScript(part) {
275
+ if (/^[A-Za-z0-9_\-./@:=+,]+$/.test(part))
276
+ return part;
277
+ return `'${part.replace(/'/g, `'\\''`)}'`;
278
+ }
279
+ function cronWrapperScriptContent(argv) {
280
+ const cmd = argv.map(quoteForShellScript).join(" ");
281
+ return `#!/bin/sh\nexec ${cmd}\n`;
282
+ }
283
+ export function buildCronLine(task, akmArgv, logDir, contextPath, _target) {
284
+ return buildCronLineParts(task, akmArgv, logDir, contextPath, _target).line;
256
285
  }
257
286
  /** The crontab line as it appears inside a block — commented when disabled. */
258
287
  export function cronBlockBody(cronLine, enabled, executionEvidenceDigest) {