@kontextmind/kxm 0.7.71 → 0.7.72

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.
@@ -7388,6 +7388,10 @@ var DEFAULT_RATE_LIMIT_WINDOW_MS = 6e4;
7388
7388
  var MAX_BODY_BYTES = 256 * 1024;
7389
7389
  var MAX_CONTENT_CHARS = 32e3;
7390
7390
  var MAX_AGENT_HOST_CHARS = 64;
7391
+ var MIN_LEASE_TTL_MS = 5e3;
7392
+ var MAX_LEASE_TTL_MS = 10 * 6e4;
7393
+ var DEFAULT_LEASE_TTL_MS = 5 * 6e4;
7394
+ var MAX_LEASE_RESOURCE_CHARS = 200;
7391
7395
  function agentPresenceView(agent, staleAfterMs = DEFAULT_STALE_AFTER_MS, now = Date.now()) {
7392
7396
  const lastSeenMs = Date.parse(agent.lastSeenAt);
7393
7397
  const leaseExpiresAtMs = (Number.isFinite(lastSeenMs) ? lastSeenMs : 0) + staleAfterMs;
@@ -11677,7 +11681,7 @@ function withDatabaseTransaction(database, work, mode = "IMMEDIATE", clock = mon
11677
11681
  }
11678
11682
 
11679
11683
  // plugins/kxm/src/store.ts
11680
- var HUB_STORE_SCHEMA_VERSION = 3;
11684
+ var HUB_STORE_SCHEMA_VERSION = 4;
11681
11685
  var HUB_STORE_TABLES = Object.freeze({
11682
11686
  agents: Object.freeze(["id", "record"]),
11683
11687
  messages: Object.freeze(["id", "record"]),
@@ -11685,9 +11689,10 @@ var HUB_STORE_TABLES = Object.freeze({
11685
11689
  agent_sequences: Object.freeze(["agent_id", "next_seq"]),
11686
11690
  workflow_runs: Object.freeze(["id", "definition_id", "delivery_id", "record"]),
11687
11691
  workflow_journal: Object.freeze(["id", "run_id", "category", "area", "record"]),
11688
- context_items: Object.freeze(["id", "project", "kind", "record"])
11692
+ context_items: Object.freeze(["id", "project", "kind", "record"]),
11693
+ leases: Object.freeze(["resource", "holder_agent_id", "fencing_token", "expires_at", "record"])
11689
11694
  });
11690
- var HUB_STORE_SCHEMA_V3 = `
11695
+ var HUB_STORE_SCHEMA_V4 = `
11691
11696
  CREATE TABLE IF NOT EXISTS agents (
11692
11697
  id TEXT PRIMARY KEY,
11693
11698
  record TEXT NOT NULL
@@ -11736,12 +11741,30 @@ var HUB_STORE_SCHEMA_V3 = `
11736
11741
  record TEXT NOT NULL
11737
11742
  ) STRICT;
11738
11743
  CREATE INDEX IF NOT EXISTS context_items_project ON context_items(project);
11744
+ CREATE TABLE IF NOT EXISTS leases (
11745
+ resource TEXT PRIMARY KEY,
11746
+ holder_agent_id TEXT NOT NULL,
11747
+ fencing_token INTEGER NOT NULL,
11748
+ expires_at TEXT NOT NULL,
11749
+ record TEXT NOT NULL
11750
+ ) STRICT;
11739
11751
  `;
11740
11752
  var HUB_STORE_SCHEMA_SPEC = Object.freeze({
11741
- schema: HUB_STORE_SCHEMA_V3,
11753
+ schema: HUB_STORE_SCHEMA_V4,
11742
11754
  version: HUB_STORE_SCHEMA_VERSION,
11743
11755
  tables: HUB_STORE_TABLES
11744
11756
  });
11757
+ function parseLeaseRecord(record) {
11758
+ try {
11759
+ return JSON.parse(record);
11760
+ } catch {
11761
+ return void 0;
11762
+ }
11763
+ }
11764
+ function leaseHasLapsed(lease, nowMs) {
11765
+ const expiresAtMs = Date.parse(lease.expiresAt);
11766
+ return !Number.isFinite(expiresAtMs) || expiresAtMs <= nowMs;
11767
+ }
11745
11768
  var MessageMap = class extends Map {
11746
11769
  store;
11747
11770
  constructor(store) {
@@ -11774,6 +11797,7 @@ var MeshStore = class {
11774
11797
  workflowRuns = /* @__PURE__ */ new Map();
11775
11798
  journal = /* @__PURE__ */ new Map();
11776
11799
  contextItems = /* @__PURE__ */ new Map();
11800
+ leases = /* @__PURE__ */ new Map();
11777
11801
  path;
11778
11802
  database;
11779
11803
  agentSequences = /* @__PURE__ */ new Map();
@@ -11966,6 +11990,117 @@ var MeshStore = class {
11966
11990
  }
11967
11991
  return result;
11968
11992
  }
11993
+ /** Read the lease over one already project-scoped resource. */
11994
+ getLease(resource) {
11995
+ if (!this.database) return this.leases.get(resource);
11996
+ const row = this.database.prepare("SELECT record FROM leases WHERE resource = ?").get(resource);
11997
+ if (!row) {
11998
+ this.leases.delete(resource);
11999
+ return void 0;
12000
+ }
12001
+ const lease = parseLeaseRecord(row.record);
12002
+ if (lease) this.leases.set(resource, lease);
12003
+ return lease;
12004
+ }
12005
+ /**
12006
+ * Take or extend the lease over `resource`, deciding the whole outcome inside
12007
+ * one transaction so two writers cannot both read "free" and both insert.
12008
+ *
12009
+ * The token is the fence: a first acquisition starts at 1, the holder's own
12010
+ * re-acquisition keeps its token, and only a takeover of an expired lease
12011
+ * increments it. That is what lets a holder that wakes up after its deadline
12012
+ * be refused at commit rather than silently writing behind the new holder.
12013
+ */
12014
+ acquireLease(input) {
12015
+ return this.inLeaseTransaction(() => {
12016
+ const current = this.readLeaseForUpdate(input.resource);
12017
+ const expired = current !== void 0 && leaseHasLapsed(current, input.nowMs);
12018
+ if (current && !expired && current.holderAgentId !== input.holderAgentId) {
12019
+ return { ok: false, reason: "held", lease: current };
12020
+ }
12021
+ const fencingToken = current === void 0 ? 1 : expired ? current.fencingToken + 1 : current.fencingToken;
12022
+ const renewed = current !== void 0 && !expired;
12023
+ const lease = {
12024
+ resource: input.resource,
12025
+ project: input.project,
12026
+ name: input.name,
12027
+ holderAgentId: input.holderAgentId,
12028
+ holderAgentName: input.holderAgentName,
12029
+ fencingToken,
12030
+ acquiredAt: renewed && current ? current.acquiredAt : new Date(input.nowMs).toISOString(),
12031
+ expiresAt: new Date(input.nowMs + input.ttlMs).toISOString()
12032
+ };
12033
+ this.writeLease(lease);
12034
+ return { ok: true, lease, renewed };
12035
+ });
12036
+ }
12037
+ /** Extend a lease the caller still holds under the token it was given. The
12038
+ * token never changes on renewal — a renewal that would need a new token is
12039
+ * a takeover, and takeovers go through `acquireLease`. */
12040
+ renewLease(input) {
12041
+ return this.inLeaseTransaction(() => {
12042
+ const current = this.readLeaseForUpdate(input.resource);
12043
+ if (!current) return { ok: false, reason: "missing" };
12044
+ if (current.holderAgentId !== input.holderAgentId || current.fencingToken !== input.fencingToken) {
12045
+ return { ok: false, reason: "superseded", lease: current };
12046
+ }
12047
+ if (leaseHasLapsed(current, input.nowMs)) {
12048
+ return { ok: false, reason: "expired", lease: current };
12049
+ }
12050
+ const lease = { ...current, expiresAt: new Date(input.nowMs + input.ttlMs).toISOString() };
12051
+ this.writeLease(lease);
12052
+ return { ok: true, lease, renewed: true };
12053
+ });
12054
+ }
12055
+ /** Drop a lease the caller holds. A clean release ends the fence: there is no
12056
+ * stale writer left to keep a token for, so the next acquisition starts over. */
12057
+ releaseLease(input) {
12058
+ return this.inLeaseTransaction(() => {
12059
+ const current = this.readLeaseForUpdate(input.resource);
12060
+ if (!current) return { ok: false, reason: "missing" };
12061
+ if (current.holderAgentId !== input.holderAgentId || current.fencingToken !== input.fencingToken) {
12062
+ return { ok: false, reason: "superseded", lease: current };
12063
+ }
12064
+ this.deleteLease(input.resource);
12065
+ return { ok: true, lease: current, renewed: false };
12066
+ });
12067
+ }
12068
+ /** Every lease of one project, newest deadline last. Reader surface only. */
12069
+ listLeases(project) {
12070
+ if (this.database) {
12071
+ const rows = this.database.prepare("SELECT record FROM leases").all();
12072
+ this.leases.clear();
12073
+ for (const row of rows) {
12074
+ const lease = parseLeaseRecord(row.record);
12075
+ if (lease) this.leases.set(lease.resource, lease);
12076
+ }
12077
+ }
12078
+ return [...this.leases.values()].filter((lease) => lease.project === project).sort((left, right) => left.resource.localeCompare(right.resource));
12079
+ }
12080
+ inLeaseTransaction(work) {
12081
+ if (!this.database) return work();
12082
+ return withDatabaseTransaction(this.database, work);
12083
+ }
12084
+ readLeaseForUpdate(resource) {
12085
+ if (!this.database) return this.leases.get(resource);
12086
+ const row = this.database.prepare("SELECT record FROM leases WHERE resource = ?").get(resource);
12087
+ return row ? parseLeaseRecord(row.record) : void 0;
12088
+ }
12089
+ writeLease(lease) {
12090
+ this.leases.set(lease.resource, lease);
12091
+ this.database?.prepare(`
12092
+ INSERT INTO leases (resource, holder_agent_id, fencing_token, expires_at, record) VALUES (?, ?, ?, ?, ?)
12093
+ ON CONFLICT(resource) DO UPDATE SET
12094
+ holder_agent_id = excluded.holder_agent_id,
12095
+ fencing_token = excluded.fencing_token,
12096
+ expires_at = excluded.expires_at,
12097
+ record = excluded.record
12098
+ `).run(lease.resource, lease.holderAgentId, lease.fencingToken, lease.expiresAt, JSON.stringify(lease));
12099
+ }
12100
+ deleteLease(resource) {
12101
+ this.leases.delete(resource);
12102
+ this.database?.prepare("DELETE FROM leases WHERE resource = ?").run(resource);
12103
+ }
11969
12104
  deleteWorkflowRun(runId) {
11970
12105
  this.workflowRuns.delete(runId);
11971
12106
  this.database?.prepare("DELETE FROM workflow_runs WHERE id = ?").run(runId);
@@ -11986,6 +12121,7 @@ var MeshStore = class {
11986
12121
  const purgedRuns = [];
11987
12122
  const purgedJournal = [];
11988
12123
  const purgedContextItems = [];
12124
+ const purgedLeases = [];
11989
12125
  if (!this.database) {
11990
12126
  for (const m of [...Map.prototype.values.call(this.messages)]) {
11991
12127
  const terminal = m.status === "replied" || m.status === "cancelled" || m.status === "expired" || m.status === "error";
@@ -12056,7 +12192,24 @@ var MeshStore = class {
12056
12192
  this.deleteContextItem(id);
12057
12193
  purgedContextItems.push(id);
12058
12194
  }
12059
- return { purgedMessages, purgedRuns, purgedJournal, purgedContextItems };
12195
+ const leaseCutoffMs = nowMs - runRetentionMs;
12196
+ for (const lease of this.listAllLeases()) {
12197
+ if (leaseHasLapsed(lease, leaseCutoffMs)) {
12198
+ this.deleteLease(lease.resource);
12199
+ purgedLeases.push(lease);
12200
+ }
12201
+ }
12202
+ return { purgedMessages, purgedRuns, purgedJournal, purgedContextItems, purgedLeases };
12203
+ }
12204
+ listAllLeases() {
12205
+ if (!this.database) return [...this.leases.values()];
12206
+ const rows = this.database.prepare("SELECT record FROM leases").all();
12207
+ const result = [];
12208
+ for (const row of rows) {
12209
+ const lease = parseLeaseRecord(row.record);
12210
+ if (lease) result.push(lease);
12211
+ }
12212
+ return result;
12060
12213
  }
12061
12214
  saveWorkflowRun(run) {
12062
12215
  this.workflowRuns.set(run.id, run);
@@ -12127,6 +12280,7 @@ var MeshStore = class {
12127
12280
  const workflowRows = this.database.prepare("SELECT record FROM workflow_runs").all();
12128
12281
  const journalRows = this.database.prepare("SELECT record FROM workflow_journal").all();
12129
12282
  const contextRows = this.database.prepare("SELECT record FROM context_items").all();
12283
+ const leaseRows = this.database.prepare("SELECT record FROM leases").all();
12130
12284
  for (const row of agentRows) {
12131
12285
  const agent = JSON.parse(row.record);
12132
12286
  this.agents.set(agent.id, agent);
@@ -12143,6 +12297,10 @@ var MeshStore = class {
12143
12297
  const item = JSON.parse(row.record);
12144
12298
  this.contextItems.set(item.id, item);
12145
12299
  }
12300
+ for (const row of leaseRows) {
12301
+ const lease = parseLeaseRecord(row.record);
12302
+ if (lease) this.leases.set(lease.resource, lease);
12303
+ }
12146
12304
  }
12147
12305
  };
12148
12306
 
@@ -12161,6 +12319,33 @@ function publicAgent(agent, staleAfterMs, now = Date.now()) {
12161
12319
  function safeTokenEqual(actual, expected) {
12162
12320
  return timingSafeStringCompare(actual, expected);
12163
12321
  }
12322
+ function leaseRefusal(reason, name, lease) {
12323
+ if (reason === "missing") {
12324
+ return new ProtocolError(404, `no lease is held on ${name}`, "lease_not_found");
12325
+ }
12326
+ if (reason === "expired") {
12327
+ return new ProtocolError(
12328
+ 409,
12329
+ `lease on ${name} expired at ${lease?.expiresAt ?? "its deadline"}; re-acquire to take it over`,
12330
+ "lease_expired",
12331
+ { lease }
12332
+ );
12333
+ }
12334
+ if (reason === "held") {
12335
+ return new ProtocolError(
12336
+ 409,
12337
+ `lease on ${name} is held by ${lease?.holderAgentName ?? "another agent"} until ${lease?.expiresAt ?? "its deadline"}`,
12338
+ "lease_held",
12339
+ { lease }
12340
+ );
12341
+ }
12342
+ return new ProtocolError(
12343
+ 409,
12344
+ `fencing token for ${name} was superseded; the hub is now at token ${lease?.fencingToken ?? "a newer value"}`,
12345
+ "lease_superseded",
12346
+ { lease }
12347
+ );
12348
+ }
12164
12349
  function bearerToken(request) {
12165
12350
  const header = request.headers.authorization;
12166
12351
  return header?.startsWith("Bearer ") ? header.slice(7) : void 0;
@@ -12383,6 +12568,7 @@ function createMeshHub(options = {}) {
12383
12568
  };
12384
12569
  const webhookWorkflows2 = new Map((options.webhookWorkflows ?? []).map((workflow) => [workflow.id, workflow]));
12385
12570
  const logger = options.logger ?? (() => void 0);
12571
+ const hubNow = options.now ?? (() => Date.now());
12386
12572
  const assetsDir2 = options.assetsDir;
12387
12573
  const hubRepoRoot = options.repoRoot ?? (options.dataPath && options.dataPath !== ":memory:" ? resolve6(dirname5(dirname5(options.dataPath))) : process.cwd());
12388
12574
  const store = new MeshStore(options.dataPath);
@@ -12431,7 +12617,10 @@ function createMeshHub(options = {}) {
12431
12617
  journalEntries: 0,
12432
12618
  contextRequests: 0,
12433
12619
  attemptLatencySecondsTotal: 0,
12434
- meteredCostUsdTotal: 0
12620
+ meteredCostUsdTotal: 0,
12621
+ leasesGranted: 0,
12622
+ leasesRefused: 0,
12623
+ leasesReleased: 0
12435
12624
  };
12436
12625
  let cleanupTimer;
12437
12626
  let closed = false;
@@ -12965,6 +13154,16 @@ data: ${JSON.stringify({ type: "ops", project, topic, at: nowIso() })}
12965
13154
  for (const runId of swept.purgedRuns) {
12966
13155
  logger({ event: "workflow_run_purged", runId });
12967
13156
  }
13157
+ for (const lease of swept.purgedLeases) {
13158
+ logger({
13159
+ event: "lease_purged",
13160
+ resource: lease.resource,
13161
+ project: lease.project,
13162
+ agentId: lease.holderAgentId,
13163
+ fencingToken: lease.fencingToken,
13164
+ expiresAt: lease.expiresAt
13165
+ });
13166
+ }
12968
13167
  }
12969
13168
  function metricsBody() {
12970
13169
  const onlineAgents = [...agents.values()].filter((agent) => agent.online).length;
@@ -13011,6 +13210,12 @@ data: ${JSON.stringify({ type: "ops", project, topic, at: nowIso() })}
13011
13210
  `kxm_attempt_latency_seconds_total ${counters.attemptLatencySecondsTotal}`,
13012
13211
  "# TYPE kxm_metered_cost_usd_total counter",
13013
13212
  `kxm_metered_cost_usd_total ${counters.meteredCostUsdTotal}`,
13213
+ "# TYPE kxm_leases_granted_total counter",
13214
+ `kxm_leases_granted_total ${counters.leasesGranted}`,
13215
+ "# TYPE kxm_leases_refused_total counter",
13216
+ `kxm_leases_refused_total ${counters.leasesRefused}`,
13217
+ "# TYPE kxm_leases_released_total counter",
13218
+ `kxm_leases_released_total ${counters.leasesReleased}`,
13014
13219
  ""
13015
13220
  ].join("\n");
13016
13221
  }
@@ -14000,6 +14205,70 @@ data: ${JSON.stringify({ type: "ops", project, topic: "agents", at: nowIso() })}
14000
14205
  response.writeHead(204, { "cache-control": "no-store" }).end();
14001
14206
  return;
14002
14207
  }
14208
+ const leaseMatch = url.pathname.match(/^\/v1\/leases\/([^/]+)\/(acquire|renew|release)$/);
14209
+ if (method === "POST" && leaseMatch) {
14210
+ const holder = requireAgent(request);
14211
+ requireProjectAuth(request, holder.project);
14212
+ const name = requireString(decodeURIComponent(leaseMatch[1]), "resource", { max: MAX_LEASE_RESOURCE_CHARS });
14213
+ const action = leaseMatch[2];
14214
+ const body = await readJson(request);
14215
+ const resource = `${holder.project}/${name}`;
14216
+ const nowMs = hubNow();
14217
+ const leaseLog = { resource, project: holder.project, agentId: holder.id, agentName: holder.name };
14218
+ if (action === "acquire") {
14219
+ const ttlMs = parseBoundedInteger(body.ttlMs, "ttlMs", DEFAULT_LEASE_TTL_MS, MIN_LEASE_TTL_MS, MAX_LEASE_TTL_MS);
14220
+ const outcome2 = store.acquireLease({
14221
+ resource,
14222
+ project: holder.project,
14223
+ name,
14224
+ holderAgentId: holder.id,
14225
+ holderAgentName: holder.name,
14226
+ ttlMs,
14227
+ nowMs
14228
+ });
14229
+ if (!outcome2.ok) {
14230
+ counters.leasesRefused += 1;
14231
+ logger({ event: "lease_denied", ...leaseLog, reason: outcome2.reason, heldBy: outcome2.lease?.holderAgentId });
14232
+ throw leaseRefusal(outcome2.reason, name, outcome2.lease);
14233
+ }
14234
+ counters.leasesGranted += 1;
14235
+ logger({
14236
+ event: outcome2.renewed ? "lease_renewed" : "lease_acquired",
14237
+ ...leaseLog,
14238
+ fencingToken: outcome2.lease.fencingToken,
14239
+ expiresAt: outcome2.lease.expiresAt
14240
+ });
14241
+ json(response, 200, { lease: outcome2.lease, renewed: outcome2.renewed });
14242
+ return;
14243
+ }
14244
+ if (body.fencingToken === void 0) {
14245
+ throw new ProtocolError(400, "fencingToken is required", "lease_token_required");
14246
+ }
14247
+ const fencingToken = parseBoundedInteger(body.fencingToken, "fencingToken", 1, 1, Number.MAX_SAFE_INTEGER);
14248
+ if (action === "renew") {
14249
+ const ttlMs = parseBoundedInteger(body.ttlMs, "ttlMs", DEFAULT_LEASE_TTL_MS, MIN_LEASE_TTL_MS, MAX_LEASE_TTL_MS);
14250
+ const outcome2 = store.renewLease({ resource, holderAgentId: holder.id, fencingToken, ttlMs, nowMs });
14251
+ if (!outcome2.ok) {
14252
+ counters.leasesRefused += 1;
14253
+ logger({ event: "lease_denied", ...leaseLog, reason: outcome2.reason, fencingToken });
14254
+ throw leaseRefusal(outcome2.reason, name, outcome2.lease);
14255
+ }
14256
+ counters.leasesGranted += 1;
14257
+ logger({ event: "lease_renewed", ...leaseLog, fencingToken, expiresAt: outcome2.lease.expiresAt });
14258
+ json(response, 200, { lease: outcome2.lease, renewed: true });
14259
+ return;
14260
+ }
14261
+ const outcome = store.releaseLease({ resource, holderAgentId: holder.id, fencingToken });
14262
+ if (!outcome.ok) {
14263
+ counters.leasesRefused += 1;
14264
+ logger({ event: "lease_denied", ...leaseLog, reason: outcome.reason, fencingToken });
14265
+ throw leaseRefusal(outcome.reason, name, outcome.lease);
14266
+ }
14267
+ counters.leasesReleased += 1;
14268
+ logger({ event: "lease_released", ...leaseLog, fencingToken });
14269
+ json(response, 200, { released: true, lease: outcome.lease });
14270
+ return;
14271
+ }
14003
14272
  if (method === "GET" && url.pathname === "/v1/events") {
14004
14273
  const agentId = requireString(url.searchParams.get("agentId"), "agentId", { max: 80 });
14005
14274
  const current = requireAgent(request, agentId);
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-plugin",
3
- "version": "0.7.71",
3
+ "version": "0.7.72",
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;
@@ -625,6 +660,9 @@ export class HubClient {
625
660
  for (const key of ["operation", "nextAction", "assignedCoordinatorName"]) {
626
661
  if (typeof body[key] === "string") extras[key] = body[key];
627
662
  }
663
+ // A lease refusal carries the lease that won, so the loser can record who
664
+ // holds the resource and at which token instead of guessing.
665
+ if (body.lease && typeof body.lease === "object") extras.lease = body.lease;
628
666
  throw new HubHttpError(
629
667
  response.status,
630
668
  String(body.error ?? `HTTP ${response.status}`),
@@ -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: 4 });
595
597
  }
596
598
 
597
599
  const registryPath = join(root, ".kxm", "runtime", "registry.db");
@@ -727,7 +729,9 @@ 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 = 4;
731
735
  if (store.storeId === "registry" || store.storeId === "binding-store") {
732
736
  maxSupported = 1;
733
737
  } else if (store.storeId.startsWith("events:")) {