1use 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
31pub const SIDECAR_PATH: &str = "/plan_assignments";
33
34pub 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
44pub 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 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 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 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 let _ = m.migrate(&repo, "main");
341 let log_after = repo.log("main", 100).unwrap();
342 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, ®istry).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, ®istry).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}