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
@@ -10,11 +10,13 @@
10
10
  * import their opener from a sibling here instead of reaching up into the
11
11
  * indexer — inverting the old storage→indexer arrow.
12
12
  */
13
- import fs from "node:fs";
14
13
  import { createRequire } from "node:module";
14
+ import { ConfigError } from "../../core/errors.js";
15
+ import { classifyPathAccess, describeInaccessiblePath } from "../../core/path-access.js";
15
16
  import { getDbPath } from "../../core/paths.js";
16
17
  import { openDatabase } from "../database.js";
17
18
  import { openManagedDatabase } from "../managed-db.js";
19
+ import { SQLITE_BUSY_TIMEOUT_MS } from "../sqlite-pragmas.js";
18
20
  import { ensureSchema } from "./index-schema.js";
19
21
  import { loadVecExtension, warnIfVecMissing } from "./index-vec-repository.js";
20
22
  export function openIndexDatabase(dbPath, options) {
@@ -71,20 +73,53 @@ export function openExistingDatabase(dbPath) {
71
73
  // tests/storage/open-existing-database-no-create.test.ts. `create: false`
72
74
  // below is the race-free backstop for this pre-check.
73
75
  const resolvedPath = dbPath ?? getDbPath();
74
- if (!fs.existsSync(resolvedPath)) {
76
+ assertIndexPathReadable(resolvedPath);
77
+ if (classifyPathAccess(resolvedPath).access === "absent") {
75
78
  throw new Error(`Index database not found at ${resolvedPath}. Run 'akm index' to build it.`);
76
79
  }
77
80
  return openManagedDatabase({ path: resolvedPath, init: loadVecExtension, create: false });
78
81
  }
82
+ /**
83
+ * Refuse to treat an UNREADABLE index as a missing one (#791).
84
+ *
85
+ * `fs.existsSync()` — which every one of these gates used to call — returns
86
+ * `false` for `EACCES` exactly as for `ENOENT`, so an index this process cannot
87
+ * read looked identical to one that had never been built. Callers then took
88
+ * their "no index yet" branch: `search`/`curate` returned no hits at exit 0 and
89
+ * told the user to run `akm index`, which would not have helped and which they
90
+ * may not have permission to do either.
91
+ *
92
+ * A `ConfigError` here exits 78 through the standard `{ok:false, error, code}`
93
+ * envelope, so both a human and a machine caller can tell "nothing indexed"
94
+ * from "I cannot see the index".
95
+ */
96
+ export function assertIndexPathReadable(resolvedPath) {
97
+ const { access, code } = classifyPathAccess(resolvedPath);
98
+ if (access !== "inaccessible")
99
+ return;
100
+ throw new ConfigError(`Index database exists but is not readable: ${describeInaccessiblePath(resolvedPath, code)}.`, "DATA_DIR_UNREADABLE");
101
+ }
79
102
  /**
80
103
  * Open an existing index for queries without creating directories, a database
81
104
  * file, journals, or running write-capable pragmas/schema initialization.
82
105
  */
83
106
  export function openReadonlyExistingDatabase(dbPath) {
84
107
  const resolvedPath = dbPath ?? getDbPath();
85
- if (!fs.existsSync(resolvedPath))
108
+ // `undefined` means "no index" — reserve it for a genuinely absent one, and
109
+ // let an unreadable index raise instead of masquerading as absent (#791).
110
+ assertIndexPathReadable(resolvedPath);
111
+ if (classifyPathAccess(resolvedPath).access === "absent")
86
112
  return undefined;
87
- return openDatabase(resolvedPath, { readonly: true, create: false });
113
+ const db = openDatabase(resolvedPath, { readonly: true, create: false });
114
+ // This opener bypasses openManagedDatabase/applyStandardPragmas by design (no
115
+ // journal or schema work on a read-only handle), but that also left
116
+ // busy_timeout at SQLite's default of 0. In WAL that is harmless — readers
117
+ // never block — but in the DELETE/TRUNCATE modes the network-FS fallback and
118
+ // AKM_SQLITE_JOURNAL_MODE can select, a concurrent writer makes every read
119
+ // fail instantly with SQLITE_BUSY. busy_timeout is legal on a read-only
120
+ // connection, so apply just that one.
121
+ db.exec(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`);
122
+ return db;
88
123
  }
89
124
  export function closeDatabase(db) {
90
125
  db.close();
@@ -13,6 +13,7 @@ import path from "node:path";
13
13
  import { parseBundleRef } from "../../core/asset/asset-ref.js";
14
14
  import { conceptIdFromTypeName } from "../../core/asset/resolve-ref.js";
15
15
  import { bestEffort } from "../../core/best-effort.js";
16
+ import { isPathAbsent } from "../../core/path-access.js";
16
17
  import { getStateDbPath, withStateDb } from "../../core/state-db.js";
17
18
  import { warn } from "../../core/warn.js";
18
19
  import { buildSearchText } from "../../indexer/search/search-fields.js";
@@ -278,7 +279,11 @@ export function rekeyEntryInPlace(db, opts) {
278
279
  * history (live-asset-wins). Best-effort + guarded on state.db's existence.
279
280
  */
280
281
  function rewriteUsageEventRefForMove(opts) {
281
- if (!fs.existsSync(getStateDbPath()))
282
+ // Every other failure in here throws (see the catch below) precisely because
283
+ // a move that quietly drops its usage history is a wrong answer wearing a
284
+ // success. An unreadable state.db must not be the one silent exception —
285
+ // only a state.db that was never created skips (#791).
286
+ if (isPathAbsent(getStateDbPath()))
282
287
  return;
283
288
  // `usage_events.entry_ref` is the fully-qualified item_ref
284
289
  // (`<bundle>//<conceptId>`).
@@ -17,6 +17,17 @@ export function getMeta(db, key) {
17
17
  export function setMeta(db, key, value) {
18
18
  db.prepare("INSERT OR REPLACE INTO index_meta (key, value) VALUES (?, ?)").run(key, value);
19
19
  }
20
+ /**
21
+ * Remove a meta key entirely.
22
+ *
23
+ * Distinct from writing an empty string: absence is what callers test for
24
+ * (`getMeta(...) === undefined`), and it is what lets a value be re-derived —
25
+ * clearing `embeddingDim` after a model change is how the vec table gets
26
+ * rebuilt at the new width.
27
+ */
28
+ export function deleteMeta(db, key) {
29
+ db.prepare("DELETE FROM index_meta WHERE key = ?").run(key);
30
+ }
20
31
  // ── Per-directory index state ───────────────────────────────────────────────
21
32
  export function getIndexDirState(db, dirPath) {
22
33
  const row = db
@@ -533,8 +533,23 @@ function ensureBundleRefColumns(db) {
533
533
  * fallback.
534
534
  */
535
535
  function ensureUniqueItemRefIndex(db) {
536
- db.exec("DROP INDEX IF EXISTS idx_entries_item_ref");
537
- db.exec("CREATE UNIQUE INDEX idx_entries_item_ref ON entries(item_ref)");
536
+ // Probe before mutating. This ran unconditionally on EVERY open as two
537
+ // separate autocommit statements, so there was always a window in which the
538
+ // index did not exist — a concurrent open (registry-cache search, indexer,
539
+ // improve) could DROP between the other's DROP and CREATE and then fail with
540
+ // "index idx_entries_item_ref already exists", or serve a query with no index
541
+ // at all. A DB whose index is already UNIQUE needs no work.
542
+ const existing = db
543
+ .prepare("SELECT sql FROM sqlite_master WHERE type = 'index' AND name = 'idx_entries_item_ref'")
544
+ .get();
545
+ if (existing?.sql && /\bUNIQUE\b/i.test(existing.sql))
546
+ return;
547
+ // Pre-v19 (non-unique index) or absent: convert atomically so a racing open
548
+ // sees either the old index or the new one, never neither.
549
+ db.transaction(() => {
550
+ db.exec("DROP INDEX IF EXISTS idx_entries_item_ref");
551
+ db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_entries_item_ref ON entries(item_ref)");
552
+ })();
538
553
  }
539
554
  /**
540
555
  * Returns true when a table exists in the current database.
@@ -22,10 +22,10 @@ export function loadVecExtension(db) {
22
22
  try {
23
23
  const esmRequire = createRequire(import.meta.url);
24
24
  const sqliteVec = esmRequire("sqlite-vec");
25
- // `db` here is the genuine underlying driver handle returned by the storage
26
- // boundary (bun:sqlite on Bun, better-sqlite3 on Node) only structurally
27
- // narrowed for callers. sqlite-vec's `load()` accepts either real handle,
28
- // so no raw-handle escape hatch is required.
25
+ // `db` is the storage boundary's handle. On Bun that IS the bun:sqlite
26
+ // handle; on Node it is a wrapper, which must forward `loadExtension` for
27
+ // this call to work at all (see openNodeDatabase in storage/database.ts
28
+ // it did not, so vec could never load on the entire npm distribution).
29
29
  sqliteVec.load(db);
30
30
  vecStatus.set(db, true);
31
31
  }
@@ -56,7 +56,45 @@ export function setVecFastPathReady(db, ready) {
56
56
  * rather than silently returning partial fast-path results.
57
57
  */
58
58
  export function isVecFastPathReady(db) {
59
- return getMeta(db, VEC_FAST_PATH_READY_META) !== "0";
59
+ if (getMeta(db, VEC_FAST_PATH_READY_META) === "0")
60
+ return false;
61
+ // The meta flag alone is not sufficient. An index built while sqlite-vec was
62
+ // unavailable wrote only BLOB rows, and because "unavailable" outcomes were
63
+ // not counted as failures the flag was still set to "1" against a table that
64
+ // is empty or absent. If sqlite-vec later becomes loadable — the user installs
65
+ // it, or the same index is opened under the other runtime — the fast path
66
+ // would then be trusted and return zero neighbours while the BLOB table holds
67
+ // every embedding. Indexes written by earlier versions still carry that stale
68
+ // flag, so the read path has to verify the table really exists.
69
+ return hasVecTable(db);
70
+ }
71
+ const vecTablePresent = new WeakMap();
72
+ /**
73
+ * Whether `entries_vec` exists on this connection, memoized per handle.
74
+ *
75
+ * openExistingDatabase loads the vec extension but deliberately does not run
76
+ * ensureSchema, so the table is not created on read paths — its absence is a
77
+ * normal state, not an error.
78
+ */
79
+ function hasVecTable(db) {
80
+ // Only a POSITIVE result is memoized. The table cannot vanish from a live
81
+ // connection, but it CAN appear — ensureSchema creates it partway through an
82
+ // index run — so caching "absent" would pin a stale answer for the rest of
83
+ // the handle's life.
84
+ if (vecTablePresent.get(db) === true)
85
+ return true;
86
+ let present = false;
87
+ try {
88
+ present =
89
+ db.prepare("SELECT 1 FROM sqlite_master WHERE type IN ('table','view') AND name = 'entries_vec'").get() !==
90
+ undefined;
91
+ }
92
+ catch {
93
+ present = false;
94
+ }
95
+ if (present)
96
+ vecTablePresent.set(db, true);
97
+ return present;
60
98
  }
61
99
  /** Remove both vector representations for an entry whose embedding input changed. */
62
100
  export function deleteEntryVectors(db, id) {
@@ -2,6 +2,7 @@
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 { openStateDatabase, withImmediateTransaction } from "../../core/state-db.js";
5
+ import { borrowScopedStateDb, withStateDbScope } from "../../core/state-db-scope.js";
5
6
  import { resolveStorageLocations } from "../locations.js";
6
7
  /**
7
8
  * Repository owning every raw SQL statement against `workflow_runs` and
@@ -214,8 +215,10 @@ export class WorkflowRunsRepository {
214
215
  //
215
216
  // Writes to `workflow_run_units` should go through the serialized writer
216
217
  // queue (`src/workflows/exec/unit-writer.ts`) when N units may complete
217
- // concurrently — SQLite has a single writer and `withWorkflowRunsRepo`
218
- // opens a fresh connection per call.
218
+ // concurrently — SQLite has a single writer per database FILE, and outside a
219
+ // {@link withWorkflowRunsConnection} scope `withWorkflowRunsRepo` opens a
220
+ // fresh connection per call (so N concurrent writers would contend against
221
+ // each other for the write lock).
219
222
  getUnitsForRun(runId) {
220
223
  return this.db
221
224
  .prepare("SELECT * FROM workflow_run_units WHERE run_id = ? ORDER BY started_at ASC, unit_id ASC")
@@ -323,22 +326,55 @@ export class WorkflowRunsRepository {
323
326
  `recoverable state.`);
324
327
  }
325
328
  }
329
+ /**
330
+ * Finish a unit row ONLY while it is still the exact row a specific dispatch
331
+ * inserted: `running`, with that dispatch's `started_at`. The native
332
+ * executor's guarded finish (single-driver invariant): a run stolen by
333
+ * another engine re-dispatches the unit through {@link insertUnit}, which
334
+ * REPLACES the row (fresh `started_at`, bumped `attempts`) — the stale
335
+ * driver's finish then matches NOTHING instead of clobbering the new
336
+ * driver's live dispatch. Returns whether the row was finished; a zero-row
337
+ * match is a caller-classified outcome here (the executor distinguishes
338
+ * "replaced by another driver" from "row vanished"), unlike
339
+ * {@link finishUnit}'s loud throw, whose callers guarantee their row exists.
340
+ */
341
+ finishUnitFromDispatch(input) {
342
+ const result = this.db
343
+ .prepare(`UPDATE workflow_run_units
344
+ SET status = ?, result_json = ?, tokens = ?, failure_reason = ?, session_id = ?, finished_at = ?
345
+ WHERE run_id = ? AND unit_id = ? AND status = 'running' AND started_at = ?`)
346
+ .run(input.status, input.resultJson, input.tokens, input.failureReason, input.sessionId ?? null, input.finishedAt, input.runId, input.unitId, input.dispatchStartedAt);
347
+ return Number(result.changes) === 1;
348
+ }
326
349
  }
327
350
  /**
328
- * Open state.db (bound to {@link StorageLocations.stateDb}, the post-cutover
329
- * home of the `workflow_runs` / `workflow_run_steps` / `workflow_run_units`
330
- * tables), run `fn` against a {@link WorkflowRunsRepository}, and close the
331
- * connection exactly once when `fn` settles.
351
+ * Run `fn` against a {@link WorkflowRunsRepository} bound to state.db
352
+ * ({@link StorageLocations.stateDb}, the post-cutover home of the
353
+ * `workflow_runs` / `workflow_run_steps` / `workflow_run_units` tables).
332
354
  *
333
- * Fresh-connection-per-call, mirroring the former workflow.db loan pattern:
334
- * `openStateDatabase` acquires its own maintenance activity + asserts the
335
- * current ledger, and the repository owns all table-scoped SQL, so the merge
336
- * into state.db is a zero-SQL-rewrite repoint. Repository read methods fully
337
- * materialise their results, so closing here never truncates lazy iteration
338
- * (WS5 connection-lifetime rule).
355
+ * Connection lifetime BORROW-OR-OWN (mirrors `withStateDb`'s `borrowed`
356
+ * option and `appendEvent`'s `ctx.db` seam):
357
+ *
358
+ * - Inside a {@link withWorkflowRunsConnection} scope, the ambient handle is
359
+ * BORROWED and left open for the rest of the scope. A wide `map` fan-out
360
+ * therefore opens ONE connection for the whole step instead of two per unit
361
+ * (insert + finish) — `openStateDatabase` registers a maintenance activity
362
+ * lockfile and opens a read-only ledger-preflight handle on every call, so
363
+ * the per-call cost is milliseconds, not microseconds.
364
+ * - Outside a scope the behaviour is unchanged: open a fresh connection, run
365
+ * `fn`, close it in a `finally`.
366
+ *
367
+ * Repository read methods fully materialise their results, so closing an owned
368
+ * handle here never truncates lazy iteration (WS5 connection-lifetime rule).
369
+ * The signature and semantics are identical in both modes — reuse is purely an
370
+ * internal optimisation and no caller needs to know which mode it is in.
339
371
  */
340
372
  export async function withWorkflowRunsRepo(fn) {
341
- const db = openStateDatabase(resolveStorageLocations().stateDb);
373
+ const stateDb = resolveStorageLocations().stateDb;
374
+ const borrowed = borrowScopedStateDb(stateDb);
375
+ if (borrowed)
376
+ return await Promise.resolve(fn(new WorkflowRunsRepository(borrowed)));
377
+ const db = openStateDatabase(stateDb);
342
378
  try {
343
379
  return await Promise.resolve(fn(new WorkflowRunsRepository(db)));
344
380
  }
@@ -346,3 +382,20 @@ export async function withWorkflowRunsRepo(fn) {
346
382
  db.close();
347
383
  }
348
384
  }
385
+ /**
386
+ * Run `fn` with ONE state.db connection shared by every `withWorkflowRunsRepo`
387
+ * call (and every {@link import("../../core/events.js").appendEvent}) inside its
388
+ * async extent. The handle opens on first use and closes when `fn` settles;
389
+ * nesting joins the outer scope.
390
+ *
391
+ * Correctness under concurrency: `bun:sqlite` statements and
392
+ * `withImmediateTransaction` bodies run synchronously to completion, so
393
+ * logically concurrent units cannot interleave statements on the shared handle
394
+ * in a single-threaded event loop — sharing REMOVES in-process writer
395
+ * contention instead of creating it. Cross-process arbitration (WAL,
396
+ * `busy_timeout`, the run lease) is untouched. See `core/state-db-scope.ts` for
397
+ * the escaped-async-work guard.
398
+ */
399
+ export function withWorkflowRunsConnection(fn) {
400
+ return withStateDbScope(fn, { path: resolveStorageLocations().stateDb });
401
+ }
@@ -95,6 +95,17 @@ export function isNetworkFilesystem(fsType) {
95
95
  return false;
96
96
  return NETWORK_FS_MAGICS.has(fsType);
97
97
  }
98
+ /** Options for {@link applyStandardPragmas}. */
99
+ /**
100
+ * How long a statement waits for a lock before failing with SQLITE_BUSY.
101
+ *
102
+ * Exported so read-only openers can apply it too. They cannot run the rest of
103
+ * the standard set (journal_mode and foreign_keys are write operations), but
104
+ * the default of 0 makes reads fail INSTANTLY under writer contention — which
105
+ * matters in the DELETE/TRUNCATE journal modes 0.9.1's network-filesystem
106
+ * fallback and `AKM_SQLITE_JOURNAL_MODE` can select, where readers do block.
107
+ */
108
+ export const SQLITE_BUSY_TIMEOUT_MS = 30_000;
98
109
  /**
99
110
  * Apply AKM's standard opening PRAGMAs to `db`, in order:
100
111
  * 1. `journal_mode` = the configured mode (with WAL→DELETE network-FS fallback)
@@ -128,7 +139,7 @@ export function applyStandardPragmas(db, opts = {}) {
128
139
  // lock instead of failing immediately with SQLITE_BUSY. For the WAL default
129
140
  // this is a no-op (WAL→WAL changes nothing), so byte-identical behaviour is
130
141
  // preserved.
131
- db.exec("PRAGMA busy_timeout = 30000");
142
+ db.exec(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`);
132
143
  db.exec(`PRAGMA journal_mode = ${mode}`);
133
144
  if (opts.foreignKeys !== false) {
134
145
  db.exec("PRAGMA foreign_keys = ON");
@@ -0,0 +1,156 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * Decide which exact values must be scrubbed from a task's persisted run log
6
+ * (issue #755).
7
+ *
8
+ * # The gap
9
+ *
10
+ * All three task target kinds funnel through `persistRunLog`, which applied
11
+ * only `redactCredentialPatterns` — credential *shapes* (`Bearer …`, `sk-…`,
12
+ * webhook URLs). Prompt- and workflow-target runs additionally redact the exact
13
+ * secret values reachable by the run before their output ever reaches the log.
14
+ * Command-target runs did not: a scheduled command that echoed a configured
15
+ * secret whose value is not credential-shaped persisted it verbatim, into both
16
+ * the `.log` file and `logs.db`, for the whole retention window.
17
+ *
18
+ * # Why the obvious fix is wrong
19
+ *
20
+ * The issue proposed reusing {@link isEnvPassthroughValueSafeToExpose} — the
21
+ * filter the prompt path uses — over the env handed to the child. That filter
22
+ * fails CLOSED for any name outside a 22-entry allowlist, which is right where
23
+ * it currently runs: the prompt path filters `envPassthrough`, a short list the
24
+ * operator explicitly declared. A command task inherits the WHOLE ambient
25
+ * environment, so the same rule classifies essentially everything as secret.
26
+ * Measured on a developer machine: 127 of 132 variables, 25 of them with
27
+ * one-character values (`SHLVL=1`, `OLDPWD=/`, `GIT_TERMINAL_PROMPT=0`).
28
+ * Redaction is substring replacement, so those become live needles:
29
+ *
30
+ * Build finished in 12.4s -> Build finished in [REDACTED]2.[REDACTED]s
31
+ * 3 tests passed, 0 failed -> [REDACTED] tests passed, [REDACTED] failed
32
+ * wrote dist/index.js (48 KB) -> wrote dist[REDACTED]index.js ([REDACTED]8 KB)
33
+ *
34
+ * A fix that destroys every command log is not a fix.
35
+ *
36
+ * # What this does instead
37
+ *
38
+ * Three sources, and the distinction between them is the whole design:
39
+ *
40
+ * 1. **Declared by config** — the config names which variables hold
41
+ * credentials (`engines.<n>.apiKey: ${VAR}`, `embedding.apiKey`, and the
42
+ * implicit `AKM_ENGINE_<NAME>_API_KEY` / `AKM_LLM_API_KEY` /
43
+ * `AKM_EMBED_API_KEY` recipes). akm KNOWS these are secret.
44
+ * 2. **Declared by the task** — the `redact:` list, names only.
45
+ * 3. **Inferred** — a name-shape heuristic over the remaining environment, for
46
+ * the ambient credential akm was never told about.
47
+ *
48
+ * Only (3) is a guess, so only (3) carries {@link MIN_INFERRED_SECRET_LENGTH}.
49
+ * A declared secret is redacted at ANY length, because the operator told us
50
+ * what it is; applying a floor to declared values would silently stop redacting
51
+ * short secrets that are scrubbed today. The floor exists solely to stop a
52
+ * *guess* from mangling a log, and 8 clears every real credential format (AWS
53
+ * key id 20, GitHub PAT 40, `sk-…` 40+) while excluding the flags and counters
54
+ * that a name heuristic occasionally catches.
55
+ *
56
+ * Note what is deliberately NOT collected: the akm secret store on disk. A
57
+ * spawned command sees `process.env`, not akm's stores — a stored secret can
58
+ * only be echoed if it is already in the environment, where the rules above
59
+ * catch it by name. Walking every bundle's `secrets/` on each task firing would
60
+ * cost a recursive readdir plus an unbounded read for values the child cannot
61
+ * reach anyway. `redact:` is the escape hatch for a secret injected under a
62
+ * name none of the rules recognise.
63
+ */
64
+ import { collectSensitiveValues, isEnvPassthroughValueSafeToExpose } from "../core/redaction.js";
65
+ import { collectEngineCredentialValues } from "../integrations/agent/engine-resolution.js";
66
+ /**
67
+ * Shortest value an INFERRED (name-heuristic) match may contribute as a
68
+ * redaction needle. Declared secrets bypass this entirely.
69
+ *
70
+ * Redaction replaces substrings, so a short needle is not merely useless — it
71
+ * corrupts unrelated output. Below 8 the noise tier is fully intact (`1`, `0`,
72
+ * `/`, `80`, `true`, `xhigh`, `31999`); at 8 a chance collision with ordinary
73
+ * log vocabulary is negligible, and every credential format in real use is far
74
+ * longer. It also matches the conventional minimum password length, so a
75
+ * secret shorter than this is already outside normal policy.
76
+ */
77
+ export const MIN_INFERRED_SECRET_LENGTH = 8;
78
+ /**
79
+ * Environment names whose VALUE is treated as a credential on shape alone.
80
+ *
81
+ * The keyword must be a whole `_`-delimited word. Anchoring on only one side
82
+ * would drag in ordinary configuration from whichever side is left open:
83
+ * a leading anchor alone matches `KEYBOARD_LAYOUT` and `AUTHOR`, a trailing one
84
+ * matches `MONKEY` and `BYPASS`. Whole-word matching gets `GH_TOKEN`,
85
+ * `NPM_AUTH_TOKEN`, `MY_API_KEY`, `DB_PASS` and `AWS_SECRET_ACCESS_KEY` right.
86
+ */
87
+ const INFERRED_SECRET_NAME = /(?:^|_)(?:API_?KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|PASS|CREDENTIALS?|AUTH)(?:_|$)/i;
88
+ /**
89
+ * Credential variables whose name is a single glued word, which no
90
+ * word-boundary rule can see. Enumerated rather than matched: loosening the
91
+ * pattern enough to catch `PGPASSWORD` also catches `MONKEY`.
92
+ *
93
+ * `PWD` cannot be a keyword above for the same reason it appears here as part
94
+ * of `MYSQL_PWD` — on its own it is the working directory.
95
+ */
96
+ const KNOWN_SECRET_NAMES = new Set(["PGPASSWORD", "MYSQL_PWD"]);
97
+ /** True when the NAME alone marks this variable as holding a credential. */
98
+ export function isInferredSecretName(name) {
99
+ return KNOWN_SECRET_NAMES.has(name.toUpperCase()) || INFERRED_SECRET_NAME.test(name);
100
+ }
101
+ /** Resolve `${VAR}` / `$VAR` to the variable NAME, or undefined for anything else. */
102
+ function envRefName(spec) {
103
+ if (!spec)
104
+ return undefined;
105
+ const match = /^\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?$/.exec(spec.trim());
106
+ return match?.[1];
107
+ }
108
+ /**
109
+ * Every value that must be scrubbed from one task's persisted output.
110
+ *
111
+ * `declaredNames` comes from the task's `redact:` list. A name that is unset in
112
+ * `env` simply contributes nothing — naming a variable you do not currently
113
+ * export is not an error.
114
+ */
115
+ export function collectTaskLogSensitiveValues(input) {
116
+ const { env, config, declaredNames } = input;
117
+ const values = new Set();
118
+ const addDeclared = (value) => {
119
+ if (value === undefined)
120
+ return;
121
+ const trimmed = value.trim();
122
+ // Both spellings: `resolveSecret` does not trim but `resolveCredentialFromEnv`
123
+ // does, so a variable with trailing whitespace reaches an output boundary in
124
+ // either form depending on which path materialized it.
125
+ if (value.length > 0)
126
+ values.add(value);
127
+ if (trimmed.length > 0)
128
+ values.add(trimmed);
129
+ };
130
+ // (1) Declared by config — engine credentials, via the collector the prompt
131
+ // path already uses, plus the embedding key it does not cover.
132
+ if (config) {
133
+ for (const value of collectEngineCredentialValues(config, env))
134
+ values.add(value);
135
+ addDeclared(env[envRefName(config.embedding?.apiKey) ?? "AKM_EMBED_API_KEY"]);
136
+ }
137
+ // (2) Declared by the task's `redact:` list — names only, never values.
138
+ for (const name of declaredNames ?? [])
139
+ addDeclared(env[name]);
140
+ // (3) Inferred from the name shape. The only guessing tier, so the only one
141
+ // with a length floor — and still subject to the value-level check that keeps
142
+ // an allowlisted name from being treated as secret.
143
+ for (const [name, value] of Object.entries(env)) {
144
+ if (value === undefined || value.length < MIN_INFERRED_SECRET_LENGTH)
145
+ continue;
146
+ if (!isInferredSecretName(name))
147
+ continue;
148
+ if (isEnvPassthroughValueSafeToExpose(name, value))
149
+ continue;
150
+ values.add(value);
151
+ }
152
+ // Expands credential-bearing URLs into their embedded components. Can yield
153
+ // needles shorter than the floor (a URL's password), which is correct: the
154
+ // operator's own value implied them.
155
+ return collectSensitiveValues(values);
156
+ }
@@ -12,6 +12,10 @@
12
12
  * workflow: workflows/daily-backup
13
13
  * params:
14
14
  * region: us-east-1
15
+ * timeoutMs: 3600000 # whole-run bound; omit for the unattended
16
+ * # default, `null` to opt out entirely
17
+ * maxSteps: 20 # optional run bounds, same as the
18
+ * maxRetries: 1 # `akm workflow run` flags
15
19
  * # ...or:
16
20
  * prompt: agents/my-agent # asset ref
17
21
  * # ...or:
@@ -27,6 +31,8 @@
27
31
  * description: …
28
32
  * when_to_use: …
29
33
  * tags: [scheduled, backup]
34
+ * redact: [ACME_DEPLOY_TOKEN] # optional: env var NAMES (never values) to
35
+ * # scrub from this task's persisted log
30
36
  * ```
31
37
  *
32
38
  * Validation lives in {@link validateTaskDocument}. The parser enforces the
@@ -38,7 +44,8 @@ import { parse as parseYaml } from "yaml";
38
44
  import { isFullRefInput } from "../core/asset/resolve-ref.js";
39
45
  import { UsageError } from "../core/errors.js";
40
46
  import { formatExtraParamsIssue, validateExtraParams } from "../core/extra-params.js";
41
- import { TASK_SCHEMA_VERSION } from "./schema.js";
47
+ import { WORKFLOW_ENV_VAR_NAME_PATTERN, WORKFLOW_MAX_RETRIES } from "../workflows/resource-limits.js";
48
+ import { TASK_MAX_REDACT_NAMES, TASK_MAX_TIMEOUT_MS, TASK_SCHEMA_VERSION, } from "./schema.js";
42
49
  import { validateTaskId } from "./task-id.js";
43
50
  export function parseTaskDocument(input) {
44
51
  const { yaml, filePath } = input;
@@ -79,15 +86,25 @@ export function parseTaskDocument(input) {
79
86
  }
80
87
  let target;
81
88
  if (hasWorkflow) {
82
- rejectTargetFields(data, ["params"], id, filePath);
89
+ rejectTargetFields(data, ["params", "timeoutMs", "maxSteps", "maxRetries"], id, filePath);
83
90
  const ref = requireString(data.workflow, "workflow", filePath);
84
91
  if (!ref) {
85
92
  throw new UsageError(`Task "${id}" has empty \`workflow\`. File: ${filePath}`, "INVALID_FLAG_VALUE");
86
93
  }
94
+ // The three run bounds `akm workflow run` takes as flags, declared in the
95
+ // task file instead: an unattended run gets the same abort path the
96
+ // interactive CLI has. `timeoutMs` left unset falls back to the runner's
97
+ // default (see DEFAULT_WORKFLOW_TASK_TIMEOUT_MS); `null` opts out.
98
+ const workflowTimeoutMs = readTimeout(data.timeoutMs, filePath);
99
+ const maxSteps = readBoundedInteger(data.maxSteps, "maxSteps", 1, undefined, filePath);
100
+ const maxRetries = readBoundedInteger(data.maxRetries, "maxRetries", 0, WORKFLOW_MAX_RETRIES, filePath);
87
101
  target = {
88
102
  kind: "workflow",
89
103
  ref,
90
104
  params: readParams(data.params, filePath),
105
+ ...(workflowTimeoutMs !== undefined ? { timeoutMs: workflowTimeoutMs } : {}),
106
+ ...(maxSteps !== undefined ? { maxSteps } : {}),
107
+ ...(maxRetries !== undefined ? { maxRetries } : {}),
91
108
  };
92
109
  }
93
110
  else if (hasCommand) {
@@ -114,6 +131,7 @@ export function parseTaskDocument(input) {
114
131
  };
115
132
  }
116
133
  const timeoutMs = hasCommand ? readTimeout(data.timeoutMs, filePath) : undefined;
134
+ const redact = readRedactNames(data.redact, filePath);
117
135
  return {
118
136
  version: TASK_SCHEMA_VERSION,
119
137
  schemaVersion: TASK_SCHEMA_VERSION,
@@ -127,6 +145,7 @@ export function parseTaskDocument(input) {
127
145
  ...(tags ? { tags } : {}),
128
146
  source: { path: filePath },
129
147
  timeoutMs,
148
+ ...(redact ? { redact } : {}),
130
149
  };
131
150
  }
132
151
  const TASK_KEYS = new Set([
@@ -144,9 +163,22 @@ const TASK_KEYS = new Set([
144
163
  "engine",
145
164
  "model",
146
165
  "timeoutMs",
166
+ "maxSteps",
167
+ "maxRetries",
147
168
  "llm",
169
+ "redact",
170
+ ]);
171
+ const SHARED_KEYS = new Set([
172
+ "version",
173
+ "name",
174
+ "description",
175
+ "when_to_use",
176
+ "tags",
177
+ "schedule",
178
+ "enabled",
179
+ // `redact:` is target-agnostic: every kind funnels through the same log sink.
180
+ "redact",
148
181
  ]);
149
- const SHARED_KEYS = new Set(["version", "name", "description", "when_to_use", "tags", "schedule", "enabled"]);
150
182
  function requireVersion(data, id, filePath) {
151
183
  if (data.version === TASK_SCHEMA_VERSION)
152
184
  return;
@@ -258,9 +290,54 @@ function readTimeout(value, filePath) {
258
290
  return undefined;
259
291
  if (value === null)
260
292
  return null;
261
- if (typeof value === "number" && Number.isInteger(value) && value > 0)
293
+ // The ceiling is `setTimeout`'s, not a policy: a larger delay overflows and
294
+ // fires immediately, turning a generous timeout into an instant abort.
295
+ if (typeof value === "number" && Number.isInteger(value) && value > 0 && value <= TASK_MAX_TIMEOUT_MS)
262
296
  return value;
263
- throw new UsageError(`Key "timeoutMs" must be a positive integer or null. File: ${filePath}`, "INVALID_FLAG_VALUE");
297
+ throw new UsageError(`Key "timeoutMs" must be an integer from 1 through ${TASK_MAX_TIMEOUT_MS}, or null. File: ${filePath}`, "INVALID_FLAG_VALUE");
298
+ }
299
+ /**
300
+ * Read the `redact:` opt-in list — environment variable NAMES whose values are
301
+ * scrubbed from this task's persisted log (#755).
302
+ *
303
+ * A value that looks like a secret rather than a name is rejected outright, not
304
+ * silently accepted: a literal in a task file would be indexed, searchable and
305
+ * printed verbatim by `akm show`, so accepting one would leak the secret
306
+ * through a wider channel than the redaction closes.
307
+ */
308
+ function readRedactNames(value, filePath) {
309
+ if (value === undefined || value === null)
310
+ return undefined;
311
+ const invalid = (detail) => {
312
+ throw new UsageError(`Key "redact" ${detail}. File: ${filePath}`, "INVALID_FLAG_VALUE");
313
+ };
314
+ if (!Array.isArray(value))
315
+ return invalid("must be a list of environment variable names");
316
+ if (value.length > TASK_MAX_REDACT_NAMES) {
317
+ return invalid(`accepts at most ${TASK_MAX_REDACT_NAMES} names (got ${value.length})`);
318
+ }
319
+ const names = [];
320
+ for (const entry of value) {
321
+ if (typeof entry !== "string" || !WORKFLOW_ENV_VAR_NAME_PATTERN.test(entry)) {
322
+ return invalid(`takes environment variable NAMES only (matching ${WORKFLOW_ENV_VAR_NAME_PATTERN.source}), not values — ` +
323
+ `got ${JSON.stringify(entry)}. A secret written here would be indexed and printed by \`akm show\``);
324
+ }
325
+ if (!names.includes(entry))
326
+ names.push(entry);
327
+ }
328
+ return names.length > 0 ? names : undefined;
329
+ }
330
+ function readBoundedInteger(value, key, minimum, maximum, filePath) {
331
+ if (value === undefined || value === null)
332
+ return undefined;
333
+ if (typeof value === "number" &&
334
+ Number.isSafeInteger(value) &&
335
+ value >= minimum &&
336
+ (maximum === undefined || value <= maximum)) {
337
+ return value;
338
+ }
339
+ const range = maximum === undefined ? `at least ${minimum}` : `from ${minimum} through ${maximum}`;
340
+ throw new UsageError(`Key "${key}" must be an integer ${range}. File: ${filePath}`, "INVALID_FLAG_VALUE");
264
341
  }
265
342
  function readLlmOverrides(value, filePath) {
266
343
  if (value === undefined)