@kontextmind/kxm 0.7.128 → 0.7.130
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 +20 -0
- package/docs/architecture/inventory.md +1 -1
- package/docs/concepts/data-and-storage.md +1 -1
- package/docs/contracts/README.md +1 -1
- package/docs/contracts/migration.md +12 -8
- package/docs/contributing/development.md +3 -1
- package/docs/operations/backup-and-restore.md +3 -3
- package/docs/operations/upgrade.md +2 -2
- package/docs/reference/cli-reference.md +11 -6
- package/docs/reference/config-reference.md +5 -2
- package/package.json +8 -8
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/cli.js +2284 -222
- package/plugins/kxm/dist/mcp-server.js +1 -1
- package/plugins/kxm/dist/runtime-supervisor.js +461 -106
- package/plugins/kxm/dist/runtime.js +470 -114
- package/plugins/kxm/dist/server.js +17 -14
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/src/cli/lanes.ts +109 -4
- package/plugins/kxm/src/cli/project.ts +2 -2
- package/plugins/kxm/src/database.ts +25 -19
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/runtime-service.ts +1 -0
- package/plugins/kxm/src/runtime-store.ts +390 -77
- package/plugins/kxm/src/runtime-supervisor.ts +87 -3
- package/scripts/run-bounded.mjs +53 -0
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
1
2
|
import { createHash, randomUUID } from "node:crypto";
|
|
2
|
-
import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, statSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
5
|
import { DatabaseSync, openReadOnlyDatabase } from "./sqlite.ts";
|
|
5
6
|
import { KxmConfigError, validateCoordinator, validateDriveReceipt, validateIntakeMessage, validateRunEvent, kxmCanonicalJson, type JsonValue, type KxmConfigIssue, type KxmConfigOptions } from "./project-config.ts";
|
|
6
7
|
import { kxmProjectRunEventsPath, projectRuntimeKey } from "./runtime-paths.ts";
|
|
@@ -48,7 +49,9 @@ function checkedParent(path: string, description: string): void {
|
|
|
48
49
|
}
|
|
49
50
|
|
|
50
51
|
import {
|
|
52
|
+
assertDatabaseFile,
|
|
51
53
|
openDatabase,
|
|
54
|
+
tableColumns,
|
|
52
55
|
withDatabaseTransaction,
|
|
53
56
|
type DatabaseSchemaSpec,
|
|
54
57
|
} from "./database.ts";
|
|
@@ -74,14 +77,16 @@ export interface KxmProjectRegistration {
|
|
|
74
77
|
projectKey: string;
|
|
75
78
|
homeRuntimeId: string;
|
|
76
79
|
configRevision?: string;
|
|
80
|
+
/** Set on a lane row: the home row's project key. Absent on the home row. */
|
|
81
|
+
laneOf?: string;
|
|
77
82
|
registeredAt: string;
|
|
78
83
|
}
|
|
79
84
|
|
|
80
|
-
export const KXM_REGISTRY_SCHEMA_VERSION =
|
|
85
|
+
export const KXM_REGISTRY_SCHEMA_VERSION = 2;
|
|
81
86
|
|
|
82
87
|
const REGISTRY_TABLES = {
|
|
83
88
|
supervisor: ["singleton_id", "runtime_id", "pid", "port", "token_hash", "started_at", "heartbeat_at", "state"],
|
|
84
|
-
projects: ["project_id", "project_root", "project_key", "home_runtime_id", "config_revision", "registered_at"],
|
|
89
|
+
projects: ["project_id", "project_root", "project_key", "home_runtime_id", "config_revision", "registered_at", "lane_of"],
|
|
85
90
|
} as const;
|
|
86
91
|
|
|
87
92
|
const REGISTRY_SCHEMA = `
|
|
@@ -96,15 +101,142 @@ CREATE TABLE supervisor (
|
|
|
96
101
|
state TEXT NOT NULL
|
|
97
102
|
) STRICT;
|
|
98
103
|
CREATE TABLE projects (
|
|
99
|
-
|
|
104
|
+
project_key TEXT PRIMARY KEY,
|
|
105
|
+
project_id TEXT NOT NULL,
|
|
100
106
|
project_root TEXT NOT NULL,
|
|
101
|
-
project_key TEXT NOT NULL UNIQUE,
|
|
102
107
|
home_runtime_id TEXT NOT NULL,
|
|
103
108
|
config_revision TEXT,
|
|
104
|
-
registered_at TEXT NOT NULL
|
|
109
|
+
registered_at TEXT NOT NULL,
|
|
110
|
+
lane_of TEXT
|
|
105
111
|
) STRICT;
|
|
112
|
+
CREATE INDEX projects_by_project_id ON projects (project_id);
|
|
106
113
|
`;
|
|
107
114
|
|
|
115
|
+
const PROJECT_COLUMNS = "project_id, project_root, project_key, home_runtime_id, config_revision, registered_at, lane_of";
|
|
116
|
+
|
|
117
|
+
interface ProjectRow {
|
|
118
|
+
project_id: string;
|
|
119
|
+
project_root: string;
|
|
120
|
+
project_key: string;
|
|
121
|
+
home_runtime_id: string;
|
|
122
|
+
config_revision: string | null;
|
|
123
|
+
registered_at: string;
|
|
124
|
+
lane_of: string | null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function registrationFromRow(row: ProjectRow): KxmProjectRegistration {
|
|
128
|
+
return {
|
|
129
|
+
projectId: row.project_id,
|
|
130
|
+
projectRoot: row.project_root,
|
|
131
|
+
projectKey: row.project_key,
|
|
132
|
+
homeRuntimeId: row.home_runtime_id,
|
|
133
|
+
...(row.config_revision !== null ? { configRevision: row.config_revision } : {}),
|
|
134
|
+
...(row.lane_of !== null ? { laneOf: row.lane_of } : {}),
|
|
135
|
+
registeredAt: row.registered_at,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function controlRootDirectoryExists(projectRoot: string): boolean {
|
|
140
|
+
const stat = statSync(projectRoot, { throwIfNoEntry: false });
|
|
141
|
+
return Boolean(stat?.isDirectory());
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function gitEnv(): NodeJS.ProcessEnv {
|
|
145
|
+
return Object.fromEntries(Object.entries(process.env).filter(([name]) => !name.toUpperCase().startsWith("GIT_")));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Absolute git common directory, or undefined when `projectRoot` is not a repository. */
|
|
149
|
+
function gitCommonDirectory(projectRoot: string): string | undefined {
|
|
150
|
+
const result = spawnSync("git", ["-C", projectRoot, "rev-parse", "--git-common-dir"], {
|
|
151
|
+
encoding: "utf8",
|
|
152
|
+
env: gitEnv(),
|
|
153
|
+
timeout: 30_000,
|
|
154
|
+
windowsHide: true,
|
|
155
|
+
});
|
|
156
|
+
if (result.status !== 0) return undefined;
|
|
157
|
+
const text = (result.stdout ?? "").trim();
|
|
158
|
+
if (!text) return undefined;
|
|
159
|
+
const absolute = isAbsolute(text) ? text : resolve(projectRoot, text);
|
|
160
|
+
let canonical: string;
|
|
161
|
+
try {
|
|
162
|
+
canonical = realpathSync.native(absolute);
|
|
163
|
+
} catch {
|
|
164
|
+
canonical = resolve(absolute);
|
|
165
|
+
}
|
|
166
|
+
return process.platform === "win32" ? canonical.toLocaleLowerCase("en-US") : canonical;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** A primary checkout is the worktree whose git common dir is `<root>/.git`. */
|
|
170
|
+
function isPrimaryWorktree(projectRoot: string, commonDir: string | undefined): boolean {
|
|
171
|
+
if (commonDir === undefined) return false;
|
|
172
|
+
const dotGit = resolve(projectRoot, ".git");
|
|
173
|
+
let canonical: string;
|
|
174
|
+
try {
|
|
175
|
+
canonical = realpathSync.native(dotGit);
|
|
176
|
+
} catch {
|
|
177
|
+
canonical = resolve(dotGit);
|
|
178
|
+
}
|
|
179
|
+
if (process.platform === "win32") canonical = canonical.toLocaleLowerCase("en-US");
|
|
180
|
+
return canonical === commonDir;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Registry schema 1 keyed `projects` on project id, so a second control root
|
|
185
|
+
* could not be stored. Schema 2 keys on the control root and adds `lane_of`.
|
|
186
|
+
* Existing rows are copied unchanged, with `lane_of` left null. A database
|
|
187
|
+
* that is already shape 2 and was only restamped as version 1 is not rebuilt.
|
|
188
|
+
* The file is opened only after the same symlink and sidecar checks as
|
|
189
|
+
* `openDatabase`.
|
|
190
|
+
*/
|
|
191
|
+
function migrateRegistryLanes(file: string): void {
|
|
192
|
+
const stat = lstatSync(file, { throwIfNoEntry: false });
|
|
193
|
+
if (!stat) return;
|
|
194
|
+
assertDatabaseFile(file, "runtime registry");
|
|
195
|
+
const database = new DatabaseSync(file);
|
|
196
|
+
let transaction = false;
|
|
197
|
+
try {
|
|
198
|
+
database.exec("PRAGMA busy_timeout = 5000");
|
|
199
|
+
const current = database.prepare("PRAGMA user_version").get() as { user_version: number } | undefined;
|
|
200
|
+
if ((current?.user_version ?? 0) !== 1) return;
|
|
201
|
+
database.exec("BEGIN IMMEDIATE");
|
|
202
|
+
transaction = true;
|
|
203
|
+
const locked = database.prepare("PRAGMA user_version").get() as { user_version: number } | undefined;
|
|
204
|
+
if ((locked?.user_version ?? 0) !== 1) {
|
|
205
|
+
database.exec("ROLLBACK");
|
|
206
|
+
transaction = false;
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
if (!tableColumns(database, "projects").includes("lane_of")) {
|
|
210
|
+
database.exec(`
|
|
211
|
+
CREATE TABLE projects_v2 (
|
|
212
|
+
project_key TEXT PRIMARY KEY,
|
|
213
|
+
project_id TEXT NOT NULL,
|
|
214
|
+
project_root TEXT NOT NULL,
|
|
215
|
+
home_runtime_id TEXT NOT NULL,
|
|
216
|
+
config_revision TEXT,
|
|
217
|
+
registered_at TEXT NOT NULL,
|
|
218
|
+
lane_of TEXT
|
|
219
|
+
) STRICT;
|
|
220
|
+
INSERT INTO projects_v2 (project_key, project_id, project_root, home_runtime_id, config_revision, registered_at, lane_of)
|
|
221
|
+
SELECT project_key, project_id, project_root, home_runtime_id, config_revision, registered_at, NULL FROM projects;
|
|
222
|
+
DROP TABLE projects;
|
|
223
|
+
ALTER TABLE projects_v2 RENAME TO projects;
|
|
224
|
+
CREATE INDEX projects_by_project_id ON projects (project_id);
|
|
225
|
+
`);
|
|
226
|
+
}
|
|
227
|
+
database.exec("PRAGMA user_version = 2");
|
|
228
|
+
database.exec("COMMIT");
|
|
229
|
+
transaction = false;
|
|
230
|
+
} catch (error) {
|
|
231
|
+
if (transaction) {
|
|
232
|
+
try { database.exec("ROLLBACK"); } catch { /* already rolled back */ }
|
|
233
|
+
}
|
|
234
|
+
throw error;
|
|
235
|
+
} finally {
|
|
236
|
+
database.close();
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
108
240
|
/** The supervisor singleton row, from any connection to a registry, including a read-only one. */
|
|
109
241
|
export function readKxmSupervisorRecord(database: DatabaseSync): KxmSupervisorRecord | undefined {
|
|
110
242
|
const row = database.prepare("SELECT runtime_id, pid, port, token_hash, started_at, heartbeat_at, state FROM supervisor WHERE singleton_id = 1").get() as
|
|
@@ -129,6 +261,7 @@ export class KxmRuntimeRegistry {
|
|
|
129
261
|
|
|
130
262
|
constructor(path: string) {
|
|
131
263
|
this.path = resolve(path);
|
|
264
|
+
migrateRegistryLanes(this.path);
|
|
132
265
|
this.database = openDatabase(this.path, "runtime registry", {
|
|
133
266
|
schema: REGISTRY_SCHEMA,
|
|
134
267
|
version: KXM_REGISTRY_SCHEMA_VERSION,
|
|
@@ -217,10 +350,9 @@ export class KxmRuntimeRegistry {
|
|
|
217
350
|
return this.readSupervisorRow();
|
|
218
351
|
}
|
|
219
352
|
|
|
220
|
-
/**
|
|
221
|
-
|
|
222
|
-
*
|
|
223
|
-
* syncing and presence keeps beating. */
|
|
353
|
+
/** All projects registered to this Runtime, including lane roots, for restart
|
|
354
|
+
* recovery: the supervisor reopens their contexts so pending outbox rows
|
|
355
|
+
* resume syncing and presence keeps beating. */
|
|
224
356
|
projectsForRuntime(homeRuntimeId: string): Array<{ projectRoot: string; projectId: string }> {
|
|
225
357
|
const rows = this.database.prepare(
|
|
226
358
|
"SELECT project_root, project_id FROM projects WHERE home_runtime_id = ? ORDER BY registered_at",
|
|
@@ -228,88 +360,269 @@ export class KxmRuntimeRegistry {
|
|
|
228
360
|
return rows.map((row) => ({ projectRoot: row.project_root, projectId: row.project_id }));
|
|
229
361
|
}
|
|
230
362
|
|
|
231
|
-
|
|
363
|
+
/**
|
|
364
|
+
* Register or revalidate a control root. The home runtime of a live row is
|
|
365
|
+
* immutable. A second root whose git common directory matches a live row
|
|
366
|
+
* for the same project is a lane, unless the registering root is the primary
|
|
367
|
+
* worktree and the live home row is not: then the primary becomes the home
|
|
368
|
+
* row (same home runtime id) and every other live row of that repository
|
|
369
|
+
* becomes its lane. A primary never becomes a lane. A root whose directory
|
|
370
|
+
* is gone is replaced. A live root that is a different repository is
|
|
371
|
+
* `project_home_conflict`.
|
|
372
|
+
*/
|
|
373
|
+
registerProject(registration: {
|
|
374
|
+
projectId: string;
|
|
375
|
+
projectRoot: string;
|
|
376
|
+
homeRuntimeId: string;
|
|
377
|
+
configRevision?: string;
|
|
378
|
+
now: string;
|
|
379
|
+
logger?: (entry: Record<string, unknown>) => void;
|
|
380
|
+
}): KxmProjectRegistration {
|
|
232
381
|
const projectRoot = resolve(registration.projectRoot);
|
|
233
382
|
const projectKey = projectRuntimeKey(projectRoot);
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
383
|
+
// Git runs only outside the write lock. The candidate is measured first;
|
|
384
|
+
// live rows are measured after a rollback, then the rows are read again.
|
|
385
|
+
const candidateCommon = gitCommonDirectory(projectRoot);
|
|
386
|
+
const commonByRoot = new Map<string, string | undefined>();
|
|
387
|
+
|
|
388
|
+
for (let attempt = 0; attempt < 8; attempt += 1) {
|
|
389
|
+
this.database.exec("BEGIN IMMEDIATE");
|
|
390
|
+
let rootsToMeasure: string[] | undefined;
|
|
391
|
+
try {
|
|
392
|
+
const byKey = this.projectRow("SELECT " + PROJECT_COLUMNS + " FROM projects WHERE project_key = ?", projectKey);
|
|
393
|
+
if (byKey) {
|
|
394
|
+
const problems: string[] = [];
|
|
395
|
+
if (byKey.project_id !== registration.projectId) problems.push(`control root is already bound to a different project id ${byKey.project_id}`);
|
|
396
|
+
if (byKey.home_runtime_id !== registration.homeRuntimeId) problems.push(`project ${registration.projectId} home runtime is immutable and cannot be rebound`);
|
|
397
|
+
if (problems.length > 0) {
|
|
398
|
+
this.database.exec("ROLLBACK");
|
|
399
|
+
throw runtimeError("project_home_conflict", ".kxm/project.yaml", problems.join("; "));
|
|
400
|
+
}
|
|
401
|
+
const result = registrationFromRow(byKey);
|
|
402
|
+
this.database.exec("COMMIT");
|
|
403
|
+
return result;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const rows = this.database.prepare(
|
|
407
|
+
`SELECT ${PROJECT_COLUMNS} FROM projects WHERE project_id = ? ORDER BY registered_at`,
|
|
408
|
+
).all(registration.projectId) as unknown as ProjectRow[];
|
|
409
|
+
const live = rows.filter((row) => controlRootDirectoryExists(row.project_root));
|
|
410
|
+
const dead = rows.filter((row) => !controlRootDirectoryExists(row.project_root));
|
|
411
|
+
const unknown = live.filter((row) => !commonByRoot.has(row.project_root));
|
|
412
|
+
if (unknown.length > 0) {
|
|
413
|
+
rootsToMeasure = unknown.map((row) => row.project_root);
|
|
249
414
|
this.database.exec("ROLLBACK");
|
|
250
|
-
|
|
415
|
+
} else if (live.length > 0) {
|
|
416
|
+
const anchor = live.find((row) => {
|
|
417
|
+
const dir = commonByRoot.get(row.project_root);
|
|
418
|
+
return candidateCommon !== undefined && dir === candidateCommon;
|
|
419
|
+
});
|
|
420
|
+
if (!anchor) {
|
|
421
|
+
this.database.exec("ROLLBACK");
|
|
422
|
+
throw runtimeError(
|
|
423
|
+
"project_home_conflict",
|
|
424
|
+
".kxm/project.yaml",
|
|
425
|
+
`project ${registration.projectId} is already bound to a different control root`,
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
if (live.some((row) => row.home_runtime_id !== registration.homeRuntimeId)) {
|
|
429
|
+
this.database.exec("ROLLBACK");
|
|
430
|
+
throw runtimeError(
|
|
431
|
+
"project_home_conflict",
|
|
432
|
+
".kxm/project.yaml",
|
|
433
|
+
`project ${registration.projectId} home runtime is immutable and cannot be rebound`,
|
|
434
|
+
);
|
|
435
|
+
}
|
|
436
|
+
const sameRepo = live.filter((row) => commonByRoot.get(row.project_root) === candidateCommon);
|
|
437
|
+
const home = sameRepo.find((row) => row.lane_of === null);
|
|
438
|
+
const registeringIsPrimary = isPrimaryWorktree(projectRoot, candidateCommon);
|
|
439
|
+
const homeIsPrimary = home !== undefined && isPrimaryWorktree(home.project_root, commonByRoot.get(home.project_root));
|
|
440
|
+
if (registeringIsPrimary && !homeIsPrimary) {
|
|
441
|
+
this.insertHome(registration, projectRoot, projectKey);
|
|
442
|
+
for (const row of sameRepo) {
|
|
443
|
+
this.database.prepare("UPDATE projects SET lane_of = ? WHERE project_key = ?").run(projectKey, row.project_key);
|
|
444
|
+
}
|
|
445
|
+
this.database.exec("COMMIT");
|
|
446
|
+
registration.logger?.({
|
|
447
|
+
event: "project_home_promoted",
|
|
448
|
+
projectId: registration.projectId,
|
|
449
|
+
projectRoot,
|
|
450
|
+
homeRuntimeId: registration.homeRuntimeId,
|
|
451
|
+
previousHomeRoot: home?.project_root ?? null,
|
|
452
|
+
previousHomeKey: home?.project_key ?? null,
|
|
453
|
+
});
|
|
454
|
+
return this.insertedRegistration(registration, projectRoot, projectKey);
|
|
455
|
+
}
|
|
456
|
+
if (registeringIsPrimary) {
|
|
457
|
+
this.database.exec("ROLLBACK");
|
|
458
|
+
throw runtimeError(
|
|
459
|
+
"project_home_conflict",
|
|
460
|
+
".kxm/project.yaml",
|
|
461
|
+
`project ${registration.projectId} primary checkout is already the home row`,
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
const laneOf = anchor.lane_of ?? anchor.project_key;
|
|
465
|
+
this.database.prepare(`
|
|
466
|
+
INSERT INTO projects (project_key, project_id, project_root, home_runtime_id, config_revision, registered_at, lane_of)
|
|
467
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
468
|
+
`).run(projectKey, registration.projectId, projectRoot, registration.homeRuntimeId, registration.configRevision ?? null, registration.now, laneOf);
|
|
469
|
+
const result = this.insertedRegistration(registration, projectRoot, projectKey, laneOf);
|
|
470
|
+
this.database.exec("COMMIT");
|
|
471
|
+
return result;
|
|
472
|
+
} else if (dead.length > 0) {
|
|
473
|
+
const replaced = dead[0] as ProjectRow;
|
|
474
|
+
if (dead.some((row) => row.home_runtime_id !== registration.homeRuntimeId)) {
|
|
475
|
+
this.database.exec("ROLLBACK");
|
|
476
|
+
throw runtimeError(
|
|
477
|
+
"project_home_conflict",
|
|
478
|
+
".kxm/project.yaml",
|
|
479
|
+
`project ${registration.projectId} home runtime is immutable and cannot be rebound`,
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
this.database.prepare("DELETE FROM projects WHERE project_id = ?").run(registration.projectId);
|
|
483
|
+
this.insertHome(registration, projectRoot, projectKey);
|
|
484
|
+
this.database.exec("COMMIT");
|
|
485
|
+
registration.logger?.({
|
|
486
|
+
event: "project_registration_replaced",
|
|
487
|
+
projectId: registration.projectId,
|
|
488
|
+
projectRoot,
|
|
489
|
+
replacedRoot: replaced.project_root,
|
|
490
|
+
replacedCount: dead.length,
|
|
491
|
+
replacedHomeRuntimeId: replaced.home_runtime_id,
|
|
492
|
+
replacedProjectKey: replaced.project_key,
|
|
493
|
+
});
|
|
494
|
+
return this.insertedRegistration(registration, projectRoot, projectKey);
|
|
495
|
+
} else {
|
|
496
|
+
this.insertHome(registration, projectRoot, projectKey);
|
|
497
|
+
this.database.exec("COMMIT");
|
|
498
|
+
return this.insertedRegistration(registration, projectRoot, projectKey);
|
|
251
499
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
projectKey: existing.project_key,
|
|
256
|
-
homeRuntimeId: existing.home_runtime_id,
|
|
257
|
-
...(existing.config_revision !== null ? { configRevision: existing.config_revision } : {}),
|
|
258
|
-
registeredAt: existing.registered_at,
|
|
259
|
-
};
|
|
260
|
-
this.database.exec("COMMIT");
|
|
261
|
-
return result;
|
|
500
|
+
} catch (error) {
|
|
501
|
+
try { this.database.exec("ROLLBACK"); } catch { /* already rolled back */ }
|
|
502
|
+
throw error;
|
|
262
503
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
504
|
+
for (const root of rootsToMeasure ?? []) {
|
|
505
|
+
commonByRoot.set(root, gitCommonDirectory(root));
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
throw runtimeError(
|
|
509
|
+
"runtime_registry_busy",
|
|
510
|
+
projectRoot,
|
|
511
|
+
`project ${registration.projectId} registration could not be rechecked`,
|
|
512
|
+
);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Remove one control root. Returns false when that root was not registered. */
|
|
516
|
+
unregisterProject(projectRoot: string): boolean {
|
|
517
|
+
this.database.exec("BEGIN IMMEDIATE");
|
|
518
|
+
try {
|
|
519
|
+
const removed = this.deleteRegisteredRoot(projectRoot);
|
|
267
520
|
this.database.exec("COMMIT");
|
|
268
|
-
return
|
|
269
|
-
projectId: registration.projectId,
|
|
270
|
-
projectRoot,
|
|
271
|
-
projectKey,
|
|
272
|
-
homeRuntimeId: registration.homeRuntimeId,
|
|
273
|
-
...(registration.configRevision !== undefined ? { configRevision: registration.configRevision } : {}),
|
|
274
|
-
registeredAt: registration.now,
|
|
275
|
-
};
|
|
521
|
+
return removed;
|
|
276
522
|
} catch (error) {
|
|
277
523
|
try { this.database.exec("ROLLBACK"); } catch { /* already rolled back */ }
|
|
278
524
|
throw error;
|
|
279
525
|
}
|
|
280
526
|
}
|
|
281
527
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
528
|
+
/**
|
|
529
|
+
* Delete the row only when `supervisorIsLive` is false for the supervisor
|
|
530
|
+
* row read inside this write transaction. A live supervisor keeps the row;
|
|
531
|
+
* the caller uses the supervisor route instead of editing the file.
|
|
532
|
+
*/
|
|
533
|
+
unregisterProjectIfIdle(
|
|
534
|
+
projectRoot: string,
|
|
535
|
+
supervisorIsLive: (record: KxmSupervisorRecord | undefined) => boolean,
|
|
536
|
+
): "removed" | "absent" | "supervisor_live" {
|
|
537
|
+
this.database.exec("BEGIN IMMEDIATE");
|
|
538
|
+
try {
|
|
539
|
+
if (supervisorIsLive(this.readSupervisorRow())) {
|
|
540
|
+
this.database.exec("ROLLBACK");
|
|
541
|
+
return "supervisor_live";
|
|
294
542
|
}
|
|
295
|
-
|
|
543
|
+
const removed = this.deleteRegisteredRoot(projectRoot);
|
|
544
|
+
this.database.exec("COMMIT");
|
|
545
|
+
return removed ? "removed" : "absent";
|
|
546
|
+
} catch (error) {
|
|
547
|
+
try { this.database.exec("ROLLBACK"); } catch { /* already rolled back */ }
|
|
548
|
+
throw error;
|
|
549
|
+
}
|
|
296
550
|
}
|
|
297
551
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
const row = this.
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
552
|
+
/** How many other rows share this home root's project. Zero when this root is a lane or missing. */
|
|
553
|
+
homeLaneCount(projectRoot: string): number {
|
|
554
|
+
const row = this.projectRow(`SELECT ${PROJECT_COLUMNS} FROM projects WHERE project_key = ?`, projectRuntimeKey(resolve(projectRoot)));
|
|
555
|
+
if (!row || row.lane_of !== null) return 0;
|
|
556
|
+
const count = this.database.prepare(
|
|
557
|
+
"SELECT COUNT(*) AS total FROM projects WHERE project_id = ? AND project_key != ?",
|
|
558
|
+
).get(row.project_id, row.project_key) as { total: number } | undefined;
|
|
559
|
+
return Number(count?.total ?? 0);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** Caller holds the write transaction. Does not commit. */
|
|
563
|
+
private deleteRegisteredRoot(projectRoot: string): boolean {
|
|
564
|
+
const projectKey = projectRuntimeKey(resolve(projectRoot));
|
|
565
|
+
const row = this.projectRow(`SELECT ${PROJECT_COLUMNS} FROM projects WHERE project_key = ?`, projectKey);
|
|
566
|
+
if (!row) return false;
|
|
567
|
+
this.database.prepare("DELETE FROM projects WHERE project_key = ?").run(projectKey);
|
|
568
|
+
if (row.lane_of === null) {
|
|
569
|
+
const successor = this.database.prepare(
|
|
570
|
+
"SELECT project_key FROM projects WHERE project_id = ? ORDER BY registered_at LIMIT 1",
|
|
571
|
+
).get(row.project_id) as { project_key: string } | undefined;
|
|
572
|
+
if (successor) {
|
|
573
|
+
this.database.prepare("UPDATE projects SET lane_of = NULL WHERE project_key = ?").run(successor.project_key);
|
|
574
|
+
this.database.prepare(
|
|
575
|
+
"UPDATE projects SET lane_of = ? WHERE project_id = ? AND project_key != ? AND (lane_of IS NULL OR lane_of = ?)",
|
|
576
|
+
).run(successor.project_key, row.project_id, successor.project_key, projectKey);
|
|
311
577
|
}
|
|
312
|
-
|
|
578
|
+
}
|
|
579
|
+
return true;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/** The home row for a project id, or the earliest row when every row is a lane. */
|
|
583
|
+
project(projectId: string): KxmProjectRegistration | undefined {
|
|
584
|
+
const row = this.projectRow(
|
|
585
|
+
`SELECT ${PROJECT_COLUMNS} FROM projects WHERE project_id = ? ORDER BY CASE WHEN lane_of IS NULL THEN 0 ELSE 1 END, registered_at LIMIT 1`,
|
|
586
|
+
projectId,
|
|
587
|
+
);
|
|
588
|
+
return row ? registrationFromRow(row) : undefined;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
projectByRoot(projectRoot: string): KxmProjectRegistration | undefined {
|
|
592
|
+
const row = this.projectRow(`SELECT ${PROJECT_COLUMNS} FROM projects WHERE project_key = ?`, projectRuntimeKey(projectRoot));
|
|
593
|
+
return row ? registrationFromRow(row) : undefined;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
private projectRow(sql: string, parameter: string): ProjectRow | undefined {
|
|
597
|
+
return this.database.prepare(sql).get(parameter) as ProjectRow | undefined;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
private insertHome(
|
|
601
|
+
registration: { projectId: string; homeRuntimeId: string; configRevision?: string; now: string },
|
|
602
|
+
projectRoot: string,
|
|
603
|
+
projectKey: string,
|
|
604
|
+
): void {
|
|
605
|
+
this.database.prepare(`
|
|
606
|
+
INSERT INTO projects (project_key, project_id, project_root, home_runtime_id, config_revision, registered_at, lane_of)
|
|
607
|
+
VALUES (?, ?, ?, ?, ?, ?, NULL)
|
|
608
|
+
`).run(projectKey, registration.projectId, projectRoot, registration.homeRuntimeId, registration.configRevision ?? null, registration.now);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
private insertedRegistration(
|
|
612
|
+
registration: { projectId: string; homeRuntimeId: string; configRevision?: string; now: string },
|
|
613
|
+
projectRoot: string,
|
|
614
|
+
projectKey: string,
|
|
615
|
+
laneOf?: string,
|
|
616
|
+
): KxmProjectRegistration {
|
|
617
|
+
return {
|
|
618
|
+
projectId: registration.projectId,
|
|
619
|
+
projectRoot,
|
|
620
|
+
projectKey,
|
|
621
|
+
homeRuntimeId: registration.homeRuntimeId,
|
|
622
|
+
...(registration.configRevision !== undefined ? { configRevision: registration.configRevision } : {}),
|
|
623
|
+
...(laneOf !== undefined ? { laneOf } : {}),
|
|
624
|
+
registeredAt: registration.now,
|
|
625
|
+
};
|
|
313
626
|
}
|
|
314
627
|
}
|
|
315
628
|
|
|
@@ -6,13 +6,13 @@ import { dirname, isAbsolute, join } from "node:path";
|
|
|
6
6
|
import { findKxmRepoRoot } from "./repo-root.ts";
|
|
7
7
|
import { loadKxmProject, KxmConfigError, type KxmConfigOptions } from "./project-config.ts";
|
|
8
8
|
import {
|
|
9
|
+
KxmRunEventStore,
|
|
9
10
|
KxmRuntimeRegistry,
|
|
10
11
|
projectRuntimeKey,
|
|
11
12
|
readKxmSupervisorRecord,
|
|
12
13
|
runtimeError,
|
|
13
14
|
verifyKxmDriveReceipt,
|
|
14
15
|
kxmRuntimePaths,
|
|
15
|
-
type KxmRunEventStore,
|
|
16
16
|
type KxmRuntimePaths,
|
|
17
17
|
type KxmSupervisorRecord,
|
|
18
18
|
} from "./runtime-store.ts";
|
|
@@ -126,6 +126,28 @@ function readRecentSupervisorError(paths: KxmRuntimePaths): string | undefined {
|
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
128
|
|
|
129
|
+
const SETTLED_RUN_STATUS = new Set(["completed", "failed", "cancelled"]);
|
|
130
|
+
|
|
131
|
+
/** Same liveness rule as `kxmSupervisorStatus`: running, a fresh heartbeat, and a live pid. */
|
|
132
|
+
export function kxmSupervisorRecordIsLive(record: KxmSupervisorRecord | undefined): boolean {
|
|
133
|
+
return supervisorStatusOf(record).running;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function unsettledRunIds(context: KxmRuntimeContext): string[] {
|
|
137
|
+
const ids: string[] = [];
|
|
138
|
+
for (const stored of context.eventStore.runsForProject(context.projectId, 10_000)) {
|
|
139
|
+
let status: string = stored.status;
|
|
140
|
+
try {
|
|
141
|
+
status = projectKxmRunReadOnly(context, stored.runId).status;
|
|
142
|
+
} catch {
|
|
143
|
+
ids.push(stored.runId);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (!SETTLED_RUN_STATUS.has(status)) ids.push(stored.runId);
|
|
147
|
+
}
|
|
148
|
+
return ids;
|
|
149
|
+
}
|
|
150
|
+
|
|
129
151
|
function supervisorStatusOf(record: KxmSupervisorRecord | undefined): KxmSupervisorStatus {
|
|
130
152
|
if (!record) return { running: false };
|
|
131
153
|
// Pid-only liveness is not enough: after a crash the pid may be reused by
|
|
@@ -781,6 +803,65 @@ async function startKxmRuntimeSupervisorInner(
|
|
|
781
803
|
return;
|
|
782
804
|
}
|
|
783
805
|
|
|
806
|
+
if (request.method === "POST" && url.pathname === "/v1/projects/unregister") {
|
|
807
|
+
const body = await readJsonBody(request);
|
|
808
|
+
const projectRoot = typeof body.projectRoot === "string" ? body.projectRoot : "";
|
|
809
|
+
const force = body.force === true;
|
|
810
|
+
if (!projectRoot || !isAbsolute(projectRoot)) {
|
|
811
|
+
sendJson(response, 400, { ok: false, error: "runtime_request_invalid", message: "projectRoot must be an absolute path" });
|
|
812
|
+
return;
|
|
813
|
+
}
|
|
814
|
+
if (!force) {
|
|
815
|
+
const key = projectRuntimeKey(projectRoot);
|
|
816
|
+
const existing = contexts.get(key);
|
|
817
|
+
let openRunIds: string[] = [];
|
|
818
|
+
if (existing) {
|
|
819
|
+
openRunIds = unsettledRunIds(existing);
|
|
820
|
+
} else {
|
|
821
|
+
const registration = registry.projectByRoot(projectRoot);
|
|
822
|
+
const eventsPath = join(paths.projectsDir, registration?.projectKey ?? projectRuntimeKey(projectRoot), "run-events.db");
|
|
823
|
+
if (registration && existsSync(eventsPath)) {
|
|
824
|
+
const store = new KxmRunEventStore(eventsPath);
|
|
825
|
+
try {
|
|
826
|
+
openRunIds = store.runsForProject(registration.projectId, 10_000)
|
|
827
|
+
.filter((run) => !SETTLED_RUN_STATUS.has(run.status))
|
|
828
|
+
.map((run) => run.runId);
|
|
829
|
+
} finally {
|
|
830
|
+
store.close();
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
if (openRunIds.length > 0) {
|
|
835
|
+
sendJson(response, 409, {
|
|
836
|
+
ok: false,
|
|
837
|
+
error: "runtime_project_busy",
|
|
838
|
+
message: `unsettled run ${openRunIds.join(", ")}`,
|
|
839
|
+
runIds: openRunIds,
|
|
840
|
+
});
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
const laneCount = registry.homeLaneCount(projectRoot);
|
|
844
|
+
if (laneCount > 0) {
|
|
845
|
+
sendJson(response, 409, {
|
|
846
|
+
ok: false,
|
|
847
|
+
error: "runtime_project_has_lanes",
|
|
848
|
+
message: `home root still has ${laneCount} lane(s)`,
|
|
849
|
+
});
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
const key = projectRuntimeKey(projectRoot);
|
|
854
|
+
const existing = contexts.get(key);
|
|
855
|
+
const unregistered = registry.unregisterProject(projectRoot);
|
|
856
|
+
if (existing) {
|
|
857
|
+
contexts.delete(key);
|
|
858
|
+
syncStatuses.delete(key);
|
|
859
|
+
closeKxmRuntimeContext(existing);
|
|
860
|
+
}
|
|
861
|
+
sendJson(response, 200, { ok: true, unregistered });
|
|
862
|
+
return;
|
|
863
|
+
}
|
|
864
|
+
|
|
784
865
|
if (request.method === "POST" && url.pathname === "/v1/runs") {
|
|
785
866
|
const body = await readJsonBody(request);
|
|
786
867
|
const projectRoot = typeof body.projectRoot === "string" ? body.projectRoot : "";
|
|
@@ -1026,9 +1107,12 @@ async function startKxmRuntimeSupervisorInner(
|
|
|
1026
1107
|
// that returned raw rows would let every consumer — including the portal's
|
|
1027
1108
|
// tenant read — present cached status as authoritative. Folding replays each
|
|
1028
1109
|
// run's events; workflows are transition-bounded, so this stays cheap at the
|
|
1029
|
-
// 50-run cap. A run that refuses to fold is returned with its cached row plus
|
|
1110
|
+
// default 50-run cap (`limit` may ask for up to 10000). A run that refuses to fold is returned with its cached row plus
|
|
1030
1111
|
// `projectionError`, so one corrupt run cannot make the listing lie by omission.
|
|
1031
|
-
const
|
|
1112
|
+
const limitText = url.searchParams.get("limit");
|
|
1113
|
+
const parsedLimit = limitText === null ? 50 : Number(limitText);
|
|
1114
|
+
const limit = Number.isInteger(parsedLimit) && parsedLimit > 0 ? Math.min(parsedLimit, 10_000) : 50;
|
|
1115
|
+
const runs = context.eventStore.runsForProject(requestedProjectId, limit).map((stored) => {
|
|
1032
1116
|
try {
|
|
1033
1117
|
return projectKxmRunReadOnly(context, stored.runId);
|
|
1034
1118
|
} catch (error) {
|