@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
package/plugins/kxm/src/hub.ts
CHANGED
|
@@ -4,15 +4,20 @@ import { createServer, type IncomingMessage, type Server, type ServerResponse }
|
|
|
4
4
|
import { isIP } from "node:net";
|
|
5
5
|
import { dirname, join, resolve } from "node:path";
|
|
6
6
|
import {
|
|
7
|
+
DEFAULT_LEASE_TTL_MS,
|
|
7
8
|
DEFAULT_MAX_HOPS,
|
|
8
9
|
DEFAULT_MESSAGE_RETENTION_MS,
|
|
9
10
|
DEFAULT_MESSAGE_TTL_MS,
|
|
10
11
|
DEFAULT_RATE_LIMIT_MAX,
|
|
11
12
|
DEFAULT_RATE_LIMIT_WINDOW_MS,
|
|
12
13
|
DEFAULT_STALE_AFTER_MS,
|
|
14
|
+
MAX_AGENT_HOST_CHARS,
|
|
13
15
|
MAX_BODY_BYTES,
|
|
14
16
|
MAX_CONTENT_CHARS,
|
|
17
|
+
MAX_LEASE_RESOURCE_CHARS,
|
|
18
|
+
MAX_LEASE_TTL_MS,
|
|
15
19
|
MAX_MESSAGE_TTL_MS,
|
|
20
|
+
MIN_LEASE_TTL_MS,
|
|
16
21
|
MIN_MESSAGE_TTL_MS,
|
|
17
22
|
MIN_MESSAGE_RETENTION_MS,
|
|
18
23
|
ProtocolError,
|
|
@@ -22,9 +27,11 @@ import {
|
|
|
22
27
|
parseBoundedInteger,
|
|
23
28
|
parseDeliveryMode,
|
|
24
29
|
requireString,
|
|
30
|
+
toAgentRecord,
|
|
25
31
|
type AgentRecord,
|
|
26
32
|
type DeliveryMode,
|
|
27
33
|
type HubEvent,
|
|
34
|
+
type LeaseRecord,
|
|
28
35
|
type MessageRecord,
|
|
29
36
|
type WorkflowMessageContext,
|
|
30
37
|
} from "./protocol.ts";
|
|
@@ -37,7 +44,7 @@ import { NativeStateProvider } from "./state.ts";
|
|
|
37
44
|
import { SkillLifecycle } from "./skills.ts";
|
|
38
45
|
import { compileKnowledgeWiki, lintKnowledgeWiki, type WikiSourcePool } from "./wiki.ts";
|
|
39
46
|
import { buildRetrospective, writeRetrospective } from "./retrospective.ts";
|
|
40
|
-
import { MeshStore, type StoredAgent } from "./store.ts";
|
|
47
|
+
import { MeshStore, type LeaseRefusal, type StoredAgent } from "./store.ts";
|
|
41
48
|
import {
|
|
42
49
|
canonicalWorkflowEvidenceKey,
|
|
43
50
|
approveWorkflowDegradation,
|
|
@@ -89,6 +96,10 @@ export interface MeshHubOptions {
|
|
|
89
96
|
skillsDir?: string;
|
|
90
97
|
skillLifecycle?: SkillLifecycle;
|
|
91
98
|
repoRoot?: string;
|
|
99
|
+
/** The hub clock every lease decision reads. Injectable so a test can move a
|
|
100
|
+
* lease past its deadline instead of sleeping through a real TTL; production
|
|
101
|
+
* leaves it at `Date.now`. It is never a client-supplied time. */
|
|
102
|
+
now?: () => number;
|
|
92
103
|
}
|
|
93
104
|
|
|
94
105
|
export interface MeshHub {
|
|
@@ -118,15 +129,50 @@ function isLoopback(host: string): boolean {
|
|
|
118
129
|
return isIP(host) === 4 && host.startsWith("127.");
|
|
119
130
|
}
|
|
120
131
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
132
|
+
/** Drop the agent key and attach hub-clocked presence. Every caller passes the
|
|
133
|
+
* hub's own `staleAfterMs` and clock, so no reader ever sees a lease derived
|
|
134
|
+
* from a client timestamp. */
|
|
135
|
+
function publicAgent(agent: StoredAgent, staleAfterMs: number, now = Date.now()): AgentRecord {
|
|
136
|
+
const { key: _key, ...identity } = agent;
|
|
137
|
+
return toAgentRecord(identity, staleAfterMs, now);
|
|
124
138
|
}
|
|
125
139
|
|
|
126
140
|
function safeTokenEqual(actual: string | undefined, expected: string): boolean {
|
|
127
141
|
return timingSafeStringCompare(actual, expected);
|
|
128
142
|
}
|
|
129
143
|
|
|
144
|
+
/** The wire answer for a lease the hub will not extend or release. Every one of
|
|
145
|
+
* these means the caller's token no longer authorizes a shared write: the
|
|
146
|
+
* holder must stop, not retry. The current lease rides along so the caller can
|
|
147
|
+
* record who holds the resource now. */
|
|
148
|
+
function leaseRefusal(reason: LeaseRefusal, name: string, lease?: LeaseRecord): ProtocolError {
|
|
149
|
+
if (reason === "missing") {
|
|
150
|
+
return new ProtocolError(404, `no lease is held on ${name}`, "lease_not_found");
|
|
151
|
+
}
|
|
152
|
+
if (reason === "expired") {
|
|
153
|
+
return new ProtocolError(
|
|
154
|
+
409,
|
|
155
|
+
`lease on ${name} expired at ${lease?.expiresAt ?? "its deadline"}; re-acquire to take it over`,
|
|
156
|
+
"lease_expired",
|
|
157
|
+
{ lease },
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
if (reason === "held") {
|
|
161
|
+
return new ProtocolError(
|
|
162
|
+
409,
|
|
163
|
+
`lease on ${name} is held by ${lease?.holderAgentName ?? "another agent"} until ${lease?.expiresAt ?? "its deadline"}`,
|
|
164
|
+
"lease_held",
|
|
165
|
+
{ lease },
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
return new ProtocolError(
|
|
169
|
+
409,
|
|
170
|
+
`fencing token for ${name} was superseded; the hub is now at token ${lease?.fencingToken ?? "a newer value"}`,
|
|
171
|
+
"lease_superseded",
|
|
172
|
+
{ lease },
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
130
176
|
function bearerToken(request: IncomingMessage): string | undefined {
|
|
131
177
|
const header = request.headers.authorization;
|
|
132
178
|
return header?.startsWith("Bearer ") ? header.slice(7) : undefined;
|
|
@@ -404,6 +450,7 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
|
|
|
404
450
|
};
|
|
405
451
|
const webhookWorkflows = new Map((options.webhookWorkflows ?? []).map((workflow) => [workflow.id, workflow]));
|
|
406
452
|
const logger = options.logger ?? (() => undefined);
|
|
453
|
+
const hubNow = options.now ?? (() => Date.now());
|
|
407
454
|
const assetsDir = options.assetsDir;
|
|
408
455
|
const hubRepoRoot = options.repoRoot ?? (options.dataPath && options.dataPath !== ":memory:" ? resolve(dirname(dirname(options.dataPath))) : process.cwd());
|
|
409
456
|
const store = new MeshStore(options.dataPath);
|
|
@@ -456,6 +503,9 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
|
|
|
456
503
|
contextRequests: 0,
|
|
457
504
|
attemptLatencySecondsTotal: 0,
|
|
458
505
|
meteredCostUsdTotal: 0,
|
|
506
|
+
leasesGranted: 0,
|
|
507
|
+
leasesRefused: 0,
|
|
508
|
+
leasesReleased: 0,
|
|
459
509
|
};
|
|
460
510
|
let cleanupTimer: NodeJS.Timeout | undefined;
|
|
461
511
|
let closed = false;
|
|
@@ -623,9 +673,10 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
|
|
|
623
673
|
}
|
|
624
674
|
|
|
625
675
|
function opsSnapshot(project: string) {
|
|
676
|
+
const snapshotAt = Date.now();
|
|
626
677
|
const projectAgents = [...agents.values()]
|
|
627
678
|
.filter((agent) => agent.project === project)
|
|
628
|
-
.map(publicAgent)
|
|
679
|
+
.map((agent) => publicAgent(agent, staleAfterMs, snapshotAt))
|
|
629
680
|
.sort((left, right) => Number(right.online) - Number(left.online) || left.name.localeCompare(right.name));
|
|
630
681
|
const open = store.getOpenMessages(project);
|
|
631
682
|
const projectRuns = [...workflowRuns.values()]
|
|
@@ -683,7 +734,7 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
|
|
|
683
734
|
function broadcastPresence(agent: StoredAgent): void {
|
|
684
735
|
for (const candidate of agents.values()) {
|
|
685
736
|
if (candidate.project === agent.project && candidate.id !== agent.id && candidate.online) {
|
|
686
|
-
publish(candidate.id, { type: "presence", agent: publicAgent(agent) });
|
|
737
|
+
publish(candidate.id, { type: "presence", agent: publicAgent(agent, staleAfterMs) });
|
|
687
738
|
}
|
|
688
739
|
}
|
|
689
740
|
publishOps(agent.project, "agents");
|
|
@@ -1059,6 +1110,16 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
|
|
|
1059
1110
|
for (const runId of swept.purgedRuns) {
|
|
1060
1111
|
logger({ event: "workflow_run_purged", runId });
|
|
1061
1112
|
}
|
|
1113
|
+
for (const lease of swept.purgedLeases) {
|
|
1114
|
+
logger({
|
|
1115
|
+
event: "lease_purged",
|
|
1116
|
+
resource: lease.resource,
|
|
1117
|
+
project: lease.project,
|
|
1118
|
+
agentId: lease.holderAgentId,
|
|
1119
|
+
fencingToken: lease.fencingToken,
|
|
1120
|
+
expiresAt: lease.expiresAt,
|
|
1121
|
+
});
|
|
1122
|
+
}
|
|
1062
1123
|
}
|
|
1063
1124
|
|
|
1064
1125
|
function metricsBody(): string {
|
|
@@ -1106,6 +1167,12 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
|
|
|
1106
1167
|
`kxm_attempt_latency_seconds_total ${counters.attemptLatencySecondsTotal}`,
|
|
1107
1168
|
"# TYPE kxm_metered_cost_usd_total counter",
|
|
1108
1169
|
`kxm_metered_cost_usd_total ${counters.meteredCostUsdTotal}`,
|
|
1170
|
+
"# TYPE kxm_leases_granted_total counter",
|
|
1171
|
+
`kxm_leases_granted_total ${counters.leasesGranted}`,
|
|
1172
|
+
"# TYPE kxm_leases_refused_total counter",
|
|
1173
|
+
`kxm_leases_refused_total ${counters.leasesRefused}`,
|
|
1174
|
+
"# TYPE kxm_leases_released_total counter",
|
|
1175
|
+
`kxm_leases_released_total ${counters.leasesReleased}`,
|
|
1109
1176
|
"",
|
|
1110
1177
|
].join("\n");
|
|
1111
1178
|
}
|
|
@@ -2109,6 +2176,9 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
|
|
|
2109
2176
|
const project = requireString(body.project, "project", { max: 128 });
|
|
2110
2177
|
requireProjectAuth(request, project);
|
|
2111
2178
|
const model = optionalString(body.model, "model", 128);
|
|
2179
|
+
// A client declares which box it runs on. The label is a reading aid
|
|
2180
|
+
// only: the project token is still the whole of admission.
|
|
2181
|
+
const hostLabel = optionalString(body.host, "host", MAX_AGENT_HOST_CHARS);
|
|
2112
2182
|
const existing = [...agents.values()].find(
|
|
2113
2183
|
(agent) => agent.project === project && agent.name.toLowerCase() === name.toLowerCase(),
|
|
2114
2184
|
);
|
|
@@ -2135,20 +2205,34 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
|
|
|
2135
2205
|
agent.online = true;
|
|
2136
2206
|
if (model) agent.model = model;
|
|
2137
2207
|
else delete agent.model;
|
|
2208
|
+
if (hostLabel) agent.host = hostLabel;
|
|
2209
|
+
else delete agent.host;
|
|
2138
2210
|
store.saveAgent(agent);
|
|
2139
2211
|
counters.registrations += 1;
|
|
2140
|
-
logger({
|
|
2212
|
+
logger({
|
|
2213
|
+
event: existing ? "agent_resumed" : "agent_registered",
|
|
2214
|
+
agentId: agent.id,
|
|
2215
|
+
name,
|
|
2216
|
+
project,
|
|
2217
|
+
...(hostLabel ? { host: hostLabel } : {}),
|
|
2218
|
+
});
|
|
2141
2219
|
broadcastPresence(agent);
|
|
2142
|
-
json(response, existing ? 200 : 201, { agent: publicAgent(agent), agentKey: agent.key, resumed: Boolean(existing) });
|
|
2220
|
+
json(response, existing ? 200 : 201, { agent: publicAgent(agent, staleAfterMs), agentKey: agent.key, resumed: Boolean(existing) });
|
|
2143
2221
|
return;
|
|
2144
2222
|
}
|
|
2145
2223
|
|
|
2146
2224
|
if (method === "GET" && url.pathname === "/v1/agents") {
|
|
2147
2225
|
const current = requireAgent(request);
|
|
2148
2226
|
requireProjectAuth(request, current.project);
|
|
2227
|
+
// Offline members are registered peers of the caller's own project
|
|
2228
|
+
// whose lease the sweep has already retired. They stay out of the
|
|
2229
|
+
// list unless a caller asks for them, so discovery keeps meaning
|
|
2230
|
+
// "who can answer right now".
|
|
2231
|
+
const includeOffline = url.searchParams.get("includeOffline") === "true";
|
|
2232
|
+
const listedAt = Date.now();
|
|
2149
2233
|
const result = [...agents.values()]
|
|
2150
|
-
.filter((agent) => agent.project === current.project && agent.online)
|
|
2151
|
-
.map(publicAgent);
|
|
2234
|
+
.filter((agent) => agent.project === current.project && (agent.online || includeOffline))
|
|
2235
|
+
.map((agent) => publicAgent(agent, staleAfterMs, listedAt));
|
|
2152
2236
|
json(response, 200, { agents: result });
|
|
2153
2237
|
return;
|
|
2154
2238
|
}
|
|
@@ -2158,7 +2242,7 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
|
|
|
2158
2242
|
const current = requireAgent(request, decodeURIComponent(heartbeatMatch[1]!));
|
|
2159
2243
|
requireProjectAuth(request, current.project);
|
|
2160
2244
|
await readJson(request);
|
|
2161
|
-
json(response, 200, { agent: publicAgent(current) });
|
|
2245
|
+
json(response, 200, { agent: publicAgent(current, staleAfterMs) });
|
|
2162
2246
|
return;
|
|
2163
2247
|
}
|
|
2164
2248
|
|
|
@@ -2175,6 +2259,81 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
|
|
|
2175
2259
|
return;
|
|
2176
2260
|
}
|
|
2177
2261
|
|
|
2262
|
+
// One writer, one clock. Every branch below decides inside a single store
|
|
2263
|
+
// transaction, so two agents racing for the same resource cannot both read it
|
|
2264
|
+
// free. The fencing token increments only on takeover, which is what lets a
|
|
2265
|
+
// holder that slept past its deadline be refused at commit instead of writing
|
|
2266
|
+
// behind whoever replaced it.
|
|
2267
|
+
const leaseMatch = url.pathname.match(/^\/v1\/leases\/([^/]+)\/(acquire|renew|release)$/);
|
|
2268
|
+
if (method === "POST" && leaseMatch) {
|
|
2269
|
+
const holder = requireAgent(request);
|
|
2270
|
+
requireProjectAuth(request, holder.project);
|
|
2271
|
+
const name = requireString(decodeURIComponent(leaseMatch[1]!), "resource", { max: MAX_LEASE_RESOURCE_CHARS });
|
|
2272
|
+
const action = leaseMatch[2]!;
|
|
2273
|
+
const body = await readJson(request);
|
|
2274
|
+
// The caller never names the prefix, so the same branch in two projects is
|
|
2275
|
+
// two leases and neither project can reach the other's.
|
|
2276
|
+
const resource = `${holder.project}/${name}`;
|
|
2277
|
+
const nowMs = hubNow();
|
|
2278
|
+
const leaseLog = { resource, project: holder.project, agentId: holder.id, agentName: holder.name };
|
|
2279
|
+
|
|
2280
|
+
if (action === "acquire") {
|
|
2281
|
+
const ttlMs = parseBoundedInteger(body.ttlMs, "ttlMs", DEFAULT_LEASE_TTL_MS, MIN_LEASE_TTL_MS, MAX_LEASE_TTL_MS);
|
|
2282
|
+
const outcome = store.acquireLease({
|
|
2283
|
+
resource,
|
|
2284
|
+
project: holder.project,
|
|
2285
|
+
name,
|
|
2286
|
+
holderAgentId: holder.id,
|
|
2287
|
+
holderAgentName: holder.name,
|
|
2288
|
+
ttlMs,
|
|
2289
|
+
nowMs,
|
|
2290
|
+
});
|
|
2291
|
+
if (!outcome.ok) {
|
|
2292
|
+
counters.leasesRefused += 1;
|
|
2293
|
+
logger({ event: "lease_denied", ...leaseLog, reason: outcome.reason, heldBy: outcome.lease?.holderAgentId });
|
|
2294
|
+
throw leaseRefusal(outcome.reason, name, outcome.lease);
|
|
2295
|
+
}
|
|
2296
|
+
counters.leasesGranted += 1;
|
|
2297
|
+
logger({
|
|
2298
|
+
event: outcome.renewed ? "lease_renewed" : "lease_acquired",
|
|
2299
|
+
...leaseLog,
|
|
2300
|
+
fencingToken: outcome.lease.fencingToken,
|
|
2301
|
+
expiresAt: outcome.lease.expiresAt,
|
|
2302
|
+
});
|
|
2303
|
+
json(response, 200, { lease: outcome.lease, renewed: outcome.renewed });
|
|
2304
|
+
return;
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
if (body.fencingToken === undefined) {
|
|
2308
|
+
throw new ProtocolError(400, "fencingToken is required", "lease_token_required");
|
|
2309
|
+
}
|
|
2310
|
+
const fencingToken = parseBoundedInteger(body.fencingToken, "fencingToken", 1, 1, Number.MAX_SAFE_INTEGER);
|
|
2311
|
+
if (action === "renew") {
|
|
2312
|
+
const ttlMs = parseBoundedInteger(body.ttlMs, "ttlMs", DEFAULT_LEASE_TTL_MS, MIN_LEASE_TTL_MS, MAX_LEASE_TTL_MS);
|
|
2313
|
+
const outcome = store.renewLease({ resource, holderAgentId: holder.id, fencingToken, ttlMs, nowMs });
|
|
2314
|
+
if (!outcome.ok) {
|
|
2315
|
+
counters.leasesRefused += 1;
|
|
2316
|
+
logger({ event: "lease_denied", ...leaseLog, reason: outcome.reason, fencingToken });
|
|
2317
|
+
throw leaseRefusal(outcome.reason, name, outcome.lease);
|
|
2318
|
+
}
|
|
2319
|
+
counters.leasesGranted += 1;
|
|
2320
|
+
logger({ event: "lease_renewed", ...leaseLog, fencingToken, expiresAt: outcome.lease.expiresAt });
|
|
2321
|
+
json(response, 200, { lease: outcome.lease, renewed: true });
|
|
2322
|
+
return;
|
|
2323
|
+
}
|
|
2324
|
+
|
|
2325
|
+
const outcome = store.releaseLease({ resource, holderAgentId: holder.id, fencingToken });
|
|
2326
|
+
if (!outcome.ok) {
|
|
2327
|
+
counters.leasesRefused += 1;
|
|
2328
|
+
logger({ event: "lease_denied", ...leaseLog, reason: outcome.reason, fencingToken });
|
|
2329
|
+
throw leaseRefusal(outcome.reason, name, outcome.lease);
|
|
2330
|
+
}
|
|
2331
|
+
counters.leasesReleased += 1;
|
|
2332
|
+
logger({ event: "lease_released", ...leaseLog, fencingToken });
|
|
2333
|
+
json(response, 200, { released: true, lease: outcome.lease });
|
|
2334
|
+
return;
|
|
2335
|
+
}
|
|
2336
|
+
|
|
2178
2337
|
if (method === "GET" && url.pathname === "/v1/events") {
|
|
2179
2338
|
const agentId = requireString(url.searchParams.get("agentId"), "agentId", { max: 80 });
|
|
2180
2339
|
const current = requireAgent(request, agentId);
|
|
@@ -2191,7 +2350,7 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
|
|
|
2191
2350
|
"x-content-type-options": "nosniff",
|
|
2192
2351
|
...(presenceOnly ? { "x-kxm-events-mode": "presence" } : {}),
|
|
2193
2352
|
});
|
|
2194
|
-
response.write(`event: ready\ndata: ${JSON.stringify({ agent: publicAgent(current) })}\n\n`);
|
|
2353
|
+
response.write(`event: ready\ndata: ${JSON.stringify({ agent: publicAgent(current, staleAfterMs) })}\n\n`);
|
|
2195
2354
|
const client: SseClient = {
|
|
2196
2355
|
response,
|
|
2197
2356
|
heartbeat: setInterval(() => response.write(": heartbeat\n\n"), 15_000),
|
|
@@ -2,7 +2,7 @@ import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { isAbsolute, join, resolve } from "node:path";
|
|
4
4
|
import { DatabaseSync } from "./sqlite.ts";
|
|
5
|
-
import type { AgentRecord, MessageRecord } from "./protocol.ts";
|
|
5
|
+
import type { AgentIdentity, AgentRecord, MessageRecord } from "./protocol.ts";
|
|
6
6
|
import type { WorkflowRun } from "./workflow.ts";
|
|
7
7
|
import { readRoutingRecords } from "./telemetry.ts";
|
|
8
8
|
import type { RoutingRecord, RoutingRecordV2 } from "./routing.ts";
|
|
@@ -253,7 +253,15 @@ export function loadLocalMeshSnapshot(
|
|
|
253
253
|
const database = new DatabaseSync(dataPath, { readOnly: true });
|
|
254
254
|
try {
|
|
255
255
|
database.exec("PRAGMA busy_timeout = 5000");
|
|
256
|
-
|
|
256
|
+
// Stored rows carry identity only. Presence is derived here against the
|
|
257
|
+
// default lease window, because the hub's configured `staleAfterMs` is
|
|
258
|
+
// not in the file; a reachable hub's own records replace these.
|
|
259
|
+
// Stored rows carry identity only. Presence is NOT computed here: the hub's
|
|
260
|
+
// configured staleAfterMs and its clock are not in the file, so a reader-side
|
|
261
|
+
// lease would mislabel agents against the hub's own projection. The stored
|
|
262
|
+
// `online` boolean is the only durable truth; the hub's /v1/agents or the
|
|
263
|
+
// ops snapshot is the authoritative presence source.
|
|
264
|
+
agents = readJsonRows<AgentIdentity>(database, "SELECT record FROM agents");
|
|
257
265
|
openMessages = readOpenMessageMetadata(database);
|
|
258
266
|
openMessageTotal = countRows(database, "messages", " WHERE json_extract(record, '$.status') IN ('queued', 'delivered')");
|
|
259
267
|
legacyRuns = readJsonRows<WorkflowRun>(database, "SELECT record FROM workflow_runs ORDER BY rowid DESC LIMIT 8");
|
|
@@ -8,7 +8,7 @@ import { AGENT_COMMANDS_MAP, enforceToolPolicy, getMcpTools, reconcileInbox } fr
|
|
|
8
8
|
import { deliverInboxNotification } from "./inbox.ts";
|
|
9
9
|
import type { HubEvent, MessageRecord } from "./protocol.ts";
|
|
10
10
|
|
|
11
|
-
const VERSION = "0.7.
|
|
11
|
+
const VERSION = "0.7.72";
|
|
12
12
|
const inbox = new Map<string, MessageRecord>();
|
|
13
13
|
const notifiedInbox = new Set<string>();
|
|
14
14
|
let meshClient: HubClient | undefined;
|
|
@@ -12,21 +12,87 @@ export const DEFAULT_RATE_LIMIT_MAX = 600;
|
|
|
12
12
|
export const DEFAULT_RATE_LIMIT_WINDOW_MS = 60_000;
|
|
13
13
|
export const MAX_BODY_BYTES = 256 * 1024;
|
|
14
14
|
export const MAX_CONTENT_CHARS = 32_000;
|
|
15
|
+
export const MAX_AGENT_HOST_CHARS = 64;
|
|
16
|
+
export const MIN_LEASE_TTL_MS = 5_000;
|
|
17
|
+
export const MAX_LEASE_TTL_MS = 10 * 60_000;
|
|
18
|
+
export const DEFAULT_LEASE_TTL_MS = 5 * 60_000;
|
|
19
|
+
export const MAX_LEASE_RESOURCE_CHARS = 200;
|
|
15
20
|
|
|
16
21
|
export type DeliveryMode = "steer" | "followUp" | "nextTurn";
|
|
17
22
|
export type MessageStatus = "queued" | "delivered" | "replied" | "cancelled" | "expired" | "error";
|
|
23
|
+
export type AgentPresence = "online" | "stale" | "offline";
|
|
18
24
|
|
|
19
|
-
|
|
25
|
+
/** The durable half of an agent: exactly the fields the hub persists in the
|
|
26
|
+
* agents record JSON. Presence is never stored, so a restored database can
|
|
27
|
+
* never claim an agent was alive. */
|
|
28
|
+
export interface AgentIdentity {
|
|
20
29
|
id: string;
|
|
21
30
|
name: string;
|
|
22
31
|
purpose: string;
|
|
23
32
|
project: string;
|
|
24
33
|
model?: string;
|
|
34
|
+
/** Client-declared label for the box this agent runs on. It exists so a
|
|
35
|
+
* reader can tell two boxes apart; the hub never reads it for
|
|
36
|
+
* authorization, project scope, or target resolution. */
|
|
37
|
+
host?: string;
|
|
25
38
|
connectedAt: string;
|
|
26
39
|
lastSeenAt: string;
|
|
27
40
|
online: boolean;
|
|
28
41
|
}
|
|
29
42
|
|
|
43
|
+
/** The public projection of an agent: durable identity plus presence derived
|
|
44
|
+
* from the hub clock at read time. */
|
|
45
|
+
export interface AgentRecord extends AgentIdentity {
|
|
46
|
+
/** `lastSeenAt + staleAfterMs`: the heartbeat lease the stale sweep enforces.
|
|
47
|
+
* Absent when projected from the local store without the hub's clock or lease. */
|
|
48
|
+
leaseExpiresAt?: string;
|
|
49
|
+
/** Hub-clocked presence. Absent when the reader has no hub projection. */
|
|
50
|
+
presence?: AgentPresence;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Presence is hub-clocked and never client-reported. An agent holds its lease
|
|
54
|
+
* until `lastSeenAt + staleAfterMs`; between lease expiry and the sweep that
|
|
55
|
+
* retires it, it reads `stale`; once retired it reads `offline`. An
|
|
56
|
+
* unparseable `lastSeenAt` leaves the lease at the epoch, which reads as an
|
|
57
|
+
* expired lease rather than as a live agent. */
|
|
58
|
+
export function agentPresenceView(
|
|
59
|
+
agent: AgentIdentity,
|
|
60
|
+
staleAfterMs: number = DEFAULT_STALE_AFTER_MS,
|
|
61
|
+
now: number = Date.now(),
|
|
62
|
+
): { leaseExpiresAt: string; presence: AgentPresence } {
|
|
63
|
+
const lastSeenMs = Date.parse(agent.lastSeenAt);
|
|
64
|
+
const leaseExpiresAtMs = (Number.isFinite(lastSeenMs) ? lastSeenMs : 0) + staleAfterMs;
|
|
65
|
+
const leaseExpiresAt = new Date(leaseExpiresAtMs).toISOString();
|
|
66
|
+
if (!agent.online) return { leaseExpiresAt, presence: "offline" };
|
|
67
|
+
return { leaseExpiresAt, presence: now < leaseExpiresAtMs ? "online" : "stale" };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Project a durable identity onto the wire shape readers consume. */
|
|
71
|
+
export function toAgentRecord(agent: AgentIdentity, staleAfterMs?: number, now?: number): AgentRecord {
|
|
72
|
+
return { ...agent, ...agentPresenceView(agent, staleAfterMs, now) };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** One hub-held fenced lease over a project-scoped resource.
|
|
76
|
+
*
|
|
77
|
+
* `resource` is what the store keys on: the caller's project prefixed onto the
|
|
78
|
+
* name it asked for, so two projects naming the same branch never contend.
|
|
79
|
+
* Expiry is the hub's clock, never the holder's, and `fencingToken` increments
|
|
80
|
+
* only when a new holder takes over an expired lease — a renewal keeps its
|
|
81
|
+
* token, so a writer that comes back after a takeover can be told its token
|
|
82
|
+
* was superseded instead of being allowed to commit. */
|
|
83
|
+
export interface LeaseRecord {
|
|
84
|
+
/** `${project}/${name}`. */
|
|
85
|
+
resource: string;
|
|
86
|
+
project: string;
|
|
87
|
+
/** The resource as the caller named it, without the project prefix. */
|
|
88
|
+
name: string;
|
|
89
|
+
holderAgentId: string;
|
|
90
|
+
holderAgentName: string;
|
|
91
|
+
fencingToken: number;
|
|
92
|
+
acquiredAt: string;
|
|
93
|
+
expiresAt: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
30
96
|
export interface MessageReply {
|
|
31
97
|
content: string;
|
|
32
98
|
createdAt: string;
|