@kontextmind/kxm 0.7.103 → 0.7.105
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 +39 -8
- package/docs/concepts/data-and-storage.md +1 -1
- package/docs/contributing/test-matrix.md +11 -6
- package/docs/operations/backup-and-restore.md +59 -27
- package/docs/operations/deploy.md +1 -1
- package/docs/reference/cli-reference.md +73 -55
- package/docs/reference/config-reference.md +1 -1
- package/docs/reference/workflow-definitions.md +4 -4
- package/docs/start/first-workflow.md +6 -2
- package/docs/start/quickstart-claude-code.md +11 -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 +1802 -1011
- package/plugins/kxm/dist/mcp-server.js +1 -1
- package/plugins/kxm/dist/runtime-supervisor.js +322 -292
- package/plugins/kxm/dist/runtime.js +427 -351
- 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 +132 -30
- package/plugins/kxm/src/cli/system.ts +19 -1
- package/plugins/kxm/src/cli/tasks.ts +55 -31
- package/plugins/kxm/src/cli/workflows.ts +52 -54
- package/plugins/kxm/src/cli.ts +8 -6
- package/plugins/kxm/src/database.ts +193 -65
- package/plugins/kxm/src/engine.ts +79 -3
- package/plugins/kxm/src/harness.ts +6 -5
- 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/plugins/kxm/src/suggest.ts +132 -79
- package/plugins/kxm/src/workflow-manager.ts +42 -19
- 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
|
+
}
|
|
@@ -3,7 +3,6 @@ import { existsSync, readFileSync } from "node:fs";
|
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
import { parse } from "yaml";
|
|
5
5
|
import { isRouteAdmitted, listRoleBindings } from "./routes.ts";
|
|
6
|
-
import { oneShotWriterArgs } from "./harness.ts";
|
|
7
6
|
import { applyAuthoringWitness, captureWorktreeWitness } from "./worktree-witness.ts";
|
|
8
7
|
import {
|
|
9
8
|
buildFormalContextPacket,
|
|
@@ -14,6 +13,7 @@ import {
|
|
|
14
13
|
type HandoffManifestV1,
|
|
15
14
|
} from "./context-packet.ts";
|
|
16
15
|
import { compileKxmWorkflow, type KxmCompiledPlan, type KxmCompiledStep } from "./engine-compile.ts";
|
|
16
|
+
import { BUILTIN_HARNESSES, oneShotPermissionArgs, oneShotWriterArgs, validateHarnessModelPair } from "./harness.ts";
|
|
17
17
|
import {
|
|
18
18
|
effectiveRunDurationBudget,
|
|
19
19
|
isTerminalRunStatus,
|
|
@@ -2693,7 +2693,83 @@ function armRunDurationBudgetTimer(context: KxmRuntimeContext, runId: string): (
|
|
|
2693
2693
|
};
|
|
2694
2694
|
}
|
|
2695
2695
|
|
|
2696
|
-
|
|
2696
|
+
/** Read-only admission guidance for a live run, using the same policies as dispatch. */
|
|
2697
|
+
export function kxmLiveRunPrerequisites(
|
|
2698
|
+
bundle: KxmProjectBundle,
|
|
2699
|
+
workflowId: string,
|
|
2700
|
+
projectRoot: string,
|
|
2701
|
+
): KxmRunHandoff[] {
|
|
2702
|
+
const workflow = bundle.workflows.get(workflowId);
|
|
2703
|
+
if (!workflow) throw runtimeError("run_workflow_unknown", workflowId, `workflow ${workflowId} does not exist in this project`);
|
|
2704
|
+
const plan = compileKxmWorkflow({ id: workflowId, value: workflow.value, logicalPath: workflow.logicalPath });
|
|
2705
|
+
const envelope = { plan, projectLimits: kxmProjectAdmissionLimits(bundle), gates: pinnedGatesForCompiledPlan(plan, bundle, projectRoot) };
|
|
2706
|
+
const prerequisites: KxmRunHandoff[] = [];
|
|
2707
|
+
const limit = unsupportedLimit(envelope);
|
|
2708
|
+
if (limit) prerequisites.push(limit);
|
|
2709
|
+
const visited = new Set<string>();
|
|
2710
|
+
const pending = [plan.entryStepId];
|
|
2711
|
+
while (pending.length > 0) {
|
|
2712
|
+
const stepId = pending.pop()!;
|
|
2713
|
+
if (visited.has(stepId)) continue;
|
|
2714
|
+
visited.add(stepId);
|
|
2715
|
+
const step = plan.steps[stepId]!;
|
|
2716
|
+
for (const transition of Object.values(step.transitions)) {
|
|
2717
|
+
if (transition.to === "step") pending.push(transition.target);
|
|
2718
|
+
}
|
|
2719
|
+
const unsupported = step.kind === "gate"
|
|
2720
|
+
? unsupportedGateStep(plan, step, envelope, { projectRoot }) ?? starterGatePrerequisite(step, envelope.gates, projectRoot)
|
|
2721
|
+
: unsupportedStep(plan, step, "oneshot");
|
|
2722
|
+
if (unsupported) {
|
|
2723
|
+
prerequisites.push({ ...unsupported, stepId });
|
|
2724
|
+
continue;
|
|
2725
|
+
}
|
|
2726
|
+
if (step.kind !== "agent" && step.kind !== "moa") continue;
|
|
2727
|
+
const agentIds = step.assignments.allowedAgents.length > 0 ? step.assignments.allowedAgents : [step.agent];
|
|
2728
|
+
for (const agentId of agentIds) {
|
|
2729
|
+
const agent = bundle.agents.get(agentId);
|
|
2730
|
+
const harness = String(agent?.value.harness ?? bundle.project.value.defaultHarness ?? "pi");
|
|
2731
|
+
const permission = Object.values(step.repositories).includes("write") ? "edit" : "read-only";
|
|
2732
|
+
if (!BUILTIN_HARNESSES.some((entry) => entry.id === harness && entry.oneShot) || !oneShotPermissionArgs(harness, permission)) {
|
|
2733
|
+
prerequisites.push({ reason: "step_unsupported", stepId, field: "harness", detail: `${agentId}: ${harness} has no audited ${permission} one-shot profile; use a supported workflow or execute this work directly in ${harness}, without substituting another harness` });
|
|
2734
|
+
continue;
|
|
2735
|
+
}
|
|
2736
|
+
const route = resolveProducerRoute(projectRoot, step, agentId);
|
|
2737
|
+
if ("error" in route) {
|
|
2738
|
+
prerequisites.push({ ...route.error, stepId, detail: `${agentId}: ${route.error.detail}; configure its model in .kxm/agents/${agentId}.yaml and admit the installed model with kxm routes admit --model <provider/model>` });
|
|
2739
|
+
continue;
|
|
2740
|
+
}
|
|
2741
|
+
const writeRefusal = unsupportedLiveWrite(projectRoot, step, agentId, route.selector, envelope.projectLimits.maxConcurrentRuns);
|
|
2742
|
+
if (writeRefusal) {
|
|
2743
|
+
prerequisites.push({ ...writeRefusal, stepId });
|
|
2744
|
+
continue;
|
|
2745
|
+
}
|
|
2746
|
+
const compatible = validateHarnessModelPair(harness, route);
|
|
2747
|
+
if (!compatible.valid) {
|
|
2748
|
+
prerequisites.push({ reason: "step_unsupported", stepId, field: "model", detail: `${agentId}: ${compatible.message ?? `${harness} cannot run ${route.selector}`}; configure a model supported by ${harness} in .kxm/agents/${agentId}.yaml` });
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
}
|
|
2752
|
+
return prerequisites;
|
|
2753
|
+
}
|
|
2754
|
+
|
|
2755
|
+
function starterGatePrerequisite(step: KxmCompiledStep & { kind: "gate" }, gates: KxmPinnedGates, projectRoot: string): Omit<KxmRunHandoff, "stepId"> | undefined {
|
|
2756
|
+
const definition = gates.definitions[step.gate];
|
|
2757
|
+
if (!definition || definition.kind !== "command" || definition.argv.length !== 2 || definition.argv[0] !== "npm" || definition.argv[1] !== "test") return undefined;
|
|
2758
|
+
let testScript: unknown;
|
|
2759
|
+
try {
|
|
2760
|
+
const manifest: unknown = JSON.parse(readFileSync(join(projectRoot, "package.json"), "utf8"));
|
|
2761
|
+
if (manifest && typeof manifest === "object" && "scripts" in manifest) {
|
|
2762
|
+
const scripts = manifest.scripts;
|
|
2763
|
+
if (scripts && typeof scripts === "object" && "test" in scripts) testScript = scripts.test;
|
|
2764
|
+
}
|
|
2765
|
+
} catch { /* A missing or unreadable manifest cannot satisfy the starter gate. */ }
|
|
2766
|
+
if (typeof testScript !== "string" || testScript.trim().length === 0) {
|
|
2767
|
+
return { reason: "gate_unsupported", field: `gates.${step.gate}.argv`, detail: `gate ${step.gate} runs npm test but this repository has no readable package.json with scripts.test; configure .kxm/gates.yaml gates.${step.gate}.argv for the repository's actual test runner before driving this workflow` };
|
|
2768
|
+
}
|
|
2769
|
+
return undefined;
|
|
2770
|
+
}
|
|
2771
|
+
|
|
2772
|
+
function unsupportedLimit(envelope: Pick<KxmRunPlanEnvelope, "plan" | "projectLimits">): KxmRunHandoff | undefined {
|
|
2697
2773
|
if (envelope.plan.limits.maxAgentTimeMs !== undefined) {
|
|
2698
2774
|
return { reason: "limit_unsupported", field: "limits.maxAgentTimeMs", detail: "agent-time budget enforcement is not available in this slice" };
|
|
2699
2775
|
}
|
|
@@ -2847,7 +2923,7 @@ function unsupportedLiveWrite(
|
|
|
2847
2923
|
return undefined;
|
|
2848
2924
|
}
|
|
2849
2925
|
|
|
2850
|
-
function unsupportedGateStep(plan: KxmCompiledPlan, step: KxmCompiledStep & { kind: "gate" }, envelope: KxmRunPlanEnvelope, context?: { projectRoot: string }): Omit<KxmRunHandoff, "stepId"> | undefined {
|
|
2926
|
+
function unsupportedGateStep(plan: KxmCompiledPlan, step: KxmCompiledStep & { kind: "gate" }, envelope: Pick<KxmRunPlanEnvelope, "gates">, context?: { projectRoot: string }): Omit<KxmRunHandoff, "stepId"> | undefined {
|
|
2851
2927
|
if (!step.gate) return { reason: "step_unsupported", field: "gate", detail: "gate step is missing gate id" };
|
|
2852
2928
|
if (envelope.gates.registry === null) {
|
|
2853
2929
|
return { reason: "step_unsupported", field: "gate", detail: `step ${step.id} refers to a gate but registry is null` };
|
|
@@ -62,6 +62,7 @@ export type HarnessRunCommand = (command: string, args: readonly string[], timeo
|
|
|
62
62
|
export type HarnessRunCommandAsync = (command: string, args: readonly string[], timeoutMs: number) => HarnessCommandResult | Promise<HarnessCommandResult>;
|
|
63
63
|
|
|
64
64
|
export interface HarnessProbeOptions {
|
|
65
|
+
defaultHarness?: string | undefined;
|
|
65
66
|
env?: NodeJS.ProcessEnv | undefined;
|
|
66
67
|
runCommand?: HarnessRunCommand | undefined;
|
|
67
68
|
timeoutMs?: number | undefined;
|
|
@@ -96,7 +97,7 @@ export interface HarnessStatus {
|
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
export interface HarnessInventory {
|
|
99
|
-
defaultHarness:
|
|
100
|
+
defaultHarness: string;
|
|
100
101
|
harnesses: readonly HarnessStatus[];
|
|
101
102
|
}
|
|
102
103
|
|
|
@@ -958,7 +959,7 @@ function probeEntry(
|
|
|
958
959
|
runCommand: (command: string, args: readonly string[], timeoutMs: number) => HarnessCommandResult,
|
|
959
960
|
timeoutMs: number,
|
|
960
961
|
platform: NodeJS.Platform,
|
|
961
|
-
probe: Pick<HarnessProbeOptions, "env" | "existsSync"> = {},
|
|
962
|
+
probe: Pick<HarnessProbeOptions, "env" | "existsSync" | "defaultHarness"> = {},
|
|
962
963
|
): HarnessStatus {
|
|
963
964
|
const issues: string[] = [];
|
|
964
965
|
const found = detectHarnessCommand(entry, runCommand, timeoutMs, platform, probe);
|
|
@@ -987,7 +988,7 @@ function probeEntry(
|
|
|
987
988
|
return {
|
|
988
989
|
id: entry.id,
|
|
989
990
|
label: entry.label,
|
|
990
|
-
default: entry.
|
|
991
|
+
default: entry.id === (probe.defaultHarness ?? DEFAULT_HARNESS),
|
|
991
992
|
mode: entry.mode,
|
|
992
993
|
detected,
|
|
993
994
|
authenticated,
|
|
@@ -1009,7 +1010,7 @@ export function probeHarnesses(options: HarnessProbeOptions = {}): HarnessInvent
|
|
|
1009
1010
|
const runCommand = options.runCommand ?? defaultRunner(options.env ?? process.env);
|
|
1010
1011
|
const platform = options.platform ?? process.platform;
|
|
1011
1012
|
return {
|
|
1012
|
-
defaultHarness: DEFAULT_HARNESS,
|
|
1013
|
+
defaultHarness: options.defaultHarness ?? DEFAULT_HARNESS,
|
|
1013
1014
|
harnesses: BUILTIN_HARNESSES.map((entry) => probeEntry(entry, runCommand, timeoutMs, platform, options)),
|
|
1014
1015
|
};
|
|
1015
1016
|
}
|
|
@@ -1508,7 +1509,7 @@ export function formatHarnessInventory(inventory: HarnessInventory): string {
|
|
|
1508
1509
|
].join(" ");
|
|
1509
1510
|
});
|
|
1510
1511
|
return [
|
|
1511
|
-
`default harness: ${inventory.defaultHarness} (
|
|
1512
|
+
`default harness: ${inventory.defaultHarness} (used when an agent omits harness:)`,
|
|
1512
1513
|
"enable/disable = Git YAML (.kxm/agents, .kxm/models) or the harness's own plugin CLI",
|
|
1513
1514
|
"governed kxm skills are not auto-updated",
|
|
1514
1515
|
header,
|
|
@@ -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.105";
|
|
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
|
+
}
|