@kontextmind/kxm 0.7.97 → 0.7.98
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/.claude-plugin/marketplace.json +1 -1
- package/.kxm/workflows/default.yaml +2 -0
- package/CHANGELOG.md +39 -2
- package/docs/concepts/architecture.md +1 -1
- package/docs/concepts/data-and-storage.md +1 -1
- package/docs/contracts/routing.md +1 -1
- package/docs/contributing/test-matrix.md +6 -5
- package/docs/operations/backup-and-restore.md +43 -24
- package/docs/operations/deploy.md +1 -1
- package/docs/reference/cli-reference.md +59 -24
- package/docs/reference/config-reference.md +24 -13
- package/docs/reference/harness-routing.md +3 -3
- package/docs/reference/http-api.md +1 -1
- package/docs/start/first-workflow.md +4 -4
- package/docs/start/quickstart-claude-code.md +2 -2
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/cli.js +393 -154
- package/plugins/kxm/dist/core.js +5 -2
- package/plugins/kxm/dist/mcp-server.js +1 -1
- package/plugins/kxm/dist/runtime-supervisor.js +231 -48
- package/plugins/kxm/dist/runtime.js +415 -92
- package/plugins/kxm/dist/server.js +7 -0
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +4 -3
- package/plugins/kxm/skills/kxm-project-setup/SKILL.md +3 -2
- package/plugins/kxm/skills/kxm-routing-improve/SKILL.md +3 -0
- package/plugins/kxm/skills/kxm-runs/SKILL.md +8 -7
- package/plugins/kxm/src/cli/project.ts +22 -13
- package/plugins/kxm/src/cli/system.ts +22 -1
- package/plugins/kxm/src/cli.ts +11 -3
- package/plugins/kxm/src/database.ts +210 -36
- package/plugins/kxm/src/engine.ts +117 -2
- package/plugins/kxm/src/harness.ts +29 -0
- package/plugins/kxm/src/init-guide-setup.ts +43 -28
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/oneshot-producer.ts +16 -8
- package/plugins/kxm/src/prices.ts +33 -2
- package/plugins/kxm/src/routing.ts +13 -7
- package/plugins/kxm/src/studio-layout.ts +5 -4
- package/plugins/kxm/src/template.ts +31 -0
- package/plugins/kxm/src/worktree-witness.ts +71 -0
- package/schemas/backup-manifest.schema.json +33 -0
|
@@ -15271,6 +15271,10 @@ var READ_ONLY_ONESHOT_ARGS = Object.freeze({
|
|
|
15271
15271
|
function oneShotReadOnlyArgs(harness) {
|
|
15272
15272
|
return Object.hasOwn(READ_ONLY_ONESHOT_ARGS, harness) ? READ_ONLY_ONESHOT_ARGS[harness] : void 0;
|
|
15273
15273
|
}
|
|
15274
|
+
var WRITER_ONESHOT_ARGS = Object.freeze({
|
|
15275
|
+
pi: Object.freeze(["-a", "--no-extensions", "--no-skills", "--no-prompt-templates", "--no-session"]),
|
|
15276
|
+
grok: Object.freeze(["--always-approve", "--no-subagents", "--disable-web-search"])
|
|
15277
|
+
});
|
|
15274
15278
|
var BUILTIN_HARNESSES = Object.freeze([
|
|
15275
15279
|
{
|
|
15276
15280
|
id: "pi",
|
|
@@ -19418,6 +19422,9 @@ var DatabaseSync = class {
|
|
|
19418
19422
|
}
|
|
19419
19423
|
};
|
|
19420
19424
|
|
|
19425
|
+
// plugins/kxm/src/bindings.ts
|
|
19426
|
+
var MAX_BINDING_RECORD_BYTES = 256 * 1024;
|
|
19427
|
+
|
|
19421
19428
|
// plugins/kxm/src/database.ts
|
|
19422
19429
|
function databaseError(code, file, message) {
|
|
19423
19430
|
const issue2 = { phase: "semantic", code, file, message };
|
package/plugins/kxm/package.json
CHANGED
|
@@ -15,7 +15,7 @@ invent restart or status subcommands.
|
|
|
15
15
|
|---|---|---|
|
|
16
16
|
| `kxm hub view` | Check `/health` and `/ready`; exits 1 when the hub is down | `--json` |
|
|
17
17
|
| `kxm tenant status` | Hub metadata and Runtime run state as one labeled view; reads only and never starts the supervisor | `--json` |
|
|
18
|
-
| `kxm backup` | Verified SQLite backup of the hub stores with a hashed manifest | `--out <dir>`, `--json` |
|
|
18
|
+
| `kxm backup` | Verified SQLite backup of the hub store and the Runtime stores with a hashed manifest; exits 1 when the backup is incomplete | `--out <dir>`, `--json` |
|
|
19
19
|
|
|
20
20
|
```bash
|
|
21
21
|
kxm hub view --json
|
|
@@ -30,8 +30,9 @@ runs have separate ID spaces, so a comparison with no shared ID is
|
|
|
30
30
|
`unverified`, never agreement.
|
|
31
31
|
|
|
32
32
|
The durable hub store defaults to `.kxm/state/kxm.db` (`KXM_DATA_PATH`). Do not
|
|
33
|
-
hand-edit it. `kxm backup`
|
|
34
|
-
under the user state root
|
|
33
|
+
hand-edit it. `kxm backup` also copies the Runtime registry and every
|
|
34
|
+
project's run event store and prompt sidecar under the user state root, so a
|
|
35
|
+
`kxm restore` rolls back every project on the machine, not only this one.
|
|
35
36
|
|
|
36
37
|
## Operator steps
|
|
37
38
|
|
|
@@ -122,8 +122,9 @@ or store the admin or project token in the conversation. The user enters
|
|
|
122
122
|
in a workflow step, then run `kxm init` again. Only when
|
|
123
123
|
`.kxm/project.yaml` does not exist yet, ask the user to follow the README's
|
|
124
124
|
move-aside workaround; never do that in a committed project.
|
|
125
|
-
- The template `default` workflow
|
|
126
|
-
`
|
|
125
|
+
- The template `default` workflow can be driven, but even a simulated drive
|
|
126
|
+
runs its `npm test` gate, and a live drive spends Claude and Grok and lets
|
|
127
|
+
Grok edit the checkout. Use `first` for the first run.
|
|
127
128
|
- `kxm run` starts the Runtime supervisor. Stop it with `kxm runtime stop`.
|
|
128
129
|
- Only run workflow IDs that `kxm workflow definitions` lists; any other ID
|
|
129
130
|
fails with `run_workflow_unknown`.
|
|
@@ -73,11 +73,14 @@ Never apply a candidate diff yourself or treat `readyForReview` as approval.
|
|
|
73
73
|
|---|---|---|
|
|
74
74
|
| `kxm routing report` | Compare verified completion, cost, and rework per behavioral configuration | `-f/--file`, `-l/--equivalent-list-cost`, `--list-prices`, `--prices <path>` |
|
|
75
75
|
| `kxm routing benchmark` | Placeholder side-by-side comparison | `--task`, `--arms`, `--runs` |
|
|
76
|
+
| `kxm prices acknowledge` | Stamp the existing `.kxm/prices.yaml` list as today's estimate without fetching vendor rates | `--json` |
|
|
76
77
|
|
|
77
78
|
`kxm routing report` reads the same sources as `kxm improve`, and
|
|
78
79
|
`routing report --json` includes the `sources`. `kxm routing benchmark` prints
|
|
79
80
|
fixed placeholder figures in this build; never cite them as measured cost or
|
|
80
81
|
quality. Use `kxm routing report` for recorded spend.
|
|
82
|
+
Estimates stay unknown until the catalog carries today's stamp, and a routing
|
|
83
|
+
total is null when any attempt has no cost.
|
|
81
84
|
|
|
82
85
|
`--equivalent-list-cost` loads the price catalog (`.kxm/prices.yaml`, or
|
|
83
86
|
`--prices <path>`) without a freshness check, so an old catalog quotes old
|
|
@@ -7,8 +7,9 @@ description: Create, drive, and inspect local KXM runs. kxm runs drive with --si
|
|
|
7
7
|
|
|
8
8
|
`kxm run` creates a run of a project workflow in the local Runtime and starts
|
|
9
9
|
the Runtime supervisor. A created run stays `created` until
|
|
10
|
-
`kxm runs drive` executes
|
|
11
|
-
`
|
|
10
|
+
`kxm runs drive` executes the pinned plan. Live drive is the default and spends
|
|
11
|
+
an admitted model; `--simulated` is the model-free producer. Do not invent get,
|
|
12
|
+
create, or logs verbs under `runs`.
|
|
12
13
|
|
|
13
14
|
## Commands
|
|
14
15
|
|
|
@@ -58,11 +59,11 @@ the receipt's settlement is terminal `completed`.
|
|
|
58
59
|
- `run_workflow_unknown`: the workflow ID is not a project workflow. Run only
|
|
59
60
|
IDs that `kxm workflow definitions` lists.
|
|
60
61
|
- `run_handoff_required`: the Runtime does not execute a field the workflow
|
|
61
|
-
uses
|
|
62
|
-
|
|
63
|
-
`
|
|
64
|
-
|
|
65
|
-
`first`.
|
|
62
|
+
uses (such as `limits.maxAgentTimeMs`, which a fresh `kxm init` no longer
|
|
63
|
+
writes), so drive fails with `runtime request failed with HTTP 409` and the
|
|
64
|
+
run stays `preparing`. Cancel the run with `kxm runs cancel <runId>` and drive
|
|
65
|
+
a workflow without the field, such as `first`.
|
|
66
66
|
|
|
67
67
|
Listing or status alone is not proof that steps ran; a verified receipt is.
|
|
68
|
+
A read-only step that settles `passed` did not author a checkout change.
|
|
68
69
|
`kxm workflow list` shows hub webhook runs, not these runs.
|
|
@@ -185,36 +185,41 @@ export async function cmdBackup(runtime: Runtime, options: { out?: string | unde
|
|
|
185
185
|
try {
|
|
186
186
|
const backupOptions = {
|
|
187
187
|
projectRoot: runtime.cwd,
|
|
188
|
+
env: runtime.env,
|
|
188
189
|
...(options.out ? { outDir: resolve(runtime.cwd, options.out) } : {}),
|
|
189
190
|
};
|
|
190
191
|
if (runtime.dryRun) {
|
|
191
192
|
const plan = planBackup(backupOptions);
|
|
192
193
|
printPlan(
|
|
193
194
|
runtime,
|
|
194
|
-
{ command: "backup", outDir: plan.outDir, stores: plan.stores },
|
|
195
|
+
{ command: "backup", outDir: plan.outDir, stores: plan.stores, files: plan.files },
|
|
195
196
|
[
|
|
196
|
-
...plan.stores.map((
|
|
197
|
+
...[...plan.stores, ...plan.files].map((entry) => ({ action: "write" as const, target: join(plan.outDir, entry.backupFile) })),
|
|
197
198
|
{ action: "write", target: join(plan.outDir, "manifest.json") },
|
|
198
199
|
],
|
|
199
|
-
`back up ${plan.stores.length} store(s) to ${plan.outDir} (sources are not opened, so their WAL is not checkpointed)`,
|
|
200
|
+
`back up ${plan.stores.length} store(s) and ${plan.files.length} file(s) to ${plan.outDir} (sources are not opened, so their WAL is not checkpointed)`,
|
|
200
201
|
);
|
|
201
202
|
return 0;
|
|
202
203
|
}
|
|
203
204
|
const { manifest, outDir } = createBackup(backupOptions);
|
|
205
|
+
const complete = manifest.complete === true;
|
|
204
206
|
const payload = {
|
|
205
|
-
ok:
|
|
207
|
+
ok: complete,
|
|
206
208
|
command: "backup",
|
|
207
209
|
backupId: manifest.backupId,
|
|
208
210
|
outDir,
|
|
209
211
|
manifest,
|
|
210
212
|
};
|
|
211
213
|
const summary = [
|
|
212
|
-
|
|
214
|
+
complete
|
|
215
|
+
? `Created SQLite backup with ${manifest.stores.length} store(s):`
|
|
216
|
+
: `Backup is incomplete (${manifest.omitted?.length ?? 0} omitted); not ok:`,
|
|
213
217
|
...manifest.stores.map((s) => ` - ${s.storeId}: ${s.sourcePath} -> ${s.backupFile} (schema v${s.schemaVersion}, ${s.bytes} bytes, sha256 ${s.sha256.slice(0, 12)}...)`),
|
|
218
|
+
...(manifest.omitted ?? []).map((id) => ` - omitted ${id}`),
|
|
214
219
|
`Manifest: ${join(outDir, "manifest.json")}`,
|
|
215
220
|
].join("\n");
|
|
216
221
|
print(runtime.io, runtime.json, payload, summary);
|
|
217
|
-
return 0;
|
|
222
|
+
return complete ? 0 : 1;
|
|
218
223
|
} catch (error) {
|
|
219
224
|
if (error instanceof KxmConfigError) {
|
|
220
225
|
print(runtime.io, runtime.json, { ok: false, command: "backup", error: "backup_failed", issues: error.issues }, `backup failed: ${error.message}`);
|
|
@@ -236,14 +241,18 @@ export async function cmdRestore(runtime: Runtime, manifestArg: string): Promise
|
|
|
236
241
|
backupId: plan.backupId,
|
|
237
242
|
manifestPath: plan.manifestPath,
|
|
238
243
|
stores: plan.stores.map(({ storeId, targetPath, schemaVersion }) => ({ storeId, targetPath, schemaVersion })),
|
|
244
|
+
files: plan.files.map(({ id, targetPath }) => ({ id, targetPath })),
|
|
239
245
|
},
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
.
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
246
|
+
[
|
|
247
|
+
...plan.stores.flatMap((store) => [
|
|
248
|
+
{ action: "write" as const, target: store.targetPath },
|
|
249
|
+
...[`${store.targetPath}-wal`, `${store.targetPath}-shm`]
|
|
250
|
+
.filter((file) => existsSync(file))
|
|
251
|
+
.map((file) => ({ action: "delete" as const, target: file })),
|
|
252
|
+
]),
|
|
253
|
+
...plan.files.map((file) => ({ action: "write" as const, target: file.targetPath })),
|
|
254
|
+
],
|
|
255
|
+
`restore ${plan.stores.length} store(s) and ${plan.files.length} file(s) from ${plan.manifestPath}; digests verified against the manifest`,
|
|
247
256
|
);
|
|
248
257
|
return 0;
|
|
249
258
|
}
|
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
type RoutingRecord,
|
|
21
21
|
type RoutingRecordV2,
|
|
22
22
|
} from "../routing.ts";
|
|
23
|
-
import { loadPriceCatalog, type PriceCatalog } from "../prices.ts";
|
|
23
|
+
import { acknowledgePriceCatalog, loadPriceCatalog, type PriceCatalog } from "../prices.ts";
|
|
24
24
|
import {
|
|
25
25
|
buildImprovementReport,
|
|
26
26
|
formatImprovementReport,
|
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
import {
|
|
60
60
|
GUIDE_WORKFLOWS,
|
|
61
61
|
parseGuideSelection,
|
|
62
|
+
mergeGuideRouteAdmission,
|
|
62
63
|
planGuideSetup,
|
|
63
64
|
renderGuideSetupFiles,
|
|
64
65
|
writeGuideSetupFiles,
|
|
@@ -867,8 +868,10 @@ export async function maybeOfferGuideSetup(runtime: Runtime): Promise<void> {
|
|
|
867
868
|
const plan = planGuideSetup({ inventory, selected });
|
|
868
869
|
const files = renderGuideSetupFiles(runtime.cwd, plan);
|
|
869
870
|
const report = writeGuideSetupFiles(files);
|
|
871
|
+
const admitted = mergeGuideRouteAdmission(runtime.cwd, plan);
|
|
870
872
|
for (const file of report.written) runtime.io.stdout(`wrote ${file}\n`);
|
|
871
873
|
for (const file of report.existed) runtime.io.stdout(`kept existing ${file} (not overwritten)\n`);
|
|
874
|
+
for (const selector of admitted) runtime.io.stdout(`admitted route ${selector}\n`);
|
|
872
875
|
for (const skip of plan.skipped) {
|
|
873
876
|
runtime.io.stdout(`skipped ${skip.workflow}/${skip.role}: ${skip.reason}\n`);
|
|
874
877
|
}
|
|
@@ -877,6 +880,24 @@ export async function maybeOfferGuideSetup(runtime: Runtime): Promise<void> {
|
|
|
877
880
|
}
|
|
878
881
|
}
|
|
879
882
|
|
|
883
|
+
export async function cmdPricesAcknowledge(runtime: Runtime): Promise<number> {
|
|
884
|
+
try {
|
|
885
|
+
const catalog = acknowledgePriceCatalog(runtime.cwd);
|
|
886
|
+
print(runtime.io, runtime.json, {
|
|
887
|
+
ok: true,
|
|
888
|
+
command: "prices acknowledge",
|
|
889
|
+
date: catalog.date,
|
|
890
|
+
sha256: catalog.sha256,
|
|
891
|
+
note: "stamped the existing list as today's estimate; vendor rates were not fetched",
|
|
892
|
+
}, `price catalog stamped ${catalog.date} (list estimate only; vendor rates were not fetched)`);
|
|
893
|
+
return 0;
|
|
894
|
+
} catch (error) {
|
|
895
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
896
|
+
print(runtime.io, runtime.json, { ok: false, command: "prices acknowledge", error: "prices_acknowledge_failed", message }, `prices acknowledge failed: ${message}`);
|
|
897
|
+
return 1;
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
880
901
|
export async function cmdRoutingReport(
|
|
881
902
|
runtime: Runtime,
|
|
882
903
|
options: { file?: string | undefined; equivalentListCost?: boolean | undefined; listPrices?: boolean | undefined; prices?: string | undefined },
|
package/plugins/kxm/src/cli.ts
CHANGED
|
@@ -149,6 +149,7 @@ import {
|
|
|
149
149
|
cmdConfigList,
|
|
150
150
|
cmdCompletion,
|
|
151
151
|
cmdCompletionInstall,
|
|
152
|
+
cmdPricesAcknowledge,
|
|
152
153
|
cmdRoutingReport,
|
|
153
154
|
cmdRoutingBenchmark,
|
|
154
155
|
maybeOfferCompletionInstall,
|
|
@@ -416,7 +417,7 @@ function createProgram(ctx: CliContext, result: { code: number }): Command {
|
|
|
416
417
|
});
|
|
417
418
|
});
|
|
418
419
|
|
|
419
|
-
addGlobalOptions(program.command("backup").description("Create a verified SQLite backup of the project hub store
|
|
420
|
+
addGlobalOptions(program.command("backup").description("Create a verified SQLite backup of the project hub store and the Runtime stores under the user state root, with a hashed manifest"))
|
|
420
421
|
.option("--out <dir>", "Directory to write backup and manifest")
|
|
421
422
|
.action(async function backupAction(this: Command, options: { out?: string }) {
|
|
422
423
|
result.code = await cmdBackup(runtimeFrom(ctx, this), options);
|
|
@@ -442,7 +443,7 @@ function createProgram(ctx: CliContext, result: { code: number }): Command {
|
|
|
442
443
|
});
|
|
443
444
|
addGlobalOptions(runCmd.command("drive").description("Drive a run with live harness calls, or with the model-free simulation when --simulated is passed"))
|
|
444
445
|
.argument("<runId>", "Run id")
|
|
445
|
-
.option("--simulated", "Use the model-free simulation producer")
|
|
446
|
+
.option("--simulated", "Use the model-free simulation producer instead of a live model")
|
|
446
447
|
.option("--wait", "Wait until a drive receipt is recorded; exits 0 only for a VERIFIED COMPLETED settlement")
|
|
447
448
|
.option("--timeout-ms <n>", "Wait timeout in milliseconds (default 60000, max 600000)")
|
|
448
449
|
.action(async function runDriveAction(this: Command, runId: string, options: { simulated?: boolean; wait?: boolean; timeoutMs?: string }) {
|
|
@@ -474,6 +475,13 @@ function createProgram(ctx: CliContext, result: { code: number }): Command {
|
|
|
474
475
|
result.code = await cmdTenantStatus(runtimeFrom(ctx, this));
|
|
475
476
|
});
|
|
476
477
|
|
|
478
|
+
const pricesCmd = addGlobalOptions(program.command("prices").description("Stamp the local list-price catalog. Estimates stay unknown until today's stamp"));
|
|
479
|
+
pricesCmd.helpCommand("help", "Show prices help");
|
|
480
|
+
addGlobalOptions(pricesCmd.command("acknowledge").description("Stamp the existing .kxm/prices.yaml list as today's estimate without fetching vendor rates"))
|
|
481
|
+
.action(async function pricesAcknowledgeAction(this: Command) {
|
|
482
|
+
result.code = await cmdPricesAcknowledge(runtimeFrom(ctx, this));
|
|
483
|
+
});
|
|
484
|
+
|
|
477
485
|
const modelsCmd = addGlobalOptions(program.command("models").description("Manage model catalogs, roles, and route state"));
|
|
478
486
|
modelsCmd.action(async function modelsScreenAction(this: Command) { result.code = await cmdModelsScreen(runtimeFrom(ctx, this)); });
|
|
479
487
|
modelsCmd.helpCommand("help", "Show models help");
|
|
@@ -928,7 +936,7 @@ function createProgram(ctx: CliContext, result: { code: number }): Command {
|
|
|
928
936
|
result.code = await cmdGithubWatch(runtimeFrom(ctx, this), options);
|
|
929
937
|
});
|
|
930
938
|
|
|
931
|
-
const improve = addGlobalOptions(program.command("improve").description("Propose coded-repeat candidates from this project's Runtime routing records and telemetry"));
|
|
939
|
+
const improve = addGlobalOptions(program.command("improve").description("Propose coded-repeat candidates from this project's Runtime routing records and telemetry. Proposals are not applied to the next run"));
|
|
932
940
|
improve.helpCommand("help", "Show improve help");
|
|
933
941
|
addGlobalOptions(improve.command("report", { isDefault: true }).description("Generate improvement report and candidates from routing records"))
|
|
934
942
|
.option("--file <path>", "Read only this routing-record JSONL instead of the project's Runtime store and telemetry")
|
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
} from "node:fs";
|
|
13
13
|
import { basename, dirname, join, resolve } from "node:path";
|
|
14
14
|
import { DatabaseSync } from "./sqlite.ts";
|
|
15
|
+
import { kxmUserStateRoot } from "./bindings.ts";
|
|
15
16
|
import { KxmConfigError, type KxmConfigIssue } from "./project-config.ts";
|
|
16
17
|
|
|
17
18
|
export interface DatabaseSchemaSpec {
|
|
@@ -31,12 +32,24 @@ export interface BackupStoreRecord {
|
|
|
31
32
|
integrity: "ok";
|
|
32
33
|
}
|
|
33
34
|
|
|
35
|
+
export interface BackupFileRecord {
|
|
36
|
+
id: string;
|
|
37
|
+
sourcePath: string;
|
|
38
|
+
backupFile: string;
|
|
39
|
+
sha256: string;
|
|
40
|
+
bytes: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
34
43
|
export interface BackupManifest {
|
|
35
44
|
schema: "kxm.backup-manifest.v1";
|
|
36
45
|
backupId: string;
|
|
37
46
|
createdAt: string;
|
|
38
47
|
projectRoot?: string;
|
|
39
48
|
stores: BackupStoreRecord[];
|
|
49
|
+
files?: BackupFileRecord[];
|
|
50
|
+
/** False when a discovered store or prompt sidecar was left out. Absent on legacy manifests. */
|
|
51
|
+
complete?: boolean;
|
|
52
|
+
omitted?: string[];
|
|
40
53
|
manifestSha256?: string;
|
|
41
54
|
}
|
|
42
55
|
|
|
@@ -607,46 +620,136 @@ export const KXM_BACKUP_CEILINGS = {
|
|
|
607
620
|
} as const;
|
|
608
621
|
|
|
609
622
|
export function kxmBackupCeiling(storeId: string): number {
|
|
623
|
+
if (storeId === "runtime-registry") return KXM_BACKUP_CEILINGS.registry;
|
|
610
624
|
if (storeId.startsWith("events:")) return KXM_BACKUP_CEILINGS.events;
|
|
611
625
|
// An id this build does not know keeps the ceiling restore has always defaulted to.
|
|
612
626
|
return KXM_BACKUP_CEILINGS[storeId as keyof typeof KXM_BACKUP_CEILINGS] ?? KXM_BACKUP_CEILINGS["hub-store"];
|
|
613
627
|
}
|
|
614
628
|
|
|
615
|
-
|
|
629
|
+
interface DiscoveredStore {
|
|
630
|
+
storeId: string;
|
|
631
|
+
sourcePath: string;
|
|
632
|
+
maxSupportedVersion: number;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
interface DiscoveredFile {
|
|
636
|
+
id: string;
|
|
637
|
+
sourcePath: string;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function pushStore(stores: DiscoveredStore[], storeId: string, sourcePath: string): void {
|
|
641
|
+
if (stores.some((store) => store.storeId === storeId || store.sourcePath === sourcePath)) return;
|
|
642
|
+
stores.push({ storeId, sourcePath, maxSupportedVersion: kxmBackupCeiling(storeId) });
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function discoverUserRuntime(env: NodeJS.ProcessEnv | undefined, stores: DiscoveredStore[], files: DiscoveredFile[]): void {
|
|
646
|
+
let stateRoot: string;
|
|
647
|
+
try {
|
|
648
|
+
stateRoot = kxmUserStateRoot(env ? { env } : {});
|
|
649
|
+
} catch {
|
|
650
|
+
return;
|
|
651
|
+
}
|
|
652
|
+
const runtimeDir = join(stateRoot, "runtime");
|
|
653
|
+
const registryPath = join(runtimeDir, "registry.db");
|
|
654
|
+
if (existsSync(registryPath)) {
|
|
655
|
+
const storeId = stores.some((store) => store.storeId === "registry") ? "runtime-registry" : "registry";
|
|
656
|
+
pushStore(stores, storeId, registryPath);
|
|
657
|
+
}
|
|
658
|
+
const projectsDir = join(runtimeDir, "projects");
|
|
659
|
+
if (!existsSync(projectsDir)) return;
|
|
660
|
+
let entries;
|
|
661
|
+
try {
|
|
662
|
+
entries = readdirSync(projectsDir, { withFileTypes: true });
|
|
663
|
+
} catch {
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
for (const entry of entries) {
|
|
667
|
+
if (!entry.isDirectory()) continue;
|
|
668
|
+
const dbPath = join(projectsDir, entry.name, "run-events.db");
|
|
669
|
+
if (!existsSync(dbPath)) continue;
|
|
670
|
+
const safe = entry.name.replace(/[^a-zA-Z0-9_.-]/g, "_");
|
|
671
|
+
let storeId = `events:${safe}`;
|
|
672
|
+
if (stores.some((store) => store.storeId === storeId)) storeId = `events:runtime:${safe}`;
|
|
673
|
+
pushStore(stores, storeId, dbPath);
|
|
674
|
+
const sidecar = `${dbPath}.run-prompts.json`;
|
|
675
|
+
if (existsSync(sidecar)) {
|
|
676
|
+
const id = `${storeId}:run-prompts`;
|
|
677
|
+
if (!files.some((file) => file.id === id || file.sourcePath === sidecar)) {
|
|
678
|
+
files.push({ id, sourcePath: sidecar });
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
function discoverBackupSources(
|
|
685
|
+
projectRoot: string,
|
|
686
|
+
options: { hubDataPath?: string; env?: NodeJS.ProcessEnv } = {},
|
|
687
|
+
): { stores: DiscoveredStore[]; files: DiscoveredFile[] } {
|
|
616
688
|
const root = resolve(projectRoot);
|
|
617
|
-
const stores:
|
|
689
|
+
const stores: DiscoveredStore[] = [];
|
|
690
|
+
const files: DiscoveredFile[] = [];
|
|
618
691
|
|
|
619
692
|
const hubPath = options.hubDataPath ? resolve(options.hubDataPath) : join(root, ".kxm", "state", "kxm.db");
|
|
620
|
-
if (existsSync(hubPath))
|
|
621
|
-
stores.push({ storeId: "hub-store", sourcePath: hubPath, maxSupportedVersion: kxmBackupCeiling("hub-store") });
|
|
622
|
-
}
|
|
693
|
+
if (existsSync(hubPath)) pushStore(stores, "hub-store", hubPath);
|
|
623
694
|
|
|
624
695
|
const registryPath = join(root, ".kxm", "runtime", "registry.db");
|
|
625
|
-
if (existsSync(registryPath))
|
|
626
|
-
stores.push({ storeId: "registry", sourcePath: registryPath, maxSupportedVersion: kxmBackupCeiling("registry") });
|
|
627
|
-
}
|
|
696
|
+
if (existsSync(registryPath)) pushStore(stores, "registry", registryPath);
|
|
628
697
|
|
|
629
698
|
const bindingsPath = join(root, ".kxm", "runtime", "bindings.db");
|
|
630
|
-
if (existsSync(bindingsPath))
|
|
631
|
-
stores.push({ storeId: "binding-store", sourcePath: bindingsPath, maxSupportedVersion: kxmBackupCeiling("binding-store") });
|
|
632
|
-
}
|
|
699
|
+
if (existsSync(bindingsPath)) pushStore(stores, "binding-store", bindingsPath);
|
|
633
700
|
|
|
634
701
|
const eventsDir = join(root, ".kxm", "runtime", "events");
|
|
635
702
|
if (existsSync(eventsDir)) {
|
|
636
703
|
const entries = readdirSync(eventsDir, { withFileTypes: true });
|
|
637
704
|
for (const entry of entries) {
|
|
638
705
|
if (entry.isFile() && entry.name.endsWith(".db")) {
|
|
639
|
-
const key = entry.name.replace(/\.db$/, "");
|
|
640
|
-
stores.
|
|
641
|
-
storeId: `events:${key}`,
|
|
642
|
-
sourcePath: join(eventsDir, entry.name),
|
|
643
|
-
maxSupportedVersion: kxmBackupCeiling(`events:${key}`),
|
|
644
|
-
});
|
|
706
|
+
const key = entry.name.replace(/\.db$/, "").replace(/[^a-zA-Z0-9_.-]/g, "_");
|
|
707
|
+
pushStore(stores, `events:${key}`, join(eventsDir, entry.name));
|
|
645
708
|
}
|
|
646
709
|
}
|
|
647
710
|
}
|
|
648
711
|
|
|
649
|
-
|
|
712
|
+
discoverUserRuntime(options.env, stores, files);
|
|
713
|
+
return { stores, files };
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
function backupPlainFile(sourcePath: string, targetPath: string, id: string): BackupFileRecord {
|
|
717
|
+
const resolvedSource = resolve(sourcePath);
|
|
718
|
+
const sourceStat = lstatSync(resolvedSource, { throwIfNoEntry: false });
|
|
719
|
+
if (!sourceStat || !sourceStat.isFile() || sourceStat.isSymbolicLink()) {
|
|
720
|
+
throw databaseError("runtime_path_invalid", resolvedSource, `backup file ${resolvedSource} must be a regular file, not a link or directory`);
|
|
721
|
+
}
|
|
722
|
+
checkedParent(targetPath, "backup target");
|
|
723
|
+
if (existsSync(targetPath)) unlinkSync(targetPath);
|
|
724
|
+
copyFileSync(resolvedSource, targetPath);
|
|
725
|
+
try { chmodSync(targetPath, 0o600); } catch { /* Windows */ }
|
|
726
|
+
return {
|
|
727
|
+
id,
|
|
728
|
+
sourcePath: resolvedSource,
|
|
729
|
+
backupFile: basename(targetPath),
|
|
730
|
+
sha256: fileSha256(targetPath),
|
|
731
|
+
bytes: sourceStat.size,
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function backupFilename(sourcePath: string, id: string, used: Set<string>): string {
|
|
736
|
+
let filename = basename(sourcePath);
|
|
737
|
+
if (used.has(filename)) {
|
|
738
|
+
filename = `${id.replace(/[^a-zA-Z0-9_.-]/g, "_")}-${filename}`;
|
|
739
|
+
}
|
|
740
|
+
used.add(filename);
|
|
741
|
+
return filename;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function restorePlainFile(backupFilePath: string, targetPath: string): void {
|
|
745
|
+
checkedParent(targetPath, "restore target");
|
|
746
|
+
if (existsSync(targetPath)) unlinkSync(targetPath);
|
|
747
|
+
copyFileSync(backupFilePath, targetPath);
|
|
748
|
+
try { chmodSync(targetPath, 0o600); } catch { /* Windows */ }
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
export function discoverProjectStores(projectRoot: string, options: { hubDataPath?: string; env?: NodeJS.ProcessEnv } = {}): Array<{ storeId: string; sourcePath: string; maxSupportedVersion: number }> {
|
|
752
|
+
return discoverBackupSources(projectRoot, options).stores;
|
|
650
753
|
}
|
|
651
754
|
|
|
652
755
|
export interface BackupPlan {
|
|
@@ -654,21 +757,28 @@ export interface BackupPlan {
|
|
|
654
757
|
outDir: string;
|
|
655
758
|
createdAt: string;
|
|
656
759
|
stores: Array<{ storeId: string; sourcePath: string; backupFile: string }>;
|
|
760
|
+
files: Array<{ id: string; sourcePath: string; backupFile: string }>;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function backupDiscoverOptions(options: { hubDataPath?: string; env?: NodeJS.ProcessEnv }): { hubDataPath?: string; env?: NodeJS.ProcessEnv } {
|
|
764
|
+
return {
|
|
765
|
+
...(options.hubDataPath !== undefined ? { hubDataPath: options.hubDataPath } : {}),
|
|
766
|
+
...(options.env !== undefined ? { env: options.env } : {}),
|
|
767
|
+
};
|
|
657
768
|
}
|
|
658
769
|
|
|
659
|
-
/** Which stores a backup would copy and where, without opening any of them.
|
|
770
|
+
/** Which stores and files a backup would copy and where, without opening any of them.
|
|
660
771
|
* Opening a source for backup checkpoints its WAL, so the plan stays at paths. */
|
|
661
772
|
export function planBackup(options: {
|
|
662
773
|
projectRoot?: string;
|
|
663
774
|
outDir?: string;
|
|
664
775
|
hubDataPath?: string;
|
|
776
|
+
env?: NodeJS.ProcessEnv;
|
|
665
777
|
} = {}): BackupPlan {
|
|
666
778
|
const projectRoot = options.projectRoot ? resolve(options.projectRoot) : process.cwd();
|
|
667
|
-
const
|
|
668
|
-
...(options.hubDataPath !== undefined ? { hubDataPath: options.hubDataPath } : {}),
|
|
669
|
-
});
|
|
779
|
+
const discovered = discoverBackupSources(projectRoot, backupDiscoverOptions(options));
|
|
670
780
|
|
|
671
|
-
if (stores.length === 0) {
|
|
781
|
+
if (discovered.stores.length === 0) {
|
|
672
782
|
throw databaseError("backup_no_stores", projectRoot, "no existing SQLite stores found to backup");
|
|
673
783
|
}
|
|
674
784
|
|
|
@@ -676,24 +786,26 @@ export function planBackup(options: {
|
|
|
676
786
|
const timestamp = createdAt.replace(/[:.]/g, "-");
|
|
677
787
|
const outDir = options.outDir ? resolve(options.outDir) : join(projectRoot, ".kxm", "backups", `backup-${timestamp}`);
|
|
678
788
|
const usedFilenames = new Set<string>();
|
|
679
|
-
const
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
789
|
+
const stores = discovered.stores.map((store) => ({
|
|
790
|
+
storeId: store.storeId,
|
|
791
|
+
sourcePath: store.sourcePath,
|
|
792
|
+
backupFile: backupFilename(store.sourcePath, store.storeId, usedFilenames),
|
|
793
|
+
}));
|
|
794
|
+
const files = discovered.files.map((file) => ({
|
|
795
|
+
id: file.id,
|
|
796
|
+
sourcePath: file.sourcePath,
|
|
797
|
+
backupFile: backupFilename(file.sourcePath, file.id, usedFilenames),
|
|
798
|
+
}));
|
|
799
|
+
return { projectRoot, outDir, createdAt, stores, files };
|
|
689
800
|
}
|
|
690
801
|
|
|
691
802
|
export function createBackup(options: {
|
|
692
803
|
projectRoot?: string;
|
|
693
804
|
outDir?: string;
|
|
694
805
|
hubDataPath?: string;
|
|
806
|
+
env?: NodeJS.ProcessEnv;
|
|
695
807
|
} = {}): { manifest: BackupManifest; outDir: string } {
|
|
696
|
-
const { projectRoot, outDir, createdAt, stores } = planBackup(options);
|
|
808
|
+
const { projectRoot, outDir, createdAt, stores, files } = planBackup(options);
|
|
697
809
|
const backupId = `bk_${randomBytes(8).toString("hex")}`;
|
|
698
810
|
|
|
699
811
|
if (!existsSync(outDir)) {
|
|
@@ -701,8 +813,38 @@ export function createBackup(options: {
|
|
|
701
813
|
}
|
|
702
814
|
|
|
703
815
|
const backedUpStores: BackupStoreRecord[] = [];
|
|
816
|
+
const backedUpFiles: BackupFileRecord[] = [];
|
|
817
|
+
const omitted: string[] = [];
|
|
818
|
+
|
|
704
819
|
for (const store of stores) {
|
|
705
|
-
|
|
820
|
+
try {
|
|
821
|
+
backedUpStores.push(backupDatabaseFile(store.sourcePath, join(outDir, store.backupFile), store.storeId));
|
|
822
|
+
} catch {
|
|
823
|
+
omitted.push(store.storeId);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
for (const file of files) {
|
|
827
|
+
try {
|
|
828
|
+
backedUpFiles.push(backupPlainFile(file.sourcePath, join(outDir, file.backupFile), file.id));
|
|
829
|
+
} catch {
|
|
830
|
+
omitted.push(file.id);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
if (backedUpStores.length === 0) {
|
|
835
|
+
throw databaseError("backup_no_stores", projectRoot, "no SQLite store could be copied");
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const again = discoverBackupSources(projectRoot, backupDiscoverOptions(options));
|
|
839
|
+
for (const store of again.stores) {
|
|
840
|
+
if (!backedUpStores.some((copied) => copied.storeId === store.storeId) && !omitted.includes(store.storeId)) {
|
|
841
|
+
omitted.push(store.storeId);
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
for (const file of again.files) {
|
|
845
|
+
if (!backedUpFiles.some((copied) => copied.id === file.id) && !omitted.includes(file.id)) {
|
|
846
|
+
omitted.push(file.id);
|
|
847
|
+
}
|
|
706
848
|
}
|
|
707
849
|
|
|
708
850
|
const manifest: BackupManifest = {
|
|
@@ -711,6 +853,9 @@ export function createBackup(options: {
|
|
|
711
853
|
createdAt,
|
|
712
854
|
projectRoot,
|
|
713
855
|
stores: backedUpStores,
|
|
856
|
+
...(backedUpFiles.length > 0 ? { files: backedUpFiles } : {}),
|
|
857
|
+
complete: omitted.length === 0,
|
|
858
|
+
...(omitted.length > 0 ? { omitted } : {}),
|
|
714
859
|
};
|
|
715
860
|
|
|
716
861
|
const manifestJson = JSON.stringify(manifest, null, 2) + "\n";
|
|
@@ -728,6 +873,7 @@ export interface RestorePlan {
|
|
|
728
873
|
manifestPath: string;
|
|
729
874
|
backupId: string;
|
|
730
875
|
stores: Array<{ storeId: string; backupFilePath: string; targetPath: string; schemaVersion: number; maxSupportedVersion: number }>;
|
|
876
|
+
files: Array<{ id: string; backupFilePath: string; targetPath: string }>;
|
|
731
877
|
}
|
|
732
878
|
|
|
733
879
|
/** Everything restore checks before it overwrites anything: the manifest, each
|
|
@@ -762,6 +908,9 @@ export function planRestore(
|
|
|
762
908
|
if (manifest.schema !== "kxm.backup-manifest.v1" || !Array.isArray(manifest.stores) || manifest.stores.length === 0) {
|
|
763
909
|
throw databaseError("restore_manifest_invalid", manifestPath, "manifest is not a valid kxm.backup-manifest.v1 document");
|
|
764
910
|
}
|
|
911
|
+
if (manifest.complete === false) {
|
|
912
|
+
throw databaseError("restore_incomplete", manifestPath, "backup manifest is incomplete; refusing to restore a partial copy");
|
|
913
|
+
}
|
|
765
914
|
|
|
766
915
|
const stores: RestorePlan["stores"] = [];
|
|
767
916
|
for (const store of manifest.stores) {
|
|
@@ -796,7 +945,29 @@ export function planRestore(
|
|
|
796
945
|
stores.push({ storeId: store.storeId, backupFilePath, targetPath, schemaVersion: store.schemaVersion, maxSupportedVersion });
|
|
797
946
|
}
|
|
798
947
|
|
|
799
|
-
|
|
948
|
+
const files: RestorePlan["files"] = [];
|
|
949
|
+
for (const file of manifest.files ?? []) {
|
|
950
|
+
const backupFilePath = join(manifestDir, file.backupFile);
|
|
951
|
+
if (!existsSync(backupFilePath)) {
|
|
952
|
+
throw databaseError("restore_file_missing", backupFilePath, `backup file ${file.backupFile} missing from ${manifestDir}`);
|
|
953
|
+
}
|
|
954
|
+
const actualSha256 = fileSha256(backupFilePath);
|
|
955
|
+
if (actualSha256 !== file.sha256) {
|
|
956
|
+
throw databaseError(
|
|
957
|
+
"restore_manifest_digest_mismatch",
|
|
958
|
+
backupFilePath,
|
|
959
|
+
`backup file ${file.backupFile} sha256 ${actualSha256} does not match manifest hash ${file.sha256}`,
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
let targetPath = file.sourcePath;
|
|
963
|
+
if (options.projectRoot && manifest.projectRoot && targetPath.startsWith(manifest.projectRoot)) {
|
|
964
|
+
const rel = targetPath.slice(manifest.projectRoot.length).replace(/^[\\/]+/, "");
|
|
965
|
+
targetPath = join(resolve(options.projectRoot), rel);
|
|
966
|
+
}
|
|
967
|
+
files.push({ id: file.id, backupFilePath, targetPath });
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
return { manifestPath, backupId: manifest.backupId, stores, files };
|
|
800
971
|
}
|
|
801
972
|
|
|
802
973
|
export function restoreBackup(
|
|
@@ -811,6 +982,9 @@ export function restoreBackup(
|
|
|
811
982
|
store.schemaVersion,
|
|
812
983
|
store.maxSupportedVersion,
|
|
813
984
|
));
|
|
985
|
+
for (const file of plan.files) {
|
|
986
|
+
restorePlainFile(file.backupFilePath, file.targetPath);
|
|
987
|
+
}
|
|
814
988
|
return {
|
|
815
989
|
manifestPath: plan.manifestPath,
|
|
816
990
|
backupId: plan.backupId,
|