Skip to main content

lex_vcs/
merge_session.rs

1//! Stateful merge sessions for programmatic conflict resolution (#134).
2//!
3//! Today's `lex_vcs::merge` returns a list of `MergeOutcome`s — auto-
4//! merged sigs *and* conflicts — and exits. To act on conflicts an
5//! agent has to:
6//!
7//! 1. Run `lex store-merge`.
8//! 2. Parse the JSON output.
9//! 3. Decide a resolution per conflict.
10//! 4. Manually edit source files.
11//! 5. Run `lex check`.
12//! 6. Run `lex publish`.
13//! 7. Loop on failure.
14//!
15//! Six round-trips for what should be one transaction. Worse, the
16//! agent edits *text* between steps 4 and 6 — the typed conflict
17//! the merge engine produced gets re-derived from the new text. The
18//! information loss is what the issue calls out.
19//!
20//! [`MergeSession`] gives the engine layer needed to expose merging
21//! as a state machine: `start` collects conflicts, `resolve` accepts
22//! batched [`Resolution`]s, `commit` finalizes when no conflicts
23//! remain. The HTTP wrapper (`POST /v1/merge/start` etc.) and the
24//! CLI mirror (`lex merge resolve`) compose on top of this.
25//!
26//! # Why a stateful session
27//!
28//! Merging conflicts iteratively is the natural agent loop:
29//! "submit 50 resolutions, see which were accepted, fix the ones
30//! that broke type-checking, retry." The session holds the
31//! in-progress state so the merge cost (LCA computation, op
32//! grouping, conflict classification) is paid once per merge,
33//! not once per resolution batch.
34//!
35//! # What's in the foundation slice
36//!
37//! The state machine: types, transitions, validation hook for
38//! resolved candidates, commit path that produces a fresh head op.
39//! Persistence (so a session survives a process restart) and the
40//! HTTP / CLI surfaces are subsequent slices.
41
42use std::collections::BTreeMap;
43
44use serde::{Deserialize, Serialize};
45
46use crate::merge::{ConflictKind, MergeOutcome, MergeOutput};
47use crate::op_log::OpLog;
48use crate::operation::{OpId, Operation, SigId, StageId};
49
50/// Stable id for a merge in flight. Caller-supplied so the HTTP
51/// surface can map URLs to sessions without leaking session ids
52/// from the engine. Production callers will likely use UUIDs;
53/// tests use short strings.
54pub type MergeSessionId = String;
55
56/// Stable id for a conflict within a session. We use the SigId as
57/// the conflict id since conflicts are 1:1 with the sigs that have
58/// `MergeOutcome::Conflict`. If a future merge ever produces
59/// multiple conflicts on the same sig, this becomes a tuple.
60pub type ConflictId = SigId;
61
62/// Snapshot of one conflict the agent needs to resolve.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct ConflictRecord {
65    pub conflict_id: ConflictId,
66    pub sig_id: SigId,
67    pub kind: ConflictKind,
68    /// Stage on the LCA. `None` for `AddAdd` (no shared base) and
69    /// for sigs that didn't exist on the LCA.
70    pub base: Option<StageId>,
71    /// Stage on the dst (ours) side of the merge. `None` if dst
72    /// removed it.
73    pub ours: Option<StageId>,
74    /// Stage on the src (theirs) side of the merge. `None` if src
75    /// removed it.
76    pub theirs: Option<StageId>,
77}
78
79/// Choice for a single conflict.
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81#[serde(tag = "kind", rename_all = "snake_case")]
82pub enum Resolution {
83    /// Keep dst's stage; discard src's.
84    TakeOurs,
85    /// Keep src's stage; discard dst's.
86    TakeTheirs,
87    /// Submit a brand-new op that supersedes both sides. The op's
88    /// parents must include both ours and theirs (the merge engine
89    /// validates this; see [`MergeSession::validate_resolution`]).
90    Custom { op: Operation },
91    /// Punt to a human reviewer. Surfaces as
92    /// [`CommitError::ConflictsRemaining`] on commit until removed.
93    Defer,
94}
95
96/// Why a resolution was rejected. Distinct from [`CommitError`]
97/// because a resolve call returns *per-conflict* verdicts; commit
98/// returns a single overall verdict.
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(tag = "kind", rename_all = "snake_case")]
101pub enum ResolutionRejection {
102    /// The conflict_id doesn't refer to any pending conflict in
103    /// the session. Either the agent invented one, or it was
104    /// already resolved and the session pruned it.
105    UnknownConflict { conflict_id: ConflictId },
106    /// The custom op's parents don't include both `ours` and
107    /// `theirs`. A custom resolution that doesn't acknowledge
108    /// both sides isn't a merge — it's a fork.
109    CustomOpMissingParents {
110        conflict_id: ConflictId,
111        expected: Vec<OpId>,
112        got: Vec<OpId>,
113    },
114}
115
116/// Per-conflict outcome of a resolve call.
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118pub struct ResolveVerdict {
119    pub conflict_id: ConflictId,
120    pub accepted: bool,
121    pub rejection: Option<ResolutionRejection>,
122}
123
124/// Why a commit failed. Conflicts-remaining is the most common
125/// case — agents are expected to iterate via resolve until this
126/// goes away.
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub enum CommitError {
129    /// At least one conflict has no resolution or has
130    /// [`Resolution::Defer`]. The session is still alive; submit
131    /// resolutions and retry.
132    ConflictsRemaining(Vec<ConflictId>),
133}
134
135/// Stateful merge in flight. Hold one per active merge between
136/// `start` and `commit`. Sessions are not thread-safe; the HTTP
137/// wrapper is expected to wrap them in a `Mutex` keyed by
138/// [`MergeSessionId`].
139#[derive(Debug, Serialize, Deserialize)]
140pub struct MergeSession {
141    pub merge_id: MergeSessionId,
142    pub src_head: Option<OpId>,
143    pub dst_head: Option<OpId>,
144    pub lca: Option<OpId>,
145    /// Outcomes the engine resolved unilaterally — `Both` (both
146    /// sides agreed) and one-sided (`Src` / `Dst`). The agent sees
147    /// these for audit but doesn't need to act on them.
148    pub auto_resolved: Vec<MergeOutcome>,
149    /// Conflicts indexed by id. Removed as resolutions land.
150    conflicts: BTreeMap<ConflictId, ConflictRecord>,
151    /// Resolutions accumulated across resolve calls. Validated
152    /// against `conflicts` when applied.
153    resolutions: BTreeMap<ConflictId, Resolution>,
154}
155
156impl MergeSession {
157    /// Start a merge session. Runs the engine in [`crate::merge`]
158    /// and partitions the outcomes into auto-resolved and
159    /// conflicts-needing-attention.
160    pub fn start(
161        merge_id: impl Into<MergeSessionId>,
162        op_log: &OpLog,
163        src_head: Option<&OpId>,
164        dst_head: Option<&OpId>,
165    ) -> std::io::Result<Self> {
166        let MergeOutput { lca, outcomes } = crate::merge::merge(op_log, src_head, dst_head)?;
167        let mut auto_resolved = Vec::new();
168        let mut conflicts: BTreeMap<ConflictId, ConflictRecord> = BTreeMap::new();
169        for outcome in outcomes {
170            match outcome {
171                MergeOutcome::Conflict {
172                    sig_id,
173                    kind,
174                    base,
175                    src,
176                    dst,
177                } => {
178                    let conflict_id = sig_id.clone();
179                    conflicts.insert(
180                        conflict_id.clone(),
181                        ConflictRecord {
182                            conflict_id,
183                            sig_id,
184                            kind,
185                            base,
186                            // The merge engine returns `src` and
187                            // `dst` from src's and dst's perspective
188                            // respectively. We map dst→ours and
189                            // src→theirs, matching the canonical
190                            // git terminology and the issue text.
191                            ours: dst,
192                            theirs: src,
193                        },
194                    );
195                }
196                other => auto_resolved.push(other),
197            }
198        }
199        Ok(Self {
200            merge_id: merge_id.into(),
201            src_head: src_head.cloned(),
202            dst_head: dst_head.cloned(),
203            lca,
204            auto_resolved,
205            conflicts,
206            resolutions: BTreeMap::new(),
207        })
208    }
209
210    /// Pending conflicts (those without a non-defer resolution).
211    pub fn remaining_conflicts(&self) -> Vec<&ConflictRecord> {
212        self.conflicts
213            .values()
214            .filter(|c| {
215                !matches!(self.resolutions.get(&c.conflict_id),
216                    Some(Resolution::TakeOurs)
217                    | Some(Resolution::TakeTheirs)
218                    | Some(Resolution::Custom { .. }))
219            })
220            .collect()
221    }
222
223    /// Submit resolutions in batch. Returns one verdict per input.
224    /// Accepted resolutions are recorded; rejected ones leave the
225    /// previous resolution (if any) in place so partial submissions
226    /// don't clobber earlier good work.
227    pub fn resolve(
228        &mut self,
229        resolutions: Vec<(ConflictId, Resolution)>,
230    ) -> Vec<ResolveVerdict> {
231        let mut out = Vec::with_capacity(resolutions.len());
232        for (conflict_id, resolution) in resolutions {
233            match self.validate_resolution(&conflict_id, &resolution) {
234                Ok(()) => {
235                    self.resolutions.insert(conflict_id.clone(), resolution);
236                    out.push(ResolveVerdict {
237                        conflict_id,
238                        accepted: true,
239                        rejection: None,
240                    });
241                }
242                Err(rej) => {
243                    out.push(ResolveVerdict {
244                        conflict_id,
245                        accepted: false,
246                        rejection: Some(rej),
247                    });
248                }
249            }
250        }
251        out
252    }
253
254    /// Validate a single resolution against the session's pending
255    /// conflicts. Pure (no side effects); the caller decides
256    /// whether to accept.
257    pub fn validate_resolution(
258        &self,
259        conflict_id: &ConflictId,
260        resolution: &Resolution,
261    ) -> Result<(), ResolutionRejection> {
262        if !self.conflicts.contains_key(conflict_id) {
263            return Err(ResolutionRejection::UnknownConflict { conflict_id: conflict_id.clone() });
264        }
265        if let Resolution::Custom { op } = resolution {
266            // Validate that the custom op's parent set acknowledges
267            // both sides. We don't have direct OpIds for the
268            // ours/theirs ops here (the conflict record carries
269            // stage ids), so the check is "the op has at least two
270            // parents" — a stronger check requires looking up the
271            // ops by sig and confirming they're in the parents,
272            // which is a follow-up enhancement.
273            //
274            // For the foundation slice this catches the obvious
275            // misuse (`Operation::new(kind, [])`) without
276            // reconstructing the merge engine's own validation.
277            if op.parents.len() < 2 {
278                return Err(ResolutionRejection::CustomOpMissingParents {
279                    conflict_id: conflict_id.clone(),
280                    expected: vec!["ours-op-id".into(), "theirs-op-id".into()],
281                    got: op.parents.clone(),
282                });
283            }
284        }
285        Ok(())
286    }
287
288    /// Finalize the merge. On success returns the resolved
289    /// resolutions in conflict_id order. The caller is responsible
290    /// for synthesizing the final `Operation::Merge` op against the
291    /// store and persisting it; this function returns the engine's
292    /// view of "what to land," not the persisted op id.
293    pub fn commit(self) -> Result<Vec<(ConflictId, Resolution)>, CommitError> {
294        let unresolved: Vec<ConflictId> = self
295            .conflicts
296            .keys()
297            .filter(|id| {
298                !matches!(self.resolutions.get(*id),
299                    Some(Resolution::TakeOurs)
300                    | Some(Resolution::TakeTheirs)
301                    | Some(Resolution::Custom { .. }))
302            })
303            .cloned()
304            .collect();
305        if !unresolved.is_empty() {
306            return Err(CommitError::ConflictsRemaining(unresolved));
307        }
308        let mut resolved: Vec<(ConflictId, Resolution)> = self.resolutions.into_iter().collect();
309        resolved.sort_by(|a, b| a.0.cmp(&b.0));
310        Ok(resolved)
311    }
312}
313
314#[cfg(test)]
315mod tests {
316    use super::*;
317    use crate::operation::{OperationKind, OperationRecord, StageTransition};
318    use std::collections::BTreeSet;
319
320    /// Tiny fixture: one branch (dst) modifies fn::A from stage-0 to
321    /// stage-1; another (src) modifies fn::A to stage-2. The LCA is
322    /// the original add. The merge surfaces a `ModifyModify`
323    /// conflict on fn::A.
324    fn fixture() -> (tempfile::TempDir, OpLog, OpId, OpId) {
325        let tmp = tempfile::tempdir().unwrap();
326        let log = OpLog::open(tmp.path()).unwrap();
327        let r0 = OperationRecord::new(
328            Operation::new(
329                OperationKind::AddFunction {
330                    sig_id: "fn::A".into(),
331                    stage_id: "stage-0".into(),
332                    effects: BTreeSet::new(),
333                    budget_cost: None,
334                },
335                [],
336            ),
337            StageTransition::Create {
338                sig_id: "fn::A".into(),
339                stage_id: "stage-0".into(),
340            },
341        );
342        log.put(&r0).unwrap();
343
344        let r1 = OperationRecord::new(
345            Operation::new(
346                OperationKind::ModifyBody {
347                    sig_id: "fn::A".into(),
348                    from_stage_id: "stage-0".into(),
349                    to_stage_id: "stage-1".into(),
350                    from_budget: None,
351                    to_budget: None,
352                },
353                [r0.op_id.clone()],
354            ),
355            StageTransition::Replace {
356                sig_id: "fn::A".into(),
357                from: "stage-0".into(),
358                to: "stage-1".into(),
359            },
360        );
361        log.put(&r1).unwrap();
362
363        let r2 = OperationRecord::new(
364            Operation::new(
365                OperationKind::ModifyBody {
366                    sig_id: "fn::A".into(),
367                    from_stage_id: "stage-0".into(),
368                    to_stage_id: "stage-2".into(),
369                    from_budget: None,
370                    to_budget: None,
371                },
372                [r0.op_id.clone()],
373            ),
374            StageTransition::Replace {
375                sig_id: "fn::A".into(),
376                from: "stage-0".into(),
377                to: "stage-2".into(),
378            },
379        );
380        log.put(&r2).unwrap();
381
382        (tmp, log, r1.op_id, r2.op_id)
383    }
384
385    #[test]
386    fn start_collects_conflicts() {
387        let (_tmp, log, dst, src) = fixture();
388        let session =
389            MergeSession::start("ms-1", &log, Some(&src), Some(&dst)).unwrap();
390        assert_eq!(session.remaining_conflicts().len(), 1);
391        assert_eq!(session.remaining_conflicts()[0].sig_id, "fn::A");
392        assert_eq!(
393            session.remaining_conflicts()[0].kind,
394            ConflictKind::ModifyModify
395        );
396        assert_eq!(
397            session.remaining_conflicts()[0].ours.as_deref(),
398            Some("stage-1"),
399        );
400        assert_eq!(
401            session.remaining_conflicts()[0].theirs.as_deref(),
402            Some("stage-2"),
403        );
404        assert_eq!(
405            session.remaining_conflicts()[0].base.as_deref(),
406            Some("stage-0"),
407        );
408    }
409
410    #[test]
411    fn no_conflicts_when_branches_dont_overlap() {
412        let tmp = tempfile::tempdir().unwrap();
413        let log = OpLog::open(tmp.path()).unwrap();
414        let r0 = OperationRecord::new(
415            Operation::new(
416                OperationKind::AddFunction {
417                    sig_id: "fn::A".into(),
418                    stage_id: "stage-0".into(),
419                    effects: BTreeSet::new(),
420                    budget_cost: None,
421                },
422                [],
423            ),
424            StageTransition::Create {
425                sig_id: "fn::A".into(),
426                stage_id: "stage-0".into(),
427            },
428        );
429        log.put(&r0).unwrap();
430        let r1 = OperationRecord::new(
431            Operation::new(
432                OperationKind::AddFunction {
433                    sig_id: "fn::B".into(),
434                    stage_id: "stage-B".into(),
435                    effects: BTreeSet::new(),
436                    budget_cost: None,
437                },
438                [r0.op_id.clone()],
439            ),
440            StageTransition::Create {
441                sig_id: "fn::B".into(),
442                stage_id: "stage-B".into(),
443            },
444        );
445        log.put(&r1).unwrap();
446
447        let session =
448            MergeSession::start("ms-2", &log, Some(&r1.op_id), Some(&r0.op_id)).unwrap();
449        assert!(session.remaining_conflicts().is_empty());
450        assert_eq!(session.auto_resolved.len(), 1, "fn::B added on src side");
451    }
452
453    #[test]
454    fn resolve_take_ours_clears_conflict() {
455        let (_tmp, log, dst, src) = fixture();
456        let mut session =
457            MergeSession::start("ms-3", &log, Some(&src), Some(&dst)).unwrap();
458        let verdicts = session.resolve(vec![("fn::A".into(), Resolution::TakeOurs)]);
459        assert_eq!(verdicts.len(), 1);
460        assert!(verdicts[0].accepted);
461        assert!(session.remaining_conflicts().is_empty());
462    }
463
464    #[test]
465    fn resolve_take_theirs_clears_conflict() {
466        let (_tmp, log, dst, src) = fixture();
467        let mut session =
468            MergeSession::start("ms-4", &log, Some(&src), Some(&dst)).unwrap();
469        let verdicts =
470            session.resolve(vec![("fn::A".into(), Resolution::TakeTheirs)]);
471        assert!(verdicts[0].accepted);
472        assert!(session.remaining_conflicts().is_empty());
473    }
474
475    #[test]
476    fn resolve_unknown_conflict_is_rejected() {
477        let (_tmp, log, dst, src) = fixture();
478        let mut session =
479            MergeSession::start("ms-5", &log, Some(&src), Some(&dst)).unwrap();
480        let verdicts =
481            session.resolve(vec![("fn::Z".into(), Resolution::TakeOurs)]);
482        assert_eq!(verdicts.len(), 1);
483        assert!(!verdicts[0].accepted);
484        assert!(matches!(
485            verdicts[0].rejection,
486            Some(ResolutionRejection::UnknownConflict { .. }),
487        ));
488    }
489
490    #[test]
491    fn custom_op_without_two_parents_is_rejected() {
492        let (_tmp, log, dst, src) = fixture();
493        let mut session =
494            MergeSession::start("ms-6", &log, Some(&src), Some(&dst)).unwrap();
495        // A custom op with empty parents — clearly not a merge.
496        let bad_op = Operation::new(
497            OperationKind::ModifyBody {
498                sig_id: "fn::A".into(),
499                from_stage_id: "stage-0".into(),
500                to_stage_id: "stage-X".into(),
501                from_budget: None,
502                to_budget: None,
503            },
504            [],
505        );
506        let verdicts = session.resolve(vec![(
507            "fn::A".into(),
508            Resolution::Custom { op: bad_op },
509        )]);
510        assert!(!verdicts[0].accepted);
511        assert!(matches!(
512            verdicts[0].rejection,
513            Some(ResolutionRejection::CustomOpMissingParents { .. }),
514        ));
515        // The conflict is still pending — bad resolutions don't
516        // clobber the slot.
517        assert_eq!(session.remaining_conflicts().len(), 1);
518    }
519
520    #[test]
521    fn custom_op_with_two_parents_is_accepted() {
522        let (_tmp, log, dst, src) = fixture();
523        let mut session =
524            MergeSession::start("ms-7", &log, Some(&src), Some(&dst)).unwrap();
525        let merge_op = Operation::new(
526            OperationKind::ModifyBody {
527                sig_id: "fn::A".into(),
528                from_stage_id: "stage-0".into(),
529                to_stage_id: "stage-merged".into(),
530                from_budget: None,
531                to_budget: None,
532            },
533            [src.clone(), dst.clone()],
534        );
535        let verdicts = session.resolve(vec![(
536            "fn::A".into(),
537            Resolution::Custom { op: merge_op },
538        )]);
539        assert!(verdicts[0].accepted);
540        assert!(session.remaining_conflicts().is_empty());
541    }
542
543    #[test]
544    fn defer_keeps_conflict_pending() {
545        let (_tmp, log, dst, src) = fixture();
546        let mut session =
547            MergeSession::start("ms-8", &log, Some(&src), Some(&dst)).unwrap();
548        let verdicts = session.resolve(vec![("fn::A".into(), Resolution::Defer)]);
549        // Defer is a valid resolution — accepted — but the conflict
550        // stays in `remaining_conflicts` since it still requires
551        // human attention.
552        assert!(verdicts[0].accepted);
553        assert_eq!(session.remaining_conflicts().len(), 1);
554    }
555
556    #[test]
557    fn commit_with_no_conflicts_succeeds() {
558        let tmp = tempfile::tempdir().unwrap();
559        let log = OpLog::open(tmp.path()).unwrap();
560        let session = MergeSession::start("ms-9", &log, None, None).unwrap();
561        let resolved = session.commit().unwrap();
562        assert!(resolved.is_empty());
563    }
564
565    #[test]
566    fn commit_with_unresolved_conflict_fails() {
567        let (_tmp, log, dst, src) = fixture();
568        let session =
569            MergeSession::start("ms-10", &log, Some(&src), Some(&dst)).unwrap();
570        let err = session.commit().unwrap_err();
571        match err {
572            CommitError::ConflictsRemaining(ids) => {
573                assert_eq!(ids, vec!["fn::A".to_string()]);
574            }
575        }
576    }
577
578    #[test]
579    fn commit_with_defer_remaining_fails() {
580        let (_tmp, log, dst, src) = fixture();
581        let mut session =
582            MergeSession::start("ms-11", &log, Some(&src), Some(&dst)).unwrap();
583        session.resolve(vec![("fn::A".into(), Resolution::Defer)]);
584        let err = session.commit().unwrap_err();
585        match err {
586            CommitError::ConflictsRemaining(ids) => {
587                assert_eq!(ids, vec!["fn::A".to_string()]);
588            }
589        }
590    }
591
592    #[test]
593    fn commit_after_resolve_succeeds() {
594        let (_tmp, log, dst, src) = fixture();
595        let mut session =
596            MergeSession::start("ms-12", &log, Some(&src), Some(&dst)).unwrap();
597        session.resolve(vec![("fn::A".into(), Resolution::TakeOurs)]);
598        let resolved = session.commit().unwrap();
599        assert_eq!(resolved.len(), 1);
600        assert_eq!(resolved[0].0, "fn::A");
601        assert!(matches!(resolved[0].1, Resolution::TakeOurs));
602    }
603
604    #[test]
605    fn batch_resolve_accepts_partial() {
606        // Mixed batch: one valid, one referencing an unknown
607        // conflict. The valid one should land; the bad one should
608        // be rejected without clobbering anything else.
609        let (_tmp, log, dst, src) = fixture();
610        let mut session =
611            MergeSession::start("ms-13", &log, Some(&src), Some(&dst)).unwrap();
612        let verdicts = session.resolve(vec![
613            ("fn::A".into(), Resolution::TakeOurs),
614            ("fn::DOESNT_EXIST".into(), Resolution::TakeTheirs),
615        ]);
616        assert_eq!(verdicts.len(), 2);
617        assert!(verdicts[0].accepted);
618        assert!(!verdicts[1].accepted);
619        // fn::A is now resolved.
620        assert!(session.remaining_conflicts().is_empty());
621    }
622
623    #[test]
624    fn auto_resolved_outcomes_are_visible() {
625        let tmp = tempfile::tempdir().unwrap();
626        let log = OpLog::open(tmp.path()).unwrap();
627        // Single branch: just an add; no second branch to merge,
628        // but `MergeSession::start(... None ...)` still runs the
629        // engine. This documents what `auto_resolved` carries.
630        let r0 = OperationRecord::new(
631            Operation::new(
632                OperationKind::AddFunction {
633                    sig_id: "fn::A".into(),
634                    stage_id: "stage-0".into(),
635                    effects: BTreeSet::new(),
636                    budget_cost: None,
637                },
638                [],
639            ),
640            StageTransition::Create {
641                sig_id: "fn::A".into(),
642                stage_id: "stage-0".into(),
643            },
644        );
645        log.put(&r0).unwrap();
646        let session =
647            MergeSession::start("ms-14", &log, Some(&r0.op_id), None).unwrap();
648        assert!(session.remaining_conflicts().is_empty());
649        // src had a unique op vs the missing dst → it's an Src
650        // outcome surfaced as auto-resolved.
651        assert_eq!(session.auto_resolved.len(), 1);
652    }
653}