@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/package.json
CHANGED
|
@@ -87,6 +87,24 @@ stored `queued` and delivered once through the reconnect cursor; unknown names
|
|
|
87
87
|
still return `target_not_found`. Queued messages are not evidence unless
|
|
88
88
|
`workflowContext` was hub-authorized at send.
|
|
89
89
|
|
|
90
|
+
## Fenced leases
|
|
91
|
+
|
|
92
|
+
- `POST /v1/leases/:resource/acquire` takes or extends the lease over a
|
|
93
|
+
resource. Body `{ttlMs}` is bounded to 5 s–10 min. A live lease held by
|
|
94
|
+
another agent returns `409 lease_held` with the current holder and token.
|
|
95
|
+
- `POST /v1/leases/:resource/renew` extends a lease under the token it was
|
|
96
|
+
issued. Body `{fencingToken, ttlMs?}`.
|
|
97
|
+
- `POST /v1/leases/:resource/release` gives the resource back. Body
|
|
98
|
+
`{fencingToken}`.
|
|
99
|
+
|
|
100
|
+
All three are agent-authenticated and project-scoped: the hub prefixes the
|
|
101
|
+
caller's project onto `:resource`, so the same name in two projects is two
|
|
102
|
+
leases. Every decision is a compare-and-set on the hub clock inside one store
|
|
103
|
+
transaction. The fencing token starts at 1, is unchanged by renewal, and
|
|
104
|
+
increments only when a new holder takes over an expired lease; a stale token
|
|
105
|
+
returns `409 lease_superseded` with the token the hub now holds. Present the
|
|
106
|
+
token before committing anything shared — a refusal means stop, not retry.
|
|
107
|
+
|
|
90
108
|
## Request states
|
|
91
109
|
|
|
92
110
|
- `queued`: stored by the hub but not acknowledged by the recipient.
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { hostname } from "node:os";
|
|
3
3
|
import { MAX_AGENT_HOST_CHARS } from "./protocol.ts";
|
|
4
|
-
import type { AgentRecord, DeliveryMode, HubEvent, MessageRecord, WorkflowMessageContext } from "./protocol.ts";
|
|
4
|
+
import type { AgentRecord, DeliveryMode, HubEvent, LeaseRecord, MessageRecord, WorkflowMessageContext } from "./protocol.ts";
|
|
5
5
|
import type { ContextAuthority, ContextConfidence, ContextItem, ContextItemAuditMetadata, ContextItemKind, ContextPacket } from "./context.ts";
|
|
6
6
|
import {
|
|
7
7
|
canonicalWorkflowEvidenceKey,
|
|
@@ -341,6 +341,41 @@ export class HubClient {
|
|
|
341
341
|
return result.message;
|
|
342
342
|
}
|
|
343
343
|
|
|
344
|
+
// ----- Fenced leases over shared resources (P3) -----
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Take or extend the lease over `resource` inside this client's project.
|
|
348
|
+
*
|
|
349
|
+
* The returned `fencingToken` is the whole point: hold it, present it on every
|
|
350
|
+
* renewal, and present it again before committing anything shared. A hub that
|
|
351
|
+
* has moved past it refuses, and the caller must stop rather than retry —
|
|
352
|
+
* another holder owns the resource now. Rejects `HubHttpError` with code
|
|
353
|
+
* `lease_held` when a live holder has it.
|
|
354
|
+
*/
|
|
355
|
+
async acquireLease(resource: string, ttlMs?: number): Promise<{ lease: LeaseRecord; renewed: boolean }> {
|
|
356
|
+
return await this.request(`/v1/leases/${encodeURIComponent(resource)}/acquire`, {
|
|
357
|
+
method: "POST",
|
|
358
|
+
body: JSON.stringify(ttlMs === undefined ? {} : { ttlMs }),
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** Extend a lease this client holds. The token never changes on renewal; a
|
|
363
|
+
* `lease_superseded` or `lease_expired` refusal means it is gone. */
|
|
364
|
+
async renewLease(resource: string, fencingToken: number, ttlMs?: number): Promise<{ lease: LeaseRecord }> {
|
|
365
|
+
return await this.request(`/v1/leases/${encodeURIComponent(resource)}/renew`, {
|
|
366
|
+
method: "POST",
|
|
367
|
+
body: JSON.stringify(ttlMs === undefined ? { fencingToken } : { fencingToken, ttlMs }),
|
|
368
|
+
});
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Give the resource back. */
|
|
372
|
+
async releaseLease(resource: string, fencingToken: number): Promise<{ released: boolean; lease: LeaseRecord }> {
|
|
373
|
+
return await this.request(`/v1/leases/${encodeURIComponent(resource)}/release`, {
|
|
374
|
+
method: "POST",
|
|
375
|
+
body: JSON.stringify({ fencingToken }),
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
|
|
344
379
|
async listWorkflows(): Promise<WorkflowRun[]> {
|
|
345
380
|
const result = await this.request<{ runs: WorkflowRun[] }>("/v1/workflows");
|
|
346
381
|
return result.runs;
|
|
@@ -597,15 +632,27 @@ export class HubClient {
|
|
|
597
632
|
}
|
|
598
633
|
|
|
599
634
|
private async request<T = unknown>(path: string, init: RequestInit = {}, includeIdentity = true): Promise<T> {
|
|
600
|
-
|
|
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;
|
|
601
648
|
const timeoutSignal = AbortSignal.timeout(requestTimeoutMs);
|
|
602
649
|
const signal = init.signal ? AbortSignal.any([init.signal, timeoutSignal]) : timeoutSignal;
|
|
603
650
|
let response: Response;
|
|
604
651
|
try {
|
|
605
|
-
response = await (
|
|
652
|
+
response = await (options.fetchImpl ?? fetch)(`${options.serverUrl.replace(/\/$/, "")}${path}`, {
|
|
606
653
|
...init,
|
|
607
654
|
signal,
|
|
608
|
-
headers: { ...
|
|
655
|
+
headers: { ...headers, ...(init.headers ?? {}) },
|
|
609
656
|
});
|
|
610
657
|
} catch (error) {
|
|
611
658
|
if (timeoutSignal.aborted) throw new Error(`request timed out after ${requestTimeoutMs}ms`);
|
|
@@ -625,6 +672,9 @@ export class HubClient {
|
|
|
625
672
|
for (const key of ["operation", "nextAction", "assignedCoordinatorName"]) {
|
|
626
673
|
if (typeof body[key] === "string") extras[key] = body[key];
|
|
627
674
|
}
|
|
675
|
+
// A lease refusal carries the lease that won, so the loser can record who
|
|
676
|
+
// holds the resource and at which token instead of guessing.
|
|
677
|
+
if (body.lease && typeof body.lease === "object") extras.lease = body.lease;
|
|
628
678
|
throw new HubHttpError(
|
|
629
679
|
response.status,
|
|
630
680
|
String(body.error ?? `HTTP ${response.status}`),
|
|
@@ -634,5 +684,75 @@ export class HubClient {
|
|
|
634
684
|
);
|
|
635
685
|
}
|
|
636
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);
|
|
637
757
|
}
|
|
638
758
|
}
|
|
@@ -591,7 +591,9 @@ export function discoverProjectStores(projectRoot: string, options: { hubDataPat
|
|
|
591
591
|
|
|
592
592
|
const hubPath = options.hubDataPath ? resolve(options.hubDataPath) : join(root, ".kxm", "state", "kxm.db");
|
|
593
593
|
if (existsSync(hubPath)) {
|
|
594
|
-
|
|
594
|
+
// Must track HUB_STORE_SCHEMA_VERSION in store.ts: the hub's own fresh backup is
|
|
595
|
+
// restored through this ceiling, so a bump left behind here refuses it.
|
|
596
|
+
stores.push({ storeId: "hub-store", sourcePath: hubPath, maxSupportedVersion: 5 });
|
|
595
597
|
}
|
|
596
598
|
|
|
597
599
|
const registryPath = join(root, ".kxm", "runtime", "registry.db");
|
|
@@ -613,7 +615,7 @@ export function discoverProjectStores(projectRoot: string, options: { hubDataPat
|
|
|
613
615
|
stores.push({
|
|
614
616
|
storeId: `events:${key}`,
|
|
615
617
|
sourcePath: join(eventsDir, entry.name),
|
|
616
|
-
maxSupportedVersion:
|
|
618
|
+
maxSupportedVersion: 6,
|
|
617
619
|
});
|
|
618
620
|
}
|
|
619
621
|
}
|
|
@@ -727,14 +729,16 @@ export function restoreBackup(
|
|
|
727
729
|
);
|
|
728
730
|
}
|
|
729
731
|
|
|
730
|
-
|
|
732
|
+
// The hub-store ceiling. Must track HUB_STORE_SCHEMA_VERSION in store.ts for the
|
|
733
|
+
// same reason as the events ceiling below.
|
|
734
|
+
let maxSupported = 5;
|
|
731
735
|
if (store.storeId === "registry" || store.storeId === "binding-store") {
|
|
732
736
|
maxSupported = 1;
|
|
733
737
|
} else if (store.storeId.startsWith("events:")) {
|
|
734
738
|
// Must track KXM_EVENT_STORE_SCHEMA_VERSION in runtime-store.ts. The pin is
|
|
735
739
|
// the e6 backup/restore round-trip test: bump one without the other and it
|
|
736
740
|
// refuses its own fresh backup.
|
|
737
|
-
maxSupported =
|
|
741
|
+
maxSupported = 6;
|
|
738
742
|
}
|
|
739
743
|
|
|
740
744
|
let targetPath = store.sourcePath;
|