@kontextmind/kxm 0.7.23 → 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.
@@ -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: new Date().toISOString(),
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
  }
@@ -26,6 +26,7 @@ export interface VnextDriveSession {
26
26
  homeRuntimeId: string;
27
27
  mode: "simulated" | "live";
28
28
  openedAt: string;
29
+ producerId: string;
29
30
  controller: AbortController;
30
31
  settled: Promise<unknown>;
31
32
  }
@@ -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 = 3;
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
- sendJson(response, 200, { ok: true, run: projected });
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()));
@@ -164,6 +164,7 @@
164
164
  "run.created",
165
165
  "run.status_changed",
166
166
  "run.cancel_requested",
167
+ "run.drive_opened",
167
168
  "step.entered",
168
169
  "step.status_changed",
169
170
  "step.outcome_recorded",
@@ -0,0 +1,79 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "https://schemas.kxm.dev/vnext/drive-receipt.schema.json",
4
+ "title": "KXM vNext drive session receipt",
5
+ "type": "object",
6
+ "required": [
7
+ "schema",
8
+ "driveId",
9
+ "runId",
10
+ "projectId",
11
+ "homeRuntimeId",
12
+ "mode",
13
+ "openedAt",
14
+ "openedSequence",
15
+ "closedAt",
16
+ "lastSequence",
17
+ "logHash",
18
+ "settlement",
19
+ "budget",
20
+ "producer"
21
+ ],
22
+ "properties": {
23
+ "schema": { "const": "kxm.drive-receipt.v1" },
24
+ "driveId": { "$ref": "common.schema.json#/$defs/opaqueId" },
25
+ "runId": { "$ref": "common.schema.json#/$defs/opaqueId" },
26
+ "projectId": { "$ref": "common.schema.json#/$defs/opaqueId" },
27
+ "homeRuntimeId": { "$ref": "common.schema.json#/$defs/opaqueId" },
28
+ "mode": { "enum": ["simulated", "live"] },
29
+ "openedAt": { "$ref": "common.schema.json#/$defs/timestamp" },
30
+ "openedSequence": { "type": "integer", "minimum": 1 },
31
+ "closedAt": { "$ref": "common.schema.json#/$defs/timestamp" },
32
+ "lastSequence": { "type": "integer", "minimum": 1 },
33
+ "logHash": { "$ref": "common.schema.json#/$defs/sha256" },
34
+ "settlement": { "$ref": "#/$defs/settlement" },
35
+ "budget": { "type": "null" },
36
+ "producer": {
37
+ "type": "object",
38
+ "required": ["id", "closed"],
39
+ "properties": {
40
+ "id": { "$ref": "common.schema.json#/$defs/identifier" },
41
+ "closed": { "type": "boolean" }
42
+ },
43
+ "additionalProperties": false
44
+ }
45
+ },
46
+ "additionalProperties": false,
47
+ "$defs": {
48
+ "settlement": {
49
+ "type": "object",
50
+ "required": ["kind", "status", "reason"],
51
+ "properties": {
52
+ "kind": { "enum": ["terminal", "handoff", "unsettled"] },
53
+ "status": { "$ref": "common.schema.json#/$defs/lifecycleStatus" },
54
+ "reason": { "type": "string", "maxLength": 4000 },
55
+ "handoff": { "$ref": "#/$defs/handoff" },
56
+ "error": { "$ref": "common.schema.json#/$defs/errorSummary" }
57
+ },
58
+ "additionalProperties": false,
59
+ "allOf": [
60
+ {
61
+ "if": { "properties": { "kind": { "const": "handoff" } }, "required": ["kind"] },
62
+ "then": { "required": ["handoff"] },
63
+ "else": { "not": { "required": ["handoff"] } }
64
+ }
65
+ ]
66
+ },
67
+ "handoff": {
68
+ "type": "object",
69
+ "required": ["reason", "detail"],
70
+ "properties": {
71
+ "reason": { "type": "string", "minLength": 1, "maxLength": 80 },
72
+ "field": { "type": "string", "minLength": 1, "maxLength": 128 },
73
+ "stepId": { "$ref": "common.schema.json#/$defs/identifier" },
74
+ "detail": { "type": "string", "minLength": 1, "maxLength": 4000 }
75
+ },
76
+ "additionalProperties": false
77
+ }
78
+ }
79
+ }
@@ -278,6 +278,14 @@
278
278
  "payload": { "type": "object", "required": ["routing"] }
279
279
  }
280
280
  }
281
+ },
282
+ {
283
+ "if": { "properties": { "eventType": { "const": "run.drive_opened" } }, "required": ["eventType"] },
284
+ "then": {
285
+ "properties": {
286
+ "payload": { "type": "object", "required": ["driveId", "mode"] }
287
+ }
288
+ }
281
289
  }
282
290
  ],
283
291
  "additionalProperties": false,
@@ -394,6 +402,8 @@
394
402
  "maxItems": 64
395
403
  },
396
404
  "lease": { "$ref": "common.schema.json#/$defs/lease" },
405
+ "driveId": { "$ref": "common.schema.json#/$defs/opaqueId" },
406
+ "mode": { "enum": ["simulated", "live"] },
397
407
  "candidateId": { "$ref": "common.schema.json#/$defs/opaqueId" },
398
408
  "counts": {
399
409
  "type": "object",