driftseal 2.1.0 → 3.1.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
@@ -20,7 +20,8 @@
20
20
  *
21
21
  * Seal root: $DRIFTSEAL_HOME, or .seal in cwd.
22
22
  * Outcome log: <seal-root>/outcomes/events.jsonl.
23
- * In a Git worktree, an open outcome is parked in Git metadata until end.
23
+ * In a default Git-repository seal, an open outcome is parked beside the WAL until end.
24
+ * Custom $DRIFTSEAL_HOME seals write open outcomes directly to the WAL.
24
25
  * MADR records: <seal-root>/madr/.
25
26
  */
26
27
 
@@ -32,6 +33,15 @@ const { isDeepStrictEqual } = require('util');
32
33
  const { StringDecoder } = require('string_decoder');
33
34
  const { execFileSync, spawnSync } = require('child_process');
34
35
  const { version: PACKAGE_VERSION } = require('../package.json');
36
+ const { createOutcomeFold } = require('../lib/outcome-fold.js');
37
+ const {
38
+ OutcomeIndexError,
39
+ SqliteUnavailableError,
40
+ openOutcomeIndex,
41
+ removeIndexFiles,
42
+ temporaryIndexPath,
43
+ } = require('../lib/outcome-index-sqlite.js');
44
+ const { assertSupportedNode } = require('../lib/sqlite-runtime.js');
35
45
 
36
46
  const END_STATUSES = ['completed', 'partial', 'failed', 'abandoned'];
37
47
  const DECISION_STATUSES = [
@@ -50,12 +60,22 @@ const PROTOCOL_VERSION = '2.1';
50
60
  const DEFAULT_LOG_LANGUAGE = 'en';
51
61
  const DEFAULT_LANE = 'main';
52
62
  const LANE_NAME_RE = /^[a-z][a-z0-9-]{0,62}$/;
53
- const IN_PROGRESS_GIT_PATH = 'driftseal-v2-in-progress.jsonl';
54
- 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;
63
+ const IN_PROGRESS_SIDECAR = '.in-progress.jsonl';
64
+ const WORKSPACE_SIDECAR_IGNORE_NAMES = Object.freeze([
65
+ '.current-lane',
66
+ '.lane-index.json',
67
+ '.in-progress.jsonl',
68
+ '.driftseal-local-outcome.json',
69
+ '.outcome-index.sqlite',
70
+ '.outcome-index.sqlite-journal',
71
+ '.outcome-index.sqlite-wal',
72
+ '.outcome-index.sqlite-shm',
73
+ '..outcome-index.sqlite.*.tmp',
74
+ '..outcome-index.sqlite.*.tmp-*',
75
+ '..current-lane.*.tmp',
76
+ '..in-progress.jsonl.*.tmp',
77
+ '..driftseal-local-outcome.json.*.tmp',
78
+ ]);
59
79
  const LOCK_STALE_MS = 30 * 60 * 1000;
60
80
  const LOCK_INIT_STALE_MS = 5 * 1000;
61
81
  const READ_ONLY_NOTICE = '(read-only: another mutation holds the lock; tail repair skipped)';
@@ -65,6 +85,12 @@ const VERIFICATION_OUTPUT_CHUNK_BYTES = 64 * 1024;
65
85
  const CAPTURE_OUTPUT_EDGE_CHARACTERS = 32 * 1024;
66
86
  const CAPTURE_OUTPUT_OMISSION = '\n... [driftseal captured output truncated] ...\n';
67
87
  const LOCAL_OUTCOME_PROVENANCE_FILE = '.driftseal-local-outcome.json';
88
+ const outcomeFoldEngine = createOutcomeFold({
89
+ fail,
90
+ contentHash,
91
+ logVersion: LOG_VERSION,
92
+ defaultLane: DEFAULT_LANE,
93
+ });
68
94
 
69
95
  class DriftSealError extends Error {
70
96
  constructor(message) {
@@ -561,13 +587,6 @@ function gitWorktreeRoot(cwd = process.cwd()) {
561
587
  return gitCapture(['rev-parse', '--show-toplevel'], cwd);
562
588
  }
563
589
 
564
- function worktreeInProgressFile(cwd = process.cwd()) {
565
- if (!isGitWorkTree(cwd)) return null;
566
- const gitPath = gitCapture(['rev-parse', '--git-path', IN_PROGRESS_GIT_PATH], cwd);
567
- if (!gitPath) return null;
568
- return path.resolve(cwd, gitPath);
569
- }
570
-
571
590
  function isParkableOutcomeLog() {
572
591
  if (process.env.DRIFTSEAL_HOME) return false;
573
592
  const root = gitWorktreeRoot();
@@ -575,40 +594,63 @@ function isParkableOutcomeLog() {
575
594
  return path.resolve(logFile()) === path.resolve(root, '.seal', 'outcomes', 'events.jsonl');
576
595
  }
577
596
 
597
+ function inProgressFileForLog(file) {
598
+ return path.join(path.dirname(file), IN_PROGRESS_SIDECAR);
599
+ }
600
+
578
601
  function inProgressFile() {
579
602
  if (!isParkableOutcomeLog()) return null;
580
- return worktreeInProgressFile();
603
+ return inProgressFileForLog(logFile());
581
604
  }
582
605
 
583
- function worktreeMetadataFile(gitPath, cwd = process.cwd()) {
584
- if (!isGitWorkTree(cwd)) return null;
585
- const resolved = gitCapture(['rev-parse', '--git-path', gitPath], cwd);
586
- if (!resolved) return null;
587
- return path.resolve(cwd, resolved);
606
+ function existingInProgressFileForLog(file) {
607
+ return inProgressFileForLog(file);
608
+ }
609
+
610
+ function existingInProgressFile() {
611
+ if (!isParkableOutcomeLog()) return null;
612
+ return existingInProgressFileForLog(logFile());
613
+ }
614
+
615
+ function adoptInProgressFile() {
616
+ const current = inProgressFile();
617
+ if (!current) return null;
618
+ ensureDerivedLaneSidecarIgnore();
619
+ ensureDirectoryDurable(path.dirname(current));
620
+ return current;
588
621
  }
589
622
 
590
623
  function currentLaneFile() {
591
- if (isParkableOutcomeLog()) return worktreeMetadataFile(CURRENT_LANE_GIT_PATH);
624
+ // Keep the worktree-local pointer beside the WAL so agent sandboxes that
625
+ // deny .git writes can still switch lanes.
592
626
  return path.join(logDir(), '.current-lane');
593
627
  }
594
628
 
595
629
  function laneIndexFile() {
596
- if (isParkableOutcomeLog()) return worktreeMetadataFile(LANE_INDEX_GIT_PATH);
630
+ // Keep the disposable index beside the WAL so agent sandboxes that deny
631
+ // .git writes can still rebuild it inside the workspace.
632
+ return path.join(logDir(), '.outcome-index.sqlite');
633
+ }
634
+
635
+ function legacyLaneIndexFile() {
597
636
  return path.join(logDir(), '.lane-index.json');
598
637
  }
599
638
 
600
639
  function emptyLaneCatalog() {
601
- return new Map([[DEFAULT_LANE, { name: DEFAULT_LANE, description: null, addedAt: null, head: null }]]);
640
+ return outcomeFoldEngine.emptyLaneCatalog();
602
641
  }
603
642
 
604
- function readCurrentLaneName() {
605
- const file = currentLaneFile();
606
- if (!file || !fs.existsSync(file)) return DEFAULT_LANE;
643
+ function readLaneNameFromFile(file) {
644
+ if (!file || !fs.existsSync(file)) return null;
607
645
  const name = fs.readFileSync(file, 'utf8').trim();
608
- if (!name) return DEFAULT_LANE;
646
+ if (!name) return null;
609
647
  return normalizeLaneName(name);
610
648
  }
611
649
 
650
+ function readCurrentLaneName() {
651
+ return readLaneNameFromFile(currentLaneFile()) || DEFAULT_LANE;
652
+ }
653
+
612
654
  function writeCurrentLaneName(name, { readOnly = false } = {}) {
613
655
  if (readOnly) return;
614
656
  const file = currentLaneFile();
@@ -618,371 +660,138 @@ function writeCurrentLaneName(name, { readOnly = false } = {}) {
618
660
  atomicWriteFile(file, `${name}\n`, 0o600);
619
661
  }
620
662
 
663
+ function existingAncestor(dir) {
664
+ let current = path.resolve(dir);
665
+ while (!fs.existsSync(current)) {
666
+ const parent = path.dirname(current);
667
+ if (parent === current) return current;
668
+ current = parent;
669
+ }
670
+ return current;
671
+ }
672
+
673
+ function listedIgnoreNames(content) {
674
+ return new Set(
675
+ content
676
+ .split(/\r?\n/)
677
+ .map((line) => line.trim())
678
+ .filter((line) => line.length > 0)
679
+ );
680
+ }
681
+
682
+ function workspaceSidecarIgnoreFile() {
683
+ return path.join(logDir(), '.gitignore');
684
+ }
685
+
686
+ function gitContainedOutcomeLog() {
687
+ return isGitWorkTree(existingAncestor(logDir()));
688
+ }
689
+
690
+ function workspaceSidecarIgnoreComplete() {
691
+ const ignoreFile = workspaceSidecarIgnoreFile();
692
+ if (!fs.existsSync(ignoreFile)) return false;
693
+ const listed = listedIgnoreNames(fs.readFileSync(ignoreFile, 'utf8'));
694
+ return WORKSPACE_SIDECAR_IGNORE_NAMES.every((name) => listed.has(name));
695
+ }
696
+
697
+ function canPersistDerivedOutcomeIndex() {
698
+ return !gitContainedOutcomeLog() || workspaceSidecarIgnoreComplete();
699
+ }
700
+
621
701
  function ensureDerivedLaneSidecarIgnore() {
622
- if (isParkableOutcomeLog()) return;
623
- if (!isGitWorkTree(logDir())) return;
624
- const ignoreFile = path.join(logDir(), '.gitignore');
702
+ if (!gitContainedOutcomeLog()) return { changed: false, target: null };
703
+ const ignoreFile = workspaceSidecarIgnoreFile();
625
704
  let current = fs.existsSync(ignoreFile) ? fs.readFileSync(ignoreFile, 'utf8') : '';
705
+ const listed = listedIgnoreNames(current);
626
706
  let next = current;
627
- for (const name of ['.current-lane', '.lane-index.json']) {
628
- const present = next.split(/\r?\n/).some((line) => line.trim() === name);
629
- if (present) continue;
707
+ for (const name of WORKSPACE_SIDECAR_IGNORE_NAMES) {
708
+ if (listed.has(name)) continue;
630
709
  if (next && !next.endsWith('\n')) next += '\n';
631
710
  next += `${name}\n`;
632
711
  }
633
- if (next === current) return;
712
+ if (next === current) return { changed: false, target: ignoreFile };
634
713
  ensureDirectoryDurable(logDir());
635
714
  atomicWriteFile(ignoreFile, next, 0o644);
715
+ return { changed: true, target: ignoreFile };
636
716
  }
637
717
 
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('');
718
+ function hashFilePrefix(file, length) {
719
+ const hash = crypto.createHash('sha256');
720
+ if (length <= 0) return hash.digest('hex');
662
721
  const fd = fs.openSync(file, 'r');
663
722
  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');
723
+ const buffer = Buffer.alloc(Math.min(1024 * 1024, length));
724
+ let position = 0;
725
+ while (position < length) {
726
+ const requested = Math.min(buffer.length, length - position);
727
+ const read = fs.readSync(fd, buffer, 0, requested, position);
728
+ if (read === 0) break;
729
+ hash.update(buffer.subarray(0, read));
730
+ position += read;
731
+ }
732
+ if (position !== length) return null;
733
+ return hash.digest('hex');
667
734
  } finally {
668
735
  fs.closeSync(fd);
669
736
  }
670
737
  }
671
738
 
672
739
  function laneIndexSourceIdentity(file, indexedThrough, indexedLines = 0) {
673
- if (!fs.existsSync(file) || indexedThrough <= 0) {
740
+ if (!fs.existsSync(file)) {
674
741
  return {
675
742
  indexedThrough: 0,
676
743
  indexedLines: 0,
677
- prefixHash: contentHash(''),
678
- tailHash: contentHash(''),
744
+ walHash: contentHash(''),
745
+ device: null,
746
+ inode: null,
747
+ mtimeMs: null,
748
+ ctimeMs: null,
679
749
  };
680
750
  }
681
- const prefix = Math.min(LANE_INDEX_PREFIX_BYTES, indexedThrough);
682
- const tail = Math.min(LANE_INDEX_TAIL_BYTES, indexedThrough);
751
+ const stat = fs.statSync(file);
683
752
  return {
684
753
  indexedThrough,
685
754
  indexedLines,
686
- prefixHash: hashFileRange(file, 0, prefix),
687
- tailHash: hashFileRange(file, indexedThrough - tail, tail),
688
- };
689
- }
690
-
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 || {})),
755
+ walHash: hashFilePrefix(file, indexedThrough),
756
+ device: Number(stat.dev),
757
+ inode: Number(stat.ino),
758
+ mtimeMs: stat.mtimeMs,
759
+ ctimeMs: stat.ctimeMs,
746
760
  };
747
761
  }
748
762
 
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);
763
+ function laneIndexMatchesFile(source, file, { exact = false } = {}) {
764
+ if (
765
+ !source ||
766
+ typeof source.walHash !== 'string' ||
767
+ !Number.isSafeInteger(source.indexedLines) ||
768
+ source.indexedLines < 0
769
+ ) {
770
+ return false;
759
771
  }
760
- for (const [name, lane] of state.lanes) {
761
- lane.head = heads.get(name) || null;
772
+ if (!fs.existsSync(file)) return source.indexedThrough === 0;
773
+ const stat = fs.statSync(file);
774
+ const size = stat.size;
775
+ if (size < source.indexedThrough || (exact && size !== source.indexedThrough)) return false;
776
+ if (
777
+ source.device !== null &&
778
+ source.inode !== null &&
779
+ (Number(stat.dev) !== source.device || Number(stat.ino) !== source.inode)
780
+ ) {
781
+ return false;
762
782
  }
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);
783
+ if (
784
+ size === source.indexedThrough &&
785
+ stat.mtimeMs === source.mtimeMs &&
786
+ stat.ctimeMs === source.ctimeMs
787
+ ) {
788
+ return true;
772
789
  }
773
- applyFoldEvent(state, ev);
790
+ return hashFilePrefix(file, source.indexedThrough) === source.walHash;
774
791
  }
775
792
 
776
793
  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
- }
794
+ return outcomeFoldEngine.applyFoldEvent(state, ev);
986
795
  }
987
796
 
988
797
  function consumeLogSlice(file, startByte, onEvent, { repairTail = false, readOnly = false, startLine = 0 } = {}) {
@@ -1039,111 +848,225 @@ function consumeLogSlice(file, startByte, onEvent, { repairTail = false, readOnl
1039
848
  }
1040
849
  onEvent(event, start, pos);
1041
850
  } catch (err) {
1042
- if (err instanceof DriftSealError) throw err;
851
+ if (err instanceof DriftSealError || err instanceof OutcomeIndexError) throw err;
1043
852
  fail(`corrupt log line ${lineNumber} in ${file}`);
1044
853
  }
1045
854
  }
1046
855
  return { endByte: pos, endLine: lineNumber };
1047
856
  }
1048
857
 
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);
858
+ function replaceOutcomeIndexFile(temporary, target) {
859
+ try {
860
+ fs.renameSync(temporary, target);
861
+ } catch (error) {
862
+ if (!['EEXIST', 'EPERM'].includes(error && error.code)) throw error;
863
+ removeIndexFiles(target);
864
+ fs.renameSync(temporary, target);
865
+ }
866
+ fs.chmodSync(target, 0o600);
867
+ for (const suffix of ['-journal', '-wal', '-shm']) {
868
+ fs.rmSync(`${target}${suffix}`, { force: true });
869
+ }
870
+ fsyncDirectory(path.dirname(target));
1056
871
  }
1057
872
 
1058
- function loadPersistedLaneIndex() {
1059
- const file = laneIndexFile();
1060
- if (!file || !fs.existsSync(file)) return null;
873
+ function removeLegacyLaneIndex() {
874
+ fs.rmSync(legacyLaneIndexFile(), { force: true });
875
+ }
876
+
877
+ function rebuildCommittedOutcomeIndex({ repairTail = false } = {}) {
878
+ const wal = logFile();
879
+ const target = laneIndexFile();
880
+ if (!target) return null;
881
+ if (!canPersistDerivedOutcomeIndex()) return null;
882
+ ensureDirectoryDurable(path.dirname(target));
883
+ const temporary = temporaryIndexPath(target);
884
+ removeIndexFiles(temporary);
885
+ let index;
1061
886
  try {
1062
- return deserializeLaneIndex(JSON.parse(fs.readFileSync(file, 'utf8')));
1063
- } catch {
1064
- return null;
887
+ index = openOutcomeIndex(temporary);
888
+ let slice;
889
+ index.transaction(() => {
890
+ const indexedEvents = [];
891
+ slice = consumeLogSlice(
892
+ wal,
893
+ 0,
894
+ (event, startByte, endByte) =>
895
+ indexedEvents.push({ event, startByte, endByte }),
896
+ { repairTail }
897
+ );
898
+ index.replaceFromFoldState(
899
+ outcomeFoldEngine.foldState(indexedEvents.map((item) => item.event)),
900
+ indexedEvents
901
+ );
902
+ index.setSource(
903
+ laneIndexSourceIdentity(wal, slice.endByte, slice.endLine),
904
+ 'full'
905
+ );
906
+ index.acceptProjection();
907
+ });
908
+ if (!index.integrityCheck()) fail('rebuilt SQLite outcome index failed integrity check');
909
+ index.close();
910
+ index = null;
911
+ replaceOutcomeIndexFile(temporary, target);
912
+ removeLegacyLaneIndex();
913
+ const reopened = openOutcomeIndex(target);
914
+ reopened.build = 'full';
915
+ return reopened;
916
+ } catch (error) {
917
+ if (index) index.close();
918
+ removeIndexFiles(temporary);
919
+ throw error;
1065
920
  }
1066
921
  }
1067
922
 
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 }
923
+ function syncCommittedLaneIndex({ repairTail = false, readOnly = false, forceFull = false } = {}) {
924
+ if (process.env._DRIFTSEAL_TEST_DISABLE_OUTCOME_INDEX === '1') return null;
925
+ const wal = logFile();
926
+ const target = laneIndexFile();
927
+ if (!target) return null;
928
+ if (!readOnly && !canPersistDerivedOutcomeIndex()) return null;
929
+ if (readOnly) {
930
+ if (!fs.existsSync(target)) return null;
931
+ try {
932
+ const index = openOutcomeIndex(target, { readOnly: true });
933
+ if (
934
+ !index.projectionTrusted() ||
935
+ !laneIndexMatchesFile(index.source(), wal, { exact: true })
936
+ ) {
937
+ index.close();
938
+ return null;
939
+ }
940
+ index.build = 'hot';
941
+ return index;
942
+ } catch {
943
+ return null;
944
+ }
945
+ }
946
+ if (forceFull || !fs.existsSync(target)) {
947
+ try {
948
+ return rebuildCommittedOutcomeIndex({ repairTail });
949
+ } catch (error) {
950
+ if (error instanceof SqliteUnavailableError) return null;
951
+ throw error;
952
+ }
953
+ }
954
+ let index;
955
+ try {
956
+ index = openOutcomeIndex(target);
957
+ const source = index.source();
958
+ if (!index.projectionTrusted() || !laneIndexMatchesFile(source, wal)) {
959
+ index.close();
960
+ return rebuildCommittedOutcomeIndex({ repairTail });
961
+ }
962
+ const size = fs.existsSync(wal) ? fs.statSync(wal).size : 0;
963
+ if (size === source.indexedThrough) {
964
+ index.build = 'hot';
965
+ removeLegacyLaneIndex();
966
+ return index;
967
+ }
968
+ let slice;
969
+ index.transaction(() => {
970
+ slice = consumeLogSlice(
971
+ wal,
972
+ source.indexedThrough,
973
+ (event, start, end) =>
974
+ index.applyEvent(event, start, end, { applyFoldEvent, defaultLane: DEFAULT_LANE }),
975
+ {
976
+ repairTail,
977
+ startLine: source.indexedLines || 0,
978
+ }
1091
979
  );
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';
980
+ index.setSource(
981
+ laneIndexSourceIdentity(wal, slice.endByte, slice.endLine),
982
+ 'incremental'
983
+ );
984
+ index.acceptProjection();
985
+ });
986
+ index.build = 'incremental';
987
+ removeLegacyLaneIndex();
988
+ return index;
989
+ } catch (error) {
990
+ if (index) index.close();
991
+ if (error instanceof DriftSealError) throw error;
992
+ if (error instanceof SqliteUnavailableError) return null;
993
+ return rebuildCommittedOutcomeIndex({ repairTail });
994
+ }
995
+ }
996
+
997
+ function attachIndexedOverlay(index, committed, plan, { readOnly = false } = {}) {
998
+ const lanes = index.laneCatalog();
999
+ if (!plan || plan.alreadyCommitted || plan.records.length === 0) {
1000
+ committed.lanes = lanes;
1001
+ return committed;
1002
+ }
1003
+ if (!readOnly && plan.mappings.length > 0) writeJsonl(plan.park, plan.records);
1004
+ const overlay = fold(plan.records.map((record) => record.event));
1005
+ for (const record of overlay) {
1006
+ let lane = lanes.get(record.lane);
1007
+ if (!lane) {
1008
+ const foldedLane = overlay.lanes.get(record.lane);
1009
+ lane = {
1010
+ name: record.lane,
1011
+ description: foldedLane ? foldedLane.description : null,
1012
+ addedAt: foldedLane ? foldedLane.addedAt : null,
1013
+ inferred: foldedLane ? foldedLane.inferred === true : true,
1014
+ head: null,
1015
+ count: 0,
1016
+ visible: 0,
1017
+ };
1018
+ lanes.set(record.lane, lane);
1099
1019
  }
1020
+ lane.count = (lane.count || 0) + 1;
1021
+ if (!record.reclaimed) lane.visible = (lane.visible || 0) + 1;
1100
1022
  }
1101
- state.source = laneIndexSourceIdentity(file, indexedThrough, indexedLines);
1102
- linkLaneIndex(state);
1103
- if (state.lastBuild !== 'hot') persistLaneIndex(state, { readOnly });
1104
- return state;
1023
+ const records = [...committed, ...overlay];
1024
+ records.lanes = lanes;
1025
+ return records;
1105
1026
  }
1106
1027
 
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;
1028
+ function queryOutcomeView(index, park, { repairTail = false, readOnly = false } = {}) {
1029
+ const committed = index.queryAll();
1030
+ if (!park || !fs.existsSync(park)) {
1031
+ committed.lanes = index.laneCatalog();
1032
+ return { index: null, records: committed };
1033
+ }
1034
+ const plan = planIndexedInProgressOverlay(index, park, { repairTail, readOnly });
1035
+ if (plan && plan.alreadyCommitted && !readOnly) discardInProgressLog(park);
1036
+ return {
1037
+ index: null,
1038
+ records: attachIndexedOverlay(index, committed, plan, { readOnly }),
1039
+ };
1113
1040
  }
1114
1041
 
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;
1042
+ function recoverableOutcomeIndexError(error) {
1043
+ return (
1044
+ error instanceof OutcomeIndexError ||
1045
+ /^SQLITE_|^ERR_SQLITE_/.test(String(error && error.code))
1046
+ );
1119
1047
  }
1120
1048
 
1121
1049
  function loadOutcomeView({ repairTail = false, readOnly = false } = {}) {
1122
- const committed = syncCommittedLaneIndex({ repairTail, readOnly });
1123
- const park = inProgressFile();
1124
- if (!park || !fs.existsSync(park)) {
1125
- return { state: committed, records: foldedRecordsFromLaneIndex(committed) };
1050
+ const park = existingInProgressFile();
1051
+ let index = syncCommittedLaneIndex({ repairTail, readOnly });
1052
+ if (!index) {
1053
+ const records = fold(readEvents({ repairTail, readOnly }));
1054
+ return { index: null, records };
1126
1055
  }
1127
1056
  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;
1057
+ return queryOutcomeView(index, park, { repairTail, readOnly });
1058
+ } catch (error) {
1059
+ index.close();
1060
+ index = null;
1061
+ if (readOnly && (error.code === 'ENOENT' || recoverableOutcomeIndexError(error))) {
1062
+ return { index: null, records: fold(readEvents({ repairTail, readOnly })) };
1063
+ }
1064
+ if (!recoverableOutcomeIndexError(error)) throw error;
1065
+ index = syncCommittedLaneIndex({ repairTail, forceFull: true });
1066
+ if (!index) return { index: null, records: fold(readEvents({ repairTail })) };
1067
+ return queryOutcomeView(index, park, { repairTail });
1068
+ } finally {
1069
+ if (index) index.close();
1147
1070
  }
1148
1071
  }
1149
1072
 
@@ -1213,6 +1136,53 @@ function selectLastLogRecords(records, current, n) {
1213
1136
  return [...clipped, ...extras].sort((left, right) => order.get(left.id) - order.get(right.id));
1214
1137
  }
1215
1138
 
1139
+ function indexedLaneSummary(state, name) {
1140
+ const lane = state.lanes.get(name);
1141
+ return {
1142
+ name,
1143
+ description: lane ? lane.description : null,
1144
+ addedAt: lane ? lane.addedAt : null,
1145
+ inferred: Boolean(lane && lane.inferred),
1146
+ visible: lane ? lane.visible : 0,
1147
+ count: lane ? lane.count : 0,
1148
+ };
1149
+ }
1150
+
1151
+ function tryLoadRecentOutcomeView(n, { includeReclaimed = false, repairTail = false, readOnly = false } = {}) {
1152
+ const park = existingInProgressFile();
1153
+ let index = syncCommittedLaneIndex({ repairTail, readOnly });
1154
+ if (!index) return null;
1155
+ const query = () => {
1156
+ const plan =
1157
+ park && fs.existsSync(park)
1158
+ ? planIndexedInProgressOverlay(index, park, { repairTail, readOnly })
1159
+ : null;
1160
+ if (plan && plan.alreadyCommitted && !readOnly) discardInProgressLog(park);
1161
+ const overlayView = attachIndexedOverlay(index, [], plan, { readOnly });
1162
+ const state = { lanes: overlayView.lanes };
1163
+ const { current, missing } = resolveCurrentLane(state);
1164
+ const committed = index.queryRecent(current, n, { includeReclaimed });
1165
+ const records = selectLastLogRecords(
1166
+ [...committed, ...overlayView],
1167
+ current,
1168
+ n
1169
+ );
1170
+ return { state, records, current, missing };
1171
+ };
1172
+ try {
1173
+ return query();
1174
+ } catch (error) {
1175
+ if (readOnly && error && error.code === 'ENOENT') return null;
1176
+ index.close();
1177
+ index = null;
1178
+ if (readOnly) return null;
1179
+ index = syncCommittedLaneIndex({ repairTail, forceFull: true });
1180
+ return query();
1181
+ } finally {
1182
+ if (index) index.close();
1183
+ }
1184
+ }
1185
+
1216
1186
  function liveWorktreeOutcomeLog() {
1217
1187
  const root = gitWorktreeRoot();
1218
1188
  if (!root) return null;
@@ -1234,7 +1204,7 @@ function readJsonlRecordsFromFile(file, { repairTail = false, readOnly = false }
1234
1204
  if (
1235
1205
  readOnly &&
1236
1206
  process.env._DRIFTSEAL_TEST_UNLINK_PARK_BEFORE_READ === '1' &&
1237
- path.basename(file) === IN_PROGRESS_GIT_PATH
1207
+ path.basename(file) === IN_PROGRESS_SIDECAR
1238
1208
  ) {
1239
1209
  // Simulate a writer flushing and unlinking the park between the existence
1240
1210
  // check and the read; the next attempt sees the park as absent.
@@ -1294,14 +1264,35 @@ function planInProgressOverlay(committedEvents, park, { repairTail = false, read
1294
1264
  return { park, records: remapped.records, mappings: remapped.mappings, alreadyCommitted: false };
1295
1265
  }
1296
1266
 
1267
+ function planIndexedInProgressOverlay(index, park, { repairTail = false, readOnly = false } = {}) {
1268
+ if (!park || !fs.existsSync(park)) return null;
1269
+ const overlayRecords = readJsonlRecordsFromFile(park, { repairTail, readOnly });
1270
+ const overlayEvents = overlayRecords.map((record) => record.event);
1271
+ if (overlayEvents.length === 0 || index.containsEventSequence(overlayEvents)) {
1272
+ return { park, records: [], mappings: [], alreadyCommitted: true };
1273
+ }
1274
+ const remapped = remapTheirsRecords(
1275
+ overlayRecords,
1276
+ index.outcomeStartEvents(),
1277
+ new Map(),
1278
+ new Map()
1279
+ );
1280
+ return {
1281
+ park,
1282
+ records: remapped.records,
1283
+ mappings: remapped.mappings,
1284
+ alreadyCommitted: false,
1285
+ };
1286
+ }
1287
+
1297
1288
  function discardInProgressLog(park) {
1298
- fs.unlinkSync(park);
1289
+ fs.rmSync(park, { force: true });
1299
1290
  fsyncDirectory(path.dirname(park));
1300
1291
  }
1301
1292
 
1302
1293
  function reconcileInProgressRecords(
1303
1294
  committedEvents,
1304
- { repairTail = false, readOnly = false, park = inProgressFile() } = {}
1295
+ { repairTail = false, readOnly = false, park = existingInProgressFile() } = {}
1305
1296
  ) {
1306
1297
  const plan = planInProgressOverlay(committedEvents, park, { repairTail, readOnly });
1307
1298
  if (!plan) return [];
@@ -1323,7 +1314,7 @@ function readEventsSnapshot(file, { repairTail = false, readOnly = false } = {})
1323
1314
  reconcileInProgressRecords(events, {
1324
1315
  repairTail,
1325
1316
  readOnly,
1326
- park: worktreeInProgressFile(),
1317
+ park: existingInProgressFileForLog(file),
1327
1318
  }).map((record) => record.event)
1328
1319
  );
1329
1320
  }
@@ -1452,7 +1443,7 @@ function appendEventTo(file, event) {
1452
1443
  * Returns the outcome ids it had to remap.
1453
1444
  */
1454
1445
  function flushInProgressLog() {
1455
- const park = inProgressFile();
1446
+ const park = adoptInProgressFile();
1456
1447
  if (!park || !fs.existsSync(park)) return new Map();
1457
1448
  const committedRecords = readJsonlRecordsFromFile(logFile());
1458
1449
  const plan = planInProgressOverlay(
@@ -1482,7 +1473,8 @@ function parkedOpenOutcome(park) {
1482
1473
  }
1483
1474
 
1484
1475
  function appendEvent(event) {
1485
- const park = inProgressFile();
1476
+ ensureDerivedLaneSidecarIgnore();
1477
+ const park = adoptInProgressFile();
1486
1478
  if (!park) {
1487
1479
  const stored = appendEventTo(logFile(), event);
1488
1480
  if (event.type === 'begin') writeLocalOutcomeProvenance(stored);
@@ -1497,7 +1489,7 @@ function appendEvent(event) {
1497
1489
  if (event.type === 'begin') return appendEventTo(park, event);
1498
1490
  if (!open || open.id !== event.id) return appendEventTo(logFile(), event);
1499
1491
  if (event.type !== 'end') return appendEventTo(park, event);
1500
- // Close in the tracked log, never in Git metadata: the parked records move first, so the
1492
+ // Close in the tracked log, never only in the park: the parked records move first, so the
1501
1493
  // closing record cannot end up somewhere a clone or a removed worktree would drop it.
1502
1494
  const remapped = flushInProgressLog();
1503
1495
  if (process.env._DRIFTSEAL_TEST_CRASH_AFTER_IN_PROGRESS_FLUSH === '1') {
@@ -1511,11 +1503,7 @@ function contentHash(content) {
1511
1503
  }
1512
1504
 
1513
1505
  function localOutcomeProvenanceFile() {
1514
- const root = gitWorktreeRoot();
1515
- const key = contentHash(path.resolve(logFile())).slice(0, 16);
1516
- if (!root) return path.join(logDir(), LOCAL_OUTCOME_PROVENANCE_FILE);
1517
- const gitPath = gitCapture(['rev-parse', '--git-path', `driftseal-local-outcome-${key}.json`]);
1518
- return gitPath ? path.resolve(process.cwd(), gitPath) : null;
1506
+ return path.join(logDir(), LOCAL_OUTCOME_PROVENANCE_FILE);
1519
1507
  }
1520
1508
 
1521
1509
  function localOutcomeLogIdentity() {
@@ -1534,6 +1522,7 @@ function localOutcomeProvenanceFingerprint({ id, ts, verify }) {
1534
1522
  function writeLocalOutcomeProvenance(event) {
1535
1523
  const file = localOutcomeProvenanceFile();
1536
1524
  if (!file) return;
1525
+ ensureDerivedLaneSidecarIgnore();
1537
1526
  ensureDirectoryDurable(path.dirname(file));
1538
1527
  atomicWriteFile(
1539
1528
  file,
@@ -1547,8 +1536,7 @@ function writeLocalOutcomeProvenance(event) {
1547
1536
  );
1548
1537
  }
1549
1538
 
1550
- function readLocalOutcomeProvenance() {
1551
- const file = localOutcomeProvenanceFile();
1539
+ function parseLocalOutcomeProvenance(file) {
1552
1540
  if (!file || !fs.existsSync(file)) return null;
1553
1541
  try {
1554
1542
  const provenance = JSON.parse(fs.readFileSync(file, 'utf8'));
@@ -1566,6 +1554,10 @@ function readLocalOutcomeProvenance() {
1566
1554
  }
1567
1555
  }
1568
1556
 
1557
+ function readLocalOutcomeProvenance() {
1558
+ return parseLocalOutcomeProvenance(localOutcomeProvenanceFile());
1559
+ }
1560
+
1569
1561
  function hasMatchingLocalOutcomeProvenance(outcome) {
1570
1562
  const provenance = readLocalOutcomeProvenance();
1571
1563
  return (
@@ -1582,11 +1574,13 @@ function hasMatchingLocalOutcomeProvenance(outcome) {
1582
1574
  }
1583
1575
 
1584
1576
  function clearLocalOutcomeProvenance(id) {
1585
- const file = localOutcomeProvenanceFile();
1586
1577
  const provenance = readLocalOutcomeProvenance();
1587
- if (!file || !provenance || provenance.id !== id) return;
1588
- fs.unlinkSync(file);
1589
- fsyncDirectory(path.dirname(file));
1578
+ if (!provenance || provenance.id !== id) return;
1579
+ const file = localOutcomeProvenanceFile();
1580
+ if (file && fs.existsSync(file)) {
1581
+ fs.unlinkSync(file);
1582
+ fsyncDirectory(path.dirname(file));
1583
+ }
1590
1584
  }
1591
1585
 
1592
1586
  function atomicWriteFile(target, content, createMode = 0o644) {
@@ -1867,77 +1861,20 @@ function withMutationLocks(resources, action, { tryWaitMs } = {}) {
1867
1861
  }
1868
1862
 
1869
1863
  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
- }));
1864
+ return outcomeFoldEngine.outcomeContractHash(record);
1882
1865
  }
1883
1866
 
1884
1867
  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;
1868
+ return outcomeFoldEngine.newOutcomeRecord(ev);
1915
1869
  }
1916
1870
 
1917
1871
  /** Fold the event stream into one record per outcome. Legacy v1 events are accepted for migration. */
1918
1872
  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;
1873
+ return outcomeFoldEngine.fold(events);
1929
1874
  }
1930
1875
 
1931
1876
  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
- });
1877
+ return outcomeFoldEngine.qualifyingDecisionUpdates(record, decisionId);
1941
1878
  }
1942
1879
 
1943
1880
  function openOutcome(records) {
@@ -3482,6 +3419,8 @@ const SKILL_RELEASE_DIGESTS = new Set([
3482
3419
  '42a0549dff21483c0508ea4a79658e7bf05cd98f8af4238c95dbd23cdcde7ee6', // 2.0.0 outcome workflow
3483
3420
  '38e89060ff37ecdd663eae73a3e0c646d6bb220fc8232a5762356375d84ba69b', // 2.1.0 outcome lanes
3484
3421
  'b4b2cc27ea71c5777b5eb9b67d861fbe1da43f31ab5d422d6a877e0cad592493', // 2.1.0 lane re-anchor recovery
3422
+ '6c49523c2f4a36ea1cd9d0b4e793ea3cdbd9d299174cf1f9a0a54f3d9a7cd331', // workspace park sidecar wording
3423
+ '678cbaa8380f75f7a45d634010b500e51a59b06ea759eaf23cff2980142662f0', // default-seal parking help wording
3485
3424
  ]);
3486
3425
 
3487
3426
  function skillInstallUsage() {
@@ -3903,11 +3842,9 @@ function hookLogFile() {
3903
3842
  const root = gitWorktreeRoot(current);
3904
3843
  while (true) {
3905
3844
  const candidate = path.join(current, '.seal', 'outcomes', 'events.jsonl');
3906
- if (fs.existsSync(candidate)) return candidate;
3907
- if (root && path.resolve(root) === current) {
3908
- const park = worktreeInProgressFile(current);
3909
- if (park && fs.existsSync(park)) return candidate;
3910
- }
3845
+ const workspacePark = path.join(current, '.seal', 'outcomes', IN_PROGRESS_SIDECAR);
3846
+ if (fs.existsSync(candidate) || fs.existsSync(workspacePark)) return candidate;
3847
+ if (root && path.resolve(root) === current) return null;
3911
3848
  const parent = path.dirname(current);
3912
3849
  if (parent === current) return null;
3913
3850
  current = parent;
@@ -4740,8 +4677,12 @@ function finishAbsorb({
4740
4677
  allowConflict = false,
4741
4678
  followupMessage = null,
4742
4679
  }) {
4743
- // An outcome parked in Git metadata is part of our side even though the log never saw it.
4744
- const park = shouldAttachInProgress(outputFile) ? inProgressFile() : null;
4680
+ // A parked outcome is local even though the tracked log never saw it.
4681
+ const park = shouldAttachInProgress(outputFile)
4682
+ ? dryRun
4683
+ ? existingInProgressFileForLog(outputFile)
4684
+ : adoptInProgressFile() || existingInProgressFileForLog(outputFile)
4685
+ : null;
4745
4686
  const plan = planInProgressOverlay(result.map((record) => record.event), park, {
4746
4687
  repairTail: true,
4747
4688
  });
@@ -5112,7 +5053,7 @@ function runMachineVerification({ allowTrackedCommand = false } = {}) {
5112
5053
  fail(`outcome ${outcome.id} has no acceptance criteria; declare them with driftseal begin --accept`);
5113
5054
  }
5114
5055
  if (!outcome.verify) fail(`outcome ${outcome.id} has no verification command`);
5115
- const park = inProgressFile();
5056
+ const park = existingInProgressFile();
5116
5057
  const parked = park ? parkedOpenOutcome(park) : null;
5117
5058
  const locallyProvenanced = hasMatchingLocalOutcomeProvenance(outcome);
5118
5059
  return {
@@ -6294,16 +6235,47 @@ const commands = {
6294
6235
  'all-lanes': 'boolean',
6295
6236
  }, 'log');
6296
6237
  if (positionals.length > 0) fail(usageFor('log'));
6238
+ const n = flags.last === undefined ? null : positiveInteger(flags.last, '--last');
6239
+ const allLanes = flags['all-lanes'] === true;
6240
+ const parkedV1 = legacyParkedIntent();
6241
+ if (
6242
+ n !== null &&
6243
+ !allLanes &&
6244
+ !parkedV1 &&
6245
+ process.env._DRIFTSEAL_TEST_DISABLE_RECENT_INDEX !== '1'
6246
+ ) {
6247
+ const recent = tryLoadRecentOutcomeView(n, {
6248
+ includeReclaimed: flags.all === true,
6249
+ repairTail: true,
6250
+ readOnly,
6251
+ });
6252
+ if (recent) {
6253
+ const { state, records, current, missing } = recent;
6254
+ warnMissingCurrentLane(missing);
6255
+ const customLanes = state.lanes.size > 1;
6256
+ if (customLanes || current !== DEFAULT_LANE || missing) {
6257
+ const summary = indexedLaneSummary(state, current);
6258
+ printLine(`lane: ${current} (${summary.visible} visible / ${summary.count} in lane)`);
6259
+ }
6260
+ if (records.length === 0) {
6261
+ printLine('log is empty');
6262
+ return [];
6263
+ }
6264
+ printLine(records.map((record) => render(record, { currentLane: current })).join('\n\n'));
6265
+ return records.map(publicOutcome);
6266
+ }
6267
+ }
6268
+ if (process.env._DRIFTSEAL_TEST_REQUIRE_RECENT_INDEX === '1' && n !== null && !allLanes) {
6269
+ fail('recent lane index fast path was not used');
6270
+ }
6297
6271
  const view = loadOutcomeView({ repairTail: true, readOnly });
6298
6272
  let records = view.records;
6299
- const parkedV1 = legacyParkedIntent();
6300
6273
  if (parkedV1 && !records.some((record) => record.id === parkedV1.id && record.status === 'in_progress')) {
6301
6274
  records = Object.assign([...records, parkedV1], { lanes: records.lanes });
6302
6275
  }
6303
6276
  const catalog = records.lanes || emptyLaneCatalog();
6304
6277
  const { current, missing } = resolveCurrentLane(view.records);
6305
6278
  warnMissingCurrentLane(missing);
6306
- const allLanes = flags['all-lanes'] === true;
6307
6279
  if (!allLanes) {
6308
6280
  records = Object.assign(selectLaneLogRecords(records, current), { lanes: catalog });
6309
6281
  }
@@ -6313,8 +6285,7 @@ const commands = {
6313
6285
  printLine(renderLaneLine(view.records, current));
6314
6286
  }
6315
6287
  let shown = visible;
6316
- if (flags.last) {
6317
- const n = positiveInteger(flags.last, '--last');
6288
+ if (n !== null) {
6318
6289
  shown = selectLastLogRecords(visible, current, n);
6319
6290
  }
6320
6291
  if (shown.length === 0) {
@@ -6781,10 +6752,11 @@ const commands = {
6781
6752
  } catch (err) {
6782
6753
  printLine(`warning: could not configure git merge driver: ${err && err.message ? err.message : err}`);
6783
6754
  }
6755
+ const indexIgnore = ensureDerivedLaneSidecarIgnore();
6784
6756
 
6785
6757
  if (localLog) warnIfDefaultLogsTracked();
6786
6758
 
6787
- if (updated === current && !attributes.changed && !driver.changed) {
6759
+ if (updated === current && !attributes.changed && !driver.changed && !indexIgnore.changed) {
6788
6760
  printLine('AGENTS.md already contains the DriftSeal protocols; nothing to do');
6789
6761
  return { changed: false, target };
6790
6762
  }
@@ -6798,6 +6770,9 @@ const commands = {
6798
6770
  if (driver.changed) {
6799
6771
  printLine('Configured local git merge driver for DriftSeal outcome logs');
6800
6772
  }
6773
+ if (indexIgnore.changed) {
6774
+ printLine(`Configured derived outcome-index ignore: ${indexIgnore.target}`);
6775
+ }
6801
6776
  return { changed: true, target };
6802
6777
  },
6803
6778
 
@@ -6875,7 +6850,7 @@ seal root: $DRIFTSEAL_HOME, or .seal in the current directory
6875
6850
  outcome log: <seal-root>/outcomes/events.jsonl
6876
6851
  MADR records: <seal-root>/madr/
6877
6852
  $DRIFTSEAL_DECISION_HOME is a v1-only default for migration source detection; v2 runtime ignores it.
6878
- In a Git worktree, begin parks an open outcome in Git metadata until end, so merge does not need a log-only commit.`);
6853
+ In a default Git-repository seal, begin parks an open outcome beside the WAL until end, so merge does not need a log-only commit. Custom $DRIFTSEAL_HOME seals write that open outcome directly to events.jsonl.`);
6879
6854
  return null;
6880
6855
  },
6881
6856
 
@@ -7035,9 +7010,52 @@ function repositoryOutcomeLogFiles() {
7035
7010
  return files;
7036
7011
  }
7037
7012
 
7013
+ function indexedMigrationEvent(file) {
7014
+ if (
7015
+ canonicalPath(file) !== canonicalPath(logFile()) ||
7016
+ !laneIndexFile() ||
7017
+ !fs.existsSync(laneIndexFile())
7018
+ ) {
7019
+ return { usable: false, migration: null };
7020
+ }
7021
+ let index;
7022
+ try {
7023
+ index = openOutcomeIndex(laneIndexFile(), { readOnly: true });
7024
+ const source = index.source();
7025
+ if (!laneIndexMatchesFile(source, file)) {
7026
+ return { usable: false, migration: null };
7027
+ }
7028
+ let migration = index.migrationEvent();
7029
+ const size = fs.existsSync(file) ? fs.statSync(file).size : 0;
7030
+ if (size > source.indexedThrough) {
7031
+ consumeLogSlice(
7032
+ file,
7033
+ source.indexedThrough,
7034
+ (event) => {
7035
+ if (event.type === 'migration' && event.id === 'v1-to-v2') migration = event;
7036
+ },
7037
+ {
7038
+ readOnly: true,
7039
+ startLine: source.indexedLines || 0,
7040
+ }
7041
+ );
7042
+ }
7043
+ return { usable: true, migration };
7044
+ } catch {
7045
+ return { usable: false, migration: null };
7046
+ } finally {
7047
+ if (index) index.close();
7048
+ }
7049
+ }
7050
+
7038
7051
  function repositoryMigrationEvent() {
7039
7052
  for (const file of repositoryOutcomeLogFiles()) {
7040
7053
  try {
7054
+ const indexed = indexedMigrationEvent(file);
7055
+ if (indexed.usable) {
7056
+ if (indexed.migration) return indexed.migration;
7057
+ continue;
7058
+ }
7041
7059
  const migration = findMigrationEvent(file);
7042
7060
  if (migration) return migration;
7043
7061
  } catch {
@@ -7244,7 +7262,7 @@ function dispatch(argv) {
7244
7262
  if (cmd === 'hook') {
7245
7263
  // Hooks read the nearest ancestor log, not the cwd-relative one: lock
7246
7264
  // the directory of the file the hook will actually read (the park a
7247
- // writer flushes under that same lock lives in the repo's Git metadata).
7265
+ // writer flushes under that same lock lives beside the WAL).
7248
7266
  // With no ancestor log the hook prints nothing, so skip locking — this
7249
7267
  // also avoids creating a spurious <cwd>/.intent-log.
7250
7268
  const hookFile = hookLogFile();
@@ -7252,6 +7270,14 @@ function dispatch(argv) {
7252
7270
  resources = [path.dirname(hookFile)];
7253
7271
  } else if (legacyParkedIntent()) {
7254
7272
  resources = [path.dirname(legacyIntentLogFile())];
7273
+ } else if (!fs.existsSync(logDir())) {
7274
+ // Do not mkdir a cwd-relative seal just to lock a read: status/log
7275
+ // from a subdirectory would otherwise plant packages/**/.seal.
7276
+ const data = fn(rest, { readOnly: true });
7277
+ return {
7278
+ data,
7279
+ exitCode: data && Number.isInteger(data.exitCode) ? data.exitCode : 0,
7280
+ };
7255
7281
  } else {
7256
7282
  resources = [logDir()];
7257
7283
  }
@@ -7305,6 +7331,7 @@ function repositoryRoot(root) {
7305
7331
  }
7306
7332
 
7307
7333
  function runCommand(argv, { root = process.cwd(), isolateStorage = false, capture = true } = {}) {
7334
+ assertSupportedNode();
7308
7335
  if (!Array.isArray(argv) || argv.some((arg) => typeof arg !== 'string')) {
7309
7336
  fail('command arguments must be an array of strings');
7310
7337
  }
@@ -7365,6 +7392,7 @@ function appendFlag(argv, flag, value) {
7365
7392
  }
7366
7393
 
7367
7394
  function createApi({ root = process.cwd(), isolateStorage = false } = {}) {
7395
+ assertSupportedNode();
7368
7396
  const fixedRoot = repositoryRoot(root);
7369
7397
  let lastReadOnly = false;
7370
7398
  const call = (argv) => {
@@ -7501,6 +7529,7 @@ function createApi({ root = process.cwd(), isolateStorage = false } = {}) {
7501
7529
 
7502
7530
  function main() {
7503
7531
  try {
7532
+ assertSupportedNode();
7504
7533
  const result = dispatch(process.argv.slice(2));
7505
7534
  process.exitCode = result.exitCode;
7506
7535
  } catch (err) {