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                },
334                [],
335            ),
336            StageTransition::Create {
337                sig_id: "fn::A".into(),
338                stage_id: "stage-0".into(),
339            },
340        );
341        log.put(&r0).unwrap();
342
343        let r1 = OperationRecord::new(
344            Operation::new(
345                OperationKind::ModifyBody {
346                    sig_id: "fn::A".into(),
347                    from_stage_id: "stage-0".into(),
348                    to_stage_id: "stage-1".into(),
349                },
350                [r0.op_id.clone()],
351            ),
352            StageTransition::Replace {
353                sig_id: "fn::A".into(),
354                from: "stage-0".into(),
355                to: "stage-1".into(),
356            },
357        );
358        log.put(&r1).unwrap();
359
360        let r2 = OperationRecord::new(
361            Operation::new(
362                OperationKind::ModifyBody {
363                    sig_id: "fn::A".into(),
364                    from_stage_id: "stage-0".into(),
365                    to_stage_id: "stage-2".into(),
366                },
367                [r0.op_id.clone()],
368            ),
369            StageTransition::Replace {
370                sig_id: "fn::A".into(),
371                from: "stage-0".into(),
372                to: "stage-2".into(),
373            },
374        );
375        log.put(&r2).unwrap();
376
377        (tmp, log, r1.op_id, r2.op_id)
378    }
379
380    #[test]
381    fn start_collects_conflicts() {
382        let (_tmp, log, dst, src) = fixture();
383        let session =
384            MergeSession::start("ms-1", &log, Some(&src), Some(&dst)).unwrap();
385        assert_eq!(session.remaining_conflicts().len(), 1);
386        assert_eq!(session.remaining_conflicts()[0].sig_id, "fn::A");
387        assert_eq!(
388            session.remaining_conflicts()[0].kind,
389            ConflictKind::ModifyModify
390        );
391        assert_eq!(
392            session.remaining_conflicts()[0].ours.as_deref(),
393            Some("stage-1"),
394        );
395        assert_eq!(
396            session.remaining_conflicts()[0].theirs.as_deref(),
397            Some("stage-2"),
398        );
399        assert_eq!(
400            session.remaining_conflicts()[0].base.as_deref(),
401            Some("stage-0"),
402        );
403    }
404
405    #[test]
406    fn no_conflicts_when_branches_dont_overlap() {
407        let tmp = tempfile::tempdir().unwrap();
408        let log = OpLog::open(tmp.path()).unwrap();
409        let r0 = OperationRecord::new(
410            Operation::new(
411                OperationKind::AddFunction {
412                    sig_id: "fn::A".into(),
413                    stage_id: "stage-0".into(),
414                    effects: BTreeSet::new(),
415                },
416                [],
417            ),
418            StageTransition::Create {
419                sig_id: "fn::A".into(),
420                stage_id: "stage-0".into(),
421            },
422        );
423        log.put(&r0).unwrap();
424        let r1 = OperationRecord::new(
425            Operation::new(
426                OperationKind::AddFunction {
427                    sig_id: "fn::B".into(),
428                    stage_id: "stage-B".into(),
429                    effects: BTreeSet::new(),
430                },
431                [r0.op_id.clone()],
432            ),
433            StageTransition::Create {
434                sig_id: "fn::B".into(),
435                stage_id: "stage-B".into(),
436            },
437        );
438        log.put(&r1).unwrap();
439
440        let session =
441            MergeSession::start("ms-2", &log, Some(&r1.op_id), Some(&r0.op_id)).unwrap();
442        assert!(session.remaining_conflicts().is_empty());
443        assert_eq!(session.auto_resolved.len(), 1, "fn::B added on src side");
444    }
445
446    #[test]
447    fn resolve_take_ours_clears_conflict() {
448        let (_tmp, log, dst, src) = fixture();
449        let mut session =
450            MergeSession::start("ms-3", &log, Some(&src), Some(&dst)).unwrap();
451        let verdicts = session.resolve(vec![("fn::A".into(), Resolution::TakeOurs)]);
452        assert_eq!(verdicts.len(), 1);
453        assert!(verdicts[0].accepted);
454        assert!(session.remaining_conflicts().is_empty());
455    }
456
457    #[test]
458    fn resolve_take_theirs_clears_conflict() {
459        let (_tmp, log, dst, src) = fixture();
460        let mut session =
461            MergeSession::start("ms-4", &log, Some(&src), Some(&dst)).unwrap();
462        let verdicts =
463            session.resolve(vec![("fn::A".into(), Resolution::TakeTheirs)]);
464        assert!(verdicts[0].accepted);
465        assert!(session.remaining_conflicts().is_empty());
466    }
467
468    #[test]
469    fn resolve_unknown_conflict_is_rejected() {
470        let (_tmp, log, dst, src) = fixture();
471        let mut session =
472            MergeSession::start("ms-5", &log, Some(&src), Some(&dst)).unwrap();
473        let verdicts =
474            session.resolve(vec![("fn::Z".into(), Resolution::TakeOurs)]);
475        assert_eq!(verdicts.len(), 1);
476        assert!(!verdicts[0].accepted);
477        assert!(matches!(
478            verdicts[0].rejection,
479            Some(ResolutionRejection::UnknownConflict { .. }),
480        ));
481    }
482
483    #[test]
484    fn custom_op_without_two_parents_is_rejected() {
485        let (_tmp, log, dst, src) = fixture();
486        let mut session =
487            MergeSession::start("ms-6", &log, Some(&src), Some(&dst)).unwrap();
488        // A custom op with empty parents — clearly not a merge.
489        let bad_op = Operation::new(
490            OperationKind::ModifyBody {
491                sig_id: "fn::A".into(),
492                from_stage_id: "stage-0".into(),
493                to_stage_id: "stage-X".into(),
494            },
495            [],
496        );
497        let verdicts = session.resolve(vec![(
498            "fn::A".into(),
499            Resolution::Custom { op: bad_op },
500        )]);
501        assert!(!verdicts[0].accepted);
502        assert!(matches!(
503            verdicts[0].rejection,
504            Some(ResolutionRejection::CustomOpMissingParents { .. }),
505        ));
506        // The conflict is still pending — bad resolutions don't
507        // clobber the slot.
508        assert_eq!(session.remaining_conflicts().len(), 1);
509    }
510
511    #[test]
512    fn custom_op_with_two_parents_is_accepted() {
513        let (_tmp, log, dst, src) = fixture();
514        let mut session =
515            MergeSession::start("ms-7", &log, Some(&src), Some(&dst)).unwrap();
516        let merge_op = Operation::new(
517            OperationKind::ModifyBody {
518                sig_id: "fn::A".into(),
519                from_stage_id: "stage-0".into(),
520                to_stage_id: "stage-merged".into(),
521            },
522            [src.clone(), dst.clone()],
523        );
524        let verdicts = session.resolve(vec![(
525            "fn::A".into(),
526            Resolution::Custom { op: merge_op },
527        )]);
528        assert!(verdicts[0].accepted);
529        assert!(session.remaining_conflicts().is_empty());
530    }
531
532    #[test]
533    fn defer_keeps_conflict_pending() {
534        let (_tmp, log, dst, src) = fixture();
535        let mut session =
536            MergeSession::start("ms-8", &log, Some(&src), Some(&dst)).unwrap();
537        let verdicts = session.resolve(vec![("fn::A".into(), Resolution::Defer)]);
538        // Defer is a valid resolution — accepted — but the conflict
539        // stays in `remaining_conflicts` since it still requires
540        // human attention.
541        assert!(verdicts[0].accepted);
542        assert_eq!(session.remaining_conflicts().len(), 1);
543    }
544
545    #[test]
546    fn commit_with_no_conflicts_succeeds() {
547        let tmp = tempfile::tempdir().unwrap();
548        let log = OpLog::open(tmp.path()).unwrap();
549        let session = MergeSession::start("ms-9", &log, None, None).unwrap();
550        let resolved = session.commit().unwrap();
551        assert!(resolved.is_empty());
552    }
553
554    #[test]
555    fn commit_with_unresolved_conflict_fails() {
556        let (_tmp, log, dst, src) = fixture();
557        let session =
558            MergeSession::start("ms-10", &log, Some(&src), Some(&dst)).unwrap();
559        let err = session.commit().unwrap_err();
560        match err {
561            CommitError::ConflictsRemaining(ids) => {
562                assert_eq!(ids, vec!["fn::A".to_string()]);
563            }
564        }
565    }
566
567    #[test]
568    fn commit_with_defer_remaining_fails() {
569        let (_tmp, log, dst, src) = fixture();
570        let mut session =
571            MergeSession::start("ms-11", &log, Some(&src), Some(&dst)).unwrap();
572        session.resolve(vec![("fn::A".into(), Resolution::Defer)]);
573        let err = session.commit().unwrap_err();
574        match err {
575            CommitError::ConflictsRemaining(ids) => {
576                assert_eq!(ids, vec!["fn::A".to_string()]);
577            }
578        }
579    }
580
581    #[test]
582    fn commit_after_resolve_succeeds() {
583        let (_tmp, log, dst, src) = fixture();
584        let mut session =
585            MergeSession::start("ms-12", &log, Some(&src), Some(&dst)).unwrap();
586        session.resolve(vec![("fn::A".into(), Resolution::TakeOurs)]);
587        let resolved = session.commit().unwrap();
588        assert_eq!(resolved.len(), 1);
589        assert_eq!(resolved[0].0, "fn::A");
590        assert!(matches!(resolved[0].1, Resolution::TakeOurs));
591    }
592
593    #[test]
594    fn batch_resolve_accepts_partial() {
595        // Mixed batch: one valid, one referencing an unknown
596        // conflict. The valid one should land; the bad one should
597        // be rejected without clobbering anything else.
598        let (_tmp, log, dst, src) = fixture();
599        let mut session =
600            MergeSession::start("ms-13", &log, Some(&src), Some(&dst)).unwrap();
601        let verdicts = session.resolve(vec![
602            ("fn::A".into(), Resolution::TakeOurs),
603            ("fn::DOESNT_EXIST".into(), Resolution::TakeTheirs),
604        ]);
605        assert_eq!(verdicts.len(), 2);
606        assert!(verdicts[0].accepted);
607        assert!(!verdicts[1].accepted);
608        // fn::A is now resolved.
609        assert!(session.remaining_conflicts().is_empty());
610    }
611
612    #[test]
613    fn auto_resolved_outcomes_are_visible() {
614        let tmp = tempfile::tempdir().unwrap();
615        let log = OpLog::open(tmp.path()).unwrap();
616        // Single branch: just an add; no second branch to merge,
617        // but `MergeSession::start(... None ...)` still runs the
618        // engine. This documents what `auto_resolved` carries.
619        let r0 = OperationRecord::new(
620            Operation::new(
621                OperationKind::AddFunction {
622                    sig_id: "fn::A".into(),
623                    stage_id: "stage-0".into(),
624                    effects: BTreeSet::new(),
625                },
626                [],
627            ),
628            StageTransition::Create {
629                sig_id: "fn::A".into(),
630                stage_id: "stage-0".into(),
631            },
632        );
633        log.put(&r0).unwrap();
634        let session =
635            MergeSession::start("ms-14", &log, Some(&r0.op_id), None).unwrap();
636        assert!(session.remaining_conflicts().is_empty());
637        // src had a unique op vs the missing dst → it's an Src
638        // outcome surfaced as auto-resolved.
639        assert_eq!(session.auto_resolved.len(), 1);
640    }
641}