@kontextmind/kxm 0.7.45 → 0.7.46
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/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
|
@@ -2,7 +2,7 @@ import { createHash, randomUUID } from "node:crypto";
|
|
|
2
2
|
import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { DatabaseSync } from "./sqlite.ts";
|
|
5
|
-
import { KxmConfigError, validateDriveReceipt, validateRunEvent, kxmCanonicalJson, type JsonValue, type KxmConfigIssue, type KxmConfigOptions } from "./project-config.ts";
|
|
5
|
+
import { KxmConfigError, validateCoordinator, validateDriveReceipt, validateIntakeMessage, validateRunEvent, kxmCanonicalJson, type JsonValue, type KxmConfigIssue, type KxmConfigOptions } from "./project-config.ts";
|
|
6
6
|
import { kxmUserStateRoot } from "./bindings.ts";
|
|
7
7
|
|
|
8
8
|
/* ------------------------------------------------------------------ *
|
|
@@ -550,7 +550,7 @@ export interface KxmCommandRecord {
|
|
|
550
550
|
recordedAt: string;
|
|
551
551
|
}
|
|
552
552
|
|
|
553
|
-
export const KXM_EVENT_STORE_SCHEMA_VERSION =
|
|
553
|
+
export const KXM_EVENT_STORE_SCHEMA_VERSION = 5;
|
|
554
554
|
export const KXM_DRIVE_RECEIPT_SCHEMA = "kxm.drive-receipt.v1";
|
|
555
555
|
export const DRIVE_RECEIPT_MAX_BYTES = 8 * 1024;
|
|
556
556
|
|
|
@@ -578,8 +578,23 @@ const EVENT_STORE_TABLES = {
|
|
|
578
578
|
"assignment_id", "effect_id", "evidence_key", "kind", "expect", "outcome", "settled_event_id", "content_hash",
|
|
579
579
|
],
|
|
580
580
|
drive_receipts: ["drive_id", "run_id", "project_id", "opened_sequence", "last_sequence", "closed_at", "schema", "receipt"],
|
|
581
|
+
coordinators: [
|
|
582
|
+
"coordinator_id", "project_id", "role", "channel", "ceiling_hash", "config_revision", "bound_at", "schema", "record",
|
|
583
|
+
],
|
|
584
|
+
intake_messages: [
|
|
585
|
+
"message_id", "project_id", "coordinator_id", "idempotency_key", "content_hash", "received_at", "dispatch_state",
|
|
586
|
+
"schema", "record",
|
|
587
|
+
],
|
|
588
|
+
project_controls: ["project_id", "paused", "reason", "updated_at", "actor", "schema", "record"],
|
|
581
589
|
} as const;
|
|
582
590
|
|
|
591
|
+
/**
|
|
592
|
+
* The authoritative table set of a run event store, sorted. Tests assert against
|
|
593
|
+
* this instead of a hand-copied list, so adding a table cannot silently leave a
|
|
594
|
+
* stale expectation behind.
|
|
595
|
+
*/
|
|
596
|
+
export const KXM_EVENT_STORE_TABLE_NAMES: string[] = Object.keys(EVENT_STORE_TABLES).sort();
|
|
597
|
+
|
|
583
598
|
const EVENT_STORE_SCHEMA = `
|
|
584
599
|
CREATE TABLE runs (
|
|
585
600
|
run_id TEXT PRIMARY KEY,
|
|
@@ -731,6 +746,77 @@ CREATE TABLE drive_receipts (
|
|
|
731
746
|
receipt TEXT NOT NULL
|
|
732
747
|
) STRICT;
|
|
733
748
|
CREATE INDEX drive_receipts_run ON drive_receipts(run_id);
|
|
749
|
+
CREATE TABLE coordinators (
|
|
750
|
+
coordinator_id TEXT PRIMARY KEY,
|
|
751
|
+
project_id TEXT NOT NULL,
|
|
752
|
+
role TEXT NOT NULL,
|
|
753
|
+
channel TEXT NOT NULL,
|
|
754
|
+
ceiling_hash TEXT NOT NULL,
|
|
755
|
+
config_revision TEXT NOT NULL,
|
|
756
|
+
bound_at TEXT NOT NULL,
|
|
757
|
+
schema TEXT NOT NULL,
|
|
758
|
+
record TEXT NOT NULL
|
|
759
|
+
) STRICT;
|
|
760
|
+
CREATE UNIQUE INDEX coordinators_slot ON coordinators(project_id, role, channel);
|
|
761
|
+
CREATE TABLE intake_messages (
|
|
762
|
+
message_id TEXT PRIMARY KEY,
|
|
763
|
+
project_id TEXT NOT NULL,
|
|
764
|
+
coordinator_id TEXT NOT NULL,
|
|
765
|
+
idempotency_key TEXT NOT NULL,
|
|
766
|
+
content_hash TEXT NOT NULL,
|
|
767
|
+
received_at TEXT NOT NULL,
|
|
768
|
+
dispatch_state TEXT NOT NULL CHECK (dispatch_state IN ('ready','held_paused','admitted','refused')),
|
|
769
|
+
schema TEXT NOT NULL,
|
|
770
|
+
record TEXT NOT NULL
|
|
771
|
+
) STRICT;
|
|
772
|
+
CREATE UNIQUE INDEX intake_idempotency ON intake_messages(project_id, coordinator_id, idempotency_key);
|
|
773
|
+
CREATE INDEX intake_dispatch ON intake_messages(project_id, dispatch_state, received_at, message_id);
|
|
774
|
+
CREATE TABLE project_controls (
|
|
775
|
+
project_id TEXT PRIMARY KEY,
|
|
776
|
+
paused INTEGER NOT NULL CHECK (paused IN (0, 1)),
|
|
777
|
+
reason TEXT,
|
|
778
|
+
updated_at TEXT NOT NULL,
|
|
779
|
+
actor TEXT NOT NULL,
|
|
780
|
+
schema TEXT NOT NULL,
|
|
781
|
+
record TEXT NOT NULL
|
|
782
|
+
) STRICT;
|
|
783
|
+
`;
|
|
784
|
+
|
|
785
|
+
const COORDINATOR_INTAKE_DDL = `
|
|
786
|
+
CREATE TABLE IF NOT EXISTS coordinators (
|
|
787
|
+
coordinator_id TEXT PRIMARY KEY,
|
|
788
|
+
project_id TEXT NOT NULL,
|
|
789
|
+
role TEXT NOT NULL,
|
|
790
|
+
channel TEXT NOT NULL,
|
|
791
|
+
ceiling_hash TEXT NOT NULL,
|
|
792
|
+
config_revision TEXT NOT NULL,
|
|
793
|
+
bound_at TEXT NOT NULL,
|
|
794
|
+
schema TEXT NOT NULL,
|
|
795
|
+
record TEXT NOT NULL
|
|
796
|
+
) STRICT;
|
|
797
|
+
CREATE UNIQUE INDEX IF NOT EXISTS coordinators_slot ON coordinators(project_id, role, channel);
|
|
798
|
+
CREATE TABLE IF NOT EXISTS intake_messages (
|
|
799
|
+
message_id TEXT PRIMARY KEY,
|
|
800
|
+
project_id TEXT NOT NULL,
|
|
801
|
+
coordinator_id TEXT NOT NULL,
|
|
802
|
+
idempotency_key TEXT NOT NULL,
|
|
803
|
+
content_hash TEXT NOT NULL,
|
|
804
|
+
received_at TEXT NOT NULL,
|
|
805
|
+
dispatch_state TEXT NOT NULL CHECK (dispatch_state IN ('ready','held_paused','admitted','refused')),
|
|
806
|
+
schema TEXT NOT NULL,
|
|
807
|
+
record TEXT NOT NULL
|
|
808
|
+
) STRICT;
|
|
809
|
+
CREATE UNIQUE INDEX IF NOT EXISTS intake_idempotency ON intake_messages(project_id, coordinator_id, idempotency_key);
|
|
810
|
+
CREATE INDEX IF NOT EXISTS intake_dispatch ON intake_messages(project_id, dispatch_state, received_at, message_id);
|
|
811
|
+
CREATE TABLE IF NOT EXISTS project_controls (
|
|
812
|
+
project_id TEXT PRIMARY KEY,
|
|
813
|
+
paused INTEGER NOT NULL CHECK (paused IN (0, 1)),
|
|
814
|
+
reason TEXT,
|
|
815
|
+
updated_at TEXT NOT NULL,
|
|
816
|
+
actor TEXT NOT NULL,
|
|
817
|
+
schema TEXT NOT NULL,
|
|
818
|
+
record TEXT NOT NULL
|
|
819
|
+
) STRICT;
|
|
734
820
|
`;
|
|
735
821
|
|
|
736
822
|
const DRIVE_RECEIPTS_DDL = `
|
|
@@ -755,8 +841,49 @@ const EVENT_STORE_MIGRATIONS = [
|
|
|
755
841
|
database.exec(DRIVE_RECEIPTS_DDL);
|
|
756
842
|
},
|
|
757
843
|
},
|
|
844
|
+
{
|
|
845
|
+
fromVersion: 4,
|
|
846
|
+
toVersion: 5,
|
|
847
|
+
migrate(database: DatabaseSync): void {
|
|
848
|
+
database.exec(COORDINATOR_INTAKE_DDL);
|
|
849
|
+
},
|
|
850
|
+
},
|
|
758
851
|
];
|
|
759
852
|
|
|
853
|
+
/** One persisted coordinator identity (`kxm.coordinator.v1`). */
|
|
854
|
+
export interface KxmCoordinatorRow {
|
|
855
|
+
coordinatorId: string;
|
|
856
|
+
projectId: string;
|
|
857
|
+
role: string;
|
|
858
|
+
channel: string;
|
|
859
|
+
ceilingHash: string;
|
|
860
|
+
configRevision: string;
|
|
861
|
+
boundAt: string;
|
|
862
|
+
record: string;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
/** One persisted intake message (`kxm.intake-message.v1`). */
|
|
866
|
+
export interface KxmIntakeMessageRow {
|
|
867
|
+
messageId: string;
|
|
868
|
+
projectId: string;
|
|
869
|
+
coordinatorId: string;
|
|
870
|
+
idempotencyKey: string;
|
|
871
|
+
contentHash: string;
|
|
872
|
+
receivedAt: string;
|
|
873
|
+
dispatchState: "ready" | "held_paused" | "admitted" | "refused";
|
|
874
|
+
record: string;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/** The project's intake control: paused or running, with who said so. */
|
|
878
|
+
export interface KxmProjectControlRow {
|
|
879
|
+
projectId: string;
|
|
880
|
+
paused: boolean;
|
|
881
|
+
reason?: string;
|
|
882
|
+
updatedAt: string;
|
|
883
|
+
actor: string;
|
|
884
|
+
record: string;
|
|
885
|
+
}
|
|
886
|
+
|
|
760
887
|
export class KxmRunEventStore {
|
|
761
888
|
readonly path: string;
|
|
762
889
|
private readonly database: DatabaseSync;
|
|
@@ -1031,6 +1158,158 @@ export class KxmRunEventStore {
|
|
|
1031
1158
|
});
|
|
1032
1159
|
}
|
|
1033
1160
|
|
|
1161
|
+
/** Insert a coordinator unless the (project, role, channel) slot is taken. */
|
|
1162
|
+
insertCoordinatorIfAbsent(row: KxmCoordinatorRow): boolean {
|
|
1163
|
+
const result = this.database.prepare(`
|
|
1164
|
+
INSERT OR IGNORE INTO coordinators
|
|
1165
|
+
(coordinator_id, project_id, role, channel, ceiling_hash, config_revision, bound_at, schema, record)
|
|
1166
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1167
|
+
`).run(
|
|
1168
|
+
row.coordinatorId,
|
|
1169
|
+
row.projectId,
|
|
1170
|
+
row.role,
|
|
1171
|
+
row.channel,
|
|
1172
|
+
row.ceilingHash,
|
|
1173
|
+
row.configRevision,
|
|
1174
|
+
row.boundAt,
|
|
1175
|
+
"kxm.coordinator.v1",
|
|
1176
|
+
row.record,
|
|
1177
|
+
);
|
|
1178
|
+
return Number(result.changes) === 1;
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
coordinatorById(coordinatorId: string): KxmCoordinatorRow | undefined {
|
|
1182
|
+
const row = this.database.prepare("SELECT * FROM coordinators WHERE coordinator_id = ?").get(coordinatorId) as
|
|
1183
|
+
| CoordinatorSqlRow
|
|
1184
|
+
| undefined;
|
|
1185
|
+
return row ? coordinatorFromRow(row) : undefined;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
coordinatorInSlot(projectId: string, role: string, channel: string): KxmCoordinatorRow | undefined {
|
|
1189
|
+
const row = this.database.prepare(`
|
|
1190
|
+
SELECT * FROM coordinators WHERE project_id = ? AND role = ? AND channel = ?
|
|
1191
|
+
`).get(projectId, role, channel) as CoordinatorSqlRow | undefined;
|
|
1192
|
+
return row ? coordinatorFromRow(row) : undefined;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/**
|
|
1196
|
+
* Replace the coordinator holding a (project, role, channel) slot, guarded by
|
|
1197
|
+
* the identity that is currently there. A rebind therefore cannot clobber a
|
|
1198
|
+
* record that changed underneath it, and cannot leave two live identities for
|
|
1199
|
+
* one slot.
|
|
1200
|
+
*/
|
|
1201
|
+
replaceCoordinatorInSlot(
|
|
1202
|
+
expectedCoordinatorId: string,
|
|
1203
|
+
row: KxmCoordinatorRow,
|
|
1204
|
+
): boolean {
|
|
1205
|
+
const result = this.database.prepare(`
|
|
1206
|
+
UPDATE coordinators
|
|
1207
|
+
SET coordinator_id = ?, ceiling_hash = ?, config_revision = ?, bound_at = ?, schema = ?, record = ?
|
|
1208
|
+
WHERE project_id = ? AND role = ? AND channel = ? AND coordinator_id = ?
|
|
1209
|
+
`).run(
|
|
1210
|
+
row.coordinatorId,
|
|
1211
|
+
row.ceilingHash,
|
|
1212
|
+
row.configRevision,
|
|
1213
|
+
row.boundAt,
|
|
1214
|
+
"kxm.coordinator.v1",
|
|
1215
|
+
row.record,
|
|
1216
|
+
row.projectId,
|
|
1217
|
+
row.role,
|
|
1218
|
+
row.channel,
|
|
1219
|
+
expectedCoordinatorId,
|
|
1220
|
+
);
|
|
1221
|
+
return Number(result.changes) === 1;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
/** Insert an intake message unless its idempotency slot is taken. */
|
|
1225
|
+
insertIntakeMessageIfAbsent(row: KxmIntakeMessageRow): boolean {
|
|
1226
|
+
const result = this.database.prepare(`
|
|
1227
|
+
INSERT OR IGNORE INTO intake_messages
|
|
1228
|
+
(message_id, project_id, coordinator_id, idempotency_key, content_hash, received_at, dispatch_state, schema, record)
|
|
1229
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1230
|
+
`).run(
|
|
1231
|
+
row.messageId,
|
|
1232
|
+
row.projectId,
|
|
1233
|
+
row.coordinatorId,
|
|
1234
|
+
row.idempotencyKey,
|
|
1235
|
+
row.contentHash,
|
|
1236
|
+
row.receivedAt,
|
|
1237
|
+
row.dispatchState,
|
|
1238
|
+
"kxm.intake-message.v1",
|
|
1239
|
+
row.record,
|
|
1240
|
+
);
|
|
1241
|
+
return Number(result.changes) === 1;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
intakeMessage(messageId: string): KxmIntakeMessageRow | undefined {
|
|
1245
|
+
const row = this.database.prepare("SELECT * FROM intake_messages WHERE message_id = ?").get(messageId) as
|
|
1246
|
+
| IntakeSqlRow
|
|
1247
|
+
| undefined;
|
|
1248
|
+
return row ? intakeFromRow(row) : undefined;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
intakeBySlot(projectId: string, coordinatorId: string, idempotencyKey: string): KxmIntakeMessageRow | undefined {
|
|
1252
|
+
const row = this.database.prepare(`
|
|
1253
|
+
SELECT * FROM intake_messages WHERE project_id = ? AND coordinator_id = ? AND idempotency_key = ?
|
|
1254
|
+
`).get(projectId, coordinatorId, idempotencyKey) as IntakeSqlRow | undefined;
|
|
1255
|
+
return row ? intakeFromRow(row) : undefined;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/** Intake rows in the given dispatch states, oldest first (stable, replay-safe order). */
|
|
1259
|
+
intakeInStates(projectId: string, states: readonly KxmIntakeMessageRow["dispatchState"][], limit = 100): KxmIntakeMessageRow[] {
|
|
1260
|
+
if (states.length === 0) return [];
|
|
1261
|
+
const placeholders = states.map(() => "?").join(", ");
|
|
1262
|
+
const rows = this.database.prepare(`
|
|
1263
|
+
SELECT * FROM intake_messages
|
|
1264
|
+
WHERE project_id = ? AND dispatch_state IN (${placeholders})
|
|
1265
|
+
ORDER BY received_at ASC, message_id ASC
|
|
1266
|
+
LIMIT ?
|
|
1267
|
+
`).all(projectId, ...states, limit) as unknown as IntakeSqlRow[];
|
|
1268
|
+
return rows.map(intakeFromRow);
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
/** Rewrite one intake row's dispatch state and record. Returns false when it raced away. */
|
|
1272
|
+
updateIntakeDispatch(
|
|
1273
|
+
messageId: string,
|
|
1274
|
+
expectState: KxmIntakeMessageRow["dispatchState"],
|
|
1275
|
+
next: { state: KxmIntakeMessageRow["dispatchState"]; record: string },
|
|
1276
|
+
): boolean {
|
|
1277
|
+
const result = this.database.prepare(`
|
|
1278
|
+
UPDATE intake_messages SET dispatch_state = ?, record = ?, schema = ?
|
|
1279
|
+
WHERE message_id = ? AND dispatch_state = ?
|
|
1280
|
+
`).run(next.state, next.record, "kxm.intake-message.v1", messageId, expectState);
|
|
1281
|
+
return Number(result.changes) === 1;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
projectControl(projectId: string): KxmProjectControlRow | undefined {
|
|
1285
|
+
const row = this.database.prepare("SELECT * FROM project_controls WHERE project_id = ?").get(projectId) as
|
|
1286
|
+
| ControlSqlRow
|
|
1287
|
+
| undefined;
|
|
1288
|
+
return row ? controlFromRow(row) : undefined;
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
putProjectControl(row: KxmProjectControlRow): void {
|
|
1292
|
+
this.database.prepare(`
|
|
1293
|
+
INSERT INTO project_controls (project_id, paused, reason, updated_at, actor, schema, record)
|
|
1294
|
+
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
1295
|
+
ON CONFLICT(project_id) DO UPDATE SET
|
|
1296
|
+
paused = excluded.paused,
|
|
1297
|
+
reason = excluded.reason,
|
|
1298
|
+
updated_at = excluded.updated_at,
|
|
1299
|
+
actor = excluded.actor,
|
|
1300
|
+
schema = excluded.schema,
|
|
1301
|
+
record = excluded.record
|
|
1302
|
+
`).run(
|
|
1303
|
+
row.projectId,
|
|
1304
|
+
row.paused ? 1 : 0,
|
|
1305
|
+
row.reason ?? null,
|
|
1306
|
+
row.updatedAt,
|
|
1307
|
+
row.actor,
|
|
1308
|
+
"kxm.project-control.v1",
|
|
1309
|
+
row.record,
|
|
1310
|
+
);
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1034
1313
|
insertCapability(row: KxmAttemptCapabilityRow): void {
|
|
1035
1314
|
this.database.prepare(`
|
|
1036
1315
|
INSERT INTO attempt_capabilities (attempt_id, run_id, assignment_id, step_id, step_attempt, producer_id, capability_hash, state)
|
|
@@ -1235,6 +1514,115 @@ export class KxmRunEventStore {
|
|
|
1235
1514
|
}
|
|
1236
1515
|
}
|
|
1237
1516
|
|
|
1517
|
+
interface CoordinatorSqlRow {
|
|
1518
|
+
coordinator_id: string;
|
|
1519
|
+
project_id: string;
|
|
1520
|
+
role: string;
|
|
1521
|
+
channel: string;
|
|
1522
|
+
ceiling_hash: string;
|
|
1523
|
+
config_revision: string;
|
|
1524
|
+
bound_at: string;
|
|
1525
|
+
schema: string;
|
|
1526
|
+
record: string;
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
interface IntakeSqlRow {
|
|
1530
|
+
message_id: string;
|
|
1531
|
+
project_id: string;
|
|
1532
|
+
coordinator_id: string;
|
|
1533
|
+
idempotency_key: string;
|
|
1534
|
+
content_hash: string;
|
|
1535
|
+
received_at: string;
|
|
1536
|
+
dispatch_state: KxmIntakeMessageRow["dispatchState"];
|
|
1537
|
+
schema: string;
|
|
1538
|
+
record: string;
|
|
1539
|
+
}
|
|
1540
|
+
|
|
1541
|
+
interface ControlSqlRow {
|
|
1542
|
+
project_id: string;
|
|
1543
|
+
paused: number;
|
|
1544
|
+
reason: string | null;
|
|
1545
|
+
updated_at: string;
|
|
1546
|
+
actor: string;
|
|
1547
|
+
schema: string;
|
|
1548
|
+
record: string;
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
/**
|
|
1552
|
+
* Read a persisted coordinator. The record is revalidated against
|
|
1553
|
+
* `kxm.coordinator.v1` and cross-checked against its own columns, so a store
|
|
1554
|
+
* whose index and payload disagree fails closed instead of trusting either.
|
|
1555
|
+
*/
|
|
1556
|
+
function coordinatorFromRow(row: CoordinatorSqlRow): KxmCoordinatorRow {
|
|
1557
|
+
const parsed = JSON.parse(row.record) as { coordinatorId?: string; ceilingHash?: string; configRevision?: string; boundAt?: string };
|
|
1558
|
+
validateCoordinator(parsed, row.coordinator_id);
|
|
1559
|
+
if (
|
|
1560
|
+
parsed.coordinatorId !== row.coordinator_id
|
|
1561
|
+
|| parsed.ceilingHash !== row.ceiling_hash
|
|
1562
|
+
|| parsed.configRevision !== row.config_revision
|
|
1563
|
+
|| parsed.boundAt !== row.bound_at
|
|
1564
|
+
) {
|
|
1565
|
+
throw runtimeError("coordinator_record_divergent", row.coordinator_id, "coordinator columns do not match the persisted record");
|
|
1566
|
+
}
|
|
1567
|
+
return {
|
|
1568
|
+
coordinatorId: row.coordinator_id,
|
|
1569
|
+
projectId: row.project_id,
|
|
1570
|
+
role: row.role,
|
|
1571
|
+
channel: row.channel,
|
|
1572
|
+
ceilingHash: row.ceiling_hash,
|
|
1573
|
+
configRevision: row.config_revision,
|
|
1574
|
+
boundAt: row.bound_at,
|
|
1575
|
+
record: row.record,
|
|
1576
|
+
};
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
function intakeFromRow(row: IntakeSqlRow): KxmIntakeMessageRow {
|
|
1580
|
+
const parsed = JSON.parse(row.record) as { messageId?: string; contentHash?: string; receivedAt?: string; dispatch?: { state?: string } };
|
|
1581
|
+
validateIntakeMessage(parsed, row.message_id);
|
|
1582
|
+
if (
|
|
1583
|
+
parsed.messageId !== row.message_id
|
|
1584
|
+
|| parsed.contentHash !== row.content_hash
|
|
1585
|
+
|| parsed.receivedAt !== row.received_at
|
|
1586
|
+
|| parsed.dispatch?.state !== row.dispatch_state
|
|
1587
|
+
) {
|
|
1588
|
+
throw runtimeError("intake_record_divergent", row.message_id, "intake columns do not match the persisted record");
|
|
1589
|
+
}
|
|
1590
|
+
return {
|
|
1591
|
+
messageId: row.message_id,
|
|
1592
|
+
projectId: row.project_id,
|
|
1593
|
+
coordinatorId: row.coordinator_id,
|
|
1594
|
+
idempotencyKey: row.idempotency_key,
|
|
1595
|
+
contentHash: row.content_hash,
|
|
1596
|
+
receivedAt: row.received_at,
|
|
1597
|
+
dispatchState: row.dispatch_state,
|
|
1598
|
+
record: row.record,
|
|
1599
|
+
};
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
/** The control record is its columns; a stored payload that disagrees is drift. */
|
|
1603
|
+
function controlFromRow(row: ControlSqlRow): KxmProjectControlRow {
|
|
1604
|
+
const paused = row.paused === 1;
|
|
1605
|
+
const expected = kxmCanonicalJson({
|
|
1606
|
+
schema: "kxm.project-control.v1",
|
|
1607
|
+
projectId: row.project_id,
|
|
1608
|
+
paused,
|
|
1609
|
+
...(row.reason !== null ? { reason: row.reason } : {}),
|
|
1610
|
+
updatedAt: row.updated_at,
|
|
1611
|
+
actor: row.actor,
|
|
1612
|
+
} as JsonValue);
|
|
1613
|
+
if (row.record !== expected) {
|
|
1614
|
+
throw runtimeError("project_control_divergent", row.project_id, "project control record does not match its columns");
|
|
1615
|
+
}
|
|
1616
|
+
return {
|
|
1617
|
+
projectId: row.project_id,
|
|
1618
|
+
paused,
|
|
1619
|
+
...(row.reason !== null ? { reason: row.reason } : {}),
|
|
1620
|
+
updatedAt: row.updated_at,
|
|
1621
|
+
actor: row.actor,
|
|
1622
|
+
record: row.record,
|
|
1623
|
+
};
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1238
1626
|
function parseDriveReceipt(raw: string): KxmDriveReceipt {
|
|
1239
1627
|
const parsed = JSON.parse(raw) as unknown;
|
|
1240
1628
|
validateDriveReceipt(parsed, "drive-receipt");
|
package/schemas/README.md
CHANGED
|
@@ -19,6 +19,8 @@ Runtime-local records, and JSON events/results for the planned KXM contract.
|
|
|
19
19
|
| `migration-receipt.schema.json` | `kxm.migration-receipt.v1` |
|
|
20
20
|
| `permission-diff.schema.json` | `kxm.permission-diff.v1` |
|
|
21
21
|
| `run-event.schema.json` | `kxm.run-event.v1` |
|
|
22
|
+
| `coordinator.schema.json` | `kxm.coordinator.v1` |
|
|
23
|
+
| `intake-message.schema.json` | `kxm.intake-message.v1` |
|
|
22
24
|
| `sync-event.schema.json` | `kxm.sync-event.v1` |
|
|
23
25
|
| `assignment-result.schema.json` | `kxm.assignment-result.v1` |
|
|
24
26
|
| `delivery-manifest.schema.json` | `kxm.delivery-manifest.v1` |
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://schemas.kxm.dev/coordinator.schema.json",
|
|
4
|
+
"title": "KXM project coordinator identity",
|
|
5
|
+
"description": "A persistent, project-scoped coordinator: an authority ceiling bound to a role and channel, not an always-running model. Rebinding a stable coordinator requires explicit policy and may never widen its ceiling.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": [
|
|
8
|
+
"schema",
|
|
9
|
+
"coordinatorId",
|
|
10
|
+
"projectId",
|
|
11
|
+
"role",
|
|
12
|
+
"channel",
|
|
13
|
+
"authority",
|
|
14
|
+
"boundAt",
|
|
15
|
+
"boundBy",
|
|
16
|
+
"configRevision",
|
|
17
|
+
"ceilingHash"
|
|
18
|
+
],
|
|
19
|
+
"properties": {
|
|
20
|
+
"schema": { "const": "kxm.coordinator.v1" },
|
|
21
|
+
"coordinatorId": { "$ref": "common.schema.json#/$defs/opaqueId" },
|
|
22
|
+
"projectId": { "$ref": "common.schema.json#/$defs/opaqueId" },
|
|
23
|
+
"role": { "$ref": "common.schema.json#/$defs/identifier" },
|
|
24
|
+
"channel": { "$ref": "common.schema.json#/$defs/identifier" },
|
|
25
|
+
"authority": {
|
|
26
|
+
"type": "object",
|
|
27
|
+
"required": ["repositoryAccess", "effects"],
|
|
28
|
+
"properties": {
|
|
29
|
+
"repositoryAccess": { "$ref": "common.schema.json#/$defs/repositoryAccess" },
|
|
30
|
+
"effects": {
|
|
31
|
+
"type": "array",
|
|
32
|
+
"items": { "$ref": "common.schema.json#/$defs/identifier" },
|
|
33
|
+
"uniqueItems": true,
|
|
34
|
+
"maxItems": 64
|
|
35
|
+
},
|
|
36
|
+
"tools": { "$ref": "common.schema.json#/$defs/toolPolicy" }
|
|
37
|
+
},
|
|
38
|
+
"additionalProperties": false
|
|
39
|
+
},
|
|
40
|
+
"boundAt": { "$ref": "common.schema.json#/$defs/timestamp" },
|
|
41
|
+
"boundBy": { "$ref": "common.schema.json#/$defs/actor" },
|
|
42
|
+
"rebindOf": { "$ref": "common.schema.json#/$defs/opaqueId" },
|
|
43
|
+
"rebindReason": { "type": "string", "minLength": 1, "maxLength": 512 },
|
|
44
|
+
"configRevision": { "$ref": "common.schema.json#/$defs/sha256" },
|
|
45
|
+
"ceilingHash": { "$ref": "common.schema.json#/$defs/sha256" }
|
|
46
|
+
},
|
|
47
|
+
"additionalProperties": false,
|
|
48
|
+
"allOf": [
|
|
49
|
+
{
|
|
50
|
+
"if": { "properties": { "rebindOf": {} }, "required": ["rebindOf"] },
|
|
51
|
+
"then": { "required": ["rebindReason"] }
|
|
52
|
+
}
|
|
53
|
+
]
|
|
54
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://schemas.kxm.dev/intake-message.schema.json",
|
|
4
|
+
"title": "KXM internal intake message",
|
|
5
|
+
"description": "One durable internal message at the Runtime intake boundary. Receipts stay separate facts: received, persisted, and dispatch-intent are different states, and content classified secret is never persisted — only its hash.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": [
|
|
8
|
+
"schema",
|
|
9
|
+
"messageId",
|
|
10
|
+
"projectId",
|
|
11
|
+
"coordinatorId",
|
|
12
|
+
"receivedAt",
|
|
13
|
+
"source",
|
|
14
|
+
"idempotencyKey",
|
|
15
|
+
"contentHash",
|
|
16
|
+
"classification",
|
|
17
|
+
"dispatch"
|
|
18
|
+
],
|
|
19
|
+
"properties": {
|
|
20
|
+
"schema": { "const": "kxm.intake-message.v1" },
|
|
21
|
+
"messageId": { "$ref": "common.schema.json#/$defs/opaqueId" },
|
|
22
|
+
"projectId": { "$ref": "common.schema.json#/$defs/opaqueId" },
|
|
23
|
+
"coordinatorId": { "$ref": "common.schema.json#/$defs/opaqueId" },
|
|
24
|
+
"receivedAt": { "$ref": "common.schema.json#/$defs/timestamp" },
|
|
25
|
+
"source": {
|
|
26
|
+
"type": "object",
|
|
27
|
+
"required": ["kind", "id"],
|
|
28
|
+
"properties": {
|
|
29
|
+
"kind": { "enum": ["adapter", "hub", "operator", "peer", "schedule"] },
|
|
30
|
+
"id": { "type": "string", "minLength": 1, "maxLength": 200 }
|
|
31
|
+
},
|
|
32
|
+
"additionalProperties": false
|
|
33
|
+
},
|
|
34
|
+
"idempotencyKey": { "type": "string", "minLength": 1, "maxLength": 200 },
|
|
35
|
+
"contentHash": { "$ref": "common.schema.json#/$defs/sha256" },
|
|
36
|
+
"content": { "type": "string", "minLength": 1, "maxLength": 16384 },
|
|
37
|
+
"contentOmittedReason": { "const": "secret-classified" },
|
|
38
|
+
"classification": { "enum": ["public", "project", "sensitive", "secret"] },
|
|
39
|
+
"dispatch": { "$ref": "#/$defs/dispatch" }
|
|
40
|
+
},
|
|
41
|
+
"additionalProperties": false,
|
|
42
|
+
"allOf": [
|
|
43
|
+
{
|
|
44
|
+
"if": { "properties": { "classification": { "enum": ["public", "project", "sensitive"] } }, "required": ["classification"] },
|
|
45
|
+
"then": { "required": ["content"] },
|
|
46
|
+
"else": { "not": { "required": ["content"] } }
|
|
47
|
+
}
|
|
48
|
+
],
|
|
49
|
+
"$defs": {
|
|
50
|
+
"dispatch": {
|
|
51
|
+
"type": "object",
|
|
52
|
+
"required": ["state"],
|
|
53
|
+
"properties": {
|
|
54
|
+
"state": { "enum": ["ready", "held_paused", "admitted", "refused"] },
|
|
55
|
+
"reason": { "type": "string", "minLength": 1, "maxLength": 512 },
|
|
56
|
+
"updatedAt": { "$ref": "common.schema.json#/$defs/timestamp" },
|
|
57
|
+
"runId": { "$ref": "common.schema.json#/$defs/opaqueId" }
|
|
58
|
+
},
|
|
59
|
+
"additionalProperties": false,
|
|
60
|
+
"allOf": [
|
|
61
|
+
{
|
|
62
|
+
"if": { "properties": { "state": { "const": "admitted" } }, "required": ["state"] },
|
|
63
|
+
"then": { "required": ["runId", "updatedAt"] }
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"if": { "properties": { "state": { "enum": ["held_paused", "refused"] } }, "required": ["state"] },
|
|
67
|
+
"then": { "required": ["reason"] }
|
|
68
|
+
}
|
|
69
|
+
]
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|