@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.
@@ -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 = 1;
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
- project_id TEXT PRIMARY KEY,
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
- /** Register or revalidate a project's home binding. Home Runtime is immutable. */
221
- /** All projects registered to this Runtime, for restart recovery: the
222
- * supervisor needs to reopen their contexts so pending outbox rows resume
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
- registerProject(registration: { projectId: string; projectRoot: string; homeRuntimeId: string; configRevision?: string; now: string }): KxmProjectRegistration {
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
- this.database.exec("BEGIN IMMEDIATE");
235
- try {
236
- const byId = this.database.prepare("SELECT project_id, project_root, project_key, home_runtime_id, config_revision, registered_at FROM projects WHERE project_id = ?").get(registration.projectId) as
237
- | { project_id: string; project_root: string; project_key: string; home_runtime_id: string; config_revision: string | null; registered_at: string }
238
- | undefined;
239
- const byKey = this.database.prepare("SELECT project_id, project_root, project_key, home_runtime_id, config_revision, registered_at FROM projects WHERE project_key = ?").get(projectKey) as
240
- | { project_id: string; project_root: string; project_key: string; home_runtime_id: string; config_revision: string | null; registered_at: string }
241
- | undefined;
242
- const existing = byId ?? byKey;
243
- if (existing) {
244
- const problems: string[] = [];
245
- if (byId && byId.project_key !== projectKey) problems.push(`project ${registration.projectId} is already bound to a different control root`);
246
- if (byKey && byKey.project_id !== registration.projectId) problems.push(`control root is already bound to a different project id ${byKey.project_id}`);
247
- if (existing.home_runtime_id !== registration.homeRuntimeId) problems.push(`project ${registration.projectId} home runtime is immutable and cannot be rebound`);
248
- if (problems.length > 0) {
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
- throw runtimeError("project_home_conflict", ".kxm/project.yaml", problems.join("; "));
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
- const result: KxmProjectRegistration = {
253
- projectId: existing.project_id,
254
- projectRoot: existing.project_root,
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
- this.database.prepare(`
264
- INSERT INTO projects (project_id, project_root, project_key, home_runtime_id, config_revision, registered_at)
265
- VALUES (?, ?, ?, ?, ?, ?)
266
- `).run(registration.projectId, projectRoot, projectKey, registration.homeRuntimeId, registration.configRevision ?? null, registration.now);
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
- project(projectId: string): KxmProjectRegistration | undefined {
283
- const row = this.database.prepare("SELECT project_id, project_root, project_key, home_runtime_id, config_revision, registered_at FROM projects WHERE project_id = ?").get(projectId) as
284
- | { project_id: string; project_root: string; project_key: string; home_runtime_id: string; config_revision: string | null; registered_at: string }
285
- | undefined;
286
- return row
287
- ? {
288
- projectId: row.project_id,
289
- projectRoot: row.project_root,
290
- projectKey: row.project_key,
291
- homeRuntimeId: row.home_runtime_id,
292
- ...(row.config_revision !== null ? { configRevision: row.config_revision } : {}),
293
- registeredAt: row.registered_at,
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
- : undefined;
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
- projectByRoot(projectRoot: string): KxmProjectRegistration | undefined {
299
- const key = projectRuntimeKey(projectRoot);
300
- const row = this.database.prepare("SELECT project_id, project_root, project_key, home_runtime_id, config_revision, registered_at FROM projects WHERE project_key = ?").get(key) as
301
- | { project_id: string; project_root: string; project_key: string; home_runtime_id: string; config_revision: string | null; registered_at: string }
302
- | undefined;
303
- return row
304
- ? {
305
- projectId: row.project_id,
306
- projectRoot: row.project_root,
307
- projectKey: row.project_key,
308
- homeRuntimeId: row.home_runtime_id,
309
- ...(row.config_revision !== null ? { configRevision: row.config_revision } : {}),
310
- registeredAt: row.registered_at,
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
- : undefined;
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 runs = context.eventStore.runsForProject(requestedProjectId, 50).map((stored) => {
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) {