driftseal 2.1.0 → 3.0.0

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/bin/driftseal.js CHANGED
@@ -32,6 +32,15 @@ const { isDeepStrictEqual } = require('util');
32
32
  const { StringDecoder } = require('string_decoder');
33
33
  const { execFileSync, spawnSync } = require('child_process');
34
34
  const { version: PACKAGE_VERSION } = require('../package.json');
35
+ const { createOutcomeFold } = require('../lib/outcome-fold.js');
36
+ const {
37
+ OutcomeIndexError,
38
+ SqliteUnavailableError,
39
+ openOutcomeIndex,
40
+ removeIndexFiles,
41
+ temporaryIndexPath,
42
+ } = require('../lib/outcome-index-sqlite.js');
43
+ const { assertSupportedNode } = require('../lib/sqlite-runtime.js');
35
44
 
36
45
  const END_STATUSES = ['completed', 'partial', 'failed', 'abandoned'];
37
46
  const DECISION_STATUSES = [
@@ -52,10 +61,8 @@ const DEFAULT_LANE = 'main';
52
61
  const LANE_NAME_RE = /^[a-z][a-z0-9-]{0,62}$/;
53
62
  const IN_PROGRESS_GIT_PATH = 'driftseal-v2-in-progress.jsonl';
54
63
  const CURRENT_LANE_GIT_PATH = 'driftseal-v2-current-lane';
55
- const LANE_INDEX_GIT_PATH = 'driftseal-v2-lane-index.json';
56
- const LANE_INDEX_VERSION = 1;
57
- const LANE_INDEX_PREFIX_BYTES = 8192;
58
- const LANE_INDEX_TAIL_BYTES = 64 * 1024;
64
+ const LANE_INDEX_GIT_PATH = 'driftseal-v3-outcome-index.sqlite';
65
+ const LEGACY_LANE_INDEX_GIT_PATH = 'driftseal-v2-lane-index.json';
59
66
  const LOCK_STALE_MS = 30 * 60 * 1000;
60
67
  const LOCK_INIT_STALE_MS = 5 * 1000;
61
68
  const READ_ONLY_NOTICE = '(read-only: another mutation holds the lock; tail repair skipped)';
@@ -65,6 +72,12 @@ const VERIFICATION_OUTPUT_CHUNK_BYTES = 64 * 1024;
65
72
  const CAPTURE_OUTPUT_EDGE_CHARACTERS = 32 * 1024;
66
73
  const CAPTURE_OUTPUT_OMISSION = '\n... [driftseal captured output truncated] ...\n';
67
74
  const LOCAL_OUTCOME_PROVENANCE_FILE = '.driftseal-local-outcome.json';
75
+ const outcomeFoldEngine = createOutcomeFold({
76
+ fail,
77
+ contentHash,
78
+ logVersion: LOG_VERSION,
79
+ defaultLane: DEFAULT_LANE,
80
+ });
68
81
 
69
82
  class DriftSealError extends Error {
70
83
  constructor(message) {
@@ -594,11 +607,16 @@ function currentLaneFile() {
594
607
 
595
608
  function laneIndexFile() {
596
609
  if (isParkableOutcomeLog()) return worktreeMetadataFile(LANE_INDEX_GIT_PATH);
610
+ return path.join(logDir(), '.outcome-index.sqlite');
611
+ }
612
+
613
+ function legacyLaneIndexFile() {
614
+ if (isParkableOutcomeLog()) return worktreeMetadataFile(LEGACY_LANE_INDEX_GIT_PATH);
597
615
  return path.join(logDir(), '.lane-index.json');
598
616
  }
599
617
 
600
618
  function emptyLaneCatalog() {
601
- return new Map([[DEFAULT_LANE, { name: DEFAULT_LANE, description: null, addedAt: null, head: null }]]);
619
+ return outcomeFoldEngine.emptyLaneCatalog();
602
620
  }
603
621
 
604
622
  function readCurrentLaneName() {
@@ -624,7 +642,15 @@ function ensureDerivedLaneSidecarIgnore() {
624
642
  const ignoreFile = path.join(logDir(), '.gitignore');
625
643
  let current = fs.existsSync(ignoreFile) ? fs.readFileSync(ignoreFile, 'utf8') : '';
626
644
  let next = current;
627
- for (const name of ['.current-lane', '.lane-index.json']) {
645
+ for (const name of [
646
+ '.current-lane',
647
+ '.lane-index.json',
648
+ '.outcome-index.sqlite',
649
+ '.outcome-index.sqlite-journal',
650
+ '.outcome-index.sqlite-wal',
651
+ '.outcome-index.sqlite-shm',
652
+ '..outcome-index.sqlite.*.tmp',
653
+ ]) {
628
654
  const present = next.split(/\r?\n/).some((line) => line.trim() === name);
629
655
  if (present) continue;
630
656
  if (next && !next.endsWith('\n')) next += '\n';
@@ -635,354 +661,83 @@ function ensureDerivedLaneSidecarIgnore() {
635
661
  atomicWriteFile(ignoreFile, next, 0o644);
636
662
  }
637
663
 
638
- function defaultLaneCatalogObject() {
639
- return { [DEFAULT_LANE]: { name: DEFAULT_LANE, description: null, addedAt: null, head: null } };
640
- }
641
-
642
- function emptyLaneIndexState() {
643
- return {
644
- indexVersion: LANE_INDEX_VERSION,
645
- source: {
646
- indexedThrough: 0,
647
- indexedLines: 0,
648
- prefixHash: contentHash(''),
649
- tailHash: contentHash(''),
650
- },
651
- lastBuild: 'full',
652
- lanes: emptyLaneCatalog(),
653
- order: [],
654
- records: new Map(),
655
- ranges: new Map(),
656
- reconciliations: new Map(),
657
- };
658
- }
659
-
660
- function hashFileRange(file, start, length) {
661
- if (length <= 0) return contentHash('');
664
+ function hashFilePrefix(file, length) {
665
+ const hash = crypto.createHash('sha256');
666
+ if (length <= 0) return hash.digest('hex');
662
667
  const fd = fs.openSync(file, 'r');
663
668
  try {
664
- const buf = Buffer.alloc(length);
665
- const read = fs.readSync(fd, buf, 0, length, start);
666
- return crypto.createHash('sha256').update(buf.subarray(0, read)).digest('hex');
669
+ const buffer = Buffer.alloc(Math.min(1024 * 1024, length));
670
+ let position = 0;
671
+ while (position < length) {
672
+ const requested = Math.min(buffer.length, length - position);
673
+ const read = fs.readSync(fd, buffer, 0, requested, position);
674
+ if (read === 0) break;
675
+ hash.update(buffer.subarray(0, read));
676
+ position += read;
677
+ }
678
+ if (position !== length) return null;
679
+ return hash.digest('hex');
667
680
  } finally {
668
681
  fs.closeSync(fd);
669
682
  }
670
683
  }
671
684
 
672
685
  function laneIndexSourceIdentity(file, indexedThrough, indexedLines = 0) {
673
- if (!fs.existsSync(file) || indexedThrough <= 0) {
686
+ if (!fs.existsSync(file)) {
674
687
  return {
675
688
  indexedThrough: 0,
676
689
  indexedLines: 0,
677
- prefixHash: contentHash(''),
678
- tailHash: contentHash(''),
690
+ walHash: contentHash(''),
691
+ device: null,
692
+ inode: null,
693
+ mtimeMs: null,
694
+ ctimeMs: null,
679
695
  };
680
696
  }
681
- const prefix = Math.min(LANE_INDEX_PREFIX_BYTES, indexedThrough);
682
- const tail = Math.min(LANE_INDEX_TAIL_BYTES, indexedThrough);
697
+ const stat = fs.statSync(file);
683
698
  return {
684
699
  indexedThrough,
685
700
  indexedLines,
686
- prefixHash: hashFileRange(file, 0, prefix),
687
- tailHash: hashFileRange(file, indexedThrough - tail, tail),
701
+ walHash: hashFilePrefix(file, indexedThrough),
702
+ device: Number(stat.dev),
703
+ inode: Number(stat.ino),
704
+ mtimeMs: stat.mtimeMs,
705
+ ctimeMs: stat.ctimeMs,
688
706
  };
689
707
  }
690
708
 
691
- function laneIndexMatchesFile(index, file) {
692
- if (!index || index.indexVersion !== LANE_INDEX_VERSION) return false;
693
- if (!Number.isSafeInteger(index.source?.indexedLines) || index.source.indexedLines < 0) return false;
694
- if (!fs.existsSync(file)) return index.source.indexedThrough === 0;
695
- const size = fs.statSync(file).size;
696
- if (size < index.source.indexedThrough) return false;
697
- const identity = laneIndexSourceIdentity(file, index.source.indexedThrough);
698
- return (
699
- identity.prefixHash === index.source.prefixHash &&
700
- identity.tailHash === index.source.tailHash
701
- );
702
- }
703
-
704
- function serializeLaneIndex(state) {
705
- return {
706
- indexVersion: state.indexVersion,
707
- source: state.source,
708
- lastBuild: state.lastBuild,
709
- lanes: Object.fromEntries(
710
- [...state.lanes.entries()].map(([name, lane]) => [
711
- name,
712
- { name, description: lane.description || null, addedAt: lane.addedAt || null, head: lane.head || null, inferred: lane.inferred === true },
713
- ])
714
- ),
715
- order: [...state.order],
716
- records: Object.fromEntries(state.records),
717
- ranges: Object.fromEntries(state.ranges),
718
- reconciliations: Object.fromEntries(state.reconciliations),
719
- };
720
- }
721
-
722
- function deserializeLaneIndex(raw) {
723
- if (!raw || raw.indexVersion !== LANE_INDEX_VERSION || !raw.source || !raw.records) return null;
724
- const lanes = new Map();
725
- for (const [name, lane] of Object.entries(raw.lanes || defaultLaneCatalogObject())) {
726
- lanes.set(name, {
727
- name,
728
- description: lane.description || null,
729
- addedAt: lane.addedAt || null,
730
- head: lane.head || null,
731
- inferred: lane.inferred === true,
732
- });
733
- }
734
- if (!lanes.has(DEFAULT_LANE)) {
735
- lanes.set(DEFAULT_LANE, { name: DEFAULT_LANE, description: null, addedAt: null, head: null });
736
- }
737
- return {
738
- indexVersion: LANE_INDEX_VERSION,
739
- source: raw.source,
740
- lastBuild: raw.lastBuild || 'full',
741
- lanes,
742
- order: Array.isArray(raw.order) ? [...raw.order] : [],
743
- records: new Map(Object.entries(raw.records)),
744
- ranges: new Map(Object.entries(raw.ranges || {})),
745
- reconciliations: new Map(Object.entries(raw.reconciliations || {})),
746
- };
747
- }
748
-
749
- function cloneLaneIndexState(state) {
750
- return deserializeLaneIndex(serializeLaneIndex(state));
751
- }
752
-
753
- function linkLaneIndex(state) {
754
- const heads = new Map();
755
- for (const id of state.order) {
756
- const rec = state.records.get(id);
757
- rec.previous = heads.get(rec.lane) || null;
758
- heads.set(rec.lane, id);
709
+ function laneIndexMatchesFile(source, file, { exact = false } = {}) {
710
+ if (
711
+ !source ||
712
+ typeof source.walHash !== 'string' ||
713
+ !Number.isSafeInteger(source.indexedLines) ||
714
+ source.indexedLines < 0
715
+ ) {
716
+ return false;
759
717
  }
760
- for (const [name, lane] of state.lanes) {
761
- lane.head = heads.get(name) || null;
718
+ if (!fs.existsSync(file)) return source.indexedThrough === 0;
719
+ const stat = fs.statSync(file);
720
+ const size = stat.size;
721
+ if (size < source.indexedThrough || (exact && size !== source.indexedThrough)) return false;
722
+ if (
723
+ source.device !== null &&
724
+ source.inode !== null &&
725
+ (Number(stat.dev) !== source.device || Number(stat.ino) !== source.inode)
726
+ ) {
727
+ return false;
762
728
  }
763
- }
764
-
765
- function applyIndexedEvent(state, ev, startByte, endByte) {
766
- if (ev.type === 'begin' || ev.type === 'import') {
767
- state.ranges.set(ev.id, { firstByte: startByte, lastByte: endByte });
768
- } else if (state.records.has(ev.id) || state.ranges.has(ev.id)) {
769
- const range = state.ranges.get(ev.id) || { firstByte: startByte, lastByte: endByte };
770
- range.lastByte = endByte;
771
- state.ranges.set(ev.id, range);
729
+ if (
730
+ size === source.indexedThrough &&
731
+ stat.mtimeMs === source.mtimeMs &&
732
+ stat.ctimeMs === source.ctimeMs
733
+ ) {
734
+ return true;
772
735
  }
773
- applyFoldEvent(state, ev);
736
+ return hashFilePrefix(file, source.indexedThrough) === source.walHash;
774
737
  }
775
738
 
776
739
  function applyFoldEvent(state, ev) {
777
- const { records, reconciliations, order, lanes } = state;
778
- const ensureLane = (name) => {
779
- if (lanes.has(name)) return;
780
- lanes.set(name, { name, description: null, addedAt: null, head: null, inferred: true });
781
- };
782
- if (ev.type === 'begin') {
783
- if (records.has(ev.id)) fail(`duplicate begin event for outcome id: ${ev.id}`);
784
- const record = newOutcomeRecord(ev);
785
- ensureLane(record.lane);
786
- records.set(ev.id, record);
787
- order.push(ev.id);
788
- return;
789
- }
790
- if (ev.type === 'import') {
791
- if (records.has(ev.id)) fail(`duplicate imported outcome id: ${ev.id}`);
792
- const record = newOutcomeRecord({
793
- ...ev,
794
- ts: ev.beganAt,
795
- acceptance: [],
796
- verify: null,
797
- });
798
- ensureLane(record.lane);
799
- record.status = ev.status;
800
- record.tsEnd = ev.endedAt;
801
- record.note = ev.summary || null;
802
- record.reclaimed = ev.reclaimed === true;
803
- record.reclaimReason = ev.reclaimReason || null;
804
- record.reclaimedAt = ev.reclaimedAt || null;
805
- record.imported = {
806
- sourceIds: ev.sources.map((source) => source.id),
807
- sourceFingerprint: ev.sourceFingerprint,
808
- sources: ev.sources,
809
- };
810
- records.set(ev.id, record);
811
- order.push(ev.id);
812
- return;
813
- }
814
- if (ev.type === 'migration') return;
815
- if (ev.type === 'lane_add') {
816
- const existing = lanes.get(ev.lane);
817
- if (existing && !existing.inferred) {
818
- if (ev.description) existing.description = ev.description;
819
- return;
820
- }
821
- lanes.set(ev.lane, {
822
- name: ev.lane,
823
- description: ev.description || null,
824
- addedAt: ev.ts,
825
- head: existing ? existing.head : null,
826
- inferred: false,
827
- });
828
- return;
829
- }
830
- if (ev.type === 'lane_assign') {
831
- const rec = records.get(ev.id);
832
- if (!rec) fail(`lane assign references unknown outcome id: ${ev.id}`);
833
- if (rec.status === 'in_progress') fail(`cannot assign lane of in_progress outcome ${ev.id}`);
834
- ensureLane(ev.lane);
835
- rec.lane = ev.lane;
836
- return;
837
- }
838
- if (ev.type === 'extend') {
839
- const rec = records.get(ev.id);
840
- if (!rec) fail(`extension references unknown outcome id: ${ev.id}`);
841
- if (rec.status !== 'in_progress') fail(`extension occurred after outcome ${ev.id} was closed`);
842
- rec.extensions.push({
843
- extension: ev.extension,
844
- acceptance: ev.acceptance,
845
- verify: ev.verify,
846
- decisions: ev.decisions,
847
- extendedAt: ev.ts,
848
- head: ev.head || null,
849
- });
850
- rec.acceptance = [...new Set([...rec.acceptance, ...ev.acceptance])];
851
- if (ev.verify) rec.verify = ev.verify;
852
- rec.decisions = [...new Set([...rec.decisions, ...ev.decisions])];
853
- rec.contractHash = outcomeContractHash(rec);
854
- rec.verification = null;
855
- rec.decisionUpdates = [];
856
- return;
857
- }
858
- if (ev.type === 'verify') {
859
- const rec = records.get(ev.id);
860
- if (!rec) fail(`verification event references unknown outcome id: ${ev.id}`);
861
- if (rec.status !== 'in_progress') fail(`verification occurred after outcome ${ev.id} was closed`);
862
- if (rec.acceptance.length === 0 || !rec.verify) {
863
- fail(`verification event references outcome ${ev.id} without acceptance criteria`);
864
- }
865
- if (ev.command !== rec.verify) fail(`verification command does not match outcome ${ev.id}`);
866
- if (rec.logVersion === LOG_VERSION && ev.contractHash !== rec.contractHash) {
867
- fail(`verification contract does not match outcome ${ev.id}`);
868
- }
869
- rec.verificationAttempts.push(ev);
870
- rec.verification = ev;
871
- return;
872
- }
873
- if (ev.type === 'reclaim' || ev.type === 'unreclaim') {
874
- const rec = records.get(ev.id);
875
- if (!rec) fail(`${ev.type} event references unknown outcome id: ${ev.id}`);
876
- if (ev.type === 'reclaim') {
877
- if (rec.status === 'in_progress') fail(`cannot reclaim outcome ${ev.id} while it is in_progress`);
878
- if (rec.reclaimed) fail(`duplicate reclaim event for outcome id: ${ev.id}`);
879
- rec.reclaimed = true;
880
- rec.reclaimReason = ev.reason;
881
- rec.reclaimedAt = ev.ts;
882
- } else {
883
- if (!rec.reclaimed) fail(`unreclaim event for outcome id that is not reclaimed: ${ev.id}`);
884
- rec.reclaimed = false;
885
- rec.reclaimReason = null;
886
- rec.reclaimedAt = null;
887
- }
888
- return;
889
- }
890
- if (ev.type === 'end') {
891
- const rec = records.get(ev.id);
892
- if (!rec) fail(`end event references unknown outcome id: ${ev.id}`);
893
- if (rec.status !== 'in_progress') fail(`duplicate end event for outcome id: ${ev.id}`);
894
- const conflictingCancellation = rec.decisionTerminals.find(
895
- (terminal) => terminal.type === 'decision_reconcile_cancel' && terminal.outcomeStatus !== ev.status
896
- );
897
- if (conflictingCancellation) {
898
- fail(`outcome ${ev.id} was closed as ${ev.status} after reconciliation recovery was cancelled for ${conflictingCancellation.outcomeStatus}`);
899
- }
900
- if (
901
- ['completed', 'partial'].includes(ev.status) &&
902
- rec.decisions.length > 0 &&
903
- ((rec.logVersion === 1 && rec.schemaVersion >= 2 && (ev.schemaVersion || 1) < 2) ||
904
- rec.decisions.some((decisionId) => qualifyingDecisionUpdates(rec, decisionId).length === 0))
905
- ) {
906
- fail(`linked outcome ${ev.id} was closed without reconciling every declared decision`);
907
- }
908
- if (ev.status === 'completed' && rec.acceptance.length > 0) {
909
- if (!rec.verification || !rec.verification.passed) {
910
- fail(`acceptance-bound outcome ${ev.id} was completed without successful machine verification`);
911
- }
912
- if (
913
- (rec.logVersion === 1 && (ev.schemaVersion || 1) < 4) ||
914
- ev.verificationId !== rec.verification.verificationId ||
915
- (ev.workspace ?? null) !== rec.verification.workspace ||
916
- (rec.logVersion === LOG_VERSION &&
917
- (ev.contractHash !== rec.contractHash || rec.verification.contractHash !== rec.contractHash))
918
- ) {
919
- fail(`acceptance-bound outcome ${ev.id} was completed with stale machine verification`);
920
- }
921
- }
922
- rec.status = ev.status;
923
- rec.tsEnd = ev.ts;
924
- rec.note = ev.note || null;
925
- rec.verifyResult = ev.verifyResult || null;
926
- rec.endHead = ev.head || null;
927
- return;
928
- }
929
- if (ev.type === 'decision_reconcile_prepare') {
930
- const rec = records.get(ev.id);
931
- if (!rec) fail(`decision reconciliation references unknown outcome id: ${ev.id}`);
932
- if (rec.status !== 'in_progress') fail(`decision reconciliation occurred after outcome ${ev.id} was closed`);
933
- if (!rec.decisions.includes(ev.decisionId)) fail(`decision reconciliation references unlinked decision ${ev.decisionId}`);
934
- if (reconciliations.has(ev.reconciliationId)) fail(`duplicate reconciliation id: ${ev.reconciliationId}`);
935
- rec.decisionPrepares.push(ev);
936
- reconciliations.set(ev.reconciliationId, {
937
- prepare: ev,
938
- terminal: null,
939
- contractHash: rec.contractHash,
940
- });
941
- return;
942
- }
943
- if (ev.type === 'decision_reconcile') {
944
- const rec = records.get(ev.id);
945
- if (!rec) fail(`decision reconciliation references unknown outcome id: ${ev.id}`);
946
- if (rec.status !== 'in_progress') fail(`decision reconciliation occurred after outcome ${ev.id} was closed`);
947
- if (rec.logVersion === 1 && rec.schemaVersion >= 2) {
948
- fail(`linked legacy schema-v2 outcome ${rec.id} contains a legacy decision reconciliation`);
949
- }
950
- rec.decisionUpdates.push(ev);
951
- return;
952
- }
953
- if (
954
- ev.type === 'decision_reconcile_commit' ||
955
- ev.type === 'decision_reconcile_abort' ||
956
- ev.type === 'decision_reconcile_cancel'
957
- ) {
958
- const rec = records.get(ev.id);
959
- const reconciliation = reconciliations.get(ev.reconciliationId);
960
- if (rec && rec.status !== 'in_progress') fail(`decision reconciliation occurred after outcome ${ev.id} was closed`);
961
- if (!rec || !reconciliation || reconciliation.prepare.id !== ev.id || reconciliation.prepare.decisionId !== ev.decisionId) {
962
- fail(`decision reconciliation terminal has no matching prepare: ${ev.reconciliationId}`);
963
- }
964
- if (reconciliation.terminal) fail(`decision reconciliation already has a terminal event: ${ev.reconciliationId}`);
965
- const priorCancellation = rec.decisionTerminals.find((terminal) => terminal.type === 'decision_reconcile_cancel');
966
- if (ev.type === 'decision_reconcile_cancel' && priorCancellation && priorCancellation.outcomeStatus !== ev.outcomeStatus) {
967
- fail(`outcome ${ev.id} has conflicting reconciliation cancellation statuses`);
968
- }
969
- if (
970
- ev.type === 'decision_reconcile_commit' &&
971
- (reconciliation.prepare.newHash !== ev.fileHash ||
972
- reconciliation.prepare.fromStatus !== ev.fromStatus ||
973
- reconciliation.prepare.toStatus !== ev.toStatus)
974
- ) {
975
- fail(`decision reconciliation commit does not match prepare: ${ev.reconciliationId}`);
976
- }
977
- reconciliation.terminal = ev;
978
- rec.decisionTerminals.push(ev);
979
- if (
980
- ev.type === 'decision_reconcile_commit' &&
981
- reconciliation.contractHash === rec.contractHash
982
- ) {
983
- rec.decisionUpdates.push(ev);
984
- }
985
- }
740
+ return outcomeFoldEngine.applyFoldEvent(state, ev);
986
741
  }
987
742
 
988
743
  function consumeLogSlice(file, startByte, onEvent, { repairTail = false, readOnly = false, startLine = 0 } = {}) {
@@ -1039,111 +794,225 @@ function consumeLogSlice(file, startByte, onEvent, { repairTail = false, readOnl
1039
794
  }
1040
795
  onEvent(event, start, pos);
1041
796
  } catch (err) {
1042
- if (err instanceof DriftSealError) throw err;
797
+ if (err instanceof DriftSealError || err instanceof OutcomeIndexError) throw err;
1043
798
  fail(`corrupt log line ${lineNumber} in ${file}`);
1044
799
  }
1045
800
  }
1046
801
  return { endByte: pos, endLine: lineNumber };
1047
802
  }
1048
803
 
1049
- function persistLaneIndex(state, { readOnly = false } = {}) {
1050
- if (readOnly) return;
1051
- const file = laneIndexFile();
1052
- if (!file) return;
1053
- ensureDirectoryDurable(path.dirname(file));
1054
- ensureDerivedLaneSidecarIgnore();
1055
- atomicWriteFile(file, `${JSON.stringify(serializeLaneIndex(state))}\n`, 0o600);
804
+ function replaceOutcomeIndexFile(temporary, target) {
805
+ try {
806
+ fs.renameSync(temporary, target);
807
+ } catch (error) {
808
+ if (!['EEXIST', 'EPERM'].includes(error && error.code)) throw error;
809
+ removeIndexFiles(target);
810
+ fs.renameSync(temporary, target);
811
+ }
812
+ fs.chmodSync(target, 0o600);
813
+ for (const suffix of ['-journal', '-wal', '-shm']) {
814
+ fs.rmSync(`${target}${suffix}`, { force: true });
815
+ }
816
+ fsyncDirectory(path.dirname(target));
1056
817
  }
1057
818
 
1058
- function loadPersistedLaneIndex() {
1059
- const file = laneIndexFile();
1060
- if (!file || !fs.existsSync(file)) return null;
819
+ function removeLegacyLaneIndex() {
820
+ const legacy = legacyLaneIndexFile();
821
+ if (legacy) fs.rmSync(legacy, { force: true });
822
+ }
823
+
824
+ function rebuildCommittedOutcomeIndex({ repairTail = false } = {}) {
825
+ const wal = logFile();
826
+ const target = laneIndexFile();
827
+ if (!target) return null;
828
+ ensureDirectoryDurable(path.dirname(target));
829
+ ensureDerivedLaneSidecarIgnore();
830
+ const temporary = temporaryIndexPath(target);
831
+ removeIndexFiles(temporary);
832
+ let index;
1061
833
  try {
1062
- return deserializeLaneIndex(JSON.parse(fs.readFileSync(file, 'utf8')));
1063
- } catch {
1064
- return null;
834
+ index = openOutcomeIndex(temporary);
835
+ let slice;
836
+ index.transaction(() => {
837
+ const indexedEvents = [];
838
+ slice = consumeLogSlice(
839
+ wal,
840
+ 0,
841
+ (event, startByte, endByte) =>
842
+ indexedEvents.push({ event, startByte, endByte }),
843
+ { repairTail }
844
+ );
845
+ index.replaceFromFoldState(
846
+ outcomeFoldEngine.foldState(indexedEvents.map((item) => item.event)),
847
+ indexedEvents
848
+ );
849
+ index.setSource(
850
+ laneIndexSourceIdentity(wal, slice.endByte, slice.endLine),
851
+ 'full'
852
+ );
853
+ index.acceptProjection();
854
+ });
855
+ if (!index.integrityCheck()) fail('rebuilt SQLite outcome index failed integrity check');
856
+ index.close();
857
+ index = null;
858
+ replaceOutcomeIndexFile(temporary, target);
859
+ removeLegacyLaneIndex();
860
+ const reopened = openOutcomeIndex(target);
861
+ reopened.build = 'full';
862
+ return reopened;
863
+ } catch (error) {
864
+ if (index) index.close();
865
+ removeIndexFiles(temporary);
866
+ throw error;
1065
867
  }
1066
868
  }
1067
869
 
1068
- function syncCommittedLaneIndex({ repairTail = false, readOnly = false } = {}) {
1069
- const file = logFile();
1070
- let state = loadPersistedLaneIndex();
1071
- const canIncrement = state && laneIndexMatchesFile(state, file);
1072
- let indexedThrough = 0;
1073
- let indexedLines = 0;
1074
- if (!canIncrement) {
1075
- state = emptyLaneIndexState();
1076
- const slice = consumeLogSlice(file, 0, (event, start, end) => applyIndexedEvent(state, event, start, end), {
1077
- repairTail,
1078
- readOnly,
1079
- });
1080
- indexedThrough = slice.endByte;
1081
- indexedLines = slice.endLine;
1082
- state.lastBuild = 'full';
1083
- } else {
1084
- const size = fs.existsSync(file) ? fs.statSync(file).size : 0;
1085
- if (size > state.source.indexedThrough) {
1086
- const slice = consumeLogSlice(
1087
- file,
1088
- state.source.indexedThrough,
1089
- (event, start, end) => applyIndexedEvent(state, event, start, end),
1090
- { repairTail, readOnly, startLine: state.source.indexedLines || 0 }
870
+ function syncCommittedLaneIndex({ repairTail = false, readOnly = false, forceFull = false } = {}) {
871
+ if (process.env._DRIFTSEAL_TEST_DISABLE_OUTCOME_INDEX === '1') return null;
872
+ const wal = logFile();
873
+ const target = laneIndexFile();
874
+ if (!target) return null;
875
+ if (readOnly) {
876
+ if (!fs.existsSync(target)) return null;
877
+ try {
878
+ const index = openOutcomeIndex(target, { readOnly: true });
879
+ if (
880
+ !index.projectionTrusted() ||
881
+ !laneIndexMatchesFile(index.source(), wal, { exact: true })
882
+ ) {
883
+ index.close();
884
+ return null;
885
+ }
886
+ index.build = 'hot';
887
+ return index;
888
+ } catch {
889
+ return null;
890
+ }
891
+ }
892
+ if (forceFull || !fs.existsSync(target)) {
893
+ try {
894
+ return rebuildCommittedOutcomeIndex({ repairTail });
895
+ } catch (error) {
896
+ if (error instanceof SqliteUnavailableError) return null;
897
+ throw error;
898
+ }
899
+ }
900
+ let index;
901
+ try {
902
+ index = openOutcomeIndex(target);
903
+ const source = index.source();
904
+ if (!index.projectionTrusted() || !laneIndexMatchesFile(source, wal)) {
905
+ index.close();
906
+ return rebuildCommittedOutcomeIndex({ repairTail });
907
+ }
908
+ const size = fs.existsSync(wal) ? fs.statSync(wal).size : 0;
909
+ if (size === source.indexedThrough) {
910
+ index.build = 'hot';
911
+ removeLegacyLaneIndex();
912
+ return index;
913
+ }
914
+ let slice;
915
+ index.transaction(() => {
916
+ slice = consumeLogSlice(
917
+ wal,
918
+ source.indexedThrough,
919
+ (event, start, end) =>
920
+ index.applyEvent(event, start, end, { applyFoldEvent, defaultLane: DEFAULT_LANE }),
921
+ {
922
+ repairTail,
923
+ startLine: source.indexedLines || 0,
924
+ }
1091
925
  );
1092
- indexedThrough = slice.endByte;
1093
- indexedLines = slice.endLine;
1094
- state.lastBuild = 'incremental';
1095
- } else {
1096
- indexedThrough = size;
1097
- indexedLines = state.source.indexedLines || 0;
1098
- state.lastBuild = 'hot';
926
+ index.setSource(
927
+ laneIndexSourceIdentity(wal, slice.endByte, slice.endLine),
928
+ 'incremental'
929
+ );
930
+ index.acceptProjection();
931
+ });
932
+ index.build = 'incremental';
933
+ removeLegacyLaneIndex();
934
+ return index;
935
+ } catch (error) {
936
+ if (index) index.close();
937
+ if (error instanceof DriftSealError) throw error;
938
+ if (error instanceof SqliteUnavailableError) return null;
939
+ return rebuildCommittedOutcomeIndex({ repairTail });
940
+ }
941
+ }
942
+
943
+ function attachIndexedOverlay(index, committed, plan, { readOnly = false } = {}) {
944
+ const lanes = index.laneCatalog();
945
+ if (!plan || plan.alreadyCommitted || plan.records.length === 0) {
946
+ committed.lanes = lanes;
947
+ return committed;
948
+ }
949
+ if (!readOnly && plan.mappings.length > 0) writeJsonl(plan.park, plan.records);
950
+ const overlay = fold(plan.records.map((record) => record.event));
951
+ for (const record of overlay) {
952
+ let lane = lanes.get(record.lane);
953
+ if (!lane) {
954
+ const foldedLane = overlay.lanes.get(record.lane);
955
+ lane = {
956
+ name: record.lane,
957
+ description: foldedLane ? foldedLane.description : null,
958
+ addedAt: foldedLane ? foldedLane.addedAt : null,
959
+ inferred: foldedLane ? foldedLane.inferred === true : true,
960
+ head: null,
961
+ count: 0,
962
+ visible: 0,
963
+ };
964
+ lanes.set(record.lane, lane);
1099
965
  }
966
+ lane.count = (lane.count || 0) + 1;
967
+ if (!record.reclaimed) lane.visible = (lane.visible || 0) + 1;
1100
968
  }
1101
- state.source = laneIndexSourceIdentity(file, indexedThrough, indexedLines);
1102
- linkLaneIndex(state);
1103
- if (state.lastBuild !== 'hot') persistLaneIndex(state, { readOnly });
1104
- return state;
969
+ const records = [...committed, ...overlay];
970
+ records.lanes = lanes;
971
+ return records;
1105
972
  }
1106
973
 
1107
- function applyOverlayToLaneIndex(state, overlayEvents) {
1108
- if (!overlayEvents || overlayEvents.length === 0) return state;
1109
- const next = cloneLaneIndexState(state);
1110
- for (const event of overlayEvents) applyFoldEvent(next, event);
1111
- linkLaneIndex(next);
1112
- return next;
974
+ function queryOutcomeView(index, park, { repairTail = false, readOnly = false } = {}) {
975
+ const committed = index.queryAll();
976
+ if (!park || !fs.existsSync(park)) {
977
+ committed.lanes = index.laneCatalog();
978
+ return { index: null, records: committed };
979
+ }
980
+ const plan = planIndexedInProgressOverlay(index, park, { repairTail, readOnly });
981
+ if (plan && plan.alreadyCommitted && !readOnly) discardInProgressLog(park);
982
+ return {
983
+ index: null,
984
+ records: attachIndexedOverlay(index, committed, plan, { readOnly }),
985
+ };
1113
986
  }
1114
987
 
1115
- function foldedRecordsFromLaneIndex(state) {
1116
- const records = state.order.map((id) => state.records.get(id)).filter(Boolean);
1117
- records.lanes = state.lanes;
1118
- return records;
988
+ function recoverableOutcomeIndexError(error) {
989
+ return (
990
+ error instanceof OutcomeIndexError ||
991
+ /^SQLITE_|^ERR_SQLITE_/.test(String(error && error.code))
992
+ );
1119
993
  }
1120
994
 
1121
995
  function loadOutcomeView({ repairTail = false, readOnly = false } = {}) {
1122
- const committed = syncCommittedLaneIndex({ repairTail, readOnly });
1123
996
  const park = inProgressFile();
1124
- if (!park || !fs.existsSync(park)) {
1125
- return { state: committed, records: foldedRecordsFromLaneIndex(committed) };
997
+ let index = syncCommittedLaneIndex({ repairTail, readOnly });
998
+ if (!index) {
999
+ const records = fold(readEvents({ repairTail, readOnly }));
1000
+ return { index: null, records };
1126
1001
  }
1127
1002
  try {
1128
- const committedEvents = readJsonlRecordsFromFile(logFile(), { repairTail, readOnly }).map(
1129
- (record) => record.event
1130
- );
1131
- const overlay = planInProgressOverlay(committedEvents, park, { repairTail, readOnly });
1132
- if (!overlay || overlay.alreadyCommitted) {
1133
- if (overlay && overlay.alreadyCommitted && !readOnly) discardInProgressLog(park);
1134
- return { state: committed, records: foldedRecordsFromLaneIndex(committed) };
1135
- }
1136
- if (!readOnly && overlay.mappings.length > 0) writeJsonl(park, overlay.records);
1137
- const state = applyOverlayToLaneIndex(
1138
- committed,
1139
- overlay.records.map((record) => record.event)
1140
- );
1141
- return { state, records: foldedRecordsFromLaneIndex(state) };
1142
- } catch (err) {
1143
- if (readOnly && err && err.code === 'ENOENT') {
1144
- return { state: committed, records: foldedRecordsFromLaneIndex(committed) };
1145
- }
1146
- throw err;
1003
+ return queryOutcomeView(index, park, { repairTail, readOnly });
1004
+ } catch (error) {
1005
+ index.close();
1006
+ index = null;
1007
+ if (readOnly && (error.code === 'ENOENT' || recoverableOutcomeIndexError(error))) {
1008
+ return { index: null, records: fold(readEvents({ repairTail, readOnly })) };
1009
+ }
1010
+ if (!recoverableOutcomeIndexError(error)) throw error;
1011
+ index = syncCommittedLaneIndex({ repairTail, forceFull: true });
1012
+ if (!index) return { index: null, records: fold(readEvents({ repairTail })) };
1013
+ return queryOutcomeView(index, park, { repairTail });
1014
+ } finally {
1015
+ if (index) index.close();
1147
1016
  }
1148
1017
  }
1149
1018
 
@@ -1213,6 +1082,53 @@ function selectLastLogRecords(records, current, n) {
1213
1082
  return [...clipped, ...extras].sort((left, right) => order.get(left.id) - order.get(right.id));
1214
1083
  }
1215
1084
 
1085
+ function indexedLaneSummary(state, name) {
1086
+ const lane = state.lanes.get(name);
1087
+ return {
1088
+ name,
1089
+ description: lane ? lane.description : null,
1090
+ addedAt: lane ? lane.addedAt : null,
1091
+ inferred: Boolean(lane && lane.inferred),
1092
+ visible: lane ? lane.visible : 0,
1093
+ count: lane ? lane.count : 0,
1094
+ };
1095
+ }
1096
+
1097
+ function tryLoadRecentOutcomeView(n, { includeReclaimed = false, repairTail = false, readOnly = false } = {}) {
1098
+ const park = inProgressFile();
1099
+ let index = syncCommittedLaneIndex({ repairTail, readOnly });
1100
+ if (!index) return null;
1101
+ const query = () => {
1102
+ const plan =
1103
+ park && fs.existsSync(park)
1104
+ ? planIndexedInProgressOverlay(index, park, { repairTail, readOnly })
1105
+ : null;
1106
+ if (plan && plan.alreadyCommitted && !readOnly) discardInProgressLog(park);
1107
+ const overlayView = attachIndexedOverlay(index, [], plan, { readOnly });
1108
+ const state = { lanes: overlayView.lanes };
1109
+ const { current, missing } = resolveCurrentLane(state);
1110
+ const committed = index.queryRecent(current, n, { includeReclaimed });
1111
+ const records = selectLastLogRecords(
1112
+ [...committed, ...overlayView],
1113
+ current,
1114
+ n
1115
+ );
1116
+ return { state, records, current, missing };
1117
+ };
1118
+ try {
1119
+ return query();
1120
+ } catch (error) {
1121
+ if (readOnly && error && error.code === 'ENOENT') return null;
1122
+ index.close();
1123
+ index = null;
1124
+ if (readOnly) return null;
1125
+ index = syncCommittedLaneIndex({ repairTail, forceFull: true });
1126
+ return query();
1127
+ } finally {
1128
+ if (index) index.close();
1129
+ }
1130
+ }
1131
+
1216
1132
  function liveWorktreeOutcomeLog() {
1217
1133
  const root = gitWorktreeRoot();
1218
1134
  if (!root) return null;
@@ -1294,6 +1210,27 @@ function planInProgressOverlay(committedEvents, park, { repairTail = false, read
1294
1210
  return { park, records: remapped.records, mappings: remapped.mappings, alreadyCommitted: false };
1295
1211
  }
1296
1212
 
1213
+ function planIndexedInProgressOverlay(index, park, { repairTail = false, readOnly = false } = {}) {
1214
+ if (!park || !fs.existsSync(park)) return null;
1215
+ const overlayRecords = readJsonlRecordsFromFile(park, { repairTail, readOnly });
1216
+ const overlayEvents = overlayRecords.map((record) => record.event);
1217
+ if (overlayEvents.length === 0 || index.containsEventSequence(overlayEvents)) {
1218
+ return { park, records: [], mappings: [], alreadyCommitted: true };
1219
+ }
1220
+ const remapped = remapTheirsRecords(
1221
+ overlayRecords,
1222
+ index.outcomeStartEvents(),
1223
+ new Map(),
1224
+ new Map()
1225
+ );
1226
+ return {
1227
+ park,
1228
+ records: remapped.records,
1229
+ mappings: remapped.mappings,
1230
+ alreadyCommitted: false,
1231
+ };
1232
+ }
1233
+
1297
1234
  function discardInProgressLog(park) {
1298
1235
  fs.unlinkSync(park);
1299
1236
  fsyncDirectory(path.dirname(park));
@@ -1867,77 +1804,20 @@ function withMutationLocks(resources, action, { tryWaitMs } = {}) {
1867
1804
  }
1868
1805
 
1869
1806
  function outcomeContractHash(record) {
1870
- return contentHash(JSON.stringify({
1871
- outcome: record.outcome,
1872
- extensions: record.extensions.map(({ extension, acceptance, verify, decisions }) => ({
1873
- extension,
1874
- acceptance,
1875
- verify,
1876
- decisions,
1877
- })),
1878
- acceptance: record.acceptance,
1879
- verify: record.verify,
1880
- decisions: record.decisions,
1881
- }));
1807
+ return outcomeFoldEngine.outcomeContractHash(record);
1882
1808
  }
1883
1809
 
1884
1810
  function newOutcomeRecord(ev) {
1885
- const record = {
1886
- id: ev.id,
1887
- tsBegin: ev.ts,
1888
- outcome: ev.outcome,
1889
- extensions: [],
1890
- acceptance: Array.isArray(ev.acceptance) ? ev.acceptance : [],
1891
- verify: ev.verify || null,
1892
- beginHead: ev.head || null,
1893
- decisions: Array.isArray(ev.decisions) ? ev.decisions : [],
1894
- logVersion: ev.logVersion || 1,
1895
- schemaVersion: ev.schemaVersion || 1,
1896
- lane: ev.lane || DEFAULT_LANE,
1897
- decisionPrepares: [],
1898
- decisionTerminals: [],
1899
- decisionUpdates: [],
1900
- verificationAttempts: [],
1901
- verification: null,
1902
- status: 'in_progress',
1903
- tsEnd: null,
1904
- note: null,
1905
- verifyResult: null,
1906
- endHead: null,
1907
- reclaimed: false,
1908
- reclaimReason: null,
1909
- reclaimedAt: null,
1910
- imported: null,
1911
- contractHash: null,
1912
- };
1913
- record.contractHash = outcomeContractHash(record);
1914
- return record;
1811
+ return outcomeFoldEngine.newOutcomeRecord(ev);
1915
1812
  }
1916
1813
 
1917
1814
  /** Fold the event stream into one record per outcome. Legacy v1 events are accepted for migration. */
1918
1815
  function fold(events) {
1919
- const state = {
1920
- records: new Map(),
1921
- reconciliations: new Map(),
1922
- order: [],
1923
- lanes: emptyLaneCatalog(),
1924
- };
1925
- for (const ev of events) applyFoldEvent(state, ev);
1926
- const folded = state.order.map((id) => state.records.get(id));
1927
- folded.lanes = state.lanes;
1928
- return folded;
1816
+ return outcomeFoldEngine.fold(events);
1929
1817
  }
1930
1818
 
1931
1819
  function qualifyingDecisionUpdates(record, decisionId) {
1932
- return record.decisionUpdates.filter((update) => {
1933
- if (update.decisionId !== decisionId) return false;
1934
- if (record.logVersion === 1 && record.schemaVersion < 2) return true;
1935
- return (
1936
- update.type === 'decision_reconcile_commit' &&
1937
- (update.logVersion === LOG_VERSION || (update.schemaVersion || 1) >= 2) &&
1938
- typeof update.fileHash === 'string'
1939
- );
1940
- });
1820
+ return outcomeFoldEngine.qualifyingDecisionUpdates(record, decisionId);
1941
1821
  }
1942
1822
 
1943
1823
  function openOutcome(records) {
@@ -6294,16 +6174,47 @@ const commands = {
6294
6174
  'all-lanes': 'boolean',
6295
6175
  }, 'log');
6296
6176
  if (positionals.length > 0) fail(usageFor('log'));
6177
+ const n = flags.last === undefined ? null : positiveInteger(flags.last, '--last');
6178
+ const allLanes = flags['all-lanes'] === true;
6179
+ const parkedV1 = legacyParkedIntent();
6180
+ if (
6181
+ n !== null &&
6182
+ !allLanes &&
6183
+ !parkedV1 &&
6184
+ process.env._DRIFTSEAL_TEST_DISABLE_RECENT_INDEX !== '1'
6185
+ ) {
6186
+ const recent = tryLoadRecentOutcomeView(n, {
6187
+ includeReclaimed: flags.all === true,
6188
+ repairTail: true,
6189
+ readOnly,
6190
+ });
6191
+ if (recent) {
6192
+ const { state, records, current, missing } = recent;
6193
+ warnMissingCurrentLane(missing);
6194
+ const customLanes = state.lanes.size > 1;
6195
+ if (customLanes || current !== DEFAULT_LANE || missing) {
6196
+ const summary = indexedLaneSummary(state, current);
6197
+ printLine(`lane: ${current} (${summary.visible} visible / ${summary.count} in lane)`);
6198
+ }
6199
+ if (records.length === 0) {
6200
+ printLine('log is empty');
6201
+ return [];
6202
+ }
6203
+ printLine(records.map((record) => render(record, { currentLane: current })).join('\n\n'));
6204
+ return records.map(publicOutcome);
6205
+ }
6206
+ }
6207
+ if (process.env._DRIFTSEAL_TEST_REQUIRE_RECENT_INDEX === '1' && n !== null && !allLanes) {
6208
+ fail('recent lane index fast path was not used');
6209
+ }
6297
6210
  const view = loadOutcomeView({ repairTail: true, readOnly });
6298
6211
  let records = view.records;
6299
- const parkedV1 = legacyParkedIntent();
6300
6212
  if (parkedV1 && !records.some((record) => record.id === parkedV1.id && record.status === 'in_progress')) {
6301
6213
  records = Object.assign([...records, parkedV1], { lanes: records.lanes });
6302
6214
  }
6303
6215
  const catalog = records.lanes || emptyLaneCatalog();
6304
6216
  const { current, missing } = resolveCurrentLane(view.records);
6305
6217
  warnMissingCurrentLane(missing);
6306
- const allLanes = flags['all-lanes'] === true;
6307
6218
  if (!allLanes) {
6308
6219
  records = Object.assign(selectLaneLogRecords(records, current), { lanes: catalog });
6309
6220
  }
@@ -6313,8 +6224,7 @@ const commands = {
6313
6224
  printLine(renderLaneLine(view.records, current));
6314
6225
  }
6315
6226
  let shown = visible;
6316
- if (flags.last) {
6317
- const n = positiveInteger(flags.last, '--last');
6227
+ if (n !== null) {
6318
6228
  shown = selectLastLogRecords(visible, current, n);
6319
6229
  }
6320
6230
  if (shown.length === 0) {
@@ -7035,9 +6945,52 @@ function repositoryOutcomeLogFiles() {
7035
6945
  return files;
7036
6946
  }
7037
6947
 
6948
+ function indexedMigrationEvent(file) {
6949
+ if (
6950
+ canonicalPath(file) !== canonicalPath(logFile()) ||
6951
+ !laneIndexFile() ||
6952
+ !fs.existsSync(laneIndexFile())
6953
+ ) {
6954
+ return { usable: false, migration: null };
6955
+ }
6956
+ let index;
6957
+ try {
6958
+ index = openOutcomeIndex(laneIndexFile(), { readOnly: true });
6959
+ const source = index.source();
6960
+ if (!laneIndexMatchesFile(source, file)) {
6961
+ return { usable: false, migration: null };
6962
+ }
6963
+ let migration = index.migrationEvent();
6964
+ const size = fs.existsSync(file) ? fs.statSync(file).size : 0;
6965
+ if (size > source.indexedThrough) {
6966
+ consumeLogSlice(
6967
+ file,
6968
+ source.indexedThrough,
6969
+ (event) => {
6970
+ if (event.type === 'migration' && event.id === 'v1-to-v2') migration = event;
6971
+ },
6972
+ {
6973
+ readOnly: true,
6974
+ startLine: source.indexedLines || 0,
6975
+ }
6976
+ );
6977
+ }
6978
+ return { usable: true, migration };
6979
+ } catch {
6980
+ return { usable: false, migration: null };
6981
+ } finally {
6982
+ if (index) index.close();
6983
+ }
6984
+ }
6985
+
7038
6986
  function repositoryMigrationEvent() {
7039
6987
  for (const file of repositoryOutcomeLogFiles()) {
7040
6988
  try {
6989
+ const indexed = indexedMigrationEvent(file);
6990
+ if (indexed.usable) {
6991
+ if (indexed.migration) return indexed.migration;
6992
+ continue;
6993
+ }
7041
6994
  const migration = findMigrationEvent(file);
7042
6995
  if (migration) return migration;
7043
6996
  } catch {
@@ -7305,6 +7258,7 @@ function repositoryRoot(root) {
7305
7258
  }
7306
7259
 
7307
7260
  function runCommand(argv, { root = process.cwd(), isolateStorage = false, capture = true } = {}) {
7261
+ assertSupportedNode();
7308
7262
  if (!Array.isArray(argv) || argv.some((arg) => typeof arg !== 'string')) {
7309
7263
  fail('command arguments must be an array of strings');
7310
7264
  }
@@ -7365,6 +7319,7 @@ function appendFlag(argv, flag, value) {
7365
7319
  }
7366
7320
 
7367
7321
  function createApi({ root = process.cwd(), isolateStorage = false } = {}) {
7322
+ assertSupportedNode();
7368
7323
  const fixedRoot = repositoryRoot(root);
7369
7324
  let lastReadOnly = false;
7370
7325
  const call = (argv) => {
@@ -7501,6 +7456,7 @@ function createApi({ root = process.cwd(), isolateStorage = false } = {}) {
7501
7456
 
7502
7457
  function main() {
7503
7458
  try {
7459
+ assertSupportedNode();
7504
7460
  const result = dispatch(process.argv.slice(2));
7505
7461
  process.exitCode = result.exitCode;
7506
7462
  } catch (err) {