@kontextmind/kxm 0.7.72 → 0.7.74

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,6 @@
1
1
  {
2
2
  "name": "claude-plugin",
3
- "version": "0.7.72",
3
+ "version": "0.7.74",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "engines": {
@@ -632,15 +632,27 @@ export class HubClient {
632
632
  }
633
633
 
634
634
  private async request<T = unknown>(path: string, init: RequestInit = {}, includeIdentity = true): Promise<T> {
635
- const requestTimeoutMs = this.options.requestTimeoutMs ?? 15_000;
635
+ return await hubJsonRequest<T>(this.options, path, init, this.headers(includeIdentity));
636
+ }
637
+ }
638
+
639
+ /** One JSON request to the hub with a bounded timeout. A non-2xx answer
640
+ * becomes a `HubHttpError` carrying the hub's code and next-action hints. */
641
+ async function hubJsonRequest<T>(
642
+ options: { serverUrl: string; requestTimeoutMs?: number; fetchImpl?: typeof fetch },
643
+ path: string,
644
+ init: RequestInit,
645
+ headers: Record<string, string>,
646
+ ): Promise<T> {
647
+ const requestTimeoutMs = options.requestTimeoutMs ?? 15_000;
636
648
  const timeoutSignal = AbortSignal.timeout(requestTimeoutMs);
637
649
  const signal = init.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal;
638
650
  let response: Response;
639
651
  try {
640
- response = await (this.options.fetchImpl ?? fetch)(`${this.options.serverUrl.replace(/\/$/, "")}${path}`, {
652
+ response = await (options.fetchImpl ?? fetch)(`${options.serverUrl.replace(/\/$/, "")}${path}`, {
641
653
  ...init,
642
654
  signal,
643
- headers: { ...this.headers(includeIdentity), ...(init.headers ?? {}) },
655
+ headers: { ...headers, ...(init.headers ?? {}) },
644
656
  });
645
657
  } catch (error) {
646
658
  if (timeoutSignal.aborted) throw new Error(`request timed out after ${requestTimeoutMs}ms`);
@@ -672,5 +684,75 @@ export class HubClient {
672
684
  );
673
685
  }
674
686
  return body as T;
687
+ }
688
+
689
+ export interface RuntimeHubClientOptions {
690
+ serverUrl: string;
691
+ /** The hub project this Runtime reports into; its token is the admission. */
692
+ project: string;
693
+ authToken?: string;
694
+ runtimeId: string;
695
+ /** Box label; defaults to the hostname. Never used for authorization. */
696
+ host?: string;
697
+ requestTimeoutMs?: number;
698
+ fetchImpl?: typeof fetch;
699
+ }
700
+
701
+ export interface RuntimePresenceView {
702
+ runtimeId: string;
703
+ host?: string;
704
+ registeredAt: string;
705
+ heartbeatAt: string;
706
+ leaseExpiresAt: string;
707
+ presence: "online" | "expired";
708
+ }
709
+
710
+ export interface SyncPushResult {
711
+ projectId?: string;
712
+ runId?: string;
713
+ sequence?: number;
714
+ outcome: "accepted" | "duplicate" | "conflict" | "rejected";
715
+ code?: string;
716
+ }
717
+
718
+ export interface SyncPushResponse {
719
+ results: SyncPushResult[];
720
+ cursors: Array<{ projectId: string; runId: string; cursor: number }>;
721
+ }
722
+
723
+ /**
724
+ * The Runtime's machine client for one hub project (P5). It registers
725
+ * presence and pushes derived sync events outbound; the hub never calls the
726
+ * Runtime back. No agent identity, no message verbs.
727
+ */
728
+ export class RuntimeHubClient {
729
+ readonly options: RuntimeHubClientOptions;
730
+
731
+ constructor(options: RuntimeHubClientOptions) {
732
+ this.options = options;
733
+ }
734
+
735
+ async heartbeat(): Promise<RuntimePresenceView> {
736
+ const result = await this.request<{ presence: RuntimePresenceView }>("/v1/runtime/presence", {
737
+ project: this.options.project,
738
+ runtimeId: this.options.runtimeId,
739
+ host: this.options.host ?? defaultHostLabel(),
740
+ });
741
+ return result.presence;
742
+ }
743
+
744
+ /** Push already-derived `kxm.sync-event.v1` objects, in outbox order. */
745
+ async pushSyncEvents(events: readonly unknown[]): Promise<SyncPushResponse> {
746
+ return await this.request<SyncPushResponse>("/v1/sync/events", {
747
+ project: this.options.project,
748
+ runtimeId: this.options.runtimeId,
749
+ events,
750
+ });
751
+ }
752
+
753
+ private async request<T>(path: string, body: unknown): Promise<T> {
754
+ const headers: Record<string, string> = { "content-type": "application/json" };
755
+ if (this.options.authToken) headers.authorization = `Bearer ${this.options.authToken}`;
756
+ return await hubJsonRequest<T>(this.options, path, { method: "POST", body: JSON.stringify(body) }, headers);
675
757
  }
676
758
  }
@@ -593,7 +593,7 @@ export function discoverProjectStores(projectRoot: string, options: { hubDataPat
593
593
  if (existsSync(hubPath)) {
594
594
  // Must track HUB_STORE_SCHEMA_VERSION in store.ts: the hub's own fresh backup is
595
595
  // restored through this ceiling, so a bump left behind here refuses it.
596
- stores.push({ storeId: "hub-store", sourcePath: hubPath, maxSupportedVersion: 4 });
596
+ stores.push({ storeId: "hub-store", sourcePath: hubPath, maxSupportedVersion: 5 });
597
597
  }
598
598
 
599
599
  const registryPath = join(root, ".kxm", "runtime", "registry.db");
@@ -615,7 +615,7 @@ export function discoverProjectStores(projectRoot: string, options: { hubDataPat
615
615
  stores.push({
616
616
  storeId: `events:${key}`,
617
617
  sourcePath: join(eventsDir, entry.name),
618
- maxSupportedVersion: 4,
618
+ maxSupportedVersion: 6,
619
619
  });
620
620
  }
621
621
  }
@@ -731,14 +731,14 @@ export function restoreBackup(
731
731
 
732
732
  // The hub-store ceiling. Must track HUB_STORE_SCHEMA_VERSION in store.ts for the
733
733
  // same reason as the events ceiling below.
734
- let maxSupported = 4;
734
+ let maxSupported = 5;
735
735
  if (store.storeId === "registry" || store.storeId === "binding-store") {
736
736
  maxSupported = 1;
737
737
  } else if (store.storeId.startsWith("events:")) {
738
738
  // Must track KXM_EVENT_STORE_SCHEMA_VERSION in runtime-store.ts. The pin is
739
739
  // the e6 backup/restore round-trip test: bump one without the other and it
740
740
  // refuses its own fresh backup.
741
- maxSupported = 5;
741
+ maxSupported = 6;
742
742
  }
743
743
 
744
744
  let targetPath = store.sourcePath;
@@ -32,7 +32,7 @@ export type ExternalActionKind =
32
32
  | "tracker-issue"
33
33
  | "webhook";
34
34
 
35
- export type ExternalEffectStatus = "in-flight" | "committed" | "failed" | "aborted";
35
+ export type ExternalEffectStatus = "in-flight" | "committed" | "failed" | "aborted" | "uncertain";
36
36
 
37
37
  export interface ExternalEffectReceipt {
38
38
  schema: typeof EXTERNAL_EFFECT_SCHEMA;
@@ -233,6 +233,13 @@ export class ExternalEffectsLedger {
233
233
 
234
234
  const existing = this.getReceipt(effectKey);
235
235
  if (existing) {
236
+ if (existing.status === "uncertain") {
237
+ return {
238
+ ok: false,
239
+ error: `effect_uncertain: ${input.actionKind} on ${input.targetRef} is parked as uncertain after a fencing conflict and requires explicit recovery`,
240
+ existing,
241
+ };
242
+ }
236
243
  if (existing.status === "committed") {
237
244
  return {
238
245
  ok: false,
@@ -315,6 +322,12 @@ export class ExternalEffectsLedger {
315
322
  return { ok: true, lastHeartbeatAt: now };
316
323
  }
317
324
 
325
+ /** Park an effect as uncertain: a fencing conflict means the outside world
326
+ * may have been touched, and only explicit recovery can unblock it. */
327
+ markUncertain(effectKey: string): void {
328
+ this.db.prepare("UPDATE external_effects SET status = 'uncertain' WHERE effect_key = ? AND status = 'in-flight'").run(effectKey);
329
+ }
330
+
318
331
  commitEffect(
319
332
  effectKey: string,
320
333
  receiptPayload: Record<string, unknown>,
@@ -392,6 +405,8 @@ export type SharedEffectRefusalCode =
392
405
  | "effect_lease_superseded"
393
406
  | "effect_already_committed"
394
407
  | "effect_in_flight"
408
+ /** The effect is parked as uncertain after a fencing conflict. */
409
+ | "effect_uncertain"
395
410
  | "effect_not_in_flight"
396
411
  | "effect_not_found";
397
412
 
@@ -494,6 +509,7 @@ export async function claimSharedEffect(input: {
494
509
  }): Promise<SharedEffectClaim | SharedEffectRefusal> {
495
510
  const needsLease = effectRequiresHubLease(input.actionKind, input.targetRef, input.runId);
496
511
  let lease: EffectLease | undefined;
512
+ let leaseRenewed = false;
497
513
 
498
514
  if (needsLease) {
499
515
  if (!input.lease) {
@@ -506,6 +522,7 @@ export async function claimSharedEffect(input: {
506
522
  try {
507
523
  const acquired = await input.lease.acquireLease(input.targetRef, input.leaseTtlMs ?? DEFAULT_LEASE_TIMEOUT_MS);
508
524
  lease = acquired.lease;
525
+ leaseRenewed = acquired.renewed === true;
509
526
  } catch (error) {
510
527
  const code = errorCodeOf(error);
511
528
  if (code === "lease_held") {
@@ -535,12 +552,17 @@ export async function claimSharedEffect(input: {
535
552
  });
536
553
 
537
554
  if (!claimed.ok) {
538
- // The ledger refused after the lease was taken, so give the resource back
539
- // rather than parking it until the TTL runs out.
540
- if (lease && input.lease) await releaseQuietly(input.lease, input.targetRef, lease.fencingToken);
555
+ // Only hand back a lease this caller newly acquired. A renewal of an
556
+ // existing holder's lease must survive a claim refusal: the original
557
+ // effect is still in-flight and still needs the fence.
558
+ if (lease && input.lease && !leaseRenewed) {
559
+ await releaseQuietly(input.lease, input.targetRef, lease.fencingToken);
560
+ }
541
561
  return {
542
562
  ok: false,
543
- code: claimed.error.startsWith("effect_already_committed") ? "effect_already_committed" : "effect_in_flight",
563
+ code: claimed.error.startsWith("effect_already_committed")
564
+ ? "effect_already_committed"
565
+ : claimed.error.startsWith("effect_uncertain") ? "effect_uncertain" : "effect_in_flight",
544
566
  error: claimed.error,
545
567
  existing: claimed.existing,
546
568
  };
@@ -653,6 +675,9 @@ export async function commitSharedEffect(input: {
653
675
  await input.lease.renewLease(receipt.targetRef, receipt.fencingToken!, input.leaseTtlMs ?? DEFAULT_LEASE_TIMEOUT_MS);
654
676
  } catch (error) {
655
677
  const code = errorCodeOf(error);
678
+ if (isLeaseLost(code)) {
679
+ input.ledger.markUncertain(input.effectKey);
680
+ }
656
681
  return {
657
682
  ok: false,
658
683
  code: isLeaseLost(code) ? "effect_lease_superseded" : "effect_lease_unavailable",
@@ -17,6 +17,7 @@ import {
17
17
  MAX_LEASE_RESOURCE_CHARS,
18
18
  MAX_LEASE_TTL_MS,
19
19
  MAX_MESSAGE_TTL_MS,
20
+ MAX_SYNC_BATCH_EVENTS,
20
21
  MIN_LEASE_TTL_MS,
21
22
  MIN_MESSAGE_TTL_MS,
22
23
  MIN_MESSAGE_RETENTION_MS,
@@ -33,8 +34,12 @@ import {
33
34
  type HubEvent,
34
35
  type LeaseRecord,
35
36
  type MessageRecord,
37
+ type RuntimePresenceRecord,
38
+ type StoredSyncEvent,
36
39
  type WorkflowMessageContext,
37
40
  } from "./protocol.ts";
41
+ import { kxmCanonicalJson, syncEventSchemaErrors, type JsonValue } from "./project-config.ts";
42
+ import { kxmSyncEventHash } from "./sync-transform.ts";
38
43
  import { workflowScopeExtras } from "./diagnostics.ts";
39
44
  import { timingSafeStringCompare } from "./commands.ts";
40
45
  import { arbitrate, explainContextItem, journalEntryToContextItem, memoryRecordToContextItem, rolePolicy } from "./arbiter.ts";
@@ -137,6 +142,32 @@ function publicAgent(agent: StoredAgent, staleAfterMs: number, now = Date.now())
137
142
  return toAgentRecord(identity, staleAfterMs, now);
138
143
  }
139
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
+
140
171
  function safeTokenEqual(actual: string | undefined, expected: string): boolean {
141
172
  return timingSafeStringCompare(actual, expected);
142
173
  }
@@ -506,6 +537,11 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
506
537
  leasesGranted: 0,
507
538
  leasesRefused: 0,
508
539
  leasesReleased: 0,
540
+ syncEventsAccepted: 0,
541
+ syncEventsDuplicate: 0,
542
+ syncEventsRefused: 0,
543
+ syncConflicts: 0,
544
+ runtimeHeartbeats: 0,
509
545
  };
510
546
  let cleanupTimer: NodeJS.Timeout | undefined;
511
547
  let closed = false;
@@ -672,6 +708,74 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
672
708
  }
673
709
  }
674
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
+
675
779
  function opsSnapshot(project: string) {
676
780
  const snapshotAt = Date.now();
677
781
  const projectAgents = [...agents.values()]
@@ -716,6 +820,7 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
716
820
  };
717
821
  }),
718
822
  runTotal: projectRuns.length,
823
+ homeRuntimes: homeRuntimesView(project, hubNow()),
719
824
  plans: [...journal.values()]
720
825
  .filter((entry) => entry.category === "plan" && projectRuns.some((run) => run.id === entry.runId))
721
826
  .sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt))
@@ -1173,6 +1278,16 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
1173
1278
  `kxm_leases_refused_total ${counters.leasesRefused}`,
1174
1279
  "# TYPE kxm_leases_released_total counter",
1175
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}`,
1176
1291
  "",
1177
1292
  ].join("\n");
1178
1293
  }
@@ -2259,6 +2374,112 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
2259
2374
  return;
2260
2375
  }
2261
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
+
2262
2483
  // One writer, one clock. Every branch below decides inside a single store
2263
2484
  // transaction, so two agents racing for the same resource cannot both read it
2264
2485
  // free. The fencing token increments only on takeover, which is what lets a
@@ -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.72";
11
+ const VERSION = "0.7.74";
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());
@@ -93,6 +93,33 @@ export interface LeaseRecord {
93
93
  expiresAt: string;
94
94
  }
95
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
+
96
123
  export interface MessageReply {
97
124
  content: string;
98
125
  createdAt: string;