akm-cli 0.9.12 → 0.9.14-beta.1
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 +100 -0
- package/dist/assets/workflows/workflow-template.md +4 -0
- package/dist/commands/improve/eligibility.js +27 -15
- package/dist/commands/improve/improve.js +1 -0
- package/dist/commands/lint/base-linter.js +10 -0
- package/dist/commands/proposal/drain.js +48 -6
- package/dist/commands/proposal/proposal-cli.js +1 -0
- package/dist/commands/read/curate.js +3 -2
- package/dist/commands/read/show.js +26 -9
- package/dist/core/adapter/adapters/akm-adapter.js +5 -1
- package/dist/core/asset/markdown-fragments.js +146 -0
- package/dist/core/config/config-walker.js +7 -3
- package/dist/core/config/config.js +21 -12
- package/dist/core/config/schema/primitives.js +8 -2
- package/dist/core/errors.js +2 -0
- package/dist/core/lexical-score.js +25 -0
- package/dist/core/type-presentation.js +36 -4
- package/dist/indexer/index-written-assets.js +4 -0
- package/dist/indexer/indexer.js +5 -2
- package/dist/indexer/passes/metadata.js +64 -1
- package/dist/indexer/scan/doc-to-entry.js +3 -0
- package/dist/indexer/scan/drain-dir.js +33 -22
- package/dist/indexer/search/db-search.js +72 -14
- package/dist/indexer/search/name-match.js +35 -0
- package/dist/indexer/search/ranking-contributors.js +15 -12
- package/dist/indexer/search/ranking.js +42 -18
- package/dist/indexer/usage/show-usage.js +14 -2
- package/dist/llm/client.js +12 -8
- package/dist/llm/embedders/remote.js +3 -2
- package/dist/llm/graph-extract.js +18 -67
- package/dist/output/shapes.js +46 -1
- package/dist/output/text/proposal-format.js +5 -0
- package/dist/scripts/akm-migrate-node.js +648 -253
- package/dist/scripts/akm-migrate.js +648 -253
- package/dist/storage/repositories/index-connection.js +23 -8
- package/dist/storage/repositories/index-entries-repository.js +3 -2
- package/dist/storage/repositories/index-entry-schema.js +43 -3
- package/dist/storage/repositories/index-fts-repository.js +160 -14
- package/dist/storage/repositories/index-schema.js +8 -18
- package/dist/storage/repositories/workflow-runs-repository.js +118 -10
- package/dist/workflows/exec/run-workflow.js +1 -1
- package/dist/workflows/exec/step-work.js +41 -0
- package/dist/workflows/parser.js +1 -1
- package/dist/workflows/runtime/runs.js +29 -5
- package/docs/migration/release-notes/0.9.14.md +26 -0
- package/docs/migration/release-notes/README.md +2 -0
- package/docs/reference/cli.md +18 -0
- package/package.json +1 -1
|
@@ -126,23 +126,38 @@ export function openExistingDatabase(dbPath) {
|
|
|
126
126
|
if (classifyPathAccess(resolvedPath).access === "absent") {
|
|
127
127
|
throw new Error(`Index database not found at ${resolvedPath}. Run 'akm index' to build it.`);
|
|
128
128
|
}
|
|
129
|
-
|
|
129
|
+
const db = openManagedDatabase({
|
|
130
130
|
path: resolvedPath,
|
|
131
131
|
init: (db) => {
|
|
132
132
|
loadVecExtension(db);
|
|
133
|
-
warnIfNonCanonicalIndexGeneration(db, resolvedPath);
|
|
134
133
|
},
|
|
135
134
|
create: false,
|
|
136
135
|
});
|
|
136
|
+
try {
|
|
137
|
+
assertCanonicalIndexGeneration(db, resolvedPath);
|
|
138
|
+
return db;
|
|
139
|
+
}
|
|
140
|
+
catch (error) {
|
|
141
|
+
db.close();
|
|
142
|
+
throw error;
|
|
143
|
+
}
|
|
137
144
|
}
|
|
138
|
-
|
|
145
|
+
/**
|
|
146
|
+
* Read callers must never receive a known-incompatible derived index. The
|
|
147
|
+
* writable opener owns rebuilding an older generation; a reader can only
|
|
148
|
+
* report the one action that is safe for the direction of the mismatch.
|
|
149
|
+
*/
|
|
150
|
+
function assertCanonicalIndexGeneration(db, resolvedPath) {
|
|
139
151
|
if (isCanonicalIndexGeneration(db))
|
|
140
152
|
return;
|
|
141
153
|
const classification = classifyIndexGeneration(db);
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
154
|
+
const stored = classification.storedVersion ?? "unknown";
|
|
155
|
+
if (classification.status === "newer") {
|
|
156
|
+
throw new ConfigError(`Index database at ${resolvedPath} was built by a newer akm (stored generation ${stored}; ` +
|
|
157
|
+
`this binary understands ${CANONICAL_INDEX_DB_VERSION}). Upgrade akm to use this index.`, "INDEX_SCHEMA_INCOMPATIBLE", "Upgrade akm to a version that understands this index generation.");
|
|
158
|
+
}
|
|
159
|
+
throw new ConfigError(`Index database at ${resolvedPath} is not usable with this akm's derived schema (stored generation ${stored}; ` +
|
|
160
|
+
`this binary understands ${CANONICAL_INDEX_DB_VERSION}). Run 'akm index' to rebuild it.`, "INDEX_SCHEMA_INCOMPATIBLE", "Run `akm index` to rebuild the derived index from the currently materialized sources.");
|
|
146
161
|
}
|
|
147
162
|
/**
|
|
148
163
|
* Refuse to treat an UNREADABLE index as a missing one (#791).
|
|
@@ -204,7 +219,7 @@ export function openReadonlyExistingDatabase(dbPath, options) {
|
|
|
204
219
|
// connection, so apply just that one.
|
|
205
220
|
try {
|
|
206
221
|
db.exec(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`);
|
|
207
|
-
|
|
222
|
+
assertCanonicalIndexGeneration(db, resolvedPath);
|
|
208
223
|
return db;
|
|
209
224
|
}
|
|
210
225
|
catch (error) {
|
|
@@ -16,6 +16,7 @@ import { bestEffort } from "../../core/best-effort.js";
|
|
|
16
16
|
import { isPathAbsent } from "../../core/path-access.js";
|
|
17
17
|
import { getStateDbPath, withStateDb } from "../../core/state-db.js";
|
|
18
18
|
import { warn } from "../../core/warn.js";
|
|
19
|
+
import { getMarkdownFragmentContent, hasMarkdownFragmentContent, } from "../../indexer/passes/metadata.js";
|
|
19
20
|
import { buildSearchText } from "../../indexer/search/search-fields.js";
|
|
20
21
|
import { ENTRY_COLUMNS, rowToIndexedEntry } from "./index-entry-mapper.js";
|
|
21
22
|
import { deleteFtsEntries, replaceFtsEntry } from "./index-fts-repository.js";
|
|
@@ -47,7 +48,7 @@ export function upsertEntry(db, filePath, entry, searchText, provenance, content
|
|
|
47
48
|
throw new Error("upsertEntry: item_ref not found after upsert");
|
|
48
49
|
if (previous?.id === result.id && previous.search_text !== searchText)
|
|
49
50
|
deleteEntryVectors(db, result.id);
|
|
50
|
-
replaceFtsEntry(db, result.id, entry);
|
|
51
|
+
replaceFtsEntry(db, result.id, entry, hasMarkdownFragmentContent(entry) ? (getMarkdownFragmentContent(entry) ?? null) : undefined);
|
|
51
52
|
return result.id;
|
|
52
53
|
};
|
|
53
54
|
// Always enter the driver's transaction wrapper. Both supported SQLite
|
|
@@ -249,7 +250,7 @@ export function rekeyEntryInPlace(db, opts) {
|
|
|
249
250
|
if (row.search_text !== searchText)
|
|
250
251
|
deleteEntryVectors(db, row.id);
|
|
251
252
|
if (document)
|
|
252
|
-
replaceFtsEntry(db, row.id, document);
|
|
253
|
+
replaceFtsEntry(db, row.id, document, hasMarkdownFragmentContent(document) ? (getMarkdownFragmentContent(document) ?? null) : undefined);
|
|
253
254
|
else
|
|
254
255
|
deleteFtsEntries(db, [row.id]);
|
|
255
256
|
})();
|
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
* serving preflights, and read-only evaluator tooling cannot drift into
|
|
11
11
|
* separate definitions of "current".
|
|
12
12
|
*/
|
|
13
|
-
|
|
13
|
+
// v23 adds an isolated fragment FTS population. v22 is the last shipped
|
|
14
|
+
// generation and is intentionally rebuilt rather than migrated in place.
|
|
15
|
+
export const CANONICAL_INDEX_DB_VERSION = 23;
|
|
14
16
|
export const CANONICAL_ENTRY_SCHEMA_SQL = `
|
|
15
17
|
CREATE TABLE IF NOT EXISTS entries (
|
|
16
18
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -31,6 +33,31 @@ export const CANONICAL_ENTRY_SCHEMA_SQL = `
|
|
|
31
33
|
CREATE INDEX IF NOT EXISTS idx_entries_type ON entries(type);
|
|
32
34
|
CREATE INDEX IF NOT EXISTS idx_entries_file_path ON entries(file_path);
|
|
33
35
|
CREATE INDEX IF NOT EXISTS idx_entries_derived_from ON entries(derived_from);
|
|
36
|
+
|
|
37
|
+
-- Keep parent metadata and body fragments in separate FTS populations.
|
|
38
|
+
-- Combining them changes parent-document IDF and conjunction semantics.
|
|
39
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS entries_fts USING fts5(
|
|
40
|
+
entry_id UNINDEXED,
|
|
41
|
+
name,
|
|
42
|
+
description,
|
|
43
|
+
tags,
|
|
44
|
+
hints,
|
|
45
|
+
content,
|
|
46
|
+
tokenize='porter unicode61'
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
CREATE TABLE IF NOT EXISTS entry_fragments (
|
|
50
|
+
entry_id INTEGER PRIMARY KEY REFERENCES entries(id) ON DELETE CASCADE,
|
|
51
|
+
safe_markdown TEXT NOT NULL
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
CREATE VIRTUAL TABLE IF NOT EXISTS entry_fragments_fts USING fts5(
|
|
55
|
+
entry_id UNINDEXED,
|
|
56
|
+
fragment_id UNINDEXED,
|
|
57
|
+
fragment_ordinal UNINDEXED,
|
|
58
|
+
content,
|
|
59
|
+
tokenize='porter unicode61'
|
|
60
|
+
);
|
|
34
61
|
`;
|
|
35
62
|
const CANONICAL_ENTRY_SCHEMA_FINGERPRINT = {
|
|
36
63
|
tableSql: "CREATE TABLE entries ( id INTEGER PRIMARY KEY AUTOINCREMENT, item_ref TEXT NOT NULL UNIQUE, bundle_id TEXT NOT NULL, component_id TEXT NOT NULL, concept_id TEXT NOT NULL, adapter_id TEXT NOT NULL, type TEXT NOT NULL, file_path TEXT NOT NULL, content_hash TEXT, document_json TEXT NOT NULL, search_text TEXT NOT NULL, derived_from TEXT )",
|
|
@@ -182,6 +209,11 @@ const CANONICAL_ENTRY_SCHEMA_FINGERPRINT = {
|
|
|
182
209
|
],
|
|
183
210
|
},
|
|
184
211
|
],
|
|
212
|
+
searchSurfaces: {
|
|
213
|
+
entriesFtsSql: "CREATE VIRTUAL TABLE entries_fts USING fts5( entry_id UNINDEXED, name, description, tags, hints, content, tokenize='porter unicode61' )",
|
|
214
|
+
fragmentSourceSql: "CREATE TABLE entry_fragments ( entry_id INTEGER PRIMARY KEY REFERENCES entries(id) ON DELETE CASCADE, safe_markdown TEXT NOT NULL )",
|
|
215
|
+
fragmentsFtsSql: "CREATE VIRTUAL TABLE entry_fragments_fts USING fts5( entry_id UNINDEXED, fragment_id UNINDEXED, fragment_ordinal UNINDEXED, content, tokenize='porter unicode61' )",
|
|
216
|
+
},
|
|
185
217
|
};
|
|
186
218
|
function sqlString(value) {
|
|
187
219
|
return `'${value.replaceAll("'", "''")}'`;
|
|
@@ -191,8 +223,11 @@ function normalizeSchemaSql(value) {
|
|
|
191
223
|
return null;
|
|
192
224
|
return value.replace(/\s+/g, " ").trim();
|
|
193
225
|
}
|
|
226
|
+
function readNamedTableSql(db, name) {
|
|
227
|
+
const row = db.prepare(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ${sqlString(name)}`).get();
|
|
228
|
+
return normalizeSchemaSql(row?.sql);
|
|
229
|
+
}
|
|
194
230
|
export function readEntrySchemaFingerprint(db) {
|
|
195
|
-
const tableRow = db.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'entries'").get();
|
|
196
231
|
const sqliteSequenceTable = db.prepare("SELECT 1 AS present FROM sqlite_master WHERE type = 'table' AND name = 'sqlite_sequence'").get() !=
|
|
197
232
|
null;
|
|
198
233
|
const maxId = Number(db.prepare("SELECT COALESCE(MAX(id), 0) AS maxId FROM entries").get().maxId);
|
|
@@ -229,11 +264,16 @@ export function readEntrySchemaFingerprint(db) {
|
|
|
229
264
|
}))
|
|
230
265
|
.sort((left, right) => left.name.localeCompare(right.name));
|
|
231
266
|
return {
|
|
232
|
-
tableSql:
|
|
267
|
+
tableSql: readNamedTableSql(db, "entries"),
|
|
233
268
|
sqliteSequenceTable,
|
|
234
269
|
sqliteSequenceValid,
|
|
235
270
|
columns,
|
|
236
271
|
indexes,
|
|
272
|
+
searchSurfaces: {
|
|
273
|
+
entriesFtsSql: readNamedTableSql(db, "entries_fts"),
|
|
274
|
+
fragmentSourceSql: readNamedTableSql(db, "entry_fragments"),
|
|
275
|
+
fragmentsFtsSql: readNamedTableSql(db, "entry_fragments_fts"),
|
|
276
|
+
},
|
|
237
277
|
};
|
|
238
278
|
}
|
|
239
279
|
export function hasCanonicalEntrySchema(db) {
|
|
@@ -7,11 +7,14 @@
|
|
|
7
7
|
* Owns the `entries_fts` full-text query path, per-entry projections, and the
|
|
8
8
|
* explicit full recovery rebuild.
|
|
9
9
|
*/
|
|
10
|
+
import { fragmentForSelector, splitMarkdownFragments } from "../../core/asset/markdown-fragments.js";
|
|
11
|
+
import { stableFtsScore } from "../../core/lexical-score.js";
|
|
10
12
|
import { warn } from "../../core/warn.js";
|
|
11
13
|
import { buildLexicalQueryPlan } from "../../indexer/search/fts-query.js";
|
|
12
14
|
import { buildSearchFields } from "../../indexer/search/search-fields.js";
|
|
13
15
|
import { SQLITE_CHUNK_SIZE } from "./index-sql.js";
|
|
14
16
|
const INSERT_FTS_SQL = "INSERT INTO entries_fts (entry_id, name, description, tags, hints, content) VALUES (?, ?, ?, ?, ?, ?)";
|
|
17
|
+
const INSERT_FRAGMENT_SQL = "INSERT INTO entry_fragments_fts (entry_id, fragment_id, fragment_ordinal, content) VALUES (?, ?, ?, ?)";
|
|
15
18
|
const ftsMutationStatementsByDb = new WeakMap();
|
|
16
19
|
function getFtsMutationStatements(db) {
|
|
17
20
|
const existing = ftsMutationStatementsByDb.get(db);
|
|
@@ -20,16 +23,34 @@ function getFtsMutationStatements(db) {
|
|
|
20
23
|
const statements = {
|
|
21
24
|
deleteOne: db.prepare("DELETE FROM entries_fts WHERE entry_id = ?"),
|
|
22
25
|
insert: db.prepare(INSERT_FTS_SQL),
|
|
26
|
+
deleteFragments: db.prepare("DELETE FROM entry_fragments_fts WHERE entry_id = ?"),
|
|
27
|
+
upsertFragmentSource: db.prepare("INSERT INTO entry_fragments (entry_id, safe_markdown) VALUES (?, ?) ON CONFLICT(entry_id) DO UPDATE SET safe_markdown = excluded.safe_markdown"),
|
|
28
|
+
deleteFragmentSource: db.prepare("DELETE FROM entry_fragments WHERE entry_id = ?"),
|
|
29
|
+
insertFragment: db.prepare(INSERT_FRAGMENT_SQL),
|
|
23
30
|
};
|
|
24
31
|
ftsMutationStatementsByDb.set(db, statements);
|
|
25
32
|
return statements;
|
|
26
33
|
}
|
|
27
34
|
/** Replace one entry's derived FTS projection inside the caller's transaction. */
|
|
28
|
-
export function replaceFtsEntry(db, entryId, entry) {
|
|
35
|
+
export function replaceFtsEntry(db, entryId, entry, fragmentContent) {
|
|
29
36
|
const fields = buildSearchFields(entry);
|
|
30
37
|
const statements = getFtsMutationStatements(db);
|
|
31
38
|
statements.deleteOne.run(entryId);
|
|
32
39
|
statements.insert.run(entryId, fields.name, fields.description, fields.tags, fields.hints, fields.content);
|
|
40
|
+
if (fragmentContent === undefined) {
|
|
41
|
+
// Metadata-only re-upserts and re-keys deserialize the public document
|
|
42
|
+
// without the internal substrate. Leave the persisted source untouched.
|
|
43
|
+
// A scan that did read Markdown always supplies a value below.
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
statements.deleteFragments.run(entryId);
|
|
47
|
+
statements.deleteFragmentSource.run(entryId);
|
|
48
|
+
if (!fragmentContent)
|
|
49
|
+
return;
|
|
50
|
+
statements.upsertFragmentSource.run(entryId, fragmentContent);
|
|
51
|
+
for (const fragment of splitMarkdownFragments(fragmentContent)) {
|
|
52
|
+
statements.insertFragment.run(entryId, fragment.fragmentId, fragment.ordinal, fragment.text.toLowerCase());
|
|
53
|
+
}
|
|
33
54
|
}
|
|
34
55
|
/** Delete derived FTS projections for canonical entries that are being removed. */
|
|
35
56
|
export function deleteFtsEntries(db, entryIds) {
|
|
@@ -37,6 +58,8 @@ export function deleteFtsEntries(db, entryIds) {
|
|
|
37
58
|
const chunk = entryIds.slice(i, i + SQLITE_CHUNK_SIZE);
|
|
38
59
|
const placeholders = chunk.map(() => "?").join(",");
|
|
39
60
|
db.prepare(`DELETE FROM entries_fts WHERE entry_id IN (${placeholders})`).run(...chunk);
|
|
61
|
+
db.prepare(`DELETE FROM entry_fragments_fts WHERE entry_id IN (${placeholders})`).run(...chunk);
|
|
62
|
+
db.prepare(`DELETE FROM entry_fragments WHERE entry_id IN (${placeholders})`).run(...chunk);
|
|
40
63
|
}
|
|
41
64
|
}
|
|
42
65
|
export function searchFts(db, query, limit, entryType, excludeTypes) {
|
|
@@ -58,41 +81,92 @@ export function searchFts(db, query, limit, entryType, excludeTypes) {
|
|
|
58
81
|
// input whose filler terms prevented a strict hit.
|
|
59
82
|
return plan.relaxed ? runFtsQuery(db, plan.relaxed, "relaxed", limit, entryType, excludeTypes) : [];
|
|
60
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Resolve an opaque fragment selector from the indexed safe projection, not
|
|
86
|
+
* from current disk. A search result remains self-consistent across a later
|
|
87
|
+
* file edit; the next index refresh atomically publishes the new revision.
|
|
88
|
+
*/
|
|
89
|
+
export function getIndexedMarkdownFragment(db, itemRef, fragmentId) {
|
|
90
|
+
const row = db
|
|
91
|
+
.prepare("SELECT s.safe_markdown FROM entry_fragments_fts f JOIN entry_fragments s ON s.entry_id = f.entry_id JOIN entries e ON e.id = f.entry_id WHERE e.item_ref = ? AND f.fragment_id = ?")
|
|
92
|
+
.get(itemRef, fragmentId);
|
|
93
|
+
const fragment = row ? fragmentForSelector(row.safe_markdown, fragmentId) : undefined;
|
|
94
|
+
return fragment ? { content: fragment.text } : undefined;
|
|
95
|
+
}
|
|
61
96
|
function runFtsQuery(db, ftsQuery, lexicalMatch, limit, entryType, excludeTypes) {
|
|
97
|
+
// Preserve the repository's ordinary limit contract for direct callers.
|
|
98
|
+
// The boundary-expansion rule applies only to a positive candidate pool.
|
|
99
|
+
if (limit <= 0)
|
|
100
|
+
return [];
|
|
62
101
|
// #627 — exclude-type clause. Only applies on the untyped ('any') path; an
|
|
63
102
|
// explicit include filter (entryType) already narrows to a single type, so
|
|
64
103
|
// exclusion is redundant there. An empty list skips the clause entirely
|
|
65
104
|
// (never emit `NOT IN ()`, which is a SQL error / always-false).
|
|
66
105
|
const excludes = excludeTypes && excludeTypes.length > 0 ? excludeTypes : [];
|
|
106
|
+
const candidateBoundaryOffset = Math.max(0, limit - 1);
|
|
67
107
|
// The typed and untyped paths differ only by one `type` WHERE clause
|
|
68
|
-
// equality vs. an optional NOT IN exclusion) and their
|
|
69
|
-
//
|
|
70
|
-
//
|
|
108
|
+
// equality vs. an optional NOT IN exclusion) and their parameter order.
|
|
109
|
+
// Join on integer entry_id directly (no CAST; we store integer). bm25()
|
|
110
|
+
// per-column weights:
|
|
71
111
|
// entry_id(0), name(10), description(5), tags(3), hints(2), content(1).
|
|
72
112
|
let filterClause;
|
|
73
113
|
let params;
|
|
74
114
|
if (entryType && entryType !== "any") {
|
|
75
115
|
filterClause = "AND e.type = ?";
|
|
76
|
-
params = [ftsQuery, entryType,
|
|
116
|
+
params = [ftsQuery, entryType, candidateBoundaryOffset];
|
|
77
117
|
}
|
|
78
118
|
else {
|
|
79
119
|
filterClause = excludes.length > 0 ? `AND e.type NOT IN (${excludes.map(() => "?").join(", ")})` : "";
|
|
80
|
-
// Param order: MATCH, then the NOT IN values, then
|
|
81
|
-
|
|
120
|
+
// Param order: MATCH, then the NOT IN values, then the zero-based
|
|
121
|
+
// candidate-boundary offset.
|
|
122
|
+
params = [ftsQuery, ...excludes, candidateBoundaryOffset];
|
|
82
123
|
}
|
|
83
124
|
const sql = `
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
125
|
+
-- Do not make a SQL-only relevance decision inside a tied BM25 boundary:
|
|
126
|
+
-- the TypeScript ranker adds exact-name, type, and other contributors
|
|
127
|
+
-- afterwards. Materialize BM25 once, locate the Nth score, and admit
|
|
128
|
+
-- every row tied with it. This deliberately makes the result set
|
|
129
|
+
-- data-bound for a pathological all-tied query; that is the only way to
|
|
130
|
+
-- avoid silently dropping a legitimate later ranking winner.
|
|
131
|
+
WITH scored AS MATERIALIZED (
|
|
132
|
+
-- Keep this materialized set deliberately narrow. document_json can be
|
|
133
|
+
-- large, and only rows admitted through the BM25 boundary need it.
|
|
134
|
+
SELECT e.id, bm25(entries_fts, 0, 10.0, 5.0, 3.0, 2.0, 1.0) AS bm25Score
|
|
87
135
|
FROM entries_fts f
|
|
88
136
|
JOIN entries e ON e.id = f.entry_id
|
|
89
137
|
WHERE entries_fts MATCH ?
|
|
90
138
|
${filterClause}
|
|
91
|
-
|
|
92
|
-
|
|
139
|
+
), boundary AS (
|
|
140
|
+
SELECT bm25Score
|
|
141
|
+
FROM scored
|
|
142
|
+
ORDER BY bm25Score
|
|
143
|
+
LIMIT 1 OFFSET ?
|
|
144
|
+
)
|
|
145
|
+
SELECT e.id, e.file_path AS filePath, e.document_json AS documentJson, e.search_text AS searchText,
|
|
146
|
+
e.item_ref AS itemRef, e.bundle_id AS bundleId, e.concept_id AS conceptId, e.adapter_id AS adapterId,
|
|
147
|
+
scored.bm25Score
|
|
148
|
+
FROM scored
|
|
149
|
+
JOIN entries e ON e.id = scored.id
|
|
150
|
+
WHERE NOT EXISTS (SELECT 1 FROM boundary)
|
|
151
|
+
OR scored.bm25Score <= (SELECT bm25Score FROM boundary)
|
|
152
|
+
ORDER BY scored.bm25Score, e.id ASC
|
|
93
153
|
`;
|
|
94
154
|
const rows = db.prepare(sql).all(...params);
|
|
95
|
-
|
|
155
|
+
const results = materializeRows(rows, lexicalMatch);
|
|
156
|
+
// Fragments are a separate, intentionally calibrated evidence population:
|
|
157
|
+
// parent FTS remains the sole implementation of metadata/body conjunction.
|
|
158
|
+
// A selector is emitted only for one fragment that independently satisfies
|
|
159
|
+
// this query. Raw BM25 values are never claimed comparable across tables;
|
|
160
|
+
// each is passed through #933's stable mapping before merge.
|
|
161
|
+
const fragmentResults = hasFragmentFts(db)
|
|
162
|
+
? runFragmentQuery(db, ftsQuery, lexicalMatch, limit, entryType, excludes)
|
|
163
|
+
: [];
|
|
164
|
+
return mergeParentAndFragmentResults(results, fragmentResults);
|
|
165
|
+
}
|
|
166
|
+
function hasFragmentFts(db) {
|
|
167
|
+
return Boolean(db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'entry_fragments_fts'").get());
|
|
168
|
+
}
|
|
169
|
+
function materializeRows(rows, lexicalMatch) {
|
|
96
170
|
const results = [];
|
|
97
171
|
for (const row of rows) {
|
|
98
172
|
let entry;
|
|
@@ -118,6 +192,69 @@ function runFtsQuery(db, ftsQuery, lexicalMatch, limit, entryType, excludeTypes)
|
|
|
118
192
|
}
|
|
119
193
|
return results;
|
|
120
194
|
}
|
|
195
|
+
function runFragmentQuery(db, ftsQuery, lexicalMatch, limit, entryType, excludes) {
|
|
196
|
+
const filter = entryType && entryType !== "any"
|
|
197
|
+
? "AND e.type = ?"
|
|
198
|
+
: excludes.length
|
|
199
|
+
? `AND e.type NOT IN (${excludes.map(() => "?").join(",")})`
|
|
200
|
+
: "";
|
|
201
|
+
const filterParams = entryType && entryType !== "any" ? [entryType] : excludes;
|
|
202
|
+
const candidateBoundaryOffset = Math.max(0, limit - 1);
|
|
203
|
+
// Select the winning child per parent inside SQLite before finding the
|
|
204
|
+
// candidate boundary. A document with many matching fragments therefore
|
|
205
|
+
// occupies one parent slot, while a boundary tie retains every parent for
|
|
206
|
+
// the TypeScript ranker to decide with its non-BM25 contributors. This has
|
|
207
|
+
// one FTS query and no OFFSET walk; the returned boundary is intentionally
|
|
208
|
+
// data-bound for a pathological all-tied query, just like parent FTS.
|
|
209
|
+
const sql = `
|
|
210
|
+
WITH matches AS MATERIALIZED (
|
|
211
|
+
-- Keep repeated child rows as narrow as parent FTS's scored CTE. The
|
|
212
|
+
-- document projection can be large; hydrate it only after the one-child
|
|
213
|
+
-- per-parent collapse and BM25 boundary filtering below.
|
|
214
|
+
SELECT e.id, f.fragment_id AS fragmentId, f.fragment_ordinal AS fragmentOrdinal,
|
|
215
|
+
bm25(entry_fragments_fts) AS bm25Score
|
|
216
|
+
FROM entry_fragments_fts f JOIN entries e ON e.id = f.entry_id
|
|
217
|
+
WHERE entry_fragments_fts MATCH ? ${filter}
|
|
218
|
+
), ranked AS MATERIALIZED (
|
|
219
|
+
SELECT *, ROW_NUMBER() OVER (PARTITION BY id ORDER BY bm25Score ASC, fragmentOrdinal ASC, fragmentId ASC) AS parentRank
|
|
220
|
+
FROM matches
|
|
221
|
+
), parents AS MATERIALIZED (
|
|
222
|
+
SELECT * FROM ranked WHERE parentRank = 1
|
|
223
|
+
), boundary AS (
|
|
224
|
+
SELECT bm25Score FROM parents ORDER BY bm25Score ASC LIMIT 1 OFFSET ?
|
|
225
|
+
)
|
|
226
|
+
SELECT e.id, e.file_path AS filePath, e.document_json AS documentJson, e.search_text AS searchText,
|
|
227
|
+
e.item_ref AS itemRef, e.bundle_id AS bundleId, e.concept_id AS conceptId, e.adapter_id AS adapterId,
|
|
228
|
+
parents.fragmentId, parents.bm25Score
|
|
229
|
+
FROM parents JOIN entries e ON e.id = parents.id
|
|
230
|
+
WHERE NOT EXISTS (SELECT 1 FROM boundary)
|
|
231
|
+
OR parents.bm25Score <= (SELECT bm25Score FROM boundary)
|
|
232
|
+
ORDER BY parents.bm25Score ASC, parents.id ASC`;
|
|
233
|
+
const rows = db.prepare(sql).all(ftsQuery, ...filterParams, candidateBoundaryOffset);
|
|
234
|
+
const results = [];
|
|
235
|
+
for (const row of rows) {
|
|
236
|
+
const [result] = materializeRows([row], lexicalMatch);
|
|
237
|
+
if (result) {
|
|
238
|
+
results.push({
|
|
239
|
+
...result,
|
|
240
|
+
fragmentId: row.fragmentId,
|
|
241
|
+
lexicalScore: stableFtsScore(result.bm25Score, "fragment"),
|
|
242
|
+
});
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return results;
|
|
246
|
+
}
|
|
247
|
+
function mergeParentAndFragmentResults(parents, fragments) {
|
|
248
|
+
const winners = new Map();
|
|
249
|
+
for (const parent of parents)
|
|
250
|
+
winners.set(parent.id, { ...parent, lexicalScore: stableFtsScore(parent.bm25Score) });
|
|
251
|
+
for (const fragment of fragments) {
|
|
252
|
+
const existing = winners.get(fragment.id);
|
|
253
|
+
if (!existing || (fragment.lexicalScore ?? 0) > (existing.lexicalScore ?? 0))
|
|
254
|
+
winners.set(fragment.id, fragment);
|
|
255
|
+
}
|
|
256
|
+
return [...winners.values()].sort((left, right) => (right.lexicalScore ?? 0) - (left.lexicalScore ?? 0) || left.id - right.id);
|
|
257
|
+
}
|
|
121
258
|
/**
|
|
122
259
|
* Explicitly rebuild the complete FTS5 projection from canonical entries.
|
|
123
260
|
* Ordinary entry mutations do not call this: `upsertEntry` and the delete
|
|
@@ -131,8 +268,12 @@ function runFtsQuery(db, ftsQuery, lexicalMatch, limit, entryType, excludeTypes)
|
|
|
131
268
|
export function rebuildFts(db) {
|
|
132
269
|
db.transaction(() => {
|
|
133
270
|
db.exec("DELETE FROM entries_fts");
|
|
134
|
-
|
|
271
|
+
db.exec("DELETE FROM entry_fragments_fts");
|
|
272
|
+
const rows = db
|
|
273
|
+
.prepare("SELECT e.id, e.document_json, f.safe_markdown FROM entries e LEFT JOIN entry_fragments f ON f.entry_id = e.id")
|
|
274
|
+
.all();
|
|
135
275
|
const insertStmt = db.prepare(INSERT_FTS_SQL);
|
|
276
|
+
const fragmentStmt = db.prepare(INSERT_FRAGMENT_SQL);
|
|
136
277
|
let skipped = 0;
|
|
137
278
|
for (const row of rows) {
|
|
138
279
|
let entry;
|
|
@@ -146,6 +287,11 @@ export function rebuildFts(db) {
|
|
|
146
287
|
continue;
|
|
147
288
|
}
|
|
148
289
|
insertStmt.run(row.id, fields.name, fields.description, fields.tags, fields.hints, fields.content);
|
|
290
|
+
if (row.safe_markdown) {
|
|
291
|
+
for (const fragment of splitMarkdownFragments(row.safe_markdown)) {
|
|
292
|
+
fragmentStmt.run(row.id, fragment.fragmentId, fragment.ordinal, fragment.text.toLowerCase());
|
|
293
|
+
}
|
|
294
|
+
}
|
|
149
295
|
}
|
|
150
296
|
if (skipped > 0) {
|
|
151
297
|
warn(`[db] rebuildFts: skipped ${skipped} entr${skipped === 1 ? "y" : "ies"} with invalid document_json`);
|
|
@@ -179,8 +179,8 @@ function rebuildIncompatibleIndexGeneration(db) {
|
|
|
179
179
|
const classification = classifyIndexGeneration(db);
|
|
180
180
|
if (classification.status === "newer") {
|
|
181
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
|
|
183
|
-
"
|
|
182
|
+
`this binary understands generation ${CANONICAL_INDEX_DB_VERSION}). Refusing to modify it — upgrade akm ` +
|
|
183
|
+
"to use this index.", "INDEX_SCHEMA_INCOMPATIBLE", "Upgrade akm to a version that understands this index generation.");
|
|
184
184
|
}
|
|
185
185
|
warn(`Index database generation ${classification.storedVersion ?? "unknown"} is older than this akm's generation ` +
|
|
186
186
|
`${CANONICAL_INDEX_DB_VERSION} — rebuilding the derived index (entries, FTS, embeddings, graph tables, ` +
|
|
@@ -202,6 +202,8 @@ function rebuildIncompatibleIndexGeneration(db) {
|
|
|
202
202
|
db.exec("DROP TABLE IF EXISTS graph_extraction_queue");
|
|
203
203
|
db.exec("DROP TABLE IF EXISTS graph_meta");
|
|
204
204
|
db.exec("DROP TABLE IF EXISTS entries_fts_dirty");
|
|
205
|
+
db.exec("DROP TABLE IF EXISTS entry_fragments_fts");
|
|
206
|
+
db.exec("DROP TABLE IF EXISTS entry_fragments");
|
|
205
207
|
db.exec("DROP TABLE IF EXISTS entries_fts");
|
|
206
208
|
db.exec("DROP TABLE IF EXISTS embeddings");
|
|
207
209
|
db.exec("DROP TABLE IF EXISTS utility_scores_scoped");
|
|
@@ -229,7 +231,6 @@ export function ensureSchema(db, embeddingDim) {
|
|
|
229
231
|
// second persisted representation and was never used by current execution.
|
|
230
232
|
// index.db is derived state, so remove the obsolete table on every open.
|
|
231
233
|
db.exec("DROP TABLE IF EXISTS workflow_documents");
|
|
232
|
-
setMeta(db, "version", String(DB_VERSION));
|
|
233
234
|
// BLOB-based embedding storage (always available, no sqlite-vec needed)
|
|
234
235
|
db.exec(`
|
|
235
236
|
CREATE TABLE IF NOT EXISTS embeddings (
|
|
@@ -238,21 +239,6 @@ export function ensureSchema(db, embeddingDim) {
|
|
|
238
239
|
FOREIGN KEY (id) REFERENCES entries(id)
|
|
239
240
|
);
|
|
240
241
|
`);
|
|
241
|
-
// FTS5 table — multi-column with per-field weighting via bm25()
|
|
242
|
-
const ftsExists = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='entries_fts'").get();
|
|
243
|
-
if (!ftsExists) {
|
|
244
|
-
db.exec(`
|
|
245
|
-
CREATE VIRTUAL TABLE entries_fts USING fts5(
|
|
246
|
-
entry_id UNINDEXED,
|
|
247
|
-
name,
|
|
248
|
-
description,
|
|
249
|
-
tags,
|
|
250
|
-
hints,
|
|
251
|
-
content,
|
|
252
|
-
tokenize='porter unicode61'
|
|
253
|
-
);
|
|
254
|
-
`);
|
|
255
|
-
}
|
|
256
242
|
// usage_events lives in state.db. utility_scores remains a regenerable
|
|
257
243
|
// index.db cache.
|
|
258
244
|
// Utility scores table (aggregated per-entry utility metrics)
|
|
@@ -394,6 +380,10 @@ export function ensureSchema(db, embeddingDim) {
|
|
|
394
380
|
// Registry index cache table — caches remote registry index documents so
|
|
395
381
|
// `akm search` does not hit the network on every invocation.
|
|
396
382
|
db.exec(REGISTRY_INDEX_CACHE_DDL);
|
|
383
|
+
// Write the generation stamp only after every required DDL surface exists.
|
|
384
|
+
// A crash before this point leaves an unversioned generation that the next
|
|
385
|
+
// writable open safely rebuilds instead of admitting a partial v23 index.
|
|
386
|
+
setMeta(db, "version", String(DB_VERSION));
|
|
397
387
|
}
|
|
398
388
|
/**
|
|
399
389
|
* Returns true when a table exists in the current database.
|
|
@@ -5,6 +5,7 @@ import { randomUUID } from "node:crypto";
|
|
|
5
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";
|
|
8
9
|
import { escapeLikePattern } from "../like-pattern.js";
|
|
9
10
|
import { resolveStorageLocations } from "../locations.js";
|
|
10
11
|
import { insertEventOnce, insertEventStrict } from "./events-repository.js";
|
|
@@ -21,6 +22,51 @@ function assertAttemptReservationLease(input, run) {
|
|
|
21
22
|
throw new UsageError(`Workflow run ${input.runId} engine lease expired before durable dispatch reservation.`, "RESOURCE_ALREADY_EXISTS");
|
|
22
23
|
}
|
|
23
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
|
+
}
|
|
24
70
|
/**
|
|
25
71
|
* Repository owning every raw SQL statement against `workflow_runs` and
|
|
26
72
|
* `workflow_run_steps`. It is DB-location-agnostic: the lifecycle helper
|
|
@@ -365,25 +411,57 @@ export class WorkflowRunsRepository {
|
|
|
365
411
|
* A live lease held by anyone (including a stale copy of the same holder)
|
|
366
412
|
* is NOT reclaimable through this method; the single UPDATE is the whole
|
|
367
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.
|
|
368
430
|
*/
|
|
369
431
|
acquireEngineLease(runId, holder, until, now) {
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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
|
+
}
|
|
377
452
|
}
|
|
378
453
|
/**
|
|
379
454
|
* Extend the lease expiry — only while `holder` still owns it. Returns
|
|
380
455
|
* false when the lease was lost (expired and claimed by another engine),
|
|
381
|
-
* 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).
|
|
382
460
|
*/
|
|
383
461
|
renewEngineLease(runId, holder, until) {
|
|
384
|
-
const result = this.db
|
|
462
|
+
const result = runLeaseStatementWithRetry(() => this.db
|
|
385
463
|
.prepare("UPDATE workflow_runs SET engine_lease_until = ? WHERE id = ? AND engine_lease_holder = ? AND status = 'active'")
|
|
386
|
-
.run(until, runId, holder);
|
|
464
|
+
.run(until, runId, holder));
|
|
387
465
|
return Number(result.changes) > 0;
|
|
388
466
|
}
|
|
389
467
|
/**
|
|
@@ -396,6 +474,36 @@ export class WorkflowRunsRepository {
|
|
|
396
474
|
.prepare("UPDATE workflow_runs SET engine_lease_holder = NULL, engine_lease_until = NULL WHERE id = ? AND engine_lease_holder = ? AND status <> 'failed'")
|
|
397
475
|
.run(runId, holder);
|
|
398
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
|
+
}
|
|
399
507
|
// ── durable v4 append-only dispatch attempts (migration 022) ─────────────
|
|
400
508
|
getUnitAttempts(runId, unitId) {
|
|
401
509
|
return this.db
|