1use std::collections::{HashMap, HashSet};
35
36use rusqlite::{params, Connection};
37
38use crate::backend::storage::store::Store;
39
40use super::{Migration, MigrationContext, MigrationError, MigrationScope};
41
42pub struct M0025AgentsLaunchStateBackfill;
43
44struct LegacyInstance {
46 id: String,
47 parent_instance_id: String,
48 definition_id: String,
49 block_id: String,
50 session_id: String,
51 status: String,
52 started_at: i64,
53 ended_at: i64,
54 created_at: i64,
55}
56
57fn projection_key(
65 inst: &LegacyInstance,
66 by_id: &HashMap<String, &LegacyInstance>,
67 is_seeded: &HashMap<String, i64>,
68) -> String {
69 let mut root = inst;
70 let mut seen: HashSet<&str> = HashSet::new();
71 while !root.parent_instance_id.is_empty() && seen.insert(root.id.as_str()) {
72 match by_id.get(&root.parent_instance_id) {
73 Some(parent) => root = parent,
74 None => break,
75 }
76 }
77 let seeded = is_seeded.get(&root.definition_id).copied().unwrap_or(1);
78 if seeded == 0 {
79 root.definition_id.clone()
80 } else {
81 root.id.clone()
82 }
83}
84
85#[derive(Debug, Default, PartialEq, Eq)]
89pub(super) struct BackfillStats {
90 pub definitions_repaired: usize,
91 pub projected: usize,
92 pub updated: usize,
93 pub missing_definition: usize,
94}
95
96fn project_template_launch_row(conn: &Connection, root: &LegacyInstance) -> Result<bool, String> {
107 let n = conn
108 .execute(
109 "INSERT OR IGNORE INTO db_agents (
110 id, name, icon, description,
111 is_template, parent_template_id,
112 provider, provider_flags, shell, environment,
113 agent_type, agent_bus_id, accounts,
114 auto_start, restart_on_crash, idle_timeout_minutes,
115 slug, branch_label,
116 identity_id, memory_id, working_directory, github_context,
117 instance_name,
118 created_at, updated_at, is_seeded, user_hidden,
119 container_image, container_volumes, container_name,
120 use_ambient_login, model_vendor_base_url, auto_continue_enabled,
121 conversation_visibility
122 )
123 SELECT i.id,
124 CASE WHEN i.instance_name = '' THEN d.name ELSE i.instance_name END,
125 d.icon, d.description,
126 0, d.id,
127 d.provider, d.provider_flags, d.shell, d.environment,
128 d.agent_type, d.agent_bus_id, d.accounts,
129 d.auto_start, d.restart_on_crash, d.idle_timeout_minutes,
130 d.slug, d.branch_label,
131 i.identity_id, i.memory_id, d.working_directory, i.github_context,
132 i.instance_name,
133 i.created_at, i.created_at, 0, i.display_hidden,
134 d.container_image, d.container_volumes, d.container_name,
135 d.use_ambient_login, d.model_vendor_base_url, d.auto_continue_enabled,
136 d.conversation_visibility
137 FROM db_agent_instances i
138 JOIN db_agent_definitions d ON d.id = i.definition_id
139 WHERE i.id = ?1",
140 params![root.id],
141 )
142 .map_err(|e| format!("project template launch {}: {e}", root.id))?;
143 Ok(n > 0)
144}
145
146fn frozen_repair_def_gaps(conn: &mut Connection) -> Result<usize, String> {
156 let mut stmt = conn
157 .prepare(
158 "SELECT d.id, d.name, d.icon, d.provider, d.description,
159 d.working_directory, d.shell, d.provider_flags, d.auto_start,
160 d.restart_on_crash, d.idle_timeout_minutes, d.created_at,
161 d.agent_type, d.environment, d.agent_bus_id, d.is_seeded,
162 d.accounts, d.parent_id, d.branch_label, d.updated_at,
163 d.slug, d.user_hidden
164 FROM db_agent_definitions d
165 LEFT JOIN db_agents a ON a.id = d.id
166 WHERE a.id IS NULL",
167 )
168 .map_err(|e| format!("gap-repair: prepare: {e}"))?;
169 #[allow(clippy::type_complexity)]
170 let missing: Vec<(
171 String, String, String, String, String, String, String, String,
172 i64, i64, i64, i64, String, String, String, i64, String, String,
173 String, i64, String, i64,
174 )> = stmt
175 .query_map([], |row| {
176 Ok((
177 row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?,
178 row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?,
179 row.get::<_, String>(6)?, row.get::<_, String>(7)?, row.get::<_, i64>(8)?,
180 row.get::<_, i64>(9)?, row.get::<_, i64>(10)?, row.get::<_, i64>(11)?,
181 row.get::<_, String>(12)?, row.get::<_, String>(13)?, row.get::<_, String>(14)?,
182 row.get::<_, i64>(15)?, row.get::<_, String>(16)?, row.get::<_, String>(17)?,
183 row.get::<_, String>(18)?, row.get::<_, i64>(19)?, row.get::<_, String>(20)?,
184 row.get::<_, i64>(21)?,
185 ))
186 })
187 .and_then(|rows| rows.collect::<Result<Vec<_>, _>>())
188 .map_err(|e| format!("gap-repair: read db_agent_definitions: {e}"))?;
189 drop(stmt);
190 if missing.is_empty() {
191 return Ok(0);
192 }
193
194 let tx = conn.transaction().map_err(|e| format!("gap-repair: begin: {e}"))?;
195 let mut inserted = 0usize;
196 for (
197 id, name, icon, provider, description, working_directory, shell,
198 provider_flags, auto_start, restart_on_crash, idle_timeout_minutes,
199 created_at, agent_type, environment, agent_bus_id, is_seeded,
200 accounts, parent_id, branch_label, updated_at, slug, user_hidden,
201 ) in &missing
202 {
203 let is_template = if *is_seeded == 1 { 1_i64 } else { 0_i64 };
204 let parent_template_id = if *is_seeded == 1 { String::new() } else { parent_id.clone() };
205 let affected = tx
206 .execute(
207 "INSERT OR IGNORE INTO db_agents (
208 id, name, icon, description,
209 is_template, parent_template_id,
210 provider, provider_flags, shell, environment,
211 agent_type, agent_bus_id, accounts,
212 auto_start, restart_on_crash, idle_timeout_minutes,
213 slug, branch_label,
214 identity_id, memory_id, working_directory, github_context,
215 instance_name,
216 created_at, updated_at, is_seeded, user_hidden
217 ) VALUES (
218 ?1, ?2, ?3, ?4,
219 ?5, ?6,
220 ?7, ?8, ?9, ?10,
221 ?11, ?12, ?13,
222 ?14, ?15, ?16,
223 ?17, ?18,
224 '', '', ?19, '',
225 '',
226 ?20, ?21, ?22, ?23
227 )",
228 params![
229 id, name, icon, description,
230 is_template, parent_template_id,
231 provider, provider_flags, shell, environment,
232 agent_type, agent_bus_id, accounts,
233 auto_start, restart_on_crash, idle_timeout_minutes,
234 slug, branch_label,
235 working_directory,
236 created_at, updated_at, is_seeded, user_hidden,
237 ],
238 )
239 .map_err(|e| format!("gap-repair: insert {id}: {e}"))?;
240 if affected > 0 {
241 inserted += 1;
242 }
243 }
244 tx.commit().map_err(|e| format!("gap-repair: commit: {e}"))?;
245 Ok(inserted)
246}
247
248pub(super) fn backfill_launch_state(conn: &mut Connection) -> Result<BackfillStats, String> {
257 let has_legacy: bool = conn
258 .query_row(
259 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'db_agent_instances')",
260 [],
261 |row| row.get(0),
262 )
263 .map_err(|e| format!("probe db_agent_instances: {e}"))?;
264 if !has_legacy {
265 return Ok(BackfillStats::default());
266 }
267 let mut stats = BackfillStats {
268 definitions_repaired: frozen_repair_def_gaps(conn)
269 .map_err(|e| format!("repair definition gaps: {e}"))?,
270 ..Default::default()
271 };
272
273 let mut stmt = conn
274 .prepare(
275 "SELECT id, parent_instance_id, definition_id, block_id, session_id, status,
276 started_at, ended_at, created_at
277 FROM db_agent_instances",
278 )
279 .map_err(|e| format!("read db_agent_instances: {e}"))?;
280 let instances: Vec<LegacyInstance> = stmt
281 .query_map([], |row| {
282 Ok(LegacyInstance {
283 id: row.get(0)?,
284 parent_instance_id: row.get(1)?,
285 definition_id: row.get(2)?,
286 block_id: row.get(3)?,
287 session_id: row.get(4)?,
288 status: row.get(5)?,
289 started_at: row.get(6)?,
290 ended_at: row.get(7)?,
291 created_at: row.get(8)?,
292 })
293 })
294 .and_then(|rows| rows.collect::<Result<Vec<_>, _>>())
295 .map_err(|e| format!("read db_agent_instances: {e}"))?;
296 drop(stmt);
297 if instances.is_empty() {
298 return Ok(stats);
299 }
300
301 let mut seeded_stmt = conn
302 .prepare("SELECT id, is_seeded FROM db_agent_definitions")
303 .map_err(|e| format!("read db_agent_definitions: {e}"))?;
304 let is_seeded: HashMap<String, i64> = seeded_stmt
305 .query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)))
306 .and_then(|rows| rows.collect::<Result<HashMap<_, _>, _>>())
307 .map_err(|e| format!("read db_agent_definitions: {e}"))?;
308 drop(seeded_stmt);
309
310 let by_id: HashMap<String, &LegacyInstance> = instances.iter().map(|i| (i.id.clone(), i)).collect();
311 let mut latest: HashMap<String, &LegacyInstance> = HashMap::new();
314 for inst in &instances {
315 let key = projection_key(inst, &by_id, &is_seeded);
316 let replace = match latest.get(&key) {
317 None => true,
318 Some(cur) => (inst.created_at, inst.id.as_str()) > (cur.created_at, cur.id.as_str()),
319 };
320 if replace {
321 latest.insert(key, inst);
322 }
323 }
324
325 for key in latest.keys() {
328 let Some(root) = by_id.get(key) else { continue };
329 let exists: bool = conn
330 .query_row("SELECT EXISTS(SELECT 1 FROM db_agents WHERE id = ?1)", params![key], |r| r.get(0))
331 .map_err(|e| format!("probe db_agents {key}: {e}"))?;
332 if exists {
333 continue;
334 }
335 if project_template_launch_row(conn, root)? {
336 stats.projected += 1;
337 } else {
338 stats.missing_definition += 1;
339 tracing::warn!(
340 instance_id = %root.id,
341 definition_id = %root.definition_id,
342 "m0025_agents_launch_state_backfill: template launch has no definition; cannot project"
343 );
344 }
345 }
346
347 for (key, inst) in latest {
348 let n = conn
349 .execute(
350 "UPDATE db_agents SET
351 session_id = ?2,
352 status = ?3,
353 started_at = ?4,
354 ended_at = ?5,
355 last_block_id = CASE WHEN last_block_id = '' THEN ?6 ELSE last_block_id END
356 WHERE id = ?1
357 AND is_template = 0
358 AND session_id = '' AND status = '' AND started_at = 0 AND ended_at = 0",
359 params![key, inst.session_id, inst.status, inst.started_at, inst.ended_at, inst.block_id],
360 )
361 .map_err(|e| format!("update db_agents {key}: {e}"))?;
362 stats.updated += n;
363 }
364 Ok(stats)
365}
366
367impl Migration for M0025AgentsLaunchStateBackfill {
368 fn id(&self) -> &'static str { "0025_agents_launch_state_backfill" }
369 fn scope(&self) -> MigrationScope { MigrationScope::Channel }
370 fn description(&self) -> &'static str {
371 "Backfill db_agents launch state (session/status/started/ended) from db_agent_instances"
372 }
373
374 fn up(&self, ctx: &MigrationContext) -> Result<(), MigrationError> {
375 if !ctx.channel_store_path.exists() {
376 return Ok(());
377 }
378 drop(
381 Store::open(&ctx.channel_store_path)
382 .map_err(|e| MigrationError(format!("agents_launch_state_backfill: open mstore: {e}")))?,
383 );
384 let mut conn = Connection::open(&ctx.channel_store_path)
385 .map_err(|e| MigrationError(format!("agents_launch_state_backfill: open: {e}")))?;
386 let stats = backfill_launch_state(&mut conn)
387 .map_err(|e| MigrationError(format!("agents_launch_state_backfill: {e}")))?;
388 tracing::info!(
389 definitions_repaired = stats.definitions_repaired,
390 projected = stats.projected,
391 updated = stats.updated,
392 missing_definition = stats.missing_definition,
393 "m0025_agents_launch_state_backfill: complete"
394 );
395 Ok(())
396 }
397
398 }
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410 use crate::backend::storage::store::AgentDefinition;
411
412 fn def(id: &str, is_seeded: i64, parent_id: &str) -> AgentDefinition {
413 AgentDefinition {
414 conversation_visibility: crate::backend::storage::agents::default_conversation_visibility(),
415 id: id.to_string(),
416 slug: id.to_string(),
417 name: id.to_string(),
418 icon: String::new(),
419 provider: "claude".to_string(),
420 description: String::new(),
421 working_directory: String::new(),
422 shell: "bash".to_string(),
423 provider_flags: String::new(),
424 auto_start: 0,
425 restart_on_crash: 0,
426 idle_timeout_minutes: 0,
427 created_at: 1000,
428 agent_type: "standalone".to_string(),
429 environment: String::new(),
430 agent_bus_id: String::new(),
431 is_seeded,
432 accounts: String::new(),
433 parent_id: parent_id.to_string(),
434 branch_label: String::new(),
435 updated_at: 1000,
436 user_hidden: 0,
437 container_image: String::new(),
438 container_volumes: "[]".to_string(),
439 container_name: String::new(),
440 use_ambient_login: 0,
441 auto_continue_enabled: 0,
442 model_vendor_base_url: String::new(),
443 memory_id: String::new(),
444 }
445 }
446
447 fn launch_state(conn: &Connection, id: &str) -> (String, String, i64, String) {
448 conn.query_row(
449 "SELECT session_id, status, started_at, last_block_id FROM db_agents WHERE id = ?1",
450 [id],
451 |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
452 )
453 .unwrap()
454 }
455
456 fn pre_v29_store() -> (tempfile::TempDir, std::path::PathBuf) {
461 let dir = tempfile::tempdir().unwrap();
462 let path = dir.path().join("objects.db");
463 let store = Store::open(&path).unwrap();
464 let mut tpl = def("tpl", 1, "");
465 store.agent_def_insert(&mut tpl).unwrap();
466 let mut clone = def("clone", 0, "tpl");
467 store.agent_def_insert(&mut clone).unwrap();
468 drop(store);
469
470 let conn = Connection::open(&path).unwrap();
477
478 conn.execute_batch(
482 "CREATE TABLE db_agent_definitions (
483 id TEXT PRIMARY KEY, slug TEXT NOT NULL DEFAULT '', name TEXT NOT NULL,
484 icon TEXT NOT NULL DEFAULT '', provider TEXT NOT NULL, description TEXT NOT NULL DEFAULT '',
485 working_directory TEXT NOT NULL DEFAULT '', shell TEXT NOT NULL DEFAULT '',
486 provider_flags TEXT NOT NULL DEFAULT '', auto_start INTEGER NOT NULL DEFAULT 0,
487 restart_on_crash INTEGER NOT NULL DEFAULT 0, idle_timeout_minutes INTEGER NOT NULL DEFAULT 0,
488 created_at INTEGER NOT NULL DEFAULT 0, agent_type TEXT NOT NULL DEFAULT 'standalone',
489 environment TEXT NOT NULL DEFAULT '', agent_bus_id TEXT NOT NULL DEFAULT '',
490 is_seeded INTEGER NOT NULL DEFAULT 0, accounts TEXT NOT NULL DEFAULT '',
491 parent_id TEXT NOT NULL DEFAULT '', branch_label TEXT NOT NULL DEFAULT '',
492 updated_at INTEGER NOT NULL DEFAULT 0, user_hidden INTEGER NOT NULL DEFAULT 0,
493 container_image TEXT NOT NULL DEFAULT '', container_volumes TEXT NOT NULL DEFAULT '[]',
494 container_name TEXT NOT NULL DEFAULT '', use_ambient_login INTEGER NOT NULL DEFAULT 0,
495 model_vendor_base_url TEXT NOT NULL DEFAULT '', auto_continue_enabled INTEGER NOT NULL DEFAULT 0,
496 memory_id TEXT NOT NULL DEFAULT '', conversation_visibility TEXT NOT NULL DEFAULT 'private'
497 );
498 CREATE TABLE db_agent_instances (
499 id TEXT PRIMARY KEY, definition_id TEXT NOT NULL,
500 parent_instance_id TEXT NOT NULL DEFAULT '', block_id TEXT NOT NULL DEFAULT '',
501 session_id TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'running',
502 github_context TEXT NOT NULL DEFAULT '', identity_id TEXT NOT NULL DEFAULT '',
503 memory_id TEXT NOT NULL DEFAULT '', instance_name TEXT NOT NULL DEFAULT '',
504 working_directory TEXT NOT NULL DEFAULT '', display_hidden INTEGER NOT NULL DEFAULT 0,
505 started_at INTEGER NOT NULL DEFAULT 0, ended_at INTEGER NOT NULL DEFAULT 0,
506 created_at INTEGER NOT NULL DEFAULT 0,
507 FOREIGN KEY (definition_id) REFERENCES db_agent_definitions(id) ON DELETE CASCADE
508 );",
509 )
510 .unwrap();
511
512 let add_legacy_def = |id: &str, is_seeded: i64, parent_id: &str| {
519 conn.execute(
520 "INSERT INTO db_agent_definitions (
521 id, slug, name, icon, provider, description, working_directory, shell,
522 provider_flags, auto_start, restart_on_crash, idle_timeout_minutes,
523 created_at, agent_type, environment, agent_bus_id, is_seeded, accounts,
524 parent_id, branch_label, updated_at, user_hidden
525 ) VALUES (?1, ?1, ?1, '', 'claude', '', '', 'bash', '', 0, 0, 0,
526 1000, 'standalone', '', '', ?2, '', ?3, '', 1000, 0)",
527 params![id, is_seeded, parent_id],
528 )
529 .unwrap();
530 };
531 add_legacy_def("tpl", 1, "");
532 add_legacy_def("clone", 0, "tpl");
533 let add_legacy = |id: &str, def_id: &str, parent: &str, block: &str, session: &str, status: &str, created: i64| {
534 conn.execute(
535 "INSERT INTO db_agent_instances
536 (id, definition_id, parent_instance_id, block_id, session_id, status,
537 github_context, started_at, ended_at, created_at,
538 identity_id, memory_id, instance_name, working_directory, display_hidden)
539 VALUES (?1, ?2, ?3, ?4, ?5, ?6, '', ?7, 0, ?7, '', '', ?1, '', 0)",
540 params![id, def_id, parent, block, session, status, created],
541 )
542 .unwrap();
543 };
544 add_legacy("head", "tpl", "", "b1", "s1", "stopped", 2000);
546 add_legacy("child", "tpl", "head", "b2", "s2", "running", 3000);
547 add_legacy("clone-run", "clone", "", "b3", "s3", "paused", 2500);
549 conn.execute(
552 "INSERT INTO db_agents (id, name, provider, is_template, parent_template_id, is_seeded, created_at, updated_at)
553 VALUES ('head', 'head', 'claude', 0, 'tpl', 0, 2000, 2000)",
554 [],
555 )
556 .unwrap();
557 conn.execute_batch(
558 "UPDATE db_agents SET session_id = '', status = '', started_at = 0, ended_at = 0, last_block_id = '';",
559 )
560 .unwrap();
561 drop(conn);
562 (dir, path)
563 }
564
565 #[test]
566 fn backfills_the_latest_launch_of_each_chain_into_its_projection_row() {
567 let (_dir, path) = pre_v29_store();
568 let mut conn = Connection::open(&path).unwrap();
569 let stats = backfill_launch_state(&mut conn).unwrap();
570 assert_eq!(stats.updated, 2, "one template chain + one clone: {stats:?}");
571 assert_eq!((stats.projected, stats.definitions_repaired, stats.missing_definition), (0, 0, 0));
572 assert_eq!(launch_state(&conn, "head"), ("s2".into(), "running".into(), 3000, "b2".into()));
574 assert_eq!(launch_state(&conn, "clone"), ("s3".into(), "paused".into(), 2500, "b3".into()));
576 assert_eq!(launch_state(&conn, "tpl"), (String::new(), String::new(), 0, String::new()));
578 }
579
580 #[test]
581 fn is_idempotent_and_never_overwrites_state_written_since() {
582 let (_dir, path) = pre_v29_store();
583 let mut conn = Connection::open(&path).unwrap();
584 conn.execute("UPDATE db_agents SET session_id = 'live', status = 'running' WHERE id = 'head'", []).unwrap();
586 assert_eq!(backfill_launch_state(&mut conn).unwrap().updated, 1, "only the still-default clone row");
587 assert_eq!(launch_state(&conn, "head").0, "live");
588 assert_eq!(backfill_launch_state(&mut conn).unwrap().updated, 0, "second run writes nothing");
589 }
590
591 #[test]
592 fn projects_a_template_launch_whose_consolidated_row_is_missing_before_copying_state() {
593 let (_dir, path) = pre_v29_store();
598 let mut conn = Connection::open(&path).unwrap();
599 conn.execute("DELETE FROM db_agents WHERE id = 'head'", []).unwrap();
600 let stats = backfill_launch_state(&mut conn).unwrap();
601 assert_eq!(stats.projected, 1, "{stats:?}");
602 assert_eq!(stats.updated, 2, "{stats:?}");
603 assert_eq!(launch_state(&conn, "head"), ("s2".into(), "running".into(), 3000, "b2".into()));
604 let (is_template, parent, name): (i64, String, String) = conn
605 .query_row("SELECT is_template, parent_template_id, name FROM db_agents WHERE id = 'head'", [], |r| {
606 Ok((r.get(0)?, r.get(1)?, r.get(2)?))
607 })
608 .unwrap();
609 assert_eq!((is_template, parent.as_str(), name.as_str()), (0, "tpl", "head"), "projected as a user agent of its template");
610 }
611
612 #[test]
613 fn repairs_a_definition_missing_from_db_agents_before_copying_state() {
614 let (_dir, path) = pre_v29_store();
615 let mut conn = Connection::open(&path).unwrap();
616 conn.execute("DELETE FROM db_agents WHERE id = 'clone'", []).unwrap();
617 let stats = backfill_launch_state(&mut conn).unwrap();
618 assert_eq!(stats.definitions_repaired, 1, "{stats:?}");
619 assert_eq!(launch_state(&conn, "clone").0, "s3");
620 }
621
622 #[test]
623 fn tolerates_a_store_without_the_legacy_table() {
624 let dir = tempfile::tempdir().unwrap();
628 let path = dir.path().join("objects.db");
629 drop(Store::open(&path).unwrap());
630 let mut conn = Connection::open(&path).unwrap();
631 assert_eq!(backfill_launch_state(&mut conn).unwrap(), BackfillStats::default());
632 }
633
634 #[test]
635 fn up_runs_end_to_end_against_a_channel_store_and_skips_a_missing_one() {
636 let (dir, path) = pre_v29_store();
637 let ctx = MigrationContext {
638 home: dir.path().to_path_buf(),
639 data_dir: dir.path().to_path_buf(),
640 shared_store_path: dir.path().join("shared.db"),
641 channel_store_path: path.clone(),
642 };
643 M0025AgentsLaunchStateBackfill.up(&ctx).unwrap();
644 let conn = Connection::open(&path).unwrap();
645 assert_eq!(launch_state(&conn, "clone").0, "s3");
646
647 let missing = MigrationContext { channel_store_path: dir.path().join("nope.db"), ..ctx };
648 M0025AgentsLaunchStateBackfill.up(&missing).unwrap();
649 assert!(!missing.channel_store_path.exists(), "a missing channel store is left missing");
650 }
651}