@kontextmind/kxm 0.7.71 → 0.7.73

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.
@@ -4,6 +4,7 @@ import { dirname, join, resolve } from "node:path";
4
4
  import { DatabaseSync } from "./sqlite.ts";
5
5
  import { KxmConfigError, validateCoordinator, validateDriveReceipt, validateIntakeMessage, validateRunEvent, kxmCanonicalJson, type JsonValue, type KxmConfigIssue, type KxmConfigOptions } from "./project-config.ts";
6
6
  import { kxmUserStateRoot } from "./bindings.ts";
7
+ import { deriveKxmSyncEvent, kxmSyncEventBytes, KxmSyncRedactor } from "./sync-transform.ts";
7
8
 
8
9
  /* ------------------------------------------------------------------ *
9
10
  * Runtime registry (per-user, platform state root)
@@ -225,6 +226,16 @@ export class KxmRuntimeRegistry {
225
226
  }
226
227
 
227
228
  /** Register or revalidate a project's home binding. Home Runtime is immutable. */
229
+ /** All projects registered to this Runtime, for restart recovery: the
230
+ * supervisor needs to reopen their contexts so pending outbox rows resume
231
+ * syncing and presence keeps beating. */
232
+ projectsForRuntime(homeRuntimeId: string): Array<{ projectRoot: string; projectId: string }> {
233
+ const rows = this.database.prepare(
234
+ "SELECT project_root, project_id FROM projects WHERE home_runtime_id = ? ORDER BY registered_at",
235
+ ).all(homeRuntimeId) as Array<{ project_root: string; project_id: string }>;
236
+ return rows.map((row) => ({ projectRoot: row.project_root, projectId: row.project_id }));
237
+ }
238
+
228
239
  registerProject(registration: { projectId: string; projectRoot: string; homeRuntimeId: string; configRevision?: string; now: string }): KxmProjectRegistration {
229
240
  const projectRoot = resolve(registration.projectRoot);
230
241
  const projectKey = projectRuntimeKey(projectRoot);
@@ -550,7 +561,7 @@ export interface KxmCommandRecord {
550
561
  recordedAt: string;
551
562
  }
552
563
 
553
- export const KXM_EVENT_STORE_SCHEMA_VERSION = 5;
564
+ export const KXM_EVENT_STORE_SCHEMA_VERSION = 6;
554
565
  export const KXM_DRIVE_RECEIPT_SCHEMA = "kxm.drive-receipt.v1";
555
566
  export const DRIVE_RECEIPT_MAX_BYTES = 8 * 1024;
556
567
 
@@ -586,6 +597,7 @@ const EVENT_STORE_TABLES = {
586
597
  "schema", "record",
587
598
  ],
588
599
  project_controls: ["project_id", "paused", "reason", "updated_at", "actor", "schema", "record"],
600
+ outbox: ["seq", "run_id", "sequence", "sync_event", "attempted_at", "acked_at"],
589
601
  } as const;
590
602
 
591
603
  /**
@@ -780,8 +792,31 @@ CREATE TABLE project_controls (
780
792
  schema TEXT NOT NULL,
781
793
  record TEXT NOT NULL
782
794
  ) STRICT;
795
+ CREATE TABLE IF NOT EXISTS outbox (
796
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
797
+ run_id TEXT NOT NULL,
798
+ sequence INTEGER NOT NULL,
799
+ sync_event TEXT NOT NULL,
800
+ attempted_at TEXT,
801
+ acked_at TEXT,
802
+ UNIQUE (run_id, sequence)
803
+ ) STRICT;
804
+ CREATE INDEX outbox_pending ON outbox(seq) WHERE acked_at IS NULL;
783
805
  `;
784
806
 
807
+ /**
808
+ * One outbox row: the already-derived `kxm.sync-event.v1` bytes plus retry
809
+ * transport metadata. The local source payload is never kept here.
810
+ */
811
+ export interface KxmOutboxRow {
812
+ seq: number;
813
+ runId: string;
814
+ sequence: number;
815
+ syncEvent: string;
816
+ attemptedAt?: string;
817
+ ackedAt?: string;
818
+ }
819
+
785
820
  /** One persisted coordinator identity (`kxm.coordinator.v1`). */
786
821
  export interface KxmCoordinatorRow {
787
822
  coordinatorId: string;
@@ -818,6 +853,9 @@ export interface KxmProjectControlRow {
818
853
 
819
854
  export class KxmRunEventStore {
820
855
  readonly path: string;
856
+ /** Secret values registered here are replaced in every sync object this
857
+ * store derives. In memory only: they are never written anywhere. */
858
+ readonly syncRedactor = new KxmSyncRedactor();
821
859
  private readonly database: DatabaseSync;
822
860
 
823
861
  constructor(path: string) {
@@ -933,6 +971,16 @@ export class KxmRunEventStore {
933
971
  return row ? runFromRow(row) : undefined;
934
972
  }
935
973
 
974
+ /** All projects registered to this Runtime, for restart recovery: the
975
+ * supervisor needs to reopen their contexts so pending outbox rows resume
976
+ * syncing and presence keeps beating. */
977
+ projectsForRuntime(homeRuntimeId: string): Array<{ projectRoot: string; projectId: string }> {
978
+ const rows = this.database.prepare(
979
+ "SELECT project_root, project_id FROM projects WHERE home_runtime_id = ? ORDER BY registered_at",
980
+ ).all(homeRuntimeId) as Array<{ project_root: string; project_id: string }>;
981
+ return rows.map((row) => ({ projectRoot: row.project_root, projectId: row.project_id }));
982
+ }
983
+
936
984
  runsForProject(projectId: string, limit = 50): KxmRunRecord[] {
937
985
  const rows = this.database.prepare(`
938
986
  SELECT run_id, project_id, home_runtime_id, workflow_id, prompt_sha256, status, config_revision, memory_revision, executor_policy_revision, tool_policy_revision, created_at, updated_at
@@ -974,6 +1022,46 @@ export class KxmRunEventStore {
974
1022
  event.schema,
975
1023
  event.homeRuntimeId,
976
1024
  );
1025
+ // Allowlist before outbox: the row holds only the derived sync object, and
1026
+ // it commits (or rolls back) with the event in the caller's transaction.
1027
+ const syncEvent = deriveKxmSyncEvent(event, { redactor: this.syncRedactor });
1028
+ this.database.prepare("INSERT INTO outbox (run_id, sequence, sync_event) VALUES (?, ?, ?)")
1029
+ .run(event.runId, event.sequence, kxmSyncEventBytes(syncEvent));
1030
+ }
1031
+
1032
+ /** Unacknowledged outbox rows after `afterSeq`, in outbox order, oldest first. */
1033
+ pendingOutbox(limit = 100, afterSeq = 0): KxmOutboxRow[] {
1034
+ const rows = this.database.prepare(`
1035
+ SELECT seq, run_id, sequence, sync_event, attempted_at, acked_at
1036
+ FROM outbox WHERE acked_at IS NULL AND seq > ? ORDER BY seq ASC LIMIT ?
1037
+ `).all(afterSeq, limit) as OutboxSqlRow[];
1038
+ return rows.map(outboxFromRow);
1039
+ }
1040
+
1041
+ /** Every outbox row of one run, acknowledged or not, in sequence order. */
1042
+ outboxForRun(runId: string): KxmOutboxRow[] {
1043
+ const rows = this.database.prepare(`
1044
+ SELECT seq, run_id, sequence, sync_event, attempted_at, acked_at
1045
+ FROM outbox WHERE run_id = ? ORDER BY sequence ASC
1046
+ `).all(runId) as OutboxSqlRow[];
1047
+ return rows.map(outboxFromRow);
1048
+ }
1049
+
1050
+ markOutboxAttempted(seqs: readonly number[], at: string): void {
1051
+ const statement = this.database.prepare("UPDATE outbox SET attempted_at = ? WHERE seq = ? AND acked_at IS NULL");
1052
+ this.transaction(() => {
1053
+ for (const seq of seqs) statement.run(at, seq);
1054
+ });
1055
+ }
1056
+
1057
+ /** Advance the cursor: the hub holds these rows now. Returns rows newly acked. */
1058
+ ackOutbox(seqs: readonly number[], at: string): number {
1059
+ const statement = this.database.prepare("UPDATE outbox SET acked_at = ? WHERE seq = ? AND acked_at IS NULL");
1060
+ return this.transaction(() => {
1061
+ let changed = 0;
1062
+ for (const seq of seqs) changed += Number(statement.run(at, seq).changes);
1063
+ return changed;
1064
+ });
977
1065
  }
978
1066
 
979
1067
  events(runId: string, afterSequence = 0, limit = 200): KxmRunEvent[] {
@@ -1585,6 +1673,26 @@ function parseDriveReceipt(raw: string): KxmDriveReceipt {
1585
1673
  return parsed as KxmDriveReceipt;
1586
1674
  }
1587
1675
 
1676
+ type OutboxSqlRow = {
1677
+ seq: number;
1678
+ run_id: string;
1679
+ sequence: number;
1680
+ sync_event: string;
1681
+ attempted_at: string | null;
1682
+ acked_at: string | null;
1683
+ };
1684
+
1685
+ function outboxFromRow(row: OutboxSqlRow): KxmOutboxRow {
1686
+ return {
1687
+ seq: row.seq,
1688
+ runId: row.run_id,
1689
+ sequence: row.sequence,
1690
+ syncEvent: row.sync_event,
1691
+ ...(row.attempted_at !== null ? { attemptedAt: row.attempted_at } : {}),
1692
+ ...(row.acked_at !== null ? { ackedAt: row.acked_at } : {}),
1693
+ };
1694
+ }
1695
+
1588
1696
  function runFromRow(row: {
1589
1697
  run_id: string;
1590
1698
  project_id: string;
@@ -11,6 +11,7 @@ import {
11
11
  runtimeError,
12
12
  verifyKxmDriveReceipt,
13
13
  kxmRuntimePaths,
14
+ type KxmRunEventStore,
14
15
  type KxmRuntimePaths,
15
16
  } from "./runtime-store.ts";
16
17
  import {
@@ -26,6 +27,10 @@ import { createKxmOneShotProducer } from "./oneshot-producer.ts";
26
27
  import { isRouteAdmitted } from "./routes.ts";
27
28
  import { KxmRunScheduler, createKxmSimulatedProducer, recordDriveReceipt, recoverKxmRun, kxmDrivePollProjection } from "./engine.ts";
28
29
  import { kxmDriveSession, kxmOpenDriveSessions } from "./runtime-owner.ts";
30
+ import { RuntimeHubClient } from "./client.ts";
31
+ import { readHubBinding } from "./hub-binding.ts";
32
+ import { resolveClientHubAuthToken } from "./hub-env.ts";
33
+ import { defaultProjectName } from "./project-name.ts";
29
34
 
30
35
  /* ------------------------------------------------------------------ *
31
36
  * Token management
@@ -351,6 +356,96 @@ async function driveSessionStillPending(settled: Promise<unknown>): Promise<bool
351
356
  return pending;
352
357
  }
353
358
 
359
+ /* ------------------------------------------------------------------ *
360
+ * Runtime → hub sync (P5): outbound only
361
+ * ------------------------------------------------------------------ */
362
+
363
+ export const DEFAULT_RUNTIME_SYNC_INTERVAL_MS = 10_000;
364
+ const MIN_RUNTIME_SYNC_INTERVAL_MS = 250;
365
+ const MAX_RUNTIME_SYNC_INTERVAL_MS = 60_000;
366
+ const OUTBOX_PUSH_BATCH = 32;
367
+
368
+ /** How often the supervisor heartbeats and pushes its outbox. Keep it well
369
+ * under the hub's presence lease (30 s by default). */
370
+ export function runtimeSyncIntervalMs(env: NodeJS.ProcessEnv = process.env): number {
371
+ const raw = env.KXM_RUNTIME_SYNC_INTERVAL_MS?.trim();
372
+ if (!raw) return DEFAULT_RUNTIME_SYNC_INTERVAL_MS;
373
+ const parsed = Number(raw);
374
+ if (!Number.isInteger(parsed)) return DEFAULT_RUNTIME_SYNC_INTERVAL_MS;
375
+ return Math.min(MAX_RUNTIME_SYNC_INTERVAL_MS, Math.max(MIN_RUNTIME_SYNC_INTERVAL_MS, parsed));
376
+ }
377
+
378
+ export interface KxmOutboxSyncResult {
379
+ pushed: number;
380
+ acked: number;
381
+ conflicts: number;
382
+ rejected: number;
383
+ }
384
+
385
+ /**
386
+ * Push every pending outbox row to the bound hub in outbox order and advance
387
+ * the cursor on each acknowledgement. Accepted and duplicate rows are acked;
388
+ * a conflict or rejection stays pending (the hub has raised the alert) and is
389
+ * skipped for the rest of this pass. A transport failure leaves every row
390
+ * pending for a safe retry.
391
+ */
392
+ export async function syncKxmOutbox(
393
+ eventStore: KxmRunEventStore,
394
+ client: RuntimeHubClient,
395
+ options: { now?: () => string; batchSize?: number } = {},
396
+ ): Promise<KxmOutboxSyncResult> {
397
+ const now = options.now ?? (() => new Date().toISOString());
398
+ const batchSize = options.batchSize ?? OUTBOX_PUSH_BATCH;
399
+ const result: KxmOutboxSyncResult = { pushed: 0, acked: 0, conflicts: 0, rejected: 0 };
400
+ let afterSeq = 0;
401
+ for (;;) {
402
+ const rows = eventStore.pendingOutbox(batchSize, afterSeq);
403
+ if (rows.length === 0) return result;
404
+ // Batch by serialized byte size, not just count: the hub rejects requests
405
+ // over its body ceiling (HTTP 413), and retrying the same oversized batch
406
+ // would permanently block the queue. Trim to the byte budget and leave the
407
+ // rest for the next iteration.
408
+ const MAX_BATCH_BYTES = 200_000; // hub ceiling is 256 KiB; leave headroom
409
+ let byteBudget = MAX_BATCH_BYTES;
410
+ let sendCount = 0;
411
+ for (const row of rows) {
412
+ const rowBytes = Buffer.byteLength(row.syncEvent, "utf8") + 64; // JSON overhead
413
+ if (sendCount > 0 && byteBudget - rowBytes < 0) break;
414
+ byteBudget -= rowBytes;
415
+ sendCount += 1;
416
+ }
417
+ const batch = rows.slice(0, sendCount);
418
+ if (batch.length === 0) batch.push(rows[0]!); // one oversized row: send alone, hub will 413
419
+ afterSeq = batch[batch.length - 1]!.seq;
420
+ eventStore.markOutboxAttempted(batch.map((row) => row.seq), now());
421
+ const response = await client.pushSyncEvents(batch.map((row) => JSON.parse(row.syncEvent) as unknown));
422
+ result.pushed += batch.length;
423
+ const outcomes = new Map(response.results.map((entry) => [`${entry.runId}\u0000${entry.sequence}`, entry.outcome]));
424
+ const acked: number[] = [];
425
+ for (const row of batch) {
426
+ const outcome = outcomes.get(`${row.runId}\u0000${row.sequence}`);
427
+ if (outcome === "accepted" || outcome === "duplicate") acked.push(row.seq);
428
+ else if (outcome === "conflict") result.conflicts += 1;
429
+ else result.rejected += 1;
430
+ }
431
+ result.acked += eventStore.ackOutbox(acked, now());
432
+ }
433
+ }
434
+
435
+ /** Where this Runtime reports one project, or nothing when no hub is bound. */
436
+ function runtimeHubClientFor(context: KxmRuntimeContext, env: NodeJS.ProcessEnv): RuntimeHubClient | undefined {
437
+ const serverUrl = env.KXM_SERVER_URL?.trim() || readHubBinding(env)?.url;
438
+ if (!serverUrl) return undefined;
439
+ const project = defaultProjectName(context.projectRoot, env);
440
+ const authToken = resolveClientHubAuthToken(env, project);
441
+ return new RuntimeHubClient({
442
+ serverUrl,
443
+ project,
444
+ runtimeId: context.homeRuntimeId,
445
+ ...(authToken ? { authToken } : {}),
446
+ });
447
+ }
448
+
354
449
  export interface KxmRuntimeSupervisor {
355
450
  server: Server;
356
451
  port: number;
@@ -395,6 +490,21 @@ async function startKxmRuntimeSupervisorInner(
395
490
  let activeRuntimeId = runtimeId;
396
491
 
397
492
  const contexts = new Map<string, KxmRuntimeContext>();
493
+ const registerSyncCredentials = (context: KxmRuntimeContext): void => {
494
+ // Register credentials on the store's redactor at context creation —
495
+ // BEFORE any event can be appended — so the very first outbox row is
496
+ // already scrubbed. Registering on the sync tick leaves a window where
497
+ // appended events retain credentials.
498
+ const hubToken = resolveClientHubAuthToken(process.env, defaultProjectName(context.projectRoot, process.env));
499
+ if (hubToken) context.eventStore.syncRedactor.register(hubToken);
500
+ for (const key of Object.keys(process.env)) {
501
+ if ((key.startsWith("KXM_") && (key.endsWith("_TOKEN") || key.endsWith("_KEY"))) || key.endsWith("_API_KEY") || key.endsWith("_SECRET")) {
502
+ const value = process.env[key]?.trim();
503
+ if (value) context.eventStore.syncRedactor.register(value);
504
+ }
505
+ }
506
+ };
507
+
398
508
  const contextFor = (projectRoot: string): KxmRuntimeContext => {
399
509
  if (!isAbsolute(projectRoot)) {
400
510
  throw runtimeError("runtime_request_invalid", "projectRoot", "projectRoot must be an absolute path");
@@ -403,6 +513,7 @@ async function startKxmRuntimeSupervisorInner(
403
513
  const existing = contexts.get(key);
404
514
  if (existing) return existing;
405
515
  const context = openKxmRuntimeContext(projectRoot, { homeRuntimeId: activeRuntimeId, stateRoot: paths.stateRoot });
516
+ registerSyncCredentials(context);
406
517
  contexts.set(key, context);
407
518
  return context;
408
519
  };
@@ -774,11 +885,50 @@ async function startKxmRuntimeSupervisorInner(
774
885
  }, 1000);
775
886
  heartbeat.unref();
776
887
 
888
+ // Outbound only: the supervisor pulls nothing and exposes nothing to the hub.
889
+ // A tick that finds no bound hub, no credential or an unreachable hub does
890
+ // nothing; outbox rows stay pending and local execution never waits on it.
891
+ //
892
+ // Restart recovery: contexts are only populated on demand (a project request
893
+ // opens one), so a restarted supervisor would see an empty map and silently
894
+ // stop syncing every registered project's pending outbox rows. Reopen the
895
+ // projects this Runtime owns before the first tick.
896
+ for (const reg of registry.projectsForRuntime(activeRuntimeId)) {
897
+ try {
898
+ contextFor(reg.projectRoot);
899
+ } catch {
900
+ // A project whose checkout has moved or been deleted stays skipped; its
901
+ // outbox rows remain pending and its presence expires, which is visible
902
+ // in the ops snapshot as orphaned.
903
+ }
904
+ }
905
+
906
+ let syncing = false;
907
+ const syncTimer = setInterval(() => {
908
+ if (syncing || stopping) return;
909
+ syncing = true;
910
+ void (async () => {
911
+ for (const context of [...contexts.values()]) {
912
+
913
+ try {
914
+ const client = runtimeHubClientFor(context, process.env);
915
+ if (!client) continue;
916
+ await client.heartbeat();
917
+ await syncKxmOutbox(context.eventStore, client, { now });
918
+ } catch {
919
+ // Retry on the next tick.
920
+ }
921
+ }
922
+ })().finally(() => { syncing = false; });
923
+ }, runtimeSyncIntervalMs());
924
+ syncTimer.unref();
925
+
777
926
  let stopping = false;
778
927
  const stop = async (): Promise<void> => {
779
928
  if (stopping) return;
780
929
  stopping = true;
781
930
  clearInterval(heartbeat);
931
+ clearInterval(syncTimer);
782
932
  try { registry.markStopping(process.pid, now()); } catch { /* best effort */ }
783
933
  const openSessions = [...contexts.values()].flatMap((context) => (
784
934
  kxmOpenDriveSessions(context.eventStore.path).map((session) => ({ context, session }))