@kontextmind/kxm 0.7.23 → 0.7.25

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.
@@ -13,10 +13,12 @@ import {
13
13
  } from "./context-packet.ts";
14
14
  import { compileVnextWorkflow, type VnextCompiledPlan, type VnextCompiledStep } from "./vnext-engine-compile.ts";
15
15
  import {
16
+ effectiveRunDurationBudget,
16
17
  isTerminalRunStatus,
17
18
  vnextFoldPanelAttempt,
18
19
  vnextFoldPanelAttemptIds,
19
20
  vnextJoinAll,
21
+ type VnextRunDurationBudget,
20
22
  type VnextRunState,
21
23
  } from "./vnext-engine-fold.ts";
22
24
  import {
@@ -66,12 +68,18 @@ import {
66
68
  type VnextRuntimeContext,
67
69
  } from "./vnext-runtime.ts";
68
70
  import {
71
+ hashVnextDriveLog,
69
72
  newVnextAssignmentId,
70
73
  newVnextAttemptId,
71
74
  newVnextEventId,
72
75
  projectRuntimeKey,
73
76
  runtimeError,
77
+ verifyVnextDriveReceipt,
78
+ VNEXT_DRIVE_RECEIPT_SCHEMA,
74
79
  type VnextAttemptCapabilityRow,
80
+ type VnextDriveReceipt,
81
+ type VnextDriveReceiptBudget,
82
+ type VnextDriveReceiptHandoff,
75
83
  type VnextGateAttemptRow,
76
84
  type VnextGateEvidenceRow,
77
85
  type VnextGateObservationRow,
@@ -79,7 +87,7 @@ import {
79
87
  type VnextRunRecord,
80
88
  type VnextRunStatus,
81
89
  } from "./vnext-runtime-store.ts";
82
- import { vnextCanonicalJson, type JsonValue, type VnextProjectBundle } from "./vnext-config.ts";
90
+ import { VnextConfigError, vnextCanonicalJson, type JsonValue, type VnextProjectBundle } from "./vnext-config.ts";
83
91
  import { evaluateArtifactsGate } from "./vnext-engine-artifacts.ts";
84
92
  import { createCommandObserver } from "./vnext-engine-command.ts";
85
93
  import {
@@ -383,6 +391,8 @@ async function driveAdmitted(
383
391
  }
384
392
  let latest: VnextRunDriveResult = { state: foldStoredVnextRun(context, requireRun(context, runId)) };
385
393
  while (latest.state.status === "running") {
394
+ const overBudget = cancelRunDurationIfExceeded(context, runId);
395
+ if (overBudget) return overBudget;
386
396
  const before = context.eventStore.runState(runId)?.lastSequence;
387
397
  latest = await stepLocked(context, runId, producer, token);
388
398
  if (latest.handoff) return latest;
@@ -432,6 +442,150 @@ function newDriveId(runId: string, token: string, runtimeId: string, monotonicNs
432
442
  return `drv_${createHash("sha256").update(`${runId}\0${token}\0${runtimeId}\0${monotonicNs}`, "utf8").digest("hex").slice(0, 24)}`;
433
443
  }
434
444
 
445
+ export interface VnextDrivePollProjection {
446
+ driveId: string;
447
+ mode: "simulated" | "live";
448
+ openedAt: string;
449
+ receipt: VnextDriveReceipt | null;
450
+ verified: boolean;
451
+ divergence?: string;
452
+ }
453
+
454
+ export interface VnextDriveReceiptCloseInfo {
455
+ kind: "terminal" | "handoff" | "unsettled";
456
+ reason?: string;
457
+ handoff?: VnextRunHandoff;
458
+ error?: { class: string; component: string; retryable: boolean };
459
+ producerId: string;
460
+ producerClosed: boolean;
461
+ }
462
+
463
+ function driveErrorSummary(error: unknown): { class: string; component: string; retryable: boolean } {
464
+ const raw = error instanceof VnextConfigError ? error.issues[0]?.code : undefined;
465
+ const className = raw && /^[a-z][a-z0-9]*(?:[-_][a-z0-9]+)*$/.test(raw) ? raw : "drive-failure";
466
+ return { class: className, component: "vnext-engine", retryable: false };
467
+ }
468
+
469
+ function receiptHandoff(handoff: VnextRunHandoff): VnextDriveReceiptHandoff {
470
+ return {
471
+ reason: handoff.reason,
472
+ detail: handoff.detail,
473
+ ...(handoff.field !== undefined ? { field: handoff.field } : {}),
474
+ ...(handoff.stepId !== undefined ? { stepId: handoff.stepId } : {}),
475
+ };
476
+ }
477
+
478
+ function appendDriveOpened(
479
+ context: VnextRuntimeContext,
480
+ runId: string,
481
+ driveId: string,
482
+ mode: "simulated" | "live",
483
+ ): { sequence: number; occurredAt: string } {
484
+ return context.eventStore.transaction(() => {
485
+ const run = requireRun(context, runId);
486
+ const now = new Date().toISOString();
487
+ const sequence = context.eventStore.nextSequence(runId);
488
+ const event: VnextRunEvent = {
489
+ ...vnextEventBase(context, run, now, vnextMonotonicNs()),
490
+ eventId: newVnextEventId(),
491
+ eventType: "run.drive_opened",
492
+ sequence,
493
+ payload: { driveId, mode },
494
+ };
495
+ context.eventStore.appendEvent(event);
496
+ const next = foldStoredVnextRun(context, run);
497
+ persistVnextRunState(context, runId, next, sequence);
498
+ return { sequence, occurredAt: now };
499
+ });
500
+ }
501
+
502
+ export function recordDriveReceipt(
503
+ context: VnextRuntimeContext,
504
+ session: VnextDriveSession,
505
+ closeInfo: VnextDriveReceiptCloseInfo,
506
+ ): void {
507
+ if (isVnextRuntimeContextClosed(context)) return;
508
+ try {
509
+ const run = context.eventStore.run(session.runId);
510
+ if (!run) return;
511
+ const state = foldStoredVnextRun(context, run);
512
+ const events = context.eventStore.events(session.runId, 0, 1_000_000);
513
+ if (events.length === 0) return;
514
+ const opened = state.drive;
515
+ const openedEvent = opened
516
+ ? events.find((event) => event.sequence === opened.openedSequence && event.eventType === "run.drive_opened")
517
+ : events.find((event) => event.eventType === "run.drive_opened" && event.payload.driveId === session.driveId);
518
+ const last = events[events.length - 1]!;
519
+ const openedSequence = opened?.openedSequence ?? openedEvent?.sequence;
520
+ if (openedSequence === undefined) return;
521
+ const closedAt = new Date().toISOString();
522
+ const receipt: VnextDriveReceipt = {
523
+ schema: VNEXT_DRIVE_RECEIPT_SCHEMA,
524
+ driveId: session.driveId,
525
+ runId: session.runId,
526
+ projectId: run.projectId,
527
+ homeRuntimeId: session.homeRuntimeId,
528
+ mode: session.mode,
529
+ openedAt: openedEvent?.occurredAt ?? session.openedAt,
530
+ openedSequence,
531
+ closedAt,
532
+ lastSequence: last.sequence,
533
+ logHash: hashVnextDriveLog(events.filter((event) => event.sequence >= 1 && event.sequence <= last.sequence)),
534
+ settlement: {
535
+ kind: closeInfo.kind,
536
+ status: state.status,
537
+ reason: closeInfo.reason ?? state.terminalReason ?? "",
538
+ ...(closeInfo.kind === "handoff" && closeInfo.handoff ? { handoff: receiptHandoff(closeInfo.handoff) } : {}),
539
+ ...(closeInfo.error !== undefined ? { error: closeInfo.error } : {}),
540
+ },
541
+ budget: driveReceiptBudget(context, run.runId, state, events, closedAt),
542
+ producer: { id: closeInfo.producerId, closed: closeInfo.producerClosed },
543
+ };
544
+ context.eventStore.insertDriveReceipt(receipt);
545
+ } catch {
546
+ // The drive result governs; a missing receipt is visible on poll.
547
+ }
548
+ }
549
+
550
+ export function vnextDrivePollProjection(
551
+ context: VnextRuntimeContext,
552
+ runId: string,
553
+ state: VnextRunState,
554
+ ): VnextDrivePollProjection | undefined {
555
+ if (!state.drive) return undefined;
556
+ const events = context.eventStore.events(runId, 0, 1_000_000);
557
+ const openedEvent = events.find((event) => event.sequence === state.drive!.openedSequence);
558
+ let receipt: VnextDriveReceipt | null = null;
559
+ let unreadable = false;
560
+ try {
561
+ receipt = context.eventStore.driveReceipt(state.drive.driveId) ?? null;
562
+ } catch {
563
+ unreadable = true;
564
+ }
565
+ const projection: VnextDrivePollProjection = {
566
+ driveId: state.drive.driveId,
567
+ mode: state.drive.mode,
568
+ openedAt: openedEvent?.occurredAt ?? "",
569
+ receipt: unreadable ? null : receipt,
570
+ verified: false,
571
+ };
572
+ if (unreadable) {
573
+ projection.divergence = "receipt unreadable";
574
+ return projection;
575
+ }
576
+ if (!receipt) {
577
+ projection.divergence = "no receipt";
578
+ return projection;
579
+ }
580
+ const checked = verifyVnextDriveReceipt(receipt, events, state.status, {
581
+ runId,
582
+ driveId: state.drive.driveId,
583
+ });
584
+ projection.verified = checked.verified;
585
+ if (checked.divergence !== undefined) projection.divergence = checked.divergence;
586
+ return projection;
587
+ }
588
+
435
589
  function inflightAttemptId(state: VnextRunState): string | undefined {
436
590
  const leftover = unreconciledPanelAttemptId(state);
437
591
  if (leftover) return leftover;
@@ -595,25 +749,50 @@ export class VnextRunScheduler {
595
749
  throw error;
596
750
  }
597
751
  const driveId = newDriveId(runId, token, this.context.homeRuntimeId, vnextMonotonicNs());
752
+ const openedMeta = appendDriveOpened(this.context, runId, driveId, options.mode);
753
+ const startedState = foldStoredVnextRun(this.context, requireRun(this.context, runId));
754
+ const deadlineAt = driveDeadlineAt(this.context, runId, startedState);
598
755
  const session: VnextDriveSession = {
599
756
  driveId,
600
757
  runId,
601
758
  token,
602
759
  homeRuntimeId: this.context.homeRuntimeId,
603
760
  mode: options.mode,
604
- openedAt: new Date().toISOString(),
761
+ openedAt: openedMeta.occurredAt,
762
+ producerId: producer.id,
605
763
  controller: new AbortController(),
606
764
  settled,
765
+ ...(deadlineAt !== undefined ? { deadlineAt } : {}),
607
766
  };
608
767
  attachVnextDriveSession(this.context.eventStore.path, runId, token, session);
609
768
  opened = true;
610
769
  resolveOpen({ driveId, settled });
611
770
  try {
612
771
  const result = await driveAdmitted(this.context, runId, producer, token, driveOptions);
772
+ const kind = result.handoff ? "handoff" : isTerminalRunStatus(result.state.status) ? "terminal" : "unsettled";
773
+ recordDriveReceipt(this.context, session, {
774
+ kind,
775
+ ...(result.state.terminalReason !== undefined ? { reason: result.state.terminalReason } : result.handoff ? { reason: result.handoff.reason } : {}),
776
+ ...(result.handoff ? { handoff: result.handoff } : {}),
777
+ producerId: producer.id,
778
+ producerClosed: false,
779
+ });
613
780
  pending.resolve(result);
614
781
  return result;
615
782
  } catch (error) {
616
783
  recordBareDriveFailure(this.context, runId);
784
+ let foldedStatus: string | undefined;
785
+ try {
786
+ foldedStatus = foldStoredVnextRun(this.context, requireRun(this.context, runId)).status;
787
+ } catch {
788
+ foldedStatus = undefined;
789
+ }
790
+ recordDriveReceipt(this.context, session, {
791
+ kind: foldedStatus !== undefined && isTerminalRunStatus(foldedStatus as VnextRunStatus) ? "terminal" : "unsettled",
792
+ error: driveErrorSummary(error),
793
+ producerId: producer.id,
794
+ producerClosed: false,
795
+ });
617
796
  pending.reject(error);
618
797
  throw error;
619
798
  }
@@ -730,11 +909,13 @@ async function stepLocked(context: VnextRuntimeContext, runId: string, producer:
730
909
  throw runtimeError("run_events_illegal", prepared.run.runId, "prepared gate is not artifacts-exist");
731
910
  }
732
911
  registerVnextAttemptController(context.eventStore.path, runId, { attemptId: prepared.attemptId, controller: prepared.controller });
912
+ const clearBudget = armRunDurationBudgetTimer(context, runId);
733
913
  try {
734
914
  const observation = evaluateArtifactsGate(context, definition);
735
915
  vnextGateDispatchSeams.afterEvaluate?.(prepared);
736
916
  return context.eventStore.transaction(() => settlePreparedGate(context, prepared, observation));
737
917
  } finally {
918
+ clearBudget();
738
919
  unregisterVnextAttemptController(context.eventStore.path, runId, prepared.attemptId);
739
920
  }
740
921
  }
@@ -775,6 +956,7 @@ async function runPreparedCommandGate(
775
956
  });
776
957
  armVnextGateHold(storePath, prepared.run.runId, token, prepared.attemptId, () => observer.requestStop("cancel"));
777
958
  registerVnextAttemptController(storePath, prepared.run.runId, { attemptId: prepared.attemptId, controller: prepared.controller });
959
+ const clearBudget = armRunDurationBudgetTimer(context, prepared.run.runId);
778
960
  const unhookClose = registerVnextRuntimeCloseHook(context, () => {
779
961
  try {
780
962
  markVnextGateHoldUnsettled(storePath, prepared.run.runId, token, prepared.attemptId);
@@ -822,6 +1004,7 @@ async function runPreparedCommandGate(
822
1004
  return commitCommandUncertainty(context, prepared, token, outcome.reason, outcome.observation);
823
1005
  } finally {
824
1006
  unhookClose();
1007
+ clearBudget();
825
1008
  unregisterVnextAttemptController(storePath, prepared.run.runId, prepared.attemptId);
826
1009
  }
827
1010
  }
@@ -1628,8 +1811,10 @@ async function drivePanel(
1628
1811
  let stopBirths = false;
1629
1812
  let settlementFailed = false;
1630
1813
 
1814
+ const budgetClears = new Map<string, () => void>();
1631
1815
  const register = (member: PreparedDispatch): void => {
1632
1816
  registerVnextAttemptController(context.eventStore.path, runId, { attemptId: member.attemptId, controller: member.controller });
1817
+ budgetClears.set(member.attemptId, armRunDurationBudgetTimer(context, runId));
1633
1818
  owned.set(member.attemptId, member);
1634
1819
  };
1635
1820
 
@@ -1836,6 +2021,7 @@ async function drivePanel(
1836
2021
  throw error;
1837
2022
  } finally {
1838
2023
  for (const attemptId of owned.keys()) {
2024
+ budgetClears.get(attemptId)?.();
1839
2025
  unregisterVnextAttemptController(context.eventStore.path, runId, attemptId);
1840
2026
  }
1841
2027
  }
@@ -2024,7 +2210,7 @@ function joinPanel(context: VnextRuntimeContext, panel: PreparedPanel): VnextRun
2024
2210
 
2025
2211
  const cancelRun = (): VnextRunDriveResult => {
2026
2212
  push("step.status_changed", { stepId: panel.stepId, status: "cancelled", previousStatus: "running" });
2027
- push("run.status_changed", { status: "cancelled", reason: "operator_cancel" });
2213
+ push("run.status_changed", { status: "cancelled", reason: cancelReasonFromLog(context, run.runId) });
2028
2214
  for (const event of events) context.eventStore.appendEvent(event);
2029
2215
  const next = foldStoredVnextRun(context, run);
2030
2216
  persistVnextRunState(context, run.runId, next, events[events.length - 1]!.sequence);
@@ -2146,16 +2332,146 @@ function transitionBudgetFailure(plan: VnextCompiledPlan, state: VnextRunState,
2146
2332
  return undefined;
2147
2333
  }
2148
2334
 
2149
- function unsupportedLimit(envelope: VnextRunPlanEnvelope): VnextRunHandoff | undefined {
2150
- if (envelope.plan.limits.maxRunDurationMs !== undefined) {
2151
- return { reason: "limit_unsupported", field: "limits.maxRunDurationMs", detail: "duration budget enforcement is not available in this slice" };
2335
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
2336
+
2337
+ function elapsedMsBetween(fromIso: string, toIso: string): number {
2338
+ const from = Date.parse(fromIso);
2339
+ const to = Date.parse(toIso);
2340
+ if (!Number.isFinite(from) || !Number.isFinite(to)) return 0;
2341
+ return Math.max(0, Math.trunc(to - from));
2342
+ }
2343
+
2344
+ function declaredRunDurationBudget(context: VnextRuntimeContext, runId: string): VnextRunDurationBudget | undefined {
2345
+ const run = context.eventStore.run(runId);
2346
+ if (!run) return undefined;
2347
+ try {
2348
+ const envelope = loadVnextRunPlanEnvelope(context.eventStore, run);
2349
+ return effectiveRunDurationBudget(envelope.plan.limits, envelope.projectLimits);
2350
+ } catch {
2351
+ return undefined;
2352
+ }
2353
+ }
2354
+
2355
+ function runDurationOverrunPayload(
2356
+ context: VnextRuntimeContext,
2357
+ runId: string,
2358
+ nowIso = new Date().toISOString(),
2359
+ ): { budgetMs: number; elapsedMs: number; source: VnextRunDurationBudget["source"] } | undefined {
2360
+ const declared = declaredRunDurationBudget(context, runId);
2361
+ if (!declared) return undefined;
2362
+ const run = context.eventStore.run(runId);
2363
+ if (!run) return undefined;
2364
+ const state = foldStoredVnextRun(context, run);
2365
+ if (!state.runningSince) return undefined;
2366
+ if (isTerminalRunStatus(state.status) || state.status === "cancelling") return undefined;
2367
+ const elapsedMs = elapsedMsBetween(state.runningSince, nowIso);
2368
+ if (elapsedMs < declared.budgetMs) return undefined;
2369
+ return { budgetMs: declared.budgetMs, elapsedMs, source: declared.source };
2370
+ }
2371
+
2372
+ function cancelRunDurationIfExceeded(context: VnextRuntimeContext, runId: string): VnextRunDriveResult | undefined {
2373
+ if (isVnextRuntimeContextClosed(context)) return undefined;
2374
+ const payload = runDurationOverrunPayload(context, runId);
2375
+ if (!payload) return undefined;
2376
+ cancelVnextRun(context, runId, { reason: "budget_run_duration", budget: payload });
2377
+ return { state: foldStoredVnextRun(context, requireRun(context, runId)) };
2378
+ }
2379
+
2380
+ function driveDeadlineAt(context: VnextRuntimeContext, runId: string, state: VnextRunState): string | undefined {
2381
+ const declared = declaredRunDurationBudget(context, runId);
2382
+ if (!declared || !state.runningSince) return undefined;
2383
+ const start = Date.parse(state.runningSince);
2384
+ if (!Number.isFinite(start)) return undefined;
2385
+ return new Date(start + declared.budgetMs).toISOString();
2386
+ }
2387
+
2388
+ function driveReceiptBudget(
2389
+ context: VnextRuntimeContext,
2390
+ runId: string,
2391
+ state: VnextRunState,
2392
+ events: readonly VnextRunEvent[],
2393
+ closedAt: string,
2394
+ ): VnextDriveReceiptBudget | null {
2395
+ const declared = declaredRunDurationBudget(context, runId);
2396
+ if (!declared) return null;
2397
+ const elapsedMs = state.runningSince ? elapsedMsBetween(state.runningSince, closedAt) : 0;
2398
+ const overrun = events.some((event) => (
2399
+ event.eventType === "run.cancel_requested" && event.payload.reason === "budget_run_duration"
2400
+ ));
2401
+ return {
2402
+ budgetMs: declared.budgetMs,
2403
+ source: declared.source,
2404
+ elapsedMs,
2405
+ overrun,
2406
+ };
2407
+ }
2408
+
2409
+ function cancelReasonFromLog(context: VnextRuntimeContext, runId: string): string {
2410
+ const events = context.eventStore.events(runId, 0, 1_000_000);
2411
+ for (let index = events.length - 1; index >= 0; index -= 1) {
2412
+ const event = events[index]!;
2413
+ if (event.eventType === "run.cancel_requested" && typeof event.payload.reason === "string") {
2414
+ return event.payload.reason;
2415
+ }
2152
2416
  }
2417
+ return "operator_cancel";
2418
+ }
2419
+
2420
+ function armRunDurationBudgetTimer(context: VnextRuntimeContext, runId: string): () => void {
2421
+ const declared = declaredRunDurationBudget(context, runId);
2422
+ if (!declared) return () => undefined;
2423
+ const run = context.eventStore.run(runId);
2424
+ if (!run) return () => undefined;
2425
+ const state = foldStoredVnextRun(context, run);
2426
+ if (!state.runningSince) return () => undefined;
2427
+ const startMs = Date.parse(state.runningSince);
2428
+ if (!Number.isFinite(startMs)) return () => undefined;
2429
+ const deadlineMs = startMs + declared.budgetMs;
2430
+ let timer: ReturnType<typeof setTimeout> | undefined;
2431
+ let cleared = false;
2432
+ const fire = (): void => {
2433
+ timer = undefined;
2434
+ if (cleared || isVnextRuntimeContextClosed(context)) return;
2435
+ const nowIso = new Date().toISOString();
2436
+ const elapsedMs = elapsedMsBetween(state.runningSince!, nowIso);
2437
+ if (elapsedMs < declared.budgetMs) {
2438
+ arm(declared.budgetMs - elapsedMs);
2439
+ return;
2440
+ }
2441
+ try {
2442
+ cancelVnextRun(context, runId, {
2443
+ reason: "budget_run_duration",
2444
+ budget: { budgetMs: declared.budgetMs, elapsedMs, source: declared.source },
2445
+ });
2446
+ } catch {
2447
+ // Already cancelling, terminal, or the store closed under us.
2448
+ }
2449
+ };
2450
+ const arm = (remaining: number): void => {
2451
+ if (cleared) return;
2452
+ const delay = Math.min(Math.max(0, remaining), MAX_TIMER_DELAY_MS);
2453
+ timer = setTimeout(fire, delay);
2454
+ timer.unref();
2455
+ };
2456
+ const unhook = registerVnextRuntimeCloseHook(context, () => {
2457
+ cleared = true;
2458
+ if (timer !== undefined) clearTimeout(timer);
2459
+ timer = undefined;
2460
+ });
2461
+ arm(deadlineMs - Date.now());
2462
+ return () => {
2463
+ if (cleared) return;
2464
+ cleared = true;
2465
+ if (timer !== undefined) clearTimeout(timer);
2466
+ timer = undefined;
2467
+ unhook();
2468
+ };
2469
+ }
2470
+
2471
+ function unsupportedLimit(envelope: VnextRunPlanEnvelope): VnextRunHandoff | undefined {
2153
2472
  if (envelope.plan.limits.maxAgentTimeMs !== undefined) {
2154
2473
  return { reason: "limit_unsupported", field: "limits.maxAgentTimeMs", detail: "agent-time budget enforcement is not available in this slice" };
2155
2474
  }
2156
- if (envelope.projectLimits.maxRunDurationMs !== undefined) {
2157
- return { reason: "limit_unsupported", field: "project.limits.maxRunDurationMs", detail: "duration budget enforcement is not available in this slice" };
2158
- }
2159
2475
  if (envelope.projectLimits.maxAgentTimeMs !== undefined) {
2160
2476
  return { reason: "limit_unsupported", field: "project.limits.maxAgentTimeMs", detail: "agent-time budget enforcement is not available in this slice" };
2161
2477
  }
@@ -26,8 +26,10 @@ 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>;
32
+ deadlineAt?: string;
31
33
  }
32
34
 
33
35
  interface AdmissionRecord {
@@ -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,80 @@ 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: VnextDriveReceiptBudget | null;
396
+ producer: { id: string; closed: boolean };
397
+ }
398
+
399
+ export interface VnextDriveReceiptBudget {
400
+ budgetMs: number;
401
+ source: "workflow" | "project" | "both";
402
+ elapsedMs: number;
403
+ overrun: boolean;
404
+ }
405
+
406
+ /** sha256 over `eventId:sequence` for events 1..lastSequence, in sequence order. */
407
+ export function hashVnextDriveLog(events: readonly { eventId: string; sequence: number }[]): string {
408
+ const body = events.map((event) => `${event.eventId}:${event.sequence}`).join("\n");
409
+ return `sha256:${createHash("sha256").update(body, "utf8").digest("hex")}`;
410
+ }
411
+
412
+ export function verifyVnextDriveReceipt(
413
+ receipt: VnextDriveReceipt,
414
+ events: readonly VnextRunEvent[],
415
+ foldedStatus: string,
416
+ binding: { runId: string; driveId: string },
417
+ ): { verified: boolean; divergence?: string } {
418
+ const reasons: string[] = [];
419
+ if (receipt.runId !== binding.runId) {
420
+ reasons.push(`runId ${receipt.runId} != binding ${binding.runId}`);
421
+ }
422
+ if (receipt.driveId !== binding.driveId) {
423
+ reasons.push(`driveId ${receipt.driveId} != binding ${binding.driveId}`);
424
+ }
425
+ const last = events[events.length - 1];
426
+ const currentLast = last?.sequence ?? 0;
427
+ if (receipt.lastSequence !== currentLast) {
428
+ reasons.push(`lastSequence ${receipt.lastSequence} != log ${currentLast}`);
429
+ }
430
+ const prefix = events.filter((event) => event.sequence >= 1 && event.sequence <= receipt.lastSequence);
431
+ if (hashVnextDriveLog(prefix) !== receipt.logHash) {
432
+ reasons.push("logHash mismatch");
433
+ }
434
+ if (receipt.settlement.status !== foldedStatus) {
435
+ reasons.push(`settlement.status ${receipt.settlement.status} != folded ${foldedStatus}`);
436
+ }
437
+ if (reasons.length === 0) return { verified: true };
438
+ return { verified: false, divergence: reasons.join("; ") };
439
+ }
440
+
367
441
  export interface VnextAttemptCapabilityRow {
368
442
  attemptId: string;
369
443
  runId: string;
@@ -472,7 +546,9 @@ export interface VnextCommandRecord {
472
546
  recordedAt: string;
473
547
  }
474
548
 
475
- export const VNEXT_EVENT_STORE_SCHEMA_VERSION = 3;
549
+ export const VNEXT_EVENT_STORE_SCHEMA_VERSION = 4;
550
+ export const VNEXT_DRIVE_RECEIPT_SCHEMA = "kxm.drive-receipt.v1";
551
+ export const DRIVE_RECEIPT_MAX_BYTES = 8 * 1024;
476
552
 
477
553
  const EVENT_STORE_TABLES = {
478
554
  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 +573,7 @@ const EVENT_STORE_TABLES = {
497
573
  "evidence_id", "attempt_id", "observation_id", "run_id", "project_id", "home_runtime_id", "step_id", "step_attempt",
498
574
  "assignment_id", "effect_id", "evidence_key", "kind", "expect", "outcome", "settled_event_id", "content_hash",
499
575
  ],
576
+ drive_receipts: ["drive_id", "run_id", "project_id", "opened_sequence", "last_sequence", "closed_at", "schema", "receipt"],
500
577
  } as const;
501
578
 
502
579
  const EVENT_STORE_SCHEMA = `
@@ -639,8 +716,43 @@ CREATE TABLE gate_evidence (
639
716
  ) STRICT;
640
717
  CREATE UNIQUE INDEX gate_evidence_key ON gate_evidence(run_id, step_id, step_attempt, evidence_key) WHERE evidence_key IS NOT NULL;
641
718
  CREATE INDEX gate_evidence_run ON gate_evidence(run_id);
719
+ CREATE TABLE drive_receipts (
720
+ drive_id TEXT PRIMARY KEY,
721
+ run_id TEXT NOT NULL,
722
+ project_id TEXT NOT NULL,
723
+ opened_sequence INTEGER NOT NULL,
724
+ last_sequence INTEGER NOT NULL,
725
+ closed_at TEXT NOT NULL,
726
+ schema TEXT NOT NULL,
727
+ receipt TEXT NOT NULL
728
+ ) STRICT;
729
+ CREATE INDEX drive_receipts_run ON drive_receipts(run_id);
642
730
  `;
643
731
 
732
+ const DRIVE_RECEIPTS_DDL = `
733
+ CREATE TABLE IF NOT EXISTS drive_receipts (
734
+ drive_id TEXT PRIMARY KEY,
735
+ run_id TEXT NOT NULL,
736
+ project_id TEXT NOT NULL,
737
+ opened_sequence INTEGER NOT NULL,
738
+ last_sequence INTEGER NOT NULL,
739
+ closed_at TEXT NOT NULL,
740
+ schema TEXT NOT NULL,
741
+ receipt TEXT NOT NULL
742
+ ) STRICT;
743
+ CREATE INDEX IF NOT EXISTS drive_receipts_run ON drive_receipts(run_id);
744
+ `;
745
+
746
+ const EVENT_STORE_MIGRATIONS = [
747
+ {
748
+ fromVersion: 3,
749
+ toVersion: 4,
750
+ migrate(database: DatabaseSync): void {
751
+ database.exec(DRIVE_RECEIPTS_DDL);
752
+ },
753
+ },
754
+ ];
755
+
644
756
  export class VnextRunEventStore {
645
757
  readonly path: string;
646
758
  private readonly database: DatabaseSync;
@@ -651,6 +763,7 @@ export class VnextRunEventStore {
651
763
  schema: EVENT_STORE_SCHEMA,
652
764
  version: VNEXT_EVENT_STORE_SCHEMA_VERSION,
653
765
  tables: EVENT_STORE_TABLES,
766
+ migrations: EVENT_STORE_MIGRATIONS,
654
767
  });
655
768
  }
656
769
 
@@ -871,6 +984,43 @@ export class VnextRunEventStore {
871
984
  `).run(row.runId, row.lastSequence, row.state);
872
985
  }
873
986
 
987
+ insertDriveReceipt(receipt: VnextDriveReceipt): { inserted: boolean } {
988
+ validateDriveReceipt(receipt, "drive-receipt");
989
+ const canonical = vnextCanonicalJson(receipt as unknown as JsonValue);
990
+ if (Buffer.byteLength(canonical, "utf8") > DRIVE_RECEIPT_MAX_BYTES) {
991
+ throw runtimeError("drive_receipt_invalid", receipt.driveId, `drive receipt exceeds ${DRIVE_RECEIPT_MAX_BYTES} bytes`);
992
+ }
993
+ const result = this.database.prepare(`
994
+ INSERT INTO drive_receipts (drive_id, run_id, project_id, opened_sequence, last_sequence, closed_at, schema, receipt)
995
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
996
+ ON CONFLICT(drive_id) DO NOTHING
997
+ `).run(
998
+ receipt.driveId,
999
+ receipt.runId,
1000
+ receipt.projectId,
1001
+ receipt.openedSequence,
1002
+ receipt.lastSequence,
1003
+ receipt.closedAt,
1004
+ receipt.schema,
1005
+ canonical,
1006
+ );
1007
+ return { inserted: Number(result.changes) === 1 };
1008
+ }
1009
+
1010
+ driveReceipt(driveId: string): VnextDriveReceipt | undefined {
1011
+ const row = this.database.prepare("SELECT receipt FROM drive_receipts WHERE drive_id = ?").get(driveId) as
1012
+ | { receipt: string }
1013
+ | undefined;
1014
+ return row ? parseDriveReceipt(row.receipt) : undefined;
1015
+ }
1016
+
1017
+ driveReceiptsForRun(runId: string, limit = 20): VnextDriveReceipt[] {
1018
+ const rows = this.database.prepare(`
1019
+ SELECT receipt FROM drive_receipts WHERE run_id = ? ORDER BY closed_at DESC, drive_id DESC LIMIT ?
1020
+ `).all(runId, limit) as Array<{ receipt: string }>;
1021
+ return rows.map((row) => parseDriveReceipt(row.receipt));
1022
+ }
1023
+
874
1024
  insertCapability(row: VnextAttemptCapabilityRow): void {
875
1025
  this.database.prepare(`
876
1026
  INSERT INTO attempt_capabilities (attempt_id, run_id, assignment_id, step_id, step_attempt, producer_id, capability_hash, state)
@@ -1075,6 +1225,12 @@ export class VnextRunEventStore {
1075
1225
  }
1076
1226
  }
1077
1227
 
1228
+ function parseDriveReceipt(raw: string): VnextDriveReceipt {
1229
+ const parsed = JSON.parse(raw) as unknown;
1230
+ validateDriveReceipt(parsed, "drive-receipt");
1231
+ return parsed as VnextDriveReceipt;
1232
+ }
1233
+
1078
1234
  function runFromRow(row: {
1079
1235
  run_id: string;
1080
1236
  project_id: string;