From d7cec2cf4b6f8b787fecb1e1ed1806067de7e3fc Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 29 Apr 2026 08:42:43 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(schema):=20rivet=20schema=20migrate=20?= =?UTF-8?q?Phase=202=20=E2=80=94=20conflict=20resolution=20UX?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of issue #236. Phase 1 (in 0.6.0) shipped the diff engine and mechanical apply with snapshot/abort. Phase 2 adds the rebase-style conflict-resolution flow. Engine (rivet-core/src/migrate.rs): * `MigrationState::Conflict` joins the existing `Planned / InProgress / Complete` states. * `MigrationManifest.resolutions` tracks per-artifact `pending / resolved / skipped` status across `--apply / --continue / --skip / --edit`. * `MigrationLayout::current_conflict_path` writes the artifact id the walker paused on; `--status` surfaces it. * `diff_artifacts` now emits `FieldValueConflict` for any source field whose value violates the target field's `allowed_values` enum (e.g. `priority: 5` → `[must|should|could|wont]`). * `apply_to_file_partial` skips conflict-class entries; the `--apply` walker uses it so mechanical changes always commit before pausing. * `write_conflict_markers` splices git-rebase-style `<<<<<<<` / `=======` / `>>>>>>>` blocks into the affected field. `scan_conflict_markers` is the inverse used by `--continue` and the `MigrationConflict` doc-check invariant. * `restore_artifact_from_snapshot` swaps a single artifact back to its pre-migration form for `--skip`. CLI (rivet-cli/src/migrate_cmd.rs + main.rs): * `--apply` no longer bails on conflicts — it walks the plan, applies every mechanical/decidable change, then writes markers for the first conflict and exits non-zero with state CONFLICT. * `--continue` verifies markers are gone, re-parses the file as YAML, marks resolved, advances. * `--skip` rebuilds the file from the snapshot (mechanical-pass applied to other artifacts in the same file) and restores the conflicted artifact's pre-migration form. * `--edit ` re-stamps markers on a previously-resolved or skipped conflict. * `--status` reports CONFLICT state plus the current conflict's id and file, with next-step suggestions. Validation (rivet-core/src/doc_check.rs): * `MigrationConflict` doc-invariant scans every `*.yaml` / `*.yml` under `/artifacts/` and emits a violation for any line that begins with `<<<<<<<` / `=======` / `>>>>>>>`. Prevents accidental commits with leftover markers. Tests (rivet-core/src/migrate.rs + rivet-cli/tests/migrate_integration.rs): * 7 new unit tests covering enum-mismatch detection, marker round trip, scan, restore-from-snapshot, partial-apply, plan lookup, and Conflict state roundtrip. * 6 new integration tests covering the apply-pauses-on-conflict flow, --continue success, --continue marker rejection, --skip restore, --edit re-open, and the docs-check MigrationConflict surface. Phase 3 (deferred): dashboard `/migrations/` view, `rivet recipes` subcommand for recipe distribution, provenance entries on migrated artifacts. Implements: REQ-007, REQ-010 Implements: REQ-004 Verifies: REQ-007, REQ-010, REQ-004 Co-Authored-By: Claude Opus 4.7 (1M context) --- rivet-cli/src/main.rs | 53 +- rivet-cli/src/migrate_cmd.rs | 439 ++++++++++++++++- rivet-cli/tests/migrate_integration.rs | 285 ++++++++++- rivet-core/src/doc_check.rs | 83 ++++ rivet-core/src/migrate.rs | 657 ++++++++++++++++++++++++- 5 files changed, 1456 insertions(+), 61 deletions(-) diff --git a/rivet-cli/src/main.rs b/rivet-cli/src/main.rs index c955cba..82ba62f 100644 --- a/rivet-cli/src/main.rs +++ b/rivet-cli/src/main.rs @@ -988,37 +988,55 @@ enum SchemaAction { #[arg(long)] content: bool, }, - /// Migrate artifacts from one preset/version to another (Phase 1: mechanical-only). + /// Migrate artifacts from one preset/version to another. /// - /// Phase 1 of issue #236. Default is plan-only (dry-run). Use - /// `--apply` to rewrite artifact YAML in place against the target - /// preset. `--abort` restores from the pre-migration snapshot. - /// `--status` reports current state. `--finish` deletes the - /// snapshot after `rivet validate` confirms the migrated tree is - /// healthy. + /// Phase 1 of issue #236 shipped the diff engine + mechanical + /// apply. Phase 2 adds rebase-style conflict resolution + /// (`--continue`, `--skip`, `--edit`). + /// + /// Default is plan-only (dry-run). Use `--apply` to rewrite + /// artifact YAML in place; the CLI pauses at the first conflict + /// (writing markers into the file) and you resolve them + /// interactively. `--abort` restores from snapshot. /// /// See `rivet docs schema-migrate` for the full guide. + #[command(disable_help_flag = false)] Migrate { /// Target preset (e.g., "aspice"). Source is inferred from /// the project's current `rivet.yaml`. target: String, - /// Apply the migration (mechanical-only in Phase 1; bails on - /// any conflict). - #[arg(long, conflicts_with_all = ["abort", "status", "finish"])] + /// Apply the migration; pause on first conflict (Phase 2). + #[arg(long, conflicts_with_all = ["abort", "status", "finish", "continue_", "skip", "edit"])] apply: bool, /// Abort the in-flight migration and restore from snapshot. - #[arg(long, conflicts_with_all = ["apply", "status", "finish"])] + #[arg(long, conflicts_with_all = ["apply", "status", "finish", "continue_", "skip", "edit"])] abort: bool, /// Print the current migration state machine pointer. - #[arg(long, conflicts_with_all = ["apply", "abort", "finish"])] + #[arg(long, conflicts_with_all = ["apply", "abort", "finish", "continue_", "skip", "edit"])] status: bool, /// Validate and finalize a COMPLETE migration (deletes snapshot). - #[arg(long, conflicts_with_all = ["apply", "abort", "status"])] + #[arg(long, conflicts_with_all = ["apply", "abort", "status", "continue_", "skip", "edit"])] finish: bool, + + /// Resume after resolving the current conflict in-place + /// (Phase 2). Verifies markers are gone and the file still + /// parses, then advances. + #[arg(long = "continue", conflicts_with_all = ["apply", "abort", "status", "finish", "skip", "edit"])] + continue_: bool, + + /// Drop the current conflicted artifact from the migration + /// (restores it from the snapshot) and advance (Phase 2). + #[arg(long, conflicts_with_all = ["apply", "abort", "status", "finish", "continue_", "edit"])] + skip: bool, + + /// Re-open a previously-resolved or skipped conflict for + /// re-editing (Phase 2). Takes the artifact id. + #[arg(long, value_name = "ARTIFACT_ID", conflicts_with_all = ["apply", "abort", "status", "finish", "continue_", "skip"])] + edit: Option, }, } @@ -7245,6 +7263,9 @@ fn cmd_schema(cli: &Cli, action: &SchemaAction) -> Result { abort, status, finish, + continue_, + skip, + edit, } => { let schemas_dir = resolve_schemas_dir(cli); let project_root = cli.project.clone(); @@ -7262,6 +7283,12 @@ fn cmd_schema(cli: &Cli, action: &SchemaAction) -> Result { migrate_cmd::cmd_status(&project_root) } else if *finish { migrate_cmd::cmd_finish(&project_root) + } else if *continue_ { + migrate_cmd::cmd_continue(&project_root, &schemas_dir) + } else if *skip { + migrate_cmd::cmd_skip(&project_root, &schemas_dir) + } else if let Some(id) = edit { + migrate_cmd::cmd_edit(&project_root, id) } else if *apply { migrate_cmd::cmd_apply(&project_root, &schemas_dir, &source_preset, target) } else { diff --git a/rivet-cli/src/migrate_cmd.rs b/rivet-cli/src/migrate_cmd.rs index 513da94..0bafbc7 100644 --- a/rivet-cli/src/migrate_cmd.rs +++ b/rivet-cli/src/migrate_cmd.rs @@ -1,14 +1,19 @@ -//! `rivet schema migrate` — Phase 1 implementation of issue #236. +//! `rivet schema migrate` — Phase 1 + Phase 2 of issue #236. //! -//! Mechanical-only migration with full snapshot/abort. No conflict -//! resolution UI yet (Phase 2). +//! Phase 1 shipped mechanical-only migration with full snapshot/abort. +//! Phase 2 adds the conflict-resolution UX: rebase-style conflict +//! markers in artifact YAML, plus `--continue`, `--skip`, `--edit`. //! //! Subcommands: //! * default (no flag) — plan only; writes plan.yaml + manifest.yaml -//! * `--apply` — applies mechanical-only changes; bails on conflict -//! * `--abort` — restores from snapshot -//! * `--status` — prints state machine pointer -//! * `--finish` — validates and deletes snapshot +//! * `--apply` — applies mechanical/decidable changes; pauses on the +//! first conflict and writes markers (Phase 2) +//! * `--continue` — verify markers gone + validate, advance to next conflict +//! * `--skip` — restore the conflicted artifact from snapshot, advance +//! * `--edit ` — re-open a previously-resolved conflict +//! * `--abort` — restores entire project from snapshot +//! * `--status` — prints state machine pointer + current conflict +//! * `--finish` — validates and deletes snapshot // SAFETY-REVIEW (SCRC Phase 1, DD-058): see schema_cmd.rs for rationale. #![allow( @@ -35,8 +40,8 @@ use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result}; use rivet_core::migrate::{ - self, ActionClass, MigrationLayout, MigrationManifest, MigrationRecipeFile, MigrationState, - RewriteMap, + self, ActionClass, ChangeKind, MigrationLayout, MigrationManifest, MigrationRecipeFile, + MigrationState, PlannedChange, ResolutionStatus, RewriteMap, }; /// Resolve a recipe by `target_preset` against (in order): @@ -171,6 +176,12 @@ pub fn cmd_plan( } /// `rivet schema migrate --apply`. +/// +/// Phase 2: applies all mechanical/decidable changes immediately. If +/// the plan has conflicts, pauses at the first one — writing +/// rebase-style markers into the affected artifact YAML and setting +/// state to CONFLICT. The user resolves with `--continue` / `--skip` +/// / `--abort`. pub fn cmd_apply( project_root: &Path, schemas_dir: &Path, @@ -190,15 +201,6 @@ pub fn cmd_apply( // 2. Load the plan. let rewrite = read_plan(&layout)?; - if rewrite.has_conflicts() { - anyhow::bail!( - "migration plan has {} conflict(s); Phase 1 --apply is mechanical-only. \ - Inspect {} and resolve conflicts manually, or wait for Phase 2's \ - rebase-style conflict markers.", - rewrite.count(ActionClass::Conflict), - layout.plan_path().display(), - ); - } // 3. Mark IN_PROGRESS, snapshot the current state. layout.write_state(MigrationState::InProgress)?; @@ -208,14 +210,14 @@ pub fn cmd_apply( let recipe_file = resolve_recipe(schemas_dir, source_preset, target_preset)?; let recipe = &recipe_file.migration; - // 5. Apply per-file. + // 5. Apply mechanical/decidable per-file (skip conflict-class entries). let by_file = rewrite.by_file(); let mut rewrites_applied = 0usize; for (file_path, changes) in &by_file { - let path = PathBuf::from(file_path); + let path = resolve_artifact_path(project_root, file_path); let original = std::fs::read_to_string(&path) .with_context(|| format!("reading {}", path.display()))?; - let new_content = migrate::apply_to_file(&original, changes, recipe) + let new_content = migrate::apply_to_file_partial(&original, changes, recipe) .with_context(|| format!("rewriting {}", path.display()))?; if new_content != original { std::fs::write(&path, &new_content) @@ -224,7 +226,45 @@ pub fn cmd_apply( } } - // 6. Mark COMPLETE. + // 6. Pause at the first unresolved conflict, if any. + if let Some(conflict) = next_unresolved_conflict(&layout, &rewrite)? { + write_markers_for_conflict(&layout, &conflict, source_preset, target_preset)?; + layout.write_state(MigrationState::Conflict)?; + layout.write_current_conflict(Some(&conflict.artifact_id))?; + update_manifest_state(&layout, MigrationState::Conflict)?; + record_resolution(&layout, &conflict.artifact_id, ResolutionStatus::Pending)?; + + let total = rewrite.count(ActionClass::Conflict); + println!( + "Applied migration: {} (paused on conflict)", + rewrite.recipe_name + ); + println!(" files rewritten: {rewrites_applied}"); + println!(" state: CONFLICT"); + println!( + " current conflict: {} ({} of {})", + conflict.artifact_id, 1, total + ); + if let Some(file) = &conflict.source_file { + println!(" edit file: {file}"); + } + println!(); + println!("Next steps:"); + println!( + " 1. Open the file above and pick a value (remove the <<<<<<<, =======, >>>>>>> markers)." + ); + println!( + " 2. rivet schema migrate {target_preset} --continue # advance after resolving" + ); + println!( + " 3. rivet schema migrate {target_preset} --skip # drop this artifact from the migration" + ); + println!(" 4. rivet schema migrate {target_preset} --abort # restore everything"); + // Non-zero exit so CI catches an unfinished migration. + return Ok(false); + } + + // 7. No conflicts — full COMPLETE. layout.write_state(MigrationState::Complete)?; update_manifest_state(&layout, MigrationState::Complete)?; @@ -240,6 +280,217 @@ pub fn cmd_apply( Ok(true) } +/// `rivet schema migrate --continue`. +pub fn cmd_continue(project_root: &Path, schemas_dir: &Path) -> Result { + let layout = migrate::find_latest_migration(project_root) + .ok_or_else(|| anyhow::anyhow!("no migration directory found"))?; + let state = layout.read_state()?; + if state != MigrationState::Conflict { + anyhow::bail!( + "migration is in state '{}', not CONFLICT — nothing to continue", + state.as_str() + ); + } + let current = layout + .read_current_conflict() + .ok_or_else(|| anyhow::anyhow!("CONFLICT state but no current-conflict pointer"))?; + let rewrite = read_plan(&layout)?; + let conflict = migrate::first_conflict_for_artifact(&rewrite, ¤t).ok_or_else(|| { + anyhow::anyhow!("plan has no conflict for current-conflict pointer {current}") + })?; + let file_rel = conflict + .source_file + .clone() + .ok_or_else(|| anyhow::anyhow!("conflict has no source_file"))?; + let path = resolve_artifact_path(project_root, &file_rel); + let content = + std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?; + let hits = migrate::scan_conflict_markers(&content); + if !hits.is_empty() { + anyhow::bail!( + "{} still contains {} conflict marker(s) at line(s) {:?}; \ + remove them and pick a value before --continue", + path.display(), + hits.len(), + hits + ); + } + + // Sanity-check that the file still parses as YAML. + serde_yaml::from_str::(&content) + .with_context(|| format!("post-resolution {} is not valid YAML", path.display()))?; + + record_resolution(&layout, ¤t, ResolutionStatus::Resolved)?; + layout.write_current_conflict(None)?; + + // Advance to next conflict (if any). + if let Some(next) = next_unresolved_conflict(&layout, &rewrite)? { + // Determine source/target for marker labelling — the manifest + // captured them when the plan was written. + let manifest = read_manifest(&layout)?; + write_markers_for_conflict( + &layout, + &next, + &manifest.source_preset, + &manifest.target_preset, + )?; + layout.write_state(MigrationState::Conflict)?; + layout.write_current_conflict(Some(&next.artifact_id))?; + update_manifest_state(&layout, MigrationState::Conflict)?; + record_resolution(&layout, &next.artifact_id, ResolutionStatus::Pending)?; + + println!("Resolved {current}."); + println!("Next conflict: {}", next.artifact_id); + if let Some(f) = &next.source_file { + println!("Edit file: {f}"); + } + return Ok(false); + } + + layout.write_state(MigrationState::Complete)?; + update_manifest_state(&layout, MigrationState::Complete)?; + let _ = schemas_dir; // not needed once we read manifest above + println!("Resolved {current}."); + println!("Migration complete. State: COMPLETE."); + println!("Next: rivet validate"); + println!(" rivet schema migrate --finish"); + Ok(true) +} + +/// `rivet schema migrate --skip`. +pub fn cmd_skip(project_root: &Path, schemas_dir: &Path) -> Result { + let layout = migrate::find_latest_migration(project_root) + .ok_or_else(|| anyhow::anyhow!("no migration directory found"))?; + let state = layout.read_state()?; + if state != MigrationState::Conflict { + anyhow::bail!( + "migration is in state '{}', not CONFLICT — nothing to skip", + state.as_str() + ); + } + let current = layout + .read_current_conflict() + .ok_or_else(|| anyhow::anyhow!("CONFLICT state but no current-conflict pointer"))?; + let rewrite = read_plan(&layout)?; + let conflict = migrate::first_conflict_for_artifact(&rewrite, ¤t).ok_or_else(|| { + anyhow::anyhow!("plan has no conflict for current-conflict pointer {current}") + })?; + let file_rel = conflict + .source_file + .clone() + .ok_or_else(|| anyhow::anyhow!("conflict has no source_file"))?; + + // The snapshot stores files relative to project root. + let relative_for_snapshot: PathBuf = { + let p = PathBuf::from(&file_rel); + if p.is_absolute() { + p.strip_prefix(project_root).map(PathBuf::from).unwrap_or(p) + } else { + p + } + }; + + // The project file currently has conflict markers, so it isn't + // parseable YAML. Rebuild it from the snapshot by re-applying the + // mechanical/decidable changes for *all other* artifacts in the + // file, then swap in the snapshot's pristine copy of the + // conflicted artifact. + let manifest = read_manifest(&layout)?; + let recipe_file = resolve_recipe( + schemas_dir, + &manifest.source_preset, + &manifest.target_preset, + )?; + let recipe = &recipe_file.migration; + + let snap_file_path = layout.snapshot_dir().join(&relative_for_snapshot); + let snap_text = std::fs::read_to_string(&snap_file_path) + .with_context(|| format!("reading {}", snap_file_path.display()))?; + let rewrite = read_plan(&layout)?; + // All changes for this file, sans the conflicts on the artifact + // we're skipping (so the rest still gets the mechanical pass). + let changes_for_file: Vec<&PlannedChange> = rewrite + .changes + .iter() + .filter(|c| { + c.source_file + .as_deref() + .is_some_and(|f| f == file_rel.as_str()) + }) + .filter(|c| !(c.artifact_id == current && c.action == ActionClass::Conflict)) + .collect(); + let rebuilt = migrate::apply_to_file_partial(&snap_text, &changes_for_file, recipe) + .with_context(|| format!("rebuilding {}", snap_file_path.display()))?; + let abs_proj_path = resolve_artifact_path(project_root, &file_rel); + std::fs::write(&abs_proj_path, rebuilt) + .with_context(|| format!("writing {}", abs_proj_path.display()))?; + + // Now swap the conflicted artifact back to its pre-migration form. + migrate::restore_artifact_from_snapshot( + &layout.snapshot_dir(), + project_root, + &relative_for_snapshot, + ¤t, + ) + .with_context(|| format!("restoring {current} from snapshot"))?; + + record_resolution(&layout, ¤t, ResolutionStatus::Skipped)?; + layout.write_current_conflict(None)?; + + if let Some(next) = next_unresolved_conflict(&layout, &rewrite)? { + let manifest = read_manifest(&layout)?; + write_markers_for_conflict( + &layout, + &next, + &manifest.source_preset, + &manifest.target_preset, + )?; + layout.write_state(MigrationState::Conflict)?; + layout.write_current_conflict(Some(&next.artifact_id))?; + update_manifest_state(&layout, MigrationState::Conflict)?; + record_resolution(&layout, &next.artifact_id, ResolutionStatus::Pending)?; + println!("Skipped {current}. Restored from snapshot."); + println!("Next conflict: {}", next.artifact_id); + if let Some(f) = &next.source_file { + println!("Edit file: {f}"); + } + return Ok(false); + } + + layout.write_state(MigrationState::Complete)?; + update_manifest_state(&layout, MigrationState::Complete)?; + println!("Skipped {current}. Restored from snapshot."); + println!("Migration complete. State: COMPLETE."); + Ok(true) +} + +/// `rivet schema migrate --edit `. +pub fn cmd_edit(project_root: &Path, artifact_id: &str) -> Result { + let layout = migrate::find_latest_migration(project_root) + .ok_or_else(|| anyhow::anyhow!("no migration directory found"))?; + let rewrite = read_plan(&layout)?; + let conflict = migrate::first_conflict_for_artifact(&rewrite, artifact_id) + .ok_or_else(|| anyhow::anyhow!("no conflict in the plan for artifact {artifact_id}"))?; + let manifest = read_manifest(&layout)?; + write_markers_for_conflict( + &layout, + conflict, + &manifest.source_preset, + &manifest.target_preset, + )?; + layout.write_state(MigrationState::Conflict)?; + layout.write_current_conflict(Some(artifact_id))?; + update_manifest_state(&layout, MigrationState::Conflict)?; + record_resolution(&layout, artifact_id, ResolutionStatus::Pending)?; + println!("Re-opened conflict for {artifact_id}."); + println!("State: CONFLICT"); + if let Some(f) = &conflict.source_file { + println!("Edit file: {f}"); + } + println!("Run --continue or --skip after resolving."); + Ok(true) +} + /// `rivet schema migrate --abort`. pub fn cmd_abort(project_root: &Path) -> Result { let layout = migrate::find_latest_migration(project_root) @@ -280,6 +531,44 @@ pub fn cmd_status(project_root: &Path) -> Result { manifest.decidable_count, manifest.conflict_count ); + if !manifest.resolutions.is_empty() { + let resolved = manifest + .resolutions + .values() + .filter(|s| matches!(s, ResolutionStatus::Resolved)) + .count(); + let skipped = manifest + .resolutions + .values() + .filter(|s| matches!(s, ResolutionStatus::Skipped)) + .count(); + let pending = manifest + .resolutions + .values() + .filter(|s| matches!(s, ResolutionStatus::Pending)) + .count(); + println!( + "Resolutions: {resolved} resolved, {skipped} skipped, {pending} pending" + ); + } + } + } + if state == MigrationState::Conflict { + if let Some(current) = layout.read_current_conflict() { + println!("Current conflict: {current}"); + // Surface the file the user should edit. + if let Ok(rewrite) = read_plan(&layout) { + if let Some(c) = migrate::first_conflict_for_artifact(&rewrite, ¤t) { + if let Some(f) = &c.source_file { + println!("Edit file: {f}"); + } + } + } + println!(); + println!("Run one of:"); + println!(" rivet schema migrate --continue # after resolving"); + println!(" rivet schema migrate --skip # drop this artifact"); + println!(" rivet schema migrate --abort # restore everything"); } } } @@ -335,6 +624,7 @@ fn write_manifest( mechanical_count: rewrite.count(ActionClass::Mechanical), decidable_count: rewrite.count(ActionClass::DecidableWithPolicy), conflict_count: rewrite.count(ActionClass::Conflict), + resolutions: std::collections::BTreeMap::new(), }; let yaml = serde_yaml::to_string(&manifest).context("serializing manifest")?; std::fs::write(layout.manifest_path(), yaml) @@ -356,6 +646,111 @@ fn update_manifest_state(layout: &MigrationLayout, state: MigrationState) -> Res Ok(()) } +/// Resolve a path stored in plan.yaml against the project root. Handles +/// both absolute paths (Phase 1 — `load_project_full` stamps absolute +/// `source_file`s onto artifacts) and relative paths (test fixtures +/// hand-write the plan). +fn resolve_artifact_path(project_root: &Path, raw: &str) -> PathBuf { + let p = PathBuf::from(raw); + if p.is_absolute() { + p + } else { + project_root.join(p) + } +} + +fn read_manifest(layout: &MigrationLayout) -> Result { + let yaml = std::fs::read_to_string(layout.manifest_path()).context("reading manifest")?; + serde_yaml::from_str(&yaml).context("parsing manifest") +} + +fn record_resolution( + layout: &MigrationLayout, + artifact_id: &str, + status: ResolutionStatus, +) -> Result<()> { + let path = layout.manifest_path(); + if !path.exists() { + return Ok(()); + } + let yaml = std::fs::read_to_string(&path).context("reading manifest")?; + let mut manifest: MigrationManifest = + serde_yaml::from_str(&yaml).context("parsing manifest")?; + manifest.resolutions.insert(artifact_id.to_string(), status); + let yaml = serde_yaml::to_string(&manifest).context("serializing manifest")?; + std::fs::write(&path, yaml).context("writing manifest")?; + Ok(()) +} + +/// Find the first conflict in the plan that hasn't been resolved or +/// skipped yet. Order matches plan.yaml — stable across `--continue` +/// runs. +fn next_unresolved_conflict( + layout: &MigrationLayout, + rewrite: &RewriteMap, +) -> Result> { + let manifest = read_manifest(layout)?; + for change in &rewrite.changes { + if change.action != ActionClass::Conflict { + continue; + } + match manifest.resolutions.get(&change.artifact_id) { + Some(ResolutionStatus::Resolved) | Some(ResolutionStatus::Skipped) => continue, + _ => return Ok(Some(change.clone())), + } + } + Ok(None) +} + +/// Stamp conflict markers on the artifact YAML pointed at by the +/// PlannedChange. Currently supports FieldValueConflict; other conflict +/// kinds (e.g. unmapped-fields-strict) bail with a clear message +/// directing the user at `--abort` for now. +fn write_markers_for_conflict( + layout: &MigrationLayout, + conflict: &PlannedChange, + source_preset: &str, + target_preset: &str, +) -> Result<()> { + let file_rel = conflict + .source_file + .as_ref() + .ok_or_else(|| anyhow::anyhow!("conflict change has no source_file"))?; + // Resolve relative to the project root (= layout.root.parent.parent.parent). + let project_root = layout + .root + .parent() + .and_then(|p| p.parent()) + .and_then(|p| p.parent()) + .ok_or_else(|| anyhow::anyhow!("cannot derive project root from migration layout"))?; + let path = resolve_artifact_path(project_root, file_rel); + let original = + std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?; + + match &conflict.change { + ChangeKind::FieldValueConflict { .. } => { + let new_content = migrate::write_conflict_markers( + &original, + &conflict.artifact_id, + conflict, + source_preset, + target_preset, + ) + .with_context(|| format!("writing markers into {}", path.display()))?; + std::fs::write(&path, new_content) + .with_context(|| format!("writing {}", path.display()))?; + Ok(()) + } + other => { + anyhow::bail!( + "conflict kind {other:?} is not yet handled by Phase 2 markers; \ + use --abort and adjust the recipe / source artifact, or wait \ + for a later phase" + ) + } + } +} + fn current_unix_secs() -> u64 { SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/rivet-cli/tests/migrate_integration.rs b/rivet-cli/tests/migrate_integration.rs index 643aef4..e7fb40c 100644 --- a/rivet-cli/tests/migrate_integration.rs +++ b/rivet-cli/tests/migrate_integration.rs @@ -18,9 +18,10 @@ clippy::print_stderr )] -//! Integration tests for `rivet schema migrate` Phase 1 (issue #236). +//! Integration tests for `rivet schema migrate` Phase 1 + Phase 2 +//! (issue #236). //! -//! Covers: +//! Phase 1 coverage: //! * `--apply` rewrites a fresh `dev` project into ASPICE shape and //! `rivet validate` passes. //! * `--abort` restores byte-identical pre-migration state. @@ -28,6 +29,17 @@ //! * Roundtrip-style: A -> B yields a valid B project (the deeper //! A -> B -> A property test depends on a reverse recipe; tracked //! for a later phase). +//! +//! Phase 2 coverage: +//! * `--apply` pauses on the first conflict and writes markers; state +//! flips to CONFLICT. +//! * `--continue` advances after the user resolves markers; rejects +//! files with leftover markers. +//! * `--skip` restores the conflicted artifact from snapshot and +//! advances. +//! * `--edit ` re-opens a previously-resolved conflict. +//! * `rivet docs check` flags artifact YAMLs with leftover markers via +//! the `MigrationConflict` invariant. use std::collections::BTreeMap; use std::path::{Path, PathBuf}; @@ -296,6 +308,275 @@ fn finish_deletes_snapshot_and_keeps_manifest() { ); } +// ── Phase 2: conflict resolution flow ────────────────────────────────── + +/// Build a fake migration directory pre-populated with a single +/// `FieldValueConflict` (priority numeric -> enum). Returns the +/// project tempdir and the migration directory's relative dir name. +fn make_conflicted_project() -> (tempfile::TempDir, PathBuf, String) { + let tmp = tempfile::tempdir().expect("temp"); + let dir = tmp.path().to_path_buf(); + + // Minimal project: rivet.yaml, artifacts/req.yaml. + std::fs::create_dir_all(dir.join("artifacts")).unwrap(); + std::fs::write( + dir.join("rivet.yaml"), + "project:\n name: t\n version: \"0.1.0\"\n schemas:\n - common\n - dev\nsources:\n - path: artifacts\n format: generic-yaml\n", + ) + .unwrap(); + let art_yaml = "artifacts:\n - id: REQ-001\n type: requirement\n title: First\n fields:\n priority: 5\n"; + std::fs::write(dir.join("artifacts/req.yaml"), art_yaml).unwrap(); + + // Hand-built migration directory. + let mig_name = "20260101-0000-dev-to-aspice".to_string(); + let mig_root = dir.join(".rivet").join("migrations").join(&mig_name); + std::fs::create_dir_all(&mig_root).unwrap(); + + // Plan with one conflict entry (priority value 5 -> enum). + // Use the public type names of rivet_core::migrate. + use rivet_core::migrate::{ + ActionClass, ChangeKind, MigrationManifest, MigrationState, PlannedChange, + ResolutionStatus, RewriteMap, + }; + let rewrite = RewriteMap { + recipe_name: "dev-to-aspice".into(), + source_preset: "dev".into(), + target_preset: "aspice".into(), + changes: vec![PlannedChange { + artifact_id: "REQ-001".into(), + source_file: Some("artifacts/req.yaml".into()), + action: ActionClass::Conflict, + change: ChangeKind::FieldValueConflict { + in_type: "sw-req".into(), + field: "priority".into(), + from_value: "5".into(), + target_constraint: "[must|should|could|wont]".into(), + }, + }], + }; + std::fs::write( + mig_root.join("plan.yaml"), + serde_yaml::to_string(&rewrite).unwrap(), + ) + .unwrap(); + + let manifest = MigrationManifest { + recipe: "dev-to-aspice".into(), + source_preset: "dev".into(), + target_preset: "aspice".into(), + created_at: "unix:0".into(), + state: MigrationState::Planned, + mechanical_count: 0, + decidable_count: 0, + conflict_count: 1, + resolutions: BTreeMap::new(), + }; + let _ = ResolutionStatus::Pending; // ensure import is referenced + std::fs::write( + mig_root.join("manifest.yaml"), + serde_yaml::to_string(&manifest).unwrap(), + ) + .unwrap(); + std::fs::write(mig_root.join("state"), "PLANNED").unwrap(); + + (tmp, dir, mig_name) +} + +#[test] +fn apply_pauses_on_conflict_and_writes_markers() { + let (_tmp, dir, mig_name) = make_conflicted_project(); + // `apply` will discover the existing PLANNED migration and try to + // re-plan against the live project. Our hand-written plan is the + // one used because cmd_apply finds the latest PLANNED migration. + let apply = run_rivet(&dir, &["schema", "migrate", "aspice", "--apply"]); + // Non-zero exit because conflict left in flight. + assert!( + !apply.status.success(), + "apply should not succeed when paused on conflict; stdout: {}\nstderr: {}", + String::from_utf8_lossy(&apply.stdout), + String::from_utf8_lossy(&apply.stderr) + ); + + // State must be CONFLICT, current-conflict points to REQ-001. + let state = std::fs::read_to_string( + dir.join(".rivet") + .join("migrations") + .join(&mig_name) + .join("state"), + ) + .unwrap(); + assert_eq!(state.trim(), "CONFLICT", "state file: {state:?}"); + + let current = std::fs::read_to_string( + dir.join(".rivet") + .join("migrations") + .join(&mig_name) + .join("current-conflict"), + ) + .unwrap(); + assert_eq!(current.trim(), "REQ-001"); + + // The artifact YAML now contains conflict markers. + let after = std::fs::read_to_string(dir.join("artifacts/req.yaml")).unwrap(); + assert!(after.contains("<<<<<<<"), "no open marker: {after}"); + assert!(after.contains("======="), "no separator: {after}"); + assert!(after.contains(">>>>>>>"), "no close marker: {after}"); + assert!(after.contains("source: dev")); + assert!(after.contains("target: aspice")); +} + +#[test] +fn continue_advances_after_user_resolves_markers() { + let (_tmp, dir, mig_name) = make_conflicted_project(); + // Trigger the conflict pause. + let _ = run_rivet(&dir, &["schema", "migrate", "aspice", "--apply"]); + + // Programmatically pretend the user resolved the conflict by + // writing a clean file with `priority: must`. + let resolved = "artifacts:\n - id: REQ-001\n type: requirement\n title: First\n fields:\n priority: must\n"; + std::fs::write(dir.join("artifacts/req.yaml"), resolved).unwrap(); + + let cont = run_rivet(&dir, &["schema", "migrate", "aspice", "--continue"]); + assert!( + cont.status.success(), + "continue failed. stderr: {}\nstdout: {}", + String::from_utf8_lossy(&cont.stderr), + String::from_utf8_lossy(&cont.stdout) + ); + + let state = std::fs::read_to_string( + dir.join(".rivet") + .join("migrations") + .join(&mig_name) + .join("state"), + ) + .unwrap(); + assert_eq!(state.trim(), "COMPLETE"); + + // current-conflict pointer should be gone. + let cur = dir + .join(".rivet") + .join("migrations") + .join(&mig_name) + .join("current-conflict"); + assert!(!cur.exists(), "current-conflict file should be removed"); +} + +#[test] +fn continue_rejects_unresolved_markers() { + let (_tmp, dir, _) = make_conflicted_project(); + let _ = run_rivet(&dir, &["schema", "migrate", "aspice", "--apply"]); + + // Don't touch the file — markers still in place. + let cont = run_rivet(&dir, &["schema", "migrate", "aspice", "--continue"]); + assert!( + !cont.status.success(), + "continue should refuse with markers present" + ); + let stderr = String::from_utf8_lossy(&cont.stderr); + assert!( + stderr.to_lowercase().contains("conflict marker") + || stderr.contains("<<<<<<<") + || stderr.contains("conflict marker(s)"), + "expected marker complaint, got: {stderr}" + ); +} + +#[test] +fn skip_restores_artifact_from_snapshot() { + let (_tmp, dir, mig_name) = make_conflicted_project(); + // Pre-conflict file content; snapshot must match it after apply. + let pre = std::fs::read_to_string(dir.join("artifacts/req.yaml")).unwrap(); + + let _ = run_rivet(&dir, &["schema", "migrate", "aspice", "--apply"]); + + let mid = std::fs::read_to_string(dir.join("artifacts/req.yaml")).unwrap(); + assert_ne!(pre, mid, "apply should have stamped markers"); + + let skip = run_rivet(&dir, &["schema", "migrate", "aspice", "--skip"]); + assert!( + skip.status.success(), + "skip failed: {}", + String::from_utf8_lossy(&skip.stderr) + ); + + let after = std::fs::read_to_string(dir.join("artifacts/req.yaml")).unwrap(); + // The artifact was restored — it should not contain conflict + // markers anymore. + assert!(!after.contains("<<<<<<<")); + assert!(!after.contains(">>>>>>>")); + // priority should be back to the pre-migration numeric value. + assert!(after.contains("priority: 5"), "after: {after}"); + + let state = std::fs::read_to_string( + dir.join(".rivet") + .join("migrations") + .join(&mig_name) + .join("state"), + ) + .unwrap(); + // Only one conflict in the plan, so skip leaves us COMPLETE. + assert_eq!(state.trim(), "COMPLETE"); +} + +#[test] +fn edit_reopens_resolved_conflict() { + let (_tmp, dir, mig_name) = make_conflicted_project(); + let _ = run_rivet(&dir, &["schema", "migrate", "aspice", "--apply"]); + // Resolve. + let resolved = "artifacts:\n - id: REQ-001\n type: requirement\n title: First\n fields:\n priority: must\n"; + std::fs::write(dir.join("artifacts/req.yaml"), resolved).unwrap(); + let cont = run_rivet(&dir, &["schema", "migrate", "aspice", "--continue"]); + assert!(cont.status.success()); + + // Re-open via --edit. + let edit = run_rivet(&dir, &["schema", "migrate", "aspice", "--edit", "REQ-001"]); + assert!( + edit.status.success(), + "edit failed: {}", + String::from_utf8_lossy(&edit.stderr) + ); + + let state = std::fs::read_to_string( + dir.join(".rivet") + .join("migrations") + .join(&mig_name) + .join("state"), + ) + .unwrap(); + assert_eq!(state.trim(), "CONFLICT"); + + let after = std::fs::read_to_string(dir.join("artifacts/req.yaml")).unwrap(); + assert!(after.contains("<<<<<<<"), "markers re-written: {after}"); + let cur = std::fs::read_to_string( + dir.join(".rivet") + .join("migrations") + .join(&mig_name) + .join("current-conflict"), + ) + .unwrap(); + assert_eq!(cur.trim(), "REQ-001"); +} + +#[test] +fn docs_check_flags_unresolved_conflict_markers() { + let (_tmp, dir, _) = make_conflicted_project(); + // Stamp markers via --apply. + let _ = run_rivet(&dir, &["schema", "migrate", "aspice", "--apply"]); + + // `rivet docs check` should now flag MigrationConflict. + let check = run_rivet(&dir, &["docs", "check", "-f", "json"]); + let stdout = String::from_utf8_lossy(&check.stdout); + assert!( + !check.status.success(), + "docs check should fail when markers are present; stdout: {stdout}" + ); + assert!( + stdout.contains("MigrationConflict"), + "expected MigrationConflict in JSON output: {stdout}" + ); +} + #[test] fn roundtrip_dev_to_aspice_keeps_artifact_count_constant() { // We don't yet have an aspice-to-dev recipe for the full diff --git a/rivet-core/src/doc_check.rs b/rivet-core/src/doc_check.rs index 3e6a32e..7450fdd 100644 --- a/rivet-core/src/doc_check.rs +++ b/rivet-core/src/doc_check.rs @@ -400,6 +400,7 @@ pub fn default_invariants() -> Vec> { Box::new(SoftGateHonesty), Box::new(ConfigExampleFreshness), Box::new(ArtifactIdValidity), + Box::new(MigrationConflict), ] } @@ -1177,6 +1178,88 @@ fn collect_frontmatter_ids(content: &str) -> BTreeSet { out } +// ──────────────────────────────────────────────────────────────────────── +// Invariant: MigrationConflict +// ──────────────────────────────────────────────────────────────────────── + +/// Phase 2 (#236) acceptance criterion: prevents accidentally committing +/// artifact YAML with unresolved migration conflict markers. +/// +/// Walks every `*.yaml` / `*.yml` under `/artifacts/` and emits +/// a violation for each line that begins with the rebase-style markers +/// `<<<<<<<`, `=======`, or `>>>>>>>` produced by `rivet schema migrate +/// --apply`. The user resolves the conflict via `rivet schema migrate +/// --continue` (which also checks for residual markers) or `--skip`. +pub struct MigrationConflict; + +impl DocInvariant for MigrationConflict { + fn name(&self) -> &'static str { + "MigrationConflict" + } + + fn check(&self, ctx: &DocCheckContext<'_>) -> Vec { + let mut out = Vec::new(); + let root = ctx.project_root.join("artifacts"); + if !root.is_dir() { + return out; + } + let mut files = Vec::new(); + collect_artifact_yaml_files(&root, &mut files); + for path in files { + let Ok(content) = std::fs::read_to_string(&path) else { + continue; + }; + let rel = path + .strip_prefix(ctx.project_root) + .unwrap_or(&path) + .to_path_buf(); + for (idx, line) in content.lines().enumerate() { + let trimmed = line.trim_start(); + let kind = if trimmed.starts_with("<<<<<<<") { + Some("open") + } else if trimmed.starts_with("=======") { + Some("separator") + } else if trimmed.starts_with(">>>>>>>") { + Some("close") + } else { + None + }; + if let Some(k) = kind { + out.push(Violation { + file: rel.clone(), + line: idx + 1, + invariant: "MigrationConflict".to_string(), + claim: format!("artifact YAML contains migration conflict marker ({k})"), + reality: "run `rivet schema migrate --status` for context, then \ + `rivet schema migrate --continue` after resolving" + .to_string(), + auto_fixable: false, + }); + } + } + } + out + } +} + +fn collect_artifact_yaml_files(dir: &Path, out: &mut Vec) { + let Ok(entries) = std::fs::read_dir(dir) else { + return; + }; + for entry in entries.flatten() { + let p = entry.path(); + if p.is_dir() { + collect_artifact_yaml_files(&p, out); + } else if p + .extension() + .and_then(|e| e.to_str()) + .is_some_and(|e| e == "yaml" || e == "yml") + { + out.push(p); + } + } +} + // ──────────────────────────────────────────────────────────────────────── // Auto-fix // ──────────────────────────────────────────────────────────────────────── diff --git a/rivet-core/src/migrate.rs b/rivet-core/src/migrate.rs index abceace..0b18da9 100644 --- a/rivet-core/src/migrate.rs +++ b/rivet-core/src/migrate.rs @@ -1,10 +1,20 @@ //! Schema migration engine — diff source/target schema sets and rewrite -//! artifacts mechanically. +//! artifacts mechanically, with rebase-style conflict resolution. //! -//! Phase 1 MVP (#236): mechanical-only migration. The diff engine -//! computes a [`RewriteMap`] from a [`MigrationRecipe`] (and optional -//! schema introspection), and [`apply_rewrite`] rewrites a single -//! artifact YAML file in place. +//! Phase 1 (#236): mechanical-only migration. The diff engine computes +//! a [`RewriteMap`] from a [`MigrationRecipe`] (and optional schema +//! introspection), and [`apply_to_file`] rewrites a single artifact +//! YAML file in place. +//! +//! Phase 2 (#236): conflict resolution UX. The diff engine now flags +//! `FieldValueConflict` for any source field whose value violates the +//! target field's `allowed_values` enum. [`apply_to_file_partial`] is +//! the conflict-tolerant variant of `apply_to_file` (skips Conflict- +//! class entries instead of bailing). [`write_conflict_markers`] +//! splices git-rebase-style markers into a YAML file for the user to +//! resolve, and [`scan_conflict_markers`] is the inverse used by +//! `--continue` and the `MigrationConflict` doc-check invariant. +//! [`restore_artifact_from_snapshot`] backs the `--skip` subcommand. //! //! See `rivet docs schema-migrate` for the user-facing topic. @@ -333,7 +343,7 @@ pub fn diff_artifacts( }) .unwrap_or_default(); - for field_name in artifact.fields.keys() { + for (field_name, field_value) in &artifact.fields { // Renames declared in the recipe. if let Some(target_name) = field_map.get(field_name.as_str()) { if *target_name != field_name { @@ -352,24 +362,51 @@ pub fn diff_artifacts( } // No explicit mapping. If we have a target schema, check // whether the same-named field exists on the target type. - // If it does, this is a no-op. If it doesn't, apply policy. - if target_type_def.is_some() && !target_field_names.contains(field_name) { - let action = match recipe.policies.unmapped_fields { - UnmappedFieldPolicy::Drop | UnmappedFieldPolicy::KeepAsOrphan => { - ActionClass::DecidableWithPolicy + // If it does, the value still has to match the target's + // `allowed_values` — otherwise it's a conflict (e.g. + // `priority: 5` -> enum [must|should|could|wont]). + // If the field isn't on the target type, apply the + // unmapped-fields policy. + if let Some(target_def) = target_type_def { + if target_field_names.contains(field_name) { + if let Some(target_field) = + target_def.fields.iter().find(|f| f.name == *field_name) + { + if let Some(allowed) = &target_field.allowed_values { + let current_value = yaml_value_as_display(field_value); + if !allowed.iter().any(|v| v == ¤t_value) { + changes.push(PlannedChange { + artifact_id: artifact.id.clone(), + source_file: source_file.clone(), + action: ActionClass::Conflict, + change: ChangeKind::FieldValueConflict { + in_type: target_type_name.clone(), + field: field_name.clone(), + from_value: current_value, + target_constraint: format!("[{}]", allowed.join("|")), + }, + }); + } + } } - UnmappedFieldPolicy::Strict => ActionClass::Conflict, - }; - changes.push(PlannedChange { - artifact_id: artifact.id.clone(), - source_file: source_file.clone(), - action, - change: ChangeKind::FieldDrop { - in_type: target_type_name.clone(), - field: field_name.clone(), - policy: recipe.policies.unmapped_fields, - }, - }); + } else { + let action = match recipe.policies.unmapped_fields { + UnmappedFieldPolicy::Drop | UnmappedFieldPolicy::KeepAsOrphan => { + ActionClass::DecidableWithPolicy + } + UnmappedFieldPolicy::Strict => ActionClass::Conflict, + }; + changes.push(PlannedChange { + artifact_id: artifact.id.clone(), + source_file: source_file.clone(), + action, + change: ChangeKind::FieldDrop { + in_type: target_type_name.clone(), + field: field_name.clone(), + policy: recipe.policies.unmapped_fields, + }, + }); + } } } } @@ -388,6 +425,10 @@ pub fn diff_artifacts( /// content. Mechanical-only — bails if any conflict-class change touches /// the file. /// +/// Phase 2 added [`apply_to_file_partial`] which applies the +/// mechanical / decidable changes and ignores conflicts (they're left +/// for the conflict-marker path). +/// /// We work at the parsed `serde_yaml::Value` level rather than CST /// editing for simplicity and because Phase 1 explicitly does not /// preserve formatting (snapshots cover the rollback story). The result @@ -520,6 +561,26 @@ pub fn apply_to_file( serde_yaml::to_string(&doc).map_err(Error::Yaml) } +/// Like [`apply_to_file`], but tolerates conflict-class changes by +/// skipping them. Used by the Phase 2 `--apply` walker to apply every +/// auto-resolvable change in a file before pausing on the first +/// conflict (which is then handled by [`write_conflict_markers`]). +pub fn apply_to_file_partial( + original: &str, + file_changes: &[&PlannedChange], + recipe: &MigrationRecipe, +) -> Result { + let auto: Vec<&PlannedChange> = file_changes + .iter() + .filter(|c| c.action != ActionClass::Conflict) + .copied() + .collect(); + if auto.is_empty() { + return Ok(original.to_string()); + } + apply_to_file(original, &auto, recipe) +} + fn apply_field_renames(map: &mut serde_yaml::Mapping, renames: &BTreeMap) { // Operate on top-level keys. for (from, to) in renames { @@ -586,6 +647,278 @@ fn apply_field_drops(map: &mut serde_yaml::Mapping, drops: &[(String, UnmappedFi } } +// ── Conflict markers (Phase 2) ───────────────────────────────────────── + +/// Format a `serde_yaml::Value` as a single-line printable string for +/// embedding in conflict markers / diagnostics. Mappings/sequences are +/// rendered via `serde_yaml::to_string` and trimmed; scalars are +/// rendered without surrounding quotes. +pub fn yaml_value_as_display(v: &serde_yaml::Value) -> String { + match v { + serde_yaml::Value::Null => "~".to_string(), + serde_yaml::Value::Bool(b) => b.to_string(), + serde_yaml::Value::Number(n) => n.to_string(), + serde_yaml::Value::String(s) => s.clone(), + other => serde_yaml::to_string(other) + .unwrap_or_default() + .trim_end() + .to_string(), + } +} + +/// Markers used to bracket a conflict region in artifact YAML. These +/// follow git rebase/merge convention with a YAML-friendly header. +pub const CONFLICT_OPEN: &str = "<<<<<<<"; +pub const CONFLICT_SEPARATOR: &str = "======="; +pub const CONFLICT_CLOSE: &str = ">>>>>>>"; + +/// Render conflict markers for a single artifact field-value conflict. +/// +/// We don't surgically embed the markers into the existing YAML +/// (that would require a YAML preserving editor we don't have); instead +/// we replace the whole file with a hand-rolled YAML that preserves all +/// other artifacts byte-faithfully, while the conflicted artifact is +/// rewritten into a minimal form with the markers in place. The user +/// edits the file, removes the markers, and runs `--continue`. +/// +/// Returns the new file content. The markers look like: +/// +/// ```text +/// fields: +/// priority: <<<<<<< source: dev (priority: number) +/// 5 +/// ======= target: aspice (priority: enum [must|should|could|wont]) +/// +/// >>>>>>> +/// ``` +pub fn write_conflict_markers( + original: &str, + artifact_id: &str, + conflict: &PlannedChange, + source_preset: &str, + target_preset: &str, +) -> Result { + let ChangeKind::FieldValueConflict { + in_type, + field, + from_value, + target_constraint, + } = &conflict.change + else { + return Err(Error::Schema( + "write_conflict_markers: only FieldValueConflict supported".into(), + )); + }; + + let mut doc: serde_yaml::Value = serde_yaml::from_str(original).map_err(Error::Yaml)?; + let artifacts = doc + .as_mapping_mut() + .and_then(|m| m.get_mut("artifacts")) + .and_then(|v| v.as_sequence_mut()) + .ok_or_else(|| Error::Schema("file has no `artifacts:` sequence".into()))?; + + // Find the artifact by id and stamp a sentinel onto the field so + // the post-serialise pass can splice in the human-readable markers. + let sentinel = format!("__RIVET_CONFLICT_SENTINEL__{artifact_id}__{field}__"); + let mut found = false; + for art in artifacts.iter_mut() { + let Some(map) = art.as_mapping_mut() else { + continue; + }; + let id = map + .get(serde_yaml::Value::String("id".into())) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_default(); + if id != artifact_id { + continue; + } + if let Some(fields) = map + .get_mut(serde_yaml::Value::String("fields".into())) + .and_then(|v| v.as_mapping_mut()) + { + fields.insert( + serde_yaml::Value::String(field.clone()), + serde_yaml::Value::String(sentinel.clone()), + ); + } + found = true; + break; + } + if !found { + return Err(Error::Schema(format!( + "artifact {artifact_id} not found in file" + ))); + } + + let serialised = serde_yaml::to_string(&doc).map_err(Error::Yaml)?; + + // Splice the sentinel line out and replace it with a multi-line + // conflict block. We match on `: ` so we don't + // accidentally rewrite a different field. The indentation used for + // the body lines is two more spaces than the field-line indent. + let needle = format!("{field}: {sentinel}"); + let replacement = render_conflict_block( + in_type, + field, + source_preset, + target_preset, + from_value, + target_constraint, + ); + + let mut out = String::with_capacity(serialised.len() + replacement.len()); + let mut found_line = false; + for line in serialised.split_inclusive('\n') { + if !found_line && line.contains(&needle) { + found_line = true; + // preserve leading indent of the original line + let indent: String = line.chars().take_while(|c| c.is_whitespace()).collect(); + for body_line in replacement.lines() { + out.push_str(&indent); + out.push_str(body_line); + out.push('\n'); + } + } else { + out.push_str(line); + } + } + if !found_line { + return Err(Error::Schema( + "internal: conflict sentinel was lost during serialisation".into(), + )); + } + Ok(out) +} + +fn render_conflict_block( + in_type: &str, + field: &str, + source_preset: &str, + target_preset: &str, + from_value: &str, + target_constraint: &str, +) -> String { + let mut s = String::new(); + s.push_str(&format!( + "{field}: {CONFLICT_OPEN} source: {source_preset} ({field}: {from_value})\n" + )); + s.push_str(&format!(" {from_value}\n")); + s.push_str(&format!( + " {CONFLICT_SEPARATOR} target: {target_preset} ({in_type}.{field}: {target_constraint})\n" + )); + s.push_str(" \n"); + s.push_str(&format!(" {CONFLICT_CLOSE}\n")); + s +} + +/// Scan a string for any conflict markers. Returns the line numbers +/// (1-based) on which open/close markers occur. +pub fn scan_conflict_markers(content: &str) -> Vec { + let mut hits = Vec::new(); + for (idx, line) in content.lines().enumerate() { + let l = line.trim_start(); + if l.starts_with(CONFLICT_OPEN) + || l.starts_with(CONFLICT_SEPARATOR) + || l.starts_with(CONFLICT_CLOSE) + { + hits.push(idx + 1); + } + } + hits +} + +/// True if `content` contains any unresolved conflict markers. +pub fn has_conflict_markers(content: &str) -> bool { + !scan_conflict_markers(content).is_empty() +} + +/// Restore a single artifact (by ID) from a snapshot back into the +/// project. Used by `--skip` to drop a conflicted artifact from the +/// migration. The artifact is assumed to live in the same file path on +/// both sides; we read the file from the snapshot, locate the artifact +/// by id, and copy that one entry over the project file. +pub fn restore_artifact_from_snapshot( + snapshot_root: &Path, + project_root: &Path, + relative_file: &Path, + artifact_id: &str, +) -> Result<(), Error> { + let snap_path = snapshot_root.join(relative_file); + let proj_path = project_root.join(relative_file); + let snap_content = std::fs::read_to_string(&snap_path) + .map_err(|e| Error::Io(format!("reading {}: {}", snap_path.display(), e)))?; + let proj_content = std::fs::read_to_string(&proj_path) + .map_err(|e| Error::Io(format!("reading {}: {}", proj_path.display(), e)))?; + + let mut proj_doc: serde_yaml::Value = + serde_yaml::from_str(&proj_content).map_err(Error::Yaml)?; + let snap_doc: serde_yaml::Value = serde_yaml::from_str(&snap_content).map_err(Error::Yaml)?; + + // Find the snapshot version of this artifact. + let snap_artifact = snap_doc + .as_mapping() + .and_then(|m| m.get("artifacts")) + .and_then(|v| v.as_sequence()) + .and_then(|seq| { + seq.iter().find(|a| { + a.as_mapping() + .and_then(|m| m.get("id")) + .and_then(|v| v.as_str()) + .map(|s| s == artifact_id) + .unwrap_or(false) + }) + }) + .cloned() + .ok_or_else(|| { + Error::Schema(format!( + "artifact {artifact_id} not in snapshot {}", + snap_path.display() + )) + })?; + + // Replace (or insert) the artifact in the project doc. + let proj_artifacts = proj_doc + .as_mapping_mut() + .and_then(|m| m.get_mut("artifacts")) + .and_then(|v| v.as_sequence_mut()) + .ok_or_else(|| Error::Schema(format!("no artifacts seq in {}", proj_path.display())))?; + let mut replaced = false; + for art in proj_artifacts.iter_mut() { + let id = art + .as_mapping() + .and_then(|m| m.get("id")) + .and_then(|v| v.as_str()) + .map(|s| s.to_string()) + .unwrap_or_default(); + if id == artifact_id { + *art = snap_artifact.clone(); + replaced = true; + break; + } + } + if !replaced { + proj_artifacts.push(snap_artifact); + } + + let new_content = serde_yaml::to_string(&proj_doc).map_err(Error::Yaml)?; + std::fs::write(&proj_path, new_content) + .map_err(|e| Error::Io(format!("writing {}: {}", proj_path.display(), e)))?; + Ok(()) +} + +/// Lookup a conflict in the plan by artifact id. Returns `None` if +/// none of the changes for that artifact are conflict-class. +pub fn first_conflict_for_artifact<'a>( + rewrite: &'a RewriteMap, + artifact_id: &str, +) -> Option<&'a PlannedChange> { + rewrite + .changes + .iter() + .find(|c| c.artifact_id == artifact_id && c.action == ActionClass::Conflict) +} + // ── Snapshot ──────────────────────────────────────────────────────────── /// Recursively copy a directory tree from `src` to `dst`. Used for @@ -650,11 +983,17 @@ pub fn remove_tree(path: &Path) -> Result<(), Error> { // ── Migration directory layout ───────────────────────────────────────── /// Migration state machine pointer. +/// +/// Phase 1 (#236) had `Planned / InProgress / Complete`. Phase 2 adds +/// `Conflict` — the in-flight pause state when `--apply` writes +/// rebase-style markers into the affected artifact YAML and waits for +/// the user to resolve them via `--continue` or `--skip`. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "UPPERCASE")] pub enum MigrationState { Planned, InProgress, + Conflict, Complete, } @@ -663,6 +1002,7 @@ impl MigrationState { match self { MigrationState::Planned => "PLANNED", MigrationState::InProgress => "IN_PROGRESS", + MigrationState::Conflict => "CONFLICT", MigrationState::Complete => "COMPLETE", } } @@ -670,15 +1010,32 @@ impl MigrationState { match s.trim() { "PLANNED" => Some(MigrationState::Planned), "IN_PROGRESS" => Some(MigrationState::InProgress), + "CONFLICT" => Some(MigrationState::Conflict), "COMPLETE" => Some(MigrationState::Complete), _ => None, } } } +/// Per-artifact resolution status tracked through the conflict-resolution +/// flow (Phase 2). Mechanical/decidable changes commit silently; only +/// conflicts produce a journal entry. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ResolutionStatus { + /// Conflict markers are currently in the file; user is editing. + Pending, + /// User ran `--continue`; markers were removed and the artifact + /// validated. + Resolved, + /// User ran `--skip`; original artifact was restored from snapshot + /// and the migration moved on. + Skipped, +} + /// Per-migration manifest written to `manifest.yaml`. #[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(deny_unknown_fields)] +#[serde(deny_unknown_fields, default)] pub struct MigrationManifest { pub recipe: String, pub source_preset: String, @@ -689,6 +1046,27 @@ pub struct MigrationManifest { pub mechanical_count: usize, pub decidable_count: usize, pub conflict_count: usize, + /// Per-artifact resolution status tracked across `--apply` / + /// `--continue` / `--skip` / `--edit`. Empty until the first + /// conflict is reached. + #[serde(default)] + pub resolutions: BTreeMap, +} + +impl Default for MigrationManifest { + fn default() -> Self { + Self { + recipe: String::new(), + source_preset: String::new(), + target_preset: String::new(), + created_at: String::new(), + state: MigrationState::Planned, + mechanical_count: 0, + decidable_count: 0, + conflict_count: 0, + resolutions: BTreeMap::new(), + } + } } /// Conventional layout helpers for a single migration directory. @@ -717,6 +1095,12 @@ impl MigrationLayout { pub fn snapshot_dir(&self) -> PathBuf { self.root.join("snapshot") } + /// Path to the `current-conflict` pointer file (Phase 2). Holds the + /// artifact ID that `--apply` paused on, or is absent when no + /// conflict is active. + pub fn current_conflict_path(&self) -> PathBuf { + self.root.join("current-conflict") + } pub fn write_state(&self, state: MigrationState) -> Result<(), Error> { std::fs::create_dir_all(&self.root) @@ -731,6 +1115,30 @@ impl MigrationLayout { .map_err(|e| Error::Io(format!("reading state: {}", e)))?; MigrationState::parse(&s).ok_or_else(|| Error::Schema(format!("unknown state '{s}'"))) } + + /// Set or clear the `current-conflict` pointer. + pub fn write_current_conflict(&self, artifact_id: Option<&str>) -> Result<(), Error> { + let path = self.current_conflict_path(); + match artifact_id { + Some(id) => std::fs::write(&path, id) + .map_err(|e| Error::Io(format!("writing current-conflict: {e}"))), + None => { + if path.exists() { + std::fs::remove_file(&path) + .map_err(|e| Error::Io(format!("removing current-conflict: {e}")))?; + } + Ok(()) + } + } + } + + /// Read the `current-conflict` pointer (if any). + pub fn read_current_conflict(&self) -> Option { + std::fs::read_to_string(self.current_conflict_path()) + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + } } /// Discover the most recent migration directory under @@ -1038,10 +1446,211 @@ mod tests { for s in [ MigrationState::Planned, MigrationState::InProgress, + MigrationState::Conflict, MigrationState::Complete, ] { let parsed = MigrationState::parse(s.as_str()).unwrap(); assert_eq!(s, parsed); } } + + // ── Phase 2: conflict markers ───────────────────────────────────── + + #[test] + fn diff_emits_field_value_conflict_for_enum_mismatch() { + // Build a target schema where sw-req.priority is enum. + let mut target = Schema { + artifact_types: std::collections::HashMap::new(), + link_types: std::collections::HashMap::new(), + inverse_map: std::collections::HashMap::new(), + traceability_rules: vec![], + conditional_rules: vec![], + }; + let sw_req = crate::schema::ArtifactTypeDef { + name: "sw-req".into(), + description: "".into(), + fields: vec![crate::schema::FieldDef { + name: "priority".into(), + field_type: "enum".into(), + required: false, + description: None, + allowed_values: Some(vec![ + "must".into(), + "should".into(), + "could".into(), + "wont".into(), + ]), + }], + link_fields: vec![], + aspice_process: None, + common_mistakes: vec![], + example: None, + yaml_section: None, + yaml_sections: vec![], + yaml_section_suffix: None, + shorthand_links: BTreeMap::new(), + }; + target.artifact_types.insert("sw-req".into(), sw_req); + + let mut a = artifact("REQ-001", "requirement"); + // Numeric priority value that cannot satisfy the enum on the + // target side. + a.fields.insert( + "priority".into(), + serde_yaml::Value::Number(serde_yaml::Number::from(5)), + ); + let recipe = dev_to_aspice(); + let map = diff_artifacts(&recipe, &[a], Some(&target)); + let confs: Vec<&PlannedChange> = map + .changes + .iter() + .filter(|c| matches!(c.change, ChangeKind::FieldValueConflict { .. })) + .collect(); + assert_eq!(confs.len(), 1); + assert_eq!(confs[0].action, ActionClass::Conflict); + if let ChangeKind::FieldValueConflict { + from_value, + target_constraint, + .. + } = &confs[0].change + { + assert_eq!(from_value, "5"); + assert!(target_constraint.contains("must")); + } + } + + #[test] + fn write_conflict_markers_round_trips() { + let original = r#"artifacts: + - id: REQ-001 + type: requirement + title: First + fields: + priority: 5 +"#; + let conflict = PlannedChange { + artifact_id: "REQ-001".into(), + source_file: Some("a.yaml".into()), + action: ActionClass::Conflict, + change: ChangeKind::FieldValueConflict { + in_type: "sw-req".into(), + field: "priority".into(), + from_value: "5".into(), + target_constraint: "[must|should|could|wont]".into(), + }, + }; + let out = write_conflict_markers(original, "REQ-001", &conflict, "dev", "aspice") + .expect("markers"); + assert!(out.contains(CONFLICT_OPEN), "open: {out}"); + assert!(out.contains(CONFLICT_SEPARATOR), "sep: {out}"); + assert!(out.contains(CONFLICT_CLOSE), "close: {out}"); + assert!(out.contains("source: dev"), "source label: {out}"); + assert!(out.contains("target: aspice"), "target label: {out}"); + assert!(has_conflict_markers(&out)); + } + + #[test] + fn scan_conflict_markers_finds_lines() { + let content = "a: 1\n<<<<<<< x\n v\n=======\n w\n>>>>>>>\nb: 2\n"; + let lines = scan_conflict_markers(content); + assert_eq!(lines, vec![2, 4, 6]); + } + + #[test] + fn restore_artifact_from_snapshot_swaps_in_pre_migration_form() { + let tmp = tempfile::tempdir().unwrap(); + let snap_root = tmp.path().join("snapshot"); + let proj_root = tmp.path().join("project"); + std::fs::create_dir_all(snap_root.join("artifacts")).unwrap(); + std::fs::create_dir_all(proj_root.join("artifacts")).unwrap(); + + let snap_yaml = "artifacts:\n - id: REQ-001\n type: requirement\n title: Original\n"; + let proj_yaml = "artifacts:\n - id: REQ-001\n type: sw-req\n title: Migrated\n"; + std::fs::write(snap_root.join("artifacts/x.yaml"), snap_yaml).unwrap(); + std::fs::write(proj_root.join("artifacts/x.yaml"), proj_yaml).unwrap(); + + restore_artifact_from_snapshot( + &snap_root, + &proj_root, + std::path::Path::new("artifacts/x.yaml"), + "REQ-001", + ) + .unwrap(); + + let after = std::fs::read_to_string(proj_root.join("artifacts/x.yaml")).unwrap(); + assert!(after.contains("type: requirement"), "{after}"); + assert!(after.contains("Original"), "{after}"); + } + + #[test] + fn first_conflict_for_artifact_finds_the_right_one() { + let rewrite = RewriteMap { + recipe_name: "x".into(), + source_preset: "dev".into(), + target_preset: "aspice".into(), + changes: vec![ + PlannedChange { + artifact_id: "REQ-001".into(), + source_file: None, + action: ActionClass::Mechanical, + change: ChangeKind::TypeRename { + from: "requirement".into(), + to: "sw-req".into(), + }, + }, + PlannedChange { + artifact_id: "REQ-001".into(), + source_file: None, + action: ActionClass::Conflict, + change: ChangeKind::FieldValueConflict { + in_type: "sw-req".into(), + field: "priority".into(), + from_value: "5".into(), + target_constraint: "[a|b]".into(), + }, + }, + ], + }; + let conf = first_conflict_for_artifact(&rewrite, "REQ-001").unwrap(); + assert_eq!(conf.action, ActionClass::Conflict); + assert!(first_conflict_for_artifact(&rewrite, "REQ-002").is_none()); + } + + #[test] + fn apply_to_file_partial_skips_conflicts_but_applies_mechanical() { + let recipe = dev_to_aspice(); + let original = r#"artifacts: + - id: REQ-001 + type: requirement + title: First + fields: + priority: 5 +"#; + let changes = [ + PlannedChange { + artifact_id: "REQ-001".into(), + source_file: Some("a.yaml".into()), + action: ActionClass::Mechanical, + change: ChangeKind::TypeRename { + from: "requirement".into(), + to: "sw-req".into(), + }, + }, + PlannedChange { + artifact_id: "REQ-001".into(), + source_file: Some("a.yaml".into()), + action: ActionClass::Conflict, + change: ChangeKind::FieldValueConflict { + in_type: "sw-req".into(), + field: "priority".into(), + from_value: "5".into(), + target_constraint: "[must|should|could|wont]".into(), + }, + }, + ]; + let refs: Vec<&PlannedChange> = changes.iter().collect(); + let out = apply_to_file_partial(original, &refs, &recipe).expect("partial"); + assert!(out.contains("type: sw-req")); + assert!(out.contains("priority: 5"), "conflict left for marker pass"); + } } From bc3ba33deaa32903074de7c387b29c17fb5f9e3d Mon Sep 17 00:00:00 2001 From: Ralf Anton Beier Date: Wed, 29 Apr 2026 08:42:51 +0200 Subject: [PATCH 2/2] docs(migrate): Phase 2 conflict-resolution flow in rivet docs schema-migrate Extend the embedded `rivet docs schema-migrate` topic with: * Updated quick-start commands (`--continue`, `--skip`, `--edit`) * CONFLICT state in the state-machine diagram * Worked example of marker syntax + resolution workflow * `current-conflict` file in the storage-layout table * Note on the `MigrationConflict` doc-check invariant * Refreshed "still deferred" list (dashboard, recipes subcommand). Refs: FEAT-001 --- rivet-cli/src/docs.rs | 120 ++++++++++++++++++++++++++++++------------ 1 file changed, 86 insertions(+), 34 deletions(-) diff --git a/rivet-cli/src/docs.rs b/rivet-cli/src/docs.rs index 68b8744..09cd25a 100644 --- a/rivet-cli/src/docs.rs +++ b/rivet-cli/src/docs.rs @@ -2569,19 +2569,25 @@ const QUICKSTART_DOC: &str = include_str!("quickstart.md"); const SCHEMA_MIGRATE_DOC: &str = r#"# rivet schema migrate `rivet schema migrate` rewrites artifact YAML when you switch presets or -upgrade a preset version. Phase 1 (issue #236) ships a strictly -mechanical-only flow with full snapshot/abort. Phase 2 will add -git-rebase-style conflict resolution (`--continue`, `--skip`, conflict -markers in YAML). +upgrade a preset version. Phase 1 (issue #236) shipped the mechanical +diff + snapshot/abort. Phase 2 added the git-rebase-style conflict +resolution flow: when `--apply` hits a value-mapping conflict it writes +markers into the affected artifact YAML, sets state to CONFLICT, and +exits non-zero so CI catches an unfinished migration. The user resolves +the conflict in-place and runs `--continue`, or drops the artifact with +`--skip`. ## Quick start ``` rivet schema migrate aspice # plan only (dry-run) -rivet schema migrate aspice --apply # apply mechanical changes +rivet schema migrate aspice --apply # apply; pause on first conflict +rivet schema migrate aspice --continue # resume after editing markers +rivet schema migrate aspice --skip # drop the current conflicted artifact +rivet schema migrate aspice --edit ID # re-open a previously-resolved conflict rivet schema migrate aspice --status # show state machine pointer rivet schema migrate aspice --finish # validate + delete snapshot -rivet schema migrate aspice --abort # restore from snapshot +rivet schema migrate aspice --abort # restore everything from snapshot ``` The default invocation is plan-only and never modifies the project tree. @@ -2596,31 +2602,75 @@ The default invocation is plan-only and never modifies the project tree. │ ▼ --apply [IN_PROGRESS] - │ - ▼ (no conflicts, mechanical-only path) - [COMPLETE] - │ --finish - ▼ + │ ┌── conflict? + ▼ ▼ + [COMPLETE] [CONFLICT]──┬── --continue ──▶ next conflict / [COMPLETE] + │ ├── --skip ──▶ next conflict / [COMPLETE] + │ --finish ├── --edit ──▶ stay [CONFLICT] on chosen artifact + ▼ └── --abort ──▶ snapshot restore (any state) (deleted) ``` `--abort` from any state restores the project tree from the snapshot captured before `--apply` and deletes the migration directory. -Phase 1 deliberately does not implement the `[CONFLICT]` state — if the -plan contains any conflicts, `--apply` bails loudly with exit 1 and -leaves the project untouched. +## Conflict resolution flow (Phase 2) + +When `--apply` encounters a value-mapping conflict (e.g. `priority: 5` +on a target type whose `priority` field is enum `[must|should|could|wont]`), +it: + +1. Applies all mechanical / decidable-with-policy changes for that file. +2. Splices rebase-style markers into the conflicted artifact's + field, like: + + ```yaml + - id: REQ-001 + type: sw-req # was: requirement (auto-renamed) + fields: + priority: <<<<<<< source: dev (priority: 5) + 5 + ======= target: aspice (sw-req.priority: [must|should|could|wont]) + + >>>>>>> + ``` +3. Sets state to `CONFLICT`, writes the artifact ID to + `.rivet/migrations//current-conflict`, and exits non-zero. + +You then: + +* Open the file. Replace the marker block with a single value. + (Anything that lands inside `<<<<<<<` … `>>>>>>>` is fine; the + important part is removing all three marker lines.) +* Run `rivet schema migrate --continue`. The CLI verifies no + markers remain in the file, re-parses it as YAML, marks the artifact + resolved in `manifest.yaml`, and moves to the next conflict (or + `COMPLETE`). + +If you'd rather drop the conflicted artifact from the migration entirely +(restoring its pre-migration form), run `--skip` instead. The artifact +is replaced by its snapshot copy; the rest of the migration carries on. + +To revisit a previously-resolved conflict (e.g. you picked the wrong +value), run `rivet schema migrate --edit `. The state +returns to `CONFLICT` with markers re-stamped on that artifact, ready +for another `--continue` / `--skip`. + +A `MigrationConflict` invariant in `rivet docs check` flags any artifact +YAML that still contains marker lines, so you can't accidentally commit +an unresolved conflict. ## Storage layout A migration is stored under `.rivet/migrations/--to-/`: -| File | Purpose | -|-------------------|--------------------------------------------------| -| `plan.yaml` | Full diff: per-artifact, per-field action class. | -| `manifest.yaml` | Recipe + state + change counts (audit trail). | -| `state` | Single-line: `PLANNED | IN_PROGRESS | COMPLETE`. | -| `snapshot/` | Full pre-migration `artifacts/` + `rivet.yaml`. | +| File | Purpose | +|-----------------------|----------------------------------------------------------------------| +| `plan.yaml` | Full diff: per-artifact, per-field action class. | +| `manifest.yaml` | Recipe + state + change counts + per-artifact resolution status. | +| `state` | Single-line: `PLANNED | IN_PROGRESS | CONFLICT | COMPLETE`. | +| `current-conflict` | (Phase 2) Artifact ID `--apply` paused on. Absent when not in CONFLICT. | +| `snapshot/` | Full pre-migration `artifacts/` + `rivet.yaml`. | Only one migration may be in flight per project. The directory survives across sessions — multi-day migrations are fine. @@ -2683,27 +2733,29 @@ classes (mirrors `git rebase --interactive`'s pick / edit / drop): | `drop` | Drop the link. | | `strict` | Treat as a conflict. `--apply` will bail. | -## What Phase 1 does NOT do +## What is still deferred -- No `--continue` / `--skip` / `--edit` (Phase 2) -- No conflict markers in YAML (Phase 2) -- No dashboard surface -- No interactive wizard -- No automatic rivet.yaml update — after `--apply` you still need to - swap your loaded schemas (e.g. dev -> aspice). Migration touches - artifacts, not config. -- No provenance entries on migrated artifacts -- No automatic recipe registration; only the shipped `dev-to-aspice` - recipe is available +- No dashboard `/migrations/` surface (Phase 3) +- No `rivet recipes` subcommand / recipe distribution (Phase 3) +- No interactive TUI wizard +- No automatic rivet.yaml update — after the migration completes you + still need to swap your loaded schemas (e.g. dev -> aspice). Migration + touches artifacts, not config. +- No provenance entries auto-stamped on migrated artifacts (post-MVP) +- No automatic recipe registration beyond the shipped `dev-to-aspice` + recipe; add new recipes under `/migrations/`. ## Tips - Always run plan-only first and read `plan.yaml` before `--apply`. - The snapshot is byte-faithful for `artifacts/` and `rivet.yaml`. - `--abort` produces an byte-identical restore. (`docs/`, `.rivet/`, - test results, etc. are not snapshotted because Phase 1 doesn't touch - them.) + `--abort` produces a byte-identical restore. (`docs/`, `.rivet/`, + test results, etc. are not snapshotted because the migration doesn't + touch them.) - `--finish` is destructive (it deletes the snapshot). Run `rivet validate` first to convince yourself the migrated tree is healthy. - If you need to redo a migration: `--abort` and start over. +- `rivet docs check` runs the `MigrationConflict` invariant — committing + artifact YAML with `<<<<<<<` / `=======` / `>>>>>>>` lines fails the + gate, so don't worry about pushing a half-resolved migration. "#;