@kontextmind/kxm 0.7.22 → 0.7.24
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 +55 -13
- package/plugins/kxm/dist/mcp-server.js +104 -32
- package/plugins/kxm/dist/runtime.js +307 -7
- package/plugins/kxm/dist/vnext-runtime-supervisor.js +300 -6
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/src/cli/hub.ts +2 -1
- package/plugins/kxm/src/cli/vnext.ts +46 -3
- package/plugins/kxm/src/cli.ts +3 -13
- package/plugins/kxm/src/database.ts +3 -1
- package/plugins/kxm/src/hub-env.ts +15 -0
- package/plugins/kxm/src/mcp-server.ts +5 -7
- package/plugins/kxm/src/vnext-config.ts +31 -0
- package/plugins/kxm/src/vnext-engine-fold.ts +24 -0
- package/plugins/kxm/src/vnext-engine.ts +172 -2
- package/plugins/kxm/src/vnext-runtime-owner.ts +1 -0
- package/plugins/kxm/src/vnext-runtime-store.ts +151 -2
- package/plugins/kxm/src/vnext-runtime-supervisor.ts +23 -2
- package/schemas/vnext/common.schema.json +1 -0
- package/schemas/vnext/drive-receipt.schema.json +79 -0
- package/schemas/vnext/run-event.schema.json +10 -0
|
@@ -2,12 +2,13 @@ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
|
2
2
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
3
3
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
4
4
|
import { HubClient } from "./client.ts";
|
|
5
|
+
import { resolveClientHubAuthToken } from "./hub-env.ts";
|
|
5
6
|
import { defaultProjectName } from "./project-name.ts";
|
|
6
7
|
import { AGENT_COMMANDS_MAP, enforceToolPolicy, getMcpTools, reconcileInbox } from "./commands.ts";
|
|
7
8
|
import { deliverInboxNotification } from "./inbox.ts";
|
|
8
9
|
import type { HubEvent, MessageRecord } from "./protocol.ts";
|
|
9
10
|
|
|
10
|
-
const VERSION = "0.7.
|
|
11
|
+
const VERSION = "0.7.24";
|
|
11
12
|
const inbox = new Map<string, MessageRecord>();
|
|
12
13
|
const notifiedInbox = new Set<string>();
|
|
13
14
|
let meshClient: HubClient | undefined;
|
|
@@ -34,10 +35,6 @@ function textResult(value: unknown) {
|
|
|
34
35
|
return { content: [{ type: "text" as const, text: JSON.stringify(value, null, 2) }] };
|
|
35
36
|
}
|
|
36
37
|
|
|
37
|
-
function optionalString(value: unknown): string | undefined {
|
|
38
|
-
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
39
|
-
}
|
|
40
|
-
|
|
41
38
|
function asRecord(value: unknown): Record<string, unknown> {
|
|
42
39
|
return value && typeof value === "object" && !Array.isArray(value)
|
|
43
40
|
? (value as Record<string, unknown>)
|
|
@@ -83,12 +80,13 @@ async function ensureClient(): Promise<HubClient> {
|
|
|
83
80
|
if (starting) return starting;
|
|
84
81
|
starting = (async () => {
|
|
85
82
|
const projectDir = process.env.KXM_PROJECT_DIR || process.env.CLAUDE_PROJECT_DIR || process.cwd();
|
|
86
|
-
const
|
|
83
|
+
const project = defaultProjectName(projectDir, process.env);
|
|
84
|
+
const authToken = resolveClientHubAuthToken(process.env, project);
|
|
87
85
|
const candidate = new HubClient({
|
|
88
86
|
serverUrl: process.env.KXM_SERVER_URL?.trim() || "http://127.0.0.1:7331",
|
|
89
87
|
name: process.env.KXM_AGENT_NAME?.trim() || `claude-${process.pid}`,
|
|
90
88
|
purpose: process.env.KXM_AGENT_PURPOSE?.trim() || "Claude Code implementation and review agent",
|
|
91
|
-
project
|
|
89
|
+
project,
|
|
92
90
|
model: "claude-code",
|
|
93
91
|
...(authToken ? { authToken } : {}),
|
|
94
92
|
});
|
|
@@ -166,6 +166,7 @@ export class VnextSchemaRegistry {
|
|
|
166
166
|
readonly migrationReceiptValidator: ValidateFunction;
|
|
167
167
|
readonly permissionDiffValidator: ValidateFunction;
|
|
168
168
|
readonly runEventValidator: ValidateFunction;
|
|
169
|
+
readonly driveReceiptValidator: ValidateFunction;
|
|
169
170
|
|
|
170
171
|
constructor(schemasDir = DEFAULT_SCHEMA_DIR) {
|
|
171
172
|
this.schemasDir = resolve(schemasDir);
|
|
@@ -183,6 +184,7 @@ export class VnextSchemaRegistry {
|
|
|
183
184
|
const migrationReceiptFile = "migration-receipt.schema.json";
|
|
184
185
|
const permissionDiffFile = "permission-diff.schema.json";
|
|
185
186
|
const runEventFile = "run-event.schema.json";
|
|
187
|
+
const driveReceiptFile = "drive-receipt.schema.json";
|
|
186
188
|
this.ajv.addSchema(readJsonObject(join(this.schemasDir, localBindingsFile)));
|
|
187
189
|
this.ajv.addSchema(readJsonObject(join(this.schemasDir, templateProvenanceFile)));
|
|
188
190
|
this.ajv.addSchema(readJsonObject(join(this.schemasDir, initOperationFile)));
|
|
@@ -191,6 +193,7 @@ export class VnextSchemaRegistry {
|
|
|
191
193
|
this.ajv.addSchema(readJsonObject(join(this.schemasDir, migrationReceiptFile)));
|
|
192
194
|
this.ajv.addSchema(readJsonObject(join(this.schemasDir, permissionDiffFile)));
|
|
193
195
|
this.ajv.addSchema(readJsonObject(join(this.schemasDir, runEventFile)));
|
|
196
|
+
this.ajv.addSchema(readJsonObject(join(this.schemasDir, driveReceiptFile)));
|
|
194
197
|
for (const [kind, definition] of Object.entries(RESOURCE_SCHEMA) as [VnextResourceKind, { identity: string; file: string }][]) {
|
|
195
198
|
const validator = this.ajv.getSchema(`https://schemas.kxm.dev/vnext/${definition.file}`);
|
|
196
199
|
if (!validator) throw new Error(`schema did not compile: ${definition.file}`);
|
|
@@ -204,6 +207,7 @@ export class VnextSchemaRegistry {
|
|
|
204
207
|
const migrationReceiptValidator = this.ajv.getSchema(`https://schemas.kxm.dev/vnext/${migrationReceiptFile}`);
|
|
205
208
|
const permissionDiffValidator = this.ajv.getSchema(`https://schemas.kxm.dev/vnext/${permissionDiffFile}`);
|
|
206
209
|
const runEventValidator = this.ajv.getSchema(`https://schemas.kxm.dev/vnext/${runEventFile}`);
|
|
210
|
+
const driveReceiptValidator = this.ajv.getSchema(`https://schemas.kxm.dev/vnext/${driveReceiptFile}`);
|
|
207
211
|
if (!localBindingsValidator) throw new Error(`schema did not compile: ${localBindingsFile}`);
|
|
208
212
|
if (!templateProvenanceValidator) throw new Error(`schema did not compile: ${templateProvenanceFile}`);
|
|
209
213
|
if (!initOperationValidator) throw new Error(`schema did not compile: ${initOperationFile}`);
|
|
@@ -212,6 +216,7 @@ export class VnextSchemaRegistry {
|
|
|
212
216
|
if (!migrationReceiptValidator) throw new Error(`schema did not compile: ${migrationReceiptFile}`);
|
|
213
217
|
if (!permissionDiffValidator) throw new Error(`schema did not compile: ${permissionDiffFile}`);
|
|
214
218
|
if (!runEventValidator) throw new Error(`schema did not compile: ${runEventFile}`);
|
|
219
|
+
if (!driveReceiptValidator) throw new Error(`schema did not compile: ${driveReceiptFile}`);
|
|
215
220
|
this.localBindingsValidator = localBindingsValidator;
|
|
216
221
|
this.templateProvenanceValidator = templateProvenanceValidator;
|
|
217
222
|
this.initOperationValidator = initOperationValidator;
|
|
@@ -220,6 +225,7 @@ export class VnextSchemaRegistry {
|
|
|
220
225
|
this.migrationReceiptValidator = migrationReceiptValidator;
|
|
221
226
|
this.permissionDiffValidator = permissionDiffValidator;
|
|
222
227
|
this.runEventValidator = runEventValidator;
|
|
228
|
+
this.driveReceiptValidator = driveReceiptValidator;
|
|
223
229
|
}
|
|
224
230
|
|
|
225
231
|
validate(kind: VnextResourceKind, value: JsonObject, file: string): VnextConfigIssue[] {
|
|
@@ -261,6 +267,10 @@ export class VnextSchemaRegistry {
|
|
|
261
267
|
return this.validateAuxiliary(value, file, "kxm.permission-diff.v1", this.permissionDiffValidator);
|
|
262
268
|
}
|
|
263
269
|
|
|
270
|
+
validateDriveReceipt(value: JsonObject, file: string): VnextConfigIssue[] {
|
|
271
|
+
return this.validateAuxiliary(value, file, "kxm.drive-receipt.v1", this.driveReceiptValidator);
|
|
272
|
+
}
|
|
273
|
+
|
|
264
274
|
private validateAuxiliary(value: JsonObject, file: string, identity: string, validator: ValidateFunction): VnextConfigIssue[] {
|
|
265
275
|
if (value.schema !== identity) {
|
|
266
276
|
return [issue("schema", "schema_identity_mismatch", file, `expected ${identity}, received ${String(value.schema)}`)];
|
|
@@ -285,6 +295,27 @@ export function validateRunEvent(value: unknown, file: string): void {
|
|
|
285
295
|
}
|
|
286
296
|
}
|
|
287
297
|
|
|
298
|
+
/** Validate a drive receipt against `kxm.drive-receipt.v1`. Throws `drive_receipt_invalid`. */
|
|
299
|
+
export function validateDriveReceipt(value: unknown, file: string): void {
|
|
300
|
+
const registry = (cachedRunEventRegistry ??= new VnextSchemaRegistry());
|
|
301
|
+
if (!value || typeof value !== "object" || Array.isArray(value) || (value as JsonObject).schema !== "kxm.drive-receipt.v1") {
|
|
302
|
+
throw new VnextConfigError([issue(
|
|
303
|
+
"schema",
|
|
304
|
+
"drive_receipt_invalid",
|
|
305
|
+
file,
|
|
306
|
+
"expected kxm.drive-receipt.v1",
|
|
307
|
+
)]);
|
|
308
|
+
}
|
|
309
|
+
if (!registry.driveReceiptValidator(value)) {
|
|
310
|
+
throw new VnextConfigError([issue(
|
|
311
|
+
"schema",
|
|
312
|
+
"drive_receipt_invalid",
|
|
313
|
+
file,
|
|
314
|
+
registry.ajv.errorsText(registry.driveReceiptValidator.errors, { separator: "; " }),
|
|
315
|
+
)]);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
288
319
|
function schemaIssue(file: string, error: ErrorObject): VnextConfigIssue {
|
|
289
320
|
const location = error.instancePath || "/";
|
|
290
321
|
const suffix = error.params && "additionalProperty" in error.params
|
|
@@ -107,6 +107,12 @@ export interface VnextRunCurrentStep {
|
|
|
107
107
|
readonly evidenceRefs?: readonly { id: string; kind: string; hash: string; status: string }[] | undefined;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
export interface VnextRunDriveBinding {
|
|
111
|
+
readonly driveId: string;
|
|
112
|
+
readonly mode: "simulated" | "live";
|
|
113
|
+
readonly openedSequence: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
110
116
|
export interface VnextRunState {
|
|
111
117
|
readonly schema: typeof VNEXT_RUN_STATE_SCHEMA;
|
|
112
118
|
readonly runId: string;
|
|
@@ -120,6 +126,7 @@ export interface VnextRunState {
|
|
|
120
126
|
readonly cancelRequested: boolean;
|
|
121
127
|
readonly terminalReason?: string | undefined;
|
|
122
128
|
readonly failureReason?: string | undefined;
|
|
129
|
+
readonly drive?: VnextRunDriveBinding | undefined;
|
|
123
130
|
}
|
|
124
131
|
|
|
125
132
|
interface MutableAttempt {
|
|
@@ -171,6 +178,7 @@ interface MutableState {
|
|
|
171
178
|
awaitingTransition: boolean;
|
|
172
179
|
cancelCommandId?: string | undefined;
|
|
173
180
|
lastTerminalTransition?: VnextTerminalStatus | undefined;
|
|
181
|
+
drive?: VnextRunDriveBinding | undefined;
|
|
174
182
|
}
|
|
175
183
|
|
|
176
184
|
function emptyPanel(): MutablePanel {
|
|
@@ -462,6 +470,9 @@ export function foldVnextRunState(
|
|
|
462
470
|
case "run.cancel_requested":
|
|
463
471
|
foldCancelRequested(state, event);
|
|
464
472
|
break;
|
|
473
|
+
case "run.drive_opened":
|
|
474
|
+
foldDriveOpened(state, event);
|
|
475
|
+
break;
|
|
465
476
|
case "step.entered":
|
|
466
477
|
foldStepEntered(state, plan!, event);
|
|
467
478
|
break;
|
|
@@ -694,6 +705,18 @@ function isProvenFailure(state: MutableState, plan: VnextCompiledPlan | undefine
|
|
|
694
705
|
return Boolean(step && used >= step.maxAttempts);
|
|
695
706
|
}
|
|
696
707
|
|
|
708
|
+
function foldDriveOpened(state: MutableState, event: VnextRunEvent): void {
|
|
709
|
+
if (state.status !== "running" && state.status !== "blocked_uncertain") {
|
|
710
|
+
throw runtimeError("run_events_illegal", state.runId, "run.drive_opened is only legal while running or blocked_uncertain");
|
|
711
|
+
}
|
|
712
|
+
const driveId = stringPayload(event, "driveId");
|
|
713
|
+
const mode = stringPayload(event, "mode");
|
|
714
|
+
if (mode !== "simulated" && mode !== "live") {
|
|
715
|
+
throw runtimeError("run_events_illegal", state.runId, "run.drive_opened mode must be simulated or live");
|
|
716
|
+
}
|
|
717
|
+
state.drive = { driveId, mode, openedSequence: event.sequence };
|
|
718
|
+
}
|
|
719
|
+
|
|
697
720
|
function foldCancelRequested(state: MutableState, event: VnextRunEvent): void {
|
|
698
721
|
if (state.cancelRequested || TERMINAL_RUN.has(state.status)) {
|
|
699
722
|
throw runtimeError("run_events_illegal", state.runId, "duplicate run.cancel_requested is not legal");
|
|
@@ -1379,6 +1402,7 @@ function freezeState(state: MutableState): VnextRunState {
|
|
|
1379
1402
|
cancelRequested: state.cancelRequested,
|
|
1380
1403
|
...(state.terminalReason !== undefined ? { terminalReason: state.terminalReason } : {}),
|
|
1381
1404
|
...(state.terminalReason !== undefined && state.status === "failed" ? { failureReason: state.terminalReason } : {}),
|
|
1405
|
+
...(state.drive !== undefined ? { drive: Object.freeze({ ...state.drive }) } : {}),
|
|
1382
1406
|
};
|
|
1383
1407
|
return Object.freeze(frozen);
|
|
1384
1408
|
}
|
|
@@ -66,12 +66,17 @@ import {
|
|
|
66
66
|
type VnextRuntimeContext,
|
|
67
67
|
} from "./vnext-runtime.ts";
|
|
68
68
|
import {
|
|
69
|
+
hashVnextDriveLog,
|
|
69
70
|
newVnextAssignmentId,
|
|
70
71
|
newVnextAttemptId,
|
|
71
72
|
newVnextEventId,
|
|
72
73
|
projectRuntimeKey,
|
|
73
74
|
runtimeError,
|
|
75
|
+
verifyVnextDriveReceipt,
|
|
76
|
+
VNEXT_DRIVE_RECEIPT_SCHEMA,
|
|
74
77
|
type VnextAttemptCapabilityRow,
|
|
78
|
+
type VnextDriveReceipt,
|
|
79
|
+
type VnextDriveReceiptHandoff,
|
|
75
80
|
type VnextGateAttemptRow,
|
|
76
81
|
type VnextGateEvidenceRow,
|
|
77
82
|
type VnextGateObservationRow,
|
|
@@ -79,7 +84,7 @@ import {
|
|
|
79
84
|
type VnextRunRecord,
|
|
80
85
|
type VnextRunStatus,
|
|
81
86
|
} from "./vnext-runtime-store.ts";
|
|
82
|
-
import { vnextCanonicalJson, type JsonValue, type VnextProjectBundle } from "./vnext-config.ts";
|
|
87
|
+
import { VnextConfigError, vnextCanonicalJson, type JsonValue, type VnextProjectBundle } from "./vnext-config.ts";
|
|
83
88
|
import { evaluateArtifactsGate } from "./vnext-engine-artifacts.ts";
|
|
84
89
|
import { createCommandObserver } from "./vnext-engine-command.ts";
|
|
85
90
|
import {
|
|
@@ -432,6 +437,149 @@ function newDriveId(runId: string, token: string, runtimeId: string, monotonicNs
|
|
|
432
437
|
return `drv_${createHash("sha256").update(`${runId}\0${token}\0${runtimeId}\0${monotonicNs}`, "utf8").digest("hex").slice(0, 24)}`;
|
|
433
438
|
}
|
|
434
439
|
|
|
440
|
+
export interface VnextDrivePollProjection {
|
|
441
|
+
driveId: string;
|
|
442
|
+
mode: "simulated" | "live";
|
|
443
|
+
openedAt: string;
|
|
444
|
+
receipt: VnextDriveReceipt | null;
|
|
445
|
+
verified: boolean;
|
|
446
|
+
divergence?: string;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
export interface VnextDriveReceiptCloseInfo {
|
|
450
|
+
kind: "terminal" | "handoff" | "unsettled";
|
|
451
|
+
reason?: string;
|
|
452
|
+
handoff?: VnextRunHandoff;
|
|
453
|
+
error?: { class: string; component: string; retryable: boolean };
|
|
454
|
+
producerId: string;
|
|
455
|
+
producerClosed: boolean;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function driveErrorSummary(error: unknown): { class: string; component: string; retryable: boolean } {
|
|
459
|
+
const raw = error instanceof VnextConfigError ? error.issues[0]?.code : undefined;
|
|
460
|
+
const className = raw && /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/.test(raw) ? raw : "drive-failure";
|
|
461
|
+
return { class: className, component: "vnext-engine", retryable: false };
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function receiptHandoff(handoff: VnextRunHandoff): VnextDriveReceiptHandoff {
|
|
465
|
+
return {
|
|
466
|
+
reason: handoff.reason,
|
|
467
|
+
detail: handoff.detail,
|
|
468
|
+
...(handoff.field !== undefined ? { field: handoff.field } : {}),
|
|
469
|
+
...(handoff.stepId !== undefined ? { stepId: handoff.stepId } : {}),
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function appendDriveOpened(
|
|
474
|
+
context: VnextRuntimeContext,
|
|
475
|
+
runId: string,
|
|
476
|
+
driveId: string,
|
|
477
|
+
mode: "simulated" | "live",
|
|
478
|
+
): { sequence: number; occurredAt: string } {
|
|
479
|
+
return context.eventStore.transaction(() => {
|
|
480
|
+
const run = requireRun(context, runId);
|
|
481
|
+
const now = new Date().toISOString();
|
|
482
|
+
const sequence = context.eventStore.nextSequence(runId);
|
|
483
|
+
const event: VnextRunEvent = {
|
|
484
|
+
...vnextEventBase(context, run, now, vnextMonotonicNs()),
|
|
485
|
+
eventId: newVnextEventId(),
|
|
486
|
+
eventType: "run.drive_opened",
|
|
487
|
+
sequence,
|
|
488
|
+
payload: { driveId, mode },
|
|
489
|
+
};
|
|
490
|
+
context.eventStore.appendEvent(event);
|
|
491
|
+
const next = foldStoredVnextRun(context, run);
|
|
492
|
+
persistVnextRunState(context, runId, next, sequence);
|
|
493
|
+
return { sequence, occurredAt: now };
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export function recordDriveReceipt(
|
|
498
|
+
context: VnextRuntimeContext,
|
|
499
|
+
session: VnextDriveSession,
|
|
500
|
+
closeInfo: VnextDriveReceiptCloseInfo,
|
|
501
|
+
): void {
|
|
502
|
+
if (isVnextRuntimeContextClosed(context)) return;
|
|
503
|
+
try {
|
|
504
|
+
const run = context.eventStore.run(session.runId);
|
|
505
|
+
if (!run) return;
|
|
506
|
+
const state = foldStoredVnextRun(context, run);
|
|
507
|
+
const events = context.eventStore.events(session.runId, 0, 1_000_000);
|
|
508
|
+
if (events.length === 0) return;
|
|
509
|
+
const opened = state.drive;
|
|
510
|
+
const openedEvent = opened
|
|
511
|
+
? events.find((event) => event.sequence === opened.openedSequence && event.eventType === "run.drive_opened")
|
|
512
|
+
: events.find((event) => event.eventType === "run.drive_opened" && event.payload.driveId === session.driveId);
|
|
513
|
+
const last = events[events.length - 1]!;
|
|
514
|
+
const openedSequence = opened?.openedSequence ?? openedEvent?.sequence;
|
|
515
|
+
if (openedSequence === undefined) return;
|
|
516
|
+
const receipt: VnextDriveReceipt = {
|
|
517
|
+
schema: VNEXT_DRIVE_RECEIPT_SCHEMA,
|
|
518
|
+
driveId: session.driveId,
|
|
519
|
+
runId: session.runId,
|
|
520
|
+
projectId: run.projectId,
|
|
521
|
+
homeRuntimeId: session.homeRuntimeId,
|
|
522
|
+
mode: session.mode,
|
|
523
|
+
openedAt: openedEvent?.occurredAt ?? session.openedAt,
|
|
524
|
+
openedSequence,
|
|
525
|
+
closedAt: new Date().toISOString(),
|
|
526
|
+
lastSequence: last.sequence,
|
|
527
|
+
logHash: hashVnextDriveLog(events.filter((event) => event.sequence >= 1 && event.sequence <= last.sequence)),
|
|
528
|
+
settlement: {
|
|
529
|
+
kind: closeInfo.kind,
|
|
530
|
+
status: state.status,
|
|
531
|
+
reason: closeInfo.reason ?? state.terminalReason ?? "",
|
|
532
|
+
...(closeInfo.kind === "handoff" && closeInfo.handoff ? { handoff: receiptHandoff(closeInfo.handoff) } : {}),
|
|
533
|
+
...(closeInfo.error !== undefined ? { error: closeInfo.error } : {}),
|
|
534
|
+
},
|
|
535
|
+
budget: null,
|
|
536
|
+
producer: { id: closeInfo.producerId, closed: closeInfo.producerClosed },
|
|
537
|
+
};
|
|
538
|
+
context.eventStore.insertDriveReceipt(receipt);
|
|
539
|
+
} catch {
|
|
540
|
+
// The drive result governs; a missing receipt is visible on poll.
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
export function vnextDrivePollProjection(
|
|
545
|
+
context: VnextRuntimeContext,
|
|
546
|
+
runId: string,
|
|
547
|
+
state: VnextRunState,
|
|
548
|
+
): VnextDrivePollProjection | undefined {
|
|
549
|
+
if (!state.drive) return undefined;
|
|
550
|
+
const events = context.eventStore.events(runId, 0, 1_000_000);
|
|
551
|
+
const openedEvent = events.find((event) => event.sequence === state.drive!.openedSequence);
|
|
552
|
+
let receipt: VnextDriveReceipt | null = null;
|
|
553
|
+
let unreadable = false;
|
|
554
|
+
try {
|
|
555
|
+
receipt = context.eventStore.driveReceipt(state.drive.driveId) ?? null;
|
|
556
|
+
} catch {
|
|
557
|
+
unreadable = true;
|
|
558
|
+
}
|
|
559
|
+
const projection: VnextDrivePollProjection = {
|
|
560
|
+
driveId: state.drive.driveId,
|
|
561
|
+
mode: state.drive.mode,
|
|
562
|
+
openedAt: openedEvent?.occurredAt ?? "",
|
|
563
|
+
receipt: unreadable ? null : receipt,
|
|
564
|
+
verified: false,
|
|
565
|
+
};
|
|
566
|
+
if (unreadable) {
|
|
567
|
+
projection.divergence = "receipt unreadable";
|
|
568
|
+
return projection;
|
|
569
|
+
}
|
|
570
|
+
if (!receipt) {
|
|
571
|
+
projection.divergence = "no receipt";
|
|
572
|
+
return projection;
|
|
573
|
+
}
|
|
574
|
+
const checked = verifyVnextDriveReceipt(receipt, events, state.status, {
|
|
575
|
+
runId,
|
|
576
|
+
driveId: state.drive.driveId,
|
|
577
|
+
});
|
|
578
|
+
projection.verified = checked.verified;
|
|
579
|
+
if (checked.divergence !== undefined) projection.divergence = checked.divergence;
|
|
580
|
+
return projection;
|
|
581
|
+
}
|
|
582
|
+
|
|
435
583
|
function inflightAttemptId(state: VnextRunState): string | undefined {
|
|
436
584
|
const leftover = unreconciledPanelAttemptId(state);
|
|
437
585
|
if (leftover) return leftover;
|
|
@@ -595,13 +743,15 @@ export class VnextRunScheduler {
|
|
|
595
743
|
throw error;
|
|
596
744
|
}
|
|
597
745
|
const driveId = newDriveId(runId, token, this.context.homeRuntimeId, vnextMonotonicNs());
|
|
746
|
+
const openedMeta = appendDriveOpened(this.context, runId, driveId, options.mode);
|
|
598
747
|
const session: VnextDriveSession = {
|
|
599
748
|
driveId,
|
|
600
749
|
runId,
|
|
601
750
|
token,
|
|
602
751
|
homeRuntimeId: this.context.homeRuntimeId,
|
|
603
752
|
mode: options.mode,
|
|
604
|
-
openedAt:
|
|
753
|
+
openedAt: openedMeta.occurredAt,
|
|
754
|
+
producerId: producer.id,
|
|
605
755
|
controller: new AbortController(),
|
|
606
756
|
settled,
|
|
607
757
|
};
|
|
@@ -610,10 +760,30 @@ export class VnextRunScheduler {
|
|
|
610
760
|
resolveOpen({ driveId, settled });
|
|
611
761
|
try {
|
|
612
762
|
const result = await driveAdmitted(this.context, runId, producer, token, driveOptions);
|
|
763
|
+
const kind = result.handoff ? "handoff" : isTerminalRunStatus(result.state.status) ? "terminal" : "unsettled";
|
|
764
|
+
recordDriveReceipt(this.context, session, {
|
|
765
|
+
kind,
|
|
766
|
+
...(result.state.terminalReason !== undefined ? { reason: result.state.terminalReason } : result.handoff ? { reason: result.handoff.reason } : {}),
|
|
767
|
+
...(result.handoff ? { handoff: result.handoff } : {}),
|
|
768
|
+
producerId: producer.id,
|
|
769
|
+
producerClosed: false,
|
|
770
|
+
});
|
|
613
771
|
pending.resolve(result);
|
|
614
772
|
return result;
|
|
615
773
|
} catch (error) {
|
|
616
774
|
recordBareDriveFailure(this.context, runId);
|
|
775
|
+
let foldedStatus: string | undefined;
|
|
776
|
+
try {
|
|
777
|
+
foldedStatus = foldStoredVnextRun(this.context, requireRun(this.context, runId)).status;
|
|
778
|
+
} catch {
|
|
779
|
+
foldedStatus = undefined;
|
|
780
|
+
}
|
|
781
|
+
recordDriveReceipt(this.context, session, {
|
|
782
|
+
kind: foldedStatus !== undefined && isTerminalRunStatus(foldedStatus as VnextRunStatus) ? "terminal" : "unsettled",
|
|
783
|
+
error: driveErrorSummary(error),
|
|
784
|
+
producerId: producer.id,
|
|
785
|
+
producerClosed: false,
|
|
786
|
+
});
|
|
617
787
|
pending.reject(error);
|
|
618
788
|
throw error;
|
|
619
789
|
}
|
|
@@ -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 { VnextConfigError, validateRunEvent, vnextCanonicalJson, type JsonValue, type VnextConfigIssue, type VnextConfigOptions } from "./vnext-config.ts";
|
|
5
|
+
import { VnextConfigError, validateDriveReceipt, validateRunEvent, vnextCanonicalJson, type JsonValue, type VnextConfigIssue, type VnextConfigOptions } from "./vnext-config.ts";
|
|
6
6
|
import { vnextUserStateRoot } from "./vnext-bindings.ts";
|
|
7
7
|
|
|
8
8
|
/* ------------------------------------------------------------------ *
|
|
@@ -364,6 +364,73 @@ export interface VnextRunStateRow {
|
|
|
364
364
|
state: string;
|
|
365
365
|
}
|
|
366
366
|
|
|
367
|
+
export interface VnextDriveReceiptHandoff {
|
|
368
|
+
reason: string;
|
|
369
|
+
field?: string;
|
|
370
|
+
stepId?: string;
|
|
371
|
+
detail: string;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export interface VnextDriveReceiptSettlement {
|
|
375
|
+
kind: "terminal" | "handoff" | "unsettled";
|
|
376
|
+
status: string;
|
|
377
|
+
reason: string;
|
|
378
|
+
handoff?: VnextDriveReceiptHandoff;
|
|
379
|
+
error?: { class: string; component: string; retryable: boolean };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
export interface VnextDriveReceipt {
|
|
383
|
+
schema: typeof VNEXT_DRIVE_RECEIPT_SCHEMA;
|
|
384
|
+
driveId: string;
|
|
385
|
+
runId: string;
|
|
386
|
+
projectId: string;
|
|
387
|
+
homeRuntimeId: string;
|
|
388
|
+
mode: "simulated" | "live";
|
|
389
|
+
openedAt: string;
|
|
390
|
+
openedSequence: number;
|
|
391
|
+
closedAt: string;
|
|
392
|
+
lastSequence: number;
|
|
393
|
+
logHash: string;
|
|
394
|
+
settlement: VnextDriveReceiptSettlement;
|
|
395
|
+
budget: null;
|
|
396
|
+
producer: { id: string; closed: boolean };
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/** sha256 over `eventId:sequence` for events 1..lastSequence, in sequence order. */
|
|
400
|
+
export function hashVnextDriveLog(events: readonly { eventId: string; sequence: number }[]): string {
|
|
401
|
+
const body = events.map((event) => `${event.eventId}:${event.sequence}`).join("\n");
|
|
402
|
+
return `sha256:${createHash("sha256").update(body, "utf8").digest("hex")}`;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
export function verifyVnextDriveReceipt(
|
|
406
|
+
receipt: VnextDriveReceipt,
|
|
407
|
+
events: readonly VnextRunEvent[],
|
|
408
|
+
foldedStatus: string,
|
|
409
|
+
binding: { runId: string; driveId: string },
|
|
410
|
+
): { verified: boolean; divergence?: string } {
|
|
411
|
+
const reasons: string[] = [];
|
|
412
|
+
if (receipt.runId !== binding.runId) {
|
|
413
|
+
reasons.push(`runId ${receipt.runId} != binding ${binding.runId}`);
|
|
414
|
+
}
|
|
415
|
+
if (receipt.driveId !== binding.driveId) {
|
|
416
|
+
reasons.push(`driveId ${receipt.driveId} != binding ${binding.driveId}`);
|
|
417
|
+
}
|
|
418
|
+
const last = events[events.length - 1];
|
|
419
|
+
const currentLast = last?.sequence ?? 0;
|
|
420
|
+
if (receipt.lastSequence !== currentLast) {
|
|
421
|
+
reasons.push(`lastSequence ${receipt.lastSequence} != log ${currentLast}`);
|
|
422
|
+
}
|
|
423
|
+
const prefix = events.filter((event) => event.sequence >= 1 && event.sequence <= receipt.lastSequence);
|
|
424
|
+
if (hashVnextDriveLog(prefix) !== receipt.logHash) {
|
|
425
|
+
reasons.push("logHash mismatch");
|
|
426
|
+
}
|
|
427
|
+
if (receipt.settlement.status !== foldedStatus) {
|
|
428
|
+
reasons.push(`settlement.status ${receipt.settlement.status} != folded ${foldedStatus}`);
|
|
429
|
+
}
|
|
430
|
+
if (reasons.length === 0) return { verified: true };
|
|
431
|
+
return { verified: false, divergence: reasons.join("; ") };
|
|
432
|
+
}
|
|
433
|
+
|
|
367
434
|
export interface VnextAttemptCapabilityRow {
|
|
368
435
|
attemptId: string;
|
|
369
436
|
runId: string;
|
|
@@ -472,7 +539,9 @@ export interface VnextCommandRecord {
|
|
|
472
539
|
recordedAt: string;
|
|
473
540
|
}
|
|
474
541
|
|
|
475
|
-
export const VNEXT_EVENT_STORE_SCHEMA_VERSION =
|
|
542
|
+
export const VNEXT_EVENT_STORE_SCHEMA_VERSION = 4;
|
|
543
|
+
export const VNEXT_DRIVE_RECEIPT_SCHEMA = "kxm.drive-receipt.v1";
|
|
544
|
+
export const DRIVE_RECEIPT_MAX_BYTES = 8 * 1024;
|
|
476
545
|
|
|
477
546
|
const EVENT_STORE_TABLES = {
|
|
478
547
|
runs: ["run_id", "project_id", "home_runtime_id", "workflow_id", "prompt_sha256", "status", "config_revision", "memory_revision", "executor_policy_revision", "tool_policy_revision", "created_at", "updated_at"],
|
|
@@ -497,6 +566,7 @@ const EVENT_STORE_TABLES = {
|
|
|
497
566
|
"evidence_id", "attempt_id", "observation_id", "run_id", "project_id", "home_runtime_id", "step_id", "step_attempt",
|
|
498
567
|
"assignment_id", "effect_id", "evidence_key", "kind", "expect", "outcome", "settled_event_id", "content_hash",
|
|
499
568
|
],
|
|
569
|
+
drive_receipts: ["drive_id", "run_id", "project_id", "opened_sequence", "last_sequence", "closed_at", "schema", "receipt"],
|
|
500
570
|
} as const;
|
|
501
571
|
|
|
502
572
|
const EVENT_STORE_SCHEMA = `
|
|
@@ -639,8 +709,43 @@ CREATE TABLE gate_evidence (
|
|
|
639
709
|
) STRICT;
|
|
640
710
|
CREATE UNIQUE INDEX gate_evidence_key ON gate_evidence(run_id, step_id, step_attempt, evidence_key) WHERE evidence_key IS NOT NULL;
|
|
641
711
|
CREATE INDEX gate_evidence_run ON gate_evidence(run_id);
|
|
712
|
+
CREATE TABLE drive_receipts (
|
|
713
|
+
drive_id TEXT PRIMARY KEY,
|
|
714
|
+
run_id TEXT NOT NULL,
|
|
715
|
+
project_id TEXT NOT NULL,
|
|
716
|
+
opened_sequence INTEGER NOT NULL,
|
|
717
|
+
last_sequence INTEGER NOT NULL,
|
|
718
|
+
closed_at TEXT NOT NULL,
|
|
719
|
+
schema TEXT NOT NULL,
|
|
720
|
+
receipt TEXT NOT NULL
|
|
721
|
+
) STRICT;
|
|
722
|
+
CREATE INDEX drive_receipts_run ON drive_receipts(run_id);
|
|
642
723
|
`;
|
|
643
724
|
|
|
725
|
+
const DRIVE_RECEIPTS_DDL = `
|
|
726
|
+
CREATE TABLE IF NOT EXISTS drive_receipts (
|
|
727
|
+
drive_id TEXT PRIMARY KEY,
|
|
728
|
+
run_id TEXT NOT NULL,
|
|
729
|
+
project_id TEXT NOT NULL,
|
|
730
|
+
opened_sequence INTEGER NOT NULL,
|
|
731
|
+
last_sequence INTEGER NOT NULL,
|
|
732
|
+
closed_at TEXT NOT NULL,
|
|
733
|
+
schema TEXT NOT NULL,
|
|
734
|
+
receipt TEXT NOT NULL
|
|
735
|
+
) STRICT;
|
|
736
|
+
CREATE INDEX IF NOT EXISTS drive_receipts_run ON drive_receipts(run_id);
|
|
737
|
+
`;
|
|
738
|
+
|
|
739
|
+
const EVENT_STORE_MIGRATIONS = [
|
|
740
|
+
{
|
|
741
|
+
fromVersion: 3,
|
|
742
|
+
toVersion: 4,
|
|
743
|
+
migrate(database: DatabaseSync): void {
|
|
744
|
+
database.exec(DRIVE_RECEIPTS_DDL);
|
|
745
|
+
},
|
|
746
|
+
},
|
|
747
|
+
];
|
|
748
|
+
|
|
644
749
|
export class VnextRunEventStore {
|
|
645
750
|
readonly path: string;
|
|
646
751
|
private readonly database: DatabaseSync;
|
|
@@ -651,6 +756,7 @@ export class VnextRunEventStore {
|
|
|
651
756
|
schema: EVENT_STORE_SCHEMA,
|
|
652
757
|
version: VNEXT_EVENT_STORE_SCHEMA_VERSION,
|
|
653
758
|
tables: EVENT_STORE_TABLES,
|
|
759
|
+
migrations: EVENT_STORE_MIGRATIONS,
|
|
654
760
|
});
|
|
655
761
|
}
|
|
656
762
|
|
|
@@ -871,6 +977,43 @@ export class VnextRunEventStore {
|
|
|
871
977
|
`).run(row.runId, row.lastSequence, row.state);
|
|
872
978
|
}
|
|
873
979
|
|
|
980
|
+
insertDriveReceipt(receipt: VnextDriveReceipt): { inserted: boolean } {
|
|
981
|
+
validateDriveReceipt(receipt, "drive-receipt");
|
|
982
|
+
const canonical = vnextCanonicalJson(receipt as unknown as JsonValue);
|
|
983
|
+
if (Buffer.byteLength(canonical, "utf8") > DRIVE_RECEIPT_MAX_BYTES) {
|
|
984
|
+
throw runtimeError("drive_receipt_invalid", receipt.driveId, `drive receipt exceeds ${DRIVE_RECEIPT_MAX_BYTES} bytes`);
|
|
985
|
+
}
|
|
986
|
+
const result = this.database.prepare(`
|
|
987
|
+
INSERT INTO drive_receipts (drive_id, run_id, project_id, opened_sequence, last_sequence, closed_at, schema, receipt)
|
|
988
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
989
|
+
ON CONFLICT(drive_id) DO NOTHING
|
|
990
|
+
`).run(
|
|
991
|
+
receipt.driveId,
|
|
992
|
+
receipt.runId,
|
|
993
|
+
receipt.projectId,
|
|
994
|
+
receipt.openedSequence,
|
|
995
|
+
receipt.lastSequence,
|
|
996
|
+
receipt.closedAt,
|
|
997
|
+
receipt.schema,
|
|
998
|
+
canonical,
|
|
999
|
+
);
|
|
1000
|
+
return { inserted: Number(result.changes) === 1 };
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
driveReceipt(driveId: string): VnextDriveReceipt | undefined {
|
|
1004
|
+
const row = this.database.prepare("SELECT receipt FROM drive_receipts WHERE drive_id = ?").get(driveId) as
|
|
1005
|
+
| { receipt: string }
|
|
1006
|
+
| undefined;
|
|
1007
|
+
return row ? parseDriveReceipt(row.receipt) : undefined;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
driveReceiptsForRun(runId: string, limit = 20): VnextDriveReceipt[] {
|
|
1011
|
+
const rows = this.database.prepare(`
|
|
1012
|
+
SELECT receipt FROM drive_receipts WHERE run_id = ? ORDER BY closed_at DESC, drive_id DESC LIMIT ?
|
|
1013
|
+
`).all(runId, limit) as Array<{ receipt: string }>;
|
|
1014
|
+
return rows.map((row) => parseDriveReceipt(row.receipt));
|
|
1015
|
+
}
|
|
1016
|
+
|
|
874
1017
|
insertCapability(row: VnextAttemptCapabilityRow): void {
|
|
875
1018
|
this.database.prepare(`
|
|
876
1019
|
INSERT INTO attempt_capabilities (attempt_id, run_id, assignment_id, step_id, step_attempt, producer_id, capability_hash, state)
|
|
@@ -1075,6 +1218,12 @@ export class VnextRunEventStore {
|
|
|
1075
1218
|
}
|
|
1076
1219
|
}
|
|
1077
1220
|
|
|
1221
|
+
function parseDriveReceipt(raw: string): VnextDriveReceipt {
|
|
1222
|
+
const parsed = JSON.parse(raw) as unknown;
|
|
1223
|
+
validateDriveReceipt(parsed, "drive-receipt");
|
|
1224
|
+
return parsed as VnextDriveReceipt;
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1078
1227
|
function runFromRow(row: {
|
|
1079
1228
|
run_id: string;
|
|
1080
1229
|
project_id: string;
|
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
} from "./vnext-runtime.ts";
|
|
24
24
|
import { createVnextOneShotProducer } from "./vnext-oneshot-producer.ts";
|
|
25
25
|
import { isProducerAdmitted } from "./producers.ts";
|
|
26
|
-
import { VnextRunScheduler, createVnextSimulatedProducer, recoverVnextRun } from "./vnext-engine.ts";
|
|
26
|
+
import { VnextRunScheduler, createVnextSimulatedProducer, recordDriveReceipt, recoverVnextRun, vnextDrivePollProjection } from "./vnext-engine.ts";
|
|
27
27
|
import { vnextOpenDriveSessions } from "./vnext-runtime-owner.ts";
|
|
28
28
|
|
|
29
29
|
/* ------------------------------------------------------------------ *
|
|
@@ -318,6 +318,16 @@ async function waitForDriveSessions(settled: Promise<unknown>[], graceMs: number
|
|
|
318
318
|
}
|
|
319
319
|
}
|
|
320
320
|
|
|
321
|
+
async function driveSessionStillPending(settled: Promise<unknown>): Promise<boolean> {
|
|
322
|
+
let pending = true;
|
|
323
|
+
void settled.then(
|
|
324
|
+
() => { pending = false; },
|
|
325
|
+
() => { pending = false; },
|
|
326
|
+
);
|
|
327
|
+
await Promise.resolve();
|
|
328
|
+
return pending;
|
|
329
|
+
}
|
|
330
|
+
|
|
321
331
|
export interface VnextRuntimeSupervisor {
|
|
322
332
|
server: Server;
|
|
323
333
|
port: number;
|
|
@@ -446,7 +456,9 @@ async function startVnextRuntimeSupervisorInner(
|
|
|
446
456
|
const bundle = loadVnextProject(projectRoot, {});
|
|
447
457
|
if (request.method === "GET" && !sub) {
|
|
448
458
|
const projected = rebuildVnextRunProjection(context, runId);
|
|
449
|
-
|
|
459
|
+
const folded = foldStoredVnextRun(context, projected);
|
|
460
|
+
const drive = vnextDrivePollProjection(context, runId, folded);
|
|
461
|
+
sendJson(response, 200, { ok: true, run: projected, ...(drive !== undefined ? { drive } : {}) });
|
|
450
462
|
return;
|
|
451
463
|
}
|
|
452
464
|
if (request.method === "POST" && sub === "drive") {
|
|
@@ -684,6 +696,15 @@ async function startVnextRuntimeSupervisorInner(
|
|
|
684
696
|
}
|
|
685
697
|
}
|
|
686
698
|
await waitForDriveSessions(openSessions.map(({ session }) => session.settled), runtimeStopGraceMs());
|
|
699
|
+
for (const { context, session } of openSessions) {
|
|
700
|
+
if (!(await driveSessionStillPending(session.settled))) continue;
|
|
701
|
+
recordDriveReceipt(context, session, {
|
|
702
|
+
kind: "unsettled",
|
|
703
|
+
reason: "runtime_shutdown_grace_expired",
|
|
704
|
+
producerId: session.producerId,
|
|
705
|
+
producerClosed: false,
|
|
706
|
+
});
|
|
707
|
+
}
|
|
687
708
|
for (const context of contexts.values()) closeVnextRuntimeContext(context);
|
|
688
709
|
contexts.clear();
|
|
689
710
|
const closed = new Promise<void>((resolveStop) => server.close(() => resolveStop()));
|