@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +22 -0
- package/docs/operations.md +37 -5
- package/docs/test-matrix.md +1 -0
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/cli.js +42 -2
- package/plugins/kxm/dist/client.js +34 -0
- package/plugins/kxm/dist/core.js +8 -0
- package/plugins/kxm/dist/extension.js +34 -0
- package/plugins/kxm/dist/mcp-server.js +35 -1
- package/plugins/kxm/dist/runtime.js +4 -2
- package/plugins/kxm/dist/server.js +275 -6
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/skills/kxm/references/protocol.md +18 -0
- package/plugins/kxm/src/client.ts +39 -1
- package/plugins/kxm/src/database.ts +6 -2
- package/plugins/kxm/src/external-effects.ts +390 -66
- package/plugins/kxm/src/hub.ts +137 -1
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/protocol.ts +25 -0
- package/plugins/kxm/src/store.ts +213 -5
|
@@ -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 =
|
|
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
|
|
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:
|
|
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
|
-
|
|
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);
|
package/plugins/kxm/package.json
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:")) {
|