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.
- package/CHANGELOG.md +227 -0
- package/STABILITY.md +6 -1
- package/dist/assets/hints/cli-hints-full.md +1 -1
- package/dist/assets/improve-strategies/consolidate.json +1 -1
- package/dist/assets/improve-strategies/default.json +1 -1
- package/dist/assets/improve-strategies/thorough.json +1 -2
- package/dist/assets/workflows/workflow-template.md +4 -0
- package/dist/cli/shared.js +16 -4
- package/dist/cli.js +15 -13
- package/dist/commands/agent/agent-dispatch.js +8 -0
- package/dist/commands/command/execution-source-loader.js +25 -22
- package/dist/commands/command/portable-template.js +4 -26
- package/dist/commands/config-cli.js +10 -4
- package/dist/commands/env/env-binding.js +10 -3
- package/dist/commands/env/env-cli.js +7 -0
- package/dist/commands/env/secret-cli.js +15 -4
- package/dist/commands/health/checks.js +186 -71
- package/dist/commands/health.js +16 -4
- package/dist/commands/improve/distill/quality-gate.js +2 -2
- package/dist/commands/improve/distill.js +28 -12
- package/dist/commands/improve/execution.js +1 -2
- package/dist/commands/improve/extract.js +82 -56
- package/dist/commands/improve/improve-strategies.js +26 -8
- package/dist/commands/improve/improve.js +14 -0
- package/dist/commands/improve/preparation.js +9 -6
- package/dist/commands/improve/reflect.js +61 -77
- package/dist/commands/lint/base-linter.js +10 -0
- package/dist/commands/lint/index.js +3 -1
- package/dist/commands/migrate-cli.js +6 -4
- package/dist/commands/proposal/drain-policies.js +22 -2
- package/dist/commands/proposal/drain.js +48 -6
- package/dist/commands/proposal/proposal-cli.js +1 -0
- package/dist/commands/proposal/repository.js +4 -4
- package/dist/commands/proposal/validators/proposal-quality-validators.js +23 -2
- package/dist/commands/proposal/validators/proposals.js +10 -19
- package/dist/commands/read/show.js +42 -31
- package/dist/commands/registry-cli.js +4 -2
- package/dist/commands/sources/init.js +4 -8
- package/dist/commands/sources/self-update.js +2 -2
- package/dist/commands/sources/source-clone.js +5 -7
- package/dist/commands/sources/sources-cli.js +3 -5
- package/dist/commands/tasks/tasks-cli.js +4 -12
- package/dist/commands/tasks/tasks.js +38 -35
- package/dist/commands/workflow-cli.js +17 -15
- package/dist/core/activation-policy.js +31 -3
- package/dist/core/adapter/execution-source.js +39 -11
- package/dist/core/asset/stash-meta.js +7 -41
- package/dist/core/common.js +8 -17
- package/dist/core/config/config-schema.js +3 -23
- package/dist/core/config/config-walker.js +56 -6
- package/dist/core/config/config.js +42 -17
- package/dist/core/config/legacy-source-shape-shim.js +79 -0
- package/dist/core/config/schema/embedding.js +2 -2
- package/dist/core/config/schema/engines.js +2 -2
- package/dist/core/config/schema/index-config.js +19 -21
- package/dist/core/config/schema/primitives.js +27 -10
- package/dist/core/config/schema/sources-bundles.js +1 -6
- package/dist/core/errors.js +4 -3
- package/dist/core/improve-types.js +17 -0
- package/dist/core/json-schema.js +1 -11
- package/dist/core/maintenance-barrier.js +17 -2
- package/dist/core/paths.js +12 -15
- package/dist/core/state/migrations.js +28 -0
- package/dist/core/state-db.js +28 -1
- package/dist/core/write-source.js +6 -6
- package/dist/indexer/bundle-identity-guard.js +3 -0
- package/dist/indexer/ensure-index.js +5 -0
- package/dist/indexer/indexer.js +11 -3
- package/dist/indexer/lookup/adapter-concept-owner.js +14 -3
- package/dist/indexer/passes/metadata.js +16 -5
- package/dist/indexer/search/search-fields.js +1 -30
- package/dist/integrations/agent/engine-resolution.js +15 -1
- package/dist/integrations/agent/model-map.js +16 -10
- package/dist/integrations/agent/prompts.js +13 -6
- package/dist/integrations/lockfile.js +22 -7
- package/dist/llm/client.js +28 -8
- package/dist/llm/embedders/remote.js +3 -2
- package/dist/llm/index-passes.js +3 -2
- package/dist/output/shapes/passthrough.js +9 -3
- package/dist/output/shapes.js +50 -3
- package/dist/output/text/proposal-format.js +5 -0
- package/dist/output/text/workflow-format.js +8 -1
- package/dist/scripts/akm-migrate-node.js +1737 -1392
- package/dist/scripts/akm-migrate.js +1736 -1391
- package/dist/setup/setup.js +14 -21
- package/dist/sources/include.js +150 -20
- package/dist/sources/providers/git-install.js +14 -12
- package/dist/sources/providers/git-provider.js +3 -3
- package/dist/sources/snapshot-fetchers/website-ingest.js +54 -16
- package/dist/sources/website-url.js +12 -4
- package/dist/storage/engines/sqlite-migrations.js +40 -10
- package/dist/storage/like-pattern.js +7 -0
- package/dist/storage/repositories/extract-sessions-repository.js +23 -0
- package/dist/storage/repositories/index-connection.js +27 -10
- package/dist/storage/repositories/index-entry-schema.js +19 -2
- package/dist/storage/repositories/index-schema.js +30 -9
- package/dist/storage/repositories/proposals-repository.js +2 -1
- package/dist/storage/repositories/task-history-repository.js +14 -7
- package/dist/storage/repositories/workflow-runs-repository.js +133 -11
- package/dist/storage/sqlite-read-snapshot.js +11 -9
- package/dist/tasks/backends/cron.js +34 -5
- package/dist/tasks/backends/launchd.js +23 -26
- package/dist/tasks/backends/schtasks.js +50 -3
- package/dist/tasks/frozen-script.js +2 -0
- package/dist/tasks/prepare/prepare.js +2 -7
- package/dist/tasks/prepare/script-capture.js +38 -6
- package/dist/tasks/schedule.js +154 -13
- package/dist/tasks/source/task-source-v3-frozen.js +0 -1
- package/dist/tasks/source/task-source-v4.js +0 -1
- package/dist/workflows/exec/child-workflow.js +2 -3
- package/dist/workflows/exec/exec-unit.js +3 -4
- package/dist/workflows/exec/run-workflow.js +20 -11
- package/dist/workflows/exec/step-work.js +76 -56
- package/dist/workflows/freeze/resolve-steps.js +19 -11
- package/dist/workflows/freeze/source-freeze.js +7 -0
- package/dist/workflows/freeze/targets/child-workflow.js +12 -18
- package/dist/workflows/freeze/targets/command.js +14 -2
- package/dist/workflows/ir/environment-v4.js +4 -2
- package/dist/workflows/ir/freeze-v4.js +2 -5
- package/dist/workflows/ir/plan-hash.js +0 -3
- package/dist/workflows/ir/schema-v4.js +14 -9
- package/dist/workflows/ir/schema.js +1 -3
- package/dist/workflows/parser.js +1 -1
- package/dist/workflows/resource-limits.js +35 -48
- package/dist/workflows/runtime/plan-classifier.js +89 -41
- package/dist/workflows/runtime/run-outputs.js +1 -21
- package/dist/workflows/runtime/runs.js +104 -154
- package/dist/workflows/source-files.js +28 -54
- package/dist/workflows/source-ir/program.js +2 -2
- package/dist/workflows/source-ir/semantics.js +5 -23
- package/docs/migration/v0.9.1-to-v0.9.2.md +20 -0
- package/docs/reference/cli.md +92 -17
- package/package.json +1 -1
- 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
|
-
|
|
133
|
+
warnIfNonCanonicalIndexGeneration(db, resolvedPath);
|
|
134
134
|
},
|
|
135
135
|
create: false,
|
|
136
136
|
});
|
|
137
137
|
}
|
|
138
|
-
function
|
|
138
|
+
function warnIfNonCanonicalIndexGeneration(db, resolvedPath) {
|
|
139
139
|
if (isCanonicalIndexGeneration(db))
|
|
140
140
|
return;
|
|
141
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
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 <
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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) {
|