1use std::collections::{HashMap, HashSet};
40
41use rusqlite::{params, Connection};
42
43use crate::backend::storage::store::Store;
44
45use super::{Migration, MigrationContext, MigrationError, MigrationScope};
46
47pub struct M0027AgentsWorkspaceBackfill;
48
49struct LegacyLaunch {
51 id: String,
52 parent_instance_id: String,
53 definition_id: String,
54 working_directory: String,
55 created_at: i64,
56}
57
58fn projection_key(
63 launch: &LegacyLaunch,
64 by_id: &HashMap<String, &LegacyLaunch>,
65 is_seeded: &HashMap<String, i64>,
66) -> String {
67 let mut root = launch;
68 let mut seen: HashSet<&str> = HashSet::new();
69 while !root.parent_instance_id.is_empty() && seen.insert(root.id.as_str()) {
70 match by_id.get(&root.parent_instance_id) {
71 Some(parent) => root = parent,
72 None => break,
73 }
74 }
75 let seeded = is_seeded.get(&root.definition_id).copied().unwrap_or(1);
76 if seeded == 0 {
77 root.definition_id.clone()
78 } else {
79 root.id.clone()
80 }
81}
82
83#[derive(Debug, Default, PartialEq, Eq)]
85pub(super) struct WorkspaceStats {
86 pub updated: usize,
87}
88
89pub(super) fn backfill_workspaces(conn: &Connection) -> Result<WorkspaceStats, String> {
90 let has_legacy: bool = conn
91 .query_row(
92 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'db_agent_instances')",
93 [],
94 |row| row.get(0),
95 )
96 .map_err(|e| format!("probe db_agent_instances: {e}"))?;
97 if !has_legacy {
98 return Ok(WorkspaceStats::default());
99 }
100
101 let mut stmt = conn
102 .prepare(
103 "SELECT id, parent_instance_id, definition_id, working_directory, created_at
104 FROM db_agent_instances",
105 )
106 .map_err(|e| format!("read db_agent_instances: {e}"))?;
107 let launches: Vec<LegacyLaunch> = stmt
108 .query_map([], |row| {
109 Ok(LegacyLaunch {
110 id: row.get(0)?,
111 parent_instance_id: row.get(1)?,
112 definition_id: row.get(2)?,
113 working_directory: row.get(3)?,
114 created_at: row.get(4)?,
115 })
116 })
117 .and_then(|rows| rows.collect::<Result<Vec<_>, _>>())
118 .map_err(|e| format!("read db_agent_instances: {e}"))?;
119 drop(stmt);
120 if launches.is_empty() {
121 return Ok(WorkspaceStats::default());
122 }
123
124 let mut seeded_stmt = conn
125 .prepare("SELECT id, is_seeded FROM db_agent_definitions")
126 .map_err(|e| format!("read db_agent_definitions: {e}"))?;
127 let is_seeded: HashMap<String, i64> = seeded_stmt
128 .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)))
129 .and_then(|rows| rows.collect::<Result<HashMap<_, _>, _>>())
130 .map_err(|e| format!("read db_agent_definitions: {e}"))?;
131 drop(seeded_stmt);
132
133 let by_id: HashMap<String, &LegacyLaunch> = launches.iter().map(|l| (l.id.clone(), l)).collect();
134 let mut latest: HashMap<String, &LegacyLaunch> = HashMap::new();
138 for launch in &launches {
139 let key = projection_key(launch, &by_id, &is_seeded);
140 let replace = match latest.get(&key) {
141 None => true,
142 Some(cur) => (launch.created_at, launch.id.as_str()) > (cur.created_at, cur.id.as_str()),
143 };
144 if replace {
145 latest.insert(key, launch);
146 }
147 }
148
149 let mut stats = WorkspaceStats::default();
150 for (key, launch) in latest {
151 if launch.working_directory.is_empty() {
154 continue;
155 }
156 let n = conn
157 .execute(
158 "UPDATE db_agents SET working_directory = ?2
159 WHERE id = ?1 AND is_template = 0 AND working_directory != ?2",
160 params![key, launch.working_directory],
161 )
162 .map_err(|e| format!("update db_agents {key}: {e}"))?;
163 stats.updated += n;
164 }
165 Ok(stats)
166}
167
168impl Migration for M0027AgentsWorkspaceBackfill {
169 fn id(&self) -> &'static str { "0027_agents_workspace_backfill" }
170 fn scope(&self) -> MigrationScope { MigrationScope::Channel }
171 fn description(&self) -> &'static str {
172 "Backfill db_agents.working_directory from the latest legacy launch's resolved workspace"
173 }
174
175 fn up(&self, ctx: &MigrationContext) -> Result<(), MigrationError> {
176 if !ctx.channel_store_path.exists() {
177 return Ok(());
178 }
179 drop(
182 Store::open(&ctx.channel_store_path)
183 .map_err(|e| MigrationError(format!("agents_workspace_backfill: open mstore: {e}")))?,
184 );
185 let conn = Connection::open(&ctx.channel_store_path)
186 .map_err(|e| MigrationError(format!("agents_workspace_backfill: open: {e}")))?;
187 let stats = backfill_workspaces(&conn)
188 .map_err(|e| MigrationError(format!("agents_workspace_backfill: {e}")))?;
189 tracing::info!(
190 updated = stats.updated,
191 "m0027_agents_workspace_backfill: complete"
192 );
193 Ok(())
194 }
195
196 }
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205
206 fn legacy_store() -> Connection {
210 let conn = Connection::open_in_memory().unwrap();
211 conn.execute_batch(
212 "CREATE TABLE db_agent_definitions (
213 id TEXT PRIMARY KEY, is_seeded INTEGER NOT NULL DEFAULT 0
214 );
215 CREATE TABLE db_agent_instances (
216 id TEXT PRIMARY KEY,
217 parent_instance_id TEXT NOT NULL DEFAULT '',
218 definition_id TEXT NOT NULL,
219 working_directory TEXT NOT NULL DEFAULT '',
220 created_at INTEGER NOT NULL DEFAULT 0
221 );
222 CREATE TABLE db_agents (
223 id TEXT PRIMARY KEY,
224 is_template INTEGER NOT NULL DEFAULT 0,
225 working_directory TEXT NOT NULL DEFAULT ''
226 );",
227 )
228 .unwrap();
229 conn
230 }
231
232 fn workdir(conn: &Connection, id: &str) -> String {
233 conn.query_row("SELECT working_directory FROM db_agents WHERE id = ?1", params![id], |r| {
234 r.get(0)
235 })
236 .unwrap()
237 }
238
239 #[test]
244 fn a_template_launch_row_takes_the_launchs_resolved_workspace() {
245 let conn = legacy_store();
246 conn.execute_batch(
247 "INSERT INTO db_agent_definitions (id, is_seeded) VALUES ('tpl', 1);
248 INSERT INTO db_agent_instances (id, definition_id, working_directory, created_at)
249 VALUES ('launch-1', 'tpl', '/agents/coder-2', 100);
250 INSERT INTO db_agents (id, is_template, working_directory)
251 VALUES ('tpl', 1, '/agents/coder'),
252 ('launch-1', 0, '/agents/coder');",
253 )
254 .unwrap();
255
256 assert_eq!(backfill_workspaces(&conn).unwrap(), WorkspaceStats { updated: 1 });
257 assert_eq!(workdir(&conn, "launch-1"), "/agents/coder-2");
258 assert_eq!(workdir(&conn, "tpl"), "/agents/coder", "a template's own cwd is not a launch's");
259 }
260
261 #[test]
265 fn a_user_agents_row_takes_its_newest_launchs_workspace() {
266 let conn = legacy_store();
267 conn.execute_batch(
268 "INSERT INTO db_agent_definitions (id, is_seeded) VALUES ('agent-a', 0);
269 INSERT INTO db_agent_instances (id, parent_instance_id, definition_id, working_directory, created_at)
270 VALUES ('l1', '', 'agent-a', '/agents/old', 100),
271 ('l2', 'l1', 'agent-a', '/agents/new', 200);
272 INSERT INTO db_agents (id, is_template, working_directory)
273 VALUES ('agent-a', 0, '/agents/configured');",
274 )
275 .unwrap();
276
277 assert_eq!(backfill_workspaces(&conn).unwrap(), WorkspaceStats { updated: 1 });
278 assert_eq!(workdir(&conn, "agent-a"), "/agents/new");
279 }
280
281 #[test]
284 fn a_second_run_and_an_empty_legacy_workspace_both_write_nothing() {
285 let conn = legacy_store();
286 conn.execute_batch(
287 "INSERT INTO db_agent_definitions (id, is_seeded) VALUES ('tpl', 1);
288 INSERT INTO db_agent_instances (id, definition_id, working_directory, created_at)
289 VALUES ('launch-1', 'tpl', '/agents/real', 100),
290 ('launch-2', 'tpl', '', 100);
291 INSERT INTO db_agents (id, is_template, working_directory)
292 VALUES ('launch-1', 0, '/agents/cfg'),
293 ('launch-2', 0, '/agents/kept');",
294 )
295 .unwrap();
296
297 assert_eq!(backfill_workspaces(&conn).unwrap(), WorkspaceStats { updated: 1 });
298 assert_eq!(workdir(&conn, "launch-2"), "/agents/kept");
299 assert_eq!(
300 backfill_workspaces(&conn).unwrap(),
301 WorkspaceStats::default(),
302 "re-running must be a no-op"
303 );
304 }
305
306 #[test]
309 fn a_store_without_the_legacy_table_is_a_no_op() {
310 let conn = Connection::open_in_memory().unwrap();
311 conn.execute_batch("CREATE TABLE db_agents (id TEXT PRIMARY KEY);").unwrap();
312 assert_eq!(backfill_workspaces(&conn).unwrap(), WorkspaceStats::default());
313 }
314}