@kontextmind/kxm 0.7.102 → 0.7.104
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/CHANGELOG.md +30 -9
- package/docs/concepts/data-and-storage.md +1 -1
- package/docs/contributing/test-matrix.md +7 -5
- package/docs/operations/backup-and-restore.md +59 -27
- package/docs/operations/deploy.md +1 -1
- package/docs/reference/cli-reference.md +32 -21
- package/docs/reference/config-reference.md +4 -2
- package/docs/reference/harness-routing.md +5 -5
- package/docs/reference/workflow-catalog.md +1 -1
- package/docs/start/quickstart-claude-code.md +1 -1
- package/docs/templates/runbook.md +2 -1
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/cli.js +788 -651
- package/plugins/kxm/dist/mcp-server.js +1 -1
- package/plugins/kxm/dist/runtime-supervisor.js +321 -291
- package/plugins/kxm/dist/runtime.js +424 -348
- package/plugins/kxm/dist/server.js +78 -78
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/skills/kxm-hub-ops/SKILL.md +14 -7
- package/plugins/kxm/src/cli/project.ts +66 -12
- package/plugins/kxm/src/cli.ts +7 -5
- package/plugins/kxm/src/database.ts +193 -65
- package/plugins/kxm/src/init-guide-setup.ts +9 -4
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/runtime-paths.ts +50 -0
- package/plugins/kxm/src/runtime-store.ts +22 -53
- package/plugins/kxm/src/runtime-supervisor.ts +38 -17
- package/schemas/backup-manifest.schema.json +15 -0
|
@@ -10,10 +10,10 @@ import {
|
|
|
10
10
|
unlinkSync,
|
|
11
11
|
writeFileSync,
|
|
12
12
|
} from "node:fs";
|
|
13
|
-
import { basename, dirname, join, resolve } from "node:path";
|
|
13
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
14
14
|
import { DatabaseSync } from "./sqlite.ts";
|
|
15
|
-
import { kxmUserStateRoot } from "./bindings.ts";
|
|
16
15
|
import { KxmConfigError, type KxmConfigIssue } from "./project-config.ts";
|
|
16
|
+
import { kxmProjectRunEventsPath, kxmRuntimePaths, projectRuntimeKey, type KxmRuntimePaths } from "./runtime-paths.ts";
|
|
17
17
|
|
|
18
18
|
export interface DatabaseSchemaSpec {
|
|
19
19
|
schema: string;
|
|
@@ -40,11 +40,25 @@ export interface BackupFileRecord {
|
|
|
40
40
|
bytes: number;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/**
|
|
44
|
+
* What a backup holds from the user state root. `project` (the default): the
|
|
45
|
+
* checkout's own Runtime event store and its prompt sidecar. `all-projects`: the
|
|
46
|
+
* shared Runtime registry and every project's event store, so restoring it rolls
|
|
47
|
+
* back every project on the machine.
|
|
48
|
+
*/
|
|
49
|
+
export type KxmBackupScope = "project" | "all-projects";
|
|
50
|
+
|
|
43
51
|
export interface BackupManifest {
|
|
44
52
|
schema: "kxm.backup-manifest.v1";
|
|
45
53
|
backupId: string;
|
|
46
54
|
createdAt: string;
|
|
47
55
|
projectRoot?: string;
|
|
56
|
+
/** Absent on manifests written before backups were scoped. */
|
|
57
|
+
scope?: KxmBackupScope;
|
|
58
|
+
/** The user state root the Runtime stores were copied from; restore rebases them onto the current one. */
|
|
59
|
+
stateRoot?: string;
|
|
60
|
+
/** The Runtime store key of the checkout the backup ran from, derived as the Runtime derives it. */
|
|
61
|
+
runtimeProjectKey?: string;
|
|
48
62
|
stores: BackupStoreRecord[];
|
|
49
63
|
files?: BackupFileRecord[];
|
|
50
64
|
/** False when a discovered store or prompt sidecar was left out. Absent on legacy manifests. */
|
|
@@ -642,49 +656,75 @@ function pushStore(stores: DiscoveredStore[], storeId: string, sourcePath: strin
|
|
|
642
656
|
stores.push({ storeId, sourcePath, maxSupportedVersion: kxmBackupCeiling(storeId) });
|
|
643
657
|
}
|
|
644
658
|
|
|
645
|
-
|
|
646
|
-
|
|
659
|
+
interface DiscoveredSources {
|
|
660
|
+
stores: DiscoveredStore[];
|
|
661
|
+
files: DiscoveredFile[];
|
|
662
|
+
stateRoot?: string;
|
|
663
|
+
runtimeProjectKey: string;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
interface BackupDiscoverOptions {
|
|
667
|
+
hubDataPath?: string;
|
|
668
|
+
env?: NodeJS.ProcessEnv;
|
|
669
|
+
allProjects?: boolean;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function pushEventStore(stores: DiscoveredStore[], files: DiscoveredFile[], paths: KxmRuntimePaths, key: string): void {
|
|
673
|
+
const dbPath = join(paths.projectsDir, key, "run-events.db");
|
|
674
|
+
if (!existsSync(dbPath)) return;
|
|
675
|
+
const safe = key.replace(/[^a-zA-Z0-9_.-]/g, "_");
|
|
676
|
+
let storeId = `events:${safe}`;
|
|
677
|
+
if (stores.some((store) => store.storeId === storeId)) storeId = `events:runtime:${safe}`;
|
|
678
|
+
pushStore(stores, storeId, dbPath);
|
|
679
|
+
const sidecar = `${dbPath}.run-prompts.json`;
|
|
680
|
+
if (existsSync(sidecar)) {
|
|
681
|
+
const id = `${storeId}:run-prompts`;
|
|
682
|
+
if (!files.some((file) => file.id === id || file.sourcePath === sidecar)) {
|
|
683
|
+
files.push({ id, sourcePath: sidecar });
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
/**
|
|
689
|
+
* The Runtime stores under the user state root. By default only this checkout's
|
|
690
|
+
* event store: the registry and the other projects' event stores are shared by every
|
|
691
|
+
* project on the machine, and a restore of them rolls all of those projects back, so
|
|
692
|
+
* only an explicit `allProjects` backup holds them.
|
|
693
|
+
*/
|
|
694
|
+
function discoverUserRuntime(
|
|
695
|
+
runtimeProjectKey: string,
|
|
696
|
+
options: BackupDiscoverOptions,
|
|
697
|
+
stores: DiscoveredStore[],
|
|
698
|
+
files: DiscoveredFile[],
|
|
699
|
+
): string | undefined {
|
|
700
|
+
let paths: KxmRuntimePaths;
|
|
647
701
|
try {
|
|
648
|
-
|
|
702
|
+
paths = kxmRuntimePaths(options.env ? { env: options.env } : {});
|
|
649
703
|
} catch {
|
|
650
|
-
return;
|
|
704
|
+
return undefined;
|
|
651
705
|
}
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
706
|
+
if (!options.allProjects) {
|
|
707
|
+
pushEventStore(stores, files, paths, runtimeProjectKey);
|
|
708
|
+
return paths.stateRoot;
|
|
709
|
+
}
|
|
710
|
+
if (existsSync(paths.registryDb)) {
|
|
655
711
|
const storeId = stores.some((store) => store.storeId === "registry") ? "runtime-registry" : "registry";
|
|
656
|
-
pushStore(stores, storeId,
|
|
712
|
+
pushStore(stores, storeId, paths.registryDb);
|
|
657
713
|
}
|
|
658
|
-
|
|
659
|
-
if (!existsSync(projectsDir)) return;
|
|
714
|
+
if (!existsSync(paths.projectsDir)) return paths.stateRoot;
|
|
660
715
|
let entries;
|
|
661
716
|
try {
|
|
662
|
-
entries = readdirSync(projectsDir, { withFileTypes: true });
|
|
717
|
+
entries = readdirSync(paths.projectsDir, { withFileTypes: true });
|
|
663
718
|
} catch {
|
|
664
|
-
return;
|
|
719
|
+
return paths.stateRoot;
|
|
665
720
|
}
|
|
666
721
|
for (const entry of entries) {
|
|
667
|
-
if (
|
|
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
|
-
}
|
|
722
|
+
if (entry.isDirectory()) pushEventStore(stores, files, paths, entry.name);
|
|
681
723
|
}
|
|
724
|
+
return paths.stateRoot;
|
|
682
725
|
}
|
|
683
726
|
|
|
684
|
-
function discoverBackupSources(
|
|
685
|
-
projectRoot: string,
|
|
686
|
-
options: { hubDataPath?: string; env?: NodeJS.ProcessEnv } = {},
|
|
687
|
-
): { stores: DiscoveredStore[]; files: DiscoveredFile[] } {
|
|
727
|
+
function discoverBackupSources(projectRoot: string, options: BackupDiscoverOptions = {}): DiscoveredSources {
|
|
688
728
|
const root = resolve(projectRoot);
|
|
689
729
|
const stores: DiscoveredStore[] = [];
|
|
690
730
|
const files: DiscoveredFile[] = [];
|
|
@@ -709,8 +749,9 @@ function discoverBackupSources(
|
|
|
709
749
|
}
|
|
710
750
|
}
|
|
711
751
|
|
|
712
|
-
|
|
713
|
-
|
|
752
|
+
const runtimeProjectKey = projectRuntimeKey(root);
|
|
753
|
+
const stateRoot = discoverUserRuntime(runtimeProjectKey, options, stores, files);
|
|
754
|
+
return { stores, files, runtimeProjectKey, ...(stateRoot !== undefined ? { stateRoot } : {}) };
|
|
714
755
|
}
|
|
715
756
|
|
|
716
757
|
function backupPlainFile(sourcePath: string, targetPath: string, id: string): BackupFileRecord {
|
|
@@ -748,7 +789,7 @@ function restorePlainFile(backupFilePath: string, targetPath: string): void {
|
|
|
748
789
|
try { chmodSync(targetPath, 0o600); } catch { /* Windows */ }
|
|
749
790
|
}
|
|
750
791
|
|
|
751
|
-
export function discoverProjectStores(projectRoot: string, options:
|
|
792
|
+
export function discoverProjectStores(projectRoot: string, options: BackupDiscoverOptions = {}): Array<{ storeId: string; sourcePath: string; maxSupportedVersion: number }> {
|
|
752
793
|
return discoverBackupSources(projectRoot, options).stores;
|
|
753
794
|
}
|
|
754
795
|
|
|
@@ -756,24 +797,26 @@ export interface BackupPlan {
|
|
|
756
797
|
projectRoot: string;
|
|
757
798
|
outDir: string;
|
|
758
799
|
createdAt: string;
|
|
800
|
+
scope: KxmBackupScope;
|
|
801
|
+
stateRoot?: string;
|
|
802
|
+
runtimeProjectKey: string;
|
|
759
803
|
stores: Array<{ storeId: string; sourcePath: string; backupFile: string }>;
|
|
760
804
|
files: Array<{ id: string; sourcePath: string; backupFile: string }>;
|
|
761
805
|
}
|
|
762
806
|
|
|
763
|
-
function backupDiscoverOptions(options:
|
|
807
|
+
function backupDiscoverOptions(options: BackupDiscoverOptions): BackupDiscoverOptions {
|
|
764
808
|
return {
|
|
765
809
|
...(options.hubDataPath !== undefined ? { hubDataPath: options.hubDataPath } : {}),
|
|
766
810
|
...(options.env !== undefined ? { env: options.env } : {}),
|
|
811
|
+
...(options.allProjects === true ? { allProjects: true } : {}),
|
|
767
812
|
};
|
|
768
813
|
}
|
|
769
814
|
|
|
770
815
|
/** Which stores and files a backup would copy and where, without opening any of them.
|
|
771
816
|
* Opening a source for backup checkpoints its WAL, so the plan stays at paths. */
|
|
772
|
-
export function planBackup(options: {
|
|
817
|
+
export function planBackup(options: BackupDiscoverOptions & {
|
|
773
818
|
projectRoot?: string;
|
|
774
819
|
outDir?: string;
|
|
775
|
-
hubDataPath?: string;
|
|
776
|
-
env?: NodeJS.ProcessEnv;
|
|
777
820
|
} = {}): BackupPlan {
|
|
778
821
|
const projectRoot = options.projectRoot ? resolve(options.projectRoot) : process.cwd();
|
|
779
822
|
const discovered = discoverBackupSources(projectRoot, backupDiscoverOptions(options));
|
|
@@ -796,16 +839,23 @@ export function planBackup(options: {
|
|
|
796
839
|
sourcePath: file.sourcePath,
|
|
797
840
|
backupFile: backupFilename(file.sourcePath, file.id, usedFilenames),
|
|
798
841
|
}));
|
|
799
|
-
return {
|
|
842
|
+
return {
|
|
843
|
+
projectRoot,
|
|
844
|
+
outDir,
|
|
845
|
+
createdAt,
|
|
846
|
+
scope: options.allProjects === true ? "all-projects" : "project",
|
|
847
|
+
...(discovered.stateRoot !== undefined ? { stateRoot: discovered.stateRoot } : {}),
|
|
848
|
+
runtimeProjectKey: discovered.runtimeProjectKey,
|
|
849
|
+
stores,
|
|
850
|
+
files,
|
|
851
|
+
};
|
|
800
852
|
}
|
|
801
853
|
|
|
802
|
-
export function createBackup(options: {
|
|
854
|
+
export function createBackup(options: BackupDiscoverOptions & {
|
|
803
855
|
projectRoot?: string;
|
|
804
856
|
outDir?: string;
|
|
805
|
-
hubDataPath?: string;
|
|
806
|
-
env?: NodeJS.ProcessEnv;
|
|
807
857
|
} = {}): { manifest: BackupManifest; outDir: string } {
|
|
808
|
-
const { projectRoot, outDir, createdAt, stores, files } = planBackup(options);
|
|
858
|
+
const { projectRoot, outDir, createdAt, scope, stateRoot, runtimeProjectKey, stores, files } = planBackup(options);
|
|
809
859
|
const backupId = `bk_${randomBytes(8).toString("hex")}`;
|
|
810
860
|
|
|
811
861
|
if (!existsSync(outDir)) {
|
|
@@ -852,6 +902,9 @@ export function createBackup(options: {
|
|
|
852
902
|
backupId,
|
|
853
903
|
createdAt,
|
|
854
904
|
projectRoot,
|
|
905
|
+
scope,
|
|
906
|
+
...(stateRoot !== undefined ? { stateRoot } : {}),
|
|
907
|
+
runtimeProjectKey,
|
|
855
908
|
stores: backedUpStores,
|
|
856
909
|
...(backedUpFiles.length > 0 ? { files: backedUpFiles } : {}),
|
|
857
910
|
complete: omitted.length === 0,
|
|
@@ -872,16 +925,74 @@ export function createBackup(options: {
|
|
|
872
925
|
export interface RestorePlan {
|
|
873
926
|
manifestPath: string;
|
|
874
927
|
backupId: string;
|
|
928
|
+
/** The manifest's scope; absent on manifests written before backups were scoped. */
|
|
929
|
+
scope?: KxmBackupScope;
|
|
875
930
|
stores: Array<{ storeId: string; backupFilePath: string; targetPath: string; schemaVersion: number; maxSupportedVersion: number }>;
|
|
876
931
|
files: Array<{ id: string; backupFilePath: string; targetPath: string }>;
|
|
877
932
|
}
|
|
878
933
|
|
|
934
|
+
export interface RestoreOptions {
|
|
935
|
+
/** The checkout to restore into. Its stores are rebased onto it, and its own Runtime
|
|
936
|
+
* event store is the one the Runtime derives for it. */
|
|
937
|
+
projectRoot?: string;
|
|
938
|
+
env?: NodeJS.ProcessEnv;
|
|
939
|
+
/** Also restore the shared Runtime registry and other projects' event stores. */
|
|
940
|
+
allProjects?: boolean;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
function pathUnder(root: string | undefined, path: string): string | undefined {
|
|
944
|
+
if (!root) return undefined;
|
|
945
|
+
const rel = relative(root, path);
|
|
946
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return undefined;
|
|
947
|
+
return rel;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Where one backed-up store or file goes, and whether it belongs to this checkout.
|
|
952
|
+
*
|
|
953
|
+
* A path under the manifest's project root is rebased onto the checkout being restored.
|
|
954
|
+
* The checkout's own Runtime event store (and its prompt sidecar) goes to the store the
|
|
955
|
+
* Runtime derives for that checkout, under the current user state root. Anything else
|
|
956
|
+
* under the user state root is machine-wide — the registry, another project's event
|
|
957
|
+
* store — and so is anything outside both recorded roots except a relocated hub store,
|
|
958
|
+
* which is how a manifest written before scopes records Runtime stores. Those restore
|
|
959
|
+
* only with `allProjects`, rebased onto the current user state root when the manifest
|
|
960
|
+
* recorded one.
|
|
961
|
+
*/
|
|
962
|
+
function restoreTarget(
|
|
963
|
+
manifest: BackupManifest,
|
|
964
|
+
sourcePath: string,
|
|
965
|
+
id: string,
|
|
966
|
+
options: RestoreOptions,
|
|
967
|
+
currentStateRoot: () => string,
|
|
968
|
+
): { targetPath: string; machineWide: boolean } {
|
|
969
|
+
const stateRel = pathUnder(manifest.stateRoot, sourcePath);
|
|
970
|
+
const projectRel = pathUnder(manifest.projectRoot, sourcePath);
|
|
971
|
+
// KXM_STATE_HOME may sit inside the checkout; the deeper root owns the path.
|
|
972
|
+
if (stateRel !== undefined && (projectRel === undefined || stateRel.length <= projectRel.length)) {
|
|
973
|
+
const ownEvents = manifest.runtimeProjectKey !== undefined
|
|
974
|
+
? join("runtime", "projects", manifest.runtimeProjectKey, "run-events.db")
|
|
975
|
+
: undefined;
|
|
976
|
+
const own = ownEvents !== undefined && (stateRel === ownEvents || stateRel === `${ownEvents}.run-prompts.json`);
|
|
977
|
+
if (own && !options.allProjects && options.projectRoot !== undefined) {
|
|
978
|
+
const eventsPath = kxmProjectRunEventsPath(resolve(options.projectRoot), options.env ?? process.env);
|
|
979
|
+
return { targetPath: stateRel === ownEvents ? eventsPath : `${eventsPath}.run-prompts.json`, machineWide: false };
|
|
980
|
+
}
|
|
981
|
+
return { targetPath: join(currentStateRoot(), stateRel), machineWide: !own };
|
|
982
|
+
}
|
|
983
|
+
if (projectRel !== undefined) {
|
|
984
|
+
return { targetPath: options.projectRoot ? join(resolve(options.projectRoot), projectRel) : sourcePath, machineWide: false };
|
|
985
|
+
}
|
|
986
|
+
return { targetPath: sourcePath, machineWide: id !== "hub-store" };
|
|
987
|
+
}
|
|
988
|
+
|
|
879
989
|
/** Everything restore checks before it overwrites anything: the manifest, each
|
|
880
|
-
* backup file's digest,
|
|
881
|
-
*
|
|
990
|
+
* backup file's digest, each store's schema against this build's ceiling (from
|
|
991
|
+
* the manifest), and that nothing outside this checkout would be overwritten
|
|
992
|
+
* without `allProjects`. Reads files; opens no database. */
|
|
882
993
|
export function planRestore(
|
|
883
994
|
manifestPathOrDir: string,
|
|
884
|
-
options:
|
|
995
|
+
options: RestoreOptions = {},
|
|
885
996
|
): RestorePlan {
|
|
886
997
|
let manifestPath = resolve(manifestPathOrDir);
|
|
887
998
|
const stat = lstatSync(manifestPath, { throwIfNoEntry: false });
|
|
@@ -912,6 +1023,10 @@ export function planRestore(
|
|
|
912
1023
|
throw databaseError("restore_incomplete", manifestPath, "backup manifest is incomplete; refusing to restore a partial copy");
|
|
913
1024
|
}
|
|
914
1025
|
|
|
1026
|
+
let stateRoot: string | undefined;
|
|
1027
|
+
const currentStateRoot = (): string => (stateRoot ??= kxmRuntimePaths({ env: options.env ?? process.env }).stateRoot);
|
|
1028
|
+
const machineWide: string[] = [];
|
|
1029
|
+
|
|
915
1030
|
const stores: RestorePlan["stores"] = [];
|
|
916
1031
|
for (const store of manifest.stores) {
|
|
917
1032
|
const backupFilePath = join(manifestDir, store.backupFile);
|
|
@@ -937,12 +1052,9 @@ export function planRestore(
|
|
|
937
1052
|
);
|
|
938
1053
|
}
|
|
939
1054
|
|
|
940
|
-
|
|
941
|
-
if (
|
|
942
|
-
|
|
943
|
-
targetPath = join(resolve(options.projectRoot), rel);
|
|
944
|
-
}
|
|
945
|
-
stores.push({ storeId: store.storeId, backupFilePath, targetPath, schemaVersion: store.schemaVersion, maxSupportedVersion });
|
|
1055
|
+
const target = restoreTarget(manifest, store.sourcePath, store.storeId, options, currentStateRoot);
|
|
1056
|
+
if (target.machineWide) machineWide.push(store.storeId);
|
|
1057
|
+
stores.push({ storeId: store.storeId, backupFilePath, targetPath: target.targetPath, schemaVersion: store.schemaVersion, maxSupportedVersion });
|
|
946
1058
|
}
|
|
947
1059
|
|
|
948
1060
|
const files: RestorePlan["files"] = [];
|
|
@@ -959,22 +1071,31 @@ export function planRestore(
|
|
|
959
1071
|
`backup file ${file.backupFile} sha256 ${actualSha256} does not match manifest hash ${file.sha256}`,
|
|
960
1072
|
);
|
|
961
1073
|
}
|
|
962
|
-
|
|
963
|
-
if (
|
|
964
|
-
|
|
965
|
-
targetPath = join(resolve(options.projectRoot), rel);
|
|
966
|
-
}
|
|
967
|
-
files.push({ id: file.id, backupFilePath, targetPath });
|
|
1074
|
+
const target = restoreTarget(manifest, file.sourcePath, file.id, options, currentStateRoot);
|
|
1075
|
+
if (target.machineWide) machineWide.push(file.id);
|
|
1076
|
+
files.push({ id: file.id, backupFilePath, targetPath: target.targetPath });
|
|
968
1077
|
}
|
|
969
1078
|
|
|
970
|
-
|
|
1079
|
+
if (machineWide.length > 0 && !options.allProjects) {
|
|
1080
|
+
throw databaseError(
|
|
1081
|
+
"restore_requires_all_projects",
|
|
1082
|
+
manifestPath,
|
|
1083
|
+
`backup ${manifest.backupId} holds Runtime state shared by every project on this machine (${machineWide.join(", ")}); `
|
|
1084
|
+
+ "restoring it rolls back the registry or other projects' runs. Pass --all-projects to restore all of it, or restore a backup taken without --all-projects",
|
|
1085
|
+
);
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
return {
|
|
1089
|
+
manifestPath,
|
|
1090
|
+
backupId: manifest.backupId,
|
|
1091
|
+
...(manifest.scope !== undefined ? { scope: manifest.scope } : {}),
|
|
1092
|
+
stores,
|
|
1093
|
+
files,
|
|
1094
|
+
};
|
|
971
1095
|
}
|
|
972
1096
|
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
options: { projectRoot?: string } = {},
|
|
976
|
-
): RestoreResult {
|
|
977
|
-
const plan = planRestore(manifestPathOrDir, options);
|
|
1097
|
+
/** Overwrite each target in a checked plan: the stores, then their prompt sidecars. */
|
|
1098
|
+
export function applyRestorePlan(plan: RestorePlan): RestoreResult {
|
|
978
1099
|
const restoredStores = plan.stores.map((store) => restoreDatabaseFile(
|
|
979
1100
|
store.backupFilePath,
|
|
980
1101
|
store.targetPath,
|
|
@@ -991,3 +1112,10 @@ export function restoreBackup(
|
|
|
991
1112
|
restoredStores,
|
|
992
1113
|
};
|
|
993
1114
|
}
|
|
1115
|
+
|
|
1116
|
+
export function restoreBackup(
|
|
1117
|
+
manifestPathOrDir: string,
|
|
1118
|
+
options: RestoreOptions = {},
|
|
1119
|
+
): RestoreResult {
|
|
1120
|
+
return applyRestorePlan(planRestore(manifestPathOrDir, options));
|
|
1121
|
+
}
|
|
@@ -16,8 +16,15 @@
|
|
|
16
16
|
* and does not use the kxm.role.v1 subsystem.
|
|
17
17
|
*
|
|
18
18
|
* Guide research ids are not dispatch ids. Only the admitted map below is
|
|
19
|
-
* written, and only when that harness is authenticated.
|
|
20
|
-
*
|
|
19
|
+
* written, and only when that harness is authenticated. Unmapped ids are skipped.
|
|
20
|
+
*
|
|
21
|
+
* Google candidates are unmapped on purpose. Google's route is the Pi
|
|
22
|
+
* `antigravity` provider, which only the KXM Pi extension registers. The
|
|
23
|
+
* Runtime's Pi one-shot runs with `--no-extensions`, and `pi auth check` never
|
|
24
|
+
* loads extensions, so a drive of an `antigravity/…` role fails at dispatch.
|
|
25
|
+
* Mapping Google to `agy` instead would contradict the routing decision. Add
|
|
26
|
+
* Google back only when drive can reach `antigravity` and a reviewed
|
|
27
|
+
* admission decision says so.
|
|
21
28
|
*/
|
|
22
29
|
|
|
23
30
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
@@ -55,8 +62,6 @@ const ADMITTED_GUIDE_BINDINGS: Readonly<Record<string, AgentBinding>> = Object.f
|
|
|
55
62
|
"openai/gpt-5.6-sol": { harness: "codex", provider: "openai", model: "gpt-5.6-sol" },
|
|
56
63
|
"x-ai/grok-4.6": { harness: "grok", provider: "xai", model: "grok-4.6" },
|
|
57
64
|
"xai/grok-4.6": { harness: "grok", provider: "xai", model: "grok-4.6" },
|
|
58
|
-
"google/gemini-3.8-flash": { harness: "pi", provider: "antigravity", model: "gemini-3.8-flash-high" },
|
|
59
|
-
"google/gemini-3.8-flash-high": { harness: "pi", provider: "antigravity", model: "gemini-3.8-flash-high" },
|
|
60
65
|
"qwen/qwen3-coder-plus": { harness: "pi", provider: "openrouter", model: "qwen/qwen3-coder-plus" },
|
|
61
66
|
});
|
|
62
67
|
|
|
@@ -11,7 +11,7 @@ import { deliverInboxNotification } from "./inbox.ts";
|
|
|
11
11
|
import type { HubEvent, MessageRecord } from "./protocol.ts";
|
|
12
12
|
import { sessionTokenFixHint } from "./session-token-hint.ts";
|
|
13
13
|
|
|
14
|
-
const VERSION = "0.7.
|
|
14
|
+
const VERSION = "0.7.104";
|
|
15
15
|
const CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
|
|
16
16
|
const inbox = new Map<string, MessageRecord>();
|
|
17
17
|
const notifiedInbox = new Set<string>();
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import { join, resolve } from "node:path";
|
|
4
|
+
import { kxmUserStateRoot } from "./bindings.ts";
|
|
5
|
+
|
|
6
|
+
/* ------------------------------------------------------------------ *
|
|
7
|
+
* Where the Runtime keeps its stores under the user state root.
|
|
8
|
+
*
|
|
9
|
+
* A leaf module on purpose: backup and restore (`database.ts`) must find a
|
|
10
|
+
* project's event store exactly as the Runtime does, and `runtime-store.ts`
|
|
11
|
+
* imports `database.ts`, so the derivation cannot live there without a cycle.
|
|
12
|
+
* ------------------------------------------------------------------ */
|
|
13
|
+
|
|
14
|
+
export interface KxmRuntimePaths {
|
|
15
|
+
stateRoot: string;
|
|
16
|
+
runtimeDir: string;
|
|
17
|
+
registryDb: string;
|
|
18
|
+
projectsDir: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function kxmRuntimePaths(options: { stateRoot?: string; env?: NodeJS.ProcessEnv; homeDir?: string } = {}): KxmRuntimePaths {
|
|
22
|
+
const stateRoot = options.stateRoot
|
|
23
|
+
? resolve(options.stateRoot)
|
|
24
|
+
: kxmUserStateRoot({ ...(options.env ? { env: options.env } : {}), ...(options.homeDir ? { homeDir: options.homeDir } : {}) });
|
|
25
|
+
const runtimeDir = join(stateRoot, "runtime");
|
|
26
|
+
return {
|
|
27
|
+
stateRoot,
|
|
28
|
+
runtimeDir,
|
|
29
|
+
registryDb: join(runtimeDir, "registry.db"),
|
|
30
|
+
projectsDir: join(runtimeDir, "projects"),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function projectRuntimeKey(projectRoot: string): string {
|
|
35
|
+
// Canonicalize through the filesystem like the repository binding store so
|
|
36
|
+
// reaching a project through a link cannot mint a second key for it.
|
|
37
|
+
let canonical: string;
|
|
38
|
+
try {
|
|
39
|
+
canonical = realpathSync.native(resolve(projectRoot));
|
|
40
|
+
} catch {
|
|
41
|
+
canonical = resolve(projectRoot);
|
|
42
|
+
}
|
|
43
|
+
const folded = process.platform === "win32" ? canonical.toLocaleLowerCase("en-US") : canonical;
|
|
44
|
+
return createHash("sha256").update(folded, "utf8").digest("hex").slice(0, 24);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The project's Runtime event store, derived exactly as the Runtime derives it. */
|
|
48
|
+
export function kxmProjectRunEventsPath(projectRoot: string, env: NodeJS.ProcessEnv): string {
|
|
49
|
+
return join(kxmRuntimePaths({ env }).projectsDir, projectRuntimeKey(projectRoot), "run-events.db");
|
|
50
|
+
}
|
|
@@ -1,34 +1,16 @@
|
|
|
1
1
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { existsSync, lstatSync, mkdirSync, readFileSync,
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { DatabaseSync, openReadOnlyDatabase } from "./sqlite.ts";
|
|
5
5
|
import { KxmConfigError, validateCoordinator, validateDriveReceipt, validateIntakeMessage, validateRunEvent, kxmCanonicalJson, type JsonValue, type KxmConfigIssue, type KxmConfigOptions } from "./project-config.ts";
|
|
6
|
-
import {
|
|
6
|
+
import { kxmProjectRunEventsPath, projectRuntimeKey } from "./runtime-paths.ts";
|
|
7
7
|
import { deriveKxmSyncEvent, kxmSyncEventBytes, KxmSyncRedactor } from "./sync-transform.ts";
|
|
8
8
|
|
|
9
9
|
/* ------------------------------------------------------------------ *
|
|
10
10
|
* Runtime registry (per-user, platform state root)
|
|
11
11
|
* ------------------------------------------------------------------ */
|
|
12
12
|
|
|
13
|
-
export
|
|
14
|
-
stateRoot: string;
|
|
15
|
-
runtimeDir: string;
|
|
16
|
-
registryDb: string;
|
|
17
|
-
projectsDir: string;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
export function kxmRuntimePaths(options: { stateRoot?: string; env?: NodeJS.ProcessEnv; homeDir?: string } = {}): KxmRuntimePaths {
|
|
21
|
-
const stateRoot = options.stateRoot
|
|
22
|
-
? resolve(options.stateRoot)
|
|
23
|
-
: kxmUserStateRoot({ ...(options.env ? { env: options.env } : {}), ...(options.homeDir ? { homeDir: options.homeDir } : {}) });
|
|
24
|
-
const runtimeDir = join(stateRoot, "runtime");
|
|
25
|
-
return {
|
|
26
|
-
stateRoot,
|
|
27
|
-
runtimeDir,
|
|
28
|
-
registryDb: join(runtimeDir, "registry.db"),
|
|
29
|
-
projectsDir: join(runtimeDir, "projects"),
|
|
30
|
-
};
|
|
31
|
-
}
|
|
13
|
+
export { kxmRuntimePaths, projectRuntimeKey, kxmProjectRunEventsPath, type KxmRuntimePaths } from "./runtime-paths.ts";
|
|
32
14
|
|
|
33
15
|
function runtimeIssue(phase: KxmConfigIssue["phase"], code: string, file: string, message: string): KxmConfigIssue {
|
|
34
16
|
return { phase, code, file, message };
|
|
@@ -38,24 +20,6 @@ export function runtimeError(code: string, file: string, message: string): KxmCo
|
|
|
38
20
|
return new KxmConfigError([runtimeIssue("semantic", code, file, message)]);
|
|
39
21
|
}
|
|
40
22
|
|
|
41
|
-
export function projectRuntimeKey(projectRoot: string): string {
|
|
42
|
-
// Canonicalize through the filesystem like the repository binding store so
|
|
43
|
-
// reaching a project through a link cannot mint a second key for it.
|
|
44
|
-
let canonical: string;
|
|
45
|
-
try {
|
|
46
|
-
canonical = realpathSync.native(resolve(projectRoot));
|
|
47
|
-
} catch {
|
|
48
|
-
canonical = resolve(projectRoot);
|
|
49
|
-
}
|
|
50
|
-
const folded = process.platform === "win32" ? canonical.toLocaleLowerCase("en-US") : canonical;
|
|
51
|
-
return createHash("sha256").update(folded, "utf8").digest("hex").slice(0, 24);
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** The project's Runtime event store, derived exactly as the Runtime derives it. */
|
|
55
|
-
export function kxmProjectRunEventsPath(projectRoot: string, env: NodeJS.ProcessEnv): string {
|
|
56
|
-
return join(kxmRuntimePaths({ env }).projectsDir, projectRuntimeKey(projectRoot), "run-events.db");
|
|
57
|
-
}
|
|
58
|
-
|
|
59
23
|
/**
|
|
60
24
|
* Whether this project's Runtime event store holds `runId`. Hub workflow runs and Runtime
|
|
61
25
|
* runs share the `run_<32 hex>` shape, so a command addressed by run id asks the owning
|
|
@@ -141,6 +105,24 @@ CREATE TABLE projects (
|
|
|
141
105
|
) STRICT;
|
|
142
106
|
`;
|
|
143
107
|
|
|
108
|
+
/** The supervisor singleton row, from any connection to a registry, including a read-only one. */
|
|
109
|
+
export function readKxmSupervisorRecord(database: DatabaseSync): KxmSupervisorRecord | undefined {
|
|
110
|
+
const row = database.prepare("SELECT runtime_id, pid, port, token_hash, started_at, heartbeat_at, state FROM supervisor WHERE singleton_id = 1").get() as
|
|
111
|
+
| { runtime_id: string; pid: number; port: number; token_hash: string; started_at: string; heartbeat_at: string; state: KxmSupervisorRecord["state"] }
|
|
112
|
+
| undefined;
|
|
113
|
+
return row
|
|
114
|
+
? {
|
|
115
|
+
runtimeId: row.runtime_id,
|
|
116
|
+
pid: row.pid,
|
|
117
|
+
port: row.port,
|
|
118
|
+
tokenHash: row.token_hash,
|
|
119
|
+
startedAt: row.started_at,
|
|
120
|
+
heartbeatAt: row.heartbeat_at,
|
|
121
|
+
state: row.state,
|
|
122
|
+
}
|
|
123
|
+
: undefined;
|
|
124
|
+
}
|
|
125
|
+
|
|
144
126
|
export class KxmRuntimeRegistry {
|
|
145
127
|
readonly path: string;
|
|
146
128
|
private readonly database: DatabaseSync;
|
|
@@ -228,20 +210,7 @@ export class KxmRuntimeRegistry {
|
|
|
228
210
|
}
|
|
229
211
|
|
|
230
212
|
private readSupervisorRow(): KxmSupervisorRecord | undefined {
|
|
231
|
-
|
|
232
|
-
| { runtime_id: string; pid: number; port: number; token_hash: string; started_at: string; heartbeat_at: string; state: KxmSupervisorRecord["state"] }
|
|
233
|
-
| undefined;
|
|
234
|
-
return row
|
|
235
|
-
? {
|
|
236
|
-
runtimeId: row.runtime_id,
|
|
237
|
-
pid: row.pid,
|
|
238
|
-
port: row.port,
|
|
239
|
-
tokenHash: row.token_hash,
|
|
240
|
-
startedAt: row.started_at,
|
|
241
|
-
heartbeatAt: row.heartbeat_at,
|
|
242
|
-
state: row.state,
|
|
243
|
-
}
|
|
244
|
-
: undefined;
|
|
213
|
+
return readKxmSupervisorRecord(this.database);
|
|
245
214
|
}
|
|
246
215
|
|
|
247
216
|
supervisor(): KxmSupervisorRecord | undefined {
|
|
@@ -8,12 +8,15 @@ import { loadKxmProject, KxmConfigError, type KxmConfigOptions } from "./project
|
|
|
8
8
|
import {
|
|
9
9
|
KxmRuntimeRegistry,
|
|
10
10
|
projectRuntimeKey,
|
|
11
|
+
readKxmSupervisorRecord,
|
|
11
12
|
runtimeError,
|
|
12
13
|
verifyKxmDriveReceipt,
|
|
13
14
|
kxmRuntimePaths,
|
|
14
15
|
type KxmRunEventStore,
|
|
15
16
|
type KxmRuntimePaths,
|
|
17
|
+
type KxmSupervisorRecord,
|
|
16
18
|
} from "./runtime-store.ts";
|
|
19
|
+
import { openReadOnlyDatabase } from "./sqlite.ts";
|
|
17
20
|
import {
|
|
18
21
|
acceptKxmRun,
|
|
19
22
|
cancelKxmRun,
|
|
@@ -122,26 +125,44 @@ function readRecentSupervisorError(paths: KxmRuntimePaths): string | undefined {
|
|
|
122
125
|
}
|
|
123
126
|
}
|
|
124
127
|
|
|
125
|
-
|
|
128
|
+
function supervisorStatusOf(record: KxmSupervisorRecord | undefined): KxmSupervisorStatus {
|
|
129
|
+
if (!record) return { running: false };
|
|
130
|
+
// Pid-only liveness is not enough: after a crash the pid may be reused by
|
|
131
|
+
// an unrelated process. A stale heartbeat is authoritative.
|
|
132
|
+
const heartbeatAgeMs = Date.now() - Date.parse(record.heartbeatAt);
|
|
133
|
+
const fresh = Number.isFinite(heartbeatAgeMs) && heartbeatAgeMs < HEARTBEAT_STALE_MS;
|
|
134
|
+
const alive = record.state === "running" && fresh && processAlive(record.pid);
|
|
135
|
+
return {
|
|
136
|
+
running: alive,
|
|
137
|
+
runtimeId: record.runtimeId,
|
|
138
|
+
pid: record.pid,
|
|
139
|
+
port: record.port,
|
|
140
|
+
state: alive ? record.state : "dead",
|
|
141
|
+
heartbeatAt: record.heartbeatAt,
|
|
142
|
+
startedAt: record.startedAt,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Is the supervisor up? `readOnly` gives the same answer without opening the
|
|
148
|
+
* registry for writing: the ordinary open takes the write lock and, as the last
|
|
149
|
+
* connection, checkpoints the WAL on close. `kxm restore` asks this way, dry run
|
|
150
|
+
* or not, because a project-scoped restore never otherwise touches the registry.
|
|
151
|
+
*/
|
|
152
|
+
export function kxmSupervisorStatus(paths: KxmRuntimePaths, options: { readOnly?: boolean } = {}): KxmSupervisorStatus {
|
|
126
153
|
if (!existsSync(paths.registryDb)) return { running: false };
|
|
154
|
+
if (options.readOnly) {
|
|
155
|
+
const database = openReadOnlyDatabase(paths.registryDb);
|
|
156
|
+
try {
|
|
157
|
+
database.exec("PRAGMA busy_timeout = 5000");
|
|
158
|
+
return supervisorStatusOf(readKxmSupervisorRecord(database));
|
|
159
|
+
} finally {
|
|
160
|
+
database.close();
|
|
161
|
+
}
|
|
162
|
+
}
|
|
127
163
|
const registry = new KxmRuntimeRegistry(paths.registryDb);
|
|
128
164
|
try {
|
|
129
|
-
|
|
130
|
-
if (!record) return { running: false };
|
|
131
|
-
// Pid-only liveness is not enough: after a crash the pid may be reused by
|
|
132
|
-
// an unrelated process. A stale heartbeat is authoritative.
|
|
133
|
-
const heartbeatAgeMs = Date.now() - Date.parse(record.heartbeatAt);
|
|
134
|
-
const fresh = Number.isFinite(heartbeatAgeMs) && heartbeatAgeMs < HEARTBEAT_STALE_MS;
|
|
135
|
-
const alive = record.state === "running" && fresh && processAlive(record.pid);
|
|
136
|
-
return {
|
|
137
|
-
running: alive,
|
|
138
|
-
runtimeId: record.runtimeId,
|
|
139
|
-
pid: record.pid,
|
|
140
|
-
port: record.port,
|
|
141
|
-
state: alive ? record.state : "dead",
|
|
142
|
-
heartbeatAt: record.heartbeatAt,
|
|
143
|
-
startedAt: record.startedAt,
|
|
144
|
-
};
|
|
165
|
+
return supervisorStatusOf(registry.supervisor());
|
|
145
166
|
} finally {
|
|
146
167
|
registry.close();
|
|
147
168
|
}
|