@kontextmind/kxm 0.7.45 → 0.7.47
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/kb/qa-authentik-authentication.md +17 -0
- package/docs/kb/qa-extension-install-and-hub-bootstrap.md +17 -0
- package/docs/kb/qa-hub-on-a-public-host.md +17 -0
- package/docs/kb/qa-sqlite-vs-duckdb.md +17 -0
- package/docs/kb/qa-what-the-hub-stores.md +17 -0
- package/docs/operations.md +9 -1
- package/docs/troubleshooting.md +1 -1
- package/package.json +1 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/cli.js +118 -1
- package/plugins/kxm/dist/mcp-server.js +1 -1
- package/plugins/kxm/dist/runtime-supervisor.js +327 -2
- package/plugins/kxm/dist/runtime.js +329 -3
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/src/database.ts +4 -1
- package/plugins/kxm/src/intake.ts +536 -0
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/project-config.ts +44 -0
- package/plugins/kxm/src/runtime-store.ts +390 -2
- package/schemas/README.md +2 -0
- package/schemas/coordinator.schema.json +54 -0
- package/schemas/intake-message.schema.json +72 -0
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KXM Runtime intake contract (unified plan M1 + M6, the durable part of M2).
|
|
3
|
+
*
|
|
4
|
+
* One persistent, project-scoped coordinator identity carries an authority
|
|
5
|
+
* ceiling; internal messages arrive against that identity with an idempotency
|
|
6
|
+
* key; and a project pause blocks *fresh dispatch* without losing the intent.
|
|
7
|
+
* These are separate facts and are recorded separately: received, persisted,
|
|
8
|
+
* and dispatch-ready each have their own state.
|
|
9
|
+
*
|
|
10
|
+
* Nothing here opens a model, a peer session, or an HTTP surface. The Runtime is
|
|
11
|
+
* the only writer, and adapters that later expose this must stay thin over it.
|
|
12
|
+
* Until they exist, no control is advertised — which is the honest M0 answer:
|
|
13
|
+
* unintegrated capability is unavailable, never a success-shaped stub.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { createHash } from "node:crypto";
|
|
17
|
+
import { newId } from "./protocol.ts";
|
|
18
|
+
import {
|
|
19
|
+
kxmCanonicalJson,
|
|
20
|
+
validateCoordinator,
|
|
21
|
+
validateIntakeMessage,
|
|
22
|
+
type JsonValue,
|
|
23
|
+
} from "./project-config.ts";
|
|
24
|
+
import type { KxmRuntimeContext } from "./runtime-service.ts";
|
|
25
|
+
import { runtimeError, type KxmIntakeMessageRow, type KxmProjectControlRow } from "./runtime-store.ts";
|
|
26
|
+
|
|
27
|
+
/** Largest intake payload we are willing to hash and persist, in bytes. */
|
|
28
|
+
export const MAX_INTAKE_CONTENT_BYTES = 16_384;
|
|
29
|
+
|
|
30
|
+
export type KxmIntakeClassification = "public" | "project" | "sensitive" | "secret";
|
|
31
|
+
export type KxmIntakeDispatchState = "ready" | "held_paused" | "admitted" | "refused";
|
|
32
|
+
export type KxmIntakeSourceKind = "adapter" | "hub" | "operator" | "peer" | "schedule";
|
|
33
|
+
|
|
34
|
+
export interface KxmCoordinatorAuthority {
|
|
35
|
+
repositoryAccess: "none" | "read" | "write";
|
|
36
|
+
effects: readonly string[];
|
|
37
|
+
tools?: {
|
|
38
|
+
preset?: string;
|
|
39
|
+
allow?: readonly string[];
|
|
40
|
+
deny?: readonly string[];
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface KxmCoordinatorRecord {
|
|
45
|
+
schema: "kxm.coordinator.v1";
|
|
46
|
+
coordinatorId: string;
|
|
47
|
+
projectId: string;
|
|
48
|
+
role: string;
|
|
49
|
+
channel: string;
|
|
50
|
+
authority: KxmCoordinatorAuthority;
|
|
51
|
+
boundAt: string;
|
|
52
|
+
boundBy: { kind: "human" | "runtime" | "hub" | "agent" | "adapter"; id: string };
|
|
53
|
+
configRevision: string;
|
|
54
|
+
ceilingHash: string;
|
|
55
|
+
rebindOf?: string;
|
|
56
|
+
rebindReason?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface KxmIntakeMessage {
|
|
60
|
+
schema: "kxm.intake-message.v1";
|
|
61
|
+
messageId: string;
|
|
62
|
+
projectId: string;
|
|
63
|
+
coordinatorId: string;
|
|
64
|
+
receivedAt: string;
|
|
65
|
+
source: { kind: KxmIntakeSourceKind; id: string };
|
|
66
|
+
idempotencyKey: string;
|
|
67
|
+
contentHash: string;
|
|
68
|
+
content?: string;
|
|
69
|
+
contentOmittedReason?: "secret-classified";
|
|
70
|
+
classification: KxmIntakeClassification;
|
|
71
|
+
dispatch: {
|
|
72
|
+
state: KxmIntakeDispatchState;
|
|
73
|
+
reason?: string;
|
|
74
|
+
updatedAt?: string;
|
|
75
|
+
runId?: string;
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** The persisted shape behind the project-control columns. */
|
|
80
|
+
interface KxmProjectControlRecord {
|
|
81
|
+
schema: "kxm.project-control.v1";
|
|
82
|
+
projectId: string;
|
|
83
|
+
paused: boolean;
|
|
84
|
+
reason?: string;
|
|
85
|
+
updatedAt: string;
|
|
86
|
+
actor: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const ACCESS_ORDER: Record<KxmCoordinatorAuthority["repositoryAccess"], number> = {
|
|
90
|
+
none: 0,
|
|
91
|
+
read: 1,
|
|
92
|
+
write: 2,
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
const IDENTIFIER_RE = /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/;
|
|
96
|
+
const COORDINATOR_ID_RE = /^[a-z][a-z0-9]{1,15}_[A-Za-z0-9][A-Za-z0-9_-]{5,127}$/;
|
|
97
|
+
const ACTOR_ID_RE = /^.{1,200}$/;
|
|
98
|
+
|
|
99
|
+
/** Canonical SHA-256 over the authority ceiling — the coordinator's fingerprint. */
|
|
100
|
+
export function kxmCeilingHash(authority: KxmCoordinatorAuthority): string {
|
|
101
|
+
return `sha256:${createHash("sha256").update(stableStringify(authority), "utf8").digest("hex")}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Bind the coordinator for a (project, role, channel) slot. Binding is
|
|
106
|
+
* create-once and idempotent: presenting the same ceiling again returns the
|
|
107
|
+
* existing identity rather than minting a second one.
|
|
108
|
+
*
|
|
109
|
+
* A different ceiling is a **rebind** and needs explicit policy; it may narrow
|
|
110
|
+
* the ceiling but never widen it, because a message or a model swap must not be
|
|
111
|
+
* able to grow authority.
|
|
112
|
+
*/
|
|
113
|
+
export function bindKxmCoordinator(
|
|
114
|
+
context: KxmRuntimeContext,
|
|
115
|
+
input: {
|
|
116
|
+
role: string;
|
|
117
|
+
channel?: string;
|
|
118
|
+
authority: KxmCoordinatorAuthority;
|
|
119
|
+
actor: { kind: KxmCoordinatorRecord["boundBy"]["kind"]; id: string };
|
|
120
|
+
now?: string;
|
|
121
|
+
rebind?: { approvedBy: { kind: KxmCoordinatorRecord["boundBy"]["kind"]; id: string }; reason: string };
|
|
122
|
+
},
|
|
123
|
+
): { coordinator: KxmCoordinatorRecord; created: boolean } {
|
|
124
|
+
const role = requireIdentifier(input.role, "role");
|
|
125
|
+
const channel = requireIdentifier(input.channel ?? "primary", "channel");
|
|
126
|
+
const authority = validateAuthority(input.authority);
|
|
127
|
+
const actor = validateActor(input.actor, "actor");
|
|
128
|
+
const now = requireTimestamp(input.now ?? new Date().toISOString(), "now");
|
|
129
|
+
const ceilingHash = kxmCeilingHash(authority);
|
|
130
|
+
const existing = context.eventStore.coordinatorInSlot(context.projectId, role, channel);
|
|
131
|
+
|
|
132
|
+
if (existing) {
|
|
133
|
+
const current = JSON.parse(existing.record) as KxmCoordinatorRecord;
|
|
134
|
+
if (current.ceilingHash === ceilingHash) {
|
|
135
|
+
return { coordinator: current, created: false };
|
|
136
|
+
}
|
|
137
|
+
if (!input.rebind) {
|
|
138
|
+
throw runtimeError(
|
|
139
|
+
"coordinator_rebind_requires_policy",
|
|
140
|
+
existing.coordinatorId,
|
|
141
|
+
`coordinator for role ${role}/${channel} is already bound with a different ceiling; rebinding needs explicit policy`,
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
assertCeilingNotWidened(current.authority, authority, existing.coordinatorId);
|
|
145
|
+
const reason = requireText(input.rebind.reason, "rebind.reason", 512);
|
|
146
|
+
const record: KxmCoordinatorRecord = {
|
|
147
|
+
schema: "kxm.coordinator.v1",
|
|
148
|
+
coordinatorId: newId("crd"),
|
|
149
|
+
projectId: context.projectId,
|
|
150
|
+
role,
|
|
151
|
+
channel,
|
|
152
|
+
authority,
|
|
153
|
+
boundAt: now,
|
|
154
|
+
boundBy: validateActor(input.rebind.approvedBy, "rebind.approvedBy"),
|
|
155
|
+
configRevision: current.configRevision,
|
|
156
|
+
ceilingHash,
|
|
157
|
+
rebindOf: existing.coordinatorId,
|
|
158
|
+
rebindReason: reason,
|
|
159
|
+
};
|
|
160
|
+
validateCoordinator(record, record.coordinatorId);
|
|
161
|
+
const replaced = context.eventStore.replaceCoordinatorInSlot(existing.coordinatorId, {
|
|
162
|
+
coordinatorId: record.coordinatorId,
|
|
163
|
+
projectId: record.projectId,
|
|
164
|
+
role: record.role,
|
|
165
|
+
channel: record.channel,
|
|
166
|
+
ceilingHash: record.ceilingHash,
|
|
167
|
+
configRevision: record.configRevision,
|
|
168
|
+
boundAt: record.boundAt,
|
|
169
|
+
record: kxmCanonicalJson(record as unknown as JsonValue),
|
|
170
|
+
});
|
|
171
|
+
if (!replaced) {
|
|
172
|
+
throw runtimeError("coordinator_write_lost", existing.coordinatorId, "the coordinator slot changed underneath this rebind");
|
|
173
|
+
}
|
|
174
|
+
return { coordinator: record, created: true };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (input.rebind) {
|
|
178
|
+
throw runtimeError("coordinator_rebind_without_subject", role, "there is no bound coordinator to rebind");
|
|
179
|
+
}
|
|
180
|
+
const record: KxmCoordinatorRecord = {
|
|
181
|
+
schema: "kxm.coordinator.v1",
|
|
182
|
+
coordinatorId: newId("crd"),
|
|
183
|
+
projectId: context.projectId,
|
|
184
|
+
role,
|
|
185
|
+
channel,
|
|
186
|
+
authority,
|
|
187
|
+
boundAt: now,
|
|
188
|
+
boundBy: actor,
|
|
189
|
+
configRevision: contextConfigRevision(context),
|
|
190
|
+
ceilingHash,
|
|
191
|
+
};
|
|
192
|
+
persistCoordinator(context, record);
|
|
193
|
+
return { coordinator: record, created: true };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** Resolve a coordinator by id; unknown identity fails closed. */
|
|
197
|
+
export function resolveKxmCoordinator(context: KxmRuntimeContext, coordinatorId: string): KxmCoordinatorRecord {
|
|
198
|
+
const row = context.eventStore.coordinatorById(requireText(coordinatorId, "coordinatorId", 144));
|
|
199
|
+
if (!row) throw runtimeError("coordinator_unknown", coordinatorId, "no coordinator is bound in this project");
|
|
200
|
+
return JSON.parse(row.record) as KxmCoordinatorRecord;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Accept one internal message at the intake boundary.
|
|
205
|
+
*
|
|
206
|
+
* Duplicate ingress (same idempotency key, same content) returns the original
|
|
207
|
+
* record and cannot create another task. The same key with **different** content
|
|
208
|
+
* is rejected: a reused key must not smuggle a different payload. Payloads
|
|
209
|
+
* classified `secret` are never persisted — only their hash, so intake cannot
|
|
210
|
+
* become a secret store. While the project is paused the message is durable with
|
|
211
|
+
* a held dispatch intent instead of being dropped.
|
|
212
|
+
*/
|
|
213
|
+
export function acceptKxmIntakeMessage(
|
|
214
|
+
context: KxmRuntimeContext,
|
|
215
|
+
input: {
|
|
216
|
+
coordinatorId: string;
|
|
217
|
+
idempotencyKey: string;
|
|
218
|
+
content: string;
|
|
219
|
+
classification?: KxmIntakeClassification;
|
|
220
|
+
source: { kind: KxmIntakeSourceKind; id: string };
|
|
221
|
+
now?: string;
|
|
222
|
+
},
|
|
223
|
+
): { message: KxmIntakeMessage; duplicate: boolean } {
|
|
224
|
+
const coordinator = resolveKxmCoordinator(context, input.coordinatorId);
|
|
225
|
+
const key = requireText(input.idempotencyKey, "idempotencyKey", 200);
|
|
226
|
+
if (typeof input.content !== "string" || input.content.length === 0) {
|
|
227
|
+
throw runtimeError("intake_content_missing", coordinator.coordinatorId, "intake content must be a non-empty string");
|
|
228
|
+
}
|
|
229
|
+
const classification = input.classification ?? "project";
|
|
230
|
+
if (!["public", "project", "sensitive", "secret"].includes(classification)) {
|
|
231
|
+
throw runtimeError("intake_classification_invalid", coordinator.coordinatorId, `unknown intake classification ${classification}`);
|
|
232
|
+
}
|
|
233
|
+
const sourceKind = input.source?.kind;
|
|
234
|
+
if (!["adapter", "hub", "operator", "peer", "schedule"].includes(String(sourceKind))) {
|
|
235
|
+
throw runtimeError("intake_source_invalid", coordinator.coordinatorId, `unknown intake source kind ${String(sourceKind)}`);
|
|
236
|
+
}
|
|
237
|
+
const sourceId = requireText(input.source.id, "source.id", 200);
|
|
238
|
+
const now = requireTimestamp(input.now ?? new Date().toISOString(), "now");
|
|
239
|
+
const bytes = Buffer.byteLength(input.content, "utf8");
|
|
240
|
+
const contentHash = `sha256:${createHash("sha256").update(input.content, "utf8").digest("hex")}`;
|
|
241
|
+
|
|
242
|
+
const existingRow = context.eventStore.intakeBySlot(context.projectId, coordinator.coordinatorId, key);
|
|
243
|
+
if (existingRow) {
|
|
244
|
+
const existing = JSON.parse(existingRow.record) as KxmIntakeMessage;
|
|
245
|
+
if (existing.contentHash !== contentHash) {
|
|
246
|
+
throw runtimeError(
|
|
247
|
+
"intake_payload_conflict",
|
|
248
|
+
existing.messageId,
|
|
249
|
+
`idempotency key ${key} was already used with different content`,
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
return { message: existing, duplicate: true };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
if (bytes > MAX_INTAKE_CONTENT_BYTES) {
|
|
256
|
+
throw runtimeError(
|
|
257
|
+
"intake_content_too_large",
|
|
258
|
+
coordinator.coordinatorId,
|
|
259
|
+
`intake content is ${bytes} bytes; the limit is ${MAX_INTAKE_CONTENT_BYTES}`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Only secrets are withheld. Everything else is the project's own bounded,
|
|
264
|
+
// owner-only store; omitting project content would just lose the work.
|
|
265
|
+
const persistContent = classification !== "secret";
|
|
266
|
+
const paused = isKxmProjectPaused(context);
|
|
267
|
+
const message: KxmIntakeMessage = {
|
|
268
|
+
schema: "kxm.intake-message.v1",
|
|
269
|
+
messageId: newId("msg"),
|
|
270
|
+
projectId: context.projectId,
|
|
271
|
+
coordinatorId: coordinator.coordinatorId,
|
|
272
|
+
receivedAt: now,
|
|
273
|
+
source: { kind: sourceKind, id: sourceId },
|
|
274
|
+
idempotencyKey: key,
|
|
275
|
+
contentHash,
|
|
276
|
+
...(persistContent ? { content: input.content } : { contentOmittedReason: "secret-classified" as const }),
|
|
277
|
+
classification,
|
|
278
|
+
dispatch: paused
|
|
279
|
+
? { state: "held_paused", reason: "project_paused", updatedAt: now }
|
|
280
|
+
: { state: "ready" },
|
|
281
|
+
};
|
|
282
|
+
validateIntakeMessage(message, message.messageId);
|
|
283
|
+
|
|
284
|
+
const inserted = context.eventStore.insertIntakeMessageIfAbsent({
|
|
285
|
+
messageId: message.messageId,
|
|
286
|
+
projectId: message.projectId,
|
|
287
|
+
coordinatorId: message.coordinatorId,
|
|
288
|
+
idempotencyKey: message.idempotencyKey,
|
|
289
|
+
contentHash: message.contentHash,
|
|
290
|
+
receivedAt: message.receivedAt,
|
|
291
|
+
dispatchState: message.dispatch.state,
|
|
292
|
+
record: kxmCanonicalJson(message as unknown as JsonValue),
|
|
293
|
+
});
|
|
294
|
+
if (!inserted) {
|
|
295
|
+
// Lost the race against an identical key: report the winner as the duplicate.
|
|
296
|
+
const winner = context.eventStore.intakeBySlot(context.projectId, coordinator.coordinatorId, key);
|
|
297
|
+
if (!winner) throw runtimeError("intake_write_lost", coordinator.coordinatorId, "intake write lost its slot and left no record");
|
|
298
|
+
return { message: JSON.parse(winner.record) as KxmIntakeMessage, duplicate: true };
|
|
299
|
+
}
|
|
300
|
+
return { message, duplicate: false };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Pending intake that may be dispatched, oldest first. Empty while paused. */
|
|
304
|
+
export function listKxmDispatchableIntake(context: KxmRuntimeContext, limit = 50): KxmIntakeMessage[] {
|
|
305
|
+
if (isKxmProjectPaused(context)) return [];
|
|
306
|
+
return context.eventStore
|
|
307
|
+
.intakeInStates(context.projectId, ["ready"], limit)
|
|
308
|
+
.map((row) => JSON.parse(row.record) as KxmIntakeMessage);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Bind a ready message to its run. Admission is idempotent for the same run and
|
|
313
|
+
* refuses a second one, so duplicate ingress cannot create another task. While
|
|
314
|
+
* paused, nothing may be admitted at all.
|
|
315
|
+
*/
|
|
316
|
+
export function admitKxmIntakeRun(
|
|
317
|
+
context: KxmRuntimeContext,
|
|
318
|
+
messageId: string,
|
|
319
|
+
input: { runId: string; now?: string },
|
|
320
|
+
): KxmIntakeMessage {
|
|
321
|
+
const id = requireText(messageId, "messageId", 144);
|
|
322
|
+
const runId = requireText(input.runId, "runId", 144);
|
|
323
|
+
if (!COORDINATOR_ID_RE.test(runId)) throw runtimeError("intake_run_id_invalid", id, "runId is not a well-formed opaque id");
|
|
324
|
+
const now = requireTimestamp(input.now ?? new Date().toISOString(), "now");
|
|
325
|
+
if (isKxmProjectPaused(context)) {
|
|
326
|
+
throw runtimeError("intake_paused", id, "the project is paused; no fresh dispatch may be admitted");
|
|
327
|
+
}
|
|
328
|
+
const row = context.eventStore.intakeMessage(id);
|
|
329
|
+
if (!row) throw runtimeError("intake_message_unknown", id, "no such intake message in this project");
|
|
330
|
+
const message = JSON.parse(row.record) as KxmIntakeMessage;
|
|
331
|
+
if (message.dispatch.state === "admitted") {
|
|
332
|
+
if (message.dispatch.runId === runId) return message;
|
|
333
|
+
throw runtimeError("intake_second_admission", id, `message already admitted as ${message.dispatch.runId}`);
|
|
334
|
+
}
|
|
335
|
+
if (message.dispatch.state !== "ready") {
|
|
336
|
+
throw runtimeError("intake_not_dispatchable", id, `intake state ${message.dispatch.state} cannot be admitted`);
|
|
337
|
+
}
|
|
338
|
+
const next: KxmIntakeMessage = { ...message, dispatch: { state: "admitted", runId, updatedAt: now } };
|
|
339
|
+
validateIntakeMessage(next, next.messageId);
|
|
340
|
+
const updated = context.eventStore.updateIntakeDispatch(id, "ready", { state: "admitted", record: kxmCanonicalJson(next as unknown as JsonValue) });
|
|
341
|
+
if (!updated) {
|
|
342
|
+
const racer = context.eventStore.intakeMessage(id);
|
|
343
|
+
const current = racer ? (JSON.parse(racer.record) as KxmIntakeMessage) : undefined;
|
|
344
|
+
if (current?.dispatch.state === "admitted" && current.dispatch.runId === runId) return current;
|
|
345
|
+
throw runtimeError("intake_dispatch_race", id, "the intake record changed underneath this admission");
|
|
346
|
+
}
|
|
347
|
+
return next;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Pause or resume intake dispatch for the project. Pausing does not cancel work
|
|
352
|
+
* that is already admitted; it stops fresh dispatch and holds the intent. A
|
|
353
|
+
* message that arrives *during* the pause is stored as `held_paused`, and a
|
|
354
|
+
* resume releases exactly those, in received order. Messages that were already
|
|
355
|
+
* `ready` stay ready — pause blocks the dispatch decision, not the record — so
|
|
356
|
+
* nothing is lost or duplicated across the pause window either way.
|
|
357
|
+
*/
|
|
358
|
+
export function setKxmProjectPause(
|
|
359
|
+
context: KxmRuntimeContext,
|
|
360
|
+
input: { paused: boolean; actor: { kind: KxmCoordinatorRecord["boundBy"]["kind"]; id: string }; reason?: string; now?: string },
|
|
361
|
+
): { control: KxmProjectControlRow; released: KxmIntakeMessage[] } {
|
|
362
|
+
const actor = validateActor(input.actor, "actor");
|
|
363
|
+
const now = requireTimestamp(input.now ?? new Date().toISOString(), "now");
|
|
364
|
+
const reason = input.reason === undefined ? undefined : requireText(input.reason, "reason", 512);
|
|
365
|
+
const record: KxmProjectControlRecord = {
|
|
366
|
+
schema: "kxm.project-control.v1",
|
|
367
|
+
projectId: context.projectId,
|
|
368
|
+
paused: input.paused,
|
|
369
|
+
...(reason !== undefined ? { reason } : {}),
|
|
370
|
+
updatedAt: now,
|
|
371
|
+
actor: `${actor.kind}:${actor.id}`,
|
|
372
|
+
};
|
|
373
|
+
const control: KxmProjectControlRow = {
|
|
374
|
+
projectId: context.projectId,
|
|
375
|
+
paused: input.paused,
|
|
376
|
+
...(reason !== undefined ? { reason } : {}),
|
|
377
|
+
updatedAt: now,
|
|
378
|
+
actor: record.actor,
|
|
379
|
+
record: kxmCanonicalJson(record as unknown as JsonValue),
|
|
380
|
+
};
|
|
381
|
+
context.eventStore.putProjectControl(control);
|
|
382
|
+
const released = input.paused ? [] : releaseHeldIntake(context, now);
|
|
383
|
+
return { control, released };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** Whether fresh dispatch is currently blocked for this project. */
|
|
387
|
+
export function isKxmProjectPaused(context: KxmRuntimeContext): boolean {
|
|
388
|
+
return context.eventStore.projectControl(context.projectId)?.paused === true;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function releaseHeldIntake(context: KxmRuntimeContext, now: string): KxmIntakeMessage[] {
|
|
392
|
+
const held = context.eventStore.intakeInStates(context.projectId, ["held_paused"], 500);
|
|
393
|
+
const released: KxmIntakeMessage[] = [];
|
|
394
|
+
for (const row of held) {
|
|
395
|
+
const message = JSON.parse(row.record) as KxmIntakeMessage;
|
|
396
|
+
const next: KxmIntakeMessage = { ...message, dispatch: { state: "ready", updatedAt: now } };
|
|
397
|
+
validateIntakeMessage(next, next.messageId);
|
|
398
|
+
if (context.eventStore.updateIntakeDispatch(row.messageId, "held_paused", { state: "ready", record: kxmCanonicalJson(next as unknown as JsonValue) })) {
|
|
399
|
+
released.push(next);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
return released;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function persistCoordinator(context: KxmRuntimeContext, record: KxmCoordinatorRecord): void {
|
|
406
|
+
validateCoordinator(record, record.coordinatorId);
|
|
407
|
+
const inserted = context.eventStore.insertCoordinatorIfAbsent({
|
|
408
|
+
coordinatorId: record.coordinatorId,
|
|
409
|
+
projectId: record.projectId,
|
|
410
|
+
role: record.role,
|
|
411
|
+
channel: record.channel,
|
|
412
|
+
ceilingHash: record.ceilingHash,
|
|
413
|
+
configRevision: record.configRevision,
|
|
414
|
+
boundAt: record.boundAt,
|
|
415
|
+
record: kxmCanonicalJson(record as unknown as JsonValue),
|
|
416
|
+
});
|
|
417
|
+
if (!inserted) {
|
|
418
|
+
throw runtimeError("coordinator_write_lost", record.coordinatorId, "the coordinator slot was claimed underneath this bind");
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function assertCeilingNotWidened(
|
|
423
|
+
previous: KxmCoordinatorAuthority,
|
|
424
|
+
next: KxmCoordinatorAuthority,
|
|
425
|
+
coordinatorId: string,
|
|
426
|
+
): void {
|
|
427
|
+
if (ACCESS_ORDER[next.repositoryAccess] > ACCESS_ORDER[previous.repositoryAccess]) {
|
|
428
|
+
throw runtimeError("coordinator_rebind_widens_access", coordinatorId, "a rebind may not widen repository access");
|
|
429
|
+
}
|
|
430
|
+
const had = new Set(previous.effects);
|
|
431
|
+
const added = next.effects.filter((effect) => !had.has(effect));
|
|
432
|
+
if (added.length > 0) {
|
|
433
|
+
throw runtimeError("coordinator_rebind_widens_effects", coordinatorId, `a rebind may not add effects: ${added.join(", ")}`);
|
|
434
|
+
}
|
|
435
|
+
const allowedBefore = new Set(previous.tools?.allow ?? []);
|
|
436
|
+
const newlyAllowed = (next.tools?.allow ?? []).filter((tool) => !allowedBefore.has(tool));
|
|
437
|
+
if (newlyAllowed.length > 0) {
|
|
438
|
+
throw runtimeError("coordinator_rebind_widens_tools", coordinatorId, `a rebind may not allow new tools: ${newlyAllowed.join(", ")}`);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function validateAuthority(authority: KxmCoordinatorAuthority): KxmCoordinatorAuthority {
|
|
443
|
+
if (!authority || typeof authority !== "object") {
|
|
444
|
+
throw runtimeError("coordinator_authority_invalid", "authority", "authority must be an object");
|
|
445
|
+
}
|
|
446
|
+
if (!(authority.repositoryAccess in ACCESS_ORDER)) {
|
|
447
|
+
throw runtimeError("coordinator_authority_invalid", "repositoryAccess", "repositoryAccess must be none, read or write");
|
|
448
|
+
}
|
|
449
|
+
if (!Array.isArray(authority.effects)) {
|
|
450
|
+
throw runtimeError("coordinator_authority_invalid", "effects", "effects must be an array of identifiers");
|
|
451
|
+
}
|
|
452
|
+
const effects = authority.effects.map((effect) => requireIdentifier(effect, "effects[]"));
|
|
453
|
+
if (new Set(effects).size !== effects.length) {
|
|
454
|
+
throw runtimeError("coordinator_authority_invalid", "effects", "effects must not repeat");
|
|
455
|
+
}
|
|
456
|
+
if (effects.length > 64) {
|
|
457
|
+
throw runtimeError("coordinator_authority_invalid", "effects", "effects may not exceed 64 entries");
|
|
458
|
+
}
|
|
459
|
+
const tools = authority.tools;
|
|
460
|
+
if (tools !== undefined) {
|
|
461
|
+
const lists = [tools.allow ?? [], tools.deny ?? []];
|
|
462
|
+
for (const list of lists) for (const tool of list) requireIdentifier(tool, "tools");
|
|
463
|
+
if (tools.preset !== undefined) requireIdentifier(tools.preset, "tools.preset");
|
|
464
|
+
}
|
|
465
|
+
return {
|
|
466
|
+
repositoryAccess: authority.repositoryAccess,
|
|
467
|
+
effects,
|
|
468
|
+
...(tools !== undefined ? { tools } : {}),
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function validateActor<T extends { kind: KxmCoordinatorRecord["boundBy"]["kind"]; id: string }>(actor: T, field: string): T {
|
|
473
|
+
const kinds: string[] = ["human", "runtime", "hub", "agent", "adapter"];
|
|
474
|
+
if (!actor || typeof actor !== "object" || !kinds.includes(actor.kind)) {
|
|
475
|
+
throw runtimeError("coordinator_actor_invalid", field, "actor kind must be one of " + kinds.join(", "));
|
|
476
|
+
}
|
|
477
|
+
const id = requireText(actor.id, `${field}.id`, 200);
|
|
478
|
+
if (!ACTOR_ID_RE.test(id)) throw runtimeError("coordinator_actor_invalid", `${field}.id`, "actor id is out of bounds");
|
|
479
|
+
return { ...actor, id };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function requireIdentifier(value: string, field: string): string {
|
|
483
|
+
const text = requireText(value, field, 64);
|
|
484
|
+
if (!IDENTIFIER_RE.test(text)) {
|
|
485
|
+
throw runtimeError("coordinator_identifier_invalid", field, `${text} is not a kebab/snake identifier`);
|
|
486
|
+
}
|
|
487
|
+
return text;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function requireText(value: unknown, field: string, max: number): string {
|
|
491
|
+
if (typeof value !== "string" || value.trim().length === 0) {
|
|
492
|
+
throw runtimeError("intake_field_invalid", field, `${field} must be a non-empty string`);
|
|
493
|
+
}
|
|
494
|
+
const text = value.trim();
|
|
495
|
+
if (text.length > max) throw runtimeError("intake_field_invalid", field, `${field} exceeds ${max} characters`);
|
|
496
|
+
return text;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function requireTimestamp(value: string, field: string): string {
|
|
500
|
+
if (!/^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(?:\.[0-9]{1,9})?Z$/.test(value)) {
|
|
501
|
+
throw runtimeError("intake_field_invalid", field, `${field} must be an ISO8601 UTC timestamp`);
|
|
502
|
+
}
|
|
503
|
+
if (!Number.isFinite(Date.parse(value))) {
|
|
504
|
+
throw runtimeError("intake_field_invalid", field, `${field} is not a real timestamp`);
|
|
505
|
+
}
|
|
506
|
+
return value;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function contextConfigRevision(context: KxmRuntimeContext): string {
|
|
510
|
+
const revision = (context as { configRevision?: string }).configRevision;
|
|
511
|
+
if (typeof revision === "string" && /^sha256:[a-f0-9]{64}$/.test(revision)) return revision;
|
|
512
|
+
// The context does not carry one: fingerprint its own identity inputs instead of
|
|
513
|
+
// inventing a revision, so drift across a reopen is still detectable.
|
|
514
|
+
return `sha256:${createHash("sha256").update(stableStringify({
|
|
515
|
+
projectId: context.projectId,
|
|
516
|
+
projectRoot: context.projectRoot,
|
|
517
|
+
homeRuntimeId: context.homeRuntimeId,
|
|
518
|
+
}), "utf8").digest("hex")}`;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function stableStringify(value: unknown): string {
|
|
522
|
+
return JSON.stringify(sortDeep(value));
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
function sortDeep(value: unknown): unknown {
|
|
526
|
+
if (Array.isArray(value)) return value.map(sortDeep);
|
|
527
|
+
if (value && typeof value === "object") {
|
|
528
|
+
const out: Record<string, unknown> = {};
|
|
529
|
+
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
|
|
530
|
+
const child = (value as Record<string, unknown>)[key];
|
|
531
|
+
if (child !== undefined) out[key] = sortDeep(child);
|
|
532
|
+
}
|
|
533
|
+
return out;
|
|
534
|
+
}
|
|
535
|
+
return value;
|
|
536
|
+
}
|
|
@@ -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.47";
|
|
12
12
|
const inbox = new Map<string, MessageRecord>();
|
|
13
13
|
const notifiedInbox = new Set<string>();
|
|
14
14
|
let meshClient: HubClient | undefined;
|
|
@@ -167,6 +167,8 @@ export class KxmSchemaRegistry {
|
|
|
167
167
|
readonly permissionDiffValidator: ValidateFunction;
|
|
168
168
|
readonly runEventValidator: ValidateFunction;
|
|
169
169
|
readonly driveReceiptValidator: ValidateFunction;
|
|
170
|
+
readonly coordinatorValidator: ValidateFunction;
|
|
171
|
+
readonly intakeMessageValidator: ValidateFunction;
|
|
170
172
|
|
|
171
173
|
constructor(schemasDir = DEFAULT_SCHEMA_DIR) {
|
|
172
174
|
this.schemasDir = resolve(schemasDir);
|
|
@@ -185,6 +187,8 @@ export class KxmSchemaRegistry {
|
|
|
185
187
|
const permissionDiffFile = "permission-diff.schema.json";
|
|
186
188
|
const runEventFile = "run-event.schema.json";
|
|
187
189
|
const driveReceiptFile = "drive-receipt.schema.json";
|
|
190
|
+
const coordinatorFile = "coordinator.schema.json";
|
|
191
|
+
const intakeMessageFile = "intake-message.schema.json";
|
|
188
192
|
this.ajv.addSchema(readJsonObject(join(this.schemasDir, localBindingsFile)));
|
|
189
193
|
this.ajv.addSchema(readJsonObject(join(this.schemasDir, templateProvenanceFile)));
|
|
190
194
|
this.ajv.addSchema(readJsonObject(join(this.schemasDir, initOperationFile)));
|
|
@@ -194,6 +198,8 @@ export class KxmSchemaRegistry {
|
|
|
194
198
|
this.ajv.addSchema(readJsonObject(join(this.schemasDir, permissionDiffFile)));
|
|
195
199
|
this.ajv.addSchema(readJsonObject(join(this.schemasDir, runEventFile)));
|
|
196
200
|
this.ajv.addSchema(readJsonObject(join(this.schemasDir, driveReceiptFile)));
|
|
201
|
+
this.ajv.addSchema(readJsonObject(join(this.schemasDir, coordinatorFile)));
|
|
202
|
+
this.ajv.addSchema(readJsonObject(join(this.schemasDir, intakeMessageFile)));
|
|
197
203
|
for (const [kind, definition] of Object.entries(RESOURCE_SCHEMA) as [KxmResourceKind, { identity: string; file: string }][]) {
|
|
198
204
|
const validator = this.ajv.getSchema(`https://schemas.kxm.dev/${definition.file}`);
|
|
199
205
|
if (!validator) throw new Error(`schema did not compile: ${definition.file}`);
|
|
@@ -208,6 +214,8 @@ export class KxmSchemaRegistry {
|
|
|
208
214
|
const permissionDiffValidator = this.ajv.getSchema(`https://schemas.kxm.dev/${permissionDiffFile}`);
|
|
209
215
|
const runEventValidator = this.ajv.getSchema(`https://schemas.kxm.dev/${runEventFile}`);
|
|
210
216
|
const driveReceiptValidator = this.ajv.getSchema(`https://schemas.kxm.dev/${driveReceiptFile}`);
|
|
217
|
+
const coordinatorValidator = this.ajv.getSchema(`https://schemas.kxm.dev/${coordinatorFile}`);
|
|
218
|
+
const intakeMessageValidator = this.ajv.getSchema(`https://schemas.kxm.dev/${intakeMessageFile}`);
|
|
211
219
|
if (!localBindingsValidator) throw new Error(`schema did not compile: ${localBindingsFile}`);
|
|
212
220
|
if (!templateProvenanceValidator) throw new Error(`schema did not compile: ${templateProvenanceFile}`);
|
|
213
221
|
if (!initOperationValidator) throw new Error(`schema did not compile: ${initOperationFile}`);
|
|
@@ -217,6 +225,8 @@ export class KxmSchemaRegistry {
|
|
|
217
225
|
if (!permissionDiffValidator) throw new Error(`schema did not compile: ${permissionDiffFile}`);
|
|
218
226
|
if (!runEventValidator) throw new Error(`schema did not compile: ${runEventFile}`);
|
|
219
227
|
if (!driveReceiptValidator) throw new Error(`schema did not compile: ${driveReceiptFile}`);
|
|
228
|
+
if (!coordinatorValidator) throw new Error(`schema did not compile: ${coordinatorFile}`);
|
|
229
|
+
if (!intakeMessageValidator) throw new Error(`schema did not compile: ${intakeMessageFile}`);
|
|
220
230
|
this.localBindingsValidator = localBindingsValidator;
|
|
221
231
|
this.templateProvenanceValidator = templateProvenanceValidator;
|
|
222
232
|
this.initOperationValidator = initOperationValidator;
|
|
@@ -226,6 +236,8 @@ export class KxmSchemaRegistry {
|
|
|
226
236
|
this.permissionDiffValidator = permissionDiffValidator;
|
|
227
237
|
this.runEventValidator = runEventValidator;
|
|
228
238
|
this.driveReceiptValidator = driveReceiptValidator;
|
|
239
|
+
this.coordinatorValidator = coordinatorValidator;
|
|
240
|
+
this.intakeMessageValidator = intakeMessageValidator;
|
|
229
241
|
}
|
|
230
242
|
|
|
231
243
|
validate(kind: KxmResourceKind, value: JsonObject, file: string): KxmConfigIssue[] {
|
|
@@ -316,6 +328,38 @@ export function validateDriveReceipt(value: unknown, file: string): void {
|
|
|
316
328
|
}
|
|
317
329
|
}
|
|
318
330
|
|
|
331
|
+
/** Validate a coordinator identity against `kxm.coordinator.v1`. Throws `coordinator_invalid`. */
|
|
332
|
+
export function validateCoordinator(value: unknown, file: string): void {
|
|
333
|
+
const registry = (cachedRunEventRegistry ??= new KxmSchemaRegistry());
|
|
334
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || (value as JsonObject).schema !== "kxm.coordinator.v1") {
|
|
335
|
+
throw new KxmConfigError([issue("schema", "coordinator_invalid", file, "expected kxm.coordinator.v1")]);
|
|
336
|
+
}
|
|
337
|
+
if (!registry.coordinatorValidator(value)) {
|
|
338
|
+
throw new KxmConfigError([issue(
|
|
339
|
+
"schema",
|
|
340
|
+
"coordinator_invalid",
|
|
341
|
+
file,
|
|
342
|
+
registry.ajv.errorsText(registry.coordinatorValidator.errors, { separator: "; " }),
|
|
343
|
+
)]);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Validate an intake message against `kxm.intake-message.v1`. Throws `intake_message_invalid`. */
|
|
348
|
+
export function validateIntakeMessage(value: unknown, file: string): void {
|
|
349
|
+
const registry = (cachedRunEventRegistry ??= new KxmSchemaRegistry());
|
|
350
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || (value as JsonObject).schema !== "kxm.intake-message.v1") {
|
|
351
|
+
throw new KxmConfigError([issue("schema", "intake_message_invalid", file, "expected kxm.intake-message.v1")]);
|
|
352
|
+
}
|
|
353
|
+
if (!registry.intakeMessageValidator(value)) {
|
|
354
|
+
throw new KxmConfigError([issue(
|
|
355
|
+
"schema",
|
|
356
|
+
"intake_message_invalid",
|
|
357
|
+
file,
|
|
358
|
+
registry.ajv.errorsText(registry.intakeMessageValidator.errors, { separator: "; " }),
|
|
359
|
+
)]);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
319
363
|
function schemaIssue(file: string, error: ErrorObject): KxmConfigIssue {
|
|
320
364
|
const location = error.instancePath || "/";
|
|
321
365
|
const suffix = error.params && "additionalProperty" in error.params
|