@kontextmind/kxm 0.7.96 → 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 +61 -2
- package/docs/concepts/architecture.md +1 -1
- package/docs/concepts/data-and-storage.md +1 -1
- package/docs/concepts/trust-model.md +3 -2
- package/docs/contracts/routing.md +1 -1
- package/docs/contributing/test-matrix.md +6 -5
- package/docs/glossary.md +1 -1
- package/docs/guides/peer-messaging.md +2 -2
- package/docs/guides/pi-workers.md +1 -1
- package/docs/guides/webhook-workflows.md +53 -18
- package/docs/operations/backup-and-restore.md +43 -24
- package/docs/operations/deploy.md +2 -2
- package/docs/operations/troubleshooting.md +3 -2
- package/docs/reference/cli-reference.md +65 -30
- package/docs/reference/config-reference.md +24 -13
- package/docs/reference/configuration.md +2 -2
- package/docs/reference/harness-routing.md +3 -3
- package/docs/reference/http-api.md +7 -7
- package/docs/reference/tools.md +1 -1
- package/docs/reference/workflow-definitions.md +2 -2
- package/docs/start/first-workflow.md +4 -4
- package/docs/start/quickstart-claude-code.md +2 -2
- package/docs/start/quickstart-pi.md +1 -1
- package/examples/workflow-signal.ts +4 -5
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/claude-hook.js +11 -1
- package/plugins/kxm/dist/cli.js +552 -228
- package/plugins/kxm/dist/client.js +3 -1
- package/plugins/kxm/dist/core.js +16 -3
- package/plugins/kxm/dist/extension.js +45 -13
- package/plugins/kxm/dist/mcp-server.js +20 -4
- package/plugins/kxm/dist/runtime-supervisor.js +232 -51
- package/plugins/kxm/dist/runtime.js +432 -95
- package/plugins/kxm/dist/server.js +122 -20
- 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/workflows.ts +12 -7
- package/plugins/kxm/src/cli.ts +30 -8
- package/plugins/kxm/src/client.ts +4 -0
- package/plugins/kxm/src/commands.ts +23 -1
- package/plugins/kxm/src/database.ts +210 -36
- package/plugins/kxm/src/engine.ts +117 -2
- package/plugins/kxm/src/extension.ts +20 -14
- package/plugins/kxm/src/github-watch.ts +8 -5
- package/plugins/kxm/src/harness.ts +29 -0
- package/plugins/kxm/src/hub-env.ts +19 -1
- package/plugins/kxm/src/hub.ts +105 -21
- package/plugins/kxm/src/improve-sources.ts +2 -7
- package/plugins/kxm/src/init-guide-setup.ts +43 -28
- package/plugins/kxm/src/mcp-server.ts +9 -2
- 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/runtime-store.ts +23 -0
- package/plugins/kxm/src/studio-layout.ts +5 -4
- package/plugins/kxm/src/template.ts +31 -0
- package/plugins/kxm/src/workflow.ts +70 -1
- package/plugins/kxm/src/worktree-witness.ts +71 -0
- package/schemas/backup-manifest.schema.json +33 -0
- package/scripts/smoke-multi-pi.mjs +5 -1
package/plugins/kxm/src/cli.ts
CHANGED
|
@@ -18,7 +18,7 @@ import { Command, CommanderError } from "commander";
|
|
|
18
18
|
import { readInstalledKxmVersion } from "./kxm-update.ts";
|
|
19
19
|
import { findKxmRepoRoot } from "./repo-root.ts";
|
|
20
20
|
import { HubClient } from "./client.ts";
|
|
21
|
-
import {
|
|
21
|
+
import { AgentProjectTokenMissingError, resolveAgentHubAuthToken } from "./hub-env.ts";
|
|
22
22
|
import { defaultProjectName } from "./project-name.ts";
|
|
23
23
|
import {
|
|
24
24
|
AGENT_COMMANDS_MAP,
|
|
@@ -27,6 +27,7 @@ import {
|
|
|
27
27
|
import { discoverKxmProjectRoot } from "./project-config.ts";
|
|
28
28
|
import { JOURNAL_CATEGORIES } from "./workflow.ts";
|
|
29
29
|
import { ensureKxmSupervisor, kxmRuntimeRequest } from "./runtime-supervisor.ts";
|
|
30
|
+
import { projectRuntimeOwnsRun } from "./runtime-store.ts";
|
|
30
31
|
|
|
31
32
|
// Submodule imports
|
|
32
33
|
import {
|
|
@@ -148,6 +149,7 @@ import {
|
|
|
148
149
|
cmdConfigList,
|
|
149
150
|
cmdCompletion,
|
|
150
151
|
cmdCompletionInstall,
|
|
152
|
+
cmdPricesAcknowledge,
|
|
151
153
|
cmdRoutingReport,
|
|
152
154
|
cmdRoutingBenchmark,
|
|
153
155
|
maybeOfferCompletionInstall,
|
|
@@ -234,14 +236,17 @@ async function ensureCliClient(runtime: Runtime): Promise<HubClient> {
|
|
|
234
236
|
const project = defaultProjectName(runtime.cwd, runtime.env);
|
|
235
237
|
const name = runtime.env.KXM_AGENT_NAME?.trim() || `cli-${process.pid}`;
|
|
236
238
|
const purpose = runtime.env.KXM_AGENT_PURPOSE?.trim() || "CLI agent client";
|
|
237
|
-
|
|
239
|
+
// These commands act as a peer agent, so they take the agent credential: never the
|
|
240
|
+
// persisted admin token, which the hub accepts for any project without its own token.
|
|
241
|
+
const authToken = resolveAgentHubAuthToken(runtime.env, project);
|
|
242
|
+
if (!authToken) throw new AgentProjectTokenMissingError(project);
|
|
238
243
|
const client = new HubClient({
|
|
239
244
|
serverUrl,
|
|
240
245
|
name,
|
|
241
246
|
project,
|
|
242
247
|
purpose,
|
|
243
248
|
fetchImpl: runtime.fetchImpl,
|
|
244
|
-
|
|
249
|
+
authToken,
|
|
245
250
|
});
|
|
246
251
|
await client.start(() => {});
|
|
247
252
|
return client;
|
|
@@ -308,11 +313,12 @@ async function dispatchAgentCliCommand(
|
|
|
308
313
|
}
|
|
309
314
|
}
|
|
310
315
|
|
|
311
|
-
//
|
|
316
|
+
// A workflow wait on a run this project's Runtime owns goes to the Runtime; any other
|
|
317
|
+
// run id, including a hub workflow run of the same shape, goes to the hub.
|
|
312
318
|
if (toolName === "kxm_workflow_wait") {
|
|
313
319
|
const runId = typeof args.runId === "string" ? args.runId : undefined;
|
|
314
320
|
const projectRoot = discoverKxmProjectRoot(runtime.cwd);
|
|
315
|
-
if (runId && projectRoot &&
|
|
321
|
+
if (runId && projectRoot && projectRuntimeOwnsRun(projectRoot, runId, runtime.env)) {
|
|
316
322
|
if (runtime.dryRun) {
|
|
317
323
|
print(runtime.io, runtime.json, { ok: true, command: "workflow wait", runId, dryRun: true }, `would wait for signal on KXM run ${runId}`);
|
|
318
324
|
return 0;
|
|
@@ -349,6 +355,15 @@ async function dispatchAgentCliCommand(
|
|
|
349
355
|
return 0;
|
|
350
356
|
} catch (error) {
|
|
351
357
|
const message = error instanceof Error ? error.message : String(error);
|
|
358
|
+
if (error instanceof AgentProjectTokenMissingError) {
|
|
359
|
+
print(
|
|
360
|
+
runtime.io,
|
|
361
|
+
runtime.json,
|
|
362
|
+
{ ok: false, error: error.code, project: error.project, nextAction: "export_kxm_auth_token", detail: message },
|
|
363
|
+
message,
|
|
364
|
+
);
|
|
365
|
+
return 2;
|
|
366
|
+
}
|
|
352
367
|
print(runtime.io, runtime.json, { ok: false, error: "command_failed", detail: message }, `command failed: ${message}`);
|
|
353
368
|
return 1;
|
|
354
369
|
} finally {
|
|
@@ -402,7 +417,7 @@ function createProgram(ctx: CliContext, result: { code: number }): Command {
|
|
|
402
417
|
});
|
|
403
418
|
});
|
|
404
419
|
|
|
405
|
-
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"))
|
|
406
421
|
.option("--out <dir>", "Directory to write backup and manifest")
|
|
407
422
|
.action(async function backupAction(this: Command, options: { out?: string }) {
|
|
408
423
|
result.code = await cmdBackup(runtimeFrom(ctx, this), options);
|
|
@@ -428,7 +443,7 @@ function createProgram(ctx: CliContext, result: { code: number }): Command {
|
|
|
428
443
|
});
|
|
429
444
|
addGlobalOptions(runCmd.command("drive").description("Drive a run with live harness calls, or with the model-free simulation when --simulated is passed"))
|
|
430
445
|
.argument("<runId>", "Run id")
|
|
431
|
-
.option("--simulated", "Use the model-free simulation producer")
|
|
446
|
+
.option("--simulated", "Use the model-free simulation producer instead of a live model")
|
|
432
447
|
.option("--wait", "Wait until a drive receipt is recorded; exits 0 only for a VERIFIED COMPLETED settlement")
|
|
433
448
|
.option("--timeout-ms <n>", "Wait timeout in milliseconds (default 60000, max 600000)")
|
|
434
449
|
.action(async function runDriveAction(this: Command, runId: string, options: { simulated?: boolean; wait?: boolean; timeoutMs?: string }) {
|
|
@@ -460,6 +475,13 @@ function createProgram(ctx: CliContext, result: { code: number }): Command {
|
|
|
460
475
|
result.code = await cmdTenantStatus(runtimeFrom(ctx, this));
|
|
461
476
|
});
|
|
462
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
|
+
|
|
463
485
|
const modelsCmd = addGlobalOptions(program.command("models").description("Manage model catalogs, roles, and route state"));
|
|
464
486
|
modelsCmd.action(async function modelsScreenAction(this: Command) { result.code = await cmdModelsScreen(runtimeFrom(ctx, this)); });
|
|
465
487
|
modelsCmd.helpCommand("help", "Show models help");
|
|
@@ -914,7 +936,7 @@ function createProgram(ctx: CliContext, result: { code: number }): Command {
|
|
|
914
936
|
result.code = await cmdGithubWatch(runtimeFrom(ctx, this), options);
|
|
915
937
|
});
|
|
916
938
|
|
|
917
|
-
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"));
|
|
918
940
|
improve.helpCommand("help", "Show improve help");
|
|
919
941
|
addGlobalOptions(improve.command("report", { isDefault: true }).description("Generate improvement report and candidates from routing records"))
|
|
920
942
|
.option("--file <path>", "Read only this routing-record JSONL instead of the project's Runtime store and telemetry")
|
|
@@ -259,6 +259,8 @@ export class HubClient {
|
|
|
259
259
|
ttlMs?: number;
|
|
260
260
|
timeoutMs?: number;
|
|
261
261
|
signal?: AbortSignal;
|
|
262
|
+
hops?: number;
|
|
263
|
+
maxHops?: number;
|
|
262
264
|
}): Promise<FanoutResult[]> {
|
|
263
265
|
const targets = [...new Set(options.targets.map((target) => target.trim().toLowerCase()).filter(Boolean))];
|
|
264
266
|
if (targets.length < 1 || targets.length > 3) throw new Error("fanout requires between one and three unique targets");
|
|
@@ -280,6 +282,8 @@ export class HubClient {
|
|
|
280
282
|
),
|
|
281
283
|
} : {}),
|
|
282
284
|
...(options.ttlMs ? { ttlMs: options.ttlMs } : {}),
|
|
285
|
+
...(options.hops !== undefined ? { hops: options.hops } : {}),
|
|
286
|
+
...(options.maxHops !== undefined ? { maxHops: options.maxHops } : {}),
|
|
283
287
|
});
|
|
284
288
|
const completed = await this.awaitResponse(
|
|
285
289
|
message.id,
|
|
@@ -74,6 +74,26 @@ export interface CommandExecutionContext {
|
|
|
74
74
|
signal?: AbortSignal | undefined;
|
|
75
75
|
inbox?: Map<string, MessageRecord> | undefined;
|
|
76
76
|
notifiedInbox?: Set<string> | undefined;
|
|
77
|
+
/** Inbound requests this session is handling: the Pi extension's active request, the MCP
|
|
78
|
+
* server's open inbox. A request sent meanwhile is one more hop along their chain. */
|
|
79
|
+
handling?: readonly Pick<MessageRecord, "hops" | "maxHops">[] | undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Hop fields for a request sent while handling inbound work: one hop past the furthest
|
|
84
|
+
* handled request, under the tightest limit among them, so the hub's `hop_limit_reached`
|
|
85
|
+
* refusal bounds a chain of agents forwarding to each other. Handling nothing starts a new
|
|
86
|
+
* chain with the hub defaults. When several requests are open the furthest one counts, so a
|
|
87
|
+
* forwarding loop cannot reset its count because an unrelated request arrived beside it.
|
|
88
|
+
*/
|
|
89
|
+
export function forwardedHops(
|
|
90
|
+
handling: CommandExecutionContext["handling"],
|
|
91
|
+
): { hops: number; maxHops: number } | undefined {
|
|
92
|
+
if (!handling?.length) return undefined;
|
|
93
|
+
return {
|
|
94
|
+
hops: Math.max(...handling.map((message) => message.hops)) + 1,
|
|
95
|
+
maxHops: Math.min(...handling.map((message) => message.maxHops)),
|
|
96
|
+
};
|
|
77
97
|
}
|
|
78
98
|
|
|
79
99
|
export interface AgentCommand {
|
|
@@ -260,12 +280,13 @@ export const AGENT_COMMANDS: readonly AgentCommand[] = [
|
|
|
260
280
|
required: ["target", "content"],
|
|
261
281
|
additionalProperties: false,
|
|
262
282
|
},
|
|
263
|
-
async execute(client, args) {
|
|
283
|
+
async execute(client, args, context) {
|
|
264
284
|
const delivery = optionalString(args.delivery) as DeliveryMode | undefined;
|
|
265
285
|
const correlationId = optionalString(args.correlationId);
|
|
266
286
|
const idempotencyKey = optionalString(args.idempotencyKey);
|
|
267
287
|
const workflowContext = optionalWorkflowContext(args.workflowContext);
|
|
268
288
|
const message = await client.send({
|
|
289
|
+
...forwardedHops(context?.handling),
|
|
269
290
|
target: requiredString(args.target, "target"),
|
|
270
291
|
content: requiredString(args.content, "content"),
|
|
271
292
|
...(delivery ? { delivery } : {}),
|
|
@@ -346,6 +367,7 @@ export const AGENT_COMMANDS: readonly AgentCommand[] = [
|
|
|
346
367
|
: [];
|
|
347
368
|
return {
|
|
348
369
|
responses: await client.fanout({
|
|
370
|
+
...forwardedHops(context?.handling),
|
|
349
371
|
targets,
|
|
350
372
|
content: requiredString(args.content, "content"),
|
|
351
373
|
...(optionalString(args.correlationId) ? { correlationId: optionalString(args.correlationId)! } : {}),
|
|
@@ -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,
|