@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.
@@ -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
- function discoverUserRuntime(env: NodeJS.ProcessEnv | undefined, stores: DiscoveredStore[], files: DiscoveredFile[]): void {
646
- let stateRoot: string;
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
- stateRoot = kxmUserStateRoot(env ? { env } : {});
702
+ paths = kxmRuntimePaths(options.env ? { env: options.env } : {});
649
703
  } catch {
650
- return;
704
+ return undefined;
651
705
  }
652
- const runtimeDir = join(stateRoot, "runtime");
653
- const registryPath = join(runtimeDir, "registry.db");
654
- if (existsSync(registryPath)) {
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, registryPath);
712
+ pushStore(stores, storeId, paths.registryDb);
657
713
  }
658
- const projectsDir = join(runtimeDir, "projects");
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 (!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
- }
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
- discoverUserRuntime(options.env, stores, files);
713
- return { stores, files };
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: { hubDataPath?: string; env?: NodeJS.ProcessEnv } = {}): Array<{ storeId: string; sourcePath: string; maxSupportedVersion: number }> {
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: { hubDataPath?: string; env?: NodeJS.ProcessEnv }): { hubDataPath?: string; env?: NodeJS.ProcessEnv } {
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 { projectRoot, outDir, createdAt, stores, files };
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, and each store's schema against this build's ceiling
881
- * (from the manifest). Reads files; opens no database. */
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: { projectRoot?: string } = {},
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
- let targetPath = store.sourcePath;
941
- if (options.projectRoot && manifest.projectRoot && targetPath.startsWith(manifest.projectRoot)) {
942
- const rel = targetPath.slice(manifest.projectRoot.length).replace(/^[\\/]+/, "");
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
- 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 });
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
- return { manifestPath, backupId: manifest.backupId, stores, files };
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
- export function restoreBackup(
974
- manifestPathOrDir: string,
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. Google goes through
20
- * the Pi `antigravity` provider. Unmapped ids are skipped.
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.102";
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, realpathSync, writeFileSync } from "node:fs";
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 { kxmUserStateRoot } from "./bindings.ts";
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 interface KxmRuntimePaths {
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
- const row = this.database.prepare("SELECT runtime_id, pid, port, token_hash, started_at, heartbeat_at, state FROM supervisor WHERE singleton_id = 1").get() as
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
- export function kxmSupervisorStatus(paths: KxmRuntimePaths): KxmSupervisorStatus {
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
- const record = registry.supervisor();
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
  }