返回 CodeWhale
migration.rs
根目录 / crates / tui / src / work_graph / migration.rs
1 //! Deterministic import of the pre-Work-Graph Plan and To-do session state.
2
3 use crate::hashing::sha256_hex;
4 use crate::tools::plan::{PlanSnapshot, PlanState, StepStatus};
5 use crate::tools::todo::{TodoList, TodoListSnapshot, TodoStatus};
6
7 use super::{
8 ChangeCtx, CompatPlanMetadata, CompatProjectionState, CompatTodoBinding, EdgeKind, NodeKind,
9 NodeState, Provenance, ValidationReport, WorkEdge, WorkEdgeId, WorkGraph, WorkGraphChange,
10 WorkGraphSnapshot, WorkNode, WorkNodeId, WorkNodePatch,
11 };
12
13 const PLAN_PROVENANCE_PREFIX: &str = "\u{2063}cw-plan-step:";
14 const PLAN_PROVENANCE_SUFFIX: &str = "\u{2063}";
15
16 /// Import legacy state into a deterministic graph. Repeating the same import
17 /// produces the same IDs, digest, and serialized snapshot.
18 pub fn import_legacy(
19 session_id: &str,
20 plan: &PlanSnapshot,
21 todos: &TodoListSnapshot,
22 ) -> Result<WorkGraphSnapshot, String> {
23 let plan = PlanState::from_snapshot(plan).snapshot();
24 let todos = TodoList::from_snapshot(todos)?.snapshot();
25 let canonical = serde_json::to_vec(&(&plan, &todos))
26 .map_err(|err| format!("could not canonicalize legacy Work state: {err}"))?;
27 let digest = sha256_hex(canonical);
28
29 let mut graph = WorkGraph::new();
30 let ctx = ChangeCtx {
31 session_id: session_id.to_string(),
32 now: 0,
33 idempotency_key: None,
34 };
35 let objective_id = WorkNodeId::derive(session_id, "objective");
36 let objective_title = plan
37 .objective
38 .as_deref()
39 .or(plan.title.as_deref())
40 .unwrap_or("Imported session work")
41 .to_string();
42 apply(
43 &mut graph,
44 WorkGraphChange::AddNode {
45 node: WorkNode {
46 id: objective_id.clone(),
47 kind: NodeKind::Objective,
48 title: objective_title,
49 state: NodeState::Ready,
50 acceptance: Vec::new(),
51 binding: None,
52 evidence: None,
53 provenance: Provenance::Import {
54 source_digest: digest.clone(),
55 ordinal: None,
56 },
57 created_at: 0,
58 updated_at: 0,
59 },
60 },
61 &ctx,
62 )?;
63
64 let mut compat = CompatProjectionState {
65 plan: CompatPlanMetadata::from_plan_snapshot(&plan),
66 ..CompatProjectionState::default()
67 };
68 for (index, item) in plan.items.iter().enumerate() {
69 let ordinal = u32::try_from(index).map_err(|_| "too many legacy plan steps")?;
70 let id = WorkNodeId::derive(session_id, &format!("plan:{index}"));
71 apply(
72 &mut graph,
73 WorkGraphChange::AddNode {
74 node: WorkNode {
75 id: id.clone(),
76 kind: NodeKind::PlanStep,
77 title: item.step.trim().to_string(),
78 state: node_state_from_plan(&item.status),
79 acceptance: Vec::new(),
80 binding: None,
81 evidence: None,
82 provenance: Provenance::Import {
83 source_digest: digest.clone(),
84 ordinal: Some(ordinal),
85 },
86 created_at: 0,
87 updated_at: 0,
88 },
89 },
90 &ctx,
91 )?;
92 add_contains_edge(&mut graph, session_id, &objective_id, &id, &ctx)?;
93 compat.plan_order.push(id);
94 }
95
96 for item in &todos.items {
97 let (clean_title, marker_index) = strip_plan_provenance(&item.content);
98 let aliased = marker_index
99 .and_then(|index| {
100 usize::try_from(index)
101 .ok()
102 .map(|usize_index| (index, usize_index))
103 })
104 .and_then(|(index, usize_index)| {
105 compat
106 .plan_order
107 .get(usize_index)
108 .cloned()
109 .map(|node| (index, node))
110 });
111 let (node, plan_index) = if let Some((index, node)) = aliased {
112 let current = graph
113 .snapshot()
114 .node(&node)
115 .map(|node| node.state)
116 .ok_or_else(|| "legacy plan alias references a missing node".to_string())?;
117 let desired = more_advanced_state(current, node_state_from_todo(item.status));
118 if desired != current {
119 apply(
120 &mut graph,
121 WorkGraphChange::UpdateNode {
122 id: node.clone(),
123 patch: WorkNodePatch {
124 state: Some(desired),
125 ..WorkNodePatch::default()
126 },
127 },
128 &ctx,
129 )?;
130 }
131 (node, Some(index))
132 } else {
133 let node = WorkNodeId::derive(session_id, &format!("todo:{}", item.id));
134 apply(
135 &mut graph,
136 WorkGraphChange::AddNode {
137 node: WorkNode {
138 id: node.clone(),
139 kind: NodeKind::PlanStep,
140 title: clean_title,
141 // Add live operations as Ready, root them, then apply
142 // their live state so V2 is true after every change.
143 state: NodeState::Ready,
144 acceptance: Vec::new(),
145 binding: None,
146 evidence: None,
147 provenance: Provenance::Import {
148 source_digest: digest.clone(),
149 ordinal: Some(item.id),
150 },
151 created_at: 0,
152 updated_at: 0,
153 },
154 },
155 &ctx,
156 )?;
157 add_contains_edge(&mut graph, session_id, &objective_id, &node, &ctx)?;
158 let desired = node_state_from_todo(item.status);
159 if desired != NodeState::Ready {
160 apply(
161 &mut graph,
162 WorkGraphChange::UpdateNode {
163 id: node.clone(),
164 patch: WorkNodePatch {
165 state: Some(desired),
166 ..WorkNodePatch::default()
167 },
168 },
169 &ctx,
170 )?;
171 }
172 (node, None)
173 };
174 compat.todos.push(CompatTodoBinding {
175 legacy_id: item.id,
176 node,
177 plan_index,
178 });
179 }
180
181 apply(
182 &mut graph,
183 WorkGraphChange::ReplaceCompatProjection { compat },
184 &ctx,
185 )?;
186 apply(
187 &mut graph,
188 WorkGraphChange::SetImportDigest { digest },
189 &ctx,
190 )?;
191 Ok(graph.into_snapshot())
192 }
193
194 fn apply(graph: &mut WorkGraph, change: WorkGraphChange, ctx: &ChangeCtx) -> Result<(), String> {
195 graph
196 .apply(change, ctx.clone())
197 .map(|_| ())
198 .map_err(|err: ValidationReport| err.to_string())
199 }
200
201 fn add_contains_edge(
202 graph: &mut WorkGraph,
203 session_id: &str,
204 objective: &WorkNodeId,
205 child: &WorkNodeId,
206 ctx: &ChangeCtx,
207 ) -> Result<(), String> {
208 apply(
209 graph,
210 WorkGraphChange::AddEdge {
211 edge: WorkEdge {
212 id: WorkEdgeId::derive(
213 session_id,
214 &format!("contains:{}:{}", objective.as_str(), child.as_str()),
215 ),
216 kind: EdgeKind::Contains,
217 from: objective.clone(),
218 to: child.clone(),
219 },
220 },
221 ctx,
222 )
223 }
224
225 fn node_state_from_plan(status: &StepStatus) -> NodeState {
226 match status {
227 StepStatus::Pending => NodeState::Ready,
228 StepStatus::InProgress => NodeState::Active,
229 StepStatus::Completed => NodeState::Completed,
230 }
231 }
232
233 fn node_state_from_todo(status: TodoStatus) -> NodeState {
234 match status {
235 TodoStatus::Pending => NodeState::Ready,
236 TodoStatus::InProgress => NodeState::Active,
237 TodoStatus::Completed => NodeState::Completed,
238 TodoStatus::Cancelled => NodeState::Cancelled,
239 }
240 }
241
242 fn more_advanced_state(left: NodeState, right: NodeState) -> NodeState {
243 fn rank(state: NodeState) -> u8 {
244 match state {
245 NodeState::Completed | NodeState::Verified => 2,
246 NodeState::Initializing | NodeState::Active => 1,
247 _ => 0,
248 }
249 }
250 if rank(right) > rank(left) {
251 right
252 } else {
253 left
254 }
255 }
256
257 /// The only remaining parser for the retired Plan→To-do marker writer.
258 fn strip_plan_provenance(content: &str) -> (String, Option<u32>) {
259 let Some(start) = content.find(PLAN_PROVENANCE_PREFIX) else {
260 return (
261 content
262 .replace(PLAN_PROVENANCE_SUFFIX, "")
263 .trim()
264 .to_string(),
265 None,
266 );
267 };
268 let value_start = start + PLAN_PROVENANCE_PREFIX.len();
269 let (marker_end, index) =
270 if let Some(relative_end) = content[value_start..].find(PLAN_PROVENANCE_SUFFIX) {
271 let end = value_start + relative_end;
272 (
273 end + PLAN_PROVENANCE_SUFFIX.len(),
274 content[value_start..end].parse::<u32>().ok(),
275 )
276 } else {
277 // A truncated retired marker was never user content. Drop the
278 // unterminated suffix so hidden migration metadata cannot leak into
279 // either compatibility projection.
280 (content.len(), None)
281 };
282 let mut clean = String::with_capacity(content.len());
283 clean.push_str(&content[..start]);
284 clean.push_str(&content[marker_end..]);
285 (
286 clean.replace(PLAN_PROVENANCE_SUFFIX, "").trim().to_string(),
287 index,
288 )
289 }
290
291 #[cfg(test)]
292 fn plan_provenance_marker(index: u32) -> String {
293 format!("{PLAN_PROVENANCE_PREFIX}{index}{PLAN_PROVENANCE_SUFFIX}")
294 }
295
296 #[cfg(test)]
297 mod tests {
298 use super::*;
299 use crate::tools::plan::PlanItemArg;
300 use crate::tools::todo::TodoItem;
301 use crate::work_graph::{project_plan, project_todos, validate};
302
303 #[test]
304 fn legacy_import_is_deterministic_and_projects_complete_old_views() {
305 let plan = PlanSnapshot {
306 objective: Some("Ship it".to_string()),
307 items: vec![PlanItemArg {
308 step: "Verify".to_string(),
309 status: StepStatus::InProgress,
310 }],
311 ..PlanSnapshot::default()
312 };
313 let todos = TodoListSnapshot {
314 items: vec![TodoItem {
315 id: 4,
316 content: format!("Verify{}", plan_provenance_marker(0)),
317 status: TodoStatus::InProgress,
318 }],
319 completion_pct: 0,
320 in_progress_id: Some(4),
321 };
322 let first = import_legacy("session-1", &plan, &todos).expect("import");
323 let second = import_legacy("session-1", &plan, &todos).expect("repeat import");
324 assert_eq!(first, second);
325 validate(&first).expect("valid graph");
326 assert_eq!(project_plan(&first), plan);
327 let projected_todos = project_todos(&first);
328 assert_eq!(projected_todos.items[0].content, "Verify");
329 assert_eq!(projected_todos.items[0].status, TodoStatus::InProgress);
330 assert_eq!(first.compat.todos[0].node, first.compat.plan_order[0]);
331 assert!(!first.nodes[1].title.contains('\u{2063}'));
332 }
333
334 #[test]
335 fn malformed_retired_markers_never_leak_into_old_views() {
336 let plan = PlanSnapshot::default();
337 let todos = TodoListSnapshot {
338 items: vec![
339 TodoItem {
340 id: 1,
341 content: format!(
342 "Visible{PLAN_PROVENANCE_PREFIX}not-a-number{PLAN_PROVENANCE_SUFFIX}"
343 ),
344 status: TodoStatus::Pending,
345 },
346 TodoItem {
347 id: 2,
348 content: format!("Also visible{PLAN_PROVENANCE_PREFIX}truncated"),
349 status: TodoStatus::Pending,
350 },
351 ],
352 completion_pct: 0,
353 in_progress_id: None,
354 };
355 let graph = import_legacy("malformed-markers", &plan, &todos).expect("import");
356 let projected = project_todos(&graph);
357 assert_eq!(projected.items[0].content, "Visible");
358 assert_eq!(projected.items[1].content, "Also visible");
359 assert!(
360 projected
361 .items
362 .iter()
363 .all(|item| !item.content.contains('\u{2063}'))
364 );
365 }
366 }
367
367 lines RUST