@kontextmind/kxm 0.7.72 → 0.7.74
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/docs/contracts/synchronization.md +6 -3
- package/docs/operations.md +35 -4
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/cli.js +307 -235
- package/plugins/kxm/dist/client.js +67 -36
- package/plugins/kxm/dist/core.js +2 -0
- package/plugins/kxm/dist/extension.js +40 -37
- package/plugins/kxm/dist/mcp-server.js +40 -37
- package/plugins/kxm/dist/runtime-supervisor.js +917 -141
- package/plugins/kxm/dist/runtime.js +977 -237
- package/plugins/kxm/dist/server.js +16206 -8256
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/src/client.ts +85 -3
- package/plugins/kxm/src/database.ts +4 -4
- package/plugins/kxm/src/external-effects.ts +30 -5
- package/plugins/kxm/src/hub.ts +221 -0
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/project-config.ts +15 -0
- package/plugins/kxm/src/protocol.ts +27 -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 +207 -4
- package/plugins/kxm/src/sync-transform.ts +380 -0
package/plugins/kxm/package.json
CHANGED
|
@@ -632,15 +632,27 @@ export class HubClient {
|
|
|
632
632
|
}
|
|
633
633
|
|
|
634
634
|
private async request<T = unknown>(path: string, init: RequestInit = {}, includeIdentity = true): Promise<T> {
|
|
635
|
-
|
|
635
|
+
return await hubJsonRequest<T>(this.options, path, init, this.headers(includeIdentity));
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** One JSON request to the hub with a bounded timeout. A non-2xx answer
|
|
640
|
+
* becomes a `HubHttpError` carrying the hub's code and next-action hints. */
|
|
641
|
+
async function hubJsonRequest<T>(
|
|
642
|
+
options: { serverUrl: string; requestTimeoutMs?: number; fetchImpl?: typeof fetch },
|
|
643
|
+
path: string,
|
|
644
|
+
init: RequestInit,
|
|
645
|
+
headers: Record<string, string>,
|
|
646
|
+
): Promise<T> {
|
|
647
|
+
const requestTimeoutMs = options.requestTimeoutMs ?? 15_000;
|
|
636
648
|
const timeoutSignal = AbortSignal.timeout(requestTimeoutMs);
|
|
637
649
|
const signal = init.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal;
|
|
638
650
|
let response: Response;
|
|
639
651
|
try {
|
|
640
|
-
response = await (
|
|
652
|
+
response = await (options.fetchImpl ?? fetch)(`${options.serverUrl.replace(/\/$/, "")}${path}`, {
|
|
641
653
|
...init,
|
|
642
654
|
signal,
|
|
643
|
-
headers: { ...
|
|
655
|
+
headers: { ...headers, ...(init.headers ?? {}) },
|
|
644
656
|
});
|
|
645
657
|
} catch (error) {
|
|
646
658
|
if (timeoutSignal.aborted) throw new Error(`request timed out after ${requestTimeoutMs}ms`);
|
|
@@ -672,5 +684,75 @@ export class HubClient {
|
|
|
672
684
|
);
|
|
673
685
|
}
|
|
674
686
|
return body as T;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
export interface RuntimeHubClientOptions {
|
|
690
|
+
serverUrl: string;
|
|
691
|
+
/** The hub project this Runtime reports into; its token is the admission. */
|
|
692
|
+
project: string;
|
|
693
|
+
authToken?: string;
|
|
694
|
+
runtimeId: string;
|
|
695
|
+
/** Box label; defaults to the hostname. Never used for authorization. */
|
|
696
|
+
host?: string;
|
|
697
|
+
requestTimeoutMs?: number;
|
|
698
|
+
fetchImpl?: typeof fetch;
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
export interface RuntimePresenceView {
|
|
702
|
+
runtimeId: string;
|
|
703
|
+
host?: string;
|
|
704
|
+
registeredAt: string;
|
|
705
|
+
heartbeatAt: string;
|
|
706
|
+
leaseExpiresAt: string;
|
|
707
|
+
presence: "online" | "expired";
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
export interface SyncPushResult {
|
|
711
|
+
projectId?: string;
|
|
712
|
+
runId?: string;
|
|
713
|
+
sequence?: number;
|
|
714
|
+
outcome: "accepted" | "duplicate" | "conflict" | "rejected";
|
|
715
|
+
code?: string;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
export interface SyncPushResponse {
|
|
719
|
+
results: SyncPushResult[];
|
|
720
|
+
cursors: Array<{ projectId: string; runId: string; cursor: number }>;
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* The Runtime's machine client for one hub project (P5). It registers
|
|
725
|
+
* presence and pushes derived sync events outbound; the hub never calls the
|
|
726
|
+
* Runtime back. No agent identity, no message verbs.
|
|
727
|
+
*/
|
|
728
|
+
export class RuntimeHubClient {
|
|
729
|
+
readonly options: RuntimeHubClientOptions;
|
|
730
|
+
|
|
731
|
+
constructor(options: RuntimeHubClientOptions) {
|
|
732
|
+
this.options = options;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
async heartbeat(): Promise<RuntimePresenceView> {
|
|
736
|
+
const result = await this.request<{ presence: RuntimePresenceView }>("/v1/runtime/presence", {
|
|
737
|
+
project: this.options.project,
|
|
738
|
+
runtimeId: this.options.runtimeId,
|
|
739
|
+
host: this.options.host ?? defaultHostLabel(),
|
|
740
|
+
});
|
|
741
|
+
return result.presence;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** Push already-derived `kxm.sync-event.v1` objects, in outbox order. */
|
|
745
|
+
async pushSyncEvents(events: readonly unknown[]): Promise<SyncPushResponse> {
|
|
746
|
+
return await this.request<SyncPushResponse>("/v1/sync/events", {
|
|
747
|
+
project: this.options.project,
|
|
748
|
+
runtimeId: this.options.runtimeId,
|
|
749
|
+
events,
|
|
750
|
+
});
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
private async request<T>(path: string, body: unknown): Promise<T> {
|
|
754
|
+
const headers: Record<string, string> = { "content-type": "application/json" };
|
|
755
|
+
if (this.options.authToken) headers.authorization = `Bearer ${this.options.authToken}`;
|
|
756
|
+
return await hubJsonRequest<T>(this.options, path, { method: "POST", body: JSON.stringify(body) }, headers);
|
|
675
757
|
}
|
|
676
758
|
}
|
|
@@ -593,7 +593,7 @@ export function discoverProjectStores(projectRoot: string, options: { hubDataPat
|
|
|
593
593
|
if (existsSync(hubPath)) {
|
|
594
594
|
// Must track HUB_STORE_SCHEMA_VERSION in store.ts: the hub's own fresh backup is
|
|
595
595
|
// restored through this ceiling, so a bump left behind here refuses it.
|
|
596
|
-
stores.push({ storeId: "hub-store", sourcePath: hubPath, maxSupportedVersion:
|
|
596
|
+
stores.push({ storeId: "hub-store", sourcePath: hubPath, maxSupportedVersion: 5 });
|
|
597
597
|
}
|
|
598
598
|
|
|
599
599
|
const registryPath = join(root, ".kxm", "runtime", "registry.db");
|
|
@@ -615,7 +615,7 @@ export function discoverProjectStores(projectRoot: string, options: { hubDataPat
|
|
|
615
615
|
stores.push({
|
|
616
616
|
storeId: `events:${key}`,
|
|
617
617
|
sourcePath: join(eventsDir, entry.name),
|
|
618
|
-
maxSupportedVersion:
|
|
618
|
+
maxSupportedVersion: 6,
|
|
619
619
|
});
|
|
620
620
|
}
|
|
621
621
|
}
|
|
@@ -731,14 +731,14 @@ export function restoreBackup(
|
|
|
731
731
|
|
|
732
732
|
// The hub-store ceiling. Must track HUB_STORE_SCHEMA_VERSION in store.ts for the
|
|
733
733
|
// same reason as the events ceiling below.
|
|
734
|
-
let maxSupported =
|
|
734
|
+
let maxSupported = 5;
|
|
735
735
|
if (store.storeId === "registry" || store.storeId === "binding-store") {
|
|
736
736
|
maxSupported = 1;
|
|
737
737
|
} else if (store.storeId.startsWith("events:")) {
|
|
738
738
|
// Must track KXM_EVENT_STORE_SCHEMA_VERSION in runtime-store.ts. The pin is
|
|
739
739
|
// the e6 backup/restore round-trip test: bump one without the other and it
|
|
740
740
|
// refuses its own fresh backup.
|
|
741
|
-
maxSupported =
|
|
741
|
+
maxSupported = 6;
|
|
742
742
|
}
|
|
743
743
|
|
|
744
744
|
let targetPath = store.sourcePath;
|
|
@@ -32,7 +32,7 @@ export type ExternalActionKind =
|
|
|
32
32
|
| "tracker-issue"
|
|
33
33
|
| "webhook";
|
|
34
34
|
|
|
35
|
-
export type ExternalEffectStatus = "in-flight" | "committed" | "failed" | "aborted";
|
|
35
|
+
export type ExternalEffectStatus = "in-flight" | "committed" | "failed" | "aborted" | "uncertain";
|
|
36
36
|
|
|
37
37
|
export interface ExternalEffectReceipt {
|
|
38
38
|
schema: typeof EXTERNAL_EFFECT_SCHEMA;
|
|
@@ -233,6 +233,13 @@ export class ExternalEffectsLedger {
|
|
|
233
233
|
|
|
234
234
|
const existing = this.getReceipt(effectKey);
|
|
235
235
|
if (existing) {
|
|
236
|
+
if (existing.status === "uncertain") {
|
|
237
|
+
return {
|
|
238
|
+
ok: false,
|
|
239
|
+
error: `effect_uncertain: ${input.actionKind} on ${input.targetRef} is parked as uncertain after a fencing conflict and requires explicit recovery`,
|
|
240
|
+
existing,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
236
243
|
if (existing.status === "committed") {
|
|
237
244
|
return {
|
|
238
245
|
ok: false,
|
|
@@ -315,6 +322,12 @@ export class ExternalEffectsLedger {
|
|
|
315
322
|
return { ok: true, lastHeartbeatAt: now };
|
|
316
323
|
}
|
|
317
324
|
|
|
325
|
+
/** Park an effect as uncertain: a fencing conflict means the outside world
|
|
326
|
+
* may have been touched, and only explicit recovery can unblock it. */
|
|
327
|
+
markUncertain(effectKey: string): void {
|
|
328
|
+
this.db.prepare("UPDATE external_effects SET status = 'uncertain' WHERE effect_key = ? AND status = 'in-flight'").run(effectKey);
|
|
329
|
+
}
|
|
330
|
+
|
|
318
331
|
commitEffect(
|
|
319
332
|
effectKey: string,
|
|
320
333
|
receiptPayload: Record<string, unknown>,
|
|
@@ -392,6 +405,8 @@ export type SharedEffectRefusalCode =
|
|
|
392
405
|
| "effect_lease_superseded"
|
|
393
406
|
| "effect_already_committed"
|
|
394
407
|
| "effect_in_flight"
|
|
408
|
+
/** The effect is parked as uncertain after a fencing conflict. */
|
|
409
|
+
| "effect_uncertain"
|
|
395
410
|
| "effect_not_in_flight"
|
|
396
411
|
| "effect_not_found";
|
|
397
412
|
|
|
@@ -494,6 +509,7 @@ export async function claimSharedEffect(input: {
|
|
|
494
509
|
}): Promise<SharedEffectClaim | SharedEffectRefusal> {
|
|
495
510
|
const needsLease = effectRequiresHubLease(input.actionKind, input.targetRef, input.runId);
|
|
496
511
|
let lease: EffectLease | undefined;
|
|
512
|
+
let leaseRenewed = false;
|
|
497
513
|
|
|
498
514
|
if (needsLease) {
|
|
499
515
|
if (!input.lease) {
|
|
@@ -506,6 +522,7 @@ export async function claimSharedEffect(input: {
|
|
|
506
522
|
try {
|
|
507
523
|
const acquired = await input.lease.acquireLease(input.targetRef, input.leaseTtlMs ?? DEFAULT_LEASE_TIMEOUT_MS);
|
|
508
524
|
lease = acquired.lease;
|
|
525
|
+
leaseRenewed = acquired.renewed === true;
|
|
509
526
|
} catch (error) {
|
|
510
527
|
const code = errorCodeOf(error);
|
|
511
528
|
if (code === "lease_held") {
|
|
@@ -535,12 +552,17 @@ export async function claimSharedEffect(input: {
|
|
|
535
552
|
});
|
|
536
553
|
|
|
537
554
|
if (!claimed.ok) {
|
|
538
|
-
//
|
|
539
|
-
//
|
|
540
|
-
|
|
555
|
+
// Only hand back a lease this caller newly acquired. A renewal of an
|
|
556
|
+
// existing holder's lease must survive a claim refusal: the original
|
|
557
|
+
// effect is still in-flight and still needs the fence.
|
|
558
|
+
if (lease && input.lease && !leaseRenewed) {
|
|
559
|
+
await releaseQuietly(input.lease, input.targetRef, lease.fencingToken);
|
|
560
|
+
}
|
|
541
561
|
return {
|
|
542
562
|
ok: false,
|
|
543
|
-
code: claimed.error.startsWith("effect_already_committed")
|
|
563
|
+
code: claimed.error.startsWith("effect_already_committed")
|
|
564
|
+
? "effect_already_committed"
|
|
565
|
+
: claimed.error.startsWith("effect_uncertain") ? "effect_uncertain" : "effect_in_flight",
|
|
544
566
|
error: claimed.error,
|
|
545
567
|
existing: claimed.existing,
|
|
546
568
|
};
|
|
@@ -653,6 +675,9 @@ export async function commitSharedEffect(input: {
|
|
|
653
675
|
await input.lease.renewLease(receipt.targetRef, receipt.fencingToken!, input.leaseTtlMs ?? DEFAULT_LEASE_TIMEOUT_MS);
|
|
654
676
|
} catch (error) {
|
|
655
677
|
const code = errorCodeOf(error);
|
|
678
|
+
if (isLeaseLost(code)) {
|
|
679
|
+
input.ledger.markUncertain(input.effectKey);
|
|
680
|
+
}
|
|
656
681
|
return {
|
|
657
682
|
ok: false,
|
|
658
683
|
code: isLeaseLost(code) ? "effect_lease_superseded" : "effect_lease_unavailable",
|
package/plugins/kxm/src/hub.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
MAX_LEASE_RESOURCE_CHARS,
|
|
18
18
|
MAX_LEASE_TTL_MS,
|
|
19
19
|
MAX_MESSAGE_TTL_MS,
|
|
20
|
+
MAX_SYNC_BATCH_EVENTS,
|
|
20
21
|
MIN_LEASE_TTL_MS,
|
|
21
22
|
MIN_MESSAGE_TTL_MS,
|
|
22
23
|
MIN_MESSAGE_RETENTION_MS,
|
|
@@ -33,8 +34,12 @@ import {
|
|
|
33
34
|
type HubEvent,
|
|
34
35
|
type LeaseRecord,
|
|
35
36
|
type MessageRecord,
|
|
37
|
+
type RuntimePresenceRecord,
|
|
38
|
+
type StoredSyncEvent,
|
|
36
39
|
type WorkflowMessageContext,
|
|
37
40
|
} from "./protocol.ts";
|
|
41
|
+
import { kxmCanonicalJson, syncEventSchemaErrors, type JsonValue } from "./project-config.ts";
|
|
42
|
+
import { kxmSyncEventHash } from "./sync-transform.ts";
|
|
38
43
|
import { workflowScopeExtras } from "./diagnostics.ts";
|
|
39
44
|
import { timingSafeStringCompare } from "./commands.ts";
|
|
40
45
|
import { arbitrate, explainContextItem, journalEntryToContextItem, memoryRecordToContextItem, rolePolicy } from "./arbiter.ts";
|
|
@@ -137,6 +142,32 @@ function publicAgent(agent: StoredAgent, staleAfterMs: number, now = Date.now())
|
|
|
137
142
|
return toAgentRecord(identity, staleAfterMs, now);
|
|
138
143
|
}
|
|
139
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
|
+
|
|
140
171
|
function safeTokenEqual(actual: string | undefined, expected: string): boolean {
|
|
141
172
|
return timingSafeStringCompare(actual, expected);
|
|
142
173
|
}
|
|
@@ -506,6 +537,11 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
|
|
|
506
537
|
leasesGranted: 0,
|
|
507
538
|
leasesRefused: 0,
|
|
508
539
|
leasesReleased: 0,
|
|
540
|
+
syncEventsAccepted: 0,
|
|
541
|
+
syncEventsDuplicate: 0,
|
|
542
|
+
syncEventsRefused: 0,
|
|
543
|
+
syncConflicts: 0,
|
|
544
|
+
runtimeHeartbeats: 0,
|
|
509
545
|
};
|
|
510
546
|
let cleanupTimer: NodeJS.Timeout | undefined;
|
|
511
547
|
let closed = false;
|
|
@@ -672,6 +708,74 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
|
|
|
672
708
|
}
|
|
673
709
|
}
|
|
674
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
|
+
|
|
675
779
|
function opsSnapshot(project: string) {
|
|
676
780
|
const snapshotAt = Date.now();
|
|
677
781
|
const projectAgents = [...agents.values()]
|
|
@@ -716,6 +820,7 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
|
|
|
716
820
|
};
|
|
717
821
|
}),
|
|
718
822
|
runTotal: projectRuns.length,
|
|
823
|
+
homeRuntimes: homeRuntimesView(project, hubNow()),
|
|
719
824
|
plans: [...journal.values()]
|
|
720
825
|
.filter((entry) => entry.category === "plan" && projectRuns.some((run) => run.id === entry.runId))
|
|
721
826
|
.sort((left, right) => Date.parse(right.createdAt) - Date.parse(left.createdAt))
|
|
@@ -1173,6 +1278,16 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
|
|
|
1173
1278
|
`kxm_leases_refused_total ${counters.leasesRefused}`,
|
|
1174
1279
|
"# TYPE kxm_leases_released_total counter",
|
|
1175
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}`,
|
|
1176
1291
|
"",
|
|
1177
1292
|
].join("\n");
|
|
1178
1293
|
}
|
|
@@ -2259,6 +2374,112 @@ export function createMeshHub(options: MeshHubOptions = {}): MeshHub {
|
|
|
2259
2374
|
return;
|
|
2260
2375
|
}
|
|
2261
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
|
+
|
|
2262
2483
|
// One writer, one clock. Every branch below decides inside a single store
|
|
2263
2484
|
// transaction, so two agents racing for the same resource cannot both read it
|
|
2264
2485
|
// free. The fencing token increments only on takeover, which is what lets a
|
|
@@ -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.74";
|
|
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());
|
|
@@ -93,6 +93,33 @@ export interface LeaseRecord {
|
|
|
93
93
|
expiresAt: string;
|
|
94
94
|
}
|
|
95
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
|
+
|
|
96
123
|
export interface MessageReply {
|
|
97
124
|
content: string;
|
|
98
125
|
createdAt: string;
|