@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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-plugin",
3
- "version": "0.7.71",
3
+ "version": "0.7.73",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "engines": {
@@ -87,6 +87,24 @@ stored `queued` and delivered once through the reconnect cursor; unknown names
87
87
  still return `target_not_found`. Queued messages are not evidence unless
88
88
  `workflowContext` was hub-authorized at send.
89
89
 
90
+ ## Fenced leases
91
+
92
+ - `POST /v1/leases/:resource/acquire` takes or extends the lease over a
93
+ resource. Body `{ttlMs}` is bounded to 5 s–10 min. A live lease held by
94
+ another agent returns `409 lease_held` with the current holder and token.
95
+ - `POST /v1/leases/:resource/renew` extends a lease under the token it was
96
+ issued. Body `{fencingToken, ttlMs?}`.
97
+ - `POST /v1/leases/:resource/release` gives the resource back. Body
98
+ `{fencingToken}`.
99
+
100
+ All three are agent-authenticated and project-scoped: the hub prefixes the
101
+ caller's project onto `:resource`, so the same name in two projects is two
102
+ leases. Every decision is a compare-and-set on the hub clock inside one store
103
+ transaction. The fencing token starts at 1, is unchanged by renewal, and
104
+ increments only when a new holder takes over an expired lease; a stale token
105
+ returns `409 lease_superseded` with the token the hub now holds. Present the
106
+ token before committing anything shared — a refusal means stop, not retry.
107
+
90
108
  ## Request states
91
109
 
92
110
  - `queued`: stored by the hub but not acknowledged by the recipient.
@@ -1,7 +1,7 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { hostname } from "node:os";
3
3
  import { MAX_AGENT_HOST_CHARS } from "./protocol.ts";
4
- import type { AgentRecord, DeliveryMode, HubEvent, MessageRecord, WorkflowMessageContext } from "./protocol.ts";
4
+ import type { AgentRecord, DeliveryMode, HubEvent, LeaseRecord, MessageRecord, WorkflowMessageContext } from "./protocol.ts";
5
5
  import type { ContextAuthority, ContextConfidence, ContextItem, ContextItemAuditMetadata, ContextItemKind, ContextPacket } from "./context.ts";
6
6
  import {
7
7
  canonicalWorkflowEvidenceKey,
@@ -341,6 +341,41 @@ export class HubClient {
341
341
  return result.message;
342
342
  }
343
343
 
344
+ // ----- Fenced leases over shared resources (P3) -----
345
+
346
+ /**
347
+ * Take or extend the lease over `resource` inside this client's project.
348
+ *
349
+ * The returned `fencingToken` is the whole point: hold it, present it on every
350
+ * renewal, and present it again before committing anything shared. A hub that
351
+ * has moved past it refuses, and the caller must stop rather than retry —
352
+ * another holder owns the resource now. Rejects `HubHttpError` with code
353
+ * `lease_held` when a live holder has it.
354
+ */
355
+ async acquireLease(resource: string, ttlMs?: number): Promise<{ lease: LeaseRecord; renewed: boolean }> {
356
+ return await this.request(`/v1/leases/${encodeURIComponent(resource)}/acquire`, {
357
+ method: "POST",
358
+ body: JSON.stringify(ttlMs === undefined ? {} : { ttlMs }),
359
+ });
360
+ }
361
+
362
+ /** Extend a lease this client holds. The token never changes on renewal; a
363
+ * `lease_superseded` or `lease_expired` refusal means it is gone. */
364
+ async renewLease(resource: string, fencingToken: number, ttlMs?: number): Promise<{ lease: LeaseRecord }> {
365
+ return await this.request(`/v1/leases/${encodeURIComponent(resource)}/renew`, {
366
+ method: "POST",
367
+ body: JSON.stringify(ttlMs === undefined ? { fencingToken } : { fencingToken, ttlMs }),
368
+ });
369
+ }
370
+
371
+ /** Give the resource back. */
372
+ async releaseLease(resource: string, fencingToken: number): Promise<{ released: boolean; lease: LeaseRecord }> {
373
+ return await this.request(`/v1/leases/${encodeURIComponent(resource)}/release`, {
374
+ method: "POST",
375
+ body: JSON.stringify({ fencingToken }),
376
+ });
377
+ }
378
+
344
379
  async listWorkflows(): Promise<WorkflowRun[]> {
345
380
  const result = await this.request<{ runs: WorkflowRun[] }>("/v1/workflows");
346
381
  return result.runs;
@@ -597,15 +632,27 @@ export class HubClient {
597
632
  }
598
633
 
599
634
  private async request<T = unknown>(path: string, init: RequestInit = {}, includeIdentity = true): Promise<T> {
600
- 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;
601
648
  const timeoutSignal = AbortSignal.timeout(requestTimeoutMs);
602
649
  const signal = init.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal;
603
650
  let response: Response;
604
651
  try {
605
- response = await (this.options.fetchImpl ?? fetch)(`${this.options.serverUrl.replace(/\/$/, "")}${path}`, {
652
+ response = await (options.fetchImpl ?? fetch)(`${options.serverUrl.replace(/\/$/, "")}${path}`, {
606
653
  ...init,
607
654
  signal,
608
- headers: { ...this.headers(includeIdentity), ...(init.headers ?? {}) },
655
+ headers: { ...headers, ...(init.headers ?? {}) },
609
656
  });
610
657
  } catch (error) {
611
658
  if (timeoutSignal.aborted) throw new Error(`request timed out after ${requestTimeoutMs}ms`);
@@ -625,6 +672,9 @@ export class HubClient {
625
672
  for (const key of ["operation", "nextAction", "assignedCoordinatorName"]) {
626
673
  if (typeof body[key] === "string") extras[key] = body[key];
627
674
  }
675
+ // A lease refusal carries the lease that won, so the loser can record who
676
+ // holds the resource and at which token instead of guessing.
677
+ if (body.lease && typeof body.lease === "object") extras.lease = body.lease;
628
678
  throw new HubHttpError(
629
679
  response.status,
630
680
  String(body.error ?? `HTTP ${response.status}`),
@@ -634,5 +684,75 @@ export class HubClient {
634
684
  );
635
685
  }
636
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);
637
757
  }
638
758
  }
@@ -591,7 +591,9 @@ export function discoverProjectStores(projectRoot: string, options: { hubDataPat
591
591
 
592
592
  const hubPath = options.hubDataPath ? resolve(options.hubDataPath) : join(root, ".kxm", "state", "kxm.db");
593
593
  if (existsSync(hubPath)) {
594
- stores.push({ storeId: "hub-store", sourcePath: hubPath, maxSupportedVersion: 3 });
594
+ // Must track HUB_STORE_SCHEMA_VERSION in store.ts: the hub's own fresh backup is
595
+ // restored through this ceiling, so a bump left behind here refuses it.
596
+ stores.push({ storeId: "hub-store", sourcePath: hubPath, maxSupportedVersion: 5 });
595
597
  }
596
598
 
597
599
  const registryPath = join(root, ".kxm", "runtime", "registry.db");
@@ -613,7 +615,7 @@ export function discoverProjectStores(projectRoot: string, options: { hubDataPat
613
615
  stores.push({
614
616
  storeId: `events:${key}`,
615
617
  sourcePath: join(eventsDir, entry.name),
616
- maxSupportedVersion: 4,
618
+ maxSupportedVersion: 6,
617
619
  });
618
620
  }
619
621
  }
@@ -727,14 +729,16 @@ export function restoreBackup(
727
729
  );
728
730
  }
729
731
 
730
- let maxSupported = 3;
732
+ // The hub-store ceiling. Must track HUB_STORE_SCHEMA_VERSION in store.ts for the
733
+ // same reason as the events ceiling below.
734
+ let maxSupported = 5;
731
735
  if (store.storeId === "registry" || store.storeId === "binding-store") {
732
736
  maxSupported = 1;
733
737
  } else if (store.storeId.startsWith("events:")) {
734
738
  // Must track KXM_EVENT_STORE_SCHEMA_VERSION in runtime-store.ts. The pin is
735
739
  // the e6 backup/restore round-trip test: bump one without the other and it
736
740
  // refuses its own fresh backup.
737
- maxSupported = 5;
741
+ maxSupported = 6;
738
742
  }
739
743
 
740
744
  let targetPath = store.sourcePath;