@klhapp/skillmux 1.9.3 → 1.10.0
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 +15 -0
- package/README.md +1 -1
- package/docs/README.md +1 -1
- package/docs/cli.md +74 -3
- package/docs/concepts.md +1 -1
- package/docs/configuration.md +1 -1
- package/docs/deployment.md +10 -6
- package/docs/getting-started.md +1 -1
- package/docs/skill-management.md +6 -0
- package/package.json +1 -1
- package/src/adapters.ts +148 -2
- package/src/cli.ts +266 -1299
- package/src/commands/audit.ts +53 -56
- package/src/commands/config.ts +11 -12
- package/src/commands/context.ts +103 -0
- package/src/commands/core.ts +5 -1
- package/src/commands/doctor.ts +76 -0
- package/src/commands/eval.ts +10 -13
- package/src/commands/init.ts +621 -0
- package/src/commands/install.ts +132 -0
- package/src/commands/local-vault.ts +60 -0
- package/src/commands/models.ts +10 -0
- package/src/commands/outdated.ts +2 -1
- package/src/commands/project.ts +37 -11
- package/src/commands/report.ts +66 -0
- package/src/commands/scan.ts +61 -0
- package/src/commands/skill.ts +32 -0
- package/src/commands/sync.ts +232 -0
- package/src/commands/target.ts +18 -6
- package/src/commands/update.ts +2 -1
- package/src/config-service.ts +1 -51
- package/src/context.ts +8 -3
- package/src/db-audit.ts +286 -0
- package/src/db-index.ts +238 -0
- package/src/db.ts +3 -521
- package/src/global-flags.ts +46 -0
- package/src/logger.ts +26 -0
- package/src/output.ts +30 -5
- package/src/router-core.ts +8 -27
- package/src/server.ts +160 -13
- package/src/toml-writer.ts +51 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { hostname } from "node:os";
|
|
3
|
+
import { expandHome, loadConfig } from "../config";
|
|
4
|
+
import {
|
|
5
|
+
parseManifest,
|
|
6
|
+
resolveManifestPath,
|
|
7
|
+
validateManifest,
|
|
8
|
+
} from "../manifest";
|
|
9
|
+
import { emitSuccess, isInteractive, warn } from "../output";
|
|
10
|
+
import {
|
|
11
|
+
installPostMergeHook,
|
|
12
|
+
resolveProjectPinDir,
|
|
13
|
+
restoreMonolith as restoreMonolithTarget,
|
|
14
|
+
syncProjectTargets,
|
|
15
|
+
syncTarget,
|
|
16
|
+
type ProjectGroupInput,
|
|
17
|
+
} from "../sync";
|
|
18
|
+
import { confirmAction } from "./shared";
|
|
19
|
+
|
|
20
|
+
function parseSyncArgs(args: string[]): {
|
|
21
|
+
dryRun: boolean;
|
|
22
|
+
restoreMonolith: boolean;
|
|
23
|
+
installHook: boolean;
|
|
24
|
+
yes: boolean;
|
|
25
|
+
isJson: boolean;
|
|
26
|
+
} {
|
|
27
|
+
let dryRun = false;
|
|
28
|
+
let restoreMonolith = false;
|
|
29
|
+
let installHook = false;
|
|
30
|
+
let yes = false;
|
|
31
|
+
let isJson = false;
|
|
32
|
+
for (const arg of args) {
|
|
33
|
+
if (arg === "--dry-run") dryRun = true;
|
|
34
|
+
else if (arg === "--restore-monolith") restoreMonolith = true;
|
|
35
|
+
else if (arg === "--install-hook") installHook = true;
|
|
36
|
+
else if (arg === "--yes") yes = true;
|
|
37
|
+
else if (arg === "--json") isJson = true;
|
|
38
|
+
else throw new Error(`unknown sync option: ${arg}`);
|
|
39
|
+
}
|
|
40
|
+
return { dryRun, restoreMonolith, installHook, yes, isJson };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A target directory that doesn't exist yet is about to be created by `sync`.
|
|
45
|
+
* `manifest.targets[*].dir` is vault content — readable and writable by whatever
|
|
46
|
+
* populated the vault (a shared git-backed vault pulled in, or a hand-edit) — and
|
|
47
|
+
* `sync` can run unattended via the `--install-hook` post-merge hook. Without this
|
|
48
|
+
* gate, a tampered manifest naming a brand-new path gets that directory silently
|
|
49
|
+
* created (and populated with symlinks) the next time anyone pulls. Creation for
|
|
50
|
+
* an as-yet-unseen directory therefore requires either `--yes` or an interactive
|
|
51
|
+
* confirmation; once the directory exists, later syncs never hit this path again.
|
|
52
|
+
*/
|
|
53
|
+
async function confirmNewSyncTarget(
|
|
54
|
+
label: string,
|
|
55
|
+
dir: string,
|
|
56
|
+
yes: boolean,
|
|
57
|
+
isJson: boolean,
|
|
58
|
+
): Promise<boolean> {
|
|
59
|
+
if (yes) return true;
|
|
60
|
+
if (!isInteractive()) {
|
|
61
|
+
if (!isJson) {
|
|
62
|
+
console.log(
|
|
63
|
+
`${label}: skipped — ${dir} does not exist yet; creating it requires approval. Re-run "skillmux sync --yes", or run "skillmux sync" interactively, once you've confirmed this target is expected.`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
return confirmAction(`${label}: create new target directory ${dir}?`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface SyncTargetSummary {
|
|
72
|
+
target: string;
|
|
73
|
+
status:
|
|
74
|
+
| "synced"
|
|
75
|
+
| "skipped_host_mismatch"
|
|
76
|
+
| "restored"
|
|
77
|
+
| "not_owned"
|
|
78
|
+
| "skipped_not_approved";
|
|
79
|
+
added?: string[];
|
|
80
|
+
removed?: string[];
|
|
81
|
+
skipped?: string[];
|
|
82
|
+
projects?: {
|
|
83
|
+
group: string;
|
|
84
|
+
pin_dir: string;
|
|
85
|
+
added: string[];
|
|
86
|
+
removed: string[];
|
|
87
|
+
skipped: string[];
|
|
88
|
+
}[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function runSync(args: string[]): Promise<void> {
|
|
92
|
+
const { dryRun, restoreMonolith, installHook, yes, isJson } = parseSyncArgs(args);
|
|
93
|
+
const config = await loadConfig();
|
|
94
|
+
const vaultPath = expandHome(config.vault_path);
|
|
95
|
+
const log = (line: string) => {
|
|
96
|
+
if (!isJson) console.log(line);
|
|
97
|
+
};
|
|
98
|
+
const warnLine = (line: string) => {
|
|
99
|
+
if (!isJson) warn(line);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
let hookInstalled: boolean | undefined;
|
|
103
|
+
if (installHook) {
|
|
104
|
+
const result = installPostMergeHook(vaultPath);
|
|
105
|
+
hookInstalled = result.installed;
|
|
106
|
+
log(result.installed ? "installed post-merge hook" : "post-merge hook already installed");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const manifestPath = resolveManifestPath(vaultPath);
|
|
110
|
+
if (!manifestPath) {
|
|
111
|
+
emitSuccess({ isJson }, { hook_installed: hookInstalled ?? null, targets: [] }, () =>
|
|
112
|
+
console.log("no skillmux.toml found at vault root — nothing to sync"),
|
|
113
|
+
);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const manifest = parseManifest(await Bun.file(manifestPath).text());
|
|
118
|
+
const localVaultPaths = config.local_vault_paths.map(expandHome);
|
|
119
|
+
const { notes } = validateManifest(manifest, vaultPath, localVaultPaths);
|
|
120
|
+
for (const note of notes) log(`note: ${note}`);
|
|
121
|
+
|
|
122
|
+
const currentHost = hostname();
|
|
123
|
+
const targetSummaries: SyncTargetSummary[] = [];
|
|
124
|
+
for (const [targetName, target] of Object.entries(manifest.targets)) {
|
|
125
|
+
if (target.host !== undefined && target.host !== currentHost) {
|
|
126
|
+
log(
|
|
127
|
+
`${targetName}: skipped (host ${target.host} does not match current host ${currentHost})`,
|
|
128
|
+
);
|
|
129
|
+
targetSummaries.push({ target: targetName, status: "skipped_host_mismatch" });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const targetDir = expandHome(target.dir);
|
|
133
|
+
|
|
134
|
+
if (restoreMonolith) {
|
|
135
|
+
const result = restoreMonolithTarget(targetDir, vaultPath);
|
|
136
|
+
log(
|
|
137
|
+
result.restored
|
|
138
|
+
? `${targetName}: restored to a vault symlink`
|
|
139
|
+
: `${targetName}: not owned by skillmux, left untouched`,
|
|
140
|
+
);
|
|
141
|
+
targetSummaries.push({
|
|
142
|
+
target: targetName,
|
|
143
|
+
status: result.restored ? "restored" : "not_owned",
|
|
144
|
+
});
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!dryRun && !existsSync(targetDir)) {
|
|
149
|
+
const approved = await confirmNewSyncTarget(targetName, targetDir, yes, isJson);
|
|
150
|
+
if (!approved) {
|
|
151
|
+
if (isInteractive()) {
|
|
152
|
+
log(`${targetName}: skipped — creating ${targetDir} was not approved`);
|
|
153
|
+
}
|
|
154
|
+
targetSummaries.push({ target: targetName, status: "skipped_not_approved" });
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const suffix = dryRun ? " (dry-run)" : "";
|
|
160
|
+
const result = syncTarget(
|
|
161
|
+
{
|
|
162
|
+
vaultPath,
|
|
163
|
+
targetDir,
|
|
164
|
+
targetName,
|
|
165
|
+
coreSkillIds: manifest.core.skills,
|
|
166
|
+
localVaultPaths,
|
|
167
|
+
},
|
|
168
|
+
{ dryRun },
|
|
169
|
+
);
|
|
170
|
+
log(`${targetName}: +${result.added.length} -${result.removed.length}${suffix}`);
|
|
171
|
+
if (result.skipped.length > 0) {
|
|
172
|
+
warnLine(`refused to sync ${result.skipped.join(", ")} — skill directory contains a symlink`);
|
|
173
|
+
}
|
|
174
|
+
const summary: SyncTargetSummary = {
|
|
175
|
+
target: targetName,
|
|
176
|
+
status: "synced",
|
|
177
|
+
added: result.added,
|
|
178
|
+
removed: result.removed,
|
|
179
|
+
skipped: result.skipped,
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
if (target.project_groups.length > 0) {
|
|
183
|
+
const allGroups = manifest.project ?? {};
|
|
184
|
+
const projectGroups: Record<string, ProjectGroupInput> = {};
|
|
185
|
+
for (const groupName of target.project_groups) {
|
|
186
|
+
const group = allGroups[groupName]!;
|
|
187
|
+
const approvedPaths: string[] = [];
|
|
188
|
+
for (const path of group.paths) {
|
|
189
|
+
// Mirror syncProjectTargets' own `if (!existsSync(path)) continue` so we
|
|
190
|
+
// never prompt for a project path it would silently skip anyway.
|
|
191
|
+
if (!existsSync(path)) continue;
|
|
192
|
+
const pinDir = resolveProjectPinDir(targetDir, path);
|
|
193
|
+
if (dryRun || existsSync(pinDir)) {
|
|
194
|
+
approvedPaths.push(path);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const approved = await confirmNewSyncTarget(`${targetName}/${groupName}`, pinDir, yes, isJson);
|
|
198
|
+
if (approved) approvedPaths.push(path);
|
|
199
|
+
}
|
|
200
|
+
projectGroups[groupName] = { ...group, paths: approvedPaths };
|
|
201
|
+
}
|
|
202
|
+
const projectResults = syncProjectTargets(
|
|
203
|
+
{ vaultPath, targetDir, targetName, projectGroups, localVaultPaths },
|
|
204
|
+
{ dryRun },
|
|
205
|
+
);
|
|
206
|
+
summary.projects = projectResults.map((projectResult) => ({
|
|
207
|
+
group: projectResult.group,
|
|
208
|
+
pin_dir: projectResult.pinDir,
|
|
209
|
+
added: projectResult.added,
|
|
210
|
+
removed: projectResult.removed,
|
|
211
|
+
skipped: projectResult.skipped,
|
|
212
|
+
}));
|
|
213
|
+
for (const projectResult of projectResults) {
|
|
214
|
+
log(
|
|
215
|
+
` ${projectResult.group} -> ${projectResult.pinDir}: +${projectResult.added.length} -${projectResult.removed.length}${suffix}`,
|
|
216
|
+
);
|
|
217
|
+
if (projectResult.skipped.length > 0) {
|
|
218
|
+
warnLine(
|
|
219
|
+
`refused to sync ${projectResult.skipped.join(", ")} — skill directory contains a symlink`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
targetSummaries.push(summary);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
emitSuccess(
|
|
228
|
+
{ isJson },
|
|
229
|
+
{ hook_installed: hookInstalled ?? null, notes, targets: targetSummaries },
|
|
230
|
+
() => {},
|
|
231
|
+
);
|
|
232
|
+
}
|
package/src/commands/target.ts
CHANGED
|
@@ -63,14 +63,16 @@ export async function runTarget(
|
|
|
63
63
|
!(await confirmIfNeeded({
|
|
64
64
|
confirmed: args.includes("--yes"),
|
|
65
65
|
isJson: options.isJson,
|
|
66
|
-
prompt: `
|
|
66
|
+
prompt: `adopt target ${name} at ${path}?`,
|
|
67
67
|
nonInteractiveError:
|
|
68
68
|
"skillmux target add requires --yes when run non-interactively",
|
|
69
69
|
}))
|
|
70
70
|
)
|
|
71
71
|
return;
|
|
72
72
|
applyInit(vaultPath, [{ name, dir: path }]);
|
|
73
|
-
|
|
73
|
+
emitSuccess({ isJson: options.isJson }, { name, dir: path }, () =>
|
|
74
|
+
console.log(`target "${name}" added at ${path}`),
|
|
75
|
+
);
|
|
74
76
|
return;
|
|
75
77
|
}
|
|
76
78
|
|
|
@@ -84,24 +86,34 @@ export async function runTarget(
|
|
|
84
86
|
);
|
|
85
87
|
}
|
|
86
88
|
if (options.dryRun) {
|
|
87
|
-
|
|
89
|
+
emitSuccess(
|
|
90
|
+
{ isJson: options.isJson },
|
|
91
|
+
{ name, preserved_dir: manifest.targets[name]!.dir },
|
|
92
|
+
() => console.log(`target remove: ${name} (files preserved, dry-run)`),
|
|
93
|
+
);
|
|
88
94
|
return;
|
|
89
95
|
}
|
|
90
96
|
if (
|
|
91
97
|
!(await confirmIfNeeded({
|
|
92
98
|
confirmed: args.includes("--yes"),
|
|
93
99
|
isJson: options.isJson,
|
|
94
|
-
prompt: `
|
|
100
|
+
prompt: `remove target ${name} from the manifest and preserve its files?`,
|
|
95
101
|
nonInteractiveError:
|
|
96
102
|
"skillmux target remove requires --yes when run non-interactively",
|
|
97
103
|
}))
|
|
98
104
|
)
|
|
99
105
|
return;
|
|
100
106
|
const targets = { ...manifest.targets };
|
|
107
|
+
const removedDir = manifest.targets[name]!.dir;
|
|
101
108
|
delete targets[name];
|
|
102
109
|
writeManifestAtomic(manifestPath, { ...manifest, targets });
|
|
103
|
-
|
|
104
|
-
|
|
110
|
+
emitSuccess(
|
|
111
|
+
{ isJson: options.isJson },
|
|
112
|
+
{ name, preserved_dir: removedDir },
|
|
113
|
+
() =>
|
|
114
|
+
console.log(
|
|
115
|
+
`target "${name}" removed from the manifest; files preserved at ${removedDir}`,
|
|
116
|
+
),
|
|
105
117
|
);
|
|
106
118
|
return;
|
|
107
119
|
}
|
package/src/commands/update.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { type ScanFinding, type ScanSeverity, scanExitCode } from "../scan";
|
|
|
18
18
|
import { SKILL_ID_PATTERN } from "../vault";
|
|
19
19
|
import { confirmIfNeeded } from "./shared";
|
|
20
20
|
import { checkOutdated } from "./outdated";
|
|
21
|
+
import { isGlobalFlag } from "../global-flags";
|
|
21
22
|
|
|
22
23
|
type UpdateKind = "update" | "up_to_date" | "skip_drift" | "skip_scan_failed" | "skip_read_error";
|
|
23
24
|
|
|
@@ -195,7 +196,7 @@ function parseUpdateArgs(args: string[]): {
|
|
|
195
196
|
throw new Error("--fail-on must be low, medium, or high");
|
|
196
197
|
}
|
|
197
198
|
failOn = value;
|
|
198
|
-
} else if (arg
|
|
199
|
+
} else if (isGlobalFlag(arg, "--json")) {
|
|
199
200
|
// handled globally
|
|
200
201
|
} else if (arg?.startsWith("--")) {
|
|
201
202
|
throw new Error(`unknown update option: ${arg}`);
|
package/src/config-service.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { dirname, join } from "node:path";
|
|
|
4
4
|
import { DEFAULT_CONFIG_PATH, expandHome, loadConfig } from "./config";
|
|
5
5
|
import { describeDeployment } from "./deployment";
|
|
6
6
|
import type { Config } from "./types";
|
|
7
|
+
import { stringifyToml } from "./toml-writer";
|
|
7
8
|
|
|
8
9
|
export type ConfigSource = "default" | "toml" | "environment";
|
|
9
10
|
export type ConfigSourceMap = Record<string, ConfigSource>;
|
|
@@ -373,57 +374,6 @@ export async function setDottedKey(
|
|
|
373
374
|
};
|
|
374
375
|
}
|
|
375
376
|
|
|
376
|
-
export function stringifyToml(obj: Record<string, any>): string {
|
|
377
|
-
let out = "";
|
|
378
|
-
const topLevel: Record<string, any> = {};
|
|
379
|
-
const sections: Record<string, any> = {};
|
|
380
|
-
|
|
381
|
-
for (const [k, v] of Object.entries(obj)) {
|
|
382
|
-
if (typeof v === "object" && v !== null && !Array.isArray(v)) {
|
|
383
|
-
sections[k] = v;
|
|
384
|
-
} else {
|
|
385
|
-
topLevel[k] = v;
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
for (const [k, v] of Object.entries(topLevel)) {
|
|
390
|
-
out += `${k} = ${formatTomlVal(v)}\n`;
|
|
391
|
-
}
|
|
392
|
-
if (Object.keys(topLevel).length > 0) out += "\n";
|
|
393
|
-
|
|
394
|
-
for (const [secName, secObj] of Object.entries(sections)) {
|
|
395
|
-
out += stringifyTomlSection([secName], secObj);
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
return out;
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
function stringifyTomlSection(path: string[], obj: Record<string, any>): string {
|
|
402
|
-
let out = `[${path.join(".")}]\n`;
|
|
403
|
-
const subSections: Record<string, any> = {};
|
|
404
|
-
|
|
405
|
-
for (const [k, v] of Object.entries(obj)) {
|
|
406
|
-
if (typeof v === "object" && v !== null && !Array.isArray(v)) {
|
|
407
|
-
subSections[k] = v;
|
|
408
|
-
} else {
|
|
409
|
-
out += `${k} = ${formatTomlVal(v)}\n`;
|
|
410
|
-
}
|
|
411
|
-
}
|
|
412
|
-
out += "\n";
|
|
413
|
-
|
|
414
|
-
for (const [subName, subObj] of Object.entries(subSections)) {
|
|
415
|
-
out += stringifyTomlSection([...path, subName], subObj);
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
return out;
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
function formatTomlVal(v: unknown): string {
|
|
422
|
-
if (typeof v === "string") return JSON.stringify(v);
|
|
423
|
-
if (typeof v === "boolean" || typeof v === "number") return String(v);
|
|
424
|
-
if (Array.isArray(v)) return JSON.stringify(v);
|
|
425
|
-
return JSON.stringify(v);
|
|
426
|
-
}
|
|
427
377
|
|
|
428
378
|
export async function getLocalConfigStatus(configPath?: string): Promise<ConfigStatusResponse> {
|
|
429
379
|
const { effective } = await getEffectiveConfig(configPath);
|
package/src/context.ts
CHANGED
|
@@ -12,7 +12,12 @@ export interface ContextConfig {
|
|
|
12
12
|
contexts: Record<string, ContextRecord>;
|
|
13
13
|
}
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Context resolution: `local` = this CLI process has the Skillmux runtime (vault,
|
|
17
|
+
* index, audit db, embeddings/reranker clients) loaded in-process; `remote` = this
|
|
18
|
+
* CLI process is a thin network client to a separate process elsewhere that owns that runtime.
|
|
19
|
+
*/
|
|
20
|
+
export type ResolvedContext =
|
|
16
21
|
| { type: "local"; name: "local" }
|
|
17
22
|
| { type: "remote"; name: string; server: string; token_env?: string };
|
|
18
23
|
|
|
@@ -127,10 +132,10 @@ export async function useContext(name: string, filePath?: string): Promise<void>
|
|
|
127
132
|
await saveContextConfig(config, filePath);
|
|
128
133
|
}
|
|
129
134
|
|
|
130
|
-
export async function
|
|
135
|
+
export async function resolveContext(
|
|
131
136
|
flags: { context?: string; server?: string },
|
|
132
137
|
filePath?: string
|
|
133
|
-
): Promise<
|
|
138
|
+
): Promise<ResolvedContext> {
|
|
134
139
|
// Precedence 1: Explicit flags
|
|
135
140
|
if (flags.context && flags.server) {
|
|
136
141
|
throw new Error("Cannot specify both --context and --server");
|
package/src/db-audit.ts
ADDED
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { existsSync, mkdirSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { AuditCandidate, AuditRow } from "./types";
|
|
6
|
+
|
|
7
|
+
export function openAudit(stateDir: string): Database {
|
|
8
|
+
mkdirSync(stateDir, { recursive: true });
|
|
9
|
+
const db = new Database(join(stateDir, "audit.sqlite3"), { create: true });
|
|
10
|
+
// auto_vacuum only takes on an empty database, so it must precede both the
|
|
11
|
+
// journal-mode switch and any CREATE TABLE. It is what lets a retention
|
|
12
|
+
// prune reclaim space without a full VACUUM.
|
|
13
|
+
db.run("PRAGMA auto_vacuum = INCREMENTAL");
|
|
14
|
+
db.run("PRAGMA journal_mode = WAL");
|
|
15
|
+
db.run("PRAGMA busy_timeout = 2000");
|
|
16
|
+
db.run(`CREATE TABLE IF NOT EXISTS audit (
|
|
17
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
18
|
+
ts TEXT NOT NULL,
|
|
19
|
+
request_id TEXT,
|
|
20
|
+
query TEXT NOT NULL,
|
|
21
|
+
retrieval TEXT NOT NULL DEFAULT 'lexical',
|
|
22
|
+
degraded_from TEXT,
|
|
23
|
+
degradation_reason TEXT,
|
|
24
|
+
candidates TEXT NOT NULL,
|
|
25
|
+
latency_ms INTEGER NOT NULL
|
|
26
|
+
)`);
|
|
27
|
+
// CREATE TABLE IF NOT EXISTS no-ops on a table opened from before request_id
|
|
28
|
+
// existed (AC4), so add it explicitly when missing.
|
|
29
|
+
const auditColumns = new Set(
|
|
30
|
+
(db.query("PRAGMA table_info(audit)").all() as { name: string }[]).map((c) => c.name),
|
|
31
|
+
);
|
|
32
|
+
if (!auditColumns.has("request_id")) {
|
|
33
|
+
db.run("ALTER TABLE audit ADD COLUMN request_id TEXT");
|
|
34
|
+
}
|
|
35
|
+
db.run(`CREATE TABLE IF NOT EXISTS fetch (
|
|
36
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
37
|
+
ts TEXT NOT NULL,
|
|
38
|
+
skill_id TEXT NOT NULL,
|
|
39
|
+
request_id TEXT,
|
|
40
|
+
resolve_audit_id INTEGER,
|
|
41
|
+
rank_at_resolve INTEGER
|
|
42
|
+
)`);
|
|
43
|
+
db.run(`CREATE TABLE IF NOT EXISTS admin_audit (
|
|
44
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
45
|
+
ts TEXT NOT NULL,
|
|
46
|
+
changes TEXT NOT NULL,
|
|
47
|
+
resulting_revision TEXT NOT NULL,
|
|
48
|
+
row_hash TEXT NOT NULL,
|
|
49
|
+
prev_row_hash TEXT
|
|
50
|
+
)`);
|
|
51
|
+
adoptAuditFromIndex(db, stateDir);
|
|
52
|
+
return db;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Audit rows used to live in index.sqlite3. Move any that remain there into the
|
|
56
|
+
// audit store, then drop the old table so the index carries no user queries.
|
|
57
|
+
function adoptAuditFromIndex(db: Database, stateDir: string): void {
|
|
58
|
+
const indexPath = join(stateDir, "index.sqlite3");
|
|
59
|
+
if (!existsSync(indexPath)) return;
|
|
60
|
+
|
|
61
|
+
db.run("ATTACH DATABASE ? AS legacy", [indexPath]);
|
|
62
|
+
try {
|
|
63
|
+
const legacyAudit = db
|
|
64
|
+
.query("SELECT name FROM legacy.sqlite_master WHERE type = 'table' AND name = 'audit'")
|
|
65
|
+
.get();
|
|
66
|
+
if (!legacyAudit) return;
|
|
67
|
+
|
|
68
|
+
// Older audit tables predate the retrieval columns and carry outcome /
|
|
69
|
+
// degraded / selected_skill_id instead. Select what is actually there and
|
|
70
|
+
// let the canonical defaults stand in for the rest.
|
|
71
|
+
const legacyColumns = new Set(
|
|
72
|
+
(db.query("PRAGMA legacy.table_info(audit)").all() as { name: string }[]).map((c) => c.name),
|
|
73
|
+
);
|
|
74
|
+
const retrieval = legacyColumns.has("retrieval") ? "COALESCE(retrieval, 'lexical')" : "'lexical'";
|
|
75
|
+
const degradedFrom = legacyColumns.has("degraded_from") ? "degraded_from" : "NULL";
|
|
76
|
+
const degradationReason = legacyColumns.has("degradation_reason") ? "degradation_reason" : "NULL";
|
|
77
|
+
|
|
78
|
+
// SQLite commits atomically across attached databases, so the copy and the
|
|
79
|
+
// drop either both land or neither does.
|
|
80
|
+
db.transaction(() => {
|
|
81
|
+
db.run(`INSERT INTO audit (ts, query, retrieval, degraded_from, degradation_reason, candidates, latency_ms)
|
|
82
|
+
SELECT ts, query, ${retrieval}, ${degradedFrom}, ${degradationReason}, candidates, latency_ms FROM legacy.audit`);
|
|
83
|
+
db.run("DROP TABLE legacy.audit");
|
|
84
|
+
})();
|
|
85
|
+
} finally {
|
|
86
|
+
db.run("DETACH DATABASE legacy");
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface AuditInsert {
|
|
91
|
+
ts: string;
|
|
92
|
+
request_id?: string | null;
|
|
93
|
+
query: string;
|
|
94
|
+
retrieval: AuditRow["retrieval"];
|
|
95
|
+
degraded_from?: string | null;
|
|
96
|
+
degradation_reason?: string | null;
|
|
97
|
+
candidates: AuditCandidate[];
|
|
98
|
+
latency_ms: number;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function insertAudit(db: Database, row: AuditInsert): void {
|
|
102
|
+
db.run(
|
|
103
|
+
`INSERT INTO audit (ts, request_id, query, retrieval, degraded_from, degradation_reason, candidates, latency_ms)
|
|
104
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
105
|
+
[
|
|
106
|
+
row.ts,
|
|
107
|
+
row.request_id ?? null,
|
|
108
|
+
row.query,
|
|
109
|
+
row.retrieval,
|
|
110
|
+
row.degraded_from ?? null,
|
|
111
|
+
row.degradation_reason ?? null,
|
|
112
|
+
JSON.stringify(row.candidates),
|
|
113
|
+
row.latency_ms,
|
|
114
|
+
],
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Correlation lookup for AC5/AC7: looks up the resolve that produced
|
|
120
|
+
* `requestId`, or null when it names no known resolve (including malformed
|
|
121
|
+
* input, which is never validated at the boundary per AC7).
|
|
122
|
+
*/
|
|
123
|
+
export function getAuditRowByRequestId(
|
|
124
|
+
db: Database,
|
|
125
|
+
requestId: string,
|
|
126
|
+
): { id: number; candidates: AuditCandidate[] } | null {
|
|
127
|
+
const row = db
|
|
128
|
+
.query("SELECT id, candidates FROM audit WHERE request_id = ?")
|
|
129
|
+
.get(requestId) as { id: number; candidates: string } | null;
|
|
130
|
+
if (!row) return null;
|
|
131
|
+
return { id: row.id, candidates: JSON.parse(row.candidates) as AuditCandidate[] };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface FetchInsert {
|
|
135
|
+
ts: string;
|
|
136
|
+
skill_id: string;
|
|
137
|
+
request_id?: string | null;
|
|
138
|
+
resolve_audit_id?: number | null;
|
|
139
|
+
rank_at_resolve?: number | null;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function insertFetch(db: Database, row: FetchInsert): void {
|
|
143
|
+
db.run(
|
|
144
|
+
`INSERT INTO fetch (ts, skill_id, request_id, resolve_audit_id, rank_at_resolve)
|
|
145
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
146
|
+
[
|
|
147
|
+
row.ts,
|
|
148
|
+
row.skill_id,
|
|
149
|
+
row.request_id ?? null,
|
|
150
|
+
row.resolve_audit_id ?? null,
|
|
151
|
+
row.rank_at_resolve ?? null,
|
|
152
|
+
],
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface PruneResult {
|
|
157
|
+
audit_deleted: number;
|
|
158
|
+
fetch_deleted: number;
|
|
159
|
+
admin_audit_deleted: number;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Deletes resolve, fetch, and admin_audit rows with ts before `cutoffIso`,
|
|
164
|
+
* each by its own timestamp; no FK ties them, so a fetch outliving its
|
|
165
|
+
* resolve row simply reads back uncorrelated (AC7's existing null path).
|
|
166
|
+
* admin_audit shares this cutoff rather than a separate retention config
|
|
167
|
+
* (AC10) — its hash chain is unaffected since pruning only ever removes the
|
|
168
|
+
* oldest rows, never rows in the middle of the chain. Reclaims the freed
|
|
169
|
+
* pages with an incremental vacuum, which only touches audit.sqlite3 (AC16).
|
|
170
|
+
*/
|
|
171
|
+
export function pruneAuditBefore(db: Database, cutoffIso: string): PruneResult {
|
|
172
|
+
const auditResult = db.run("DELETE FROM audit WHERE ts < ?", [cutoffIso]);
|
|
173
|
+
const fetchResult = db.run("DELETE FROM fetch WHERE ts < ?", [cutoffIso]);
|
|
174
|
+
const adminAuditResult = db.run("DELETE FROM admin_audit WHERE ts < ?", [cutoffIso]);
|
|
175
|
+
db.run("PRAGMA incremental_vacuum");
|
|
176
|
+
|
|
177
|
+
return {
|
|
178
|
+
audit_deleted: auditResult.changes,
|
|
179
|
+
fetch_deleted: fetchResult.changes,
|
|
180
|
+
admin_audit_deleted: adminAuditResult.changes,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** AC12: retentionDays <= 0 disables pruning entirely. */
|
|
185
|
+
export function pruneAudit(db: Database, retentionDays: number, now: Date = new Date()): PruneResult {
|
|
186
|
+
if (retentionDays <= 0) return { audit_deleted: 0, fetch_deleted: 0, admin_audit_deleted: 0 };
|
|
187
|
+
const cutoff = new Date(now.getTime() - retentionDays * 86_400_000).toISOString();
|
|
188
|
+
return pruneAuditBefore(db, cutoff);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export interface AdminAuditChange {
|
|
192
|
+
key: string;
|
|
193
|
+
old_value: unknown;
|
|
194
|
+
new_value: unknown;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface AdminAuditInsert {
|
|
198
|
+
ts: string;
|
|
199
|
+
changes: AdminAuditChange[];
|
|
200
|
+
resulting_revision: string;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface AdminAuditRow {
|
|
204
|
+
id: number;
|
|
205
|
+
ts: string;
|
|
206
|
+
changes: AdminAuditChange[];
|
|
207
|
+
resulting_revision: string;
|
|
208
|
+
row_hash: string;
|
|
209
|
+
prev_row_hash: string | null;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function computeAdminAuditRowHash(
|
|
213
|
+
prevRowHash: string | null,
|
|
214
|
+
fields: { ts: string; changes: AdminAuditChange[]; resulting_revision: string },
|
|
215
|
+
): string {
|
|
216
|
+
const payload = JSON.stringify({ prev_row_hash: prevRowHash, ...fields });
|
|
217
|
+
return createHash("sha256").update(payload).digest("hex");
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Appends one tamper-evident admin_audit row, chaining its hash to the
|
|
222
|
+
* previous row's hash (or null for the first row) so any out-of-band
|
|
223
|
+
* edit/delete breaks the chain — see verifyAdminAuditChain.
|
|
224
|
+
*/
|
|
225
|
+
export function insertAdminAuditRow(db: Database, row: AdminAuditInsert): AdminAuditRow {
|
|
226
|
+
const prevRow = db
|
|
227
|
+
.query("SELECT row_hash FROM admin_audit ORDER BY id DESC LIMIT 1")
|
|
228
|
+
.get() as { row_hash: string } | null;
|
|
229
|
+
const prevRowHash = prevRow?.row_hash ?? null;
|
|
230
|
+
const rowHash = computeAdminAuditRowHash(prevRowHash, row);
|
|
231
|
+
|
|
232
|
+
db.run(
|
|
233
|
+
`INSERT INTO admin_audit (ts, changes, resulting_revision, row_hash, prev_row_hash)
|
|
234
|
+
VALUES (?, ?, ?, ?, ?)`,
|
|
235
|
+
[row.ts, JSON.stringify(row.changes), row.resulting_revision, rowHash, prevRowHash],
|
|
236
|
+
);
|
|
237
|
+
|
|
238
|
+
const inserted = db.query("SELECT last_insert_rowid() AS id").get() as { id: number };
|
|
239
|
+
return {
|
|
240
|
+
id: inserted.id,
|
|
241
|
+
ts: row.ts,
|
|
242
|
+
changes: row.changes,
|
|
243
|
+
resulting_revision: row.resulting_revision,
|
|
244
|
+
row_hash: rowHash,
|
|
245
|
+
prev_row_hash: prevRowHash,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export interface AdminAuditChainResult {
|
|
250
|
+
valid: boolean;
|
|
251
|
+
broken_at_id: number | null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/** Walks admin_audit in insertion order and reports whether the hash chain is unbroken. */
|
|
255
|
+
export function verifyAdminAuditChain(db: Database): AdminAuditChainResult {
|
|
256
|
+
const rows = db
|
|
257
|
+
.query("SELECT id, ts, changes, resulting_revision, row_hash, prev_row_hash FROM admin_audit ORDER BY id ASC")
|
|
258
|
+
.all() as { id: number; ts: string; changes: string; resulting_revision: string; row_hash: string; prev_row_hash: string | null }[];
|
|
259
|
+
|
|
260
|
+
let expectedPrevHash: string | null = null;
|
|
261
|
+
for (const row of rows) {
|
|
262
|
+
if (row.prev_row_hash !== expectedPrevHash) {
|
|
263
|
+
return { valid: false, broken_at_id: row.id };
|
|
264
|
+
}
|
|
265
|
+
const recomputed = computeAdminAuditRowHash(expectedPrevHash, {
|
|
266
|
+
ts: row.ts,
|
|
267
|
+
changes: JSON.parse(row.changes),
|
|
268
|
+
resulting_revision: row.resulting_revision,
|
|
269
|
+
});
|
|
270
|
+
if (recomputed !== row.row_hash) {
|
|
271
|
+
return { valid: false, broken_at_id: row.id };
|
|
272
|
+
}
|
|
273
|
+
expectedPrevHash = row.row_hash;
|
|
274
|
+
}
|
|
275
|
+
return { valid: true, broken_at_id: null };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Dry-run counterpart of pruneAuditBefore: counts without deleting (AC15). */
|
|
279
|
+
export function countPrunable(db: Database, cutoffIso: string): PruneResult {
|
|
280
|
+
const auditRow = db.query("SELECT count(*) AS n FROM audit WHERE ts < ?").get(cutoffIso) as { n: number };
|
|
281
|
+
const fetchRow = db.query("SELECT count(*) AS n FROM fetch WHERE ts < ?").get(cutoffIso) as { n: number };
|
|
282
|
+
const adminAuditRow = db
|
|
283
|
+
.query("SELECT count(*) AS n FROM admin_audit WHERE ts < ?")
|
|
284
|
+
.get(cutoffIso) as { n: number };
|
|
285
|
+
return { audit_deleted: auditRow.n, fetch_deleted: fetchRow.n, admin_audit_deleted: adminAuditRow.n };
|
|
286
|
+
}
|