@kontextmind/kxm 0.7.70 → 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 +83 -19
- package/plugins/kxm/dist/client.js +50 -3
- package/plugins/kxm/dist/core.js +31 -4
- package/plugins/kxm/dist/extension.js +59 -7
- package/plugins/kxm/dist/mcp-server.js +60 -8
- package/plugins/kxm/dist/runtime.js +4 -2
- package/plugins/kxm/dist/server.js +317 -20
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/skills/kxm/references/protocol.md +18 -0
- package/plugins/kxm/skills/kxm-peer/SKILL.md +3 -1
- package/plugins/kxm/src/cli.ts +2 -1
- package/plugins/kxm/src/client.ts +62 -3
- package/plugins/kxm/src/commands.ts +10 -4
- package/plugins/kxm/src/database.ts +6 -2
- package/plugins/kxm/src/external-effects.ts +390 -66
- package/plugins/kxm/src/hub.ts +171 -12
- package/plugins/kxm/src/local-snapshot.ts +10 -2
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/protocol.ts +67 -1
- package/plugins/kxm/src/store.ts +217 -6
- package/plugins/kxm/src/tui.ts +20 -4
|
@@ -7387,6 +7387,21 @@ var DEFAULT_RATE_LIMIT_MAX = 600;
|
|
|
7387
7387
|
var DEFAULT_RATE_LIMIT_WINDOW_MS = 6e4;
|
|
7388
7388
|
var MAX_BODY_BYTES = 256 * 1024;
|
|
7389
7389
|
var MAX_CONTENT_CHARS = 32e3;
|
|
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;
|
|
7395
|
+
function agentPresenceView(agent, staleAfterMs = DEFAULT_STALE_AFTER_MS, now = Date.now()) {
|
|
7396
|
+
const lastSeenMs = Date.parse(agent.lastSeenAt);
|
|
7397
|
+
const leaseExpiresAtMs = (Number.isFinite(lastSeenMs) ? lastSeenMs : 0) + staleAfterMs;
|
|
7398
|
+
const leaseExpiresAt = new Date(leaseExpiresAtMs).toISOString();
|
|
7399
|
+
if (!agent.online) return { leaseExpiresAt, presence: "offline" };
|
|
7400
|
+
return { leaseExpiresAt, presence: now < leaseExpiresAtMs ? "online" : "stale" };
|
|
7401
|
+
}
|
|
7402
|
+
function toAgentRecord(agent, staleAfterMs, now) {
|
|
7403
|
+
return { ...agent, ...agentPresenceView(agent, staleAfterMs, now) };
|
|
7404
|
+
}
|
|
7390
7405
|
var ProtocolError = class extends Error {
|
|
7391
7406
|
statusCode;
|
|
7392
7407
|
code;
|
|
@@ -8575,14 +8590,19 @@ var AGENT_COMMANDS = [
|
|
|
8575
8590
|
group: "peer",
|
|
8576
8591
|
verb: "list",
|
|
8577
8592
|
label: "List hub peers",
|
|
8578
|
-
description: "List
|
|
8593
|
+
description: "List peer agents in this project's hub pool with their names, purposes, host label, and hub-clocked presence (online, stale, offline). Registered offline peers are listed only when includeOffline is set.",
|
|
8579
8594
|
parameters: {
|
|
8580
8595
|
type: "object",
|
|
8581
|
-
properties: {
|
|
8596
|
+
properties: {
|
|
8597
|
+
includeOffline: {
|
|
8598
|
+
type: "boolean",
|
|
8599
|
+
description: "Also list registered peers whose hub lease has expired"
|
|
8600
|
+
}
|
|
8601
|
+
},
|
|
8582
8602
|
additionalProperties: false
|
|
8583
8603
|
},
|
|
8584
|
-
async execute(client) {
|
|
8585
|
-
return { agents: await client.listAgents() };
|
|
8604
|
+
async execute(client, args) {
|
|
8605
|
+
return { agents: await client.listAgents({ includeOffline: args.includeOffline === true }) };
|
|
8586
8606
|
}
|
|
8587
8607
|
},
|
|
8588
8608
|
{
|
|
@@ -11661,7 +11681,7 @@ function withDatabaseTransaction(database, work, mode = "IMMEDIATE", clock = mon
|
|
|
11661
11681
|
}
|
|
11662
11682
|
|
|
11663
11683
|
// plugins/kxm/src/store.ts
|
|
11664
|
-
var HUB_STORE_SCHEMA_VERSION =
|
|
11684
|
+
var HUB_STORE_SCHEMA_VERSION = 4;
|
|
11665
11685
|
var HUB_STORE_TABLES = Object.freeze({
|
|
11666
11686
|
agents: Object.freeze(["id", "record"]),
|
|
11667
11687
|
messages: Object.freeze(["id", "record"]),
|
|
@@ -11669,9 +11689,10 @@ var HUB_STORE_TABLES = Object.freeze({
|
|
|
11669
11689
|
agent_sequences: Object.freeze(["agent_id", "next_seq"]),
|
|
11670
11690
|
workflow_runs: Object.freeze(["id", "definition_id", "delivery_id", "record"]),
|
|
11671
11691
|
workflow_journal: Object.freeze(["id", "run_id", "category", "area", "record"]),
|
|
11672
|
-
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"])
|
|
11673
11694
|
});
|
|
11674
|
-
var
|
|
11695
|
+
var HUB_STORE_SCHEMA_V4 = `
|
|
11675
11696
|
CREATE TABLE IF NOT EXISTS agents (
|
|
11676
11697
|
id TEXT PRIMARY KEY,
|
|
11677
11698
|
record TEXT NOT NULL
|
|
@@ -11720,12 +11741,30 @@ var HUB_STORE_SCHEMA_V3 = `
|
|
|
11720
11741
|
record TEXT NOT NULL
|
|
11721
11742
|
) STRICT;
|
|
11722
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;
|
|
11723
11751
|
`;
|
|
11724
11752
|
var HUB_STORE_SCHEMA_SPEC = Object.freeze({
|
|
11725
|
-
schema:
|
|
11753
|
+
schema: HUB_STORE_SCHEMA_V4,
|
|
11726
11754
|
version: HUB_STORE_SCHEMA_VERSION,
|
|
11727
11755
|
tables: HUB_STORE_TABLES
|
|
11728
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
|
+
}
|
|
11729
11768
|
var MessageMap = class extends Map {
|
|
11730
11769
|
store;
|
|
11731
11770
|
constructor(store) {
|
|
@@ -11758,6 +11797,7 @@ var MeshStore = class {
|
|
|
11758
11797
|
workflowRuns = /* @__PURE__ */ new Map();
|
|
11759
11798
|
journal = /* @__PURE__ */ new Map();
|
|
11760
11799
|
contextItems = /* @__PURE__ */ new Map();
|
|
11800
|
+
leases = /* @__PURE__ */ new Map();
|
|
11761
11801
|
path;
|
|
11762
11802
|
database;
|
|
11763
11803
|
agentSequences = /* @__PURE__ */ new Map();
|
|
@@ -11950,6 +11990,117 @@ var MeshStore = class {
|
|
|
11950
11990
|
}
|
|
11951
11991
|
return result;
|
|
11952
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
|
+
}
|
|
11953
12104
|
deleteWorkflowRun(runId) {
|
|
11954
12105
|
this.workflowRuns.delete(runId);
|
|
11955
12106
|
this.database?.prepare("DELETE FROM workflow_runs WHERE id = ?").run(runId);
|
|
@@ -11970,6 +12121,7 @@ var MeshStore = class {
|
|
|
11970
12121
|
const purgedRuns = [];
|
|
11971
12122
|
const purgedJournal = [];
|
|
11972
12123
|
const purgedContextItems = [];
|
|
12124
|
+
const purgedLeases = [];
|
|
11973
12125
|
if (!this.database) {
|
|
11974
12126
|
for (const m of [...Map.prototype.values.call(this.messages)]) {
|
|
11975
12127
|
const terminal = m.status === "replied" || m.status === "cancelled" || m.status === "expired" || m.status === "error";
|
|
@@ -12040,7 +12192,24 @@ var MeshStore = class {
|
|
|
12040
12192
|
this.deleteContextItem(id);
|
|
12041
12193
|
purgedContextItems.push(id);
|
|
12042
12194
|
}
|
|
12043
|
-
|
|
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;
|
|
12044
12213
|
}
|
|
12045
12214
|
saveWorkflowRun(run) {
|
|
12046
12215
|
this.workflowRuns.set(run.id, run);
|
|
@@ -12111,6 +12280,7 @@ var MeshStore = class {
|
|
|
12111
12280
|
const workflowRows = this.database.prepare("SELECT record FROM workflow_runs").all();
|
|
12112
12281
|
const journalRows = this.database.prepare("SELECT record FROM workflow_journal").all();
|
|
12113
12282
|
const contextRows = this.database.prepare("SELECT record FROM context_items").all();
|
|
12283
|
+
const leaseRows = this.database.prepare("SELECT record FROM leases").all();
|
|
12114
12284
|
for (const row of agentRows) {
|
|
12115
12285
|
const agent = JSON.parse(row.record);
|
|
12116
12286
|
this.agents.set(agent.id, agent);
|
|
@@ -12127,6 +12297,10 @@ var MeshStore = class {
|
|
|
12127
12297
|
const item = JSON.parse(row.record);
|
|
12128
12298
|
this.contextItems.set(item.id, item);
|
|
12129
12299
|
}
|
|
12300
|
+
for (const row of leaseRows) {
|
|
12301
|
+
const lease = parseLeaseRecord(row.record);
|
|
12302
|
+
if (lease) this.leases.set(lease.resource, lease);
|
|
12303
|
+
}
|
|
12130
12304
|
}
|
|
12131
12305
|
};
|
|
12132
12306
|
|
|
@@ -12138,13 +12312,40 @@ function isLoopback(host2) {
|
|
|
12138
12312
|
if (host2 === "localhost" || host2 === "::1") return true;
|
|
12139
12313
|
return isIP(host2) === 4 && host2.startsWith("127.");
|
|
12140
12314
|
}
|
|
12141
|
-
function publicAgent(agent) {
|
|
12142
|
-
const { key: _key, ...
|
|
12143
|
-
return
|
|
12315
|
+
function publicAgent(agent, staleAfterMs, now = Date.now()) {
|
|
12316
|
+
const { key: _key, ...identity } = agent;
|
|
12317
|
+
return toAgentRecord(identity, staleAfterMs, now);
|
|
12144
12318
|
}
|
|
12145
12319
|
function safeTokenEqual(actual, expected) {
|
|
12146
12320
|
return timingSafeStringCompare(actual, expected);
|
|
12147
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
|
+
}
|
|
12148
12349
|
function bearerToken(request) {
|
|
12149
12350
|
const header = request.headers.authorization;
|
|
12150
12351
|
return header?.startsWith("Bearer ") ? header.slice(7) : void 0;
|
|
@@ -12367,6 +12568,7 @@ function createMeshHub(options = {}) {
|
|
|
12367
12568
|
};
|
|
12368
12569
|
const webhookWorkflows2 = new Map((options.webhookWorkflows ?? []).map((workflow) => [workflow.id, workflow]));
|
|
12369
12570
|
const logger = options.logger ?? (() => void 0);
|
|
12571
|
+
const hubNow = options.now ?? (() => Date.now());
|
|
12370
12572
|
const assetsDir2 = options.assetsDir;
|
|
12371
12573
|
const hubRepoRoot = options.repoRoot ?? (options.dataPath && options.dataPath !== ":memory:" ? resolve6(dirname5(dirname5(options.dataPath))) : process.cwd());
|
|
12372
12574
|
const store = new MeshStore(options.dataPath);
|
|
@@ -12415,7 +12617,10 @@ function createMeshHub(options = {}) {
|
|
|
12415
12617
|
journalEntries: 0,
|
|
12416
12618
|
contextRequests: 0,
|
|
12417
12619
|
attemptLatencySecondsTotal: 0,
|
|
12418
|
-
meteredCostUsdTotal: 0
|
|
12620
|
+
meteredCostUsdTotal: 0,
|
|
12621
|
+
leasesGranted: 0,
|
|
12622
|
+
leasesRefused: 0,
|
|
12623
|
+
leasesReleased: 0
|
|
12419
12624
|
};
|
|
12420
12625
|
let cleanupTimer;
|
|
12421
12626
|
let closed = false;
|
|
@@ -12560,7 +12765,8 @@ data: ${JSON.stringify({ type: "ops", project, topic, at: nowIso() })}
|
|
|
12560
12765
|
}
|
|
12561
12766
|
}
|
|
12562
12767
|
function opsSnapshot(project) {
|
|
12563
|
-
const
|
|
12768
|
+
const snapshotAt = Date.now();
|
|
12769
|
+
const projectAgents = [...agents.values()].filter((agent) => agent.project === project).map((agent) => publicAgent(agent, staleAfterMs, snapshotAt)).sort((left, right) => Number(right.online) - Number(left.online) || left.name.localeCompare(right.name));
|
|
12564
12770
|
const open = store.getOpenMessages(project);
|
|
12565
12771
|
const projectRuns = [...workflowRuns.values()].filter((run) => run.project === project).sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt));
|
|
12566
12772
|
return {
|
|
@@ -12610,7 +12816,7 @@ data: ${JSON.stringify({ type: "ops", project, topic, at: nowIso() })}
|
|
|
12610
12816
|
function broadcastPresence(agent) {
|
|
12611
12817
|
for (const candidate of agents.values()) {
|
|
12612
12818
|
if (candidate.project === agent.project && candidate.id !== agent.id && candidate.online) {
|
|
12613
|
-
publish(candidate.id, { type: "presence", agent: publicAgent(agent) });
|
|
12819
|
+
publish(candidate.id, { type: "presence", agent: publicAgent(agent, staleAfterMs) });
|
|
12614
12820
|
}
|
|
12615
12821
|
}
|
|
12616
12822
|
publishOps(agent.project, "agents");
|
|
@@ -12948,6 +13154,16 @@ data: ${JSON.stringify({ type: "ops", project, topic, at: nowIso() })}
|
|
|
12948
13154
|
for (const runId of swept.purgedRuns) {
|
|
12949
13155
|
logger({ event: "workflow_run_purged", runId });
|
|
12950
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
|
+
}
|
|
12951
13167
|
}
|
|
12952
13168
|
function metricsBody() {
|
|
12953
13169
|
const onlineAgents = [...agents.values()].filter((agent) => agent.online).length;
|
|
@@ -12994,6 +13210,12 @@ data: ${JSON.stringify({ type: "ops", project, topic, at: nowIso() })}
|
|
|
12994
13210
|
`kxm_attempt_latency_seconds_total ${counters.attemptLatencySecondsTotal}`,
|
|
12995
13211
|
"# TYPE kxm_metered_cost_usd_total counter",
|
|
12996
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}`,
|
|
12997
13219
|
""
|
|
12998
13220
|
].join("\n");
|
|
12999
13221
|
}
|
|
@@ -13912,6 +14134,7 @@ data: ${JSON.stringify({ type: "ops", project, topic: "agents", at: nowIso() })}
|
|
|
13912
14134
|
const project = requireString(body.project, "project", { max: 128 });
|
|
13913
14135
|
requireProjectAuth(request, project);
|
|
13914
14136
|
const model = optionalString(body.model, "model", 128);
|
|
14137
|
+
const hostLabel = optionalString(body.host, "host", MAX_AGENT_HOST_CHARS);
|
|
13915
14138
|
const existing = [...agents.values()].find(
|
|
13916
14139
|
(agent2) => agent2.project === project && agent2.name.toLowerCase() === name.toLowerCase()
|
|
13917
14140
|
);
|
|
@@ -13938,17 +14161,27 @@ data: ${JSON.stringify({ type: "ops", project, topic: "agents", at: nowIso() })}
|
|
|
13938
14161
|
agent.online = true;
|
|
13939
14162
|
if (model) agent.model = model;
|
|
13940
14163
|
else delete agent.model;
|
|
14164
|
+
if (hostLabel) agent.host = hostLabel;
|
|
14165
|
+
else delete agent.host;
|
|
13941
14166
|
store.saveAgent(agent);
|
|
13942
14167
|
counters.registrations += 1;
|
|
13943
|
-
logger({
|
|
14168
|
+
logger({
|
|
14169
|
+
event: existing ? "agent_resumed" : "agent_registered",
|
|
14170
|
+
agentId: agent.id,
|
|
14171
|
+
name,
|
|
14172
|
+
project,
|
|
14173
|
+
...hostLabel ? { host: hostLabel } : {}
|
|
14174
|
+
});
|
|
13944
14175
|
broadcastPresence(agent);
|
|
13945
|
-
json(response, existing ? 200 : 201, { agent: publicAgent(agent), agentKey: agent.key, resumed: Boolean(existing) });
|
|
14176
|
+
json(response, existing ? 200 : 201, { agent: publicAgent(agent, staleAfterMs), agentKey: agent.key, resumed: Boolean(existing) });
|
|
13946
14177
|
return;
|
|
13947
14178
|
}
|
|
13948
14179
|
if (method === "GET" && url.pathname === "/v1/agents") {
|
|
13949
14180
|
const current = requireAgent(request);
|
|
13950
14181
|
requireProjectAuth(request, current.project);
|
|
13951
|
-
const
|
|
14182
|
+
const includeOffline = url.searchParams.get("includeOffline") === "true";
|
|
14183
|
+
const listedAt = Date.now();
|
|
14184
|
+
const result = [...agents.values()].filter((agent) => agent.project === current.project && (agent.online || includeOffline)).map((agent) => publicAgent(agent, staleAfterMs, listedAt));
|
|
13952
14185
|
json(response, 200, { agents: result });
|
|
13953
14186
|
return;
|
|
13954
14187
|
}
|
|
@@ -13957,7 +14190,7 @@ data: ${JSON.stringify({ type: "ops", project, topic: "agents", at: nowIso() })}
|
|
|
13957
14190
|
const current = requireAgent(request, decodeURIComponent(heartbeatMatch[1]));
|
|
13958
14191
|
requireProjectAuth(request, current.project);
|
|
13959
14192
|
await readJson(request);
|
|
13960
|
-
json(response, 200, { agent: publicAgent(current) });
|
|
14193
|
+
json(response, 200, { agent: publicAgent(current, staleAfterMs) });
|
|
13961
14194
|
return;
|
|
13962
14195
|
}
|
|
13963
14196
|
const agentMatch = url.pathname.match(/^\/v1\/agents\/([^/]+)$/);
|
|
@@ -13972,6 +14205,70 @@ data: ${JSON.stringify({ type: "ops", project, topic: "agents", at: nowIso() })}
|
|
|
13972
14205
|
response.writeHead(204, { "cache-control": "no-store" }).end();
|
|
13973
14206
|
return;
|
|
13974
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
|
+
}
|
|
13975
14272
|
if (method === "GET" && url.pathname === "/v1/events") {
|
|
13976
14273
|
const agentId = requireString(url.searchParams.get("agentId"), "agentId", { max: 80 });
|
|
13977
14274
|
const current = requireAgent(request, agentId);
|
|
@@ -13989,7 +14286,7 @@ data: ${JSON.stringify({ type: "ops", project, topic: "agents", at: nowIso() })}
|
|
|
13989
14286
|
...presenceOnly ? { "x-kxm-events-mode": "presence" } : {}
|
|
13990
14287
|
});
|
|
13991
14288
|
response.write(`event: ready
|
|
13992
|
-
data: ${JSON.stringify({ agent: publicAgent(current) })}
|
|
14289
|
+
data: ${JSON.stringify({ agent: publicAgent(current, staleAfterMs) })}
|
|
13993
14290
|
|
|
13994
14291
|
`);
|
|
13995
14292
|
const client = {
|
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.
|
|
@@ -15,7 +15,9 @@ All commands support `--json` for machine-readable output.
|
|
|
15
15
|
|
|
16
16
|
| Command | Purpose | Key Options |
|
|
17
17
|
|---|---|---|
|
|
18
|
-
| `kxm peer list` | List
|
|
18
|
+
| `kxm peer list` | List peer agents with purpose, host label, and presence | `--json`, `--include-offline` |
|
|
19
|
+
|
|
20
|
+
Every listed peer carries `host` (the box it declared at registration), `lastSeenAt`, `leaseExpiresAt`, and `presence`. Presence is the hub's own reading of its heartbeat lease: `online` holds the lease, `stale` has passed `leaseExpiresAt` but has not been swept yet, and `offline` is a registered peer the hub has retired. Offline peers are listed only with `--include-offline`. The host label is a reading aid, never a permission.
|
|
19
21
|
|
|
20
22
|
### Sending Requests (`kxm peer send`)
|
|
21
23
|
|
package/plugins/kxm/src/cli.ts
CHANGED
|
@@ -533,7 +533,8 @@ function createProgram(ctx: CliContext, result: { code: number }): Command {
|
|
|
533
533
|
const peer = addGlobalOptions(program.command("peer").description("Peer agent messaging and coordination"));
|
|
534
534
|
peer.helpCommand("help", "Show peer help");
|
|
535
535
|
|
|
536
|
-
addGlobalOptions(peer.command("list").description("List
|
|
536
|
+
addGlobalOptions(peer.command("list").description("List peer agents in this project's hub pool with host and presence"))
|
|
537
|
+
.option("--include-offline", "Also list registered peers whose hub lease has expired")
|
|
537
538
|
.option("--payload <json>", "JSON payload")
|
|
538
539
|
.action(async function peerListAction(this: Command, opts?: Record<string, unknown>) {
|
|
539
540
|
result.code = await dispatchAgentCliCommand(runtimeFrom(ctx, this), "kxm_list", opts ?? {});
|