agentmux_srv\backend\storage/
wstore.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! WaveStore: generic OID-based CRUD for WaveObj types.
5//! Port of Go's pkg/wstore/wstore_dbops.go + wstore_dbsetup.go.
6//!
7//! Uses `Mutex<Connection>` matching Go's `MaxOpenConns(1)`.
8//! SQLite WAL mode + 5s busy timeout (same as Go).
9
10
11use std::path::Path;
12use std::sync::{Arc, Mutex};
13
14use rusqlite::{params, Connection};
15use serde::{Deserialize, Serialize};
16
17use crate::backend::obj::{wave_obj_from_json, wave_obj_to_json, WaveObj};
18use crate::registry::Registry;
19
20use super::error::StoreError;
21use super::migrations::{check_schema_compat, run_object_schema, stamp_version, OBJECT_SCHEMA_VERSION};
22
23/// SQLite-backed object store for WaveObj types.
24pub struct WaveStore {
25    conn: Mutex<Connection>,
26    /// Cross-version named-agent registry. `None` for in-memory test
27    /// stores; `Some` for production srv. Mutations to
28    /// `db_agent_instances` parallel-write to this registry when set.
29    /// See `docs/specs/SPEC_SHARED_AGENT_REGISTRY_2026_05_12.md`.
30    registry: Mutex<Option<Arc<Registry>>>,
31}
32
33impl WaveStore {
34    /// Open a WaveStore backed by a file on disk.
35    /// Configures WAL mode and 5s busy timeout (matching Go).
36    pub fn open(path: &Path) -> Result<Self, StoreError> {
37        let conn = Connection::open(path)?;
38        Self::configure_and_migrate(conn)
39    }
40
41    /// Open an in-memory WaveStore for testing.
42    #[allow(dead_code)]
43    pub fn open_in_memory() -> Result<Self, StoreError> {
44        let conn = Connection::open_in_memory()?;
45        Self::configure_and_migrate(conn)
46    }
47
48    /// Crate-internal accessor for sibling modules that maintain their
49    /// own per-table CRUD via the `DroneStore` extension trait
50    /// pattern (see `agentmux-srv/src/drone/storage.rs`). Outside
51    /// callers must use the typed methods on this impl.
52    pub(crate) fn conn(&self) -> &Mutex<Connection> {
53        &self.conn
54    }
55
56    /// Run the Phase 3a `db_agents` consolidation backfill under the
57    /// wstore's exclusive connection lock. Idempotent — gated by a
58    /// marker file in `data_dir` (skip with `None` for tests).
59    pub fn run_agents_consolidate(
60        &self,
61        data_dir: Option<&Path>,
62    ) -> Result<super::agents_consolidate::ConsolidateStats, StoreError> {
63        let mut conn = self.conn.lock().unwrap();
64        super::agents_consolidate::run_consolidate_migration(&mut conn, data_dir)
65    }
66
67    fn configure_and_migrate(conn: Connection) -> Result<Self, StoreError> {
68        conn.execute_batch(
69            // `foreign_keys=ON` is per-connection and defaults to OFF in
70            // SQLite. The v6 schema (`db_agent_identity_links`,
71            // `db_agent_instances`) relies on `ON DELETE CASCADE` to clean
72            // up junction rows and instances when a parent agent or
73            // identity is removed. Without this pragma on the production
74            // connection, cascades silently no-op; migration tests set it
75            // explicitly, which would have masked the gap.
76            "PRAGMA journal_mode=WAL;
77             PRAGMA busy_timeout=5000;
78             PRAGMA foreign_keys=ON;
79             PRAGMA synchronous=NORMAL;
80             PRAGMA cache_size=-8000;
81             PRAGMA mmap_size=268435456;
82             PRAGMA temp_store=MEMORY;",
83        )?;
84        // Safety lock BEFORE any migration side effects — the legacy-
85        // table rename + seed-insert steps in `run_object_schema` are
86        // mutating, so we must refuse to open a newer-schema DB before
87        // we touch it. (codex P1 on #1029 — fixes the original PR's
88        // check-after-migrate order that let a downgraded binary
89        // partially mutate a newer DB before the error fired.)
90        check_schema_compat(&conn, OBJECT_SCHEMA_VERSION, "objects.db")?;
91        run_object_schema(&conn)?;
92        stamp_version(&conn, OBJECT_SCHEMA_VERSION)?;
93        Ok(Self {
94            conn: Mutex::new(conn),
95            registry: Mutex::new(None),
96        })
97    }
98
99    /// Attach a shared cross-version agent registry. Called once on
100    /// srv startup after `WaveStore::open` and before the store is
101    /// wrapped in `Arc`. Mutations to `db_agent_instances` will then
102    /// parallel-write to the registry; the SQLite table remains the
103    /// authoritative read path for PR A.
104    pub fn set_registry(&self, registry: Arc<Registry>) {
105        *self.registry.lock().unwrap_or_else(|e| e.into_inner()) = Some(registry);
106    }
107
108    fn registry(&self) -> Option<Arc<Registry>> {
109        self.registry
110            .lock()
111            .unwrap_or_else(|e| e.into_inner())
112            .clone()
113    }
114
115    /// Public accessor for the cross-version named-agent registry.
116    /// Returns `None` when the registry couldn't be resolved at
117    /// startup (CI / unusual envs); callers must handle the absent
118    /// case by falling back to SQLite.
119    pub fn shared_agent_registry(&self) -> Option<Arc<Registry>> {
120        self.registry()
121    }
122
123    /// Table name for a WaveObj type: `db_<otype>`.
124    fn table_name<T: WaveObj>() -> String {
125        format!("db_{}", T::get_otype())
126    }
127
128    /// Get a single object by OID. Returns `None` if not found.
129    pub fn get<T: WaveObj>(&self, oid: &str) -> Result<Option<T>, StoreError> {
130        let conn = self.conn.lock().unwrap();
131        let table = Self::table_name::<T>();
132        let mut stmt =
133            conn.prepare(&format!("SELECT version, data FROM {table} WHERE oid = ?1"))?;
134
135        let result = stmt.query_row(params![oid], |row| {
136            let version: i64 = row.get(0)?;
137            let data: Vec<u8> = row.get(1)?;
138            Ok((version, data))
139        });
140
141        match result {
142            Ok((version, data)) => {
143                let mut obj: T = wave_obj_from_json(&data)?;
144                obj.set_version(version);
145                Ok(Some(obj))
146            }
147            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
148            Err(e) => Err(StoreError::Sqlite(e)),
149        }
150    }
151
152    /// Get a single object, returning `StoreError::NotFound` if missing.
153    pub fn must_get<T: WaveObj>(&self, oid: &str) -> Result<T, StoreError> {
154        self.get::<T>(oid)?.ok_or(StoreError::NotFound)
155    }
156
157    /// Get a single object as raw JSON Value by otype and OID.
158    /// Used by GetObject/GetObjects to return data without strict struct deserialization.
159    pub fn get_raw(&self, otype: &str, oid: &str) -> Result<Option<serde_json::Value>, StoreError> {
160        let conn = self.conn.lock().unwrap();
161        let table = format!("db_{}", otype);
162        let mut stmt =
163            conn.prepare(&format!("SELECT version, data FROM {table} WHERE oid = ?1"))?;
164
165        let result = stmt.query_row(params![oid], |row| {
166            let version: i64 = row.get(0)?;
167            let data: Vec<u8> = row.get(1)?;
168            Ok((version, data))
169        });
170
171        match result {
172            Ok((version, data)) => {
173                let mut val: serde_json::Value = serde_json::from_slice(&data)
174                    .map_err(|e| StoreError::Json(e))?;
175                if let Some(obj) = val.as_object_mut() {
176                    obj.insert("version".to_string(), serde_json::json!(version));
177                    obj.insert("otype".to_string(), serde_json::json!(otype));
178                }
179                Ok(Some(val))
180            }
181            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
182            Err(e) => Err(StoreError::Sqlite(e)),
183        }
184    }
185
186    /// Check if an object exists (by otype and OID).
187    #[allow(dead_code)]
188    pub fn exists_raw(&self, otype: &str, oid: &str) -> Result<bool, StoreError> {
189        let conn = self.conn.lock().unwrap();
190        let table = format!("db_{}", otype);
191        let count: i64 = conn.query_row(
192            &format!("SELECT COUNT(*) FROM {table} WHERE oid = ?1"),
193            params![oid],
194            |row| row.get(0),
195        )?;
196        Ok(count > 0)
197    }
198
199    /// Insert a new object. Sets version to 1.
200    pub fn insert<T: WaveObj>(&self, obj: &mut T) -> Result<(), StoreError> {
201        let oid = obj.get_oid().to_string();
202        if oid.is_empty() {
203            return Err(StoreError::EmptyOID);
204        }
205
206        obj.set_version(1);
207        let data = wave_obj_to_json(obj)?;
208
209        let conn = self.conn.lock().unwrap();
210        let table = Self::table_name::<T>();
211        conn.execute(
212            &format!("INSERT INTO {table} (oid, version, data) VALUES (?1, 1, ?2)"),
213            params![oid, data],
214        )?;
215
216        Ok(())
217    }
218
219    /// Update an existing object. Increments version atomically.
220    /// Returns the new version number.
221    pub fn update<T: WaveObj>(&self, obj: &mut T) -> Result<i64, StoreError> {
222        let oid = obj.get_oid().to_string();
223        if oid.is_empty() {
224            return Err(StoreError::EmptyOID);
225        }
226
227        let data = wave_obj_to_json(obj)?;
228
229        let conn = self.conn.lock().unwrap();
230        let table = Self::table_name::<T>();
231
232        // Optimistic locking: increment version and return new value.
233        // Matches Go: `UPDATE ... SET version = version+1 ... RETURNING version`
234        let new_version: i64 = conn.query_row(
235            &format!(
236                "UPDATE {table} SET data = ?1, version = version + 1 WHERE oid = ?2 RETURNING version"
237            ),
238            params![data, oid],
239            |row| row.get(0),
240        )?;
241
242        obj.set_version(new_version);
243        Ok(new_version)
244    }
245
246    /// Update an object using raw JSON (bypasses struct deserialization).
247    /// Used by UpdateObject where the frontend sends the full replacement object.
248    /// This matches Go's generic map-based UpdateObject behavior.
249    pub fn update_raw(&self, otype: &str, oid: &str, value: &serde_json::Value) -> Result<i64, StoreError> {
250        if oid.is_empty() {
251            return Err(StoreError::EmptyOID);
252        }
253        let data = serde_json::to_vec(value)?;
254        let conn = self.conn.lock().unwrap();
255        let table = format!("db_{}", otype);
256        let new_version: i64 = conn.query_row(
257            &format!(
258                "UPDATE {table} SET data = ?1, version = version + 1 WHERE oid = ?2 RETURNING version"
259            ),
260            params![data, oid],
261            |row| row.get(0),
262        )?;
263        Ok(new_version)
264    }
265
266    /// Delete an object by OID.
267    #[allow(dead_code)]
268    pub fn delete<T: WaveObj>(&self, oid: &str) -> Result<(), StoreError> {
269        let conn = self.conn.lock().unwrap();
270        let table = Self::table_name::<T>();
271        conn.execute(
272            &format!("DELETE FROM {table} WHERE oid = ?1"),
273            params![oid],
274        )?;
275        Ok(())
276    }
277
278    /// Delete by otype string and OID (for dynamic dispatch).
279    /// Validates `otype` against `VALID_OTYPES` to prevent SQL injection.
280    #[allow(dead_code)]
281    pub fn delete_by_otype(&self, otype: &str, oid: &str) -> Result<(), StoreError> {
282        if !crate::backend::obj::VALID_OTYPES.contains(&otype) {
283            return Err(StoreError::Other(format!("unknown otype: {otype:?}")));
284        }
285        let conn = self.conn.lock().unwrap();
286        let table = format!("db_{otype}");
287        conn.execute(
288            &format!("DELETE FROM {table} WHERE oid = ?1"),
289            params![oid],
290        )?;
291        Ok(())
292    }
293
294    /// Get all objects of a given type.
295    pub fn get_all<T: WaveObj>(&self) -> Result<Vec<T>, StoreError> {
296        let conn = self.conn.lock().unwrap();
297        let table = Self::table_name::<T>();
298        let mut stmt = conn.prepare(&format!("SELECT oid, version, data FROM {table}"))?;
299        let rows = stmt.query_map([], |row| {
300            let version: i64 = row.get(1)?;
301            let data: Vec<u8> = row.get(2)?;
302            Ok((version, data))
303        })?;
304
305        let mut result = Vec::new();
306        for row in rows {
307            let (version, data) = row?;
308            let mut obj: T = wave_obj_from_json(&data)?;
309            obj.set_version(version);
310            result.push(obj);
311        }
312        Ok(result)
313    }
314
315    /// Count objects of a given type.
316    #[allow(dead_code)]
317    pub fn count<T: WaveObj>(&self) -> Result<i64, StoreError> {
318        let conn = self.conn.lock().unwrap();
319        let table = Self::table_name::<T>();
320        let count: i64 =
321            conn.query_row(&format!("SELECT count(*) FROM {table}"), [], |row| {
322                row.get(0)
323            })?;
324        Ok(count)
325    }
326
327    /// Execute multiple operations in a single SQLite transaction.
328    /// Acquires the Mutex once, wraps all operations in BEGIN/COMMIT.
329    /// On error, rolls back and returns the error.
330    ///
331    /// This is the key performance primitive — reduces N lock acquisitions
332    /// and N fsyncs to 1 each.
333    pub fn with_tx<F, R>(&self, f: F) -> Result<R, StoreError>
334    where
335        F: FnOnce(&StoreTx) -> Result<R, StoreError>,
336    {
337        let conn = self.conn.lock().unwrap();
338        conn.execute_batch("BEGIN")?;
339        let tx = StoreTx { conn: &conn };
340        match f(&tx) {
341            Ok(result) => {
342                conn.execute_batch("COMMIT")?;
343                Ok(result)
344            }
345            Err(e) => {
346                let _ = conn.execute_batch("ROLLBACK");
347                Err(e)
348            }
349        }
350    }
351}
352
353/// A borrowed connection handle for use inside [`WaveStore::with_tx`].
354/// Provides the same CRUD methods as `WaveStore` but operates on the
355/// already-locked connection without additional Mutex acquisition.
356pub struct StoreTx<'a> {
357    conn: &'a Connection,
358}
359
360impl<'a> StoreTx<'a> {
361    fn table_name<T: WaveObj>() -> String {
362        format!("db_{}", T::get_otype())
363    }
364
365    pub fn get<T: WaveObj>(&self, oid: &str) -> Result<Option<T>, StoreError> {
366        let table = Self::table_name::<T>();
367        let mut stmt =
368            self.conn.prepare(&format!("SELECT version, data FROM {table} WHERE oid = ?1"))?;
369
370        let result = stmt.query_row(params![oid], |row| {
371            let version: i64 = row.get(0)?;
372            let data: Vec<u8> = row.get(1)?;
373            Ok((version, data))
374        });
375
376        match result {
377            Ok((version, data)) => {
378                let mut obj: T = wave_obj_from_json(&data)?;
379                obj.set_version(version);
380                Ok(Some(obj))
381            }
382            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
383            Err(e) => Err(StoreError::Sqlite(e)),
384        }
385    }
386
387    pub fn must_get<T: WaveObj>(&self, oid: &str) -> Result<T, StoreError> {
388        self.get::<T>(oid)?.ok_or(StoreError::NotFound)
389    }
390
391    pub fn insert<T: WaveObj>(&self, obj: &mut T) -> Result<(), StoreError> {
392        let oid = obj.get_oid().to_string();
393        if oid.is_empty() {
394            return Err(StoreError::EmptyOID);
395        }
396
397        obj.set_version(1);
398        let data = wave_obj_to_json(obj)?;
399
400        let table = Self::table_name::<T>();
401        self.conn.execute(
402            &format!("INSERT INTO {table} (oid, version, data) VALUES (?1, 1, ?2)"),
403            params![oid, data],
404        )?;
405
406        Ok(())
407    }
408
409    pub fn update<T: WaveObj>(&self, obj: &mut T) -> Result<i64, StoreError> {
410        let oid = obj.get_oid().to_string();
411        if oid.is_empty() {
412            return Err(StoreError::EmptyOID);
413        }
414
415        let data = wave_obj_to_json(obj)?;
416
417        let table = Self::table_name::<T>();
418        let new_version: i64 = self.conn.query_row(
419            &format!(
420                "UPDATE {table} SET data = ?1, version = version + 1 WHERE oid = ?2 RETURNING version"
421            ),
422            params![data, oid],
423            |row| row.get(0),
424        )?;
425
426        obj.set_version(new_version);
427        Ok(new_version)
428    }
429
430    pub fn get_all<T: WaveObj>(&self) -> Result<Vec<T>, StoreError> {
431        let table = Self::table_name::<T>();
432        let mut stmt = self.conn.prepare(&format!("SELECT oid, version, data FROM {table}"))?;
433        let rows = stmt.query_map([], |row| {
434            let version: i64 = row.get(1)?;
435            let data: Vec<u8> = row.get(2)?;
436            Ok((version, data))
437        })?;
438
439        let mut result = Vec::new();
440        for row in rows {
441            let (version, data) = row?;
442            let mut obj: T = wave_obj_from_json(&data)?;
443            obj.set_version(version);
444            result.push(obj);
445        }
446        Ok(result)
447    }
448
449    #[allow(dead_code)]
450    pub fn delete<T: WaveObj>(&self, oid: &str) -> Result<(), StoreError> {
451        let table = Self::table_name::<T>();
452        self.conn.execute(
453            &format!("DELETE FROM {table} WHERE oid = ?1"),
454            params![oid],
455        )?;
456        Ok(())
457    }
458}
459
460// ====================================================================
461// AgentDefinition CRUD
462// ====================================================================
463
464/// A user-defined AI agent in the user's agent-definition catalog.
465#[derive(Debug, Clone, Serialize, Deserialize)]
466pub struct AgentDefinition {
467    pub id: String,
468    /// Stable, filesystem-safe identifier. Drives working directory,
469    /// env var keys (AGENTMUX_AGENT_ID), and cross-references.
470    /// NEVER changes after creation — distinct from `name` which is
471    /// the renameable display. See
472    /// specs/SPEC_AGENT_IDENTITY_RESTRUCTURE_2026_04_14.md.
473    #[serde(default)]
474    pub slug: String,
475    pub name: String,
476    pub icon: String,
477    pub provider: String,
478    pub description: String,
479    #[serde(default)]
480    pub working_directory: String,
481    #[serde(default)]
482    pub shell: String,
483    #[serde(default)]
484    pub provider_flags: String,
485    #[serde(default)]
486    pub auto_start: i64,
487    #[serde(default)]
488    pub restart_on_crash: i64,
489    #[serde(default)]
490    pub idle_timeout_minutes: i64,
491    pub created_at: i64,
492    #[serde(default = "default_agent_type")]
493    pub agent_type: String,
494    #[serde(default)]
495    pub environment: String,
496    #[serde(default)]
497    pub agent_bus_id: String,
498    #[serde(default)]
499    pub is_seeded: i64,
500    /// JSON-encoded per-provider account assignments
501    /// (`{"github":"acct-id", …}`). Written by the Agent pane's Identity
502    /// tab (`AgentIdentityPanel`) via `updateagent`, read back by
503    /// `parseAgentAccounts` and consumed by startup credential
504    /// resolution. (An older v6 comment called this deprecated in favour
505    /// of `db_agent_identity_links`; that migration never completed — the
506    /// JSON blob is still the live store, so the schema flatten keeps the
507    /// column.)
508    #[serde(default)]
509    pub accounts: String,
510    /// Parent definition id (db_agent_definitions.id). Empty string = root
511    /// definition; non-empty = this agent was forked from another.
512    /// Added in v6. See spec §Phase 1.
513    #[serde(default)]
514    pub parent_id: String,
515    /// Label describing the branch (e.g. `"pr-422-review"`,
516    /// `"experiment-refactor"`). Empty for root definitions and for
517    /// branches that didn't set a label. Added in v6.
518    #[serde(default)]
519    pub branch_label: String,
520    /// Last-modified timestamp (epoch ms). Set to `created_at` on insert
521    /// and refreshed on every `agent_def_update`. Schema v2. `0` for
522    /// rows written before v2 (until next update).
523    #[serde(default)]
524    pub updated_at: i64,
525    /// Per-user hide flag for seeded templates. `1` = the user clicked
526    /// "Hide template" on the picker's `+ New from template` tier; the
527    /// row stays on disk (templates are manifest-managed; deletion would
528    /// fight re-seed) but the default `listagents` view filters it out.
529    /// Reset to `0` by the agent-seed re-sync flow for any NEW template
530    /// id newly added to the manifest, so a fresh template surfaces once
531    /// even if a same-named one was previously hidden. Schema v3 (Phase
532    /// 2 of `SPEC_AGENT_PICKER_TWO_TIER_2026_05_24.md` — Q2 Decision Y).
533    /// User-owned rows (`is_seeded = 0`) MUST stay at `0` here; their
534    /// removal path is `deleteagent`, not hide.
535    #[serde(default)]
536    pub user_hidden: i64,
537}
538
539/// Derive a filesystem-safe slug from a display name. Lowercase,
540/// ASCII alphanumeric + dash/underscore, consecutive dashes collapsed,
541/// trimmed to 64 chars. Returns `"agent"` if the input has no valid
542/// characters (defensive fallback).
543pub fn derive_slug(name: &str) -> String {
544    let filtered: String = name
545        .to_lowercase()
546        .chars()
547        .map(|c| {
548            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
549                c
550            } else {
551                '-'
552            }
553        })
554        .collect();
555    let collapsed: String = filtered
556        .split('-')
557        .filter(|s| !s.is_empty())
558        .collect::<Vec<_>>()
559        .join("-");
560    let trimmed: String = collapsed.chars().take(64).collect();
561    if trimmed.is_empty() {
562        "agent".to_string()
563    } else {
564        trimmed
565    }
566}
567
568fn default_agent_type() -> String {
569    "standalone".to_string()
570}
571
572/// A content blob associated with a agent definition.
573#[derive(Debug, Clone, Serialize, Deserialize)]
574pub struct AgentContent {
575    pub agent_id: String,
576    pub content_type: String,
577    pub content: String,
578    pub updated_at: i64,
579}
580
581/// A reusable skill/capability attached to a agent definition.
582#[derive(Debug, Clone, Serialize, Deserialize)]
583pub struct AgentSkill {
584    pub id: String,
585    pub agent_id: String,
586    pub name: String,
587    pub trigger: String,
588    pub skill_type: String,
589    pub description: String,
590    pub content: String,
591    pub created_at: i64,
592}
593
594/// An append-only session history entry for a agent definition.
595#[derive(Debug, Clone, Serialize, Deserialize)]
596pub struct AgentHistory {
597    pub id: i64,
598    pub agent_id: String,
599    pub session_date: String,
600    pub entry: String,
601    pub timestamp: i64,
602}
603
604impl WaveStore {
605    /// List all agent definitions, **most-recently-used first**.
606    ///
607    /// Phase 3b: read from the consolidated `db_agents` table. Order is
608    /// most-recently-touched first (`updated_at DESC`) then stable on
609    /// creation (`created_at ASC`). Dual-write keeps `db_agents.updated_at`
610    /// fresh on every definition mutation AND every instance lifecycle
611    /// touch, so this approximates the old "MAX(started_at)" ordering
612    /// without joining the instances table — recency on a row tracks the
613    /// last time the agent was either edited or launched.
614    ///
615    /// Result-set shape: every row in `db_agents` is returned. Under the
616    /// fold rule (`agents_consolidate.rs`):
617    ///   - Templates (`is_template = 1`) appear once each.
618    ///   - User-cloned defs (`is_template = 0`, id matches old `db_agent_definitions.id`)
619    ///     appear once each — instance bindings, when present, are folded
620    ///     into this same row.
621    ///   - Template-instances (`is_template = 0`, id matches old `db_agent_instances.id`)
622    ///     appear once each as first-class user agents.
623    /// `parent_id` on the returned struct is sourced from
624    /// `db_agents.parent_template_id` — the dual-write preserves the
625    /// legacy semantics (def.parent_id for user-clones, template id for
626    /// instance projections), so existing handlers that look up the parent
627    /// template by id continue to work.
628    /// Find user-clone definitions for a given seeded template (rows
629    /// in `db_agent_definitions` with `is_seeded = 0` and
630    /// `parent_id = <template.id>`). Returns the most-recent-first.
631    ///
632    /// Reads `db_agent_definitions` directly — NOT the `db_agents`
633    /// consolidated view — because the latter surfaces template-
634    /// instance projection rows under the same
635    /// `is_template = 0 AND parent_template_id = <tpl>` shape as
636    /// user-clone defs, which would conflate two distinct things.
637    ///
638    /// Sole production caller today is the `template_promote`
639    /// migration's "did the user delete the deterministic-id
640    /// clone?" diagnostic logging and its tests. Kept public so
641    /// follow-up callers (e.g. a cleanup pass that GCs orphaned
642    /// pre-deterministic-id clones from earlier migration code)
643    /// can use it without re-deriving the schema.
644    pub fn user_clone_defs_for_template(
645        &self,
646        template_id: &str,
647    ) -> Result<Vec<AgentDefinition>, StoreError> {
648        let conn = self.conn.lock().unwrap();
649        let mut stmt = conn.prepare(
650            "SELECT id, slug, name, icon, provider, description,
651                    working_directory, shell, provider_flags, auto_start,
652                    restart_on_crash, idle_timeout_minutes, created_at,
653                    agent_type, environment, agent_bus_id, is_seeded,
654                    accounts, parent_id, branch_label, updated_at,
655                    user_hidden
656             FROM db_agent_definitions
657             WHERE is_seeded = 0 AND parent_id = ?1
658             ORDER BY updated_at DESC, created_at DESC",
659        )?;
660        let rows = stmt.query_map(params![template_id], map_agent_definition_row)?;
661        let mut out = Vec::new();
662        for r in rows {
663            out.push(r?);
664        }
665        Ok(out)
666    }
667
668    /// Fetch a single agent definition by primary key. Reads
669    /// `db_agent_definitions` directly (not the `db_agents`
670    /// consolidated view that `agent_def_list` reads), so it
671    /// returns user-clone definitions and seeded templates, never
672    /// template-instance projection rows.
673    ///
674    /// Used by the `template_promote` migration's deterministic-id
675    /// idempotency check (see
676    /// `migrate_promote_template_sessions_v1`): every retry asks
677    /// "does the promote-target clone for this template already
678    /// exist?" and either reuses it or inserts it.
679    pub fn agent_def_get(&self, id: &str) -> Result<Option<AgentDefinition>, StoreError> {
680        let conn = self.conn.lock().unwrap();
681        let mut stmt = conn.prepare(
682            "SELECT id, slug, name, icon, provider, description,
683                    working_directory, shell, provider_flags, auto_start,
684                    restart_on_crash, idle_timeout_minutes, created_at,
685                    agent_type, environment, agent_bus_id, is_seeded,
686                    accounts, parent_id, branch_label, updated_at,
687                    user_hidden
688             FROM db_agent_definitions
689             WHERE id = ?1",
690        )?;
691        let mut rows = stmt.query_map(params![id], map_agent_definition_row)?;
692        match rows.next() {
693            Some(row) => Ok(Some(row?)),
694            None => Ok(None),
695        }
696    }
697
698    pub fn agent_def_list(&self) -> Result<Vec<AgentDefinition>, StoreError> {
699        let conn = self.conn.lock().unwrap();
700        let mut stmt = conn.prepare(
701            "SELECT id, slug, name, icon, provider, description,
702                    working_directory, shell, provider_flags, auto_start,
703                    restart_on_crash, idle_timeout_minutes, created_at,
704                    agent_type, environment, agent_bus_id, is_seeded,
705                    accounts, parent_template_id, branch_label, updated_at,
706                    user_hidden
707             FROM db_agents
708             ORDER BY updated_at DESC, created_at ASC",
709        )?;
710        let rows = stmt.query_map([], map_agent_definition_row)?;
711        let mut agents = Vec::new();
712        for row in rows {
713            agents.push(row?);
714        }
715        Ok(agents)
716    }
717
718    /// Count agent rows (used by seed engine to check if seeding is needed).
719    /// Phase 3b: reads from the consolidated `db_agents` table — every
720    /// definition AND every template-instance projection counts, mirroring
721    /// the new shape of `agent_def_list`. The seed engine only cares about
722    /// `== 0` to decide "fresh database, seed templates", so the broader
723    /// count doesn't false-positive that branch (an empty db_agents
724    /// guarantees an empty db_agent_definitions).
725    pub fn agent_def_count(&self) -> Result<i64, StoreError> {
726        let conn = self.conn.lock().unwrap();
727        let count: i64 = conn.query_row(
728            "SELECT COUNT(*) FROM db_agents",
729            [],
730            |row| row.get(0),
731        )?;
732        Ok(count)
733    }
734
735    /// Delete all seeded agents (is_seeded=1). Used by reseed to clear built-in agents.
736    pub fn agent_def_delete_seeded(&self) -> Result<usize, StoreError> {
737        // Reagent P1 round 4 on #1013: capture the cascaded instance ids
738        // BEFORE the FK cascade fires. The bulk delete on
739        // `db_agent_definitions` triggers `ON DELETE CASCADE` on
740        // `db_agent_instances` for every instance keyed off those
741        // templates; once the cascade runs, we can't query them anymore,
742        // and `db_agents` would be left holding orphaned instance
743        // projections. Capture → delete → drop projections.
744        let (rows, cascaded_inst_ids) = {
745            let conn = self.conn.lock().unwrap();
746            let mut stmt = conn.prepare(
747                "SELECT i.id FROM db_agent_instances i
748                 INNER JOIN db_agent_definitions d ON i.definition_id = d.id
749                 WHERE d.is_seeded = 1",
750            )?;
751            let ids: Vec<String> = stmt
752                .query_map([], |r| r.get::<_, String>(0))?
753                .collect::<Result<Vec<_>, _>>()?;
754            drop(stmt);
755            let rows = conn.execute("DELETE FROM db_agent_definitions WHERE is_seeded=1", [])?;
756            (rows, ids)
757        };
758        // Phase 3a dual-write (Phase 3b: errors propagate): drop template
759        // projections AND any cascaded instance projections. User-clone
760        // DEFINITION projections (`is_template = 0`, `id` is a def_id)
761        // are NOT touched here — those persist as long as the underlying
762        // user-clone def row in `db_agent_definitions` does.
763        self.agents_dual_write_seeded_delete(&cascaded_inst_ids)?;
764        Ok(rows)
765    }
766
767    /// Insert a new agent definition. Auto-derives slug from name if empty,
768    /// resolves collisions by appending `-2`, `-3`, etc., and mutates
769    /// `agent.slug` so the caller sees the resolved value (important
770    /// for handlers that serialize the struct back to the frontend
771    /// after insert).
772    ///
773    /// The collision check + insert run under a single mutex lock,
774    /// so this is race-safe against concurrent inserts on the same
775    /// connection.
776    pub fn agent_def_insert(&self, agent: &mut AgentDefinition) -> Result<(), StoreError> {
777        let conn = self.conn.lock().unwrap();
778        let base = if agent.slug.is_empty() {
779            derive_slug(&agent.name)
780        } else {
781            agent.slug.clone()
782        };
783        // Collision-resolve: scan for existing slugs matching base or
784        // base-N. Phase 3b reads slug uniqueness from `db_agents` — the
785        // dual-write keeps every definition's slug mirrored there, and
786        // the consolidated table also surfaces template-instance
787        // projections, so a slug collision against an instance-derived
788        // row is caught now too (under the legacy schema, instances
789        // didn't have slugs at all, so this is a strict superset).
790        let mut candidate = base.clone();
791        let mut n: u32 = 2;
792        loop {
793            let count: i64 = conn.query_row(
794                "SELECT COUNT(*) FROM db_agents WHERE slug = ?1",
795                params![candidate],
796                |row| row.get(0),
797            )?;
798            if count == 0 {
799                break;
800            }
801            candidate = format!("{}-{}", base, n);
802            n += 1;
803        }
804        agent.slug = candidate;
805        conn.execute(
806            "INSERT INTO db_agent_definitions (id, slug, name, icon, provider, description,
807             working_directory, shell, provider_flags, auto_start, restart_on_crash,
808             idle_timeout_minutes, created_at, agent_type, environment, agent_bus_id,
809             is_seeded, accounts, parent_id, branch_label, updated_at, user_hidden)
810             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15, ?16,
811                     ?17, ?18, ?19, ?20, ?21, ?22)",
812            params![
813                agent.id,
814                agent.slug,
815                agent.name,
816                agent.icon,
817                agent.provider,
818                agent.description,
819                agent.working_directory,
820                agent.shell,
821                agent.provider_flags,
822                agent.auto_start,
823                agent.restart_on_crash,
824                agent.idle_timeout_minutes,
825                agent.created_at,
826                agent.agent_type,
827                agent.environment,
828                agent.agent_bus_id,
829                agent.is_seeded,
830                agent.accounts,
831                agent.parent_id,
832                agent.branch_label,
833                // New definitions: updated_at == created_at.
834                agent.created_at,
835                // Phase 2 (hide templates): new rows start visible. The
836                // user only hides via the explicit `agent_def_hide` RPC,
837                // and the agent-seed re-sync forces user_hidden = 0 on
838                // any newly-added template id anyway, so honouring the
839                // caller-supplied value here is safe even when a stray
840                // 1 sneaks through.
841                agent.user_hidden,
842            ],
843        )?;
844        // Persist the stamped updated_at before we leave the lock so the
845        // dual-write helper sees the same value the SQL row carries.
846        let stamped_updated_at = agent.created_at;
847        drop(conn);
848        let mut snapshot = agent.clone();
849        snapshot.updated_at = stamped_updated_at;
850        // Phase 3a dual-write (Phase 3b: errors propagate): mirror to
851        // db_agents so readers see the new row immediately.
852        self.agents_dual_write_definition_upsert(&snapshot)?;
853        // Reagent P1 on #1013 round 2: do NOT mutate the caller's
854        // `&mut AgentDefinition` here. The PR is supposed to be
855        // zero-behaviour-change; the previous version reflected
856        // `stamped_updated_at` back onto the caller, which is an
857        // observable mutation downstream callers may rely on remaining
858        // untouched. Callers that need the freshly-stamped value can
859        // re-fetch the row via the normal read path.
860        let _ = stamped_updated_at;
861        Ok(())
862    }
863
864    /// Set the `user_hidden` flag on a single agent definition. Phase 2
865    /// of the two-tier picker (Q2 Decision Y). Returns:
866    ///   `Ok(true)`  — row updated.
867    ///   `Ok(false)` — no row with that id exists.
868    ///   `Err(...)`  — the row exists but is NOT a seeded template
869    ///                 (`is_seeded != 1`). User-owned definitions go
870    ///                 through `agent_def_delete`, not hide.
871    ///
872    /// Does NOT bump `updated_at`: hide is a per-user view-state flag,
873    /// not a definition-content edit. Keeps `updated_at` faithful to the
874    /// agent's payload (the manifest re-sync compares `description` etc.
875    /// against the canonical row).
876    pub fn agent_def_set_hidden(&self, id: &str, hidden: bool) -> Result<bool, StoreError> {
877        let conn = self.conn.lock().unwrap();
878        // Phase 3b: precondition check reads `is_template` from `db_agents`.
879        // Templates carry `is_template = 1` and are the only rows allowed
880        // to flip the hide flag; folded user-clone-def projections and
881        // template-instance projections (both `is_template = 0`) reject.
882        let is_template: i64 = match conn.query_row(
883            "SELECT is_template FROM db_agents WHERE id = ?1",
884            params![id],
885            |row| row.get(0),
886        ) {
887            Ok(v) => v,
888            Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(false),
889            Err(e) => return Err(StoreError::Sqlite(e)),
890        };
891        if is_template != 1 {
892            return Err(StoreError::Other(format!(
893                "agent_def_set_hidden: {id} is not a seeded template (is_template={is_template}); \
894                 user-owned definitions must use delete/archive paths, not hide"
895            )));
896        }
897        // The hide flag is per-row; the write must still hit the legacy
898        // table (cascade source) — dual-write mirrors into `db_agents`
899        // for the next read.
900        let rows = conn.execute(
901            "UPDATE db_agent_definitions SET user_hidden = ?1 WHERE id = ?2",
902            params![if hidden { 1_i64 } else { 0_i64 }, id],
903        )?;
904        if rows > 0 {
905            // Mirror the flag into `db_agents` so the next read of the
906            // template projection sees the update without waiting for a
907            // round-trip through definition_upsert.
908            if let Err(e) = conn.execute(
909                "UPDATE db_agents SET user_hidden = ?1 WHERE id = ?2 AND is_template = 1",
910                params![if hidden { 1_i64 } else { 0_i64 }, id],
911            ) {
912                tracing::error!(
913                    id = %id,
914                    hidden,
915                    error = %e,
916                    "db_agents dual-write: template hide flag mirror failed",
917                );
918            }
919        }
920        Ok(rows > 0)
921    }
922
923    /// Update an existing agent definition (all fields except id, created_at, is_seeded).
924    /// `parent_id` and `branch_label` are NOT updatable post-insert — they
925    /// describe the agent's provenance; renaming or re-branching is done by
926    /// creating a new fork, not mutating the original.
927    ///
928    /// Self-stamps `updated_at` with the current time and writes it back into
929    /// `agent.updated_at`, so the caller's struct (e.g. an RPC response body)
930    /// reflects exactly what landed in the database.
931    pub fn agent_def_update(&self, agent: &mut AgentDefinition) -> Result<bool, StoreError> {
932        let now = std::time::SystemTime::now()
933            .duration_since(std::time::UNIX_EPOCH)
934            .unwrap_or_default()
935            .as_millis() as i64;
936        let rows = {
937            let conn = self.conn.lock().unwrap();
938            conn.execute(
939                "UPDATE db_agent_definitions SET name=?1, icon=?2, provider=?3, description=?4,
940                 working_directory=?5, shell=?6, provider_flags=?7, auto_start=?8,
941                 restart_on_crash=?9, idle_timeout_minutes=?10,
942                 agent_type=?11, environment=?12, agent_bus_id=?13, accounts=?14, updated_at=?15
943                 WHERE id=?16",
944                params![
945                    agent.name,
946                    agent.icon,
947                    agent.provider,
948                    agent.description,
949                    agent.working_directory,
950                    agent.shell,
951                    agent.provider_flags,
952                    agent.auto_start,
953                    agent.restart_on_crash,
954                    agent.idle_timeout_minutes,
955                    agent.agent_type,
956                    agent.environment,
957                    agent.agent_bus_id,
958                    agent.accounts,
959                    now,
960                    agent.id
961                ],
962            )?
963        };
964        // Reflect the persisted timestamp back to the caller's struct so an
965        // RPC response carries the fresh value, not the pre-update one.
966        agent.updated_at = now;
967        // Phase 3a dual-write (Phase 3b: errors propagate): mirror to
968        // db_agents so the next read sees the new name/payload.
969        if rows > 0 {
970            self.agents_dual_write_definition_upsert(agent)?;
971        }
972        Ok(rows > 0)
973    }
974
975    /// Delete a agent definition by id. Returns true if a row was deleted.
976    pub fn agent_def_delete(&self, id: &str) -> Result<bool, StoreError> {
977        // Snapshot cascaded instance ids AND issue the parent DELETE
978        // under one lock acquisition so no thread can `instance_create`
979        // a new row for this definition between the two steps. The
980        // SQL DELETE's FK cascade fires inside SQLite while we hold
981        // the mutex, so the snapshot exactly matches the rows that
982        // got removed by the cascade.
983        let (cascaded_instance_ids, rows) = {
984            let conn = self.conn.lock().unwrap();
985            let cascaded_instance_ids: Vec<String> = {
986                let mut stmt = conn
987                    .prepare("SELECT id FROM db_agent_instances WHERE definition_id = ?1")?;
988                let iter = stmt.query_map(params![id], |row| row.get::<_, String>(0))?;
989                iter.collect::<Result<Vec<_>, _>>()?
990            };
991            let rows = conn.execute(
992                "DELETE FROM db_agent_definitions WHERE id=?1",
993                params![id],
994            )?;
995            (cascaded_instance_ids, rows)
996        };
997        if rows > 0 {
998            if let Some(reg) = self.registry() {
999                for instance_id in &cascaded_instance_ids {
1000                    if let Err(e) = reg.hard_delete(instance_id) {
1001                        tracing::warn!(
1002                            instance_id = %instance_id,
1003                            agent_def_id = %id,
1004                            error = %e,
1005                            "registry: failed to mirror agent_def_delete cascade"
1006                        );
1007                    }
1008                }
1009            }
1010            // Phase 3a dual-write (Phase 3b: errors propagate): drop
1011            // the definition's projection + every cascaded user-clone
1012            // (instance) projection.
1013            self.agents_dual_write_definition_delete(id)?;
1014            for instance_id in &cascaded_instance_ids {
1015                self.agents_dual_write_instance_delete(instance_id)?;
1016            }
1017        }
1018        Ok(rows > 0)
1019    }
1020
1021    // ---- AgentContent CRUD ----
1022
1023    /// Get a single content blob for an agent.
1024    pub fn agent_content_get(&self, agent_id: &str, content_type: &str) -> Result<Option<AgentContent>, StoreError> {
1025        let conn = self.conn.lock().unwrap();
1026        let mut stmt = conn.prepare(
1027            "SELECT agent_id, content_type, content, updated_at
1028             FROM db_agent_content WHERE agent_id=?1 AND content_type=?2",
1029        )?;
1030        let result = stmt.query_row(params![agent_id, content_type], |row| {
1031            Ok(AgentContent {
1032                agent_id: row.get(0)?,
1033                content_type: row.get(1)?,
1034                content: row.get(2)?,
1035                updated_at: row.get(3)?,
1036            })
1037        });
1038        match result {
1039            Ok(content) => Ok(Some(content)),
1040            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1041            Err(e) => Err(StoreError::Sqlite(e)),
1042        }
1043    }
1044
1045    /// Upsert a content blob for an agent.
1046    pub fn agent_content_set(&self, content: &AgentContent) -> Result<(), StoreError> {
1047        let conn = self.conn.lock().unwrap();
1048        conn.execute(
1049            "INSERT INTO db_agent_content (agent_id, content_type, content, updated_at)
1050             VALUES (?1, ?2, ?3, ?4)
1051             ON CONFLICT(agent_id, content_type) DO UPDATE SET content=?3, updated_at=?4",
1052            params![
1053                content.agent_id,
1054                content.content_type,
1055                content.content,
1056                content.updated_at,
1057            ],
1058        )?;
1059        Ok(())
1060    }
1061
1062    /// Get all content blobs for an agent.
1063    pub fn agent_content_get_all(&self, agent_id: &str) -> Result<Vec<AgentContent>, StoreError> {
1064        let conn = self.conn.lock().unwrap();
1065        let mut stmt = conn.prepare(
1066            "SELECT agent_id, content_type, content, updated_at
1067             FROM db_agent_content WHERE agent_id=?1 ORDER BY content_type ASC",
1068        )?;
1069        let rows = stmt.query_map(params![agent_id], |row| {
1070            Ok(AgentContent {
1071                agent_id: row.get(0)?,
1072                content_type: row.get(1)?,
1073                content: row.get(2)?,
1074                updated_at: row.get(3)?,
1075            })
1076        })?;
1077        let mut contents = Vec::new();
1078        for row in rows {
1079            contents.push(row?);
1080        }
1081        Ok(contents)
1082    }
1083
1084    /// Delete a specific content blob. Returns true if a row was deleted.
1085    #[allow(dead_code)]
1086    pub fn agent_content_delete(&self, agent_id: &str, content_type: &str) -> Result<bool, StoreError> {
1087        let conn = self.conn.lock().unwrap();
1088        let rows = conn.execute(
1089            "DELETE FROM db_agent_content WHERE agent_id=?1 AND content_type=?2",
1090            params![agent_id, content_type],
1091        )?;
1092        Ok(rows > 0)
1093    }
1094
1095    // ---- AgentSkill CRUD ----
1096
1097    /// List all skills for an agent, ordered by created_at ascending.
1098    pub fn agent_skill_list(&self, agent_id: &str) -> Result<Vec<AgentSkill>, StoreError> {
1099        let conn = self.conn.lock().unwrap();
1100        let mut stmt = conn.prepare(
1101            "SELECT id, agent_id, name, trigger, skill_type, description, content, created_at
1102             FROM db_agent_skills WHERE agent_id=?1 ORDER BY created_at ASC",
1103        )?;
1104        let rows = stmt.query_map(params![agent_id], |row| {
1105            Ok(AgentSkill {
1106                id: row.get(0)?,
1107                agent_id: row.get(1)?,
1108                name: row.get(2)?,
1109                trigger: row.get(3)?,
1110                skill_type: row.get(4)?,
1111                description: row.get(5)?,
1112                content: row.get(6)?,
1113                created_at: row.get(7)?,
1114            })
1115        })?;
1116        let mut skills = Vec::new();
1117        for row in rows {
1118            skills.push(row?);
1119        }
1120        Ok(skills)
1121    }
1122
1123    /// Get a single skill by id.
1124    pub fn agent_skill_get(&self, id: &str) -> Result<Option<AgentSkill>, StoreError> {
1125        let conn = self.conn.lock().unwrap();
1126        let mut stmt = conn.prepare(
1127            "SELECT id, agent_id, name, trigger, skill_type, description, content, created_at
1128             FROM db_agent_skills WHERE id=?1",
1129        )?;
1130        let result = stmt.query_row(params![id], |row| {
1131            Ok(AgentSkill {
1132                id: row.get(0)?,
1133                agent_id: row.get(1)?,
1134                name: row.get(2)?,
1135                trigger: row.get(3)?,
1136                skill_type: row.get(4)?,
1137                description: row.get(5)?,
1138                content: row.get(6)?,
1139                created_at: row.get(7)?,
1140            })
1141        });
1142        match result {
1143            Ok(skill) => Ok(Some(skill)),
1144            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1145            Err(e) => Err(StoreError::Sqlite(e)),
1146        }
1147    }
1148
1149    /// Insert a new skill.
1150    pub fn agent_skill_insert(&self, skill: &AgentSkill) -> Result<(), StoreError> {
1151        let conn = self.conn.lock().unwrap();
1152        conn.execute(
1153            "INSERT INTO db_agent_skills (id, agent_id, name, trigger, skill_type, description, content, created_at)
1154             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
1155            params![
1156                skill.id,
1157                skill.agent_id,
1158                skill.name,
1159                skill.trigger,
1160                skill.skill_type,
1161                skill.description,
1162                skill.content,
1163                skill.created_at
1164            ],
1165        )?;
1166        Ok(())
1167    }
1168
1169    /// Update an existing skill (all fields except id, agent_id, created_at).
1170    pub fn agent_skill_update(&self, skill: &AgentSkill) -> Result<bool, StoreError> {
1171        let conn = self.conn.lock().unwrap();
1172        let rows = conn.execute(
1173            "UPDATE db_agent_skills SET name=?1, trigger=?2, skill_type=?3, description=?4, content=?5
1174             WHERE id=?6",
1175            params![
1176                skill.name,
1177                skill.trigger,
1178                skill.skill_type,
1179                skill.description,
1180                skill.content,
1181                skill.id
1182            ],
1183        )?;
1184        Ok(rows > 0)
1185    }
1186
1187    /// Delete a skill by id. Returns true if a row was deleted.
1188    pub fn agent_skill_delete(&self, id: &str) -> Result<bool, StoreError> {
1189        let conn = self.conn.lock().unwrap();
1190        let rows = conn.execute(
1191            "DELETE FROM db_agent_skills WHERE id=?1",
1192            params![id],
1193        )?;
1194        Ok(rows > 0)
1195    }
1196
1197    // ---- AgentHistory methods ----
1198
1199    /// Append a history entry for an agent. Auto-sets session_date (today) and timestamp.
1200    pub fn agent_history_append(&self, agent_id: &str, entry: &str) -> Result<AgentHistory, StoreError> {
1201        let now = std::time::SystemTime::now()
1202            .duration_since(std::time::UNIX_EPOCH)
1203            .unwrap_or_default()
1204            .as_millis() as i64;
1205        // session_date as YYYY-MM-DD
1206        let secs = (now / 1000) as u64;
1207        let days = secs / 86400;
1208        // Simple date calculation (no chrono dependency needed)
1209        let session_date = format_epoch_date(days);
1210        let conn = self.conn.lock().unwrap();
1211        conn.execute(
1212            "INSERT INTO db_agent_history (agent_id, session_date, entry, timestamp) VALUES (?1, ?2, ?3, ?4)",
1213            params![agent_id, session_date, entry, now],
1214        )?;
1215        let id = conn.last_insert_rowid();
1216        Ok(AgentHistory {
1217            id,
1218            agent_id: agent_id.to_string(),
1219            session_date,
1220            entry: entry.to_string(),
1221            timestamp: now,
1222        })
1223    }
1224
1225    /// List history entries for an agent, with optional date filter and pagination.
1226    pub fn agent_history_list(
1227        &self,
1228        agent_id: &str,
1229        session_date: Option<&str>,
1230        limit: i64,
1231        offset: i64,
1232    ) -> Result<Vec<AgentHistory>, StoreError> {
1233        let conn = self.conn.lock().unwrap();
1234        let (sql, params_vec): (String, Vec<Box<dyn rusqlite::types::ToSql>>) = match session_date {
1235            Some(date) => (
1236                "SELECT id, agent_id, session_date, entry, timestamp
1237                 FROM db_agent_history WHERE agent_id=?1 AND session_date=?2
1238                 ORDER BY timestamp DESC LIMIT ?3 OFFSET ?4".to_string(),
1239                vec![
1240                    Box::new(agent_id.to_string()),
1241                    Box::new(date.to_string()),
1242                    Box::new(limit),
1243                    Box::new(offset),
1244                ],
1245            ),
1246            None => (
1247                "SELECT id, agent_id, session_date, entry, timestamp
1248                 FROM db_agent_history WHERE agent_id=?1
1249                 ORDER BY timestamp DESC LIMIT ?2 OFFSET ?3".to_string(),
1250                vec![
1251                    Box::new(agent_id.to_string()),
1252                    Box::new(limit),
1253                    Box::new(offset),
1254                ],
1255            ),
1256        };
1257        let params_refs: Vec<&dyn rusqlite::types::ToSql> = params_vec.iter().map(|b| b.as_ref()).collect();
1258        let mut stmt = conn.prepare(&sql)?;
1259        let rows = stmt.query_map(params_refs.as_slice(), |row| {
1260            Ok(AgentHistory {
1261                id: row.get(0)?,
1262                agent_id: row.get(1)?,
1263                session_date: row.get(2)?,
1264                entry: row.get(3)?,
1265                timestamp: row.get(4)?,
1266            })
1267        })?;
1268        let mut entries = Vec::new();
1269        for row in rows {
1270            entries.push(row?);
1271        }
1272        Ok(entries)
1273    }
1274
1275    /// Search history entries for an agent using LIKE-based matching.
1276    pub fn agent_history_search(
1277        &self,
1278        agent_id: &str,
1279        query: &str,
1280        limit: i64,
1281    ) -> Result<Vec<AgentHistory>, StoreError> {
1282        let conn = self.conn.lock().unwrap();
1283        let pattern = format!("%{}%", query);
1284        let mut stmt = conn.prepare(
1285            "SELECT id, agent_id, session_date, entry, timestamp
1286             FROM db_agent_history WHERE agent_id=?1 AND entry LIKE ?2
1287             ORDER BY timestamp DESC LIMIT ?3",
1288        )?;
1289        let rows = stmt.query_map(params![agent_id, pattern, limit], |row| {
1290            Ok(AgentHistory {
1291                id: row.get(0)?,
1292                agent_id: row.get(1)?,
1293                session_date: row.get(2)?,
1294                entry: row.get(3)?,
1295                timestamp: row.get(4)?,
1296            })
1297        })?;
1298        let mut entries = Vec::new();
1299        for row in rows {
1300            entries.push(row?);
1301        }
1302        Ok(entries)
1303    }
1304}
1305
1306/// Format days-since-epoch as YYYY-MM-DD string.
1307/// Simple implementation without chrono dependency.
1308fn format_epoch_date(days_since_epoch: u64) -> String {
1309    // Algorithm from https://howardhinnant.github.io/date_algorithms.html
1310    let z = days_since_epoch + 719468;
1311    let era = z / 146097;
1312    let doe = z - era * 146097;
1313    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
1314    let y = yoe + era * 400;
1315    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
1316    let mp = (5 * doy + 2) / 153;
1317    let d = doy - (153 * mp + 2) / 5 + 1;
1318    let m = if mp < 10 { mp + 3 } else { mp - 9 };
1319    let y = if m <= 2 { y + 1 } else { y };
1320    format!("{:04}-{:02}-{:02}", y, m, d)
1321}
1322
1323// ====================================================================
1324// Identity Accounts + Agent Instances + Junction
1325// (Phase 2 of SPEC_FORGE_IDENTITY_AGENT_INSTANCES_IMPL_2026_04_20.md)
1326// ====================================================================
1327
1328/// Provider-specific credential reference. Stored as JSON in
1329/// `db_identity_accounts.secret_ref`. `backend` is the discriminator.
1330/// The actual secret value is NEVER stored in the DB — only how to
1331/// look it up at launch time (env var, secrets-manager path, etc.).
1332/// `PlaintextDev` exists for local dev convenience and must never be
1333/// the default path in production builds.
1334#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1335#[serde(tag = "backend", rename_all = "snake_case")]
1336pub enum SecretRef {
1337    Env {
1338        env_var: String,
1339    },
1340    SecretsManager {
1341        sm_path: String,
1342        #[serde(default, skip_serializing_if = "Option::is_none")]
1343        sm_json_path: Option<String>,
1344    },
1345    PlaintextDev {
1346        plaintext_dev: String,
1347    },
1348    /// **OAuth credentials stored as a filesystem pointer.** The CLI
1349    /// (Claude Code, codex, openclaw, …) reads its OAuth tokens from
1350    /// this directory at spawn time — agentmux only holds the path,
1351    /// never the tokens themselves. Token refresh is the CLI's job;
1352    /// the path stays stable across refreshes. Used by oauth-class
1353    /// providers; the resolver (PR B) dispatches to a config-dir
1354    /// env-var injection mode rather than the api-key env-var path.
1355    /// See `docs/specs/SPEC_OAUTH_IDENTITY_BUNDLES_2026_05_22.md`.
1356    OAuthConfigDir {
1357        /// Absolute path to the per-bundle, per-provider config
1358        /// directory — e.g. `~/.agentmux/shared/identities/<id>/claude/`,
1359        /// or the legacy `~/.claude/` for the Default migration bundle
1360        /// (PR E).
1361        dir: String,
1362    },
1363}
1364
1365/// An identity account (reusable credential, linked to agents via the
1366/// `db_agent_identity_links` junction). Replaces the browser
1367/// localStorage identity store.
1368#[derive(Debug, Clone, Serialize, Deserialize)]
1369pub struct IdentityAccount {
1370    pub id: String,
1371    pub name: String,
1372    pub provider: String, // "github" | "aws" | "anthropic" | "custom"
1373    pub kind: String,     // "pat" | "role" | "api_key" | "env_ref"
1374    #[serde(default)]
1375    pub display_name: String,
1376    pub secret_ref: SecretRef,
1377    /// Free-form JSON context (username, scopes, role ARN, etc.). Stored
1378    /// verbatim; frontend types it by `provider`.
1379    #[serde(default = "default_context_json")]
1380    pub context: serde_json::Value,
1381    #[serde(default = "default_identity_status")]
1382    pub status: String, // "unknown" | "ok" | "expired" | "invalid"
1383    pub created_at: i64,
1384    pub updated_at: i64,
1385}
1386
1387fn default_context_json() -> serde_json::Value {
1388    serde_json::json!({})
1389}
1390
1391fn default_identity_status() -> String {
1392    "unknown".to_string()
1393}
1394
1395/// Junction row: which identity an agent uses for a given provider.
1396#[derive(Debug, Clone, Serialize, Deserialize)]
1397pub struct AgentIdentityLink {
1398    pub agent_id: String,
1399    pub account_id: String,
1400    pub provider: String,
1401}
1402
1403// ====================================================================
1404// v7 — Identity bundles (named credential bundles) + Memory bundles
1405// ====================================================================
1406
1407/// A named credential bundle. Contains zero or more accounts via the
1408/// `db_identity_bindings` junction. `is_blank` tags the seeded singleton
1409/// row that the launch UI uses as the default option.
1410#[derive(Debug, Clone, Serialize, Deserialize)]
1411pub struct Identity {
1412    pub id: String,
1413    pub name: String,
1414    #[serde(default)]
1415    pub description: String,
1416    #[serde(default)]
1417    pub is_blank: bool,
1418    pub created_at: i64,
1419    pub updated_at: i64,
1420}
1421
1422/// Junction row binding an account to an identity for a given provider.
1423#[derive(Debug, Clone, Serialize, Deserialize)]
1424pub struct IdentityBinding {
1425    pub identity_id: String,
1426    pub provider: String,
1427    pub account_id: String,
1428}
1429
1430/// A Memory bundle — the agent's personality and capability stack.
1431/// Provider, model, instructions, and JSON-encoded arrays of context
1432/// files / MCP servers / skills. Agent definitions shadow-migrate into this
1433/// table during the v7 migration.
1434#[derive(Debug, Clone, Serialize, Deserialize)]
1435pub struct Memory {
1436    pub id: String,
1437    pub name: String,
1438    #[serde(default)]
1439    pub description: String,
1440    #[serde(default)]
1441    pub is_blank: bool,
1442    /// "claude" | "codex" | "gemini" | empty string
1443    #[serde(default)]
1444    pub provider: String,
1445    #[serde(default)]
1446    pub model: String,
1447    #[serde(default)]
1448    pub instructions: String,
1449    /// JSON-encoded array; the renderer types it as `[{path, content}]`.
1450    #[serde(default = "default_json_array_string")]
1451    pub context_files: String,
1452    /// JSON-encoded array of MCP server configs.
1453    #[serde(default = "default_json_array_string")]
1454    pub mcp_servers: String,
1455    /// JSON-encoded array of skill IDs.
1456    #[serde(default = "default_json_array_string")]
1457    pub skills: String,
1458    pub created_at: i64,
1459    pub updated_at: i64,
1460}
1461
1462fn default_json_array_string() -> String {
1463    "[]".to_string()
1464}
1465
1466/// Instance lifecycle status. Serialised lowercase to match the DB text.
1467#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1468#[serde(rename_all = "lowercase")]
1469pub enum InstanceStatus {
1470    Running,
1471    Paused,
1472    Stopped,
1473    Crashed,
1474    Detached,
1475}
1476
1477impl InstanceStatus {
1478    pub fn as_str(&self) -> &'static str {
1479        match self {
1480            Self::Running => "running",
1481            Self::Paused => "paused",
1482            Self::Stopped => "stopped",
1483            Self::Crashed => "crashed",
1484            Self::Detached => "detached",
1485        }
1486    }
1487    pub fn parse(s: &str) -> Option<Self> {
1488        match s {
1489            "running" => Some(Self::Running),
1490            "paused" => Some(Self::Paused),
1491            "stopped" => Some(Self::Stopped),
1492            "crashed" => Some(Self::Crashed),
1493            "detached" => Some(Self::Detached),
1494            _ => None,
1495        }
1496    }
1497}
1498
1499/// Optional context describing which GitHub-side unit of work a specific
1500/// instance is operating on. Stored as JSON in
1501/// `db_agent_instances.github_context` (empty string when unset).
1502#[derive(Debug, Clone, Serialize, Deserialize)]
1503pub struct GitHubContext {
1504    pub repo: String, // "owner/repo"
1505    #[serde(default, skip_serializing_if = "Option::is_none")]
1506    pub pr_number: Option<u32>,
1507    #[serde(default, skip_serializing_if = "Option::is_none")]
1508    pub branch: Option<String>,
1509    #[serde(default, skip_serializing_if = "Option::is_none")]
1510    pub issue_number: Option<u32>,
1511    #[serde(default, skip_serializing_if = "Option::is_none")]
1512    pub workflow_run_id: Option<u64>,
1513}
1514
1515/// One row per running/historical execution of an agent definition.
1516/// `block_id` / `session_id` / `github_context` are modelled as empty
1517/// strings on the wire rather than `Option<String>` to match the
1518/// existing schema conventions (`NOT NULL DEFAULT ''`). Callers
1519/// that need structured absence can use `.is_empty()`.
1520#[derive(Debug, Clone, Serialize, Deserialize)]
1521pub struct AgentInstance {
1522    pub id: String,
1523    pub definition_id: String,
1524    #[serde(default)]
1525    pub parent_instance_id: String,
1526    #[serde(default)]
1527    pub block_id: String,
1528    #[serde(default)]
1529    pub session_id: String,
1530    pub status: String,
1531    /// JSON-encoded `GitHubContext`, or empty string.
1532    #[serde(default)]
1533    pub github_context: String,
1534    pub started_at: i64,
1535    #[serde(default)]
1536    pub ended_at: i64,
1537    pub created_at: i64,
1538    /// FK to `db_identity_bundles.id`. Empty string means "use the blank
1539    /// singleton" (= ambient creds, no env-var injection). Set at
1540    /// instantiation via the launch modal's Identity dropdown.
1541    #[serde(default)]
1542    pub identity_id: String,
1543    /// FK to `db_memory_bundles.id`. Empty string means "use the blank
1544    /// singleton" (= vanilla CLI, no instructions). Set at
1545    /// instantiation via the launch modal's Memory dropdown.
1546    #[serde(default)]
1547    pub memory_id: String,
1548    /// User-chosen instance name (becomes `AGENTMUX_AGENT_ID` in the
1549    /// spawn env). Empty for pre-v8 rows and for un-named launches.
1550    /// Drives the "Continue agent" dropdown in the launch modal.
1551    #[serde(default)]
1552    pub instance_name: String,
1553    /// Absolute path returned by `allocate_agent_workdir` at spawn.
1554    /// Stored explicitly (rather than re-derived from the slug at
1555    /// continue-time) so the continue flow is robust against
1556    /// slug-rule changes and user-side renames.
1557    #[serde(default)]
1558    pub working_directory: String,
1559    /// Soft-delete flag for the "Forget agent" affordance. Hidden
1560    /// rows stay on disk for audit + recovery.
1561    #[serde(default)]
1562    pub display_hidden: bool,
1563}
1564
1565/// Build a registry record from an `AgentInstance`. Returns an error
1566/// if the working directory can't be expressed as a path relative to
1567/// the canonical shared agents root (e.g. user pointed an agent at
1568/// `~/projects/foo`, which would also fail a naive `"agents"`
1569/// segment-scan that happened to match `~/projects/agents/foo`).
1570/// Caller logs + skips — agent stays in SQLite, just not in the
1571/// cross-version dropdown.
1572fn agent_instance_to_record(
1573    inst: &AgentInstance,
1574    agents_root: &Path,
1575) -> Result<crate::registry::NamedAgentRecord, String> {
1576    use crate::registry::{NamedAgentRecord, NamedAgentRecordV1, MAX_SUPPORTED_SCHEMA};
1577    let rel = relative_workdir(&inst.working_directory, agents_root).ok_or_else(|| {
1578        format!(
1579            "working_directory {:?} is not under {:?}",
1580            inst.working_directory,
1581            agents_root.display()
1582        )
1583    })?;
1584    let version = env!("CARGO_PKG_VERSION").to_string();
1585    Ok(NamedAgentRecord {
1586        schema_version: MAX_SUPPORTED_SCHEMA,
1587        data: NamedAgentRecordV1 {
1588            instance_id: inst.id.clone(),
1589            instance_name: inst.instance_name.clone(),
1590            definition_id: inst.definition_id.clone(),
1591            identity_id: empty_to_none(&inst.identity_id),
1592            memory_id: empty_to_none(&inst.memory_id),
1593            working_dir: rel,
1594            created_at_ms: inst.created_at,
1595            last_launched_at_ms: inst.started_at,
1596            created_by_version: version.clone(),
1597            last_launched_by_version: version,
1598        },
1599    })
1600}
1601
1602fn empty_to_none(s: &str) -> Option<String> {
1603    if s.is_empty() { None } else { Some(s.to_string()) }
1604}
1605
1606/// Express `abs` as a path relative to `agents_root`. Returns `None`
1607/// when `abs` is empty, not under `agents_root`, or after stripping
1608/// resolves to an empty path. Anchors against the **resolved** shared
1609/// root (passed in by the caller) — never scans for a path segment
1610/// named "agents", which would match unrelated user directories like
1611/// `/home/me/projects/agents/foo`.
1612fn relative_workdir(abs: &str, agents_root: &Path) -> Option<String> {
1613    if abs.is_empty() {
1614        return None;
1615    }
1616    let p = std::path::Path::new(abs);
1617    let rel = p.strip_prefix(agents_root).ok()?;
1618    // Reject empties + traversals (defense in depth — strip_prefix
1619    // already rules out `..` escapes, but the registry's own validator
1620    // re-checks).
1621    let s = rel.to_string_lossy().to_string();
1622    if s.is_empty() || s == "." {
1623        return None;
1624    }
1625    Some(s)
1626}
1627
1628impl WaveStore {
1629    // ---- Identity account CRUD ----
1630
1631    /// List identity accounts. If `provider` is `Some`, filter to that
1632    /// provider; otherwise return every account, ordered by most recent
1633    /// update first (so the identity panel shows live accounts on top).
1634    pub fn identity_list(
1635        &self,
1636        provider: Option<&str>,
1637    ) -> Result<Vec<IdentityAccount>, StoreError> {
1638        let conn = self.conn.lock().unwrap();
1639        let mut rows_vec = Vec::new();
1640        let map_row = |row: &rusqlite::Row| -> rusqlite::Result<IdentityAccount> {
1641            let secret_ref_json: String = row.get(5)?;
1642            let context_json: String = row.get(6)?;
1643            Ok(IdentityAccount {
1644                id: row.get(0)?,
1645                name: row.get(1)?,
1646                provider: row.get(2)?,
1647                kind: row.get(3)?,
1648                display_name: row.get(4)?,
1649                secret_ref: serde_json::from_str(&secret_ref_json).map_err(|e| {
1650                    rusqlite::Error::FromSqlConversionFailure(
1651                        5,
1652                        rusqlite::types::Type::Text,
1653                        Box::new(e),
1654                    )
1655                })?,
1656                context: serde_json::from_str(&context_json).unwrap_or_else(|_| serde_json::json!({})),
1657                status: row.get(7)?,
1658                created_at: row.get(8)?,
1659                updated_at: row.get(9)?,
1660            })
1661        };
1662        match provider {
1663            Some(p) => {
1664                let mut stmt = conn.prepare(
1665                    "SELECT id, name, provider, kind, display_name, secret_ref, context,
1666                            status, created_at, updated_at
1667                     FROM db_identity_accounts
1668                     WHERE provider = ?1
1669                     ORDER BY updated_at DESC",
1670                )?;
1671                let iter = stmt.query_map(params![p], map_row)?;
1672                for r in iter {
1673                    rows_vec.push(r?);
1674                }
1675            }
1676            None => {
1677                let mut stmt = conn.prepare(
1678                    "SELECT id, name, provider, kind, display_name, secret_ref, context,
1679                            status, created_at, updated_at
1680                     FROM db_identity_accounts
1681                     ORDER BY updated_at DESC",
1682                )?;
1683                let iter = stmt.query_map([], map_row)?;
1684                for r in iter {
1685                    rows_vec.push(r?);
1686                }
1687            }
1688        }
1689        Ok(rows_vec)
1690    }
1691
1692    pub fn identity_get(&self, id: &str) -> Result<Option<IdentityAccount>, StoreError> {
1693        let conn = self.conn.lock().unwrap();
1694        let mut stmt = conn.prepare(
1695            "SELECT id, name, provider, kind, display_name, secret_ref, context,
1696                    status, created_at, updated_at
1697             FROM db_identity_accounts WHERE id = ?1",
1698        )?;
1699        let result = stmt.query_row(params![id], |row| {
1700            let secret_ref_json: String = row.get(5)?;
1701            let context_json: String = row.get(6)?;
1702            Ok(IdentityAccount {
1703                id: row.get(0)?,
1704                name: row.get(1)?,
1705                provider: row.get(2)?,
1706                kind: row.get(3)?,
1707                display_name: row.get(4)?,
1708                secret_ref: serde_json::from_str(&secret_ref_json).map_err(|e| {
1709                    rusqlite::Error::FromSqlConversionFailure(
1710                        5,
1711                        rusqlite::types::Type::Text,
1712                        Box::new(e),
1713                    )
1714                })?,
1715                context: serde_json::from_str(&context_json).unwrap_or_else(|_| serde_json::json!({})),
1716                status: row.get(7)?,
1717                created_at: row.get(8)?,
1718                updated_at: row.get(9)?,
1719            })
1720        });
1721        match result {
1722            Ok(a) => Ok(Some(a)),
1723            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1724            Err(e) => Err(e.into()),
1725        }
1726    }
1727
1728    /// Upsert an identity account. If `account.id` is empty the caller
1729    /// must generate one first (we don't silently mint ids here — callers
1730    /// should know whether they're creating vs updating).
1731    pub fn identity_upsert(&self, account: &IdentityAccount) -> Result<(), StoreError> {
1732        let conn = self.conn.lock().unwrap();
1733        let secret_ref_json = serde_json::to_string(&account.secret_ref)?;
1734        let context_json = serde_json::to_string(&account.context)?;
1735        conn.execute(
1736            "INSERT INTO db_identity_accounts
1737                (id, name, provider, kind, display_name, secret_ref, context,
1738                 status, created_at, updated_at)
1739             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)
1740             ON CONFLICT(id) DO UPDATE SET
1741                name = excluded.name,
1742                provider = excluded.provider,
1743                kind = excluded.kind,
1744                display_name = excluded.display_name,
1745                secret_ref = excluded.secret_ref,
1746                context = excluded.context,
1747                status = excluded.status,
1748                updated_at = excluded.updated_at",
1749            params![
1750                account.id,
1751                account.name,
1752                account.provider,
1753                account.kind,
1754                account.display_name,
1755                secret_ref_json,
1756                context_json,
1757                account.status,
1758                account.created_at,
1759                account.updated_at,
1760            ],
1761        )?;
1762        Ok(())
1763    }
1764
1765    pub fn identity_delete(&self, id: &str) -> Result<bool, StoreError> {
1766        let conn = self.conn.lock().unwrap();
1767        let rows = conn.execute("DELETE FROM db_identity_accounts WHERE id = ?1", params![id])?;
1768        Ok(rows > 0)
1769    }
1770
1771    // ---- Agent ↔ Identity junction ----
1772
1773    /// Link an agent to an identity for a given provider. Overwrites any
1774    /// existing link for the same (agent_id, provider) — each agent has
1775    /// at most one account per provider.
1776    pub fn agent_identity_link(
1777        &self,
1778        agent_id: &str,
1779        account_id: &str,
1780        provider: &str,
1781    ) -> Result<(), StoreError> {
1782        let conn = self.conn.lock().unwrap();
1783        conn.execute(
1784            "INSERT INTO db_agent_identity_links (agent_id, account_id, provider)
1785             VALUES (?1, ?2, ?3)
1786             ON CONFLICT(agent_id, provider) DO UPDATE SET account_id = excluded.account_id",
1787            params![agent_id, account_id, provider],
1788        )?;
1789        Ok(())
1790    }
1791
1792    /// Remove the identity link for a given (agent_id, provider).
1793    /// Returns true iff a link existed.
1794    pub fn agent_identity_unlink(
1795        &self,
1796        agent_id: &str,
1797        provider: &str,
1798    ) -> Result<bool, StoreError> {
1799        let conn = self.conn.lock().unwrap();
1800        let rows = conn.execute(
1801            "DELETE FROM db_agent_identity_links WHERE agent_id = ?1 AND provider = ?2",
1802            params![agent_id, provider],
1803        )?;
1804        Ok(rows > 0)
1805    }
1806
1807    /// List all (agent_id, account_id, provider) triples for an agent.
1808    pub fn agent_identity_list_for_agent(
1809        &self,
1810        agent_id: &str,
1811    ) -> Result<Vec<AgentIdentityLink>, StoreError> {
1812        let conn = self.conn.lock().unwrap();
1813        let mut stmt = conn.prepare(
1814            "SELECT agent_id, account_id, provider
1815             FROM db_agent_identity_links
1816             WHERE agent_id = ?1
1817             ORDER BY provider",
1818        )?;
1819        let iter = stmt.query_map(params![agent_id], |row| {
1820            Ok(AgentIdentityLink {
1821                agent_id: row.get(0)?,
1822                account_id: row.get(1)?,
1823                provider: row.get(2)?,
1824            })
1825        })?;
1826        let mut out = Vec::new();
1827        for r in iter {
1828            out.push(r?);
1829        }
1830        Ok(out)
1831    }
1832
1833    // ---- Agent instance CRUD ----
1834
1835    /// List instances. Both filters are optional — pass `None` to scan all
1836    /// instances. Ordered by `created_at` descending (most recent first).
1837    pub fn instance_list(
1838        &self,
1839        definition_id: Option<&str>,
1840        status: Option<&str>,
1841    ) -> Result<Vec<AgentInstance>, StoreError> {
1842        let conn = self.conn.lock().unwrap();
1843        // Build the query dynamically. The parameter count varies, so we
1844        // can't reuse a single prepared statement across filter combos.
1845        let mut sql = String::from(
1846            "SELECT id, definition_id, parent_instance_id, block_id, session_id,
1847                    status, github_context, started_at, ended_at, created_at,
1848                    identity_id, memory_id, instance_name, working_directory,
1849                    display_hidden
1850             FROM db_agent_instances",
1851        );
1852        let mut clauses: Vec<&str> = Vec::new();
1853        if definition_id.is_some() {
1854            clauses.push("definition_id = ?");
1855        }
1856        if status.is_some() {
1857            clauses.push("status = ?");
1858        }
1859        if !clauses.is_empty() {
1860            sql.push_str(" WHERE ");
1861            sql.push_str(&clauses.join(" AND "));
1862        }
1863        sql.push_str(" ORDER BY created_at DESC");
1864
1865        let mut stmt = conn.prepare(&sql)?;
1866        // Build a Vec<String> so parameter lifetimes outlive the query call.
1867        // Borrowing the `&str` args directly caused E0597 because they're
1868        // bound to the match arms, not the outer scope.
1869        let mut param_vals: Vec<String> = Vec::new();
1870        if let Some(d) = definition_id {
1871            param_vals.push(d.to_string());
1872        }
1873        if let Some(s) = status {
1874            param_vals.push(s.to_string());
1875        }
1876        let iter = stmt.query_map(rusqlite::params_from_iter(param_vals.iter()), map_instance_row)?;
1877        let mut out = Vec::new();
1878        for r in iter {
1879            out.push(r?);
1880        }
1881        Ok(out)
1882    }
1883
1884    pub fn instance_get(&self, id: &str) -> Result<Option<AgentInstance>, StoreError> {
1885        let conn = self.conn.lock().unwrap();
1886        let mut stmt = conn.prepare(
1887            "SELECT id, definition_id, parent_instance_id, block_id, session_id,
1888                    status, github_context, started_at, ended_at, created_at,
1889                    identity_id, memory_id, instance_name, working_directory,
1890                    display_hidden
1891             FROM db_agent_instances WHERE id = ?1",
1892        )?;
1893        let result = stmt.query_row(params![id], map_instance_row);
1894        match result {
1895            Ok(a) => Ok(Some(a)),
1896            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1897            Err(e) => Err(e.into()),
1898        }
1899    }
1900
1901    /// Insert a new instance row. Caller is responsible for the id (UUID).
1902    pub fn instance_create(&self, inst: &AgentInstance) -> Result<(), StoreError> {
1903        {
1904            let conn = self.conn.lock().unwrap();
1905            conn.execute(
1906                "INSERT INTO db_agent_instances
1907                    (id, definition_id, parent_instance_id, block_id, session_id, status,
1908                     github_context, started_at, ended_at, created_at,
1909                     identity_id, memory_id,
1910                     instance_name, working_directory, display_hidden)
1911                 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12,
1912                         ?13, ?14, ?15)",
1913                params![
1914                    inst.id,
1915                    inst.definition_id,
1916                    inst.parent_instance_id,
1917                    inst.block_id,
1918                    inst.session_id,
1919                    inst.status,
1920                    inst.github_context,
1921                    inst.started_at,
1922                    inst.ended_at,
1923                    inst.created_at,
1924                    inst.identity_id,
1925                    inst.memory_id,
1926                    inst.instance_name,
1927                    inst.working_directory,
1928                    if inst.display_hidden { 1_i64 } else { 0_i64 },
1929                ],
1930            )?;
1931        }
1932        self.registry_upsert_if_named(inst);
1933        // Phase 3a dual-write (Phase 3b: errors propagate): mirror to
1934        // db_agents so the next read sees the new instance.
1935        self.agents_dual_write_instance_create(inst)?;
1936        Ok(())
1937    }
1938
1939    /// Set the `display_hidden` flag on an existing instance row. Used
1940    /// by the "Forget agent" affordance — soft-delete only; the row +
1941    /// working directory remain on disk for audit + recovery.
1942    ///
1943    /// Cross-version case: an agent migrated into the registry from
1944    /// another version's SQLite won't have a row in the current
1945    /// version's SQLite. The UPDATE returns 0 rows, but the registry
1946    /// still needs to flip — otherwise "Forget agent" silently no-ops
1947    /// on cross-version entries. Returns `true` if either side acted.
1948    pub fn instance_set_hidden(&self, id: &str, hidden: bool) -> Result<bool, StoreError> {
1949        let rows = {
1950            let conn = self.conn.lock().unwrap();
1951            conn.execute(
1952                "UPDATE db_agent_instances SET display_hidden = ?1 WHERE id = ?2",
1953                params![if hidden { 1_i64 } else { 0_i64 }, id],
1954            )?
1955        };
1956        let mut registry_acted = false;
1957        if let Some(reg) = self.registry() {
1958            // Only act on the registry side if a record exists there
1959            // (in either active or retired). Avoids logging spurious
1960            // "failed to retire" warnings on a no-op for unrelated ids.
1961            if reg.exists_anywhere(id) {
1962                let res = if hidden {
1963                    reg.retire(id)
1964                } else {
1965                    reg.unretire(id)
1966                };
1967                match res {
1968                    Ok(()) => registry_acted = true,
1969                    Err(e) => tracing::warn!(
1970                        instance_id = %id,
1971                        hidden,
1972                        error = %e,
1973                        "registry: failed to mirror instance_set_hidden"
1974                    ),
1975                }
1976            }
1977        }
1978        // Phase 3a dual-write (Phase 3b: errors propagate): flip the
1979        // hidden bit on db_agents.
1980        self.agents_dual_write_instance_set_hidden(id, hidden)?;
1981        Ok(rows > 0 || registry_acted)
1982    }
1983
1984    /// List named instances for the launch-modal "Continue agent"
1985    /// dropdown (`include_continuations = false`) or the picker
1986    /// "My Agents" surface (`include_continuations = true`). Filters
1987    /// to non-hidden + named rows, sorted by `started_at DESC`,
1988    /// capped by `limit`.
1989    ///
1990    /// `definition_id`, when provided, restricts the result to
1991    /// instances of that definition. Server-side filtering is
1992    /// necessary because the launch modal opens per-definition: a
1993    /// user with 200+ named agents across many definitions could
1994    /// have the current definition's older instances cut off by a
1995    /// purely global limit otherwise.
1996    ///
1997    /// `include_continuations` controls whether rows with
1998    /// `parent_instance_id != ''` (continuation chains) are
1999    /// returned:
2000    ///
2001    /// - **`false`** (legacy "head-of-chain only"). Pre-Option-E
2002    ///   semantics: hides continuation rows so the launch-modal
2003    ///   dropdown shows one entry per chain root. `listnamedagents`
2004    ///   ALSO uses this mode for its same-version SQLite enrichment
2005    ///   of registry-sourced rows — the registry mirror filter at
2006    ///   `registry_upsert_if_named` excludes continuations
2007    ///   symmetrically, and breaking that symmetry under a `limit`
2008    ///   truncation would let continuation rows displace
2009    ///   registry-head rows in the top-N and miss the
2010    ///   merge-by-id enrichment. (Codex P1 on PR #1016 first cut:
2011    ///   regresses running-state badges and focus-existing-pane
2012    ///   hints for any user whose latest instance is a continuation.)
2013    ///
2014    /// - **`true`** (Option-E "include continuations"). For the
2015    ///   picker's `listrecentsessions` flow and the
2016    ///   `template_promote` migration's instance-name lookup. Under
2017    ///   Option E the session zone is anchored on `definition_id`,
2018    ///   so a continuation row is simply the most-recent named
2019    ///   instance of an agent the user actively used — exactly
2020    ///   what those callers want visible. Excluding them hides
2021    ///   real agents (the original 2026-05-24 "Maks doesn't appear
2022    ///   under My Agents" report) and makes `template_promote`'s
2023    ///   name lookup miss the real `instance_name`, falling back
2024    ///   to the template name.
2025    pub fn instance_list_named(
2026        &self,
2027        limit: usize,
2028        definition_id: Option<&str>,
2029        include_continuations: bool,
2030    ) -> Result<Vec<AgentInstance>, StoreError> {
2031        let conn = self.conn.lock().unwrap();
2032        // Build SQL in pieces so the (definition_id × continuations)
2033        // matrix doesn't produce four copy-paste strings. Parameters
2034        // are positional: `?1` is definition_id when present, `?N`
2035        // is the limit (N = 2 if def filter present, else 1).
2036        let mut sql = String::from(
2037            "SELECT id, definition_id, parent_instance_id, block_id, session_id,
2038                    status, github_context, started_at, ended_at, created_at,
2039                    identity_id, memory_id, instance_name, working_directory,
2040                    display_hidden
2041             FROM db_agent_instances
2042             WHERE display_hidden = 0
2043               AND instance_name <> ''",
2044        );
2045        if !include_continuations {
2046            sql.push_str("\n               AND parent_instance_id = ''");
2047        }
2048        let limit_param_idx = if definition_id.is_some() {
2049            sql.push_str("\n               AND definition_id = ?1");
2050            2
2051        } else {
2052            1
2053        };
2054        sql.push_str(&format!(
2055            "\n             ORDER BY started_at DESC\n             LIMIT ?{}",
2056            limit_param_idx
2057        ));
2058        let mut stmt = conn.prepare(&sql)?;
2059        let iter = match definition_id {
2060            Some(def) => stmt.query_map(params![def, limit as i64], map_instance_row)?,
2061            None => stmt.query_map(params![limit as i64], map_instance_row)?,
2062        };
2063        let mut out = Vec::new();
2064        for r in iter {
2065            out.push(r?);
2066        }
2067        Ok(out)
2068    }
2069
2070    /// Look up the most recent (by `started_at`) named instance that
2071    /// matches the given `instance_name`. Used by the launch modal to
2072    /// detect name collisions ("did you mean to continue?") and by
2073    /// `ContinueNamedAgentCommand` to resolve the canonical row when
2074    /// the caller only knows the name. Hidden rows are excluded.
2075    pub fn instance_get_by_name(
2076        &self,
2077        instance_name: &str,
2078    ) -> Result<Option<AgentInstance>, StoreError> {
2079        if instance_name.is_empty() {
2080            return Ok(None);
2081        }
2082        let conn = self.conn.lock().unwrap();
2083        let mut stmt = conn.prepare(
2084            "SELECT id, definition_id, parent_instance_id, block_id, session_id,
2085                    status, github_context, started_at, ended_at, created_at,
2086                    identity_id, memory_id, instance_name, working_directory,
2087                    display_hidden
2088             FROM db_agent_instances
2089             WHERE instance_name = ?1
2090               AND display_hidden = 0
2091             ORDER BY started_at DESC
2092             LIMIT 1",
2093        )?;
2094        let result = stmt.query_row(params![instance_name], map_instance_row);
2095        match result {
2096            Ok(a) => Ok(Some(a)),
2097            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
2098            Err(e) => Err(e.into()),
2099        }
2100    }
2101
2102    /// Update mutable instance fields. `id`, `definition_id`,
2103    /// `parent_instance_id`, `started_at`, `created_at` are immutable
2104    /// after insert (they describe provenance, not state).
2105    pub fn instance_update(&self, inst: &AgentInstance) -> Result<bool, StoreError> {
2106        let rows = {
2107            let conn = self.conn.lock().unwrap();
2108            conn.execute(
2109                "UPDATE db_agent_instances SET
2110                    block_id = ?1,
2111                    session_id = ?2,
2112                    status = ?3,
2113                    github_context = ?4,
2114                    ended_at = ?5
2115                 WHERE id = ?6",
2116                params![
2117                    inst.block_id,
2118                    inst.session_id,
2119                    inst.status,
2120                    inst.github_context,
2121                    inst.ended_at,
2122                    inst.id,
2123                ],
2124            )?
2125        };
2126        if rows > 0 {
2127            // Refresh the registry from the post-update authoritative row.
2128            // `inst` is the caller's pre-update view; SQL UPDATE only
2129            // touches a subset of fields, so we reload to keep registry
2130            // mirror exact.
2131            if let Ok(Some(fresh)) = self.instance_get(&inst.id) {
2132                self.registry_upsert_if_named(&fresh);
2133                // Phase 3a dual-write (Phase 3b: errors propagate):
2134                // mirror the fields the consolidation cares about
2135                // (github_context, updated_at).
2136                self.agents_dual_write_instance_update(&fresh)?;
2137            }
2138        }
2139        Ok(rows > 0)
2140    }
2141
2142    /// Repoint every instance currently referencing `old_def_id` to
2143    /// `new_def_id`. Used by the Phase 1 two-tier-picker migration
2144    /// (SPEC_AGENT_PICKER_TWO_TIER_2026_05_24.md): when a seeded
2145    /// template has been used directly (carries an `agent:<id>:current`
2146    /// zone), the migration clones the template into a user agent and
2147    /// repoints any instances so the existing reattach flow
2148    /// (`continueOfInstanceId`) keeps working against the new
2149    /// definition_id. Returns the number of rows updated.
2150    ///
2151    /// `definition_id` is declared immutable post-insert on the normal
2152    /// `instance_update` path. This is the migration escape hatch.
2153    pub fn instance_repoint_definition(
2154        &self,
2155        old_def_id: &str,
2156        new_def_id: &str,
2157    ) -> Result<usize, StoreError> {
2158        let rows = {
2159            let conn = self.conn.lock().unwrap();
2160            conn.execute(
2161                "UPDATE db_agent_instances SET definition_id = ?1 WHERE definition_id = ?2",
2162                params![new_def_id, old_def_id],
2163            )?
2164        };
2165        // Phase 3a dual-write (Phase 3b: errors propagate): re-aim
2166        // parent_template_id on the user-clone projection rows that
2167        // were pointing at old_def_id.
2168        if rows > 0 {
2169            self.agents_dual_write_instance_repoint(old_def_id, new_def_id)?;
2170        }
2171        Ok(rows)
2172    }
2173
2174    pub fn instance_delete(&self, id: &str) -> Result<bool, StoreError> {
2175        let rows = {
2176            let conn = self.conn.lock().unwrap();
2177            conn.execute("DELETE FROM db_agent_instances WHERE id = ?1", params![id])?
2178        };
2179        if rows > 0 {
2180            if let Some(reg) = self.registry() {
2181                if let Err(e) = reg.hard_delete(id) {
2182                    tracing::warn!(
2183                        instance_id = %id,
2184                        error = %e,
2185                        "registry: failed to mirror instance_delete"
2186                    );
2187                }
2188            }
2189            // Phase 3a dual-write (Phase 3b: errors propagate): drop
2190            // the user-clone projection.
2191            self.agents_dual_write_instance_delete(id)?;
2192        }
2193        Ok(rows > 0)
2194    }
2195
2196    /// Back-fill `db_agent_instances.identity_id` for legacy rows that
2197    /// have either the empty string (post-v7 default before the launch
2198    /// modal required Identity) or the literal `"blank"` sentinel
2199    /// (pre-v8 placeholder for "use ambient creds"). Both shapes map
2200    /// to "no Identity bundle assigned" and the OAuth-bundles startup
2201    /// migration (PR E, spec §5) routes them to the newly-seeded
2202    /// Default bundle so the resolver can inject env vars from the
2203    /// captured ambient credentials at the next spawn.
2204    ///
2205    /// Returns the number of rows touched. Caller must verify that
2206    /// `new_identity_id` is a real `db_identity_bundles.id` — this
2207    /// method does NOT enforce FK validity (the column has no FK
2208    /// constraint per the v7 migration). Mis-use would orphan the
2209    /// rows to a non-existent bundle; the OAuth-bundles migration
2210    /// guards against this by only calling here when it just upserted
2211    /// the bundle row.
2212    pub fn instance_backfill_identity_id(
2213        &self,
2214        new_identity_id: &str,
2215    ) -> Result<usize, StoreError> {
2216        let rows = {
2217            let conn = self.conn.lock().unwrap();
2218            conn.execute(
2219                "UPDATE db_agent_instances
2220                 SET identity_id = ?1
2221                 WHERE identity_id = '' OR identity_id = 'blank'",
2222                params![new_identity_id],
2223            )?
2224        };
2225        // Phase 3a dual-write (Phase 3b: errors propagate): same
2226        // backfill on db_agents user-clone rows.
2227        self.agents_dual_write_backfill_identity(new_identity_id)?;
2228        Ok(rows)
2229    }
2230
2231    /// Mirror a `db_agent_instances` mutation into the cross-version
2232    /// registry. Only fires for **named** rows. Routes by
2233    /// `display_hidden` so the registry file ends up in the tree
2234    /// matching SQLite's dropdown filter:
2235    ///
2236    /// - hidden = true  → upsert (atomic write to active/) then
2237    ///   retire (atomic rename to retired/). Net: file lives in
2238    ///   `retired/<id>.json` with the freshest content. Prevents
2239    ///   `instance_update` on a previously-hidden row from
2240    ///   resurrecting an active registry file, AND keeps the
2241    ///   retired tombstone's content current.
2242    /// - hidden = false → unretire (no-op if not retired) then
2243    ///   upsert. Net: file in `active/<id>.json`, no orphan retired.
2244    ///
2245    /// Failures are logged, never propagated: SQLite remains
2246    /// authoritative.
2247    fn registry_upsert_if_named(&self, inst: &AgentInstance) {
2248        // Mirror filter: only registers named rows. Pre-Option-E this
2249        // also excluded continuation rows (parent_instance_id != '')
2250        // so the registry-sourced read path wouldn't surface chained
2251        // resumes as duplicate dropdown rows. Under Option E, the
2252        // session zone is anchored on definition_id, so a continuation
2253        // row IS the most-recent named instance — exactly what we want
2254        // visible. `instance_list_named` (the SQLite-sourced read
2255        // path) dropped its parent_instance_id filter in the
2256        // 2026-05-24 picker-visibility fix; the registry mirror keeps
2257        // its filter here for now since the registry-sourced read path
2258        // doesn't have the dedup-by-(definition_id, instance_name)
2259        // affordance the SQLite ORDER BY/LIMIT provides. Follow-up
2260        // PR will land cross-version dedup so this filter can drop too.
2261        if inst.instance_name.is_empty() || !inst.parent_instance_id.is_empty() {
2262            return;
2263        }
2264        let Some(reg) = self.registry() else { return };
2265        let Some(agents_root) = reg.agents_root() else {
2266            tracing::warn!("registry: agents_root has no parent — skipping mirror");
2267            return;
2268        };
2269        let rec = match agent_instance_to_record(inst, agents_root) {
2270            Ok(rec) => rec,
2271            Err(e) => {
2272                tracing::warn!(
2273                    instance_id = %inst.id,
2274                    error = %e,
2275                    "registry: instance not representable as record, skipping mirror"
2276                );
2277                return;
2278            }
2279        };
2280
2281        // If the row was previously hidden, the file lives in
2282        // `retired/`. Move it back to active before upserting so
2283        // upsert's merge-preserves-unknown-fields path operates on
2284        // the right file (and we never leave dangling retired files).
2285        if let Err(e) = reg.unretire(&inst.id) {
2286            tracing::warn!(
2287                instance_id = %inst.id,
2288                error = %e,
2289                "registry: failed to unretire row before upsert"
2290            );
2291        }
2292
2293        if let Err(e) = reg.upsert(&rec) {
2294            tracing::warn!(
2295                instance_id = %inst.id,
2296                error = %e,
2297                "registry: failed to mirror instance_create/update"
2298            );
2299            return;
2300        }
2301
2302        // After the upsert, move into retired/ if the row is hidden.
2303        // Combined: hidden row's tombstone always has up-to-date
2304        // content, and active/ never carries a hidden row.
2305        if inst.display_hidden {
2306            if let Err(e) = reg.retire(&inst.id) {
2307                tracing::warn!(
2308                    instance_id = %inst.id,
2309                    error = %e,
2310                    "registry: failed to retire hidden row post-upsert"
2311                );
2312            }
2313        }
2314    }
2315
2316    /// Look up the most-recently-created **active** instance for a
2317    /// block — `active` = status in (running, paused). Stopped and
2318    /// crashed instances are skipped: when a user closes a pane and
2319    /// re-opens it (creating a fresh instance), the resolver should
2320    /// see the NEW row's identity_id / memory_id, not bleed creds
2321    /// from the prior stopped one. Reagent P2 (PR #751).
2322    pub fn instance_get_active_for_block(
2323        &self,
2324        block_id: &str,
2325    ) -> Result<Option<AgentInstance>, StoreError> {
2326        let conn = self.conn.lock().unwrap();
2327        let mut stmt = conn.prepare(
2328            "SELECT id, definition_id, parent_instance_id, block_id, session_id,
2329                    status, github_context, started_at, ended_at, created_at,
2330                    identity_id, memory_id, instance_name, working_directory,
2331                    display_hidden
2332             FROM db_agent_instances
2333             WHERE block_id = ?1 AND status IN ('running', 'paused')
2334             ORDER BY created_at DESC
2335             LIMIT 1",
2336        )?;
2337        let result = stmt.query_row(params![block_id], map_instance_row);
2338        match result {
2339            Ok(a) => Ok(Some(a)),
2340            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
2341            Err(e) => Err(e.into()),
2342        }
2343    }
2344
2345    // ====================================================================
2346    // Phase 3a — db_agents dual-write helpers
2347    //
2348    // Every mutation on `db_agent_definitions` / `db_agent_instances`
2349    // mirrors into `db_agents` so a later read-migration PR (Phase 3b)
2350    // can flip readers over with confidence. Dual-write failures LOG +
2351    // CONTINUE — the old tables remain authoritative until Phase 3b.
2352    // See docs/specs/SPEC_AGENT_CONCEPT_CONSOLIDATION_2026_05_24.md.
2353    // ====================================================================
2354
2355    /// Mirror a `db_agent_definitions` row into `db_agents` as the
2356    /// canonical row for that definition.
2357    ///
2358    /// - `is_seeded = 1` rows become `is_template = 1` (canonical
2359    ///   template; bindings stay empty).
2360    /// - `is_seeded = 0` rows become `is_template = 0` with
2361    ///   `parent_template_id = parent_id` (user-cloned from a template;
2362    ///   bindings come from the matching instance, if any — handled by
2363    ///   `agents_dual_write_instance_create` updating in place).
2364    ///
2365    /// Idempotent: uses `INSERT … ON CONFLICT(id) DO UPDATE`. Existing
2366    /// bindings on the row (written previously by an instance dual-write)
2367    /// are preserved — only definition-side fields are overwritten.
2368    ///
2369    /// Phase 3b: returns `Err` on failure (previously logged + continued).
2370    /// Phase 3b readers see `db_agents`, so a silent dual-write failure
2371    /// would leak stale data into the picker.
2372    pub(crate) fn agents_dual_write_definition_upsert(&self, def: &AgentDefinition) -> Result<(), StoreError> {
2373        let conn = self.conn.lock().unwrap();
2374        let is_template = if def.is_seeded == 1 { 1_i64 } else { 0_i64 };
2375        let parent_template_id = if def.is_seeded == 1 {
2376            String::new()
2377        } else {
2378            def.parent_id.clone()
2379        };
2380        // Phase 3b: carry the caller's `user_hidden` into the INSERT
2381        // (previously hardcoded to 0). The legacy `db_agent_definitions`
2382        // INSERT honours it, and now that Phase 3b readers look at
2383        // `db_agents`, the projection must agree from row creation —
2384        // not just after a subsequent `agent_def_set_hidden` flip.
2385        // ON CONFLICT deliberately does NOT update `user_hidden`: hide
2386        // is a per-user view-state flag that survives definition
2387        // payload edits.
2388        conn.execute(
2389            "INSERT INTO db_agents (
2390                id, name, icon, description,
2391                is_template, parent_template_id,
2392                provider, provider_flags, shell, environment,
2393                agent_type, agent_bus_id, accounts,
2394                auto_start, restart_on_crash, idle_timeout_minutes,
2395                slug, branch_label,
2396                created_at, updated_at, is_seeded, user_hidden
2397             ) VALUES (
2398                ?1, ?2, ?3, ?4,
2399                ?5, ?6,
2400                ?7, ?8, ?9, ?10,
2401                ?11, ?12, ?13,
2402                ?14, ?15, ?16,
2403                ?17, ?18,
2404                ?19, ?20, ?21, ?22
2405             )
2406             ON CONFLICT(id) DO UPDATE SET
2407                name = excluded.name,
2408                icon = excluded.icon,
2409                description = excluded.description,
2410                is_template = excluded.is_template,
2411                parent_template_id = excluded.parent_template_id,
2412                provider = excluded.provider,
2413                provider_flags = excluded.provider_flags,
2414                shell = excluded.shell,
2415                environment = excluded.environment,
2416                agent_type = excluded.agent_type,
2417                agent_bus_id = excluded.agent_bus_id,
2418                accounts = excluded.accounts,
2419                auto_start = excluded.auto_start,
2420                restart_on_crash = excluded.restart_on_crash,
2421                idle_timeout_minutes = excluded.idle_timeout_minutes,
2422                slug = excluded.slug,
2423                branch_label = excluded.branch_label,
2424                updated_at = excluded.updated_at,
2425                is_seeded = excluded.is_seeded",
2426            params![
2427                def.id,
2428                def.name,
2429                def.icon,
2430                def.description,
2431                is_template,
2432                parent_template_id,
2433                def.provider,
2434                def.provider_flags,
2435                def.shell,
2436                def.environment,
2437                def.agent_type,
2438                def.agent_bus_id,
2439                def.accounts,
2440                def.auto_start,
2441                def.restart_on_crash,
2442                def.idle_timeout_minutes,
2443                def.slug,
2444                def.branch_label,
2445                def.created_at,
2446                def.updated_at,
2447                def.is_seeded,
2448                def.user_hidden,
2449            ],
2450        )?;
2451        Ok(())
2452    }
2453
2454    /// Mirror a `db_agent_definitions` DELETE into `db_agents`. The
2455    /// definition row itself is removed; any user-cloned children (rows
2456    /// with `parent_template_id = old_id`) are left intact because the
2457    /// FK cascade on the OLD schema only deletes instances, not other
2458    /// definitions.
2459    pub(crate) fn agents_dual_write_definition_delete(&self, def_id: &str) -> Result<(), StoreError> {
2460        let conn = self.conn.lock().unwrap();
2461        // Reagent P2 on #1013 round 3: `id` is the PK so two DELETE
2462        // statements scoped by `id = ?1 AND is_template = N` add nothing
2463        // over a single PK delete (only one row can match either, and
2464        // an early return on the first error would skip the second
2465        // cleanup unnecessarily). Collapsed to a single direct PK
2466        // delete that handles both template and user-clone projections.
2467        conn.execute(
2468            "DELETE FROM db_agents WHERE id = ?1",
2469            params![def_id],
2470        )?;
2471        Ok(())
2472    }
2473
2474    /// Bulk dual-write: mirror `agent_def_delete_seeded`. Deletes:
2475    ///   1. every `is_template = 1` row (the template projections), AND
2476    ///   2. every `db_agents` row whose `id` is in the
2477    ///      `cascaded_inst_ids` set (template-instance projections that
2478    ///      were just removed by the FK cascade on `db_agent_instances`).
2479    ///
2480    /// User-clone DEFINITION projections (`is_template = 0`, `id` is a
2481    /// def_id in `db_agent_definitions`) are NOT touched — those rows
2482    /// represent persistent user agents and live or die with their
2483    /// `db_agent_definitions` row, not with the seeded-template bulk
2484    /// delete. Reagent P1 round 4 on #1013: the previous version
2485    /// scoped by `parent_template_id` and over-deleted user-clone DEF
2486    /// projections too. Idempotent.
2487    pub(crate) fn agents_dual_write_seeded_delete(&self, cascaded_inst_ids: &[String]) -> Result<(), StoreError> {
2488        let conn = self.conn.lock().unwrap();
2489        conn.execute(
2490            "DELETE FROM db_agents WHERE is_template = 1",
2491            [],
2492        )?;
2493        // Delete cascaded instance projections one by one. Could batch
2494        // via `id IN (?1, ?2, ...)` but the seeded delete is rare and
2495        // the typical set is small (< 100); per-id loop keeps the SQL
2496        // simple and avoids dynamic-parameter expansion.
2497        for inst_id in cascaded_inst_ids {
2498            conn.execute(
2499                "DELETE FROM db_agents WHERE id = ?1 AND is_template = 0",
2500                params![inst_id],
2501            )?;
2502        }
2503        Ok(())
2504    }
2505
2506    /// Mirror a `db_agent_instances` INSERT into `db_agents`. Always
2507    /// creates a NEW row in `db_agents` whose id == instance id.
2508    ///
2509    /// The row's identity comes from the instance (id, name, bindings).
2510    /// Its template-config fields are copied from the parent definition;
2511    /// `parent_template_id` points at that definition.
2512    ///
2513    /// Continuation rows (`parent_instance_id` non-empty) are skipped —
2514    /// they're not "agents" in the new model, they're a vestigial
2515    /// pre-Option-E continuation chain that the consolidation retires.
2516    pub(crate) fn agents_dual_write_instance_create(&self, inst: &AgentInstance) -> Result<(), StoreError> {
2517        if !inst.parent_instance_id.is_empty() {
2518            return Ok(());
2519        }
2520        let conn = self.conn.lock().unwrap();
2521        // Pull the parent definition for the cmd-config fields.
2522        let def = match Self::load_definition_for_dual_write(&conn, &inst.definition_id)? {
2523            Some(d) => d,
2524            None => {
2525                // Orphan instance — no matching definition. The legacy
2526                // table accepted the row (no FK in the old test stores),
2527                // but with nothing to project there's nothing this
2528                // helper can mirror. Surfacing this as Err would block
2529                // a write the legacy path tolerated; log at error level
2530                // and continue with no projection.
2531                tracing::error!(
2532                    instance_id = %inst.id,
2533                    definition_id = %inst.definition_id,
2534                    "db_agents dual-write: instance has no matching definition; skipping mirror",
2535                );
2536                return Ok(());
2537            }
2538        };
2539        let name = if inst.instance_name.is_empty() {
2540            def.name.clone()
2541        } else {
2542            inst.instance_name.clone()
2543        };
2544        // Reagent P1 on #1013 round 2: match the backfill rule
2545        // (`agents_consolidate.rs::backfill_instances`) exactly. When the
2546        // parent def is a user-clone (`is_seeded = 0`), the existing
2547        // `db_agents` row keyed by `def.id` already represents this
2548        // agent — FOLD the instance's bindings into it instead of
2549        // inserting a new row keyed by `inst.id` with
2550        // `parent_template_id = def.id` (which would point at a
2551        // non-template row and produce a shape inconsistent with what
2552        // backfill produced for identical data).
2553        let res = if def.is_seeded == 0 {
2554            // Reagent P2 round 3 on #1013: don't write `inst.created_at`
2555            // here — the user-clone def may already have a fresher
2556            // `updated_at` from a prior `agent_def_update` (e.g. user
2557            // renamed the agent after first launch). Wall-clock now()
2558            // is the right monotonic stamp (matches what
2559            // `agents_dual_write_instance_update` writes on subsequent
2560            // touches). Avoids Phase 3b readers sorting on a regressed
2561            // timestamp.
2562            //
2563            // Phase 3b: also bump strictly past the table's GLOBAL
2564            // `MAX(updated_at)` so that successive fast-fire launches
2565            // (e.g. a rapid relaunch after a crash, or tests that drive
2566            // the store in-memory) end up with a strict total order on
2567            // the sort key. Per-row floor isn't enough: two distinct
2568            // defs touched in the same millisecond would tie. SQLite
2569            // millisecond resolution on Windows collides under fast
2570            // test loops; the table-wide max is the only monotonic
2571            // floor we can use without adding a column.
2572            let wall_now: i64 = std::time::SystemTime::now()
2573                .duration_since(std::time::UNIX_EPOCH)
2574                .map(|d| d.as_millis() as i64)
2575                .unwrap_or(inst.created_at);
2576            let global_prior: i64 = conn
2577                .query_row(
2578                    "SELECT COALESCE(MAX(updated_at), 0) FROM db_agents",
2579                    [],
2580                    |row| row.get::<_, i64>(0),
2581                )
2582                .unwrap_or(0);
2583            let now_ms = std::cmp::max(wall_now, global_prior.saturating_add(1));
2584            conn.execute(
2585                "UPDATE db_agents SET
2586                    name = ?2,
2587                    identity_id = ?3,
2588                    memory_id = ?4,
2589                    working_directory = ?5,
2590                    github_context = ?6,
2591                    instance_name = ?7,
2592                    updated_at = ?8,
2593                    user_hidden = ?9
2594                 WHERE id = ?1",
2595                params![
2596                    def.id,
2597                    name,
2598                    inst.identity_id,
2599                    inst.memory_id,
2600                    inst.working_directory,
2601                    inst.github_context,
2602                    inst.instance_name,
2603                    now_ms,
2604                    if inst.display_hidden { 1_i64 } else { 0_i64 },
2605                ],
2606            )
2607        } else {
2608            conn.execute(
2609                "INSERT INTO db_agents (
2610                    id, name, icon, description,
2611                    is_template, parent_template_id,
2612                    provider, provider_flags, shell, environment,
2613                    agent_type, agent_bus_id, accounts,
2614                    auto_start, restart_on_crash, idle_timeout_minutes,
2615                    slug, branch_label,
2616                    identity_id, memory_id, working_directory, github_context,
2617                    instance_name,
2618                    created_at, updated_at, is_seeded, user_hidden
2619                 ) VALUES (
2620                    ?1, ?2, ?3, ?4,
2621                    0, ?5,
2622                    ?6, ?7, ?8, ?9,
2623                    ?10, ?11, ?12,
2624                    ?13, ?14, ?15,
2625                    ?16, ?17,
2626                    ?18, ?19, ?20, ?21,
2627                    ?22,
2628                    ?23, ?24, 0, ?25
2629                 )
2630                 ON CONFLICT(id) DO UPDATE SET
2631                    name = excluded.name,
2632                    identity_id = excluded.identity_id,
2633                    memory_id = excluded.memory_id,
2634                    working_directory = excluded.working_directory,
2635                    github_context = excluded.github_context,
2636                    instance_name = excluded.instance_name,
2637                    updated_at = excluded.updated_at,
2638                    user_hidden = excluded.user_hidden",
2639                params![
2640                    inst.id,
2641                    name,
2642                    def.icon,
2643                    def.description,
2644                    def.id,                  // parent_template_id = definition_id
2645                    def.provider,
2646                    def.provider_flags,
2647                    def.shell,
2648                    def.environment,
2649                    def.agent_type,
2650                    def.agent_bus_id,
2651                    def.accounts,
2652                    def.auto_start,
2653                    def.restart_on_crash,
2654                    def.idle_timeout_minutes,
2655                    def.slug,
2656                    def.branch_label,
2657                    inst.identity_id,
2658                    inst.memory_id,
2659                    inst.working_directory,
2660                    inst.github_context,
2661                    inst.instance_name,
2662                    inst.created_at,
2663                    inst.created_at,          // updated_at = created_at on insert
2664                    if inst.display_hidden { 1_i64 } else { 0_i64 },
2665                ],
2666            )
2667        };
2668        res?;
2669        Ok(())
2670    }
2671
2672    /// Mirror a `db_agent_instances` UPDATE into `db_agents`. Touches
2673    /// only the fields that `instance_update` writes (block + session +
2674    /// status + github_context + ended_at) — name/bindings come from
2675    /// the original create.
2676    pub(crate) fn agents_dual_write_instance_update(&self, inst: &AgentInstance) -> Result<(), StoreError> {
2677        if !inst.parent_instance_id.is_empty() {
2678            return Ok(());
2679        }
2680        let conn = self.conn.lock().unwrap();
2681        let now = std::time::SystemTime::now()
2682            .duration_since(std::time::UNIX_EPOCH)
2683            .unwrap_or_default()
2684            .as_millis() as i64;
2685        // Reagent P1 round 4 on #1013: route by projection key so the
2686        // UPDATE actually hits the folded user-clone-def row when this
2687        // instance was folded at create. The previous version keyed by
2688        // `inst.id` always and silently no-op'd on every folded
2689        // instance's lifecycle event.
2690        let key = match Self::agents_projection_key_for_inst(&conn, &inst.id) {
2691            Some((k, _)) => k,
2692            None => return Ok(()),
2693        };
2694        // `instance_update` only touches block_id/session_id/status/
2695        // github_context/ended_at. Of those, only `github_context` lands
2696        // on db_agents (block/session/status/ended_at are not modelled
2697        // on the consolidated row — they're block/session-machine
2698        // concerns the consolidation deliberately drops). We DO refresh
2699        // updated_at so a Phase-3b reader can sort by recency. Apply
2700        // the same monotonic-floor trick as the fold branch in
2701        // `agents_dual_write_instance_create` — wall clock alone
2702        // collides under millisecond resolution on fast successive
2703        // mutations.
2704        let global_prior: i64 = conn
2705            .query_row(
2706                "SELECT COALESCE(MAX(updated_at), 0) FROM db_agents",
2707                [],
2708                |row| row.get::<_, i64>(0),
2709            )
2710            .unwrap_or(0);
2711        let now_monotonic = std::cmp::max(now, global_prior.saturating_add(1));
2712        conn.execute(
2713            "UPDATE db_agents SET
2714                github_context = ?1,
2715                updated_at = ?2
2716             WHERE id = ?3 AND is_template = 0",
2717            params![inst.github_context, now_monotonic, key],
2718        )?;
2719        Ok(())
2720    }
2721
2722    /// Mirror `instance_set_hidden` into `db_agents.user_hidden`.
2723    pub(crate) fn agents_dual_write_instance_set_hidden(&self, id: &str, hidden: bool) -> Result<(), StoreError> {
2724        let conn = self.conn.lock().unwrap();
2725        // Reagent P1 round 4 on #1013: route by projection key (see
2726        // `agents_dual_write_instance_update` for context).
2727        let key = match Self::agents_projection_key_for_inst(&conn, id) {
2728            Some((k, _)) => k,
2729            None => return Ok(()),
2730        };
2731        conn.execute(
2732            "UPDATE db_agents SET user_hidden = ?1 WHERE id = ?2 AND is_template = 0",
2733            params![if hidden { 1_i64 } else { 0_i64 }, key],
2734        )?;
2735        Ok(())
2736    }
2737
2738    /// Mirror `instance_repoint_definition` into `db_agents`. The
2739    /// `parent_template_id` of every user-clone row that pointed at
2740    /// `old_def_id` is updated to `new_def_id`.
2741    pub(crate) fn agents_dual_write_instance_repoint(
2742        &self,
2743        old_def_id: &str,
2744        new_def_id: &str,
2745    ) -> Result<(), StoreError> {
2746        let conn = self.conn.lock().unwrap();
2747        conn.execute(
2748            // Reagent P1 round 3 on #1013: `instance_repoint_definition`
2749            // only rewrites `db_agent_instances.definition_id` — it
2750            // does NOT rewrite the parent of a sibling user-cloned
2751            // definition that happens to share the same parent template.
2752            // Restrict the projection update to rows whose `id` is an
2753            // ACTUALLY repointed instance id (i.e. the rows whose
2754            // `db_agent_instances.definition_id` was just rewritten to
2755            // `new_def_id`). User-clone definition projections, whose
2756            // `id` lives in `db_agent_definitions` not `db_agent_instances`,
2757            // are untouched.
2758            "UPDATE db_agents
2759             SET parent_template_id = ?1
2760             WHERE is_template = 0
2761               AND parent_template_id = ?2
2762               AND id IN (SELECT id FROM db_agent_instances WHERE definition_id = ?1)",
2763            params![new_def_id, old_def_id],
2764        )?;
2765        Ok(())
2766    }
2767
2768    /// Mirror `instance_delete` into `db_agents`.
2769    pub(crate) fn agents_dual_write_instance_delete(&self, id: &str) -> Result<(), StoreError> {
2770        let conn = self.conn.lock().unwrap();
2771        // Reagent P1 round 4 on #1013: route by projection key. For a
2772        // template-instance projection (`is_folded = false`), the row
2773        // lives at `id` and goes when the instance goes. For a
2774        // user-clone-def fold (`is_folded = true`), the projection at
2775        // `def.id` represents the DEF — the def persists when its
2776        // instance ends, so NO-OP. `agents_dual_write_definition_delete`
2777        // is the right entry point to remove that row.
2778        //
2779        // Race fallback: `instance_delete` runs the DELETE on
2780        // `db_agent_instances` BEFORE calling this helper, so by now
2781        // the instance row is gone and `agents_projection_key_for_inst`
2782        // returns None. Fall back to checking `db_agents` directly: if
2783        // there's a row at `id` with `is_template = 0`, that's a
2784        // non-folded projection — delete it. (Folded projections live
2785        // at `def.id`, never at `inst.id`, so this is safe.)
2786        let key_info = Self::agents_projection_key_for_inst(&conn, id);
2787        let (key, is_folded) = match key_info {
2788            Some(info) => info,
2789            None => (id.to_string(), false),
2790        };
2791        if is_folded {
2792            return Ok(());
2793        }
2794        conn.execute(
2795            "DELETE FROM db_agents WHERE id = ?1 AND is_template = 0",
2796            params![key],
2797        )?;
2798        Ok(())
2799    }
2800
2801    /// Mirror `instance_backfill_identity_id` into `db_agents`. Same
2802    /// filter (empty or `"blank"` identity_id) restricted to user-clone
2803    /// rows.
2804    pub(crate) fn agents_dual_write_backfill_identity(&self, new_identity_id: &str) -> Result<(), StoreError> {
2805        let conn = self.conn.lock().unwrap();
2806        conn.execute(
2807            "UPDATE db_agents
2808             SET identity_id = ?1
2809             WHERE is_template = 0 AND (identity_id = '' OR identity_id = 'blank')",
2810            params![new_identity_id],
2811        )?;
2812        Ok(())
2813    }
2814
2815    /// Reagent P1 round 4 on #1013 — fold-aware projection key lookup.
2816    /// Resolves "for an instance with this id, which `db_agents` row
2817    /// represents it post-create?". Returns `Some((key, is_folded))`:
2818    ///   - `is_folded = true`  → key is the parent definition id
2819    ///                            (the user-clone-def projection absorbs
2820    ///                            the instance's bindings).
2821    ///   - `is_folded = false` → key is the instance id (template-instance
2822    ///                            projection is its own row).
2823    /// Returns `None` if the instance no longer exists in
2824    /// `db_agent_instances` (e.g. already deleted by FK cascade).
2825    fn agents_projection_key_for_inst(
2826        conn: &Connection,
2827        inst_id: &str,
2828    ) -> Option<(String, bool)> {
2829        conn.query_row(
2830            "SELECT i.id, i.definition_id, COALESCE(d.is_seeded, 1) AS is_seeded
2831             FROM db_agent_instances i
2832             LEFT JOIN db_agent_definitions d ON i.definition_id = d.id
2833             WHERE i.id = ?1",
2834            params![inst_id],
2835            |row| {
2836                let id: String = row.get(0)?;
2837                let def_id: String = row.get(1)?;
2838                let is_seeded: i64 = row.get(2)?;
2839                Ok(if is_seeded == 0 {
2840                    (def_id, true)  // folded into def-projection
2841                } else {
2842                    (id, false)     // instance-projection in its own row
2843                })
2844            },
2845        ).ok()
2846    }
2847
2848    /// Helper: re-read a definition row from inside an active connection
2849    /// lock (used by `agents_dual_write_instance_create` to avoid
2850    /// re-locking the mutex recursively).
2851    fn load_definition_for_dual_write(
2852        conn: &Connection,
2853        id: &str,
2854    ) -> rusqlite::Result<Option<AgentDefinition>> {
2855        let mut stmt = conn.prepare(
2856            "SELECT id, slug, name, icon, provider, description,
2857                    working_directory, shell, provider_flags, auto_start,
2858                    restart_on_crash, idle_timeout_minutes, created_at,
2859                    agent_type, environment, agent_bus_id, is_seeded,
2860                    accounts, parent_id, branch_label, updated_at,
2861                    user_hidden
2862             FROM db_agent_definitions WHERE id = ?1",
2863        )?;
2864        let result = stmt.query_row(params![id], |row| {
2865            Ok(AgentDefinition {
2866                id: row.get(0)?,
2867                slug: row.get(1)?,
2868                name: row.get(2)?,
2869                icon: row.get(3)?,
2870                provider: row.get(4)?,
2871                description: row.get(5)?,
2872                working_directory: row.get(6)?,
2873                shell: row.get(7)?,
2874                provider_flags: row.get(8)?,
2875                auto_start: row.get(9)?,
2876                restart_on_crash: row.get(10)?,
2877                idle_timeout_minutes: row.get(11)?,
2878                created_at: row.get(12)?,
2879                agent_type: row.get(13)?,
2880                environment: row.get(14)?,
2881                agent_bus_id: row.get(15)?,
2882                is_seeded: row.get(16)?,
2883                accounts: row.get(17)?,
2884                parent_id: row.get(18)?,
2885                branch_label: row.get(19)?,
2886                updated_at: row.get(20)?,
2887                user_hidden: row.get(21)?,
2888            })
2889        });
2890        match result {
2891            Ok(d) => Ok(Some(d)),
2892            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
2893            Err(e) => Err(e),
2894        }
2895    }
2896
2897    // ====================================================================
2898    // v7 — Identity bundles (named credential bundles) + Memory bundles
2899    // ====================================================================
2900
2901    // ---- Identity bundle CRUD ----
2902
2903    /// List all Identity bundles, blank singleton last so the picker shows
2904    /// user-defined bundles first.
2905    pub fn bundle_identity_list(&self) -> Result<Vec<Identity>, StoreError> {
2906        let conn = self.conn.lock().unwrap();
2907        let mut stmt = conn.prepare(
2908            "SELECT id, name, description, is_blank, created_at, updated_at
2909             FROM db_identity_bundles
2910             ORDER BY is_blank ASC, updated_at DESC",
2911        )?;
2912        let iter = stmt.query_map([], |row| {
2913            Ok(Identity {
2914                id: row.get(0)?,
2915                name: row.get(1)?,
2916                description: row.get(2)?,
2917                is_blank: row.get::<_, i64>(3)? != 0,
2918                created_at: row.get(4)?,
2919                updated_at: row.get(5)?,
2920            })
2921        })?;
2922        let mut out = Vec::new();
2923        for r in iter {
2924            out.push(r?);
2925        }
2926        Ok(out)
2927    }
2928
2929    pub fn bundle_identity_get(&self, id: &str) -> Result<Option<Identity>, StoreError> {
2930        let conn = self.conn.lock().unwrap();
2931        let mut stmt = conn.prepare(
2932            "SELECT id, name, description, is_blank, created_at, updated_at
2933             FROM db_identity_bundles WHERE id = ?1",
2934        )?;
2935        let result = stmt.query_row(params![id], |row| {
2936            Ok(Identity {
2937                id: row.get(0)?,
2938                name: row.get(1)?,
2939                description: row.get(2)?,
2940                is_blank: row.get::<_, i64>(3)? != 0,
2941                created_at: row.get(4)?,
2942                updated_at: row.get(5)?,
2943            })
2944        });
2945        match result {
2946            Ok(i) => Ok(Some(i)),
2947            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
2948            Err(e) => Err(e.into()),
2949        }
2950    }
2951
2952    /// Upsert an Identity bundle. Caller mints the id (no silent generation).
2953    /// The `is_blank` flag is reserved for the seeded singleton — callers
2954    /// should pass `false` for user-created identities.
2955    pub fn bundle_identity_upsert(&self, identity: &Identity) -> Result<(), StoreError> {
2956        let conn = self.conn.lock().unwrap();
2957        conn.execute(
2958            "INSERT INTO db_identity_bundles
2959                (id, name, description, is_blank, created_at, updated_at)
2960             VALUES (?1, ?2, ?3, ?4, ?5, ?6)
2961             ON CONFLICT(id) DO UPDATE SET
2962                name = excluded.name,
2963                description = excluded.description,
2964                updated_at = excluded.updated_at",
2965            params![
2966                identity.id,
2967                identity.name,
2968                identity.description,
2969                identity.is_blank as i64,
2970                identity.created_at,
2971                identity.updated_at,
2972            ],
2973        )?;
2974        Ok(())
2975    }
2976
2977    /// Delete an Identity bundle. Refuses to delete the blank singleton —
2978    /// the launch UI depends on it as the always-present default option.
2979    pub fn bundle_identity_delete(&self, id: &str) -> Result<bool, StoreError> {
2980        if id == "blank" {
2981            return Err(StoreError::Other(
2982                "cannot delete the blank Identity singleton".to_string(),
2983            ));
2984        }
2985        let conn = self.conn.lock().unwrap();
2986        let rows = conn.execute("DELETE FROM db_identity_bundles WHERE id = ?1", params![id])?;
2987        Ok(rows > 0)
2988    }
2989
2990    // ---- Identity bundle bindings (junction with accounts) ----
2991
2992    /// Set the account for `(identity_id, provider)`. Overwrites any
2993    /// existing binding for the same (identity, provider).
2994    pub fn bundle_identity_bind(
2995        &self,
2996        identity_id: &str,
2997        provider: &str,
2998        account_id: &str,
2999    ) -> Result<(), StoreError> {
3000        let conn = self.conn.lock().unwrap();
3001        conn.execute(
3002            "INSERT INTO db_identity_bindings (identity_id, provider, account_id)
3003             VALUES (?1, ?2, ?3)
3004             ON CONFLICT(identity_id, provider) DO UPDATE SET account_id = excluded.account_id",
3005            params![identity_id, provider, account_id],
3006        )?;
3007        Ok(())
3008    }
3009
3010    /// Remove the binding for `(identity_id, provider)`. Returns whether a
3011    /// row was deleted.
3012    pub fn bundle_identity_unbind(
3013        &self,
3014        identity_id: &str,
3015        provider: &str,
3016    ) -> Result<bool, StoreError> {
3017        let conn = self.conn.lock().unwrap();
3018        let rows = conn.execute(
3019            "DELETE FROM db_identity_bindings WHERE identity_id = ?1 AND provider = ?2",
3020            params![identity_id, provider],
3021        )?;
3022        Ok(rows > 0)
3023    }
3024
3025    /// List bindings for an Identity bundle.
3026    pub fn bundle_identity_bindings(
3027        &self,
3028        identity_id: &str,
3029    ) -> Result<Vec<IdentityBinding>, StoreError> {
3030        let conn = self.conn.lock().unwrap();
3031        let mut stmt = conn.prepare(
3032            "SELECT identity_id, provider, account_id
3033             FROM db_identity_bindings
3034             WHERE identity_id = ?1
3035             ORDER BY provider ASC",
3036        )?;
3037        let iter = stmt.query_map(params![identity_id], |row| {
3038            Ok(IdentityBinding {
3039                identity_id: row.get(0)?,
3040                provider: row.get(1)?,
3041                account_id: row.get(2)?,
3042            })
3043        })?;
3044        let mut out = Vec::new();
3045        for r in iter {
3046            out.push(r?);
3047        }
3048        Ok(out)
3049    }
3050
3051    // ---- Memory bundle CRUD ----
3052
3053    pub fn bundle_memory_list(&self) -> Result<Vec<Memory>, StoreError> {
3054        let conn = self.conn.lock().unwrap();
3055        let mut stmt = conn.prepare(
3056            "SELECT id, name, description, is_blank, provider, model, instructions,
3057                    context_files, mcp_servers, skills, created_at, updated_at
3058             FROM db_memory_bundles
3059             ORDER BY is_blank ASC, updated_at DESC",
3060        )?;
3061        let iter = stmt.query_map([], map_memory_row)?;
3062        let mut out = Vec::new();
3063        for r in iter {
3064            out.push(r?);
3065        }
3066        Ok(out)
3067    }
3068
3069    pub fn bundle_memory_get(&self, id: &str) -> Result<Option<Memory>, StoreError> {
3070        let conn = self.conn.lock().unwrap();
3071        let mut stmt = conn.prepare(
3072            "SELECT id, name, description, is_blank, provider, model, instructions,
3073                    context_files, mcp_servers, skills, created_at, updated_at
3074             FROM db_memory_bundles WHERE id = ?1",
3075        )?;
3076        let result = stmt.query_row(params![id], map_memory_row);
3077        match result {
3078            Ok(m) => Ok(Some(m)),
3079            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
3080            Err(e) => Err(e.into()),
3081        }
3082    }
3083
3084    pub fn bundle_memory_upsert(&self, memory: &Memory) -> Result<(), StoreError> {
3085        let conn = self.conn.lock().unwrap();
3086        conn.execute(
3087            "INSERT INTO db_memory_bundles
3088                (id, name, description, is_blank, provider, model, instructions,
3089                 context_files, mcp_servers, skills, created_at, updated_at)
3090             VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)
3091             ON CONFLICT(id) DO UPDATE SET
3092                name = excluded.name,
3093                description = excluded.description,
3094                provider = excluded.provider,
3095                model = excluded.model,
3096                instructions = excluded.instructions,
3097                context_files = excluded.context_files,
3098                mcp_servers = excluded.mcp_servers,
3099                skills = excluded.skills,
3100                updated_at = excluded.updated_at",
3101            params![
3102                memory.id,
3103                memory.name,
3104                memory.description,
3105                memory.is_blank as i64,
3106                memory.provider,
3107                memory.model,
3108                memory.instructions,
3109                memory.context_files,
3110                memory.mcp_servers,
3111                memory.skills,
3112                memory.created_at,
3113                memory.updated_at,
3114            ],
3115        )?;
3116        Ok(())
3117    }
3118
3119    /// Delete a Memory bundle. Refuses to delete the blank singleton.
3120    pub fn bundle_memory_delete(&self, id: &str) -> Result<bool, StoreError> {
3121        if id == "blank" {
3122            return Err(StoreError::Other(
3123                "cannot delete the blank Memory singleton".to_string(),
3124            ));
3125        }
3126        let conn = self.conn.lock().unwrap();
3127        let rows = conn.execute("DELETE FROM db_memory_bundles WHERE id = ?1", params![id])?;
3128        Ok(rows > 0)
3129    }
3130}
3131
3132fn map_memory_row(row: &rusqlite::Row) -> rusqlite::Result<Memory> {
3133    Ok(Memory {
3134        id: row.get(0)?,
3135        name: row.get(1)?,
3136        description: row.get(2)?,
3137        is_blank: row.get::<_, i64>(3)? != 0,
3138        provider: row.get(4)?,
3139        model: row.get(5)?,
3140        instructions: row.get(6)?,
3141        context_files: row.get(7)?,
3142        mcp_servers: row.get(8)?,
3143        skills: row.get(9)?,
3144        created_at: row.get(10)?,
3145        updated_at: row.get(11)?,
3146    })
3147}
3148
3149fn map_instance_row(row: &rusqlite::Row) -> rusqlite::Result<AgentInstance> {
3150    let display_hidden_int: i64 = row.get(14)?;
3151    Ok(AgentInstance {
3152        id: row.get(0)?,
3153        definition_id: row.get(1)?,
3154        parent_instance_id: row.get(2)?,
3155        block_id: row.get(3)?,
3156        session_id: row.get(4)?,
3157        status: row.get(5)?,
3158        github_context: row.get(6)?,
3159        started_at: row.get(7)?,
3160        ended_at: row.get(8)?,
3161        created_at: row.get(9)?,
3162        identity_id: row.get(10)?,
3163        memory_id: row.get(11)?,
3164        instance_name: row.get(12)?,
3165        working_directory: row.get(13)?,
3166        display_hidden: display_hidden_int != 0,
3167    })
3168}
3169
3170/// Phase 3b — row mapper for `db_agents` rows projected back into the
3171/// `AgentDefinition` shape. The column order MUST match the SELECT in
3172/// `agent_def_list`. `parent_template_id` maps to `parent_id` because
3173/// the consolidated table renamed the field to clarify its semantics
3174/// (template lineage), but the wire shape kept the old name.
3175fn map_agent_definition_row(row: &rusqlite::Row) -> rusqlite::Result<AgentDefinition> {
3176    Ok(AgentDefinition {
3177        id: row.get(0)?,
3178        slug: row.get(1)?,
3179        name: row.get(2)?,
3180        icon: row.get(3)?,
3181        provider: row.get(4)?,
3182        description: row.get(5)?,
3183        working_directory: row.get(6)?,
3184        shell: row.get(7)?,
3185        provider_flags: row.get(8)?,
3186        auto_start: row.get(9)?,
3187        restart_on_crash: row.get(10)?,
3188        idle_timeout_minutes: row.get(11)?,
3189        created_at: row.get(12)?,
3190        agent_type: row.get(13)?,
3191        environment: row.get(14)?,
3192        agent_bus_id: row.get(15)?,
3193        is_seeded: row.get(16)?,
3194        accounts: row.get(17)?,
3195        parent_id: row.get(18)?,
3196        branch_label: row.get(19)?,
3197        updated_at: row.get(20)?,
3198        user_hidden: row.get(21)?,
3199    })
3200}
3201
3202// ====================================================================
3203// Tests
3204// ====================================================================
3205
3206#[cfg(test)]
3207mod tests {
3208    use super::*;
3209    use crate::backend::obj::*;
3210
3211    fn make_store() -> WaveStore {
3212        WaveStore::open_in_memory().unwrap()
3213    }
3214
3215    #[test]
3216    fn test_insert_and_get_client() {
3217        let store = make_store();
3218        let mut client = Client {
3219            oid: "test-client-oid".to_string(),
3220            version: 0,
3221            windowids: vec!["w1".to_string()],
3222            meta: MetaMapType::new(),
3223            tosagreed: 1700000000000,
3224            ..Default::default()
3225        };
3226        store.insert(&mut client).unwrap();
3227        assert_eq!(client.get_version(), 1);
3228
3229        let loaded = store.must_get::<Client>("test-client-oid").unwrap();
3230        assert_eq!(loaded.oid, "test-client-oid");
3231        assert_eq!(loaded.version, 1);
3232        assert_eq!(loaded.windowids, vec!["w1"]);
3233        assert_eq!(loaded.tosagreed, 1700000000000);
3234    }
3235
3236    #[test]
3237    fn test_insert_and_get_window() {
3238        let store = make_store();
3239        let mut win = Window {
3240            oid: "win-1".to_string(),
3241            workspaceid: "ws-1".to_string(),
3242            pos: Point { x: 10, y: 20 },
3243            winsize: WinSize {
3244                width: 800,
3245                height: 600,
3246            },
3247            meta: MetaMapType::new(),
3248            ..Default::default()
3249        };
3250        store.insert(&mut win).unwrap();
3251
3252        let loaded = store.must_get::<Window>("win-1").unwrap();
3253        assert_eq!(loaded.workspaceid, "ws-1");
3254        assert_eq!(loaded.pos.x, 10);
3255        assert_eq!(loaded.winsize.width, 800);
3256    }
3257
3258    #[test]
3259    fn test_insert_and_get_workspace() {
3260        let store = make_store();
3261        let mut ws = Workspace {
3262            oid: "ws-1".to_string(),
3263            name: "Test WS".to_string(),
3264            tabids: vec!["t1".to_string()],
3265            activetabid: "t1".to_string(),
3266            meta: MetaMapType::new(),
3267            ..Default::default()
3268        };
3269        store.insert(&mut ws).unwrap();
3270
3271        let loaded = store.must_get::<Workspace>("ws-1").unwrap();
3272        assert_eq!(loaded.name, "Test WS");
3273        assert_eq!(loaded.tabids, vec!["t1"]);
3274    }
3275
3276    #[test]
3277    fn test_insert_and_get_tab() {
3278        let store = make_store();
3279        let mut tab = Tab {
3280            oid: "tab-1".to_string(),
3281            name: "Shell".to_string(),
3282            layoutstate: "ls-1".to_string(),
3283            blockids: vec!["b1".to_string()],
3284            meta: MetaMapType::new(),
3285            ..Default::default()
3286        };
3287        store.insert(&mut tab).unwrap();
3288
3289        let loaded = store.must_get::<Tab>("tab-1").unwrap();
3290        assert_eq!(loaded.name, "Shell");
3291    }
3292
3293    #[test]
3294    fn test_insert_and_get_block() {
3295        let store = make_store();
3296        let mut block = Block {
3297            oid: "blk-1".to_string(),
3298            parentoref: "tab:tab-1".to_string(),
3299            meta: {
3300                let mut m = MetaMapType::new();
3301                m.insert("view".into(), serde_json::json!("term"));
3302                m
3303            },
3304            ..Default::default()
3305        };
3306        store.insert(&mut block).unwrap();
3307
3308        let loaded = store.must_get::<Block>("blk-1").unwrap();
3309        assert_eq!(loaded.parentoref, "tab:tab-1");
3310        assert_eq!(loaded.meta.get("view").unwrap(), "term");
3311    }
3312
3313    #[test]
3314    fn test_insert_and_get_layout_state() {
3315        let store = make_store();
3316        // Phase E.4.B Phase 2 — uses typed LayoutNode (was a junk JSON blob).
3317        let mut ls = LayoutState {
3318            oid: "ls-1".to_string(),
3319            rootnode: Some(crate::backend::obj::LayoutNode {
3320                id: "n1".into(),
3321                flex_direction: crate::backend::obj::FlexDirection::Row,
3322                size: 1.0,
3323                children: Vec::new(),
3324                data: None,
3325                ..Default::default()
3326            }),
3327            magnifiednodeid: "n1".to_string(),
3328            ..Default::default()
3329        };
3330        store.insert(&mut ls).unwrap();
3331
3332        let loaded = store.must_get::<LayoutState>("ls-1").unwrap();
3333        assert_eq!(loaded.magnifiednodeid, "n1");
3334        assert!(loaded.rootnode.is_some());
3335    }
3336
3337    #[test]
3338    fn test_get_nonexistent_returns_none() {
3339        let store = make_store();
3340        let result = store.get::<Client>("nonexistent").unwrap();
3341        assert!(result.is_none());
3342    }
3343
3344    #[test]
3345    fn test_must_get_nonexistent_returns_error() {
3346        let store = make_store();
3347        let result = store.must_get::<Client>("nonexistent");
3348        assert!(matches!(result, Err(StoreError::NotFound)));
3349    }
3350
3351    #[test]
3352    fn test_update_increments_version() {
3353        let store = make_store();
3354        let mut client = Client {
3355            oid: "c1".to_string(),
3356            meta: MetaMapType::new(),
3357            ..Default::default()
3358        };
3359        store.insert(&mut client).unwrap();
3360        assert_eq!(client.version, 1);
3361
3362        client.windowids = vec!["w-new".to_string()];
3363        let v2 = store.update(&mut client).unwrap();
3364        assert_eq!(v2, 2);
3365        assert_eq!(client.version, 2);
3366
3367        let v3 = store.update(&mut client).unwrap();
3368        assert_eq!(v3, 3);
3369    }
3370
3371    #[test]
3372    fn test_delete() {
3373        let store = make_store();
3374        let mut client = Client {
3375            oid: "del-me".to_string(),
3376            meta: MetaMapType::new(),
3377            ..Default::default()
3378        };
3379        store.insert(&mut client).unwrap();
3380        assert!(store.get::<Client>("del-me").unwrap().is_some());
3381
3382        store.delete::<Client>("del-me").unwrap();
3383        assert!(store.get::<Client>("del-me").unwrap().is_none());
3384    }
3385
3386    #[test]
3387    fn test_get_all() {
3388        let store = make_store();
3389        for i in 0..3 {
3390            let mut tab = Tab {
3391                oid: format!("tab-{i}"),
3392                name: format!("Tab {i}"),
3393                meta: MetaMapType::new(),
3394                ..Default::default()
3395            };
3396            store.insert(&mut tab).unwrap();
3397        }
3398
3399        let all = store.get_all::<Tab>().unwrap();
3400        assert_eq!(all.len(), 3);
3401    }
3402
3403    #[test]
3404    fn test_count() {
3405        let store = make_store();
3406        assert_eq!(store.count::<Client>().unwrap(), 0);
3407
3408        let mut c = Client {
3409            oid: "c1".to_string(),
3410            meta: MetaMapType::new(),
3411            ..Default::default()
3412        };
3413        store.insert(&mut c).unwrap();
3414        assert_eq!(store.count::<Client>().unwrap(), 1);
3415    }
3416
3417    #[test]
3418    fn test_insert_empty_oid_fails() {
3419        let store = make_store();
3420        let mut client = Client {
3421            oid: String::new(),
3422            meta: MetaMapType::new(),
3423            ..Default::default()
3424        };
3425        let result = store.insert(&mut client);
3426        assert!(matches!(result, Err(StoreError::EmptyOID)));
3427    }
3428
3429    #[test]
3430    fn test_with_tx_commits_on_success() {
3431        let store = make_store();
3432        store
3433            .with_tx(|tx| {
3434                let mut ws = Workspace {
3435                    oid: "ws-tx".to_string(),
3436                    name: "TX Workspace".to_string(),
3437                    meta: MetaMapType::new(),
3438                    ..Default::default()
3439                };
3440                tx.insert(&mut ws)?;
3441
3442                let mut tab = Tab {
3443                    oid: "tab-tx".to_string(),
3444                    // tabN naming convention per SPEC_TAB_GAPS_AND_NAMING_2026_04_25.
3445                    name: "tab1".to_string(),
3446                    layoutstate: "ls-tx".to_string(),
3447                    meta: MetaMapType::new(),
3448                    ..Default::default()
3449                };
3450                tx.insert(&mut tab)?;
3451
3452                // Update workspace to reference tab
3453                ws.tabids.push("tab-tx".to_string());
3454                tx.update(&mut ws)?;
3455
3456                Ok(())
3457            })
3458            .unwrap();
3459
3460        // Verify everything committed
3461        let ws = store.must_get::<Workspace>("ws-tx").unwrap();
3462        assert_eq!(ws.name, "TX Workspace");
3463        assert_eq!(ws.tabids, vec!["tab-tx"]);
3464        assert_eq!(ws.version, 2); // insert=v1, update=v2
3465
3466        let tab = store.must_get::<Tab>("tab-tx").unwrap();
3467        assert_eq!(tab.name, "tab1");
3468    }
3469
3470    #[test]
3471    fn test_with_tx_rollbacks_on_error() {
3472        let store = make_store();
3473        let result: Result<(), StoreError> = store.with_tx(|tx| {
3474            let mut ws = Workspace {
3475                oid: "ws-rollback".to_string(),
3476                name: "Should Not Exist".to_string(),
3477                meta: MetaMapType::new(),
3478                ..Default::default()
3479            };
3480            tx.insert(&mut ws)?;
3481
3482            // Force an error
3483            Err(StoreError::Other("intentional failure".to_string()))
3484        });
3485        assert!(result.is_err());
3486
3487        // Verify the insert was rolled back
3488        let ws = store.get::<Workspace>("ws-rollback").unwrap();
3489        assert!(ws.is_none());
3490    }
3491
3492    #[test]
3493    fn test_agent_def_insert_collision_resolves_at_runtime() {
3494        // Two agents whose names derive to the same slug must both
3495        // insert successfully, with the second getting a `-2` suffix.
3496        // This exercises the runtime collision-resolution path in
3497        // agent_def_insert (separate from the migration backfill path
3498        // tested in migrations.rs).
3499        let store = make_store();
3500
3501        let mut a1 = AgentDefinition {
3502            id: "id-a".to_string(),
3503            slug: String::new(),
3504            name: "Agent X".to_string(),
3505            icon: "✦".to_string(),
3506            provider: "claude".to_string(),
3507            description: String::new(),
3508            working_directory: String::new(),
3509            shell: String::new(),
3510            provider_flags: String::new(),
3511            auto_start: 0,
3512            restart_on_crash: 0,
3513            idle_timeout_minutes: 0,
3514            created_at: 0,
3515            agent_type: "host".to_string(),
3516            environment: String::new(),
3517            agent_bus_id: String::new(),
3518            is_seeded: 0,
3519            accounts: String::new(),
3520            parent_id: String::new(),
3521            branch_label: String::new(),
3522            updated_at: 0,
3523            user_hidden: 0,
3524        };
3525        store.agent_def_insert(&mut a1).unwrap();
3526        // "Agent X" → "agent-x"
3527        assert_eq!(a1.slug, "agent-x");
3528
3529        let mut a2 = AgentDefinition {
3530            id: "id-b".to_string(),
3531            // Different surface form, derives to the same slug
3532            name: "agent x".to_string(),
3533            ..a1.clone()
3534        };
3535        a2.slug = String::new();
3536        store.agent_def_insert(&mut a2).unwrap();
3537        assert_eq!(a2.slug, "agent-x-2");
3538
3539        let mut a3 = AgentDefinition {
3540            id: "id-c".to_string(),
3541            name: "AGENT-X".to_string(),
3542            ..a1.clone()
3543        };
3544        a3.slug = String::new();
3545        store.agent_def_insert(&mut a3).unwrap();
3546        assert_eq!(a3.slug, "agent-x-3");
3547
3548        // Verify the underlying rows actually got written
3549        let listed = store.agent_def_list().unwrap();
3550        let slugs: Vec<&str> = listed.iter().map(|a| a.slug.as_str()).collect();
3551        assert!(slugs.contains(&"agent-x"));
3552        assert!(slugs.contains(&"agent-x-2"));
3553        assert!(slugs.contains(&"agent-x-3"));
3554    }
3555
3556    #[test]
3557    fn test_agent_def_insert_explicit_slug_collision_resolves() {
3558        // When a caller passes an explicit (non-empty) slug that
3559        // already exists, agent_def_insert still resolves the collision
3560        // via suffixing — guards against the seed pre-loading the
3561        // same slug twice or any other "I know the slug" path.
3562        let store = make_store();
3563
3564        let mut a1 = AgentDefinition {
3565            id: "id-a".to_string(),
3566            slug: "explicit".to_string(),
3567            name: "First".to_string(),
3568            icon: "✦".to_string(),
3569            provider: "claude".to_string(),
3570            description: String::new(),
3571            working_directory: String::new(),
3572            shell: String::new(),
3573            provider_flags: String::new(),
3574            auto_start: 0,
3575            restart_on_crash: 0,
3576            idle_timeout_minutes: 0,
3577            created_at: 0,
3578            agent_type: "host".to_string(),
3579            environment: String::new(),
3580            agent_bus_id: String::new(),
3581            is_seeded: 0,
3582            accounts: String::new(),
3583            parent_id: String::new(),
3584            branch_label: String::new(),
3585            updated_at: 0,
3586            user_hidden: 0,
3587        };
3588        store.agent_def_insert(&mut a1).unwrap();
3589        assert_eq!(a1.slug, "explicit");
3590
3591        let mut a2 = AgentDefinition {
3592            id: "id-b".to_string(),
3593            ..a1.clone()
3594        };
3595        a2.slug = "explicit".to_string();
3596        store.agent_def_insert(&mut a2).unwrap();
3597        assert_eq!(a2.slug, "explicit-2");
3598    }
3599
3600    // ---- v6 identity / instance CRUD ----
3601
3602    fn v6_test_store() -> WaveStore {
3603        WaveStore::open_in_memory().unwrap()
3604    }
3605
3606    fn sample_account(id: &str, provider: &str) -> IdentityAccount {
3607        IdentityAccount {
3608            id: id.to_string(),
3609            name: format!("asaf-{provider}"),
3610            provider: provider.to_string(),
3611            kind: "pat".to_string(),
3612            display_name: "".to_string(),
3613            secret_ref: SecretRef::Env { env_var: format!("{}_TOKEN", provider.to_uppercase()) },
3614            context: serde_json::json!({"username": "asaf"}),
3615            status: "unknown".to_string(),
3616            created_at: 0,
3617            updated_at: 0,
3618        }
3619    }
3620
3621    fn sample_agent(id: &str, slug: &str) -> AgentDefinition {
3622        AgentDefinition {
3623            id: id.to_string(),
3624            slug: slug.to_string(),
3625            name: id.to_string(),
3626            icon: "✦".to_string(),
3627            provider: "claude".to_string(),
3628            description: "".to_string(),
3629            working_directory: "".to_string(),
3630            shell: "".to_string(),
3631            provider_flags: "".to_string(),
3632            auto_start: 0,
3633            restart_on_crash: 0,
3634            idle_timeout_minutes: 0,
3635            created_at: 0,
3636            agent_type: "host".to_string(),
3637            environment: "".to_string(),
3638            agent_bus_id: "".to_string(),
3639            is_seeded: 0,
3640            accounts: String::new(),
3641            parent_id: String::new(),
3642            branch_label: String::new(),
3643            updated_at: 0,
3644            user_hidden: 0,
3645        }
3646    }
3647
3648    #[test]
3649    fn test_identity_upsert_round_trip() {
3650        let store = v6_test_store();
3651        let acct = sample_account("id-gh", "github");
3652        store.identity_upsert(&acct).unwrap();
3653
3654        let fetched = store.identity_get("id-gh").unwrap().expect("row");
3655        assert_eq!(fetched.name, "asaf-github");
3656        assert_eq!(fetched.provider, "github");
3657        assert!(matches!(fetched.secret_ref, SecretRef::Env { ref env_var } if env_var == "GITHUB_TOKEN"));
3658        assert_eq!(fetched.context["username"], "asaf");
3659    }
3660
3661    #[test]
3662    fn test_identity_list_filtered_by_provider() {
3663        let store = v6_test_store();
3664        store.identity_upsert(&sample_account("id-gh", "github")).unwrap();
3665        store.identity_upsert(&sample_account("id-aws", "aws")).unwrap();
3666        store.identity_upsert(&sample_account("id-gh2", "github")).unwrap();
3667
3668        let all = store.identity_list(None).unwrap();
3669        assert_eq!(all.len(), 3);
3670        let gh = store.identity_list(Some("github")).unwrap();
3671        assert_eq!(gh.len(), 2);
3672        assert!(gh.iter().all(|a| a.provider == "github"));
3673    }
3674
3675    #[test]
3676    fn test_identity_delete() {
3677        let store = v6_test_store();
3678        store.identity_upsert(&sample_account("id-gh", "github")).unwrap();
3679        assert!(store.identity_delete("id-gh").unwrap());
3680        assert!(store.identity_get("id-gh").unwrap().is_none());
3681        // Second delete is a no-op.
3682        assert!(!store.identity_delete("id-gh").unwrap());
3683    }
3684
3685    #[test]
3686    fn test_agent_identity_link_and_unlink() {
3687        let store = v6_test_store();
3688        let mut agent = sample_agent("ag1", "agent-x");
3689        store.agent_def_insert(&mut agent).unwrap();
3690        store.identity_upsert(&sample_account("id-gh", "github")).unwrap();
3691
3692        store.agent_identity_link("ag1", "id-gh", "github").unwrap();
3693        let links = store.agent_identity_list_for_agent("ag1").unwrap();
3694        assert_eq!(links.len(), 1);
3695        assert_eq!(links[0].account_id, "id-gh");
3696
3697        // Re-link with a different account overwrites (one account per provider per agent)
3698        store.identity_upsert(&sample_account("id-gh2", "github")).unwrap();
3699        store.agent_identity_link("ag1", "id-gh2", "github").unwrap();
3700        let links = store.agent_identity_list_for_agent("ag1").unwrap();
3701        assert_eq!(links.len(), 1);
3702        assert_eq!(links[0].account_id, "id-gh2");
3703
3704        assert!(store.agent_identity_unlink("ag1", "github").unwrap());
3705        assert!(store.agent_identity_list_for_agent("ag1").unwrap().is_empty());
3706    }
3707
3708    #[test]
3709    fn test_agent_identity_cascade_on_agent_delete() {
3710        let store = v6_test_store();
3711        let mut agent = sample_agent("ag1", "agent-x");
3712        store.agent_def_insert(&mut agent).unwrap();
3713        store.identity_upsert(&sample_account("id-gh", "github")).unwrap();
3714        store.agent_identity_link("ag1", "id-gh", "github").unwrap();
3715
3716        store.agent_def_delete("ag1").unwrap();
3717        assert!(store.agent_identity_list_for_agent("ag1").unwrap().is_empty());
3718    }
3719
3720    #[test]
3721    fn test_instance_create_update_filter() {
3722        let store = v6_test_store();
3723        let mut agent = sample_agent("def1", "agent-x");
3724        store.agent_def_insert(&mut agent).unwrap();
3725
3726        let inst = AgentInstance {
3727            id: "inst1".to_string(),
3728            definition_id: "def1".to_string(),
3729            parent_instance_id: String::new(),
3730            block_id: "block-abc".to_string(),
3731            session_id: String::new(),
3732            status: InstanceStatus::Running.as_str().to_string(),
3733            github_context: String::new(),
3734            started_at: 1000,
3735            ended_at: 0,
3736            created_at: 1000,
3737            identity_id: String::new(),
3738            memory_id: String::new(),
3739            instance_name: String::new(),
3740            working_directory: String::new(),
3741            display_hidden: false,
3742        };
3743        store.instance_create(&inst).unwrap();
3744
3745        let fetched = store.instance_get("inst1").unwrap().expect("row");
3746        assert_eq!(fetched.block_id, "block-abc");
3747        assert_eq!(fetched.status, "running");
3748
3749        // Update status → stopped
3750        let mut updated = fetched.clone();
3751        updated.status = InstanceStatus::Stopped.as_str().to_string();
3752        updated.ended_at = 2000;
3753        assert!(store.instance_update(&updated).unwrap());
3754        assert_eq!(store.instance_get("inst1").unwrap().unwrap().status, "stopped");
3755
3756        // Filter queries
3757        let all = store.instance_list(None, None).unwrap();
3758        assert_eq!(all.len(), 1);
3759        let by_def = store.instance_list(Some("def1"), None).unwrap();
3760        assert_eq!(by_def.len(), 1);
3761        let running = store.instance_list(None, Some("running")).unwrap();
3762        assert_eq!(running.len(), 0);
3763        let stopped = store.instance_list(None, Some("stopped")).unwrap();
3764        assert_eq!(stopped.len(), 1);
3765    }
3766
3767    #[test]
3768    fn test_agent_def_list_orders_by_last_used() {
3769        let store = v6_test_store();
3770        // Three definitions; none launched yet.
3771        let mut a = sample_agent("def-a", "agent-a");
3772        let mut b = sample_agent("def-b", "agent-b");
3773        let mut c = sample_agent("def-c", "agent-c");
3774        store.agent_def_insert(&mut a).unwrap();
3775        store.agent_def_insert(&mut b).unwrap();
3776        store.agent_def_insert(&mut c).unwrap();
3777
3778        let mk = |id: &str, def: &str, started: i64| AgentInstance {
3779            id: id.to_string(),
3780            definition_id: def.to_string(),
3781            parent_instance_id: String::new(),
3782            block_id: String::new(),
3783            session_id: String::new(),
3784            status: InstanceStatus::Running.as_str().to_string(),
3785            github_context: String::new(),
3786            started_at: started,
3787            ended_at: 0,
3788            created_at: started,
3789            identity_id: String::new(),
3790            memory_id: String::new(),
3791            instance_name: String::new(),
3792            working_directory: String::new(),
3793            display_hidden: false,
3794        };
3795        // Launch def-a, then def-b later. def-c is never launched.
3796        store.instance_create(&mk("i-a", "def-a", 500)).unwrap();
3797        store.instance_create(&mk("i-b", "def-b", 600)).unwrap();
3798
3799        let ids = |s: &WaveStore| -> Vec<String> {
3800            s.agent_def_list().unwrap().into_iter().map(|d| d.id).collect()
3801        };
3802        // Most-recently-launched first; never-launched (def-c) last.
3803        assert_eq!(ids(&store), vec!["def-b", "def-a", "def-c"]);
3804
3805        // A newer launch of def-a flips it above def-b (MAX(started_at)).
3806        store.instance_create(&mk("i-a2", "def-a", 700)).unwrap();
3807        assert_eq!(ids(&store), vec!["def-a", "def-b", "def-c"]);
3808    }
3809
3810    #[test]
3811    fn test_instance_cascade_on_definition_delete() {
3812        let store = v6_test_store();
3813        let mut agent = sample_agent("def1", "agent-x");
3814        store.agent_def_insert(&mut agent).unwrap();
3815        let inst = AgentInstance {
3816            id: "inst1".to_string(),
3817            definition_id: "def1".to_string(),
3818            parent_instance_id: String::new(),
3819            block_id: String::new(),
3820            session_id: String::new(),
3821            status: "running".to_string(),
3822            github_context: String::new(),
3823            started_at: 0,
3824            ended_at: 0,
3825            created_at: 0,
3826            identity_id: String::new(),
3827            memory_id: String::new(),
3828            instance_name: String::new(),
3829            working_directory: String::new(),
3830            display_hidden: false,
3831        };
3832        store.instance_create(&inst).unwrap();
3833
3834        store.agent_def_delete("def1").unwrap();
3835        assert!(store.instance_get("inst1").unwrap().is_none());
3836    }
3837
3838    #[test]
3839    fn test_instance_status_enum_roundtrip() {
3840        for s in &[
3841            InstanceStatus::Running,
3842            InstanceStatus::Paused,
3843            InstanceStatus::Stopped,
3844            InstanceStatus::Crashed,
3845            InstanceStatus::Detached,
3846        ] {
3847            assert_eq!(Some(*s), InstanceStatus::parse(s.as_str()));
3848        }
3849        assert_eq!(None, InstanceStatus::parse("nonsense"));
3850    }
3851
3852    // ── v7 — Identity bundle accessors ───────────────────────────────────
3853
3854    #[test]
3855    fn test_bundle_identity_lifecycle() {
3856        let store = make_store();
3857
3858        // Blank singleton always present.
3859        let initial = store.bundle_identity_list().unwrap();
3860        assert_eq!(initial.len(), 1);
3861        assert!(initial[0].is_blank);
3862        assert_eq!(initial[0].id, "blank");
3863
3864        // Upsert a user identity.
3865        let work = Identity {
3866            id: "id-work".to_string(),
3867            name: "Work".to_string(),
3868            description: "Office laptop credentials".to_string(),
3869            is_blank: false,
3870            created_at: 100,
3871            updated_at: 100,
3872        };
3873        store.bundle_identity_upsert(&work).unwrap();
3874
3875        // List orders user identities first, blank last.
3876        let listed = store.bundle_identity_list().unwrap();
3877        assert_eq!(listed.len(), 2);
3878        assert_eq!(listed[0].id, "id-work");
3879        assert_eq!(listed[1].id, "blank");
3880
3881        // Get round-trip.
3882        let fetched = store.bundle_identity_get("id-work").unwrap().unwrap();
3883        assert_eq!(fetched.name, "Work");
3884
3885        // Refuse to delete the blank singleton.
3886        let blank_delete = store.bundle_identity_delete("blank");
3887        assert!(blank_delete.is_err());
3888
3889        // Delete the user identity.
3890        assert!(store.bundle_identity_delete("id-work").unwrap());
3891        assert_eq!(store.bundle_identity_list().unwrap().len(), 1);
3892    }
3893
3894    #[test]
3895    fn test_bundle_identity_bindings_round_trip() {
3896        let store = make_store();
3897
3898        // Need an account to bind.
3899        let acct = IdentityAccount {
3900            id: "acct-1".to_string(),
3901            name: "asaf-github".to_string(),
3902            provider: "github".to_string(),
3903            kind: "pat".to_string(),
3904            display_name: String::new(),
3905            secret_ref: SecretRef::Env {
3906                env_var: "GITHUB_TOKEN".to_string(),
3907            },
3908            context: serde_json::json!({}),
3909            status: "unknown".to_string(),
3910            created_at: 0,
3911            updated_at: 0,
3912        };
3913        store.identity_upsert(&acct).unwrap();
3914
3915        let identity = Identity {
3916            id: "id-work".to_string(),
3917            name: "Work".to_string(),
3918            description: String::new(),
3919            is_blank: false,
3920            created_at: 0,
3921            updated_at: 0,
3922        };
3923        store.bundle_identity_upsert(&identity).unwrap();
3924
3925        // Bind, list, unbind.
3926        store
3927            .bundle_identity_bind("id-work", "github", "acct-1")
3928            .unwrap();
3929        let bindings = store.bundle_identity_bindings("id-work").unwrap();
3930        assert_eq!(bindings.len(), 1);
3931        assert_eq!(bindings[0].provider, "github");
3932        assert_eq!(bindings[0].account_id, "acct-1");
3933
3934        // Re-bind same provider replaces the account.
3935        let acct2 = IdentityAccount {
3936            id: "acct-2".to_string(),
3937            name: "asaf-github-2".to_string(),
3938            provider: "github".to_string(),
3939            kind: "pat".to_string(),
3940            display_name: String::new(),
3941            secret_ref: SecretRef::Env {
3942                env_var: "GITHUB_TOKEN_ALT".to_string(),
3943            },
3944            context: serde_json::json!({}),
3945            status: "unknown".to_string(),
3946            created_at: 0,
3947            updated_at: 0,
3948        };
3949        store.identity_upsert(&acct2).unwrap();
3950        store
3951            .bundle_identity_bind("id-work", "github", "acct-2")
3952            .unwrap();
3953        let rebound = store.bundle_identity_bindings("id-work").unwrap();
3954        assert_eq!(rebound.len(), 1);
3955        assert_eq!(rebound[0].account_id, "acct-2");
3956
3957        // Unbind.
3958        assert!(store.bundle_identity_unbind("id-work", "github").unwrap());
3959        assert_eq!(
3960            store.bundle_identity_bindings("id-work").unwrap().len(),
3961            0
3962        );
3963    }
3964
3965    #[test]
3966    fn test_bundle_identity_bindings_cascade_on_account_delete() {
3967        let store = make_store();
3968
3969        let acct = IdentityAccount {
3970            id: "acct-1".to_string(),
3971            name: "asaf-github".to_string(),
3972            provider: "github".to_string(),
3973            kind: "pat".to_string(),
3974            display_name: String::new(),
3975            secret_ref: SecretRef::Env {
3976                env_var: "GITHUB_TOKEN".to_string(),
3977            },
3978            context: serde_json::json!({}),
3979            status: "unknown".to_string(),
3980            created_at: 0,
3981            updated_at: 0,
3982        };
3983        store.identity_upsert(&acct).unwrap();
3984
3985        let identity = Identity {
3986            id: "id-work".to_string(),
3987            name: "Work".to_string(),
3988            description: String::new(),
3989            is_blank: false,
3990            created_at: 0,
3991            updated_at: 0,
3992        };
3993        store.bundle_identity_upsert(&identity).unwrap();
3994        store
3995            .bundle_identity_bind("id-work", "github", "acct-1")
3996            .unwrap();
3997
3998        // Deleting the account cascades the binding row.
3999        store.identity_delete("acct-1").unwrap();
4000        assert_eq!(
4001            store.bundle_identity_bindings("id-work").unwrap().len(),
4002            0
4003        );
4004    }
4005
4006    // ── v7 — Memory bundle accessors ─────────────────────────────────────
4007
4008    #[test]
4009    fn test_bundle_memory_lifecycle() {
4010        let store = make_store();
4011
4012        // Blank singleton always present.
4013        let initial = store.bundle_memory_list().unwrap();
4014        assert_eq!(initial.len(), 1);
4015        assert!(initial[0].is_blank);
4016        assert_eq!(initial[0].id, "blank");
4017
4018        // Upsert a user memory.
4019        let coder = Memory {
4020            id: "mem-coder".to_string(),
4021            name: "Claude-coder".to_string(),
4022            description: "Pair-programming setup".to_string(),
4023            is_blank: false,
4024            provider: "claude".to_string(),
4025            model: "claude-sonnet-4-6".to_string(),
4026            instructions: "You are a careful refactorer.".to_string(),
4027            context_files: "[]".to_string(),
4028            mcp_servers: "[]".to_string(),
4029            skills: "[]".to_string(),
4030            created_at: 100,
4031            updated_at: 100,
4032        };
4033        store.bundle_memory_upsert(&coder).unwrap();
4034
4035        let listed = store.bundle_memory_list().unwrap();
4036        assert_eq!(listed.len(), 2);
4037        assert_eq!(listed[0].id, "mem-coder");
4038        assert_eq!(listed[1].id, "blank");
4039
4040        let fetched = store.bundle_memory_get("mem-coder").unwrap().unwrap();
4041        assert_eq!(fetched.provider, "claude");
4042        assert_eq!(fetched.instructions, "You are a careful refactorer.");
4043
4044        // Refuse to delete the blank singleton.
4045        assert!(store.bundle_memory_delete("blank").is_err());
4046
4047        // Delete the user memory.
4048        assert!(store.bundle_memory_delete("mem-coder").unwrap());
4049        assert_eq!(store.bundle_memory_list().unwrap().len(), 1);
4050    }
4051
4052    // ---- Registry parallel-write mirror (PR A) ----
4053
4054    fn make_named_inst(id: &str, name: &str, agents_root: &Path) -> AgentInstance {
4055        // working_directory must sit under <agents_root>/<slug> so the
4056        // relative-path resolver picks it up.
4057        let wd = agents_root.join(format!("{name}-fixture")).to_string_lossy().to_string();
4058        AgentInstance {
4059            id: id.to_string(),
4060            definition_id: "def-mirror".to_string(),
4061            parent_instance_id: String::new(),
4062            block_id: String::new(),
4063            session_id: String::new(),
4064            status: "running".to_string(),
4065            github_context: String::new(),
4066            started_at: 1_000,
4067            ended_at: 0,
4068            created_at: 900,
4069            identity_id: String::new(),
4070            memory_id: String::new(),
4071            instance_name: name.to_string(),
4072            working_directory: wd,
4073            display_hidden: false,
4074        }
4075    }
4076
4077    fn store_with_registry() -> (tempfile::TempDir, WaveStore, Arc<crate::registry::Registry>) {
4078        let tmp = tempfile::tempdir().unwrap();
4079        let agents_root = tmp.path().join("agents");
4080        std::fs::create_dir_all(&agents_root).unwrap();
4081        let reg_root = agents_root.join("registry");
4082        let reg = Arc::new(crate::registry::Registry::open(reg_root).unwrap());
4083        let store = WaveStore::open_in_memory().unwrap();
4084        store.set_registry(reg.clone());
4085        // Satisfy the FK from db_agent_instances.definition_id.
4086        let mut agent = sample_agent("def-mirror", "mirror");
4087        store.agent_def_insert(&mut agent).unwrap();
4088        (tmp, store, reg)
4089    }
4090
4091    #[test]
4092    fn instance_create_named_writes_registry_file() {
4093        let (tmp, store, reg) = store_with_registry();
4094        let agents_root = tmp.path().join("agents");
4095        let inst = make_named_inst("inst-1", "demo", &agents_root);
4096        store.instance_create(&inst).unwrap();
4097        let records = reg.list_active().unwrap();
4098        assert_eq!(records.len(), 1);
4099        assert_eq!(records[0].data.instance_id, "inst-1");
4100        assert_eq!(records[0].data.instance_name, "demo");
4101        assert_eq!(records[0].data.identity_id, None);
4102        assert_eq!(records[0].data.memory_id, None);
4103        assert_eq!(records[0].data.working_dir, "demo-fixture");
4104    }
4105
4106    #[test]
4107    fn instance_create_unnamed_does_not_mirror() {
4108        let (tmp, store, reg) = store_with_registry();
4109        let agents_root = tmp.path().join("agents");
4110        let mut inst = make_named_inst("inst-2", "demo2", &agents_root);
4111        inst.instance_name = String::new(); // unnamed
4112        store.instance_create(&inst).unwrap();
4113        assert!(reg.list_active().unwrap().is_empty());
4114    }
4115
4116    #[test]
4117    fn instance_set_hidden_retires_then_unretires() {
4118        let (tmp, store, reg) = store_with_registry();
4119        let agents_root = tmp.path().join("agents");
4120        let inst = make_named_inst("inst-3", "demo3", &agents_root);
4121        store.instance_create(&inst).unwrap();
4122        store.instance_set_hidden("inst-3", true).unwrap();
4123        assert!(reg.list_active().unwrap().is_empty());
4124        store.instance_set_hidden("inst-3", false).unwrap();
4125        assert_eq!(reg.list_active().unwrap().len(), 1);
4126    }
4127
4128    #[test]
4129    fn instance_update_refreshes_registry_record() {
4130        let (tmp, store, reg) = store_with_registry();
4131        let agents_root = tmp.path().join("agents");
4132        let inst = make_named_inst("inst-4", "demo4", &agents_root);
4133        store.instance_create(&inst).unwrap();
4134        let mut updated = inst.clone();
4135        updated.status = "paused".to_string();
4136        updated.session_id = "sess-xyz".to_string();
4137        store.instance_update(&updated).unwrap();
4138        // instance_update doesn't bump last_launched_at_ms (started_at is
4139        // immutable in the SQL update), so we just verify the record
4140        // still exists and is reachable.
4141        let records = reg.list_active().unwrap();
4142        assert_eq!(records.len(), 1);
4143        assert_eq!(records[0].data.instance_id, "inst-4");
4144    }
4145
4146    #[test]
4147    fn instance_delete_removes_registry_file() {
4148        let (tmp, store, reg) = store_with_registry();
4149        let agents_root = tmp.path().join("agents");
4150        let inst = make_named_inst("inst-5", "demo5", &agents_root);
4151        store.instance_create(&inst).unwrap();
4152        store.instance_delete("inst-5").unwrap();
4153        assert!(reg.list_active().unwrap().is_empty());
4154    }
4155
4156    #[test]
4157    fn instance_create_outside_agents_dir_skips_mirror() {
4158        let (tmp, store, reg) = store_with_registry();
4159        let mut inst = make_named_inst("inst-6", "demo6", tmp.path());
4160        // Override working_dir to live outside any "agents/" segment.
4161        inst.working_directory = tmp.path().join("projects").join("myrepo").to_string_lossy().to_string();
4162        store.instance_create(&inst).unwrap();
4163        // SQL row was written, but mirror is skipped because the working
4164        // dir can't be expressed as a relative subpath under agents/.
4165        assert!(reg.list_active().unwrap().is_empty());
4166    }
4167
4168    #[test]
4169    fn instance_create_user_path_with_agents_segment_is_skipped() {
4170        // Anchored-prefix check: a user-owned workspace at
4171        // `/home/user/code/agents/myproject` must NOT be mirrored. The
4172        // pre-fix scan-for-segment logic matched the inner "agents",
4173        // producing `working_dir = "myproject"` that would resolve to
4174        // `<shared>/agents/myproject` (wrong) when PR B reads the row.
4175        let (tmp, store, reg) = store_with_registry();
4176        // tmp is NOT under the registry's agents root, so this is a
4177        // user path that happens to include an "agents" component.
4178        let outside = tmp.path().join("code").join("agents").join("myproject");
4179        let mut inst = make_named_inst("inst-pathconfuse", "confuse", tmp.path());
4180        inst.working_directory = outside.to_string_lossy().to_string();
4181        store.instance_create(&inst).unwrap();
4182        assert!(reg.list_active().unwrap().is_empty(),
4183            "user path with inner 'agents' segment must not be mirrored");
4184    }
4185
4186    #[test]
4187    fn instance_create_continuation_row_does_not_mirror() {
4188        // Registry mirror filter (per the doc comment in
4189        // registry_upsert_if_named) intentionally lags the SQLite
4190        // dropdown filter: continuation rows are NOT mirrored to
4191        // registry, even though `instance_list_named` does return
4192        // them under Option E. Cross-version dedup is the planned
4193        // follow-up; until then the registry-sourced read path
4194        // doesn't have the SQLite ORDER BY/LIMIT affordance and so
4195        // continues to gate on parent_instance_id == ''.
4196        let (tmp, store, reg) = store_with_registry();
4197        let agents_root = tmp.path().join("agents");
4198        let parent = make_named_inst("inst-parent", "demoP", &agents_root);
4199        store.instance_create(&parent).unwrap();
4200        assert_eq!(reg.list_active().unwrap().len(), 1);
4201
4202        let mut child = make_named_inst("inst-child", "demoP", &agents_root);
4203        child.parent_instance_id = "inst-parent".to_string();
4204        store.instance_create(&child).unwrap();
4205        // Still only one record — the continuation row is NOT mirrored.
4206        let recs = reg.list_active().unwrap();
4207        assert_eq!(recs.len(), 1);
4208        assert_eq!(recs[0].data.instance_id, "inst-parent");
4209    }
4210
4211    #[test]
4212    fn instance_list_named_picker_mode_includes_continuations() {
4213        // Regression test for the 2026-05-24 "Maks not under My
4214        // Agents" report. Pre-Option-E `instance_list_named` always
4215        // filtered `parent_instance_id = ''`, hiding every
4216        // continuation row. Under Option E the session zone is
4217        // anchored on definition_id, so a continuation row IS the
4218        // most-recent named instance — exactly what the picker
4219        // needs to surface. The function now has two modes:
4220        // `include_continuations = true` (picker path) returns
4221        // everything; `include_continuations = false` (legacy
4222        // launch-modal / registry-enrichment path) keeps the head-
4223        // of-chain semantics.
4224        let (tmp, store, _reg) = store_with_registry();
4225        let agents_root = tmp.path().join("agents");
4226
4227        let mut head = make_named_inst("inst-head", "Maks", &agents_root);
4228        head.started_at = 100;
4229        store.instance_create(&head).unwrap();
4230
4231        let mut cont = make_named_inst("inst-cont", "Maks", &agents_root);
4232        cont.parent_instance_id = "inst-head".to_string();
4233        cont.started_at = 200;
4234        store.instance_create(&cont).unwrap();
4235
4236        // Picker mode — both rows surface, most-recent-first.
4237        let picker_rows = store.instance_list_named(10, None, true).unwrap();
4238        assert_eq!(picker_rows.len(), 2);
4239        assert_eq!(picker_rows[0].id, "inst-cont");
4240        assert_eq!(picker_rows[1].id, "inst-head");
4241        // Continuation row preserves its parent linkage (just not
4242        // used as an exclusion gate in picker mode).
4243        assert_eq!(picker_rows[0].parent_instance_id, "inst-head");
4244        assert_eq!(picker_rows[1].parent_instance_id, "");
4245
4246        // Definition-scoped picker mode — same rows.
4247        let scoped_picker = store
4248            .instance_list_named(10, Some("def-mirror"), true)
4249            .unwrap();
4250        assert_eq!(scoped_picker.len(), 2);
4251        assert_eq!(scoped_picker[0].id, "inst-cont");
4252    }
4253
4254    #[test]
4255    fn instance_list_named_dropdown_mode_excludes_continuations() {
4256        // Launch-modal "Continue agent" dropdown / `listnamedagents`
4257        // registry-enrichment path: `include_continuations = false`.
4258        // Symmetric with `registry_upsert_if_named`'s mirror filter —
4259        // a chain shows up as ONE entry (the head), not N entries
4260        // for every resume. Codex P1 on PR #1016 first cut: when the
4261        // enrichment path lost this filter, continuation rows could
4262        // displace registry-head rows under the `limit` truncation
4263        // and miss the merge-by-id enrichment.
4264        let (tmp, store, _reg) = store_with_registry();
4265        let agents_root = tmp.path().join("agents");
4266
4267        let mut head = make_named_inst("inst-head", "Maks", &agents_root);
4268        head.started_at = 100;
4269        store.instance_create(&head).unwrap();
4270        let mut cont = make_named_inst("inst-cont", "Maks", &agents_root);
4271        cont.parent_instance_id = "inst-head".to_string();
4272        cont.started_at = 200;
4273        store.instance_create(&cont).unwrap();
4274
4275        let dropdown_rows = store.instance_list_named(10, None, false).unwrap();
4276        assert_eq!(
4277            dropdown_rows.len(),
4278            1,
4279            "legacy dropdown mode must drop continuation rows"
4280        );
4281        assert_eq!(dropdown_rows[0].id, "inst-head");
4282
4283        // Definition-scoped dropdown mode — head only.
4284        let scoped_dropdown = store
4285            .instance_list_named(10, Some("def-mirror"), false)
4286            .unwrap();
4287        assert_eq!(scoped_dropdown.len(), 1);
4288        assert_eq!(scoped_dropdown[0].id, "inst-head");
4289    }
4290
4291    #[test]
4292    fn instance_update_does_not_resurrect_hidden_row() {
4293        // Sequence: create (active) → set_hidden(true) → update.
4294        // The update must NOT move the file from retired/ back to active.
4295        let (tmp, store, reg) = store_with_registry();
4296        let agents_root = tmp.path().join("agents");
4297        let inst = make_named_inst("inst-resurrect", "demoR", &agents_root);
4298        store.instance_create(&inst).unwrap();
4299        assert_eq!(reg.list_active().unwrap().len(), 1);
4300
4301        store.instance_set_hidden("inst-resurrect", true).unwrap();
4302        assert!(reg.list_active().unwrap().is_empty(),
4303            "after set_hidden(true), record must be in retired/");
4304        assert!(reg.root().join("retired").join("inst-resurrect.json").exists());
4305
4306        // SQLite still has the row (display_hidden=1). instance_update
4307        // would refresh it — the mirror must NOT re-add to active.
4308        let mut updated = inst.clone();
4309        updated.status = "stopped".to_string();
4310        updated.ended_at = 9999;
4311        store.instance_update(&updated).unwrap();
4312
4313        assert!(reg.list_active().unwrap().is_empty(),
4314            "instance_update on a hidden row must NOT resurrect it");
4315        assert!(reg.root().join("retired").join("inst-resurrect.json").exists());
4316    }
4317
4318    #[test]
4319    fn instance_create_with_display_hidden_writes_retired() {
4320        let (tmp, store, reg) = store_with_registry();
4321        let agents_root = tmp.path().join("agents");
4322        let mut inst = make_named_inst("inst-bornhidden", "demoH", &agents_root);
4323        inst.display_hidden = true;
4324        store.instance_create(&inst).unwrap();
4325        assert!(reg.list_active().unwrap().is_empty());
4326        assert!(reg.root().join("retired").join("inst-bornhidden.json").exists());
4327    }
4328
4329    #[test]
4330    fn instance_update_toggling_hidden_off_unretires() {
4331        // Sequence: create → set_hidden(true) → set_hidden(false) →
4332        // update. After the toggle off, the file should be in active.
4333        let (tmp, store, reg) = store_with_registry();
4334        let agents_root = tmp.path().join("agents");
4335        let inst = make_named_inst("inst-toggle", "demoT", &agents_root);
4336        store.instance_create(&inst).unwrap();
4337        store.instance_set_hidden("inst-toggle", true).unwrap();
4338        store.instance_set_hidden("inst-toggle", false).unwrap();
4339        assert_eq!(reg.list_active().unwrap().len(), 1);
4340
4341        // A subsequent update should preserve active state.
4342        let mut updated = inst.clone();
4343        updated.status = "paused".to_string();
4344        store.instance_update(&updated).unwrap();
4345        assert_eq!(reg.list_active().unwrap().len(), 1);
4346        assert!(!reg.root().join("retired").join("inst-toggle.json").exists(),
4347            "no orphan retired file alongside active");
4348    }
4349
4350    #[test]
4351    fn instance_set_hidden_acts_on_registry_only_row() {
4352        // Cross-version case: a registry record exists (e.g. migrated
4353        // from another version's SQLite) but the current version's
4354        // SQLite has no matching row. `instance_set_hidden` must still
4355        // flip the registry file and report success.
4356        let (tmp, store, reg) = store_with_registry();
4357        // Seed a registry record directly — no SQLite row.
4358        let agents_root = tmp.path().join("agents");
4359        std::fs::create_dir_all(&agents_root).unwrap();
4360        let wd = agents_root.join("cross-ver");
4361        std::fs::create_dir_all(&wd).unwrap();
4362        reg.upsert(&crate::registry::NamedAgentRecord {
4363            schema_version: crate::registry::MAX_SUPPORTED_SCHEMA,
4364            data: crate::registry::NamedAgentRecordV1 {
4365                instance_id: "inst-crossver".to_string(),
4366                instance_name: "crossver".to_string(),
4367                definition_id: "claude-code".to_string(),
4368                identity_id: None,
4369                memory_id: None,
4370                working_dir: "cross-ver".to_string(),
4371                created_at_ms: 100,
4372                last_launched_at_ms: 100,
4373                created_by_version: "0.33.821".to_string(),
4374                last_launched_by_version: "0.33.821".to_string(),
4375            },
4376        })
4377        .unwrap();
4378        assert_eq!(reg.list_active().unwrap().len(), 1);
4379        assert!(store.instance_get("inst-crossver").unwrap().is_none(),
4380            "precondition: no SQLite row for cross-version agent");
4381
4382        let result = store.instance_set_hidden("inst-crossver", true).unwrap();
4383        assert!(result, "must report success even when only registry was affected");
4384        assert!(reg.list_active().unwrap().is_empty(),
4385            "registry record must be retired");
4386        assert!(reg.root().join("retired").join("inst-crossver.json").exists());
4387    }
4388
4389    #[test]
4390    fn agent_def_delete_cascade_removes_registry_files() {
4391        let (tmp, store, reg) = store_with_registry();
4392        let agents_root = tmp.path().join("agents");
4393        let inst_a = make_named_inst("inst-cascade-a", "demoA", &agents_root);
4394        let inst_b = make_named_inst("inst-cascade-b", "demoB", &agents_root);
4395        store.instance_create(&inst_a).unwrap();
4396        store.instance_create(&inst_b).unwrap();
4397        assert_eq!(reg.list_active().unwrap().len(), 2);
4398
4399        // Delete the agent definition — SQLite FK cascades both instance
4400        // rows; the mirror must also drop both registry files.
4401        store.agent_def_delete("def-mirror").unwrap();
4402        assert!(reg.list_active().unwrap().is_empty(),
4403            "agent_def_delete cascade must remove all child instance registry files");
4404    }
4405
4406    // ----------------------------------------------------------------
4407    // Phase 3a — db_agents dual-write coverage
4408    // ----------------------------------------------------------------
4409
4410    fn count_agents(store: &WaveStore, where_clause: &str) -> i64 {
4411        let conn = store.conn.lock().unwrap();
4412        let sql = format!("SELECT COUNT(*) FROM db_agents WHERE {where_clause}");
4413        conn.query_row(&sql, [], |row| row.get(0)).unwrap()
4414    }
4415
4416    fn read_agent_field(store: &WaveStore, id: &str, field: &str) -> Option<String> {
4417        let conn = store.conn.lock().unwrap();
4418        let sql = format!("SELECT {field} FROM db_agents WHERE id = ?1");
4419        let mut stmt = conn.prepare(&sql).unwrap();
4420        let r = stmt.query_row(params![id], |row| row.get::<_, String>(0));
4421        match r {
4422            Ok(s) => Some(s),
4423            Err(rusqlite::Error::QueryReturnedNoRows) => None,
4424            Err(e) => panic!("query failed: {e}"),
4425        }
4426    }
4427
4428    fn read_agent_int(store: &WaveStore, id: &str, field: &str) -> Option<i64> {
4429        let conn = store.conn.lock().unwrap();
4430        let sql = format!("SELECT {field} FROM db_agents WHERE id = ?1");
4431        let mut stmt = conn.prepare(&sql).unwrap();
4432        let r = stmt.query_row(params![id], |row| row.get::<_, i64>(0));
4433        match r {
4434            Ok(v) => Some(v),
4435            Err(rusqlite::Error::QueryReturnedNoRows) => None,
4436            Err(e) => panic!("query failed: {e}"),
4437        }
4438    }
4439
4440    #[test]
4441    fn dual_write_agent_def_insert_seeded_creates_template_row() {
4442        let store = make_store();
4443        let mut def = AgentDefinition {
4444            id: "tpl-dw-seeded".to_string(),
4445            slug: String::new(),
4446            name: "Coder".to_string(),
4447            icon: "✦".to_string(),
4448            provider: "claude".to_string(),
4449            description: "desc".to_string(),
4450            working_directory: String::new(),
4451            shell: "bash".to_string(),
4452            provider_flags: String::new(),
4453            auto_start: 0,
4454            restart_on_crash: 0,
4455            idle_timeout_minutes: 0,
4456            created_at: 1000,
4457            agent_type: "standalone".to_string(),
4458            environment: String::new(),
4459            agent_bus_id: String::new(),
4460            is_seeded: 1,
4461            accounts: String::new(),
4462            parent_id: String::new(),
4463            branch_label: String::new(),
4464            updated_at: 1000,
4465            user_hidden: 0,
4466        };
4467        store.agent_def_insert(&mut def).unwrap();
4468        // db_agents row exists, projected as template.
4469        assert_eq!(read_agent_int(&store, "tpl-dw-seeded", "is_template"), Some(1));
4470        assert_eq!(read_agent_field(&store, "tpl-dw-seeded", "parent_template_id"), Some(String::new()));
4471        assert_eq!(read_agent_field(&store, "tpl-dw-seeded", "name"), Some("Coder".to_string()));
4472        assert_eq!(read_agent_field(&store, "tpl-dw-seeded", "provider"), Some("claude".to_string()));
4473    }
4474
4475    #[test]
4476    fn dual_write_agent_def_insert_user_clone_carries_parent() {
4477        let store = make_store();
4478        // Seed the template first so the FK exists in the old table.
4479        let mut tpl = AgentDefinition {
4480            id: "tpl-parent".to_string(),
4481            slug: String::new(),
4482            name: "Coder".to_string(),
4483            icon: "✦".to_string(),
4484            provider: "claude".to_string(),
4485            description: String::new(),
4486            working_directory: String::new(),
4487            shell: "bash".to_string(),
4488            provider_flags: String::new(),
4489            auto_start: 0,
4490            restart_on_crash: 0,
4491            idle_timeout_minutes: 0,
4492            created_at: 1000,
4493            agent_type: "standalone".to_string(),
4494            environment: String::new(),
4495            agent_bus_id: String::new(),
4496            is_seeded: 1,
4497            accounts: String::new(),
4498            parent_id: String::new(),
4499            branch_label: String::new(),
4500            updated_at: 1000,
4501            user_hidden: 0,
4502        };
4503        store.agent_def_insert(&mut tpl).unwrap();
4504        // User-cloned def has is_seeded=0 + parent_id pointing at template.
4505        let mut user_def = tpl.clone();
4506        user_def.id = "def-user".to_string();
4507        user_def.slug = String::new();
4508        user_def.is_seeded = 0;
4509        user_def.parent_id = "tpl-parent".to_string();
4510        store.agent_def_insert(&mut user_def).unwrap();
4511        assert_eq!(read_agent_int(&store, "def-user", "is_template"), Some(0));
4512        assert_eq!(
4513            read_agent_field(&store, "def-user", "parent_template_id"),
4514            Some("tpl-parent".to_string())
4515        );
4516    }
4517
4518    #[test]
4519    fn dual_write_agent_def_update_refreshes_name_in_db_agents() {
4520        let store = make_store();
4521        let mut def = AgentDefinition {
4522            id: "tpl-update".to_string(),
4523            slug: String::new(),
4524            name: "Old Name".to_string(),
4525            icon: "✦".to_string(),
4526            provider: "claude".to_string(),
4527            description: String::new(),
4528            working_directory: String::new(),
4529            shell: "bash".to_string(),
4530            provider_flags: String::new(),
4531            auto_start: 0,
4532            restart_on_crash: 0,
4533            idle_timeout_minutes: 0,
4534            created_at: 1000,
4535            agent_type: "standalone".to_string(),
4536            environment: String::new(),
4537            agent_bus_id: String::new(),
4538            is_seeded: 1,
4539            accounts: String::new(),
4540            parent_id: String::new(),
4541            branch_label: String::new(),
4542            updated_at: 1000,
4543            user_hidden: 0,
4544        };
4545        store.agent_def_insert(&mut def).unwrap();
4546        def.name = "New Name".to_string();
4547        assert!(store.agent_def_update(&mut def).unwrap());
4548        assert_eq!(
4549            read_agent_field(&store, "tpl-update", "name"),
4550            Some("New Name".to_string())
4551        );
4552    }
4553
4554    #[test]
4555    fn dual_write_agent_def_delete_removes_db_agents_row() {
4556        let store = make_store();
4557        let mut def = AgentDefinition {
4558            id: "tpl-del".to_string(),
4559            slug: String::new(),
4560            name: "Goner".to_string(),
4561            icon: "✦".to_string(),
4562            provider: "claude".to_string(),
4563            description: String::new(),
4564            working_directory: String::new(),
4565            shell: "bash".to_string(),
4566            provider_flags: String::new(),
4567            auto_start: 0,
4568            restart_on_crash: 0,
4569            idle_timeout_minutes: 0,
4570            created_at: 1000,
4571            agent_type: "standalone".to_string(),
4572            environment: String::new(),
4573            agent_bus_id: String::new(),
4574            is_seeded: 1,
4575            accounts: String::new(),
4576            parent_id: String::new(),
4577            branch_label: String::new(),
4578            updated_at: 1000,
4579            user_hidden: 0,
4580        };
4581        store.agent_def_insert(&mut def).unwrap();
4582        assert_eq!(count_agents(&store, "id = 'tpl-del'"), 1);
4583        store.agent_def_delete("tpl-del").unwrap();
4584        assert_eq!(count_agents(&store, "id = 'tpl-del'"), 0);
4585    }
4586
4587    #[test]
4588    fn dual_write_instance_create_inserts_user_clone_row() {
4589        let store = make_store();
4590        // Seed template.
4591        let mut tpl = AgentDefinition {
4592            id: "tpl-for-inst".to_string(),
4593            slug: String::new(),
4594            name: "Coder".to_string(),
4595            icon: "✦".to_string(),
4596            provider: "claude".to_string(),
4597            description: "desc".to_string(),
4598            working_directory: String::new(),
4599            shell: "bash".to_string(),
4600            provider_flags: String::new(),
4601            auto_start: 0,
4602            restart_on_crash: 0,
4603            idle_timeout_minutes: 0,
4604            created_at: 1000,
4605            agent_type: "standalone".to_string(),
4606            environment: String::new(),
4607            agent_bus_id: String::new(),
4608            is_seeded: 1,
4609            accounts: String::new(),
4610            parent_id: String::new(),
4611            branch_label: String::new(),
4612            updated_at: 1000,
4613            user_hidden: 0,
4614        };
4615        store.agent_def_insert(&mut tpl).unwrap();
4616
4617        let inst = AgentInstance {
4618            id: "inst-dw".to_string(),
4619            definition_id: "tpl-for-inst".to_string(),
4620            parent_instance_id: String::new(),
4621            block_id: String::new(),
4622            session_id: String::new(),
4623            status: "running".to_string(),
4624            github_context: String::new(),
4625            started_at: 2000,
4626            ended_at: 0,
4627            created_at: 2000,
4628            identity_id: "id-1".to_string(),
4629            memory_id: "mem-1".to_string(),
4630            instance_name: "Maks".to_string(),
4631            working_directory: "/wd/maks".to_string(),
4632            display_hidden: false,
4633        };
4634        store.instance_create(&inst).unwrap();
4635        // Projected row: id == inst.id, is_template = 0, parent = tpl id,
4636        // bindings copied.
4637        assert_eq!(read_agent_int(&store, "inst-dw", "is_template"), Some(0));
4638        assert_eq!(
4639            read_agent_field(&store, "inst-dw", "parent_template_id"),
4640            Some("tpl-for-inst".to_string())
4641        );
4642        assert_eq!(read_agent_field(&store, "inst-dw", "name"), Some("Maks".to_string()));
4643        assert_eq!(read_agent_field(&store, "inst-dw", "identity_id"), Some("id-1".to_string()));
4644        assert_eq!(read_agent_field(&store, "inst-dw", "memory_id"), Some("mem-1".to_string()));
4645        assert_eq!(read_agent_field(&store, "inst-dw", "working_directory"), Some("/wd/maks".to_string()));
4646        // Continuation rows skipped.
4647        let cont = AgentInstance {
4648            id: "inst-cont".to_string(),
4649            parent_instance_id: "inst-dw".to_string(),
4650            ..inst.clone()
4651        };
4652        store.instance_create(&cont).unwrap();
4653        assert_eq!(count_agents(&store, "id = 'inst-cont'"), 0);
4654    }
4655
4656    /// Reagent P1 + P2 on #1013 round 2 — pins the user-cloned-def
4657    /// branch of `agents_dual_write_instance_create` so it matches
4658    /// the backfill rule (`agents_consolidate.rs::backfill_instances`
4659    /// folds the instance's bindings into the EXISTING `db_agents`
4660    /// row keyed by `def.id`, NOT a fresh row keyed by `inst.id`).
4661    /// Round-1 test only covered the seeded-template branch.
4662    #[test]
4663    fn dual_write_instance_create_folds_into_user_clone_def() {
4664        let store = make_store();
4665        // Seed a template.
4666        let mut tpl = AgentDefinition {
4667            id: "tpl-folded".to_string(),
4668            slug: String::new(),
4669            name: "Coder".to_string(),
4670            icon: "✦".to_string(),
4671            provider: "claude".to_string(),
4672            description: "desc".to_string(),
4673            working_directory: String::new(),
4674            shell: "bash".to_string(),
4675            provider_flags: String::new(),
4676            auto_start: 0,
4677            restart_on_crash: 0,
4678            idle_timeout_minutes: 0,
4679            created_at: 1000,
4680            agent_type: "standalone".to_string(),
4681            environment: String::new(),
4682            agent_bus_id: String::new(),
4683            is_seeded: 1,
4684            accounts: String::new(),
4685            parent_id: String::new(),
4686            branch_label: String::new(),
4687            updated_at: 1000,
4688            user_hidden: 0,
4689        };
4690        store.agent_def_insert(&mut tpl).unwrap();
4691
4692        // User-clone of the template (is_seeded = 0, parent_id = tpl id).
4693        let mut clone = AgentDefinition {
4694            id: "user-clone-1".to_string(),
4695            slug: String::new(),
4696            name: "Maks".to_string(),
4697            is_seeded: 0,
4698            parent_id: "tpl-folded".to_string(),
4699            created_at: 1500,
4700            updated_at: 1500,
4701            ..tpl.clone()
4702        };
4703        store.agent_def_insert(&mut clone).unwrap();
4704        // The user-clone projection in db_agents starts with empty bindings.
4705        assert_eq!(read_agent_field(&store, "user-clone-1", "identity_id"), Some(String::new()));
4706        assert_eq!(read_agent_field(&store, "user-clone-1", "memory_id"), Some(String::new()));
4707
4708        // Create an instance ON the user-clone def. Per backfill rule,
4709        // this must FOLD the instance's bindings into the existing
4710        // user-clone-1 row — NOT create a separate inst-fold-1 row
4711        // with parent_template_id pointing at a non-template row.
4712        let inst = AgentInstance {
4713            id: "inst-fold-1".to_string(),
4714            definition_id: "user-clone-1".to_string(),
4715            parent_instance_id: String::new(),
4716            block_id: String::new(),
4717            session_id: String::new(),
4718            status: "running".to_string(),
4719            github_context: "gh-ctx-A".to_string(),
4720            started_at: 2000,
4721            ended_at: 0,
4722            created_at: 2000,
4723            identity_id: "id-folded".to_string(),
4724            memory_id: "mem-folded".to_string(),
4725            instance_name: "Maks v2".to_string(),
4726            working_directory: "/wd/folded".to_string(),
4727            display_hidden: false,
4728        };
4729        store.instance_create(&inst).unwrap();
4730
4731        // No new row keyed by inst.id — backfill never creates one for
4732        // user-clone-def instances, and dual-write must match.
4733        assert_eq!(count_agents(&store, "id = 'inst-fold-1'"), 0);
4734
4735        // Bindings folded onto the user-clone-1 row.
4736        assert_eq!(read_agent_field(&store, "user-clone-1", "identity_id"), Some("id-folded".to_string()));
4737        assert_eq!(read_agent_field(&store, "user-clone-1", "memory_id"), Some("mem-folded".to_string()));
4738        assert_eq!(read_agent_field(&store, "user-clone-1", "working_directory"), Some("/wd/folded".to_string()));
4739        assert_eq!(read_agent_field(&store, "user-clone-1", "github_context"), Some("gh-ctx-A".to_string()));
4740        assert_eq!(read_agent_field(&store, "user-clone-1", "instance_name"), Some("Maks v2".to_string()));
4741        assert_eq!(read_agent_field(&store, "user-clone-1", "name"), Some("Maks v2".to_string()));
4742        // is_template stays 0, parent_template_id untouched (still empty
4743        // since user-clone insert leaves it blank).
4744        assert_eq!(read_agent_int(&store, "user-clone-1", "is_template"), Some(0));
4745    }
4746
4747    #[test]
4748    fn dual_write_instance_set_hidden_flips_user_hidden_bit() {
4749        let store = make_store();
4750        let mut tpl = AgentDefinition {
4751            id: "tpl-hide".to_string(),
4752            slug: String::new(),
4753            name: "Coder".to_string(),
4754            icon: "✦".to_string(),
4755            provider: "claude".to_string(),
4756            description: String::new(),
4757            working_directory: String::new(),
4758            shell: "bash".to_string(),
4759            provider_flags: String::new(),
4760            auto_start: 0,
4761            restart_on_crash: 0,
4762            idle_timeout_minutes: 0,
4763            created_at: 1000,
4764            agent_type: "standalone".to_string(),
4765            environment: String::new(),
4766            agent_bus_id: String::new(),
4767            is_seeded: 1,
4768            accounts: String::new(),
4769            parent_id: String::new(),
4770            branch_label: String::new(),
4771            updated_at: 1000,
4772            user_hidden: 0,
4773        };
4774        store.agent_def_insert(&mut tpl).unwrap();
4775        let inst = AgentInstance {
4776            id: "inst-hide".to_string(),
4777            definition_id: "tpl-hide".to_string(),
4778            parent_instance_id: String::new(),
4779            block_id: String::new(),
4780            session_id: String::new(),
4781            status: "running".to_string(),
4782            github_context: String::new(),
4783            started_at: 2000,
4784            ended_at: 0,
4785            created_at: 2000,
4786            identity_id: String::new(),
4787            memory_id: String::new(),
4788            instance_name: "H".to_string(),
4789            working_directory: "/wd/h".to_string(),
4790            display_hidden: false,
4791        };
4792        store.instance_create(&inst).unwrap();
4793        assert_eq!(read_agent_int(&store, "inst-hide", "user_hidden"), Some(0));
4794        store.instance_set_hidden("inst-hide", true).unwrap();
4795        assert_eq!(read_agent_int(&store, "inst-hide", "user_hidden"), Some(1));
4796        store.instance_set_hidden("inst-hide", false).unwrap();
4797        assert_eq!(read_agent_int(&store, "inst-hide", "user_hidden"), Some(0));
4798    }
4799
4800    #[test]
4801    fn dual_write_instance_delete_drops_db_agents_row() {
4802        let store = make_store();
4803        let mut tpl = AgentDefinition {
4804            id: "tpl-instdel".to_string(),
4805            slug: String::new(),
4806            name: "Coder".to_string(),
4807            icon: "✦".to_string(),
4808            provider: "claude".to_string(),
4809            description: String::new(),
4810            working_directory: String::new(),
4811            shell: "bash".to_string(),
4812            provider_flags: String::new(),
4813            auto_start: 0,
4814            restart_on_crash: 0,
4815            idle_timeout_minutes: 0,
4816            created_at: 1000,
4817            agent_type: "standalone".to_string(),
4818            environment: String::new(),
4819            agent_bus_id: String::new(),
4820            is_seeded: 1,
4821            accounts: String::new(),
4822            parent_id: String::new(),
4823            branch_label: String::new(),
4824            updated_at: 1000,
4825            user_hidden: 0,
4826        };
4827        store.agent_def_insert(&mut tpl).unwrap();
4828        let inst = AgentInstance {
4829            id: "inst-del".to_string(),
4830            definition_id: "tpl-instdel".to_string(),
4831            parent_instance_id: String::new(),
4832            block_id: String::new(),
4833            session_id: String::new(),
4834            status: "running".to_string(),
4835            github_context: String::new(),
4836            started_at: 2000,
4837            ended_at: 0,
4838            created_at: 2000,
4839            identity_id: String::new(),
4840            memory_id: String::new(),
4841            instance_name: "D".to_string(),
4842            working_directory: "/wd/d".to_string(),
4843            display_hidden: false,
4844        };
4845        store.instance_create(&inst).unwrap();
4846        assert_eq!(count_agents(&store, "id = 'inst-del'"), 1);
4847        store.instance_delete("inst-del").unwrap();
4848        assert_eq!(count_agents(&store, "id = 'inst-del'"), 0);
4849    }
4850
4851    #[test]
4852    fn dual_write_instance_repoint_updates_parent_template_id() {
4853        let store = make_store();
4854        let mut tpl_a = AgentDefinition {
4855            id: "tpl-A".to_string(),
4856            slug: String::new(),
4857            name: "A".to_string(),
4858            icon: "✦".to_string(),
4859            provider: "claude".to_string(),
4860            description: String::new(),
4861            working_directory: String::new(),
4862            shell: "bash".to_string(),
4863            provider_flags: String::new(),
4864            auto_start: 0,
4865            restart_on_crash: 0,
4866            idle_timeout_minutes: 0,
4867            created_at: 1000,
4868            agent_type: "standalone".to_string(),
4869            environment: String::new(),
4870            agent_bus_id: String::new(),
4871            is_seeded: 1,
4872            accounts: String::new(),
4873            parent_id: String::new(),
4874            branch_label: String::new(),
4875            updated_at: 1000,
4876            user_hidden: 0,
4877        };
4878        store.agent_def_insert(&mut tpl_a).unwrap();
4879        let mut tpl_b = tpl_a.clone();
4880        tpl_b.id = "tpl-B".to_string();
4881        tpl_b.slug = String::new();
4882        store.agent_def_insert(&mut tpl_b).unwrap();
4883
4884        let inst = AgentInstance {
4885            id: "inst-rp".to_string(),
4886            definition_id: "tpl-A".to_string(),
4887            parent_instance_id: String::new(),
4888            block_id: String::new(),
4889            session_id: String::new(),
4890            status: "running".to_string(),
4891            github_context: String::new(),
4892            started_at: 2000,
4893            ended_at: 0,
4894            created_at: 2000,
4895            identity_id: String::new(),
4896            memory_id: String::new(),
4897            instance_name: "R".to_string(),
4898            working_directory: "/wd/r".to_string(),
4899            display_hidden: false,
4900        };
4901        store.instance_create(&inst).unwrap();
4902        assert_eq!(
4903            read_agent_field(&store, "inst-rp", "parent_template_id"),
4904            Some("tpl-A".to_string())
4905        );
4906        store.instance_repoint_definition("tpl-A", "tpl-B").unwrap();
4907        assert_eq!(
4908            read_agent_field(&store, "inst-rp", "parent_template_id"),
4909            Some("tpl-B".to_string())
4910        );
4911    }
4912
4913    #[test]
4914    fn dual_write_agent_def_delete_seeded_drops_all_template_rows() {
4915        let store = make_store();
4916        for id in &["s1", "s2", "s3"] {
4917            let mut d = AgentDefinition {
4918                id: id.to_string(),
4919                slug: String::new(),
4920                name: id.to_string(),
4921                icon: "✦".to_string(),
4922                provider: "claude".to_string(),
4923                description: String::new(),
4924                working_directory: String::new(),
4925                shell: "bash".to_string(),
4926                provider_flags: String::new(),
4927                auto_start: 0,
4928                restart_on_crash: 0,
4929                idle_timeout_minutes: 0,
4930                created_at: 1000,
4931                agent_type: "standalone".to_string(),
4932                environment: String::new(),
4933                agent_bus_id: String::new(),
4934                is_seeded: 1,
4935                accounts: String::new(),
4936                parent_id: String::new(),
4937                branch_label: String::new(),
4938                updated_at: 1000,
4939                user_hidden: 0,
4940            };
4941            store.agent_def_insert(&mut d).unwrap();
4942        }
4943        assert_eq!(count_agents(&store, "is_template = 1"), 3);
4944        store.agent_def_delete_seeded().unwrap();
4945        assert_eq!(count_agents(&store, "is_template = 1"), 0);
4946    }
4947
4948    /// Reagent P2 round 4 on #1013 — pins the seeded-bulk-delete
4949    /// scope: templates + cascaded INSTANCE projections go;
4950    /// user-clone DEF projections survive.
4951    #[test]
4952    fn dual_write_seeded_delete_preserves_user_clone_def_projections() {
4953        let store = make_store();
4954        // Seeded template.
4955        let mut tpl = AgentDefinition {
4956            id: "tpl-keep-check".to_string(),
4957            slug: String::new(),
4958            name: "TplCheck".to_string(),
4959            icon: "✦".to_string(),
4960            provider: "claude".to_string(),
4961            description: String::new(),
4962            working_directory: String::new(),
4963            shell: "bash".to_string(),
4964            provider_flags: String::new(),
4965            auto_start: 0,
4966            restart_on_crash: 0,
4967            idle_timeout_minutes: 0,
4968            created_at: 1000,
4969            agent_type: "standalone".to_string(),
4970            environment: String::new(),
4971            agent_bus_id: String::new(),
4972            is_seeded: 1,
4973            accounts: String::new(),
4974            parent_id: String::new(),
4975            branch_label: String::new(),
4976            updated_at: 1000,
4977            user_hidden: 0,
4978        };
4979        store.agent_def_insert(&mut tpl).unwrap();
4980        // User-clone DEF of that template (Phase 1 created this).
4981        let mut clone = AgentDefinition {
4982            id: "user-clone-keep".to_string(),
4983            slug: String::new(),
4984            name: "MaksKeeper".to_string(),
4985            is_seeded: 0,
4986            parent_id: "tpl-keep-check".to_string(),
4987            created_at: 1500,
4988            updated_at: 1500,
4989            ..tpl.clone()
4990        };
4991        store.agent_def_insert(&mut clone).unwrap();
4992        // Instance ON the seeded template (cascaded instance projection).
4993        let inst_on_tpl = AgentInstance {
4994            id: "inst-on-tpl-keep".to_string(),
4995            definition_id: "tpl-keep-check".to_string(),
4996            parent_instance_id: String::new(),
4997            block_id: String::new(),
4998            session_id: String::new(),
4999            status: "running".to_string(),
5000            github_context: String::new(),
5001            started_at: 2000,
5002            ended_at: 0,
5003            created_at: 2000,
5004            identity_id: String::new(),
5005            memory_id: String::new(),
5006            instance_name: String::new(),
5007            working_directory: String::new(),
5008            display_hidden: false,
5009        };
5010        store.instance_create(&inst_on_tpl).unwrap();
5011        // Now delete seeded → template + cascaded instance go, user-clone survives.
5012        store.agent_def_delete_seeded().unwrap();
5013        assert_eq!(count_agents(&store, "id = 'tpl-keep-check'"), 0, "template projection gone");
5014        assert_eq!(count_agents(&store, "id = 'inst-on-tpl-keep'"), 0, "cascaded instance projection gone");
5015        assert_eq!(count_agents(&store, "id = 'user-clone-keep'"), 1, "user-clone def projection survives");
5016    }
5017
5018    /// Reagent P2 round 4 on #1013 — pins instance_update/hide/delete
5019    /// routing through the projection key. The previous version keyed
5020    /// everything on `inst.id` and silently no-op'd on folded rows.
5021    #[test]
5022    fn dual_write_instance_lifecycle_on_user_clone_def_routes_to_folded_row() {
5023        let store = make_store();
5024        // Template, user-clone def of it, instance on the user-clone.
5025        let mut tpl = AgentDefinition {
5026            id: "tpl-rt".to_string(),
5027            slug: String::new(),
5028            name: "Tpl".to_string(),
5029            icon: "✦".to_string(),
5030            provider: "claude".to_string(),
5031            description: String::new(),
5032            working_directory: String::new(),
5033            shell: "bash".to_string(),
5034            provider_flags: String::new(),
5035            auto_start: 0,
5036            restart_on_crash: 0,
5037            idle_timeout_minutes: 0,
5038            created_at: 1000,
5039            agent_type: "standalone".to_string(),
5040            environment: String::new(),
5041            agent_bus_id: String::new(),
5042            is_seeded: 1,
5043            accounts: String::new(),
5044            parent_id: String::new(),
5045            branch_label: String::new(),
5046            updated_at: 1000,
5047            user_hidden: 0,
5048        };
5049        store.agent_def_insert(&mut tpl).unwrap();
5050        let mut clone = AgentDefinition {
5051            id: "user-rt".to_string(),
5052            slug: String::new(),
5053            name: "Maks".to_string(),
5054            is_seeded: 0,
5055            parent_id: "tpl-rt".to_string(),
5056            created_at: 1500,
5057            updated_at: 1500,
5058            ..tpl.clone()
5059        };
5060        store.agent_def_insert(&mut clone).unwrap();
5061        let inst = AgentInstance {
5062            id: "inst-rt".to_string(),
5063            definition_id: "user-rt".to_string(),
5064            parent_instance_id: String::new(),
5065            block_id: String::new(),
5066            session_id: String::new(),
5067            status: "running".to_string(),
5068            github_context: "gh-initial".to_string(),
5069            started_at: 2000,
5070            ended_at: 0,
5071            created_at: 2000,
5072            identity_id: "id-init".to_string(),
5073            memory_id: "mem-init".to_string(),
5074            instance_name: "Maks v1".to_string(),
5075            working_directory: "/wd".to_string(),
5076            display_hidden: false,
5077        };
5078        store.instance_create(&inst).unwrap();
5079        // Sanity: no inst-rt row (folded).
5080        assert_eq!(count_agents(&store, "id = 'inst-rt'"), 0);
5081        assert_eq!(read_agent_field(&store, "user-rt", "github_context"), Some("gh-initial".to_string()));
5082
5083        // instance_update: github_context flows through to the folded row.
5084        let updated = AgentInstance {
5085            github_context: "gh-updated".to_string(),
5086            ..inst.clone()
5087        };
5088        store.instance_update(&updated).unwrap();
5089        assert_eq!(
5090            read_agent_field(&store, "user-rt", "github_context"),
5091            Some("gh-updated".to_string()),
5092            "instance_update on user-clone-def routes to folded row",
5093        );
5094
5095        // instance_set_hidden: flips user_hidden on the folded row.
5096        store.instance_set_hidden("inst-rt", true).unwrap();
5097        assert_eq!(
5098            read_agent_int(&store, "user-rt", "user_hidden"),
5099            Some(1),
5100            "instance_set_hidden routes to folded row",
5101        );
5102
5103        // instance_delete: NO-OP on folded row (the def projection persists).
5104        store.instance_delete("inst-rt").unwrap();
5105        assert_eq!(
5106            count_agents(&store, "id = 'user-rt'"),
5107            1,
5108            "instance_delete on user-clone-def is a no-op (def projection persists)",
5109        );
5110    }
5111}