@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 { createServer, type IncomingMessage, type Server, type ServerResponse }
4
4
  import { isIP } from "node:net";
5
5
  import { dirname, join, resolve } from "node:path";
6
6
  import {
7
+ DEFAULT_LEASE_TTL_MS,
7
8
  DEFAULT_MAX_HOPS,
8
9
  DEFAULT_MESSAGE_RETENTION_MS,
9
10
  DEFAULT_MESSAGE_TTL_MS,
@@ -13,7 +14,11 @@ import {
13
14
  MAX_AGENT_HOST_CHARS,
14
15
  MAX_BODY_BYTES,
15
16
  MAX_CONTENT_CHARS,
17
+ MAX_LEASE_RESOURCE_CHARS,
18
+ MAX_LEASE_TTL_MS,
16
19
  MAX_MESSAGE_TTL_MS,
20
+ MAX_SYNC_BATCH_EVENTS,
21
+ MIN_LEASE_TTL_MS,
17
22
  MIN_MESSAGE_TTL_MS,
18
23
  MIN_MESSAGE_RETENTION_MS,
19
24
  ProtocolError,
@@ -27,9 +32,14 @@ import {
27
32
  type AgentRecord,
28
33
  type DeliveryMode,
29
34
  type HubEvent,
35
+ type LeaseRecord,
30
36
  type MessageRecord,
37
+ type RuntimePresenceRecord,
38
+ type StoredSyncEvent,
31
39
  type WorkflowMessageContext,
32
40
  } from "./protocol.ts";
41
+ import { kxmCanonicalJson, syncEventSchemaErrors, type JsonValue } from "./project-config.ts";
42
+ import { kxmSyncEventHash } from "./sync-transform.ts";
33
43
  import { workflowScopeExtras } from "./diagnostics.ts";
34
44
  import { timingSafeStringCompare } from "./commands.ts";
35
45
  import { arbitrate, explainContextItem, journalEntryToContextItem, memoryRecordToContextItem, rolePolicy } from "./arbiter.ts";
@@ -39,7 +49,7 @@ import { NativeStateProvider } from "./state.ts";
39
49
  import { SkillLifecycle } from "./skills.ts";
40
50
  import { compileKnowledgeWiki, lintKnowledgeWiki, type WikiSourcePool } from "./wiki.ts";
41
51
  import { buildRetrospective, writeRetrospective } from "./retrospective.ts";
42
- import { MeshStore, type StoredAgent } from "./store.ts";
52
+ import { MeshStore, type LeaseRefusal, type StoredAgent } from "./store.ts";
43
53
  import {
44
54
  canonicalWorkflowEvidenceKey,
45
55
  approveWorkflowDegradation,
@@ -91,6 +101,10 @@ export interface MeshHubOptions {
91
101
  skillsDir?: string;
92
102
  skillLifecycle?: SkillLifecycle;
93
103
  repoRoot?: string;
104
+ /** The hub clock every lease decision reads. Injectable so a test can move a
105
+ * lease past its deadline instead of sleeping through a real TTL; production
106
+ * leaves it at `Date.now`. It is never a client-supplied time. */
107
+ now?: () => number;
94
108
  }
95
109
 
96
110
  export interface MeshHub {
@@ -128,10 +142,68 @@ function publicAgent(agent: StoredAgent, staleAfterMs: number, now = Date.now())
128
142
  return toAgentRecord(identity, staleAfterMs, now);
129
143
  }
130
144
 
145
+ /** Runtime ids share the contract's opaque-id shape (`rtm_…`). */
146
+ const RUNTIME_ID_PATTERN = /^[a-z][a-z0-9]{1,15}_[A-Za-z0-9][A-Za-z0-9_-]{5,127}$/;
147
+
148
+ function requireRuntimeId(value: unknown): string {
149
+ const runtimeId = requireString(value, "runtimeId", { max: 144 });
150
+ if (!RUNTIME_ID_PATTERN.test(runtimeId)) {
151
+ throw new ProtocolError(400, "runtimeId must be an opaque runtime id", "invalid_runtime_id");
152
+ }
153
+ return runtimeId;
154
+ }
155
+
156
+ /** A Runtime's presence as readers see it. The lease is the same hub-clocked
157
+ * window agents get: `heartbeatAt + staleAfterMs`. */
158
+ function runtimePresenceView(record: RuntimePresenceRecord, staleAfterMs: number, nowMs: number) {
159
+ const heartbeatMs = Date.parse(record.heartbeatAt);
160
+ const leaseExpiresMs = Number.isFinite(heartbeatMs) ? heartbeatMs + staleAfterMs : 0;
161
+ return {
162
+ runtimeId: record.runtimeId,
163
+ ...(record.host ? { host: record.host } : {}),
164
+ registeredAt: record.registeredAt,
165
+ heartbeatAt: record.heartbeatAt,
166
+ leaseExpiresAt: new Date(leaseExpiresMs).toISOString(),
167
+ presence: leaseExpiresMs > nowMs ? "online" as const : "expired" as const,
168
+ };
169
+ }
170
+
131
171
  function safeTokenEqual(actual: string | undefined, expected: string): boolean {
132
172
  return timingSafeStringCompare(actual, expected);
133
173
  }
134
174
 
175
+ /** The wire answer for a lease the hub will not extend or release. Every one of
176
+ * these means the caller's token no longer authorizes a shared write: the
177
+ * holder must stop, not retry. The current lease rides along so the caller can
178
+ * record who holds the resource now. */
179
+ function leaseRefusal(reason: LeaseRefusal, name: string, lease?: LeaseRecord): ProtocolError {
180
+ if (reason === "missing") {
181
+ return new ProtocolError(404, `no lease is held on ${name}`, "lease_not_found");
182
+ }
183
+ if (reason === "expired") {
184
+ return new ProtocolError(
185
+ 409,
186
+ `lease on ${name} expired at ${lease?.expiresAt ?? "its deadline"}; re-acquire to take it over`,
187
+ "lease_expired",
188
+ { lease },
189
+ );
190
+ }
191
+ if (reason === "held") {
192
+ return new ProtocolError(
193
+ 409,
194
+ `lease on ${name} is held by ${lease?.holderAgentName ?? "another agent"} until ${lease?.expiresAt ?? "its deadline"}`,
195
+ "lease_held",
196
+ { lease },
197
+ );
198
+ }
199
+ return new ProtocolError(
200
+ 409,
201
+ `fencing token for ${name} was superseded; the hub is now at token ${lease?.fencingToken ?? "a newer value"}`,
202
+ "lease_superseded",
203
+ { lease },
204
+ );
205
+ }
206
+
135
207
  function bearerToken(request: IncomingMessage): string | undefined {
136
208
  const header = request.headers.authorization;
137
209
  return header?.startsWith("Bearer ") ? header.slice(7) : undefined;
@@ -409,6 +481,7 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
409
481
  };
410
482
  const webhookWorkflows = new Map((options.webhookWorkflows ?? []).map((workflow) => [workflow.id, workflow]));
411
483
  const logger = options.logger ?? (() => undefined);
484
+ const hubNow = options.now ?? (() => Date.now());
412
485
  const assetsDir = options.assetsDir;
413
486
  const hubRepoRoot = options.repoRoot ?? (options.dataPath && options.dataPath !== ":memory:" ? resolve(dirname(dirname(options.dataPath))) : process.cwd());
414
487
  const store = new MeshStore(options.dataPath);
@@ -461,6 +534,14 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
461
534
  contextRequests: 0,
462
535
  attemptLatencySecondsTotal: 0,
463
536
  meteredCostUsdTotal: 0,
537
+ leasesGranted: 0,
538
+ leasesRefused: 0,
539
+ leasesReleased: 0,
540
+ syncEventsAccepted: 0,
541
+ syncEventsDuplicate: 0,
542
+ syncEventsRefused: 0,
543
+ syncConflicts: 0,
544
+ runtimeHeartbeats: 0,
464
545
  };
465
546
  let cleanupTimer: NodeJS.Timeout | undefined;
466
547
  let closed = false;
@@ -627,6 +708,74 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
627
708
  }
628
709
  }
629
710
 
711
+ /**
712
+ * Synchronized runs grouped by their home Runtime. Built from the gapless
713
+ * prefix of each run only, so a missing sequence is shown as pending and
714
+ * never papered over. `orphaned` is view state: the home Runtime's presence
715
+ * lease has expired (or it never registered). Nothing is migrated.
716
+ */
717
+ function homeRuntimesView(project: string, nowMs: number) {
718
+ const presence = new Map(store.listRuntimePresence(project).map((record) => [record.runtimeId, record]));
719
+ const runs = new Map<string, StoredSyncEvent[]>();
720
+ for (const event of store.listSyncEvents(project)) {
721
+ const key = `${event.projectId}\u0000${event.runId}`;
722
+ const bucket = runs.get(key);
723
+ if (bucket) bucket.push(event);
724
+ else runs.set(key, [event]);
725
+ }
726
+ const homes = new Map<string, Array<Record<string, unknown>>>();
727
+ for (const events of runs.values()) {
728
+ const first = events[0]!;
729
+ let cursor = 0;
730
+ let status: string | undefined;
731
+ let workflowId: string | undefined;
732
+ let displayTitle: string | undefined;
733
+ let updatedAt: string | undefined;
734
+ for (const event of events) {
735
+ if (event.sequence !== cursor + 1) break;
736
+ cursor = event.sequence;
737
+ const parsed = JSON.parse(event.bytes) as { occurredAt?: string; payload?: Record<string, unknown> };
738
+ const payload = parsed.payload ?? {};
739
+ if (event.eventType.startsWith("run.") && typeof payload.status === "string") status = payload.status;
740
+ if (typeof payload.workflowId === "string") workflowId = payload.workflowId;
741
+ if (typeof payload.displayTitle === "string") displayTitle = payload.displayTitle;
742
+ if (typeof parsed.occurredAt === "string") updatedAt = parsed.occurredAt;
743
+ }
744
+ const home = presence.get(first.homeRuntimeId);
745
+ const orphaned = !home || runtimePresenceView(home, staleAfterMs, nowMs).presence === "expired";
746
+ const list = homes.get(first.homeRuntimeId) ?? [];
747
+ list.push({
748
+ projectId: first.projectId,
749
+ runId: first.runId,
750
+ ...(workflowId ? { workflowId } : {}),
751
+ ...(displayTitle ? { displayTitle } : {}),
752
+ ...(status ? { status } : {}),
753
+ lastSequence: cursor,
754
+ pendingGap: events.length > cursor,
755
+ ...(updatedAt ? { updatedAt } : {}),
756
+ orphaned,
757
+ });
758
+ homes.set(first.homeRuntimeId, list);
759
+ }
760
+ for (const runtimeId of presence.keys()) if (!homes.has(runtimeId)) homes.set(runtimeId, []);
761
+ return [...homes.entries()]
762
+ .sort(([left], [right]) => left.localeCompare(right))
763
+ .map(([runtimeId, homeRuns]) => {
764
+ const record = presence.get(runtimeId);
765
+ const view = record ? runtimePresenceView(record, staleAfterMs, nowMs) : undefined;
766
+ return {
767
+ runtimeId,
768
+ ...(view ? { host: view.host, heartbeatAt: view.heartbeatAt, leaseExpiresAt: view.leaseExpiresAt } : {}),
769
+ presence: view?.presence ?? "unknown",
770
+ orphaned: !view || view.presence === "expired",
771
+ runs: homeRuns
772
+ .sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? "")))
773
+ .slice(0, 16),
774
+ runTotal: homeRuns.length,
775
+ };
776
+ });
777
+ }
778
+
630
779
  function opsSnapshot(project: string) {
631
780
  const snapshotAt = Date.now();
632
781
  const projectAgents = [...agents.values()]
@@ -671,6 +820,7 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
671
820
  };
672
821
  }),
673
822
  runTotal: projectRuns.length,
823
+ homeRuntimes: homeRuntimesView(project, hubNow()),
674
824
  plans: [...journal.values()]
675
825
  .filter((entry) => entry.category === "plan" && projectRuns.some((run) => run.id === entry.runId))
676
826
  .sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt))
@@ -1065,6 +1215,16 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
1065
1215
  for (const runId of swept.purgedRuns) {
1066
1216
  logger({ event: "workflow_run_purged", runId });
1067
1217
  }
1218
+ for (const lease of swept.purgedLeases) {
1219
+ logger({
1220
+ event: "lease_purged",
1221
+ resource: lease.resource,
1222
+ project: lease.project,
1223
+ agentId: lease.holderAgentId,
1224
+ fencingToken: lease.fencingToken,
1225
+ expiresAt: lease.expiresAt,
1226
+ });
1227
+ }
1068
1228
  }
1069
1229
 
1070
1230
  function metricsBody(): string {
@@ -1112,6 +1272,22 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
1112
1272
  `kxm_attempt_latency_seconds_total ${counters.attemptLatencySecondsTotal}`,
1113
1273
  "# TYPE kxm_metered_cost_usd_total counter",
1114
1274
  `kxm_metered_cost_usd_total ${counters.meteredCostUsdTotal}`,
1275
+ "# TYPE kxm_leases_granted_total counter",
1276
+ `kxm_leases_granted_total ${counters.leasesGranted}`,
1277
+ "# TYPE kxm_leases_refused_total counter",
1278
+ `kxm_leases_refused_total ${counters.leasesRefused}`,
1279
+ "# TYPE kxm_leases_released_total counter",
1280
+ `kxm_leases_released_total ${counters.leasesReleased}`,
1281
+ "# TYPE kxm_sync_events_accepted_total counter",
1282
+ `kxm_sync_events_accepted_total ${counters.syncEventsAccepted}`,
1283
+ "# TYPE kxm_sync_events_duplicate_total counter",
1284
+ `kxm_sync_events_duplicate_total ${counters.syncEventsDuplicate}`,
1285
+ "# TYPE kxm_sync_events_refused_total counter",
1286
+ `kxm_sync_events_refused_total ${counters.syncEventsRefused}`,
1287
+ "# TYPE kxm_sync_conflicts_total counter",
1288
+ `kxm_sync_conflicts_total ${counters.syncConflicts}`,
1289
+ "# TYPE kxm_runtime_heartbeats_total counter",
1290
+ `kxm_runtime_heartbeats_total ${counters.runtimeHeartbeats}`,
1115
1291
  "",
1116
1292
  ].join("\n");
1117
1293
  }
@@ -2198,6 +2374,187 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
2198
2374
  return;
2199
2375
  }
2200
2376
 
2377
+ // Runtime presence (P5). A Runtime is a machine client, not an agent: it
2378
+ // is admitted by the project token and declares its id and host label.
2379
+ // The hub stamps the heartbeat with its own clock; that stamp plus
2380
+ // staleAfterMs is the presence lease the ops snapshot reads.
2381
+ if (method === "POST" && url.pathname === "/v1/runtime/presence") {
2382
+ const body = await readJson(request);
2383
+ const project = requireString(body.project, "project", { max: 128 });
2384
+ requireProjectAuth(request, project);
2385
+ const runtimeId = requireRuntimeId(body.runtimeId);
2386
+ const hostLabel = optionalString(body.host, "host", MAX_AGENT_HOST_CHARS);
2387
+ const heartbeatAt = new Date(hubNow()).toISOString();
2388
+ const existing = store.getRuntimePresence(project, runtimeId);
2389
+ const record: RuntimePresenceRecord = {
2390
+ runtimeId,
2391
+ project,
2392
+ ...(hostLabel ? { host: hostLabel } : {}),
2393
+ registeredAt: existing?.registeredAt ?? heartbeatAt,
2394
+ heartbeatAt,
2395
+ };
2396
+ store.saveRuntimePresence(record);
2397
+ counters.runtimeHeartbeats += 1;
2398
+ if (!existing) logger({ event: "runtime_registered", project, runtimeId, ...(hostLabel ? { host: hostLabel } : {}) });
2399
+ json(response, existing ? 200 : 201, { presence: runtimePresenceView(record, staleAfterMs, hubNow()) });
2400
+ return;
2401
+ }
2402
+
2403
+ // Run-fact sync (P5), not a peer transport. Each event is accepted once by
2404
+ // {projectId, runId, sequence}: identical bytes are an idempotent
2405
+ // duplicate, different bytes under a used sequence are refused and raised
2406
+ // as a security alert. Out-of-order events are held; the per-run cursor is
2407
+ // the gapless prefix, so a gap stays pending until it is filled.
2408
+ if (method === "POST" && url.pathname === "/v1/sync/events") {
2409
+ const body = await readJson(request);
2410
+ const project = requireString(body.project, "project", { max: 128 });
2411
+ requireProjectAuth(request, project);
2412
+ const runtimeId = requireRuntimeId(body.runtimeId);
2413
+ if (!Array.isArray(body.events)) {
2414
+ throw new ProtocolError(400, "events must be an array", "invalid_sync_batch");
2415
+ }
2416
+ if (body.events.length > MAX_SYNC_BATCH_EVENTS) {
2417
+ throw new ProtocolError(413, `a sync batch holds at most ${MAX_SYNC_BATCH_EVENTS} events`, "sync_batch_too_large");
2418
+ }
2419
+ const receivedAt = new Date(hubNow()).toISOString();
2420
+ const results: Array<Record<string, unknown>> = [];
2421
+ const touched = new Map<string, { projectId: string; runId: string }>();
2422
+ for (const candidate of body.events as unknown[]) {
2423
+ const identity = candidate && typeof candidate === "object" && !Array.isArray(candidate)
2424
+ ? candidate as Record<string, unknown>
2425
+ : {};
2426
+ const echo = {
2427
+ ...(typeof identity.projectId === "string" ? { projectId: identity.projectId.slice(0, 144) } : {}),
2428
+ ...(typeof identity.runId === "string" ? { runId: identity.runId.slice(0, 144) } : {}),
2429
+ ...(Number.isInteger(identity.sequence) ? { sequence: identity.sequence } : {}),
2430
+ };
2431
+ if (syncEventSchemaErrors(candidate) !== undefined) {
2432
+ counters.syncEventsRefused += 1;
2433
+ results.push({ ...echo, outcome: "rejected", code: "sync_event_invalid" });
2434
+ continue;
2435
+ }
2436
+ const event = identity as { projectId: string; runId: string; sequence: number; homeRuntimeId: string; eventType: string };
2437
+ if (event.homeRuntimeId !== runtimeId) {
2438
+ counters.syncEventsRefused += 1;
2439
+ logger({ event: "security_alert", alert: "sync_runtime_mismatch", project, runtimeId, ...echo, homeRuntimeId: event.homeRuntimeId });
2440
+ results.push({ ...echo, outcome: "rejected", code: "sync_runtime_mismatch" });
2441
+ continue;
2442
+ }
2443
+ const bytes = kxmCanonicalJson(candidate as JsonValue);
2444
+ const contentHash = kxmSyncEventHash(bytes);
2445
+ const outcome = store.ingestSyncEvent({
2446
+ projectId: event.projectId,
2447
+ runId: event.runId,
2448
+ sequence: event.sequence,
2449
+ hubProject: project,
2450
+ homeRuntimeId: event.homeRuntimeId,
2451
+ eventType: event.eventType,
2452
+ contentHash,
2453
+ receivedAt,
2454
+ bytes,
2455
+ });
2456
+ if (outcome.outcome === "conflict") {
2457
+ counters.syncConflicts += 1;
2458
+ counters.syncEventsRefused += 1;
2459
+ logger({
2460
+ event: "security_alert",
2461
+ alert: "sync_sequence_conflict",
2462
+ reason: outcome.reason,
2463
+ project,
2464
+ runtimeId,
2465
+ ...echo,
2466
+ presentedHash: contentHash,
2467
+ ...(outcome.existingHash ? { existingHash: outcome.existingHash } : {}),
2468
+ });
2469
+ results.push({ ...echo, outcome: "conflict", code: `sync_${outcome.reason}` });
2470
+ continue;
2471
+ }
2472
+ if (outcome.outcome === "accepted") counters.syncEventsAccepted += 1;
2473
+ else counters.syncEventsDuplicate += 1;
2474
+ touched.set(`${event.projectId}\u0000${event.runId}`, { projectId: event.projectId, runId: event.runId });
2475
+ results.push({ ...echo, outcome: outcome.outcome });
2476
+ }
2477
+ const cursors = [...touched.values()].map((run) => ({ ...run, cursor: store.syncCursor(run.projectId, run.runId) }));
2478
+ if (results.some((result) => result.outcome === "accepted")) publishOps(project, "workflows");
2479
+ json(response, 200, { results, cursors });
2480
+ return;
2481
+ }
2482
+
2483
+ // One writer, one clock. Every branch below decides inside a single store
2484
+ // transaction, so two agents racing for the same resource cannot both read it
2485
+ // free. The fencing token increments only on takeover, which is what lets a
2486
+ // holder that slept past its deadline be refused at commit instead of writing
2487
+ // behind whoever replaced it.
2488
+ const leaseMatch = url.pathname.match(/^\/v1\/leases\/([^/]+)\/(acquire|renew|release)$/);
2489
+ if (method === "POST" && leaseMatch) {
2490
+ const holder = requireAgent(request);
2491
+ requireProjectAuth(request, holder.project);
2492
+ const name = requireString(decodeURIComponent(leaseMatch[1]!), "resource", { max: MAX_LEASE_RESOURCE_CHARS });
2493
+ const action = leaseMatch[2]!;
2494
+ const body = await readJson(request);
2495
+ // The caller never names the prefix, so the same branch in two projects is
2496
+ // two leases and neither project can reach the other's.
2497
+ const resource = `${holder.project}/${name}`;
2498
+ const nowMs = hubNow();
2499
+ const leaseLog = { resource, project: holder.project, agentId: holder.id, agentName: holder.name };
2500
+
2501
+ if (action === "acquire") {
2502
+ const ttlMs = parseBoundedInteger(body.ttlMs, "ttlMs", DEFAULT_LEASE_TTL_MS, MIN_LEASE_TTL_MS, MAX_LEASE_TTL_MS);
2503
+ const outcome = store.acquireLease({
2504
+ resource,
2505
+ project: holder.project,
2506
+ name,
2507
+ holderAgentId: holder.id,
2508
+ holderAgentName: holder.name,
2509
+ ttlMs,
2510
+ nowMs,
2511
+ });
2512
+ if (!outcome.ok) {
2513
+ counters.leasesRefused += 1;
2514
+ logger({ event: "lease_denied", ...leaseLog, reason: outcome.reason, heldBy: outcome.lease?.holderAgentId });
2515
+ throw leaseRefusal(outcome.reason, name, outcome.lease);
2516
+ }
2517
+ counters.leasesGranted += 1;
2518
+ logger({
2519
+ event: outcome.renewed ? "lease_renewed" : "lease_acquired",
2520
+ ...leaseLog,
2521
+ fencingToken: outcome.lease.fencingToken,
2522
+ expiresAt: outcome.lease.expiresAt,
2523
+ });
2524
+ json(response, 200, { lease: outcome.lease, renewed: outcome.renewed });
2525
+ return;
2526
+ }
2527
+
2528
+ if (body.fencingToken === undefined) {
2529
+ throw new ProtocolError(400, "fencingToken is required", "lease_token_required");
2530
+ }
2531
+ const fencingToken = parseBoundedInteger(body.fencingToken, "fencingToken", 1, 1, Number.MAX_SAFE_INTEGER);
2532
+ if (action === "renew") {
2533
+ const ttlMs = parseBoundedInteger(body.ttlMs, "ttlMs", DEFAULT_LEASE_TTL_MS, MIN_LEASE_TTL_MS, MAX_LEASE_TTL_MS);
2534
+ const outcome = store.renewLease({ resource, holderAgentId: holder.id, fencingToken, ttlMs, nowMs });
2535
+ if (!outcome.ok) {
2536
+ counters.leasesRefused += 1;
2537
+ logger({ event: "lease_denied", ...leaseLog, reason: outcome.reason, fencingToken });
2538
+ throw leaseRefusal(outcome.reason, name, outcome.lease);
2539
+ }
2540
+ counters.leasesGranted += 1;
2541
+ logger({ event: "lease_renewed", ...leaseLog, fencingToken, expiresAt: outcome.lease.expiresAt });
2542
+ json(response, 200, { lease: outcome.lease, renewed: true });
2543
+ return;
2544
+ }
2545
+
2546
+ const outcome = store.releaseLease({ resource, holderAgentId: holder.id, fencingToken });
2547
+ if (!outcome.ok) {
2548
+ counters.leasesRefused += 1;
2549
+ logger({ event: "lease_denied", ...leaseLog, reason: outcome.reason, fencingToken });
2550
+ throw leaseRefusal(outcome.reason, name, outcome.lease);
2551
+ }
2552
+ counters.leasesReleased += 1;
2553
+ logger({ event: "lease_released", ...leaseLog, fencingToken });
2554
+ json(response, 200, { released: true, lease: outcome.lease });
2555
+ return;
2556
+ }
2557
+
2201
2558
  if (method === "GET" && url.pathname === "/v1/events") {
2202
2559
  const agentId = requireString(url.searchParams.get("agentId"), "agentId", { max: 80 });
2203
2560
  const current = requireAgent(request, agentId);
@@ -8,7 +8,7 @@ import { AGENT_COMMANDS_MAP, enforceToolPolicy, getMcpTools, reconcileInbox } fr
8
8
  import { deliverInboxNotification } from "./inbox.ts";
9
9
  import type { HubEvent, MessageRecord } from "./protocol.ts";
10
10
 
11
- const VERSION = "0.7.71";
11
+ const VERSION = "0.7.73";
12
12
  const inbox = new Map<string, MessageRecord>();
13
13
  const notifiedInbox = new Set<string>();
14
14
  let meshClient: HubClient | undefined;
@@ -163,6 +163,7 @@ export class KxmSchemaRegistry {
163
163
  readonly driveReceiptValidator: ValidateFunction;
164
164
  readonly coordinatorValidator: ValidateFunction;
165
165
  readonly intakeMessageValidator: ValidateFunction;
166
+ readonly syncEventValidator: ValidateFunction;
166
167
 
167
168
  constructor(schemasDir = DEFAULT_SCHEMA_DIR) {
168
169
  this.schemasDir = resolve(schemasDir);
@@ -180,6 +181,7 @@ export class KxmSchemaRegistry {
180
181
  const driveReceiptFile = "drive-receipt.schema.json";
181
182
  const coordinatorFile = "coordinator.schema.json";
182
183
  const intakeMessageFile = "intake-message.schema.json";
184
+ const syncEventFile = "sync-event.schema.json";
183
185
  this.ajv.addSchema(readJsonObject(join(this.schemasDir, localBindingsFile)));
184
186
  this.ajv.addSchema(readJsonObject(join(this.schemasDir, templateProvenanceFile)));
185
187
  this.ajv.addSchema(readJsonObject(join(this.schemasDir, initOperationFile)));
@@ -188,6 +190,7 @@ export class KxmSchemaRegistry {
188
190
  this.ajv.addSchema(readJsonObject(join(this.schemasDir, driveReceiptFile)));
189
191
  this.ajv.addSchema(readJsonObject(join(this.schemasDir, coordinatorFile)));
190
192
  this.ajv.addSchema(readJsonObject(join(this.schemasDir, intakeMessageFile)));
193
+ this.ajv.addSchema(readJsonObject(join(this.schemasDir, syncEventFile)));
191
194
  for (const [kind, definition] of Object.entries(RESOURCE_SCHEMA) as [KxmResourceKind, { identity: string; file: string }][]) {
192
195
  const validator = this.ajv.getSchema(`https://schemas.kxm.dev/${definition.file}`);
193
196
  if (!validator) throw new Error(`schema did not compile: ${definition.file}`);
@@ -201,6 +204,7 @@ export class KxmSchemaRegistry {
201
204
  const driveReceiptValidator = this.ajv.getSchema(`https://schemas.kxm.dev/${driveReceiptFile}`);
202
205
  const coordinatorValidator = this.ajv.getSchema(`https://schemas.kxm.dev/${coordinatorFile}`);
203
206
  const intakeMessageValidator = this.ajv.getSchema(`https://schemas.kxm.dev/${intakeMessageFile}`);
207
+ const syncEventValidator = this.ajv.getSchema(`https://schemas.kxm.dev/${syncEventFile}`);
204
208
  if (!localBindingsValidator) throw new Error(`schema did not compile: ${localBindingsFile}`);
205
209
  if (!templateProvenanceValidator) throw new Error(`schema did not compile: ${templateProvenanceFile}`);
206
210
  if (!initOperationValidator) throw new Error(`schema did not compile: ${initOperationFile}`);
@@ -209,6 +213,7 @@ export class KxmSchemaRegistry {
209
213
  if (!driveReceiptValidator) throw new Error(`schema did not compile: ${driveReceiptFile}`);
210
214
  if (!coordinatorValidator) throw new Error(`schema did not compile: ${coordinatorFile}`);
211
215
  if (!intakeMessageValidator) throw new Error(`schema did not compile: ${intakeMessageFile}`);
216
+ if (!syncEventValidator) throw new Error(`schema did not compile: ${syncEventFile}`);
212
217
  this.localBindingsValidator = localBindingsValidator;
213
218
  this.templateProvenanceValidator = templateProvenanceValidator;
214
219
  this.initOperationValidator = initOperationValidator;
@@ -217,6 +222,7 @@ export class KxmSchemaRegistry {
217
222
  this.driveReceiptValidator = driveReceiptValidator;
218
223
  this.coordinatorValidator = coordinatorValidator;
219
224
  this.intakeMessageValidator = intakeMessageValidator;
225
+ this.syncEventValidator = syncEventValidator;
220
226
  }
221
227
 
222
228
  validate(kind: KxmResourceKind, value: JsonObject, file: string): KxmConfigIssue[] {
@@ -274,6 +280,15 @@ export function validateRunEvent(value: unknown, file: string): void {
274
280
  }
275
281
  }
276
282
 
283
+ /** Check a derived sync object against `kxm.sync-event.v1`. Returns the
284
+ * schema errors instead of throwing so the transform can fall back to a
285
+ * degraded object and the hub can refuse one event without failing a batch. */
286
+ export function syncEventSchemaErrors(value: unknown): string | undefined {
287
+ const registry = (cachedRunEventRegistry ??= new KxmSchemaRegistry());
288
+ if (registry.syncEventValidator(value)) return undefined;
289
+ return registry.ajv.errorsText(registry.syncEventValidator.errors, { separator: "; " });
290
+ }
291
+
277
292
  /** Validate a drive receipt against `kxm.drive-receipt.v1`. Throws `drive_receipt_invalid`. */
278
293
  export function validateDriveReceipt(value: unknown, file: string): void {
279
294
  const registry = (cachedRunEventRegistry ??= new KxmSchemaRegistry());
@@ -13,6 +13,10 @@ export const DEFAULT_RATE_LIMIT_WINDOW_MS = 60_000;
13
13
  export const MAX_BODY_BYTES = 256 * 1024;
14
14
  export const MAX_CONTENT_CHARS = 32_000;
15
15
  export const MAX_AGENT_HOST_CHARS = 64;
16
+ export const MIN_LEASE_TTL_MS = 5_000;
17
+ export const MAX_LEASE_TTL_MS = 10 * 60_000;
18
+ export const DEFAULT_LEASE_TTL_MS = 5 * 60_000;
19
+ export const MAX_LEASE_RESOURCE_CHARS = 200;
16
20
 
17
21
  export type DeliveryMode = "steer" | "followUp" | "nextTurn";
18
22
  export type MessageStatus = "queued" | "delivered" | "replied" | "cancelled" | "expired" | "error";
@@ -68,6 +72,54 @@ export function toAgentRecord(agent: AgentIdentity, staleAfterMs?: number, now?:
68
72
  return { ...agent, ...agentPresenceView(agent, staleAfterMs, now) };
69
73
  }
70
74
 
75
+ /** One hub-held fenced lease over a project-scoped resource.
76
+ *
77
+ * `resource` is what the store keys on: the caller's project prefixed onto the
78
+ * name it asked for, so two projects naming the same branch never contend.
79
+ * Expiry is the hub's clock, never the holder's, and `fencingToken` increments
80
+ * only when a new holder takes over an expired lease — a renewal keeps its
81
+ * token, so a writer that comes back after a takeover can be told its token
82
+ * was superseded instead of being allowed to commit. */
83
+ export interface LeaseRecord {
84
+ /** `${project}/${name}`. */
85
+ resource: string;
86
+ project: string;
87
+ /** The resource as the caller named it, without the project prefix. */
88
+ name: string;
89
+ holderAgentId: string;
90
+ holderAgentName: string;
91
+ fencingToken: number;
92
+ acquiredAt: string;
93
+ expiresAt: string;
94
+ }
95
+
96
+ /** A Runtime's machine-client presence in one hub project (P5). Not an agent:
97
+ * it holds no agent key, sends no messages, and is admitted by the project
98
+ * token alone. `heartbeatAt` is the hub's clock. */
99
+ export interface RuntimePresenceRecord {
100
+ runtimeId: string;
101
+ project: string;
102
+ host?: string;
103
+ registeredAt: string;
104
+ heartbeatAt: string;
105
+ }
106
+
107
+ /** One accepted sync event as the hub holds it (P5). `bytes` is the canonical
108
+ * `kxm.sync-event.v1` text; `contentHash` is its sha256, the conflict test. */
109
+ export interface StoredSyncEvent {
110
+ projectId: string;
111
+ runId: string;
112
+ sequence: number;
113
+ hubProject: string;
114
+ homeRuntimeId: string;
115
+ eventType: string;
116
+ contentHash: string;
117
+ receivedAt: string;
118
+ bytes: string;
119
+ }
120
+
121
+ export const MAX_SYNC_BATCH_EVENTS = 100;
122
+
71
123
  export interface MessageReply {
72
124
  content: string;
73
125
  createdAt: string;