akm-cli 0.9.10 → 0.9.12

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 (145) hide show
  1. package/CHANGELOG.md +226 -0
  2. package/STABILITY.md +28 -15
  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/cli/shared.js +16 -4
  8. package/dist/cli.js +15 -13
  9. package/dist/commands/agent/agent-dispatch.js +8 -0
  10. package/dist/commands/command/execution-source-loader.js +25 -22
  11. package/dist/commands/command/portable-template.js +4 -26
  12. package/dist/commands/config-cli.js +10 -4
  13. package/dist/commands/env/env-binding.js +10 -3
  14. package/dist/commands/env/env-cli.js +7 -0
  15. package/dist/commands/env/secret-cli.js +15 -4
  16. package/dist/commands/health/checks.js +209 -78
  17. package/dist/commands/health/improve-metrics.js +12 -0
  18. package/dist/commands/health.js +16 -4
  19. package/dist/commands/improve/distill/quality-gate.js +15 -7
  20. package/dist/commands/improve/distill.js +28 -12
  21. package/dist/commands/improve/eval-cases.js +9 -2
  22. package/dist/commands/improve/execution.js +1 -2
  23. package/dist/commands/improve/extract.js +82 -56
  24. package/dist/commands/improve/improve-strategies.js +26 -8
  25. package/dist/commands/improve/improve.js +32 -4
  26. package/dist/commands/improve/loop-stages.js +13 -3
  27. package/dist/commands/improve/preparation.js +9 -6
  28. package/dist/commands/improve/reflect.js +61 -77
  29. package/dist/commands/lint/index.js +3 -1
  30. package/dist/commands/migrate-cli.js +6 -4
  31. package/dist/commands/proposal/drain-policies.js +22 -2
  32. package/dist/commands/proposal/repository.js +4 -4
  33. package/dist/commands/proposal/validators/proposal-quality-validators.js +23 -2
  34. package/dist/commands/proposal/validators/proposals.js +10 -19
  35. package/dist/commands/read/show.js +42 -31
  36. package/dist/commands/registry-cli.js +4 -2
  37. package/dist/commands/sources/init.js +4 -8
  38. package/dist/commands/sources/self-update.js +2 -2
  39. package/dist/commands/sources/source-clone.js +5 -7
  40. package/dist/commands/sources/sources-cli.js +3 -5
  41. package/dist/commands/tasks/tasks-cli.js +36 -12
  42. package/dist/commands/tasks/tasks.js +38 -35
  43. package/dist/commands/tasks/validate.js +186 -0
  44. package/dist/commands/url-checker.js +75 -16
  45. package/dist/commands/workflow-cli.js +17 -15
  46. package/dist/core/activation-policy.js +31 -3
  47. package/dist/core/adapter/execution-source.js +39 -11
  48. package/dist/core/asset/stash-meta.js +7 -41
  49. package/dist/core/bundle-id.js +7 -1
  50. package/dist/core/common.js +8 -17
  51. package/dist/core/config/config-schema.js +3 -23
  52. package/dist/core/config/config-walker.js +50 -4
  53. package/dist/core/config/config.js +21 -5
  54. package/dist/core/config/legacy-source-shape-shim.js +79 -0
  55. package/dist/core/config/schema/embedding.js +2 -2
  56. package/dist/core/config/schema/engines.js +19 -2
  57. package/dist/core/config/schema/index-config.js +19 -21
  58. package/dist/core/config/schema/primitives.js +21 -10
  59. package/dist/core/config/schema/sources-bundles.js +1 -6
  60. package/dist/core/errors.js +2 -3
  61. package/dist/core/improve-result.js +8 -0
  62. package/dist/core/improve-types.js +17 -0
  63. package/dist/core/json-schema.js +1 -11
  64. package/dist/core/maintenance-barrier.js +17 -2
  65. package/dist/core/paths.js +124 -15
  66. package/dist/core/state/migrations.js +28 -0
  67. package/dist/core/state-db.js +28 -1
  68. package/dist/core/write-source.js +6 -6
  69. package/dist/indexer/bundle-identity-guard.js +3 -0
  70. package/dist/indexer/ensure-index.js +5 -0
  71. package/dist/indexer/indexer.js +11 -3
  72. package/dist/indexer/lookup/adapter-concept-owner.js +14 -3
  73. package/dist/indexer/passes/metadata.js +16 -5
  74. package/dist/indexer/search/search-fields.js +1 -30
  75. package/dist/indexer/search/search-source.js +3 -2
  76. package/dist/integrations/agent/engine-resolution.js +107 -4
  77. package/dist/integrations/agent/execution-lowering.js +15 -2
  78. package/dist/integrations/agent/model-map.js +16 -10
  79. package/dist/integrations/agent/prompts.js +13 -6
  80. package/dist/integrations/agent/runner-dispatch.js +16 -3
  81. package/dist/integrations/agent/runner.js +2 -0
  82. package/dist/integrations/lockfile.js +22 -7
  83. package/dist/llm/client.js +16 -0
  84. package/dist/llm/index-passes.js +3 -2
  85. package/dist/output/shapes/passthrough.js +10 -3
  86. package/dist/output/shapes.js +5 -3
  87. package/dist/output/text/workflow-format.js +8 -1
  88. package/dist/scripts/akm-migrate-node.js +2028 -1613
  89. package/dist/scripts/akm-migrate.js +2027 -1612
  90. package/dist/setup/setup.js +14 -21
  91. package/dist/sources/include.js +150 -20
  92. package/dist/sources/providers/git-install.js +14 -12
  93. package/dist/sources/providers/git-provider.js +3 -3
  94. package/dist/sources/snapshot-fetchers/website-ingest.js +54 -16
  95. package/dist/sources/website-url.js +12 -4
  96. package/dist/storage/engines/sqlite-migrations.js +40 -10
  97. package/dist/storage/like-pattern.js +7 -0
  98. package/dist/storage/repositories/extract-sessions-repository.js +23 -0
  99. package/dist/storage/repositories/index-connection.js +27 -10
  100. package/dist/storage/repositories/index-entry-schema.js +19 -2
  101. package/dist/storage/repositories/index-schema.js +30 -9
  102. package/dist/storage/repositories/proposals-repository.js +2 -1
  103. package/dist/storage/repositories/task-history-repository.js +14 -7
  104. package/dist/storage/repositories/workflow-runs-repository.js +15 -1
  105. package/dist/storage/sqlite-read-snapshot.js +11 -9
  106. package/dist/tasks/backends/cron.js +34 -5
  107. package/dist/tasks/backends/launchd.js +23 -26
  108. package/dist/tasks/backends/schtasks.js +50 -3
  109. package/dist/tasks/frozen-script.js +2 -0
  110. package/dist/tasks/prepare/prepare.js +2 -7
  111. package/dist/tasks/prepare/script-capture.js +38 -6
  112. package/dist/tasks/schedule.js +154 -13
  113. package/dist/tasks/scheduler-sync.js +51 -25
  114. package/dist/tasks/source/task-source-v3-frozen.js +0 -1
  115. package/dist/tasks/source/task-source-v4.js +0 -1
  116. package/dist/workflows/exec/child-workflow.js +2 -3
  117. package/dist/workflows/exec/dispatch-redaction.js +21 -7
  118. package/dist/workflows/exec/exec-unit.js +3 -4
  119. package/dist/workflows/exec/run-workflow.js +19 -10
  120. package/dist/workflows/exec/step-work.js +35 -56
  121. package/dist/workflows/freeze/resolve-steps.js +19 -11
  122. package/dist/workflows/freeze/source-freeze.js +7 -0
  123. package/dist/workflows/freeze/targets/child-workflow.js +12 -18
  124. package/dist/workflows/freeze/targets/command.js +14 -2
  125. package/dist/workflows/ir/environment-v4.js +4 -2
  126. package/dist/workflows/ir/freeze-v4.js +2 -5
  127. package/dist/workflows/ir/plan-hash.js +0 -3
  128. package/dist/workflows/ir/schema-v4.js +14 -9
  129. package/dist/workflows/ir/schema.js +1 -3
  130. package/dist/workflows/resource-limits.js +35 -48
  131. package/dist/workflows/runtime/plan-classifier.js +89 -41
  132. package/dist/workflows/runtime/run-outputs.js +1 -21
  133. package/dist/workflows/runtime/runs.js +76 -150
  134. package/dist/workflows/source-files.js +28 -54
  135. package/dist/workflows/source-ir/program.js +2 -2
  136. package/dist/workflows/source-ir/semantics.js +5 -23
  137. package/docs/integration/bundling-akm.md +1 -1
  138. package/docs/migration/v0.8-to-v0.9.md +32 -0
  139. package/docs/migration/v0.9.1-to-v0.9.2.md +20 -0
  140. package/docs/reference/cli.md +105 -22
  141. package/docs/reference/configuration.md +12 -2
  142. package/docs/reference/data-and-telemetry.md +1 -1
  143. package/docs/reference/tasks.md +8 -0
  144. package/package.json +1 -1
  145. package/schemas/akm-config.json +11 -8
@@ -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,10 @@
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 { escapeLikePattern } from "../like-pattern.js";
8
9
  import { resolveStorageLocations } from "../locations.js";
9
10
  import { insertEventOnce, insertEventStrict } from "./events-repository.js";
10
11
  function assertAttemptReservationLease(input, run) {
@@ -96,6 +97,19 @@ export class WorkflowRunsRepository {
96
97
  const row = this.db.prepare("SELECT 1 FROM workflow_runs WHERE id = ? LIMIT 1").get(runId);
97
98
  return !!row;
98
99
  }
100
+ /** The one run id starting with `prefix` (#919); `UsageError` on several, `NotFoundError` on none. */
101
+ resolveRunIdPrefix(prefix) {
102
+ const escaped = escapeLikePattern(prefix);
103
+ const rows = this.db
104
+ .prepare("SELECT id FROM workflow_runs WHERE id LIKE ? ESCAPE '\\' ORDER BY id ASC")
105
+ .all(`${escaped}%`);
106
+ if (rows.length === 1)
107
+ return rows[0].id;
108
+ if (rows.length > 1) {
109
+ throw new UsageError(`Ambiguous workflow run id prefix "${prefix}" — matches: ${rows.map((r) => r.id).join(", ")}`, "INVALID_FLAG_VALUE");
110
+ }
111
+ throw new NotFoundError(`Workflow run "${prefix}" not found.`, "WORKFLOW_NOT_FOUND");
112
+ }
99
113
  listRuns(filter) {
100
114
  const filters = [];
101
115
  const params = [];
@@ -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) {
@@ -28,6 +28,7 @@ import launchdTemplate from "../../assets/backends/launchd-template.xml" with {
28
28
  import { hasErrnoCode } from "../../core/common.js";
29
29
  import { ConfigError } from "../../core/errors.js";
30
30
  import { getTaskLogDir } from "../../core/paths.js";
31
+ import { warn } from "../../core/warn.js";
31
32
  import { resolveAkmInvocation } from "../resolve-akm-bin.js";
32
33
  import { parseSchedule, translateToLaunchd } from "../schedule.js";
33
34
  import { assertSchedulerExecutionEvidenceDigest, assertSchedulerExpectationIdentity, assertSchedulerMutationArtifact, assertSchedulerNativeArtifactCardinality, assertSchedulerNativeArtifactOwner, assertSchedulerRemovalArtifact, assertSchedulerRollbackArtifactCardinality, schedulerBindingNativeId, schedulerLogicalBindingId, schedulerLogicalBindingOwner, schedulerNativeArtifactKey, } from "../scheduler-binding.js";
@@ -514,13 +515,7 @@ function inspectStableLaunchdNamespace(seedIds, context) {
514
515
  throw new ConfigError(`launchctl failed to enumerate the loaded user domain during scheduler state inspection: ${domain.stderr || domain.stdout || "no output"}.`, "INVALID_CONFIG_FILE");
515
516
  }
516
517
  const loadedLabels = parseLaunchdLoadedLabels(domain.stdout);
517
- if (loadedLabels === undefined) {
518
- throw new ConfigError("launchctl returned an unsafe, unsupported, or oversized loaded-service inventory during scheduler state inspection.", "INVALID_CONFIG_FILE");
519
- }
520
518
  const disabledLabels = readDisabledLabels(context.exec);
521
- if (disabledLabels === undefined) {
522
- throw new ConfigError("launchctl print-disabled failed during scheduler state inspection.", "INVALID_CONFIG_FILE");
523
- }
524
519
  const akmDisabledLabels = [...disabledLabels].filter((label) => label.startsWith(LAUNCHD_LABEL_PREFIX)).sort();
525
520
  const plistEntries = [];
526
521
  if (context.fsLike.exists(context.agentsDir)) {
@@ -834,48 +829,50 @@ function normalizeSignature(xml) {
834
829
  * labels we will track. Exceeding either still returns `undefined`.
835
830
  */
836
831
  export function parseLaunchdLoadedLabels(output) {
837
- if (Buffer.byteLength(output, "utf8") > MAX_LAUNCHD_DOMAIN_OUTPUT_BYTES)
838
- return undefined;
839
832
  const labels = new Set();
840
833
  // Our own namespace is the only thing we look for. `[^\s"{}=,()]` stops the
841
834
  // token at whatever punctuation the surrounding launchctl syntax uses, so a
842
835
  // label works whether it appears as a bare table cell, a quoted string, or a
843
836
  // dictionary key.
844
837
  const labelPattern = /com\.akm\.task\.[^\s"{}=,()]+/gu;
845
- for (const match of output.matchAll(labelPattern)) {
838
+ for (const match of boundLaunchdOutput(output).matchAll(labelPattern)) {
846
839
  const label = match[0];
847
840
  if (!LAUNCHD_AKM_LABEL_RE.test(label))
848
841
  continue;
849
842
  labels.add(label);
850
- if (labels.size > MAX_LAUNCHD_AKM_NAMESPACE_ENTRIES)
851
- return undefined;
843
+ if (labels.size >= MAX_LAUNCHD_AKM_NAMESPACE_ENTRIES)
844
+ break;
852
845
  }
853
846
  return labels;
854
847
  }
848
+ function boundLaunchdOutput(output) {
849
+ return output.length > MAX_LAUNCHD_DOMAIN_OUTPUT_BYTES ? output.slice(0, MAX_LAUNCHD_DOMAIN_OUTPUT_BYTES) : output;
850
+ }
855
851
  function readDisabledLabels(exec) {
856
852
  try {
857
853
  const result = exec.run(["launchctl", "print-disabled", `gui/${exec.uid()}`]);
858
- if (result.status !== 0)
859
- return undefined;
854
+ if (result.status !== 0) {
855
+ warn("[akm] launchctl print-disabled exited %d; assuming no akm task is disabled.", result.status);
856
+ return new Set();
857
+ }
860
858
  return parseDisabledLabels(result.stdout);
861
859
  }
862
- catch {
863
- return undefined;
860
+ catch (error) {
861
+ warn("[akm] launchctl print-disabled could not be run; assuming no akm task is disabled: %s", error instanceof Error ? error.message : String(error));
862
+ return new Set();
864
863
  }
865
864
  }
866
865
  function parseDisabledLabels(output) {
867
- const envelope = /^\s*disabled services\s*=\s*\{([\s\S]*)\}\s*$/.exec(output);
868
- if (!envelope)
869
- return undefined;
870
866
  const disabled = new Set();
871
- let body = envelope[1];
872
- while (body.trim()) {
873
- const entry = /^\s*"([^"\r\n]+)"\s*=>\s*(true|false|enabled|disabled)\s*/.exec(body);
874
- if (!entry)
875
- return undefined;
876
- if (entry[2] === "true" || entry[2] === "disabled")
877
- disabled.add(entry[1]);
878
- body = body.slice(entry[0].length);
867
+ const entryPattern = /"(com\.akm\.task\.[^"\r\n]+)"\s*=>\s*(true|false|enabled|disabled)/gu;
868
+ for (const match of boundLaunchdOutput(output).matchAll(entryPattern)) {
869
+ const label = match[1];
870
+ if (!LAUNCHD_AKM_LABEL_RE.test(label))
871
+ continue;
872
+ if (match[2] === "true" || match[2] === "disabled")
873
+ disabled.add(label);
874
+ if (disabled.size >= MAX_LAUNCHD_AKM_NAMESPACE_ENTRIES)
875
+ break;
879
876
  }
880
877
  return disabled;
881
878
  }
@@ -552,6 +552,8 @@ function expandNativeTriggers(trigger) {
552
552
  return [{ kind: "daily", atHour: trigger.atHour, atMinute: trigger.atMinute }];
553
553
  case "weekly":
554
554
  return [trigger];
555
+ case "monthly":
556
+ return [trigger];
555
557
  }
556
558
  }
557
559
  function renderNativeTrigger(trigger, startBoundary) {
@@ -570,9 +572,10 @@ ${repetition} <StartBoundary>${startBoundary}</StartBoundary>
570
572
  <ScheduleByDay><DaysInterval>1</DaysInterval></ScheduleByDay>
571
573
  </CalendarTrigger>`;
572
574
  }
573
- const dayMap = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
574
- const days = trigger.daysOfWeek.map((d) => ` <${dayMap[d]} />`).join("\n");
575
- return ` <CalendarTrigger>
575
+ if (trigger.kind === "weekly") {
576
+ const dayMap = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
577
+ const days = trigger.daysOfWeek.map((d) => ` <${dayMap[d]} />`).join("\n");
578
+ return ` <CalendarTrigger>
576
579
  <StartBoundary>${startBoundary}</StartBoundary>
577
580
  <Enabled>true</Enabled>
578
581
  <ScheduleByWeek>
@@ -582,6 +585,35 @@ ${days}
582
585
  <WeeksInterval>1</WeeksInterval>
583
586
  </ScheduleByWeek>
584
587
  </CalendarTrigger>`;
588
+ }
589
+ const monthMap = [
590
+ "January",
591
+ "February",
592
+ "March",
593
+ "April",
594
+ "May",
595
+ "June",
596
+ "July",
597
+ "August",
598
+ "September",
599
+ "October",
600
+ "November",
601
+ "December",
602
+ ];
603
+ const days = trigger.daysOfMonth.map((d) => ` <Day>${d}</Day>`).join("\n");
604
+ const months = trigger.months.map((m) => ` <${monthMap[m - 1]} />`).join("\n");
605
+ return ` <CalendarTrigger>
606
+ <StartBoundary>${startBoundary}</StartBoundary>
607
+ <Enabled>true</Enabled>
608
+ <ScheduleByMonth>
609
+ <DaysOfMonth>
610
+ ${days}
611
+ </DaysOfMonth>
612
+ <Months>
613
+ ${months}
614
+ </Months>
615
+ </ScheduleByMonth>
616
+ </CalendarTrigger>`;
585
617
  }
586
618
  function formatRepetitionInterval(minutes) {
587
619
  return formatMinuteDuration(minutes);
@@ -617,6 +649,21 @@ function nextStartBoundary(trigger, now) {
617
649
  boundary.setHours(trigger.atHour, trigger.atMinute, 0, 0);
618
650
  }
619
651
  return boundary;
652
+ case "monthly": {
653
+ boundary.setHours(trigger.atHour, trigger.atMinute, 0, 0);
654
+ const daysOfMonth = new Set(trigger.daysOfMonth);
655
+ const months = new Set(trigger.months);
656
+ for (let guard = 0; guard < 4000; guard++) {
657
+ if (daysOfMonth.has(boundary.getDate()) &&
658
+ months.has(boundary.getMonth() + 1) &&
659
+ boundary.getTime() > now.getTime()) {
660
+ break;
661
+ }
662
+ boundary.setDate(boundary.getDate() + 1);
663
+ boundary.setHours(trigger.atHour, trigger.atMinute, 0, 0);
664
+ }
665
+ return boundary;
666
+ }
620
667
  }
621
668
  }
622
669
  function formatStartBoundary(d) {
@@ -24,6 +24,8 @@ export function frozenScriptCommand(script, materializedPath) {
24
24
  return [process.execPath, materializedPath];
25
25
  case "bun-standalone":
26
26
  return [process.execPath, STANDALONE_FROZEN_SCRIPT_ARG, materializedPath];
27
+ case "node":
28
+ return ["node", materializedPath];
27
29
  case "powershell":
28
30
  return ["powershell", "-NoProfile", "-NonInteractive", "-File", materializedPath];
29
31
  case "cmd":
@@ -21,6 +21,7 @@
21
21
  import fs from "node:fs";
22
22
  import { prepareCommandInvocation } from "../../commands/command/command-execution.js";
23
23
  import { UsageError } from "../../core/errors.js";
24
+ import { warn } from "../../core/warn.js";
24
25
  import { base, commandEnvironmentSnapshot, currentExecutionValues, defaultTaskShell, environmentSnapshot, qualifyOwnedRef, resolvedOwnedAsset, validatePreparedCommand, validateWorkflowRuntimeSource, } from "./prepare-support.js";
25
26
  import { captureDirectoryIdentity, captureScriptTarget } from "./script-capture.js";
26
27
  /** Project one canonical task-v3 source into immutable executable work. */
@@ -79,13 +80,7 @@ export async function prepareTaskV3Execution(document, context) {
79
80
  if (target.kind === "workflow") {
80
81
  // Stays reachable — task source v4 still has a top-level env: (P4-N4).
81
82
  if (Object.keys(environment).length > 0) {
82
- // P4 (docs/plans/specs/p4-deletions-closeout.md §5.5, row P-04): PRESERVED,
83
- // not re-coded — tests/integration/tasks-with-classification-characterization.test.ts's
84
- // P-04 block pins this exact code (CONVERT, not FLIP, per §7.2 F-A2.8:
85
- // "the P-04 block ... stays reachable and stays pinned"). §5.2's target
86
- // table predicted all 3 of this file's remaining sites → COMPOSITION_INVALID;
87
- // this is the recorded deviation for the one site a preservation gate blocks.
88
- throw new UsageError("Task workflow env cannot be consumed by the durable workflow runtime in 0.9.2; remove env or use a command target.", "INVALID_FLAG_VALUE");
83
+ warn("[akm] Task %s: env: is not translated for a workflow target and will be ignored; the durable workflow runtime does not consume it. Use a command target, or drop env:.", context.taskRef);
89
84
  }
90
85
  const resolved = await resolvedOwnedAsset(qualified, "workflow", context);
91
86
  validateWorkflowRuntimeSource(resolved.file, resolved.bundleRoot, context.readFile ?? ((targetPath) => fs.readFileSync(targetPath)));