@kontextmind/kxm 0.7.71 → 0.7.73
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +22 -0
- package/docs/contracts/synchronization.md +6 -3
- package/docs/operations.md +68 -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 +346 -234
- package/plugins/kxm/dist/client.js +100 -35
- package/plugins/kxm/dist/core.js +10 -0
- package/plugins/kxm/dist/extension.js +73 -36
- package/plugins/kxm/dist/mcp-server.js +73 -36
- package/plugins/kxm/dist/runtime-supervisor.js +917 -141
- package/plugins/kxm/dist/runtime.js +977 -235
- package/plugins/kxm/dist/server.js +18584 -10365
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/skills/kxm/references/protocol.md +18 -0
- package/plugins/kxm/src/client.ts +124 -4
- package/plugins/kxm/src/database.ts +8 -4
- package/plugins/kxm/src/external-effects.ts +390 -66
- package/plugins/kxm/src/hub.ts +358 -1
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/project-config.ts +15 -0
- package/plugins/kxm/src/protocol.ts +52 -0
- package/plugins/kxm/src/runtime-store.ts +109 -1
- package/plugins/kxm/src/runtime-supervisor.ts +150 -0
- package/plugins/kxm/src/store.ts +416 -5
- package/plugins/kxm/src/sync-transform.ts +380 -0
package/plugins/kxm/src/hub.ts
CHANGED
|
@@ -4,6 +4,7 @@ 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,
|
|
@@ -13,7 +14,11 @@ import {
|
|
|
13
14
|
MAX_AGENT_HOST_CHARS,
|
|
14
15
|
MAX_BODY_BYTES,
|
|
15
16
|
MAX_CONTENT_CHARS,
|
|
17
|
+
MAX_LEASE_RESOURCE_CHARS,
|
|
18
|
+
MAX_LEASE_TTL_MS,
|
|
16
19
|
MAX_MESSAGE_TTL_MS,
|
|
20
|
+
MAX_SYNC_BATCH_EVENTS,
|
|
21
|
+
MIN_LEASE_TTL_MS,
|
|
17
22
|
MIN_MESSAGE_TTL_MS,
|
|
18
23
|
MIN_MESSAGE_RETENTION_MS,
|
|
19
24
|
ProtocolError,
|
|
@@ -27,9 +32,14 @@ import {
|
|
|
27
32
|
type AgentRecord,
|
|
28
33
|
type DeliveryMode,
|
|
29
34
|
type HubEvent,
|
|
35
|
+
type LeaseRecord,
|
|
30
36
|
type MessageRecord,
|
|
37
|
+
type RuntimePresenceRecord,
|
|
38
|
+
type StoredSyncEvent,
|
|
31
39
|
type WorkflowMessageContext,
|
|
32
40
|
} from "./protocol.ts";
|
|
41
|
+
import { kxmCanonicalJson, syncEventSchemaErrors, type JsonValue } from "./project-config.ts";
|
|
42
|
+
import { kxmSyncEventHash } from "./sync-transform.ts";
|
|
33
43
|
import { workflowScopeExtras } from "./diagnostics.ts";
|
|
34
44
|
import { timingSafeStringCompare } from "./commands.ts";
|
|
35
45
|
import { arbitrate, explainContextItem, journalEntryToContextItem, memoryRecordToContextItem, rolePolicy } from "./arbiter.ts";
|
|
@@ -39,7 +49,7 @@ import { NativeStateProvider } from "./state.ts";
|
|
|
39
49
|
import { SkillLifecycle } from "./skills.ts";
|
|
40
50
|
import { compileKnowledgeWiki, lintKnowledgeWiki, type WikiSourcePool } from "./wiki.ts";
|
|
41
51
|
import { buildRetrospective, writeRetrospective } from "./retrospective.ts";
|
|
42
|
-
import { MeshStore, type StoredAgent } from "./store.ts";
|
|
52
|
+
import { MeshStore, type LeaseRefusal, type StoredAgent } from "./store.ts";
|
|
43
53
|
import {
|
|
44
54
|
canonicalWorkflowEvidenceKey,
|
|
45
55
|
approveWorkflowDegradation,
|
|
@@ -91,6 +101,10 @@ export interface MeshHubOptions {
|
|
|
91
101
|
skillsDir?: string;
|
|
92
102
|
skillLifecycle?: SkillLifecycle;
|
|
93
103
|
repoRoot?: string;
|
|
104
|
+
/** The hub clock every lease decision reads. Injectable so a test can move a
|
|
105
|
+
* lease past its deadline instead of sleeping through a real TTL; production
|
|
106
|
+
* leaves it at `Date.now`. It is never a client-supplied time. */
|
|
107
|
+
now?: () => number;
|
|
94
108
|
}
|
|
95
109
|
|
|
96
110
|
export interface MeshHub {
|
|
@@ -128,10 +142,68 @@ function publicAgent(agent: StoredAgent, staleAfterMs: number, now = Date.now())
|
|
|
128
142
|
return toAgentRecord(identity, staleAfterMs, now);
|
|
129
143
|
}
|
|
130
144
|
|
|
145
|
+
/** Runtime ids share the contract's opaque-id shape (`rtm_…`). */
|
|
146
|
+
const RUNTIME_ID_PATTERN = /^[a-z][a-z0-9]{1,15}_[A-Za-z0-9][A-Za-z0-9_-]{5,127}$/;
|
|
147
|
+
|
|
148
|
+
function requireRuntimeId(value: unknown): string {
|
|
149
|
+
const runtimeId = requireString(value, "runtimeId", { max: 144 });
|
|
150
|
+
if (!RUNTIME_ID_PATTERN.test(runtimeId)) {
|
|
151
|
+
throw new ProtocolError(400, "runtimeId must be an opaque runtime id", "invalid_runtime_id");
|
|
152
|
+
}
|
|
153
|
+
return runtimeId;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** A Runtime's presence as readers see it. The lease is the same hub-clocked
|
|
157
|
+
* window agents get: `heartbeatAt + staleAfterMs`. */
|
|
158
|
+
function runtimePresenceView(record: RuntimePresenceRecord, staleAfterMs: number, nowMs: number) {
|
|
159
|
+
const heartbeatMs = Date.parse(record.heartbeatAt);
|
|
160
|
+
const leaseExpiresMs = Number.isFinite(heartbeatMs) ? heartbeatMs + staleAfterMs : 0;
|
|
161
|
+
return {
|
|
162
|
+
runtimeId: record.runtimeId,
|
|
163
|
+
...(record.host ? { host: record.host } : {}),
|
|
164
|
+
registeredAt: record.registeredAt,
|
|
165
|
+
heartbeatAt: record.heartbeatAt,
|
|
166
|
+
leaseExpiresAt: new Date(leaseExpiresMs).toISOString(),
|
|
167
|
+
presence: leaseExpiresMs > nowMs ? "online" as const : "expired" as const,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
131
171
|
function safeTokenEqual(actual: string | undefined, expected: string): boolean {
|
|
132
172
|
return timingSafeStringCompare(actual, expected);
|
|
133
173
|
}
|
|
134
174
|
|
|
175
|
+
/** The wire answer for a lease the hub will not extend or release. Every one of
|
|
176
|
+
* these means the caller's token no longer authorizes a shared write: the
|
|
177
|
+
* holder must stop, not retry. The current lease rides along so the caller can
|
|
178
|
+
* record who holds the resource now. */
|
|
179
|
+
function leaseRefusal(reason: LeaseRefusal, name: string, lease?: LeaseRecord): ProtocolError {
|
|
180
|
+
if (reason === "missing") {
|
|
181
|
+
return new ProtocolError(404, `no lease is held on ${name}`, "lease_not_found");
|
|
182
|
+
}
|
|
183
|
+
if (reason === "expired") {
|
|
184
|
+
return new ProtocolError(
|
|
185
|
+
409,
|
|
186
|
+
`lease on ${name} expired at ${lease?.expiresAt ?? "its deadline"}; re-acquire to take it over`,
|
|
187
|
+
"lease_expired",
|
|
188
|
+
{ lease },
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
if (reason === "held") {
|
|
192
|
+
return new ProtocolError(
|
|
193
|
+
409,
|
|
194
|
+
`lease on ${name} is held by ${lease?.holderAgentName ?? "another agent"} until ${lease?.expiresAt ?? "its deadline"}`,
|
|
195
|
+
"lease_held",
|
|
196
|
+
{ lease },
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
return new ProtocolError(
|
|
200
|
+
409,
|
|
201
|
+
`fencing token for ${name} was superseded; the hub is now at token ${lease?.fencingToken ?? "a newer value"}`,
|
|
202
|
+
"lease_superseded",
|
|
203
|
+
{ lease },
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
135
207
|
function bearerToken(request: IncomingMessage): string | undefined {
|
|
136
208
|
const header = request.headers.authorization;
|
|
137
209
|
return header?.startsWith("Bearer ") ? header.slice(7) : undefined;
|
|
@@ -409,6 +481,7 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
|
|
|
409
481
|
};
|
|
410
482
|
const webhookWorkflows = new Map((options.webhookWorkflows ?? []).map((workflow) => [workflow.id, workflow]));
|
|
411
483
|
const logger = options.logger ?? (() => undefined);
|
|
484
|
+
const hubNow = options.now ?? (() => Date.now());
|
|
412
485
|
const assetsDir = options.assetsDir;
|
|
413
486
|
const hubRepoRoot = options.repoRoot ?? (options.dataPath && options.dataPath !== ":memory:" ? resolve(dirname(dirname(options.dataPath))) : process.cwd());
|
|
414
487
|
const store = new MeshStore(options.dataPath);
|
|
@@ -461,6 +534,14 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
|
|
|
461
534
|
contextRequests: 0,
|
|
462
535
|
attemptLatencySecondsTotal: 0,
|
|
463
536
|
meteredCostUsdTotal: 0,
|
|
537
|
+
leasesGranted: 0,
|
|
538
|
+
leasesRefused: 0,
|
|
539
|
+
leasesReleased: 0,
|
|
540
|
+
syncEventsAccepted: 0,
|
|
541
|
+
syncEventsDuplicate: 0,
|
|
542
|
+
syncEventsRefused: 0,
|
|
543
|
+
syncConflicts: 0,
|
|
544
|
+
runtimeHeartbeats: 0,
|
|
464
545
|
};
|
|
465
546
|
let cleanupTimer: NodeJS.Timeout | undefined;
|
|
466
547
|
let closed = false;
|
|
@@ -627,6 +708,74 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
|
|
|
627
708
|
}
|
|
628
709
|
}
|
|
629
710
|
|
|
711
|
+
/**
|
|
712
|
+
* Synchronized runs grouped by their home Runtime. Built from the gapless
|
|
713
|
+
* prefix of each run only, so a missing sequence is shown as pending and
|
|
714
|
+
* never papered over. `orphaned` is view state: the home Runtime's presence
|
|
715
|
+
* lease has expired (or it never registered). Nothing is migrated.
|
|
716
|
+
*/
|
|
717
|
+
function homeRuntimesView(project: string, nowMs: number) {
|
|
718
|
+
const presence = new Map(store.listRuntimePresence(project).map((record) => [record.runtimeId, record]));
|
|
719
|
+
const runs = new Map<string, StoredSyncEvent[]>();
|
|
720
|
+
for (const event of store.listSyncEvents(project)) {
|
|
721
|
+
const key = `${event.projectId}\u0000${event.runId}`;
|
|
722
|
+
const bucket = runs.get(key);
|
|
723
|
+
if (bucket) bucket.push(event);
|
|
724
|
+
else runs.set(key, [event]);
|
|
725
|
+
}
|
|
726
|
+
const homes = new Map<string, Array<Record<string, unknown>>>();
|
|
727
|
+
for (const events of runs.values()) {
|
|
728
|
+
const first = events[0]!;
|
|
729
|
+
let cursor = 0;
|
|
730
|
+
let status: string | undefined;
|
|
731
|
+
let workflowId: string | undefined;
|
|
732
|
+
let displayTitle: string | undefined;
|
|
733
|
+
let updatedAt: string | undefined;
|
|
734
|
+
for (const event of events) {
|
|
735
|
+
if (event.sequence !== cursor + 1) break;
|
|
736
|
+
cursor = event.sequence;
|
|
737
|
+
const parsed = JSON.parse(event.bytes) as { occurredAt?: string; payload?: Record<string, unknown> };
|
|
738
|
+
const payload = parsed.payload ?? {};
|
|
739
|
+
if (event.eventType.startsWith("run.") && typeof payload.status === "string") status = payload.status;
|
|
740
|
+
if (typeof payload.workflowId === "string") workflowId = payload.workflowId;
|
|
741
|
+
if (typeof payload.displayTitle === "string") displayTitle = payload.displayTitle;
|
|
742
|
+
if (typeof parsed.occurredAt === "string") updatedAt = parsed.occurredAt;
|
|
743
|
+
}
|
|
744
|
+
const home = presence.get(first.homeRuntimeId);
|
|
745
|
+
const orphaned = !home || runtimePresenceView(home, staleAfterMs, nowMs).presence === "expired";
|
|
746
|
+
const list = homes.get(first.homeRuntimeId) ?? [];
|
|
747
|
+
list.push({
|
|
748
|
+
projectId: first.projectId,
|
|
749
|
+
runId: first.runId,
|
|
750
|
+
...(workflowId ? { workflowId } : {}),
|
|
751
|
+
...(displayTitle ? { displayTitle } : {}),
|
|
752
|
+
...(status ? { status } : {}),
|
|
753
|
+
lastSequence: cursor,
|
|
754
|
+
pendingGap: events.length > cursor,
|
|
755
|
+
...(updatedAt ? { updatedAt } : {}),
|
|
756
|
+
orphaned,
|
|
757
|
+
});
|
|
758
|
+
homes.set(first.homeRuntimeId, list);
|
|
759
|
+
}
|
|
760
|
+
for (const runtimeId of presence.keys()) if (!homes.has(runtimeId)) homes.set(runtimeId, []);
|
|
761
|
+
return [...homes.entries()]
|
|
762
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
763
|
+
.map(([runtimeId, homeRuns]) => {
|
|
764
|
+
const record = presence.get(runtimeId);
|
|
765
|
+
const view = record ? runtimePresenceView(record, staleAfterMs, nowMs) : undefined;
|
|
766
|
+
return {
|
|
767
|
+
runtimeId,
|
|
768
|
+
...(view ? { host: view.host, heartbeatAt: view.heartbeatAt, leaseExpiresAt: view.leaseExpiresAt } : {}),
|
|
769
|
+
presence: view?.presence ?? "unknown",
|
|
770
|
+
orphaned: !view || view.presence === "expired",
|
|
771
|
+
runs: homeRuns
|
|
772
|
+
.sort((left, right) => String(right.updatedAt ?? "").localeCompare(String(left.updatedAt ?? "")))
|
|
773
|
+
.slice(0, 16),
|
|
774
|
+
runTotal: homeRuns.length,
|
|
775
|
+
};
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
|
|
630
779
|
function opsSnapshot(project: string) {
|
|
631
780
|
const snapshotAt = Date.now();
|
|
632
781
|
const projectAgents = [...agents.values()]
|
|
@@ -671,6 +820,7 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
|
|
|
671
820
|
};
|
|
672
821
|
}),
|
|
673
822
|
runTotal: projectRuns.length,
|
|
823
|
+
homeRuntimes: homeRuntimesView(project, hubNow()),
|
|
674
824
|
plans: [...journal.values()]
|
|
675
825
|
.filter((entry) => entry.category === "plan" && projectRuns.some((run) => run.id === entry.runId))
|
|
676
826
|
.sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt))
|
|
@@ -1065,6 +1215,16 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
|
|
|
1065
1215
|
for (const runId of swept.purgedRuns) {
|
|
1066
1216
|
logger({ event: "workflow_run_purged", runId });
|
|
1067
1217
|
}
|
|
1218
|
+
for (const lease of swept.purgedLeases) {
|
|
1219
|
+
logger({
|
|
1220
|
+
event: "lease_purged",
|
|
1221
|
+
resource: lease.resource,
|
|
1222
|
+
project: lease.project,
|
|
1223
|
+
agentId: lease.holderAgentId,
|
|
1224
|
+
fencingToken: lease.fencingToken,
|
|
1225
|
+
expiresAt: lease.expiresAt,
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
1068
1228
|
}
|
|
1069
1229
|
|
|
1070
1230
|
function metricsBody(): string {
|
|
@@ -1112,6 +1272,22 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
|
|
|
1112
1272
|
`kxm_attempt_latency_seconds_total ${counters.attemptLatencySecondsTotal}`,
|
|
1113
1273
|
"# TYPE kxm_metered_cost_usd_total counter",
|
|
1114
1274
|
`kxm_metered_cost_usd_total ${counters.meteredCostUsdTotal}`,
|
|
1275
|
+
"# TYPE kxm_leases_granted_total counter",
|
|
1276
|
+
`kxm_leases_granted_total ${counters.leasesGranted}`,
|
|
1277
|
+
"# TYPE kxm_leases_refused_total counter",
|
|
1278
|
+
`kxm_leases_refused_total ${counters.leasesRefused}`,
|
|
1279
|
+
"# TYPE kxm_leases_released_total counter",
|
|
1280
|
+
`kxm_leases_released_total ${counters.leasesReleased}`,
|
|
1281
|
+
"# TYPE kxm_sync_events_accepted_total counter",
|
|
1282
|
+
`kxm_sync_events_accepted_total ${counters.syncEventsAccepted}`,
|
|
1283
|
+
"# TYPE kxm_sync_events_duplicate_total counter",
|
|
1284
|
+
`kxm_sync_events_duplicate_total ${counters.syncEventsDuplicate}`,
|
|
1285
|
+
"# TYPE kxm_sync_events_refused_total counter",
|
|
1286
|
+
`kxm_sync_events_refused_total ${counters.syncEventsRefused}`,
|
|
1287
|
+
"# TYPE kxm_sync_conflicts_total counter",
|
|
1288
|
+
`kxm_sync_conflicts_total ${counters.syncConflicts}`,
|
|
1289
|
+
"# TYPE kxm_runtime_heartbeats_total counter",
|
|
1290
|
+
`kxm_runtime_heartbeats_total ${counters.runtimeHeartbeats}`,
|
|
1115
1291
|
"",
|
|
1116
1292
|
].join("\n");
|
|
1117
1293
|
}
|
|
@@ -2198,6 +2374,187 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
|
|
|
2198
2374
|
return;
|
|
2199
2375
|
}
|
|
2200
2376
|
|
|
2377
|
+
// Runtime presence (P5). A Runtime is a machine client, not an agent: it
|
|
2378
|
+
// is admitted by the project token and declares its id and host label.
|
|
2379
|
+
// The hub stamps the heartbeat with its own clock; that stamp plus
|
|
2380
|
+
// staleAfterMs is the presence lease the ops snapshot reads.
|
|
2381
|
+
if (method === "POST" && url.pathname === "/v1/runtime/presence") {
|
|
2382
|
+
const body = await readJson(request);
|
|
2383
|
+
const project = requireString(body.project, "project", { max: 128 });
|
|
2384
|
+
requireProjectAuth(request, project);
|
|
2385
|
+
const runtimeId = requireRuntimeId(body.runtimeId);
|
|
2386
|
+
const hostLabel = optionalString(body.host, "host", MAX_AGENT_HOST_CHARS);
|
|
2387
|
+
const heartbeatAt = new Date(hubNow()).toISOString();
|
|
2388
|
+
const existing = store.getRuntimePresence(project, runtimeId);
|
|
2389
|
+
const record: RuntimePresenceRecord = {
|
|
2390
|
+
runtimeId,
|
|
2391
|
+
project,
|
|
2392
|
+
...(hostLabel ? { host: hostLabel } : {}),
|
|
2393
|
+
registeredAt: existing?.registeredAt ?? heartbeatAt,
|
|
2394
|
+
heartbeatAt,
|
|
2395
|
+
};
|
|
2396
|
+
store.saveRuntimePresence(record);
|
|
2397
|
+
counters.runtimeHeartbeats += 1;
|
|
2398
|
+
if (!existing) logger({ event: "runtime_registered", project, runtimeId, ...(hostLabel ? { host: hostLabel } : {}) });
|
|
2399
|
+
json(response, existing ? 200 : 201, { presence: runtimePresenceView(record, staleAfterMs, hubNow()) });
|
|
2400
|
+
return;
|
|
2401
|
+
}
|
|
2402
|
+
|
|
2403
|
+
// Run-fact sync (P5), not a peer transport. Each event is accepted once by
|
|
2404
|
+
// {projectId, runId, sequence}: identical bytes are an idempotent
|
|
2405
|
+
// duplicate, different bytes under a used sequence are refused and raised
|
|
2406
|
+
// as a security alert. Out-of-order events are held; the per-run cursor is
|
|
2407
|
+
// the gapless prefix, so a gap stays pending until it is filled.
|
|
2408
|
+
if (method === "POST" && url.pathname === "/v1/sync/events") {
|
|
2409
|
+
const body = await readJson(request);
|
|
2410
|
+
const project = requireString(body.project, "project", { max: 128 });
|
|
2411
|
+
requireProjectAuth(request, project);
|
|
2412
|
+
const runtimeId = requireRuntimeId(body.runtimeId);
|
|
2413
|
+
if (!Array.isArray(body.events)) {
|
|
2414
|
+
throw new ProtocolError(400, "events must be an array", "invalid_sync_batch");
|
|
2415
|
+
}
|
|
2416
|
+
if (body.events.length > MAX_SYNC_BATCH_EVENTS) {
|
|
2417
|
+
throw new ProtocolError(413, `a sync batch holds at most ${MAX_SYNC_BATCH_EVENTS} events`, "sync_batch_too_large");
|
|
2418
|
+
}
|
|
2419
|
+
const receivedAt = new Date(hubNow()).toISOString();
|
|
2420
|
+
const results: Array<Record<string, unknown>> = [];
|
|
2421
|
+
const touched = new Map<string, { projectId: string; runId: string }>();
|
|
2422
|
+
for (const candidate of body.events as unknown[]) {
|
|
2423
|
+
const identity = candidate && typeof candidate === "object" && !Array.isArray(candidate)
|
|
2424
|
+
? candidate as Record<string, unknown>
|
|
2425
|
+
: {};
|
|
2426
|
+
const echo = {
|
|
2427
|
+
...(typeof identity.projectId === "string" ? { projectId: identity.projectId.slice(0, 144) } : {}),
|
|
2428
|
+
...(typeof identity.runId === "string" ? { runId: identity.runId.slice(0, 144) } : {}),
|
|
2429
|
+
...(Number.isInteger(identity.sequence) ? { sequence: identity.sequence } : {}),
|
|
2430
|
+
};
|
|
2431
|
+
if (syncEventSchemaErrors(candidate) !== undefined) {
|
|
2432
|
+
counters.syncEventsRefused += 1;
|
|
2433
|
+
results.push({ ...echo, outcome: "rejected", code: "sync_event_invalid" });
|
|
2434
|
+
continue;
|
|
2435
|
+
}
|
|
2436
|
+
const event = identity as { projectId: string; runId: string; sequence: number; homeRuntimeId: string; eventType: string };
|
|
2437
|
+
if (event.homeRuntimeId !== runtimeId) {
|
|
2438
|
+
counters.syncEventsRefused += 1;
|
|
2439
|
+
logger({ event: "security_alert", alert: "sync_runtime_mismatch", project, runtimeId, ...echo, homeRuntimeId: event.homeRuntimeId });
|
|
2440
|
+
results.push({ ...echo, outcome: "rejected", code: "sync_runtime_mismatch" });
|
|
2441
|
+
continue;
|
|
2442
|
+
}
|
|
2443
|
+
const bytes = kxmCanonicalJson(candidate as JsonValue);
|
|
2444
|
+
const contentHash = kxmSyncEventHash(bytes);
|
|
2445
|
+
const outcome = store.ingestSyncEvent({
|
|
2446
|
+
projectId: event.projectId,
|
|
2447
|
+
runId: event.runId,
|
|
2448
|
+
sequence: event.sequence,
|
|
2449
|
+
hubProject: project,
|
|
2450
|
+
homeRuntimeId: event.homeRuntimeId,
|
|
2451
|
+
eventType: event.eventType,
|
|
2452
|
+
contentHash,
|
|
2453
|
+
receivedAt,
|
|
2454
|
+
bytes,
|
|
2455
|
+
});
|
|
2456
|
+
if (outcome.outcome === "conflict") {
|
|
2457
|
+
counters.syncConflicts += 1;
|
|
2458
|
+
counters.syncEventsRefused += 1;
|
|
2459
|
+
logger({
|
|
2460
|
+
event: "security_alert",
|
|
2461
|
+
alert: "sync_sequence_conflict",
|
|
2462
|
+
reason: outcome.reason,
|
|
2463
|
+
project,
|
|
2464
|
+
runtimeId,
|
|
2465
|
+
...echo,
|
|
2466
|
+
presentedHash: contentHash,
|
|
2467
|
+
...(outcome.existingHash ? { existingHash: outcome.existingHash } : {}),
|
|
2468
|
+
});
|
|
2469
|
+
results.push({ ...echo, outcome: "conflict", code: `sync_${outcome.reason}` });
|
|
2470
|
+
continue;
|
|
2471
|
+
}
|
|
2472
|
+
if (outcome.outcome === "accepted") counters.syncEventsAccepted += 1;
|
|
2473
|
+
else counters.syncEventsDuplicate += 1;
|
|
2474
|
+
touched.set(`${event.projectId}\u0000${event.runId}`, { projectId: event.projectId, runId: event.runId });
|
|
2475
|
+
results.push({ ...echo, outcome: outcome.outcome });
|
|
2476
|
+
}
|
|
2477
|
+
const cursors = [...touched.values()].map((run) => ({ ...run, cursor: store.syncCursor(run.projectId, run.runId) }));
|
|
2478
|
+
if (results.some((result) => result.outcome === "accepted")) publishOps(project, "workflows");
|
|
2479
|
+
json(response, 200, { results, cursors });
|
|
2480
|
+
return;
|
|
2481
|
+
}
|
|
2482
|
+
|
|
2483
|
+
// One writer, one clock. Every branch below decides inside a single store
|
|
2484
|
+
// transaction, so two agents racing for the same resource cannot both read it
|
|
2485
|
+
// free. The fencing token increments only on takeover, which is what lets a
|
|
2486
|
+
// holder that slept past its deadline be refused at commit instead of writing
|
|
2487
|
+
// behind whoever replaced it.
|
|
2488
|
+
const leaseMatch = url.pathname.match(/^\/v1\/leases\/([^/]+)\/(acquire|renew|release)$/);
|
|
2489
|
+
if (method === "POST" && leaseMatch) {
|
|
2490
|
+
const holder = requireAgent(request);
|
|
2491
|
+
requireProjectAuth(request, holder.project);
|
|
2492
|
+
const name = requireString(decodeURIComponent(leaseMatch[1]!), "resource", { max: MAX_LEASE_RESOURCE_CHARS });
|
|
2493
|
+
const action = leaseMatch[2]!;
|
|
2494
|
+
const body = await readJson(request);
|
|
2495
|
+
// The caller never names the prefix, so the same branch in two projects is
|
|
2496
|
+
// two leases and neither project can reach the other's.
|
|
2497
|
+
const resource = `${holder.project}/${name}`;
|
|
2498
|
+
const nowMs = hubNow();
|
|
2499
|
+
const leaseLog = { resource, project: holder.project, agentId: holder.id, agentName: holder.name };
|
|
2500
|
+
|
|
2501
|
+
if (action === "acquire") {
|
|
2502
|
+
const ttlMs = parseBoundedInteger(body.ttlMs, "ttlMs", DEFAULT_LEASE_TTL_MS, MIN_LEASE_TTL_MS, MAX_LEASE_TTL_MS);
|
|
2503
|
+
const outcome = store.acquireLease({
|
|
2504
|
+
resource,
|
|
2505
|
+
project: holder.project,
|
|
2506
|
+
name,
|
|
2507
|
+
holderAgentId: holder.id,
|
|
2508
|
+
holderAgentName: holder.name,
|
|
2509
|
+
ttlMs,
|
|
2510
|
+
nowMs,
|
|
2511
|
+
});
|
|
2512
|
+
if (!outcome.ok) {
|
|
2513
|
+
counters.leasesRefused += 1;
|
|
2514
|
+
logger({ event: "lease_denied", ...leaseLog, reason: outcome.reason, heldBy: outcome.lease?.holderAgentId });
|
|
2515
|
+
throw leaseRefusal(outcome.reason, name, outcome.lease);
|
|
2516
|
+
}
|
|
2517
|
+
counters.leasesGranted += 1;
|
|
2518
|
+
logger({
|
|
2519
|
+
event: outcome.renewed ? "lease_renewed" : "lease_acquired",
|
|
2520
|
+
...leaseLog,
|
|
2521
|
+
fencingToken: outcome.lease.fencingToken,
|
|
2522
|
+
expiresAt: outcome.lease.expiresAt,
|
|
2523
|
+
});
|
|
2524
|
+
json(response, 200, { lease: outcome.lease, renewed: outcome.renewed });
|
|
2525
|
+
return;
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
if (body.fencingToken === undefined) {
|
|
2529
|
+
throw new ProtocolError(400, "fencingToken is required", "lease_token_required");
|
|
2530
|
+
}
|
|
2531
|
+
const fencingToken = parseBoundedInteger(body.fencingToken, "fencingToken", 1, 1, Number.MAX_SAFE_INTEGER);
|
|
2532
|
+
if (action === "renew") {
|
|
2533
|
+
const ttlMs = parseBoundedInteger(body.ttlMs, "ttlMs", DEFAULT_LEASE_TTL_MS, MIN_LEASE_TTL_MS, MAX_LEASE_TTL_MS);
|
|
2534
|
+
const outcome = store.renewLease({ resource, holderAgentId: holder.id, fencingToken, ttlMs, nowMs });
|
|
2535
|
+
if (!outcome.ok) {
|
|
2536
|
+
counters.leasesRefused += 1;
|
|
2537
|
+
logger({ event: "lease_denied", ...leaseLog, reason: outcome.reason, fencingToken });
|
|
2538
|
+
throw leaseRefusal(outcome.reason, name, outcome.lease);
|
|
2539
|
+
}
|
|
2540
|
+
counters.leasesGranted += 1;
|
|
2541
|
+
logger({ event: "lease_renewed", ...leaseLog, fencingToken, expiresAt: outcome.lease.expiresAt });
|
|
2542
|
+
json(response, 200, { lease: outcome.lease, renewed: true });
|
|
2543
|
+
return;
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
const outcome = store.releaseLease({ resource, holderAgentId: holder.id, fencingToken });
|
|
2547
|
+
if (!outcome.ok) {
|
|
2548
|
+
counters.leasesRefused += 1;
|
|
2549
|
+
logger({ event: "lease_denied", ...leaseLog, reason: outcome.reason, fencingToken });
|
|
2550
|
+
throw leaseRefusal(outcome.reason, name, outcome.lease);
|
|
2551
|
+
}
|
|
2552
|
+
counters.leasesReleased += 1;
|
|
2553
|
+
logger({ event: "lease_released", ...leaseLog, fencingToken });
|
|
2554
|
+
json(response, 200, { released: true, lease: outcome.lease });
|
|
2555
|
+
return;
|
|
2556
|
+
}
|
|
2557
|
+
|
|
2201
2558
|
if (method === "GET" && url.pathname === "/v1/events") {
|
|
2202
2559
|
const agentId = requireString(url.searchParams.get("agentId"), "agentId", { max: 80 });
|
|
2203
2560
|
const current = requireAgent(request, agentId);
|
|
@@ -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.73";
|
|
12
12
|
const inbox = new Map<string, MessageRecord>();
|
|
13
13
|
const notifiedInbox = new Set<string>();
|
|
14
14
|
let meshClient: HubClient | undefined;
|
|
@@ -163,6 +163,7 @@ export class KxmSchemaRegistry {
|
|
|
163
163
|
readonly driveReceiptValidator: ValidateFunction;
|
|
164
164
|
readonly coordinatorValidator: ValidateFunction;
|
|
165
165
|
readonly intakeMessageValidator: ValidateFunction;
|
|
166
|
+
readonly syncEventValidator: ValidateFunction;
|
|
166
167
|
|
|
167
168
|
constructor(schemasDir = DEFAULT_SCHEMA_DIR) {
|
|
168
169
|
this.schemasDir = resolve(schemasDir);
|
|
@@ -180,6 +181,7 @@ export class KxmSchemaRegistry {
|
|
|
180
181
|
const driveReceiptFile = "drive-receipt.schema.json";
|
|
181
182
|
const coordinatorFile = "coordinator.schema.json";
|
|
182
183
|
const intakeMessageFile = "intake-message.schema.json";
|
|
184
|
+
const syncEventFile = "sync-event.schema.json";
|
|
183
185
|
this.ajv.addSchema(readJsonObject(join(this.schemasDir, localBindingsFile)));
|
|
184
186
|
this.ajv.addSchema(readJsonObject(join(this.schemasDir, templateProvenanceFile)));
|
|
185
187
|
this.ajv.addSchema(readJsonObject(join(this.schemasDir, initOperationFile)));
|
|
@@ -188,6 +190,7 @@ export class KxmSchemaRegistry {
|
|
|
188
190
|
this.ajv.addSchema(readJsonObject(join(this.schemasDir, driveReceiptFile)));
|
|
189
191
|
this.ajv.addSchema(readJsonObject(join(this.schemasDir, coordinatorFile)));
|
|
190
192
|
this.ajv.addSchema(readJsonObject(join(this.schemasDir, intakeMessageFile)));
|
|
193
|
+
this.ajv.addSchema(readJsonObject(join(this.schemasDir, syncEventFile)));
|
|
191
194
|
for (const [kind, definition] of Object.entries(RESOURCE_SCHEMA) as [KxmResourceKind, { identity: string; file: string }][]) {
|
|
192
195
|
const validator = this.ajv.getSchema(`https://schemas.kxm.dev/${definition.file}`);
|
|
193
196
|
if (!validator) throw new Error(`schema did not compile: ${definition.file}`);
|
|
@@ -201,6 +204,7 @@ export class KxmSchemaRegistry {
|
|
|
201
204
|
const driveReceiptValidator = this.ajv.getSchema(`https://schemas.kxm.dev/${driveReceiptFile}`);
|
|
202
205
|
const coordinatorValidator = this.ajv.getSchema(`https://schemas.kxm.dev/${coordinatorFile}`);
|
|
203
206
|
const intakeMessageValidator = this.ajv.getSchema(`https://schemas.kxm.dev/${intakeMessageFile}`);
|
|
207
|
+
const syncEventValidator = this.ajv.getSchema(`https://schemas.kxm.dev/${syncEventFile}`);
|
|
204
208
|
if (!localBindingsValidator) throw new Error(`schema did not compile: ${localBindingsFile}`);
|
|
205
209
|
if (!templateProvenanceValidator) throw new Error(`schema did not compile: ${templateProvenanceFile}`);
|
|
206
210
|
if (!initOperationValidator) throw new Error(`schema did not compile: ${initOperationFile}`);
|
|
@@ -209,6 +213,7 @@ export class KxmSchemaRegistry {
|
|
|
209
213
|
if (!driveReceiptValidator) throw new Error(`schema did not compile: ${driveReceiptFile}`);
|
|
210
214
|
if (!coordinatorValidator) throw new Error(`schema did not compile: ${coordinatorFile}`);
|
|
211
215
|
if (!intakeMessageValidator) throw new Error(`schema did not compile: ${intakeMessageFile}`);
|
|
216
|
+
if (!syncEventValidator) throw new Error(`schema did not compile: ${syncEventFile}`);
|
|
212
217
|
this.localBindingsValidator = localBindingsValidator;
|
|
213
218
|
this.templateProvenanceValidator = templateProvenanceValidator;
|
|
214
219
|
this.initOperationValidator = initOperationValidator;
|
|
@@ -217,6 +222,7 @@ export class KxmSchemaRegistry {
|
|
|
217
222
|
this.driveReceiptValidator = driveReceiptValidator;
|
|
218
223
|
this.coordinatorValidator = coordinatorValidator;
|
|
219
224
|
this.intakeMessageValidator = intakeMessageValidator;
|
|
225
|
+
this.syncEventValidator = syncEventValidator;
|
|
220
226
|
}
|
|
221
227
|
|
|
222
228
|
validate(kind: KxmResourceKind, value: JsonObject, file: string): KxmConfigIssue[] {
|
|
@@ -274,6 +280,15 @@ export function validateRunEvent(value: unknown, file: string): void {
|
|
|
274
280
|
}
|
|
275
281
|
}
|
|
276
282
|
|
|
283
|
+
/** Check a derived sync object against `kxm.sync-event.v1`. Returns the
|
|
284
|
+
* schema errors instead of throwing so the transform can fall back to a
|
|
285
|
+
* degraded object and the hub can refuse one event without failing a batch. */
|
|
286
|
+
export function syncEventSchemaErrors(value: unknown): string | undefined {
|
|
287
|
+
const registry = (cachedRunEventRegistry ??= new KxmSchemaRegistry());
|
|
288
|
+
if (registry.syncEventValidator(value)) return undefined;
|
|
289
|
+
return registry.ajv.errorsText(registry.syncEventValidator.errors, { separator: "; " });
|
|
290
|
+
}
|
|
291
|
+
|
|
277
292
|
/** Validate a drive receipt against `kxm.drive-receipt.v1`. Throws `drive_receipt_invalid`. */
|
|
278
293
|
export function validateDriveReceipt(value: unknown, file: string): void {
|
|
279
294
|
const registry = (cachedRunEventRegistry ??= new KxmSchemaRegistry());
|
|
@@ -13,6 +13,10 @@ 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
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;
|
|
16
20
|
|
|
17
21
|
export type DeliveryMode = "steer" | "followUp" | "nextTurn";
|
|
18
22
|
export type MessageStatus = "queued" | "delivered" | "replied" | "cancelled" | "expired" | "error";
|
|
@@ -68,6 +72,54 @@ export function toAgentRecord(agent: AgentIdentity, staleAfterMs?: number, now?:
|
|
|
68
72
|
return { ...agent, ...agentPresenceView(agent, staleAfterMs, now) };
|
|
69
73
|
}
|
|
70
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
|
+
|
|
96
|
+
/** A Runtime's machine-client presence in one hub project (P5). Not an agent:
|
|
97
|
+
* it holds no agent key, sends no messages, and is admitted by the project
|
|
98
|
+
* token alone. `heartbeatAt` is the hub's clock. */
|
|
99
|
+
export interface RuntimePresenceRecord {
|
|
100
|
+
runtimeId: string;
|
|
101
|
+
project: string;
|
|
102
|
+
host?: string;
|
|
103
|
+
registeredAt: string;
|
|
104
|
+
heartbeatAt: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** One accepted sync event as the hub holds it (P5). `bytes` is the canonical
|
|
108
|
+
* `kxm.sync-event.v1` text; `contentHash` is its sha256, the conflict test. */
|
|
109
|
+
export interface StoredSyncEvent {
|
|
110
|
+
projectId: string;
|
|
111
|
+
runId: string;
|
|
112
|
+
sequence: number;
|
|
113
|
+
hubProject: string;
|
|
114
|
+
homeRuntimeId: string;
|
|
115
|
+
eventType: string;
|
|
116
|
+
contentHash: string;
|
|
117
|
+
receivedAt: string;
|
|
118
|
+
bytes: string;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export const MAX_SYNC_BATCH_EVENTS = 100;
|
|
122
|
+
|
|
71
123
|
export interface MessageReply {
|
|
72
124
|
content: string;
|
|
73
125
|
createdAt: string;
|