Skip to main content

agentstategraph_migrate/migrations/
v0_4_0_plan_assignments.rs

1//! Migrate `/plan_assignments` sidecar → native `Task::assigned_to`.
2//!
3//! Shape of the sidecar, per a legacy pre-0.4 deployment:
4//!
5//! ```json
6//! {
7//!   "plan_assignments": {
8//!     "<plan_name>": {
9//!       "<task_id>": "<agent_id>"
10//!     }
11//!   }
12//! }
13//! ```
14//!
15//! After migration:
16//!  - each referenced task has `assigned_to = Some(agent)`
17//!  - the entire `/plan_assignments` subtree is gone
18//!  - `/_meta/schema_version` = `"0.4.0"`
19//!
20//! All four effects land in a single atomic commit tagged
21//! `IntentCategory::Migrate`.
22
23use std::sync::LazyLock;
24
25use agentstategraph::{META_SCHEMA_VERSION_PATH, RepoError, Repository};
26use semver::{Version, VersionReq};
27use serde_json::Value;
28
29use crate::{MigrateError, MigrationOutcome, migrate_commit_options, version_object};
30
31/// The legacy sidecar path used pre-0.4.
32pub const SIDECAR_PATH: &str = "/plan_assignments";
33
34/// Default prefix under which `agentstategraph-tasks` plans are stored.
35/// Consumers with a non-standard prefix should register their own
36/// `Migration { plans_prefix }` instance.
37pub const DEFAULT_PLANS_PREFIX: &str = "/plans";
38
39static FROM_REQ: LazyLock<VersionReq> =
40    LazyLock::new(|| VersionReq::parse("<0.4.0").expect("valid range"));
41static TO_VER: LazyLock<Version> =
42    LazyLock::new(|| Version::parse("0.4.0").expect("valid version"));
43
44/// The migration. Unit struct defaults to `DEFAULT_PLANS_PREFIX`.
45pub struct Migration;
46
47impl Migration {
48    fn plans_prefix(&self) -> &str {
49        DEFAULT_PLANS_PREFIX
50    }
51}
52
53impl crate::Migration for Migration {
54    fn from_version(&self) -> &VersionReq {
55        &FROM_REQ
56    }
57
58    fn to_version(&self) -> &Version {
59        &TO_VER
60    }
61
62    fn name(&self) -> &str {
63        "plan_assignments_sidecar_to_native"
64    }
65
66    fn describe(&self) -> &str {
67        "migrate /plan_assignments sidecar → Task.assigned_to"
68    }
69
70    fn applies_to(&self, repo: &Repository, ref_name: &str) -> Result<bool, MigrateError> {
71        match repo.get_json(ref_name, SIDECAR_PATH) {
72            Ok(Value::Object(m)) => Ok(!m.is_empty()),
73            Ok(_) => Ok(false),
74            Err(RepoError::Tree(_)) => Ok(false),
75            Err(e) => Err(MigrateError::Repo(e)),
76        }
77    }
78
79    fn migrate(&self, repo: &Repository, ref_name: &str) -> Result<MigrationOutcome, MigrateError> {
80        let sidecar = match repo.get_json(ref_name, SIDECAR_PATH) {
81            Ok(v) => v,
82            Err(RepoError::Tree(_)) => {
83                // Nothing to migrate — still bump the sentinel in its own commit.
84                let commit_id = bump_version_only(repo, ref_name)?;
85                return Ok(MigrationOutcome {
86                    name: self.name().to_string(),
87                    commit_id: Some(commit_id),
88                    from: Version::parse("0.3.0").unwrap(),
89                    to: TO_VER.clone(),
90                    notes: vec!["no sidecar present; stamped schema_version".into()],
91                });
92            }
93            Err(e) => return Err(MigrateError::Repo(e)),
94        };
95
96        let plans_map = match sidecar {
97            Value::Object(m) => m,
98            other => {
99                return Err(MigrateError::Corrupt(format!(
100                    "{SIDECAR_PATH} must be an object; got {other:?}"
101                )));
102            }
103        };
104
105        let desc = format!("migrate {SIDECAR_PATH} sidecar → Task.assigned_to (→ 0.4.0)");
106        let reasoning =
107            "native assigned_to supersedes sidecar; spec/UPGRADE-PATH.md §4".to_string();
108
109        let handle = repo.speculate(ref_name, Some(desc.clone()))?;
110
111        let mut touched_tasks: usize = 0;
112        let mut notes = Vec::new();
113
114        for (plan_name, per_plan) in plans_map.iter() {
115            let Value::Object(task_map) = per_plan else {
116                notes.push(format!(
117                    "skipping plan {plan_name:?} — value is not an object"
118                ));
119                continue;
120            };
121
122            for (task_id, agent_val) in task_map.iter() {
123                let Some(agent) = agent_val.as_str() else {
124                    notes.push(format!(
125                        "skipping {plan_name}/{task_id} — agent is not a string"
126                    ));
127                    continue;
128                };
129
130                let task_path = format!("{}/{}/{}", self.plans_prefix(), plan_name, task_id);
131
132                let mut task_json = match repo.get_json(ref_name, &task_path) {
133                    Ok(v) => v,
134                    Err(RepoError::Tree(_)) => {
135                        notes.push(format!(
136                            "skipping {plan_name}/{task_id} — task missing at {task_path}"
137                        ));
138                        continue;
139                    }
140                    Err(e) => {
141                        repo.discard_speculation(handle).ok();
142                        return Err(MigrateError::Repo(e));
143                    }
144                };
145
146                let Value::Object(ref mut task_obj) = task_json else {
147                    notes.push(format!(
148                        "skipping {plan_name}/{task_id} — task JSON is not an object"
149                    ));
150                    continue;
151                };
152
153                task_obj.insert("assigned_to".into(), Value::String(agent.to_string()));
154
155                if let Err(e) = repo.spec_set_json(handle, &task_path, &task_json) {
156                    repo.discard_speculation(handle).ok();
157                    return Err(MigrateError::Repo(e));
158                }
159                touched_tasks += 1;
160            }
161        }
162
163        if let Err(e) = repo.spec_delete(handle, SIDECAR_PATH) {
164            repo.discard_speculation(handle).ok();
165            return Err(MigrateError::Repo(e));
166        }
167
168        let version_val = serde_json::to_value(TO_VER.to_string()).expect("string is valid json");
169        if let Err(e) = repo.spec_set_json(handle, META_SCHEMA_VERSION_PATH, &version_val) {
170            repo.discard_speculation(handle).ok();
171            return Err(MigrateError::Repo(e));
172        }
173
174        let opts = migrate_commit_options(self.name(), desc, reasoning);
175        let commit_id = repo
176            .commit_speculation(handle, opts)
177            .map_err(MigrateError::Repo)?;
178
179        notes.insert(0, format!("assigned {touched_tasks} task(s)"));
180
181        Ok(MigrationOutcome {
182            name: self.name().to_string(),
183            commit_id: Some(commit_id),
184            from: Version::parse("0.3.0").unwrap(),
185            to: TO_VER.clone(),
186            notes,
187        })
188    }
189}
190
191fn bump_version_only(
192    repo: &Repository,
193    ref_name: &str,
194) -> Result<agentstategraph_core::ObjectId, MigrateError> {
195    let opts = migrate_commit_options(
196        "plan_assignments_sidecar_to_native",
197        "stamp schema_version = 0.4.0 (no sidecar present)",
198        "no-op migration; advances /_meta/schema_version",
199    );
200    repo.set(
201        ref_name,
202        META_SCHEMA_VERSION_PATH,
203        &version_object(&TO_VER),
204        opts,
205    )
206    .map_err(MigrateError::Repo)
207}
208
209#[cfg(test)]
210mod tests {
211    use super::*;
212    use crate::Migration as MigrationTrait;
213    use crate::{CheckResult, Registry, RunMode, check};
214    use agentstategraph::{CommitOptions, META_SCHEMA_VERSION_PATH, Repository};
215    use agentstategraph_core::IntentCategory;
216    use agentstategraph_storage::SqliteStorage;
217    use agentstategraph_tasks::{Priority, TaskId, TaskStore};
218    use std::sync::Arc;
219
220    /// Emulate a pre-0.4 database: init it, then stomp the schema_version
221    /// sentinel to pretend 0.3.0, seed some tasks via TaskStore, and
222    /// install a `/plan_assignments` sidecar.
223    fn seed_pre_0_4_repo() -> (Arc<Repository>, Vec<(String, TaskId, String)>) {
224        let repo = Arc::new(Repository::new(Box::new(
225            SqliteStorage::in_memory().expect("in-memory sqlite"),
226        )));
227        repo.init().unwrap();
228
229        // Downgrade the stamp using a Migrate commit so the guard allows it.
230        repo.set(
231            "main",
232            META_SCHEMA_VERSION_PATH,
233            &agentstategraph_core::Object::Atom(agentstategraph_core::Atom::String("0.3.0".into())),
234            CommitOptions::new("test/setup", IntentCategory::Migrate, "seed pre-0.4"),
235        )
236        .unwrap();
237
238        let store = TaskStore::new(repo.clone(), DEFAULT_PLANS_PREFIX, "seed");
239
240        store.create_plan("main", "alpha", None).unwrap();
241        let t1 = store
242            .add_task(
243                "main",
244                "alpha",
245                "Write hero",
246                Priority::High,
247                None,
248                vec![],
249                None,
250            )
251            .unwrap();
252        let t2 = store
253            .add_task(
254                "main",
255                "alpha",
256                "Ship hero",
257                Priority::Medium,
258                None,
259                vec![],
260                None,
261            )
262            .unwrap();
263
264        store.create_plan("main", "beta", None).unwrap();
265        let t3 = store
266            .add_task(
267                "main",
268                "beta",
269                "Research",
270                Priority::Low,
271                None,
272                vec![],
273                None,
274            )
275            .unwrap();
276
277        let assignments = vec![
278            ("alpha".to_string(), t1.id.clone(), "agent-A".to_string()),
279            ("alpha".to_string(), t2.id.clone(), "agent-B".to_string()),
280            ("beta".to_string(), t3.id.clone(), "agent-A".to_string()),
281        ];
282
283        let sidecar = serde_json::json!({
284            "alpha": {
285                t1.id.as_str(): "agent-A",
286                t2.id.as_str(): "agent-B",
287            },
288            "beta": { t3.id.as_str(): "agent-A" },
289        });
290
291        repo.set_json(
292            "main",
293            SIDECAR_PATH,
294            &sidecar,
295            CommitOptions::new("test/setup", IntentCategory::Checkpoint, "install sidecar"),
296        )
297        .unwrap();
298
299        (repo, assignments)
300    }
301
302    #[test]
303    fn migrates_sidecar_into_tasks_and_bumps_version() {
304        let (repo, assignments) = seed_pre_0_4_repo();
305        let store = TaskStore::new(repo.clone(), DEFAULT_PLANS_PREFIX, "after");
306
307        let m = Migration;
308        assert!(m.applies_to(&repo, "main").unwrap());
309
310        let outcome = m.migrate(&repo, "main").unwrap();
311        assert!(outcome.commit_id.is_some());
312        assert!(outcome.notes.first().unwrap().contains("assigned 3"));
313
314        for (plan, task_id, agent) in &assignments {
315            let task = store.get_task("main", plan, task_id).unwrap();
316            assert_eq!(task.assigned_to.as_deref(), Some(agent.as_str()));
317        }
318
319        let sidecar = repo.get_json("main", SIDECAR_PATH);
320        assert!(sidecar.is_err(), "sidecar should be gone, got {sidecar:?}");
321
322        let v = repo.get_json("main", META_SCHEMA_VERSION_PATH).unwrap();
323        assert_eq!(v, serde_json::json!("0.4.0"));
324    }
325
326    #[test]
327    fn migration_is_idempotent() {
328        let (repo, _) = seed_pre_0_4_repo();
329        let m = Migration;
330        m.migrate(&repo, "main").unwrap();
331
332        assert!(
333            !m.applies_to(&repo, "main").unwrap(),
334            "should be a no-op now"
335        );
336
337        let log_before = repo.log("main", 100).unwrap();
338        // Run again — produces one more commit that only bumps version,
339        // which is fine (idempotent on data, not on commit count).
340        let _ = m.migrate(&repo, "main");
341        let log_after = repo.log("main", 100).unwrap();
342        // The second run may produce at most one commit (the version stamp).
343        assert!(
344            log_after.len() - log_before.len() <= 1,
345            "expected <=1 additional commit, got {} → {}",
346            log_before.len(),
347            log_after.len()
348        );
349    }
350
351    #[test]
352    fn migration_commit_is_tagged_migrate() {
353        let (repo, _) = seed_pre_0_4_repo();
354        Migration.migrate(&repo, "main").unwrap();
355
356        let log = repo.log("main", 10).unwrap();
357        let head = &log[0];
358        assert_eq!(head.intent.category, IntentCategory::Migrate);
359        assert!(head.intent.description.contains("plan_assignments"));
360    }
361
362    #[test]
363    fn registry_run_end_to_end() {
364        let (repo, _) = seed_pre_0_4_repo();
365        let registry = Registry::builtin();
366        let target = TO_VER.clone();
367
368        let r = check(&repo, "main", &target, &registry).unwrap();
369        assert!(
370            matches!(r, CheckResult::UpgradeAvailable { .. }),
371            "expected UpgradeAvailable, got {r:?}"
372        );
373
374        let report = registry
375            .run(&repo, "main", &target, RunMode::Apply)
376            .unwrap();
377        assert_eq!(report.final_version, target);
378        assert_eq!(report.steps.len(), 1);
379
380        let r = check(&repo, "main", &target, &registry).unwrap();
381        assert!(matches!(r, CheckResult::UpToDate { .. }), "got {r:?}");
382    }
383
384    #[test]
385    fn dry_run_reports_without_mutating() {
386        let (repo, _) = seed_pre_0_4_repo();
387        let registry = Registry::builtin();
388        let target = TO_VER.clone();
389
390        let log_before = repo.log("main", 100).unwrap().len();
391        let report = registry
392            .run(&repo, "main", &target, RunMode::DryRun)
393            .unwrap();
394        let log_after = repo.log("main", 100).unwrap().len();
395
396        assert_eq!(log_before, log_after, "dry-run must not write");
397        assert_eq!(report.steps.len(), 1);
398        assert!(matches!(
399            report.steps[0].status,
400            crate::StepStatus::WouldApply
401        ));
402    }
403}