akm-cli 0.9.17-alpha.9 → 0.9.18
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 +296 -2025
- package/dist/cli/unknown-flags.js +24 -1
- package/dist/cli.js +46 -1
- package/dist/commands/health/archive-usage.js +9 -15
- package/dist/commands/health/checks.js +6 -6
- package/dist/commands/health/improve-metrics.js +25 -12
- package/dist/commands/health.js +3 -3
- package/dist/commands/improve/consolidate/pair-pass.js +2 -2
- package/dist/commands/improve/consolidate.js +3 -3
- package/dist/commands/improve/distill.js +1 -1
- package/dist/commands/improve/memory/memory-improve.js +8 -15
- package/dist/commands/improve/preparation.js +2 -0
- package/dist/commands/improve/reflect.js +1 -1
- package/dist/commands/improve/stage.js +5 -3
- package/dist/commands/proposal/repository.js +5 -1
- package/dist/commands/sources/info.js +122 -18
- package/dist/commands/sources/stash-cli.js +21 -1
- package/dist/core/improve-result.js +6 -1
- package/dist/output/text/command-format.js +9 -0
- package/dist/scripts/akm-migrate-node.js +29 -2
- package/dist/scripts/akm-migrate.js +29 -2
- package/dist/sources/providers/git-stash.js +28 -0
- package/dist/storage/repositories/index-connection.js +5 -2
- package/dist/storage/sqlite-read-snapshot.js +46 -2
- package/dist/storage/state-db-integrity.js +12 -9
- package/docs/migration/README.md +1 -1
- package/docs/migration/release-notes/0.9.17.md +130 -41
- package/docs/migration/release-notes/README.md +7 -0
- package/docs/migration/v0.7-to-v0.8.md +2 -2
- package/docs/reference/cli.md +11 -2
- package/docs/reference/data-and-telemetry.md +5 -2
- package/package.json +1 -1
|
@@ -1,30 +1,87 @@
|
|
|
1
1
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
2
|
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
3
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
import path from "node:path";
|
|
4
5
|
import { placementTypes } from "../../core/asset/asset-placement.js";
|
|
5
6
|
import { resolveStashDir } from "../../core/common.js";
|
|
6
|
-
import { getSources, loadConfig } from "../../core/config/config.js";
|
|
7
|
+
import { DEFAULT_CONFIG, getSources, loadConfig } from "../../core/config/config.js";
|
|
8
|
+
import { ConfigError } from "../../core/errors.js";
|
|
7
9
|
import { classifyPathAccess, describeInaccessiblePath } from "../../core/path-access.js";
|
|
8
|
-
import { getCacheDir, getConfigDir, getDataDir,
|
|
10
|
+
import { getCacheDir, getConfigDir, getDataDir, getDefaultStashDir, getStateDir } from "../../core/paths.js";
|
|
9
11
|
import { formatRegistryUrl } from "../../core/registry-url.js";
|
|
10
12
|
import { error } from "../../core/warn.js";
|
|
11
|
-
import { closeDatabase,
|
|
13
|
+
import { closeDatabase, openReadonlyExistingDatabase } from "../../storage/repositories/index-connection.js";
|
|
12
14
|
import { getEntryCount, getEntryCountByType } from "../../storage/repositories/index-entries-repository.js";
|
|
13
15
|
import { countLinksByKind } from "../../storage/repositories/index-links-repository.js";
|
|
14
16
|
import { getMeta } from "../../storage/repositories/index-meta-repository.js";
|
|
17
|
+
import { isSqliteContentionError } from "../../storage/sqlite-transaction.js";
|
|
15
18
|
import { pkgVersion } from "../../version.js";
|
|
19
|
+
/**
|
|
20
|
+
* Bound for `akm info`'s diagnostic index.db read — short enough that a
|
|
21
|
+
* locked database never stalls the command, unlike the shared 30s
|
|
22
|
+
* `SQLITE_BUSY_TIMEOUT_MS` every write-capable opener uses. The opener's own
|
|
23
|
+
* layout check (`checkIndexLayout`) swallows a busy error on its one SELECT
|
|
24
|
+
* rather than surfacing it, so a genuinely locked database costs this
|
|
25
|
+
* timeout TWICE before the first real query here (`countLinksByKind`)
|
|
26
|
+
* throws for real — 750ms keeps that ~1.5s worst case well clear of the 3s
|
|
27
|
+
* bound integration tests hold this to, on a loaded CI box.
|
|
28
|
+
*/
|
|
29
|
+
const INFO_INDEX_BUSY_TIMEOUT_MS = 750;
|
|
16
30
|
/**
|
|
17
31
|
* Assemble system info describing the current capabilities, configuration,
|
|
18
|
-
* and index state. Used by `akm info
|
|
32
|
+
* and index state. Used by `akm info`, which must behave like a help
|
|
33
|
+
* command (owner ruling): always print a report and exit 0, whatever else
|
|
34
|
+
* is happening. Every section below is read best-effort, so one failing
|
|
35
|
+
* lookup degrades only its own field(s) instead of the whole report;
|
|
36
|
+
* `infoCommand` itself (stash-cli.ts) wraps this whole call as the final
|
|
37
|
+
* backstop for anything left over — e.g. a path resolver that needs an
|
|
38
|
+
* environment variable nothing here sets, such as `HOME`.
|
|
19
39
|
*
|
|
20
40
|
* @param options.dbPath - Override the database path (useful for testing)
|
|
21
41
|
*/
|
|
22
42
|
export function assembleInfo(options) {
|
|
23
|
-
|
|
43
|
+
let config;
|
|
44
|
+
let configError;
|
|
45
|
+
try {
|
|
46
|
+
config = loadConfig();
|
|
47
|
+
}
|
|
48
|
+
catch (err) {
|
|
49
|
+
config = DEFAULT_CONFIG;
|
|
50
|
+
configError = err instanceof Error ? err.message : String(err);
|
|
51
|
+
}
|
|
24
52
|
// Primary stash directory + default bundle name — same resolution
|
|
25
53
|
// `akm sources list` uses (R-057), so `akm info` and `akm sources list`
|
|
26
|
-
// agree on which stash is primary.
|
|
27
|
-
|
|
54
|
+
// agree on which stash is primary. No bundle created yet
|
|
55
|
+
// (STASH_DIR_NOT_FOUND, the ordinary fresh-install state) reports where a
|
|
56
|
+
// fresh `akm setup`/`akm bundle create` would put it, same as "report the
|
|
57
|
+
// defaults" for a missing config — no error needed, nothing is actually
|
|
58
|
+
// wrong. A bundle that WAS configured (an env override or `bundles.*` in
|
|
59
|
+
// config) but doesn't resolve (STASH_DIR_UNREADABLE/STASH_DIR_NOT_A_DIRECTORY)
|
|
60
|
+
// is different: the fallback path is a courtesy, not a way to hide a
|
|
61
|
+
// genuine misconfiguration, so that reason is kept in `bundleDirError`.
|
|
62
|
+
let stashDir;
|
|
63
|
+
let bundleDirError;
|
|
64
|
+
try {
|
|
65
|
+
stashDir = resolveStashDir();
|
|
66
|
+
}
|
|
67
|
+
catch (err) {
|
|
68
|
+
if (!(err instanceof ConfigError) || err.code !== "STASH_DIR_NOT_FOUND") {
|
|
69
|
+
bundleDirError = err instanceof Error ? err.message : String(err);
|
|
70
|
+
}
|
|
71
|
+
// The fallback itself resolves purely from `HOME` (or its platform
|
|
72
|
+
// equivalent) and can throw the exact same way `resolveStashDir()` can
|
|
73
|
+
// (e.g. HOME entirely unset) — guarded so a blank `bundleDir` (never a
|
|
74
|
+
// throw) still always carries a reason, reusing the one above when the
|
|
75
|
+
// outer catch already set it (a configured-but-broken path takes
|
|
76
|
+
// precedence over restating "and the default isn't resolvable either").
|
|
77
|
+
try {
|
|
78
|
+
stashDir = getDefaultStashDir();
|
|
79
|
+
}
|
|
80
|
+
catch (fallbackErr) {
|
|
81
|
+
stashDir = "";
|
|
82
|
+
bundleDirError ??= fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
28
85
|
const defaultBundle = config.defaultBundle ?? null;
|
|
29
86
|
// Asset types (copy into a mutable array — `placementTypes()` returns readonly)
|
|
30
87
|
const assetTypes = [...placementTypes()];
|
|
@@ -45,11 +102,29 @@ export function assembleInfo(options) {
|
|
|
45
102
|
...(s.url ? { url: s.url } : {}),
|
|
46
103
|
...(s.enabled !== undefined ? { enabled: s.enabled } : {}),
|
|
47
104
|
}));
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
105
|
+
// Data directory. `getDataDir()` can itself throw (e.g. TEST_ISOLATION_MISSING
|
|
106
|
+
// when NODE_ENV=test leaks into a real invocation — a JS test runner's
|
|
107
|
+
// child process — with no XDG_DATA_HOME/AKM_DATA_DIR override), caught
|
|
108
|
+
// here so that failure degrades only `dataDir`/`indexStats` rather than
|
|
109
|
+
// the whole report. Always attempted, even when `options.dbPath` (a
|
|
110
|
+
// test-only override, see the param doc above) means it is not needed to
|
|
111
|
+
// resolve the index path below — `dataDir` is its own reported field.
|
|
112
|
+
let dataDir = "";
|
|
113
|
+
let dataDirError;
|
|
114
|
+
try {
|
|
115
|
+
dataDir = getDataDir();
|
|
116
|
+
}
|
|
117
|
+
catch (err) {
|
|
118
|
+
dataDirError = err instanceof Error ? err.message : String(err);
|
|
119
|
+
}
|
|
120
|
+
// Index stats. `options.dbPath` overrides the resolved path outright;
|
|
121
|
+
// otherwise it is derived from `dataDir` above, so a data dir that failed
|
|
122
|
+
// to resolve degrades index stats the same way rather than retrying (and
|
|
123
|
+
// re-throwing) `getDataDir()` a second time.
|
|
124
|
+
const resolvedDbPath = options?.dbPath ?? (dataDir ? path.join(dataDir, "index.db") : undefined);
|
|
125
|
+
const indexStats = resolvedDbPath
|
|
126
|
+
? readIndexStats(resolvedDbPath)
|
|
127
|
+
: { entryCount: 0, byType: {}, lastBuiltAt: null, hasEmbeddings: false, unavailable: dataDirError };
|
|
53
128
|
// Semantic status is read live from the index's own state, not a cached
|
|
54
129
|
// verdict — a failed embed attempt at search time falls back to FTS and
|
|
55
130
|
// reports that in the search response, it never disables the mode here.
|
|
@@ -63,10 +138,12 @@ export function assembleInfo(options) {
|
|
|
63
138
|
version: pkgVersion,
|
|
64
139
|
bundleDir: stashDir,
|
|
65
140
|
defaultBundle,
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
141
|
+
...(configError ? { configError } : {}),
|
|
142
|
+
...(bundleDirError ? { bundleDirError } : {}),
|
|
143
|
+
dataDir,
|
|
144
|
+
configDir: safePath(getConfigDir),
|
|
145
|
+
cacheDir: safePath(getCacheDir),
|
|
146
|
+
stateDir: safePath(getStateDir),
|
|
70
147
|
assetTypes,
|
|
71
148
|
searchModes,
|
|
72
149
|
semanticSearch: {
|
|
@@ -78,6 +155,15 @@ export function assembleInfo(options) {
|
|
|
78
155
|
indexStats,
|
|
79
156
|
};
|
|
80
157
|
}
|
|
158
|
+
/** Best-effort path resolution for a section that must degrade, not throw: an empty string in place of an exception. */
|
|
159
|
+
function safePath(fn) {
|
|
160
|
+
try {
|
|
161
|
+
return fn();
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
return "";
|
|
165
|
+
}
|
|
166
|
+
}
|
|
81
167
|
function readIndexStats(resolvedPath) {
|
|
82
168
|
const EMPTY = {
|
|
83
169
|
entryCount: 0,
|
|
@@ -99,7 +185,15 @@ function readIndexStats(resolvedPath) {
|
|
|
99
185
|
}
|
|
100
186
|
let db;
|
|
101
187
|
try {
|
|
102
|
-
|
|
188
|
+
// Never writes the index — no schema or journal-mode change — and
|
|
189
|
+
// bounded to INFO_INDEX_BUSY_TIMEOUT_MS rather than the shared 30s
|
|
190
|
+
// busy_timeout every write-capable opener uses. A newer index layout is
|
|
191
|
+
// reported below rather than refused (checkIndexLayout throws); an
|
|
192
|
+
// older layout just warns and is served as-is, never migrated — both
|
|
193
|
+
// already true of this opener.
|
|
194
|
+
db = openReadonlyExistingDatabase(resolvedPath, { busyTimeoutMs: INFO_INDEX_BUSY_TIMEOUT_MS });
|
|
195
|
+
if (!db)
|
|
196
|
+
return EMPTY; // raced away (deleted) between the access check above and here
|
|
103
197
|
const links = countLinksByKind(db);
|
|
104
198
|
return {
|
|
105
199
|
entryCount: getEntryCount(db),
|
|
@@ -115,7 +209,7 @@ function readIndexStats(resolvedPath) {
|
|
|
115
209
|
// Routed through core/warn's `error()` (not a raw process.stderr.write)
|
|
116
210
|
// so `--quiet`/`setQuiet()` actually gate this line (R-057).
|
|
117
211
|
error(`[akm info] failed to read index stats from ${resolvedPath}: ${String(err)}`);
|
|
118
|
-
return EMPTY;
|
|
212
|
+
return { ...EMPTY, unavailable: describeIndexReadFailure(err) };
|
|
119
213
|
}
|
|
120
214
|
finally {
|
|
121
215
|
if (db) {
|
|
@@ -128,3 +222,13 @@ function readIndexStats(resolvedPath) {
|
|
|
128
222
|
}
|
|
129
223
|
}
|
|
130
224
|
}
|
|
225
|
+
/**
|
|
226
|
+
* Reason for `indexStats.unavailable`. Contention gets its own clear
|
|
227
|
+
* wording; everything else (a too-new layout, on-disk corruption, ...) is
|
|
228
|
+
* already descriptive as the driver/opener's own message.
|
|
229
|
+
*/
|
|
230
|
+
function describeIndexReadFailure(err) {
|
|
231
|
+
if (isSqliteContentionError(err))
|
|
232
|
+
return "index.db is locked by another akm process";
|
|
233
|
+
return err instanceof Error ? err.message : String(err);
|
|
234
|
+
}
|
|
@@ -33,6 +33,7 @@ import * as p from "../../cli/clack.js";
|
|
|
33
33
|
import { getParsedInvocation } from "../../cli/invocation.js";
|
|
34
34
|
import { defineJsonCommand, GLOBAL_OUTPUT_ARGS, output, parseAllFlagValues, runWithJsonErrors } from "../../cli/shared.js";
|
|
35
35
|
import { assertFlatAssetName } from "../../core/asset/asset-create.js";
|
|
36
|
+
import { placementTypes } from "../../core/asset/asset-placement.js";
|
|
36
37
|
import { parseFrontmatter } from "../../core/asset/frontmatter.js";
|
|
37
38
|
import { isHttpUrl, resolveStashDir } from "../../core/common.js";
|
|
38
39
|
import { loadConfig } from "../../core/config/config.js";
|
|
@@ -45,6 +46,7 @@ import { resolveWriteTarget } from "../../core/write-source.js";
|
|
|
45
46
|
import { releaseIndexRebuildLock, tryAcquireIndexRebuildLock } from "../../indexer/index-rebuild-lock.js";
|
|
46
47
|
import { akmIndex } from "../../indexer/indexer.js";
|
|
47
48
|
import { getHyphenatedBoolean, getOutputMode } from "../../output/context.js";
|
|
49
|
+
import { pkgVersion } from "../../version.js";
|
|
48
50
|
import { inferAssetName, mergeXrefsIntoContent, readKnowledgeInput, resolveSupersedesForWrite, resolveSupersedesWriteTarget, resolveXrefsForWrite, writeMarkdownAsset, } from "../read/knowledge.js";
|
|
49
51
|
import { assembleInfo } from "./info.js";
|
|
50
52
|
/** Matches the high-frequency per-committed-batch progress line (#954), excluded from non-verbose JSON-mode stderr. */
|
|
@@ -181,7 +183,25 @@ export const indexCommand = defineCommand({
|
|
|
181
183
|
export const infoCommand = defineJsonCommand({
|
|
182
184
|
meta: { name: "info", description: "Show system capabilities, configuration, and index stats" },
|
|
183
185
|
run() {
|
|
184
|
-
|
|
186
|
+
// `akm info` must behave like a help command: always exit 0 with a
|
|
187
|
+
// report. `assembleInfo()` already degrades what it reasonably can
|
|
188
|
+
// per-section; this is the final backstop for whatever it can't (e.g.
|
|
189
|
+
// every path resolver needs an environment variable, such as `HOME`,
|
|
190
|
+
// that this process genuinely does not have) — still a report, with
|
|
191
|
+
// whatever is cheap and safe enough to never itself throw.
|
|
192
|
+
let result;
|
|
193
|
+
try {
|
|
194
|
+
result = assembleInfo();
|
|
195
|
+
}
|
|
196
|
+
catch (err) {
|
|
197
|
+
result = {
|
|
198
|
+
schemaVersion: 1,
|
|
199
|
+
version: pkgVersion,
|
|
200
|
+
error: err instanceof Error ? err.message : String(err),
|
|
201
|
+
assetTypes: [...placementTypes()],
|
|
202
|
+
searchModes: ["fts"],
|
|
203
|
+
};
|
|
204
|
+
}
|
|
185
205
|
output("info", result);
|
|
186
206
|
},
|
|
187
207
|
});
|
|
@@ -393,7 +393,12 @@ function validateImprovePlan(value, dryRun, plannedRefNames) {
|
|
|
393
393
|
if (value.limits.totalCeiling !== undefined && value.effectiveRefs.length > value.limits.totalCeiling) {
|
|
394
394
|
fail("plan.effectiveRefs cannot exceed plan.limits.totalCeiling");
|
|
395
395
|
}
|
|
396
|
-
|
|
396
|
+
// #947 added plan.processes (2026-09-09T09:03:19Z); every run recorded
|
|
397
|
+
// before it legitimately stored `plan` with no `processes` key at all —
|
|
398
|
+
// validate the rows only when present, same as `proactive` below, so a
|
|
399
|
+
// pre-#947 envelope still decodes (AGENTS.md "Reading persisted data").
|
|
400
|
+
if (value.processes !== undefined)
|
|
401
|
+
validateProcessRoutingRows(value.processes);
|
|
397
402
|
if (value.proactive !== undefined)
|
|
398
403
|
validateProactivePlan(value.proactive);
|
|
399
404
|
validateConsolidationPlan(value.consolidation);
|
|
@@ -20,11 +20,20 @@ export function formatInfoPlain(r) {
|
|
|
20
20
|
const lines = [];
|
|
21
21
|
if (r.version)
|
|
22
22
|
lines.push(`version: ${String(r.version)}`);
|
|
23
|
+
// Only present in infoCommand's own last-resort fallback (stash-cli.ts),
|
|
24
|
+
// when assembleInfo() itself threw — every field below it belongs to the
|
|
25
|
+
// normal shape and is absent there.
|
|
26
|
+
if (typeof r.error === "string")
|
|
27
|
+
lines.push(`error: ${r.error}`);
|
|
23
28
|
if (r.bundleDir)
|
|
24
29
|
lines.push(`bundleDir: ${String(r.bundleDir)}`);
|
|
25
30
|
if (r.defaultBundle !== undefined) {
|
|
26
31
|
lines.push(`defaultBundle: ${r.defaultBundle === null ? "(none)" : String(r.defaultBundle)}`);
|
|
27
32
|
}
|
|
33
|
+
if (typeof r.configError === "string")
|
|
34
|
+
lines.push(`configError: ${r.configError}`);
|
|
35
|
+
if (typeof r.bundleDirError === "string")
|
|
36
|
+
lines.push(`bundleDirError: ${r.bundleDirError}`);
|
|
28
37
|
if (Array.isArray(r.assetTypes) && r.assetTypes.length > 0) {
|
|
29
38
|
lines.push(`assetTypes: ${r.assetTypes.join(", ")}`);
|
|
30
39
|
}
|
|
@@ -29609,11 +29609,17 @@ function formatInfoPlain(r) {
|
|
|
29609
29609
|
const lines = [];
|
|
29610
29610
|
if (r.version)
|
|
29611
29611
|
lines.push(`version: ${String(r.version)}`);
|
|
29612
|
+
if (typeof r.error === "string")
|
|
29613
|
+
lines.push(`error: ${r.error}`);
|
|
29612
29614
|
if (r.bundleDir)
|
|
29613
29615
|
lines.push(`bundleDir: ${String(r.bundleDir)}`);
|
|
29614
29616
|
if (r.defaultBundle !== undefined) {
|
|
29615
29617
|
lines.push(`defaultBundle: ${r.defaultBundle === null ? "(none)" : String(r.defaultBundle)}`);
|
|
29616
29618
|
}
|
|
29619
|
+
if (typeof r.configError === "string")
|
|
29620
|
+
lines.push(`configError: ${r.configError}`);
|
|
29621
|
+
if (typeof r.bundleDirError === "string")
|
|
29622
|
+
lines.push(`bundleDirError: ${r.bundleDirError}`);
|
|
29617
29623
|
if (Array.isArray(r.assetTypes) && r.assetTypes.length > 0) {
|
|
29618
29624
|
lines.push(`assetTypes: ${r.assetTypes.join(", ")}`);
|
|
29619
29625
|
}
|
|
@@ -52058,6 +52064,7 @@ import os6 from "node:os";
|
|
|
52058
52064
|
import path44 from "node:path";
|
|
52059
52065
|
|
|
52060
52066
|
// src/storage/sqlite-read-snapshot.ts
|
|
52067
|
+
import { spawnSync as spawnSync5 } from "node:child_process";
|
|
52061
52068
|
import fs36 from "node:fs";
|
|
52062
52069
|
import os5 from "node:os";
|
|
52063
52070
|
import path43 from "node:path";
|
|
@@ -52102,6 +52109,26 @@ function fingerprintsEqual(left, right) {
|
|
|
52102
52109
|
const sameFile = (a, b) => a?.size === b?.size && a?.mtimeNs === b?.mtimeNs && a?.ctimeNs === b?.ctimeNs;
|
|
52103
52110
|
return sameFile(left.main, right.main) && sameFile(left.wal, right.wal);
|
|
52104
52111
|
}
|
|
52112
|
+
function copyFileOutsideThisProcess(source, destination) {
|
|
52113
|
+
if (process.platform === "win32") {
|
|
52114
|
+
fs36.copyFileSync(source, destination);
|
|
52115
|
+
return;
|
|
52116
|
+
}
|
|
52117
|
+
const result = spawnSync5("cp", ["--", source, destination], {
|
|
52118
|
+
encoding: "utf8",
|
|
52119
|
+
stdio: ["ignore", "ignore", "pipe"]
|
|
52120
|
+
});
|
|
52121
|
+
if (result.error) {
|
|
52122
|
+
throw new SqliteReadSnapshotUnavailableError(`cannot run cp to copy ${source}: ${result.error.message}`);
|
|
52123
|
+
}
|
|
52124
|
+
if (result.status === 0)
|
|
52125
|
+
return;
|
|
52126
|
+
if (fileFingerprint(source) === undefined) {
|
|
52127
|
+
throw Object.assign(new Error(`${source} disappeared while it was being copied`), { code: "ENOENT" });
|
|
52128
|
+
}
|
|
52129
|
+
const reason = result.stderr.trim() || (result.signal ? `killed by ${result.signal}` : `exit status ${result.status}`);
|
|
52130
|
+
throw new Error(`cp could not copy ${source}: ${reason}`);
|
|
52131
|
+
}
|
|
52105
52132
|
function openSqliteReadSnapshot(dbPath) {
|
|
52106
52133
|
if (!pathExists(dbPath))
|
|
52107
52134
|
return;
|
|
@@ -52117,9 +52144,9 @@ function openSqliteReadSnapshot(dbPath) {
|
|
|
52117
52144
|
if (pathExists(`${dbPath}-journal`))
|
|
52118
52145
|
continue;
|
|
52119
52146
|
const before = databaseFingerprint(dbPath);
|
|
52120
|
-
|
|
52147
|
+
copyFileOutsideThisProcess(dbPath, snapshotPath);
|
|
52121
52148
|
if (before.wal)
|
|
52122
|
-
|
|
52149
|
+
copyFileOutsideThisProcess(`${dbPath}-wal`, `${snapshotPath}-wal`);
|
|
52123
52150
|
else
|
|
52124
52151
|
fs36.rmSync(`${snapshotPath}-wal`, { force: true });
|
|
52125
52152
|
const after = databaseFingerprint(dbPath);
|
|
@@ -28937,11 +28937,17 @@ function formatInfoPlain(r) {
|
|
|
28937
28937
|
const lines = [];
|
|
28938
28938
|
if (r.version)
|
|
28939
28939
|
lines.push(`version: ${String(r.version)}`);
|
|
28940
|
+
if (typeof r.error === "string")
|
|
28941
|
+
lines.push(`error: ${r.error}`);
|
|
28940
28942
|
if (r.bundleDir)
|
|
28941
28943
|
lines.push(`bundleDir: ${String(r.bundleDir)}`);
|
|
28942
28944
|
if (r.defaultBundle !== undefined) {
|
|
28943
28945
|
lines.push(`defaultBundle: ${r.defaultBundle === null ? "(none)" : String(r.defaultBundle)}`);
|
|
28944
28946
|
}
|
|
28947
|
+
if (typeof r.configError === "string")
|
|
28948
|
+
lines.push(`configError: ${r.configError}`);
|
|
28949
|
+
if (typeof r.bundleDirError === "string")
|
|
28950
|
+
lines.push(`bundleDirError: ${r.bundleDirError}`);
|
|
28945
28951
|
if (Array.isArray(r.assetTypes) && r.assetTypes.length > 0) {
|
|
28946
28952
|
lines.push(`assetTypes: ${r.assetTypes.join(", ")}`);
|
|
28947
28953
|
}
|
|
@@ -52053,6 +52059,7 @@ import os6 from "os";
|
|
|
52053
52059
|
import path44 from "path";
|
|
52054
52060
|
|
|
52055
52061
|
// src/storage/sqlite-read-snapshot.ts
|
|
52062
|
+
import { spawnSync as spawnSync5 } from "child_process";
|
|
52056
52063
|
import fs36 from "fs";
|
|
52057
52064
|
import os5 from "os";
|
|
52058
52065
|
import path43 from "path";
|
|
@@ -52097,6 +52104,26 @@ function fingerprintsEqual(left, right) {
|
|
|
52097
52104
|
const sameFile = (a, b) => a?.size === b?.size && a?.mtimeNs === b?.mtimeNs && a?.ctimeNs === b?.ctimeNs;
|
|
52098
52105
|
return sameFile(left.main, right.main) && sameFile(left.wal, right.wal);
|
|
52099
52106
|
}
|
|
52107
|
+
function copyFileOutsideThisProcess(source, destination) {
|
|
52108
|
+
if (process.platform === "win32") {
|
|
52109
|
+
fs36.copyFileSync(source, destination);
|
|
52110
|
+
return;
|
|
52111
|
+
}
|
|
52112
|
+
const result = spawnSync5("cp", ["--", source, destination], {
|
|
52113
|
+
encoding: "utf8",
|
|
52114
|
+
stdio: ["ignore", "ignore", "pipe"]
|
|
52115
|
+
});
|
|
52116
|
+
if (result.error) {
|
|
52117
|
+
throw new SqliteReadSnapshotUnavailableError(`cannot run cp to copy ${source}: ${result.error.message}`);
|
|
52118
|
+
}
|
|
52119
|
+
if (result.status === 0)
|
|
52120
|
+
return;
|
|
52121
|
+
if (fileFingerprint(source) === undefined) {
|
|
52122
|
+
throw Object.assign(new Error(`${source} disappeared while it was being copied`), { code: "ENOENT" });
|
|
52123
|
+
}
|
|
52124
|
+
const reason = result.stderr.trim() || (result.signal ? `killed by ${result.signal}` : `exit status ${result.status}`);
|
|
52125
|
+
throw new Error(`cp could not copy ${source}: ${reason}`);
|
|
52126
|
+
}
|
|
52100
52127
|
function openSqliteReadSnapshot(dbPath) {
|
|
52101
52128
|
if (!pathExists(dbPath))
|
|
52102
52129
|
return;
|
|
@@ -52112,9 +52139,9 @@ function openSqliteReadSnapshot(dbPath) {
|
|
|
52112
52139
|
if (pathExists(`${dbPath}-journal`))
|
|
52113
52140
|
continue;
|
|
52114
52141
|
const before = databaseFingerprint(dbPath);
|
|
52115
|
-
|
|
52142
|
+
copyFileOutsideThisProcess(dbPath, snapshotPath);
|
|
52116
52143
|
if (before.wal)
|
|
52117
|
-
|
|
52144
|
+
copyFileOutsideThisProcess(`${dbPath}-wal`, `${snapshotPath}-wal`);
|
|
52118
52145
|
else
|
|
52119
52146
|
fs36.rmSync(`${snapshotPath}-wal`, { force: true });
|
|
52120
52147
|
const after = databaseFingerprint(dbPath);
|
|
@@ -97,6 +97,34 @@ export function tryListGitUnverifiablePaths(repoDir, pathspec) {
|
|
|
97
97
|
}
|
|
98
98
|
return { paths, ok: true };
|
|
99
99
|
}
|
|
100
|
+
/**
|
|
101
|
+
* The three-part git cleanliness check shared by the archive purge sweep
|
|
102
|
+
* (`purgeGracedArchive` in `commands/improve/memory/memory-improve.ts`) and
|
|
103
|
+
* the `memory-cleanup-archive` health advisory (`collectArchiveUsageAdvisory`
|
|
104
|
+
* in `commands/health/archive-usage.ts`): a path is safe to treat as
|
|
105
|
+
* committed only if it is tracked ({@link tryListGitTrackedPaths}), not dirty
|
|
106
|
+
* ({@link tryListGitChangedPaths}), and not assume-unchanged/skip-worktree
|
|
107
|
+
* ({@link tryListGitUnverifiablePaths} — those hide their own edits from
|
|
108
|
+
* `git status`, so an unverifiable file is never trusted as clean either).
|
|
109
|
+
*
|
|
110
|
+
* Each of the three git calls can fail independently (a broken submodule, or
|
|
111
|
+
* git missing from `PATH`); `ok` is `false` if any one does, and `isSafe`
|
|
112
|
+
* then returns `false` for every path rather than guessing — callers that
|
|
113
|
+
* need to short-circuit before doing other work still check `ok` themselves.
|
|
114
|
+
*/
|
|
115
|
+
export function checkGitPathSafety(repoDir, pathspec) {
|
|
116
|
+
const dirtyQuery = tryListGitChangedPaths(repoDir);
|
|
117
|
+
const trackedQuery = tryListGitTrackedPaths(repoDir, pathspec);
|
|
118
|
+
const unverifiableQuery = tryListGitUnverifiablePaths(repoDir, pathspec);
|
|
119
|
+
const ok = dirtyQuery.ok && trackedQuery.ok && unverifiableQuery.ok;
|
|
120
|
+
const dirty = new Set(dirtyQuery.paths);
|
|
121
|
+
const tracked = new Set(trackedQuery.paths);
|
|
122
|
+
const unverifiable = new Set(unverifiableQuery.paths);
|
|
123
|
+
return {
|
|
124
|
+
ok,
|
|
125
|
+
isSafe: (repoRelativePath) => ok && tracked.has(repoRelativePath) && !dirty.has(repoRelativePath) && !unverifiable.has(repoRelativePath),
|
|
126
|
+
};
|
|
127
|
+
}
|
|
100
128
|
export class GitStashPushError extends Error {
|
|
101
129
|
commit;
|
|
102
130
|
constructor(message, commit) {
|
|
@@ -180,9 +180,12 @@ export function openReadonlyExistingDatabase(dbPath, options) {
|
|
|
180
180
|
// never block — but in the DELETE/TRUNCATE modes the network-FS fallback and
|
|
181
181
|
// AKM_SQLITE_JOURNAL_MODE can select, a concurrent writer makes every read
|
|
182
182
|
// fail instantly with SQLITE_BUSY. busy_timeout is legal on a read-only
|
|
183
|
-
// connection, so apply just that one.
|
|
183
|
+
// connection, so apply just that one. `busyTimeoutMs` defaults to the
|
|
184
|
+
// shared 30s constant; a caller that must never sit behind another akm
|
|
185
|
+
// process's write lock for long (e.g. `akm info`) can pass a much shorter
|
|
186
|
+
// bound instead.
|
|
184
187
|
try {
|
|
185
|
-
db.exec(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`);
|
|
188
|
+
db.exec(`PRAGMA busy_timeout = ${options?.busyTimeoutMs ?? SQLITE_BUSY_TIMEOUT_MS}`);
|
|
186
189
|
checkIndexLayout(db, resolvedPath);
|
|
187
190
|
return db;
|
|
188
191
|
}
|
|
@@ -9,7 +9,12 @@
|
|
|
9
9
|
* must not do that. This helper copies a stable main/WAL pair and opens that
|
|
10
10
|
* private copy, so SQLite never attaches to the operator's original
|
|
11
11
|
* main/WAL/SHM files.
|
|
12
|
+
*
|
|
13
|
+
* The copy itself is made by a child process, never by this one: see
|
|
14
|
+
* {@link copyFileOutsideThisProcess} for why an in-process read of a live
|
|
15
|
+
* database is unsafe.
|
|
12
16
|
*/
|
|
17
|
+
import { spawnSync } from "node:child_process";
|
|
13
18
|
import fs from "node:fs";
|
|
14
19
|
import os from "node:os";
|
|
15
20
|
import path from "node:path";
|
|
@@ -57,6 +62,45 @@ function fingerprintsEqual(left, right) {
|
|
|
57
62
|
const sameFile = (a, b) => a?.size === b?.size && a?.mtimeNs === b?.mtimeNs && a?.ctimeNs === b?.ctimeNs;
|
|
58
63
|
return sameFile(left.main, right.main) && sameFile(left.wal, right.wal);
|
|
59
64
|
}
|
|
65
|
+
/**
|
|
66
|
+
* Copy `source` to `destination` without opening `source` in THIS process.
|
|
67
|
+
*
|
|
68
|
+
* POSIX advisory locks belong to the process, not to the descriptor: when a
|
|
69
|
+
* process closes ANY descriptor for a file, the kernel drops every lock that
|
|
70
|
+
* process holds on it, including the SHARED lock a live SQLite connection in
|
|
71
|
+
* this same process (`akm improve` keeps one on state.db for the whole run)
|
|
72
|
+
* holds. A peer using an older SQLite (< 3.51) read-write would then see no
|
|
73
|
+
* reader, take EXCLUSIVE when it closes, and delete the `-wal`/`-shm` this
|
|
74
|
+
* process is still using. The read therefore happens in a child `cp`, whose
|
|
75
|
+
* descriptors and locks are its own. Windows locks belong to the handle, so
|
|
76
|
+
* an in-process copy cannot release another handle's lock there (and there is
|
|
77
|
+
* no `cp`).
|
|
78
|
+
*
|
|
79
|
+
* A source that vanished (a WAL checkpointed away mid-copy) throws an
|
|
80
|
+
* `ENOENT` error, which {@link openSqliteReadSnapshot} retries.
|
|
81
|
+
*/
|
|
82
|
+
function copyFileOutsideThisProcess(source, destination) {
|
|
83
|
+
if (process.platform === "win32") {
|
|
84
|
+
fs.copyFileSync(source, destination);
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
const result = spawnSync("cp", ["--", source, destination], {
|
|
88
|
+
encoding: "utf8",
|
|
89
|
+
stdio: ["ignore", "ignore", "pipe"],
|
|
90
|
+
});
|
|
91
|
+
if (result.error) {
|
|
92
|
+
throw new SqliteReadSnapshotUnavailableError(`cannot run cp to copy ${source}: ${result.error.message}`);
|
|
93
|
+
}
|
|
94
|
+
if (result.status === 0)
|
|
95
|
+
return;
|
|
96
|
+
// `cp` reports its errors as text, so the vanished-source case is recognised
|
|
97
|
+
// by looking at the source itself.
|
|
98
|
+
if (fileFingerprint(source) === undefined) {
|
|
99
|
+
throw Object.assign(new Error(`${source} disappeared while it was being copied`), { code: "ENOENT" });
|
|
100
|
+
}
|
|
101
|
+
const reason = result.stderr.trim() || (result.signal ? `killed by ${result.signal}` : `exit status ${result.status}`);
|
|
102
|
+
throw new Error(`cp could not copy ${source}: ${reason}`);
|
|
103
|
+
}
|
|
60
104
|
/**
|
|
61
105
|
* Open an isolated copy of an existing SQLite database.
|
|
62
106
|
*
|
|
@@ -82,9 +126,9 @@ export function openSqliteReadSnapshot(dbPath) {
|
|
|
82
126
|
if (pathExists(`${dbPath}-journal`))
|
|
83
127
|
continue;
|
|
84
128
|
const before = databaseFingerprint(dbPath);
|
|
85
|
-
|
|
129
|
+
copyFileOutsideThisProcess(dbPath, snapshotPath);
|
|
86
130
|
if (before.wal)
|
|
87
|
-
|
|
131
|
+
copyFileOutsideThisProcess(`${dbPath}-wal`, `${snapshotPath}-wal`);
|
|
88
132
|
else
|
|
89
133
|
fs.rmSync(`${snapshotPath}-wal`, { force: true });
|
|
90
134
|
const after = databaseFingerprint(dbPath);
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*
|
|
8
8
|
* `akm health`'s `state-db-integrity` check (src/commands/health/checks.ts)
|
|
9
9
|
* is a pure projection like every other check, so the actual IO lives here:
|
|
10
|
-
* a read-only `PRAGMA
|
|
10
|
+
* a read-only `PRAGMA integrity_check` and a read-only freelist/page-count read.
|
|
11
11
|
* Both open their own short-lived read-only connection via the plain
|
|
12
12
|
* {@link openDatabase} opener rather than `openStateDatabase`
|
|
13
13
|
* (src/core/state-db.ts), since a corrupt database must not need a clean
|
|
@@ -27,8 +27,8 @@
|
|
|
27
27
|
import { appendEvent } from "../core/events.js";
|
|
28
28
|
import { openDatabase } from "./database.js";
|
|
29
29
|
import { applyReadonlyPragmas } from "./sqlite-pragmas.js";
|
|
30
|
-
/** How many corruption errors `PRAGMA
|
|
31
|
-
const
|
|
30
|
+
/** How many corruption errors `PRAGMA integrity_check` collects before it stops scanning and returns. */
|
|
31
|
+
const INTEGRITY_CHECK_ERROR_LIMIT = 10;
|
|
32
32
|
/**
|
|
33
33
|
* Above this fraction of free pages, `state-db-integrity` warns, and
|
|
34
34
|
* {@link vacuumIfReclaimable} compacts state.db (after improve's retention
|
|
@@ -48,16 +48,19 @@ function openReadonlyStateDb(dbPath) {
|
|
|
48
48
|
return db;
|
|
49
49
|
}
|
|
50
50
|
/**
|
|
51
|
-
* Run `PRAGMA
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
51
|
+
* Run `PRAGMA integrity_check(N)` against `dbPath` read-only. Unlike
|
|
52
|
+
* `quick_check`, it also verifies every index against its table ("row N
|
|
53
|
+
* missing from index", "wrong # of entries in index"): the damage a WAL
|
|
54
|
+
* deleted under a live connection leaves behind, which `quick_check` reports
|
|
55
|
+
* as `ok`. Sub-second on a healthy state.db of a few hundred MB; on a corrupt
|
|
56
|
+
* one, `N` bounds how many errors SQLite collects before it stops scanning,
|
|
57
|
+
* which keeps the check's runtime bounded even against a badly corrupt file.
|
|
55
58
|
*/
|
|
56
|
-
export function
|
|
59
|
+
export function runStateDbIntegrityCheck(dbPath) {
|
|
57
60
|
let db;
|
|
58
61
|
try {
|
|
59
62
|
db = openReadonlyStateDb(dbPath);
|
|
60
|
-
const rows = db.prepare(`PRAGMA
|
|
63
|
+
const rows = db.prepare(`PRAGMA integrity_check(${INTEGRITY_CHECK_ERROR_LIMIT})`).all();
|
|
61
64
|
const lines = rows.map((row) => String(firstColumn(row)));
|
|
62
65
|
const ok = lines.length === 1 && lines[0] === "ok";
|
|
63
66
|
return { ok, lines };
|
package/docs/migration/README.md
CHANGED
|
@@ -5,7 +5,7 @@ Upgrade guides and per-release migration notes.
|
|
|
5
5
|
- [v0.9.1 -> v0.9.2 migration guide](v0.9.1-to-v0.9.2.md) -- Task-v2/task-v3 to task source v4 conversion, the durable-v4-family workflow boundary at executable `irVersion: 5`, and release behavior changes
|
|
6
6
|
- [v0.9.2 release note](release-notes/0.9.2.md) -- Self-contained terminal upgrade summary shipped for `akm help migrate 0.9.2`
|
|
7
7
|
- [v0.9.16 release note](release-notes/0.9.16.md) -- Source-bound scheduler grants, local execution authority, and split unsafe overrides
|
|
8
|
-
- [v0.9.17 release note](release-notes/0.9.17.md) --
|
|
8
|
+
- [v0.9.17 release note](release-notes/0.9.17.md) -- Consolidate's retire proposals and index layout 26, replacing the LLM entity graph with declared links, and `akm improve` scoped to what retrieval actually returns
|
|
9
9
|
- [v0.8 -> current v0.9 migration guide](v0.8-to-v0.9.md) -- Package upgrade with fresh current config/state and explicit task conversion
|
|
10
10
|
- [v0.7 -> v0.8 migration guide](v0.7-to-v0.8.md) -- Task schema and 0.8-era changes
|
|
11
11
|
- [v0.5 -> v0.6 migration guide](https://github.com/itlackey/akm/blob/main/docs/migration/v0.5-to-v0.6.md) -- Terminology cut, registry schema v3, publisher changes
|