agentmux_srv/backend/storage/agents/definition.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! `agent_def_*`: the agent-definition catalog's CRUD on `db_agents`.
5
6use super::*;
7
8impl Store {
9 /// Fetch a single agent definition by primary key. Reads `db_agents`
10 /// directly (the same source `agent_def_list` reads, via the shared
11 /// private `agent_row_get`) — as of the definition flip this is no longer
12 /// scoped to definitions/templates only: any `db_agents` row, including
13 /// a bare template launch with no `db_agent_definitions` counterpart,
14 /// resolves here, matching what `agent_def_list` already returns for
15 /// it. Falls back to the global cross-channel registry when the row
16 /// doesn't exist locally at all.
17 ///
18 /// Used by the `template_promote` migration's deterministic-id
19 /// idempotency check (see
20 /// `migrate_promote_template_sessions_v1`): every retry asks
21 /// "does the promote-target clone for this template already
22 /// exist?" and either reuses it or inserts it.
23 pub fn agent_def_get(&self, id: &str) -> Result<Option<AgentDefinition>, StoreError> {
24 let local = self.agent_row_get(id)?;
25 if local.is_some() {
26 return Ok(local);
27 }
28 // Not in local SQLite — check the global cross-channel registry.
29 // agent_def_list() already overlays this; keep agent_def_get consistent.
30 match self.shared_def_get(id) {
31 Ok(def) => Ok(def),
32 Err(e) => {
33 tracing::warn!(agent_id = %id, error = %e, "agent_def_get: global registry lookup failed; returning not-found");
34 Ok(None)
35 }
36 }
37 }
38
39 /// `agent_def_get`, but a failed read of the global definition registry
40 /// is an error rather than "not found". For callers that must refuse
41 /// instead of acting on a partial answer — the name resolver treats "no
42 /// such UID" as "send the bare name", which a hidden fault must not
43 /// trigger (Codex P2 on #3568).
44 pub(crate) fn agent_def_get_strict(&self, id: &str) -> Result<Option<AgentDefinition>, String> {
45 if let Some(local) = self.agent_row_get(id).map_err(|e| e.to_string())? {
46 return Ok(Some(local));
47 }
48 self.shared_def_get(id)
49 .map_err(|e| format!("global definition registry: {e}"))
50 }
51
52 /// The global cross-channel definition for `id`, errors preserved.
53 fn shared_def_get(&self, id: &str) -> Result<Option<AgentDefinition>, crate::registry::DefStoreError> {
54 let Some(reg) = self.shared_def_registry() else {
55 return Ok(None);
56 };
57 Ok(reg
58 .get(id)?
59 .map(|record| super::super::def_registry_mirror::record_to_agent_definition(&record)))
60 }
61
62 /// List all agent definitions, **most-recently-used first**.
63 ///
64 /// Reads from the consolidated `db_agents` table, ordered by
65 /// `updated_at DESC` then `created_at ASC`. Every definition mutation
66 /// AND every instance lifecycle touch bumps `db_agents.updated_at`, so
67 /// recency on a row tracks the last time the agent was either edited or
68 /// launched.
69 ///
70 /// Result-set shape: every `db_agents` row is returned — templates
71 /// (`is_template = 1`) and user-clone projections (`is_template = 0`)
72 /// each appear once. `parent_id` is sourced from
73 /// `db_agents.parent_template_id`.
74 pub fn agent_def_list(&self) -> Result<Vec<AgentDefinition>, StoreError> {
75 // Local SQLite: this channel's templates (seeded) + its own user agents.
76 let local: Vec<AgentDefinition> = {
77 let conn = self.conn.lock().unwrap();
78 let mut stmt = conn.prepare(&format!("{AGENT_DEFINITION_SELECT} ORDER BY updated_at DESC, created_at ASC"))?;
79 let rows = stmt.query_map([], map_agent_definition_row)?;
80 let mut agents = Vec::new();
81 for row in rows {
82 agents.push(row?);
83 }
84 agents
85 };
86 // conn dropped. Overlay the GLOBAL cross-channel user-agent roster so
87 // an agent created in another channel appears here too. The global
88 // store wins for user rows (it's authoritative and holds cross-channel
89 // agents); templates (seeded — never in the global store) come from
90 // local SQLite. Falls back to SQLite-only when the global store is
91 // absent or unreadable. (P0.2c.)
92 let Some(reg) = self.shared_def_registry() else {
93 return Ok(local);
94 };
95 let global = match reg.list_active() {
96 Ok(recs) => recs,
97 Err(e) => {
98 tracing::warn!(error = %e, "def registry: global list failed, using SQLite only");
99 return Ok(local);
100 }
101 };
102 let mut by_id: std::collections::HashMap<String, AgentDefinition> =
103 local.into_iter().map(|d| (d.id.clone(), d)).collect();
104 for rec in &global {
105 let mut def = super::super::def_registry_mirror::record_to_agent_definition(rec);
106 // model_vendor_base_url is deliberately channel-local only —
107 // DefinitionRecordV1 doesn't carry it, so record_to_agent_definition
108 // always returns "" for it. Without this, the global overlay
109 // silently wiped a same-channel agent's override on every read
110 // (including agent.open's spawn-time resolution), making the
111 // whole feature a no-op in default single-instance operation —
112 // not just the genuinely-cross-channel case this limitation is
113 // documented for. Preserve the local row's value when one
114 // exists; only a truly cross-channel agent (no local row) sees
115 // the empty default. (reagent P0 on PR #2505.)
116 //
117 // memory_id needs the IDENTICAL treatment, for the identical
118 // reason (P0 fix, ReAgent review on PR #2587 round 6):
119 // DefinitionRecordV1 doesn't carry memory_id either (same
120 // documented gap, def_registry_mirror.rs), so
121 // record_to_agent_definition always returns "" for it too.
122 // Every local write auto-mirrors into the global registry
123 // (agent_def_insert -> registry_def_upsert), so this overlay
124 // fires for virtually every agent whenever a shared store is
125 // configured — the "normal case," not an edge case. Without
126 // this fix, agent_def_list() would silently zero memory_id on
127 // every read, defeating the bundle lookups that read it
128 // (bundle-scoped skills, the empty-provider
129 // fallback in resolve_effective_provider_id) and m0021's own
130 // memory_id-empty backfill filter (which would re-process
131 // every already-bound agent on every migration run, since it
132 // reads through this same function).
133 if let Some(existing) = by_id.get(&def.id) {
134 def.model_vendor_base_url = existing.model_vendor_base_url.clone();
135 def.memory_id = existing.memory_id.clone();
136 // conversation_visibility is the identical channel-local-only
137 // case (SPEC_MUXSPECT_CROSS_TIER_CONVERSATION_VISIBILITY_2026_08_21.md
138 // Phase B/C, jekt rules SPEC_JEKT_TRANSCRIPT_REQUEST_TIER_RULES_2026_08_22.md)
139 // — DefinitionRecordV1 doesn't carry it, so without this the
140 // global overlay would silently reset every agent's
141 // disclosure policy back to "private" on every list read,
142 // even though "private" is a safe fail-closed default (this
143 // preserves the ADMINISTRATOR'S actual configured choice,
144 // not just avoiding a crash).
145 def.conversation_visibility = existing.conversation_visibility.clone();
146 }
147 by_id.insert(def.id.clone(), def);
148 }
149 let mut result: Vec<AgentDefinition> = by_id.into_values().collect();
150 // Match the SQL ORDER BY: updated_at DESC, then created_at ASC.
151 result.sort_by(|a, b| {
152 b.updated_at
153 .cmp(&a.updated_at)
154 .then(a.created_at.cmp(&b.created_at))
155 });
156 Ok(result)
157 }
158
159 /// Count agent rows (used by seed engine to check if seeding is needed).
160 /// Reads from the consolidated `db_agents` table. The seed engine only
161 /// cares about `== 0` to decide "fresh database, seed templates".
162 pub fn agent_def_count(&self) -> Result<i64, StoreError> {
163 let conn = self.conn.lock().unwrap();
164 let count: i64 = conn.query_row(
165 "SELECT COUNT(*) FROM db_agents",
166 [],
167 |row| row.get(0),
168 )?;
169 Ok(count)
170 }
171
172 /// Whether a definition with `id` exists in the LOCAL channel's SQLite
173 /// (`db_agents`). Gates the cross-channel content/skills fallback: a
174 /// locally-known agent with genuinely empty content/skills must NOT
175 /// resurrect them from the global record. (reagent P1 on #1385.)
176 pub(in crate::backend::storage) fn agent_def_exists_local(&self, id: &str) -> Result<bool, StoreError> {
177 let conn = self.conn.lock().unwrap();
178 let exists: bool = conn.query_row(
179 "SELECT EXISTS(SELECT 1 FROM db_agents WHERE id = ?1)",
180 params![id],
181 |row| row.get(0),
182 )?;
183 Ok(exists)
184 }
185
186 /// Delete all seeded agents (is_seeded=1). Used by reseed to clear built-in agents.
187 ///
188 /// No project-instruction cleanup here, unlike `agent_def_delete`: this
189 /// removes template rows, and a template is never launched — observations
190 /// are recorded by `agent.open`, so a template has none to forget (Codex,
191 /// PR #3162).
192 pub fn agent_def_delete_seeded(&self) -> Result<usize, StoreError> {
193 // Consolidation Phase 3b (PR 2): only the template rows go. Every
194 // `is_template = 0` row — a user clone OR an agent launched from a
195 // template — is an agent in its own right and persists across a
196 // reseed, keeping its `parent_template_id` (templates have stable
197 // ids, so the lineage survives the re-insert).
198 let conn = self.conn.lock().unwrap();
199 Ok(conn.execute("DELETE FROM db_agents WHERE is_template=1", [])?)
200 }
201
202 /// Insert a new agent definition. Auto-derives slug from name if empty,
203 /// resolves collisions by appending `-2`, `-3`, etc., and mutates
204 /// `agent.slug` so the caller sees the resolved value (important
205 /// for handlers that serialize the struct back to the frontend
206 /// after insert).
207 ///
208 /// The collision check + insert run under a single mutex lock,
209 /// so this is race-safe against concurrent inserts on the same
210 /// connection.
211 pub fn agent_def_insert(&self, agent: &mut AgentDefinition) -> Result<(), StoreError> {
212 self.agent_def_insert_local_only(agent, None)?;
213 // Mirror into the global cross-channel definition store. Content +
214 // skills are inserted after the definition (separate calls), so this
215 // initial record is content-less; agent_content_set / agent_skill_*
216 // re-mirror with the full payload. (P0.2b.)
217 self.registry_def_upsert(&agent.id);
218 Ok(())
219 }
220
221 /// Insert into `db_agents` only — does NOT mirror to the global
222 /// registry. Used directly by `agent_def_insert` (which mirrors right
223 /// after) and by `agent_def_backfill_local_from_registry` (which must
224 /// NOT mirror — see that function's doc comment for why re-mirroring
225 /// immediately after this call would wipe the registry's real
226 /// content/skills).
227 ///
228 /// `updated_at_override`: `None` stamps `updated_at = created_at`
229 /// (the original, unchanged behavior for genuinely new definitions).
230 /// `Some(ts)` stamps `updated_at = ts` instead — used by the backfill
231 /// path to preserve the registry record's real `updated_at` rather
232 /// than resetting it.
233 ///
234 /// `is_seeded = 1` rows become `is_template = 1` (a template; bindings
235 /// stay empty). `is_seeded = 0` rows become `is_template = 0` with
236 /// `parent_template_id = parent_id` — same is_template/parent_template_id
237 /// derivation the old dual-write mirror used (frozen intent, no longer
238 /// a second write).
239 pub(in crate::backend::storage) fn agent_def_insert_local_only(
240 &self,
241 agent: &mut AgentDefinition,
242 updated_at_override: Option<i64>,
243 ) -> Result<(), StoreError> {
244 let conn = self.conn.lock().unwrap();
245 let base = if agent.slug.is_empty() {
246 derive_slug(&agent.name)
247 } else {
248 agent.slug.clone()
249 };
250 // Collision-resolve against every `db_agents` slug — the consolidated
251 // table surfaces definition slugs and template-instance projections
252 // alike, so a collision against an instance-derived row is caught here
253 // too. Shared with `instance_create` so the two cannot drift.
254 agent.slug = resolve_slug_collision(&conn, &base)?;
255 let stamped_updated_at = updated_at_override.unwrap_or(agent.created_at);
256 let is_template = if agent.is_seeded == 1 { 1_i64 } else { 0_i64 };
257 let parent_template_id = if agent.is_seeded == 1 { String::new() } else { agent.parent_id.clone() };
258 conn.execute(
259 "INSERT INTO db_agents (
260 id, name, icon, description,
261 is_template, parent_template_id,
262 provider, provider_flags, shell, environment,
263 agent_type, agent_bus_id, accounts,
264 auto_start, restart_on_crash, idle_timeout_minutes,
265 slug, branch_label, working_directory,
266 created_at, updated_at, is_seeded, user_hidden,
267 container_image, container_volumes, container_name,
268 use_ambient_login, model_vendor_base_url, auto_continue_enabled,
269 default_memory_id, conversation_visibility
270 ) VALUES (
271 ?1, ?2, ?3, ?4,
272 ?5, ?6,
273 ?7, ?8, ?9, ?10,
274 ?11, ?12, ?13,
275 ?14, ?15, ?16,
276 ?17, ?18, ?19,
277 ?20, ?21, ?22, ?23,
278 ?24, ?25, ?26,
279 ?27, ?28, ?29,
280 ?30, ?31
281 )",
282 params![
283 agent.id,
284 agent.name,
285 agent.icon,
286 agent.description,
287 is_template,
288 parent_template_id,
289 agent.provider,
290 agent.provider_flags,
291 agent.shell,
292 agent.environment,
293 agent.agent_type,
294 agent.agent_bus_id,
295 agent.accounts,
296 agent.auto_start,
297 agent.restart_on_crash,
298 agent.idle_timeout_minutes,
299 agent.slug,
300 agent.branch_label,
301 agent.working_directory,
302 agent.created_at,
303 // New definitions: updated_at == created_at, unless the
304 // caller supplied an override (backfill path preserving
305 // the registry record's real updated_at).
306 stamped_updated_at,
307 agent.is_seeded,
308 // Phase 2 (hide templates): new rows start visible. The
309 // user only hides via the explicit `agentdefhide` RPC,
310 // and the agent-seed re-sync forces user_hidden = 0 on
311 // any newly-added template id anyway, so honouring the
312 // caller-supplied value here is safe even when a stray
313 // 1 sneaks through.
314 agent.user_hidden,
315 agent.container_image,
316 agent.container_volumes,
317 agent.container_name,
318 agent.use_ambient_login,
319 agent.model_vendor_base_url,
320 agent.auto_continue_enabled,
321 agent.memory_id,
322 agent.conversation_visibility,
323 ],
324 )?;
325 // Reagent P1 on #1013 round 2: do NOT mutate the caller's
326 // `&mut AgentDefinition` here. The PR is supposed to be
327 // zero-behaviour-change; the previous version reflected
328 // `stamped_updated_at` back onto the caller, which is an
329 // observable mutation downstream callers may rely on remaining
330 // untouched. Callers that need the freshly-stamped value can
331 // re-fetch the row via the normal read path.
332 Ok(())
333 }
334
335 /// Materialize a local shadow of a registry-only definition — this
336 /// channel's `db_agents` row plus a best-effort local copy of
337 /// content/skills — so `instance_create`'s FK can succeed for an agent
338 /// whose definition exists cross-channel but was never created in THIS
339 /// channel's SQLite.
340 ///
341 /// Deliberately does NOT call `registry_def_upsert` (unlike
342 /// `agent_def_insert`, which does): the registry is already
343 /// authoritative for this record. `registry_def_upsert` rebuilds
344 /// the registry record from `agent_content_get_all_local`/
345 /// `agent_skill_list_local` — LOCAL-only reads, by design (see that
346 /// function's own comment) — so calling it immediately after this
347 /// insert-with-no-content-yet would overwrite the registry's real
348 /// content/skills with empty arrays. Content/skill rows are instead
349 /// copied here via direct INSERTs (bypassing `agent_content_set`/
350 /// `agent_skill_insert`, which each call `registry_def_refresh_if_mirrored`
351 /// themselves) so nothing re-mirrors mid-backfill.
352 ///
353 /// Copy failures for content/skills are logged and otherwise
354 /// ignored — the definition row alone satisfies the FK, and reads
355 /// still resolve content/skills correctly via the existing
356 /// cross-channel fallback even if this best-effort local copy is
357 /// incomplete. A slug collision against an unrelated local agent
358 /// will rename the local slug (existing `agent_def_insert_local_only`
359 /// behavior) — low-probability and non-fatal, since slug isn't the
360 /// FK target.
361 pub(in crate::backend::storage) fn agent_def_backfill_local_from_registry(
362 &self,
363 record: &crate::registry::DefinitionRecord,
364 ) -> Result<(), StoreError> {
365 let mut def = super::super::def_registry_mirror::record_to_agent_definition(record);
366 self.agent_def_insert_local_only(&mut def, Some(record.data.updated_at))?;
367
368 let conn = self.conn.lock().unwrap();
369 for c in &record.data.content {
370 if let Err(e) = conn.execute(
371 "INSERT INTO db_agent_content (agent_id, content_type, content, updated_at)
372 VALUES (?1, ?2, ?3, ?4)
373 ON CONFLICT(agent_id, content_type) DO UPDATE SET content=?3, updated_at=?4",
374 params![def.id, c.content_type, c.content, record.data.updated_at],
375 ) {
376 tracing::warn!(
377 def_id = %def.id,
378 content_type = %c.content_type,
379 error = %e,
380 "instance_create backfill: local content copy failed (non-fatal)"
381 );
382 }
383 }
384 for s in &record.data.skills {
385 if let Err(e) = conn.execute(
386 "INSERT INTO db_agent_skills (id, agent_id, name, trigger, skill_type, description, content, created_at)
387 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
388 params![
389 s.id,
390 def.id,
391 s.name,
392 s.trigger,
393 s.skill_type,
394 s.description,
395 s.content,
396 record.data.updated_at,
397 ],
398 ) {
399 tracing::warn!(
400 def_id = %def.id,
401 skill = %s.name,
402 error = %e,
403 "instance_create backfill: local skill copy failed (non-fatal)"
404 );
405 }
406 }
407 Ok(())
408 }
409
410 /// Atomic check-then-insert for `agent.define`.
411 ///
412 /// Looks up an existing user-owned definition by name (case-insensitive)
413 /// or derived slug under the SAME mutex guard that protects the INSERT —
414 /// preventing TOCTOU when two concurrent `agent.define` calls arrive for
415 /// the same name.
416 ///
417 /// Returns:
418 /// - `Ok(Some(def))` — an existing row matched; `agent` was NOT inserted.
419 /// - `Ok(None)` — no match; `agent` was inserted and `agent.slug` now
420 /// holds the collision-resolved slug.
421 pub fn agent_def_find_or_insert(
422 &self,
423 agent: &mut AgentDefinition,
424 ) -> Result<Option<AgentDefinition>, StoreError> {
425 let name_lower = agent.name.trim().to_lowercase();
426 let derived_slug = derive_slug(agent.name.trim());
427
428 {
429 let conn = self.conn.lock().unwrap();
430
431 // Check under the same lock to close the TOCTOU window.
432 // `is_seeded = 0` already excludes templates; it does NOT
433 // exclude a template-launch row (also `is_seeded = 0`, no
434 // `db_agent_definitions` counterpart) — same as before this
435 // read moved to `db_agents`, since the legacy table never held
436 // launch rows to exclude in the first place.
437 let mut stmt = conn.prepare(&format!(
438 "{AGENT_DEFINITION_SELECT}
439 WHERE (lower(trim(name)) = ?1 OR slug = ?2)
440 AND is_seeded = 0
441 ORDER BY CASE WHEN lower(trim(name)) = ?1 THEN 0 ELSE 1 END
442 LIMIT 1"
443 ))?;
444 let mut rows = stmt.query_map(
445 params![name_lower, derived_slug],
446 map_agent_definition_row,
447 )?;
448 if let Some(row) = rows.next() {
449 return Ok(Some(row?));
450 }
451 // Drop borrows on `conn` before proceeding to the insert.
452 drop(rows);
453 drop(stmt);
454
455 // Not found — insert under the same lock.
456 let base = if agent.slug.is_empty() {
457 derive_slug(&agent.name)
458 } else {
459 agent.slug.clone()
460 };
461 let mut candidate = base.clone();
462 let mut n: u32 = 2;
463 loop {
464 let count: i64 = conn.query_row(
465 "SELECT COUNT(*) FROM db_agents WHERE slug = ?1",
466 params![candidate],
467 |row| row.get(0),
468 )?;
469 if count == 0 {
470 break;
471 }
472 candidate = format!("{}-{}", base, n);
473 n += 1;
474 }
475 agent.slug = candidate;
476 let is_template = if agent.is_seeded == 1 { 1_i64 } else { 0_i64 };
477 let parent_template_id = if agent.is_seeded == 1 { String::new() } else { agent.parent_id.clone() };
478 conn.execute(
479 "INSERT INTO db_agents (
480 id, name, icon, description,
481 is_template, parent_template_id,
482 provider, provider_flags, shell, environment,
483 agent_type, agent_bus_id, accounts,
484 auto_start, restart_on_crash, idle_timeout_minutes,
485 slug, branch_label, working_directory,
486 created_at, updated_at, is_seeded, user_hidden,
487 container_image, container_volumes, container_name,
488 use_ambient_login, model_vendor_base_url, auto_continue_enabled,
489 default_memory_id, conversation_visibility
490 ) VALUES (
491 ?1, ?2, ?3, ?4,
492 ?5, ?6,
493 ?7, ?8, ?9, ?10,
494 ?11, ?12, ?13,
495 ?14, ?15, ?16,
496 ?17, ?18, ?19,
497 ?20, ?21, ?22, ?23,
498 ?24, ?25, ?26,
499 ?27, ?28, ?29,
500 ?30, ?31
501 )",
502 params![
503 agent.id,
504 agent.name,
505 agent.icon,
506 agent.description,
507 is_template,
508 parent_template_id,
509 agent.provider,
510 agent.provider_flags,
511 agent.shell,
512 agent.environment,
513 agent.agent_type,
514 agent.agent_bus_id,
515 agent.accounts,
516 agent.auto_start,
517 agent.restart_on_crash,
518 agent.idle_timeout_minutes,
519 agent.slug,
520 agent.branch_label,
521 agent.working_directory,
522 agent.created_at,
523 agent.created_at, // updated_at = created_at for new rows
524 agent.is_seeded,
525 agent.user_hidden,
526 agent.container_image,
527 agent.container_volumes,
528 agent.container_name,
529 agent.use_ambient_login,
530 agent.model_vendor_base_url,
531 agent.auto_continue_enabled,
532 agent.memory_id,
533 agent.conversation_visibility,
534 ],
535 )?;
536 }
537 // Do NOT mutate `agent.updated_at` here — matches
538 // `agent_def_insert_local_only`'s own "zero-behaviour-change"
539 // posture (reagent P1 on #1013 round 2): callers that need the
540 // freshly-stamped value re-fetch the row via the normal read path.
541 // Mirror the freshly-defined agent into the global store (agent.define
542 // path). Without this a define-created agent stays channel-local until
543 // a later edit. (codex P2 on #1385.)
544 self.registry_def_upsert(&agent.id);
545
546 Ok(None)
547 }
548
549 /// Set the `user_hidden` flag on a single agent definition. Phase 2
550 /// of the two-tier picker (Q2 Decision Y). Returns:
551 /// `Ok(true)` — row updated.
552 /// `Ok(false)` — no row with that id exists.
553 /// `Err(...)` — the row exists but is NOT a seeded template
554 /// (`is_seeded != 1`). User-owned definitions go
555 /// through `agent_def_delete`, not hide.
556 ///
557 /// Does NOT bump `updated_at`: hide is a per-user view-state flag,
558 /// not a definition-content edit. Keeps `updated_at` faithful to the
559 /// agent's payload (the manifest re-sync compares `description` etc.
560 /// against the canonical row).
561 pub fn agent_def_set_hidden(&self, id: &str, hidden: bool) -> Result<bool, StoreError> {
562 let conn = self.conn.lock().unwrap();
563 // Only seeded templates (`is_template = 1`) may flip the hide flag.
564 // Templates carry `is_template = 1` and are the only rows allowed
565 // to flip the hide flag; folded user-clone-def projections and
566 // template-instance projections (both `is_template = 0`) reject.
567 let is_template: i64 = match conn.query_row(
568 "SELECT is_template FROM db_agents WHERE id = ?1",
569 params![id],
570 |row| row.get(0),
571 ) {
572 Ok(v) => v,
573 Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(false),
574 Err(e) => return Err(StoreError::Sqlite(e)),
575 };
576 if is_template != 1 {
577 return Err(StoreError::Other(format!(
578 "agent_def_set_hidden: {id} is not a seeded template (is_template={is_template}); \
579 user-owned definitions must use delete/archive paths, not hide"
580 )));
581 }
582 // Templates are seeded; they do NOT go to the global cross-channel
583 // def store, so no registry mirror here.
584 let rows = conn.execute(
585 "UPDATE db_agents SET user_hidden = ?1 WHERE id = ?2 AND is_template = 1",
586 params![if hidden { 1_i64 } else { 0_i64 }, id],
587 )?;
588 Ok(rows > 0)
589 }
590
591 /// Whether this channel's store has its own row for definition `id`, as
592 /// opposed to the definition only resolving through the global registry
593 /// overlay. Only a local row can have its `default_memory_id` set.
594 pub fn agent_def_has_local_row(&self, id: &str) -> Result<bool, StoreError> {
595 let conn = self.conn.lock().unwrap();
596 let found: bool = conn.query_row(
597 "SELECT EXISTS(SELECT 1 FROM db_agents WHERE id = ?1)",
598 params![id],
599 |r| r.get(0),
600 )?;
601 Ok(found)
602 }
603
604 /// Set `db_agents.default_memory_id` on a LOCAL agent — but only if
605 /// it's currently empty. Exists because `agent_def_update`'s SET clause
606 /// deliberately never touches this column (readonly-after-creation, see
607 /// its own field doc comment) — this is the one narrow, explicit path
608 /// allowed to set it, and only the FIRST time, for `m0021`'s backfill
609 /// and definition-time provisioning to use. Returns `Ok(true)` if the
610 /// row existed and had an empty `default_memory_id` (write applied),
611 /// `Ok(false)` if the row doesn't exist locally OR already has a
612 /// non-empty value (no-op, not an error — matches the "set once"
613 /// semantic silently rather than requiring every caller to pre-check).
614 ///
615 /// LOCAL ONLY, deliberately — a cross-channel (global-registry-only)
616 /// definition can't be reached this way today because
617 /// `DefinitionRecordV1` doesn't carry `memory_id` yet (same accepted gap
618 /// as `model_vendor_base_url`, see `def_registry_mirror.rs`). Callers
619 /// backfilling across the whole agent population must check
620 /// `agent_def_get` first and skip (with a log) anything that only
621 /// resolves via the global registry.
622 pub fn agent_def_set_memory_id_if_empty(
623 &self,
624 id: &str,
625 memory_id: &str,
626 ) -> Result<bool, StoreError> {
627 let conn = self.conn.lock().unwrap();
628 let rows = conn.execute(
629 "UPDATE db_agents SET default_memory_id = ?1 WHERE id = ?2 AND default_memory_id = ''",
630 params![memory_id, id],
631 )?;
632 Ok(rows > 0)
633 }
634
635 /// The provider's default vendor, or `"custom"` when the agent has a
636 /// non-empty `model_vendor_base_url` override — same rule the
637 /// frontend's `resolveEffectiveVendor`
638 /// (`frontend/app/view/agent/providers/catalog.ts`) already uses for
639 /// the dual-icon vendor badge. Falls back to the provider id itself
640 /// if the provider isn't in the registry (e.g. a stale/custom
641 /// provider string), same fallback the frontend uses.
642 ///
643 /// Derived wherever it's needed rather than stored: a Claude agent
644 /// pointed at a custom endpoint is `"custom"`, not `"anthropic"`
645 /// (#2587).
646 pub(crate) fn resolve_effective_vendor(provider: &str, model_vendor_base_url: &str) -> String {
647 if !model_vendor_base_url.trim().is_empty() {
648 return "custom".to_string();
649 }
650 crate::backend::providers::get_provider(provider)
651 .and_then(|p| p.supported_vendors.first().copied())
652 .unwrap_or(provider)
653 .to_string()
654 }
655
656 /// The harness/provider id `agent` runs: its own `provider`. A bundle
657 /// carries no harness (SPEC_AGENT_BUNDLE_FORMAT_V0_3_2026_10_05.md §3.1);
658 /// the agent owns it, read-only after creation (`updateagent` and
659 /// `agent.define` refuse a change). The bound bundle's `provider` is a
660 /// hint and is consulted only when the agent has none at all, a
661 /// definition the §3.3 migration couldn't reach; there it is the value
662 /// the agent has always run with.
663 ///
664 /// **Call this on the EFFECTIVE identity/memory store
665 /// (`AppState.id_store`), never on the per-channel `mstore`** — that
666 /// fallback reads the bundle, which lives in the shared store when one is
667 /// configured. One shared implementation for spawn (`agent_open.rs`),
668 /// the credential gate (`identity/resolver/inject.rs`), clone, fork and
669 /// export, so they can't disagree.
670 pub fn resolve_effective_provider_id(&self, agent: &AgentDefinition) -> String {
671 if !agent.provider.is_empty() || agent.memory_id.is_empty() {
672 return agent.provider.clone();
673 }
674 match self.bundle_get(&agent.memory_id) {
675 Ok(Some(b)) => b.provider,
676 _ => String::new(),
677 }
678 }
679
680 /// Provision a fresh, dedicated ABF bundle for a NEW agent definition —
681 /// the definition-time half of
682 /// `docs/specs/ARCHITECTURE_MANDATORY_ABF_RETHINK_2026_08_14.md` §3.2
683 /// (m0021 is the backfill half, for agents that already existed before
684 /// this shipped). The bundle carries no harness: the agent owns its
685 /// provider (SPEC_AGENT_BUNDLE_FORMAT_V0_3_2026_10_05.md §3.2); the agent
686 /// is passed for the bundle's name.
687 ///
688 /// **Call this on the EFFECTIVE identity/memory store
689 /// (`AppState.id_store`), never on the per-channel `mstore` directly.**
690 /// Bundles live in the shared store when one is configured (the normal
691 /// case) — every real bundle-read path (`listmemories`/`getmemory`/
692 /// the Memory editor/the bundle-summary panel) reads through
693 /// `id_store`, so a bundle created via `mstore.bundle_upsert`
694 /// directly would be written to a different SQLite file and be
695 /// invisible everywhere else (P1 finding, Codex review on PR #2587,
696 /// live in the shipped code this fixes: `agent_def_provision_and_
697 /// bind_bundle`'s callers all used to invoke this method ON `mstore`).
698 ///
699 /// Does NOT mutate `agent` or touch `db_agent_definitions` — returns
700 /// the new bundle's id; callers set `agent.memory_id` themselves before
701 /// their own insert (so the existing `agent_def_insert*` INSERT
702 /// statements, which already include `memory_id`, pick it up with no
703 /// separate write).
704 pub fn bundle_provision_for_new_agent(
705 &self,
706 agent: &AgentDefinition,
707 now: i64,
708 ) -> Result<String, StoreError> {
709 let bundle_id = uuid::Uuid::new_v4().to_string();
710 let name = self.resolve_unique_bundle_name(&format!("{} — ABF", agent.name))?;
711 let bundle = super::super::bundles::Bundle {
712 id: bundle_id.clone(),
713 name,
714 description: String::new(),
715 is_blank: false,
716 is_global: false,
717 // No harness: the agent owns it (SPEC_AGENT_BUNDLE_FORMAT_V0_3_2026_10_05.md §3.2).
718 provider: String::new(),
719 model: String::new(),
720 instructions: String::new(),
721 instructions_by_provider: "{}".to_string(),
722 context_files: "[]".to_string(),
723 mcp_servers: "[]".to_string(),
724 skills: "[]".to_string(),
725 sort_order: 0,
726 created_at: now,
727 updated_at: now,
728 is_system: false,
729 };
730 self.bundle_upsert(&bundle)?;
731 Ok(bundle_id)
732 }
733
734 /// Resolve a `db_bundles.name` guaranteed not to collide with an
735 /// existing row — that column is `TEXT NOT NULL UNIQUE`, but only
736 /// `AgentDefinition.slug` is guaranteed unique, not the display
737 /// `name`, so a naive `"{agent.name} — ABF"` collides whenever two
738 /// agents share a display name. Appends a numeric suffix on
739 /// collision, same shape `agent_def_insert_local_only` already uses
740 /// for slug collisions.
741 ///
742 /// P1 fix (2026-08-15, ReAgent review on PR #2587 round 4): before
743 /// this, a collision here silently left a runtime-provisioned agent
744 /// unbound (the caller's best-effort error handling swallowed it) and
745 /// — worse — unconditionally ABORTED `m0021`'s entire backfill loop
746 /// via `?` on the very first collision, permanently stalling backfill
747 /// for every agent processed after it, on every subsequent boot,
748 /// until the underlying name collision was manually resolved.
749 pub(crate) fn resolve_unique_bundle_name(&self, base_name: &str) -> Result<String, StoreError> {
750 let existing_names: std::collections::HashSet<String> =
751 self.bundle_list()?.into_iter().map(|b| b.name).collect();
752 if !existing_names.contains(base_name) {
753 return Ok(base_name.to_string());
754 }
755 let mut n: u32 = 2;
756 loop {
757 let candidate = format!("{base_name} ({n})");
758 if !existing_names.contains(&candidate) {
759 return Ok(candidate);
760 }
761 n += 1;
762 }
763 }
764
765 /// Provision + bind a fresh bundle to an ALREADY-INSERTED agent
766 /// definition — the two-step, post-insert counterpart to
767 /// `bundle_provision_for_new_agent` above. Deliberately separate from
768 /// the insert itself (rather than setting `memory_id` on the struct
769 /// before calling `agent_def_insert*`/`agent_def_find_or_insert`):
770 /// `agent_def_find_or_insert` in particular uses its `AgentDefinition`
771 /// argument as BOTH the lookup key and the conditional-insert payload,
772 /// so a bundle built up-front would go to waste (leaked, unbound) on
773 /// every `if_exists=skip`/`update` call against an already-existing
774 /// name — and `agent.define` is meant to be called repeatedly/
775 /// idempotently. Binding after the fact, only once the caller has
776 /// confirmed a row was genuinely freshly inserted, avoids that leak.
777 ///
778 /// Best-effort by design (matches `createagent`'s own color-assignment
779 /// comment: a failure here shouldn't fail agent creation) — logs and
780 /// returns on either step's failure rather than propagating, since the
781 /// agent row itself is already durably committed by the time this
782 /// runs, and a still-unbound agent is exactly the pre-existing
783 /// `memory_id=''` state `m0021` already knows how to backfill later.
784 ///
785 /// Takes `&mut AgentDefinition` and writes the new bundle id back into
786 /// `agent.memory_id` on success — same "caller's struct reflects what
787 /// landed" convention `agent_def_update` documents just below — so RPC
788 /// handlers that serialize `agent` straight back to the caller (e.g.
789 /// `createagent`, `importagentfromclaw`) return the real value instead
790 /// of the empty string the struct held before this call.
791 ///
792 /// `self` (the definition store, i.e. `mstore`) and `bundle_store`
793 /// (the effective identity/memory store, i.e. `AppState.id_store`) are
794 /// deliberately two SEPARATE parameters, not the same store used for
795 /// both writes — see `bundle_provision_for_new_agent`'s own doc
796 /// comment for why (P1 fix, Codex review on PR #2587: they used to be
797 /// the same store, silently writing every provisioned bundle
798 /// somewhere the rest of the app can't see it).
799 pub fn agent_def_provision_and_bind_bundle(
800 &self,
801 bundle_store: &Store,
802 agent: &mut AgentDefinition,
803 now: i64,
804 ) {
805 let bundle_id = match bundle_store.bundle_provision_for_new_agent(agent, now) {
806 Ok(id) => id,
807 Err(e) => {
808 tracing::warn!(agent_id = %agent.id, error = %e, "agent_def_provision_and_bind_bundle: bundle create failed (non-fatal)");
809 return;
810 }
811 };
812 match self.agent_def_set_memory_id_if_empty(&agent.id, &bundle_id) {
813 Ok(true) => {
814 // So the agent's other channels bind this bundle instead of
815 // minting their own (m0021).
816 crate::backend::agent_bundle_sidecar::record(&agent.id, &bundle_id);
817 agent.memory_id = bundle_id
818 }
819 Ok(false) => {}
820 Err(e) => {
821 tracing::warn!(agent_id = %agent.id, bundle_id = %bundle_id, error = %e, "agent_def_provision_and_bind_bundle: bind failed (non-fatal)");
822 }
823 }
824 }
825
826 /// Update an existing agent definition (all fields except id, created_at, is_seeded, `parent_id`).
827 /// `parent_id` is NOT updatable post-insert — it describes the agent's
828 /// lineage; re-parenting is done by creating a new fork, not mutating the
829 /// original.
830 ///
831 /// `branch_label` IS written here (unlike `parent_id`) — this storage-
832 /// layer function persists whatever is on `agent`. Immutability for most
833 /// callers is enforced one layer up, at the RPC handler: `updateagent`
834 /// always passes back `old.branch_label.clone()` unchanged, so it's a
835 /// no-op for that caller. `renameagentdefinitiontitle` is the one
836 /// deliberate exception that supplies a real change — see
837 /// `crates/srv/src/server/agent_handlers/template.rs` and
838 /// docs/specs/SPEC_PANE_TAB_STRIP_COMPACT_SIZING_AND_RENAME_2026_07_22.md §4.
839 ///
840 /// Self-stamps `updated_at` with the current time and writes it back into
841 /// `agent.updated_at`, so the caller's struct (e.g. an RPC response body)
842 /// reflects exactly what landed in the database.
843 pub fn agent_def_update(&self, agent: &mut AgentDefinition) -> Result<bool, StoreError> {
844 let now = agentmux_common::time::now_ms();
845 let rows = {
846 let conn = self.conn.lock().unwrap();
847 conn.execute(
848 "UPDATE db_agents SET name=?1, icon=?2, provider=?3, description=?4,
849 working_directory=?5, shell=?6, provider_flags=?7, auto_start=?8,
850 restart_on_crash=?9, idle_timeout_minutes=?10,
851 agent_type=?11, environment=?12, agent_bus_id=?13, accounts=?14, updated_at=?15,
852 container_image=?17, container_volumes=?18, container_name=?19,
853 use_ambient_login=?20, branch_label=?21, model_vendor_base_url=?22,
854 auto_continue_enabled=?23, conversation_visibility=?24
855 WHERE id=?16",
856 params![
857 agent.name,
858 agent.icon,
859 agent.provider,
860 agent.description,
861 agent.working_directory,
862 agent.shell,
863 agent.provider_flags,
864 agent.auto_start,
865 agent.restart_on_crash,
866 agent.idle_timeout_minutes,
867 agent.agent_type,
868 agent.environment,
869 agent.agent_bus_id,
870 agent.accounts,
871 now,
872 agent.id,
873 agent.container_image,
874 agent.container_volumes,
875 agent.container_name,
876 agent.use_ambient_login,
877 agent.branch_label,
878 agent.model_vendor_base_url,
879 agent.auto_continue_enabled,
880 agent.conversation_visibility,
881 ],
882 )?
883 };
884 // Reflect the persisted timestamp back to the caller's struct so an
885 // RPC response carries the fresh value, not the pre-update one.
886 agent.updated_at = now;
887 if rows > 0 {
888 // Mirror the updated definition into the global store. (P0.2b.)
889 self.registry_def_upsert(&agent.id);
890 }
891 // Cross-channel edit: an agent surfaced only via the global overlay has
892 // no local SQLite row, so the UPDATE affected 0 rows. Apply the edit to
893 // the global record directly (preserving its content/skills) so editing
894 // a cross-channel agent isn't silently dropped with a "not found" error
895 // — symmetric with the unconditional cross-channel delete. (reagent P1
896 // on #1385.)
897 let updated_global = if rows == 0 {
898 self.registry_def_update_definition_fields(agent)
899 } else {
900 false
901 };
902 Ok(rows > 0 || updated_global)
903 }
904
905 /// Delete a agent definition by id. Returns true if a row was deleted.
906 pub fn agent_def_delete(&self, id: &str) -> Result<bool, StoreError> {
907 // Consolidation Phase 3b (PR 2): a user agent launched from this
908 // template keeps its own `db_agents` row (it is an agent, not a
909 // launch of the template), the same way user clones always survived
910 // deleting their template — deleting `id` here removes only its own
911 // row. The JSON registry mirror for the deleted row is covered by
912 // `registry_def_retire` below.
913 let (rows, scope) = {
914 let conn = self.conn.lock().unwrap();
915 // Read BEFORE the DELETE — afterwards there is no row to ask.
916 // See `scope_for_row` for what this decides and why.
917 let scope = Self::scope_for_row(&conn, id);
918 // Identity M4a: also read before the DELETE.
919 tombstone_key_names(&conn, id)?;
920 purge_name_keyed_keys(&conn, id, self.wan_identity().as_deref())?;
921 let rows = conn.execute("DELETE FROM db_agents WHERE id=?1", params![id])?;
922 // Unconditional, same convergence rule as the registry sweep
923 // below (codex P2 on PR #3262): if a previous attempt committed
924 // the `db_agents` DELETE and then failed partway through the
925 // dependent purge, the retry sees `rows == 0` — gating here
926 // would strand that agent's credentials and signing keys
927 // permanently, with no UI path left to try again. Every
928 // statement in the purge is an idempotent DELETE, so running it
929 // for an id with no rows costs a few no-op statements.
930 purge_agent_dependents(&conn, id, self.token_index().as_deref())?;
931 (rows, scope)
932 };
933 // Tombstone the global definition record so another channel's stale
934 // SQLite can't resurrect this deleted user agent — AND so an agent
935 // that exists in THIS channel only via the global overlay (no local
936 // SQLite row, rows == 0) is actually deletable instead of reappearing
937 // on the next agent_def_list. (P0.2b + codex P1 on #1385.)
938 let global_retired = self.registry_def_retire(id);
939 // Unconditional, deliberately.
940 //
941 // Gating this on `rows > 0` was the original bug's second half: the
942 // instance registry is host-global while `db_agents` is
943 // per-channel, so a cross-channel agent — one this channel only ever
944 // saw through the global overlay — deletes with `rows == 0` and
945 // still has a registry record another channel wrote. That record
946 // stayed active with its definition now tombstoned, which
947 // `listrecentsessions` still renders as a row (no provider, so no
948 // icon, and "(missing definition)") — the delete looked like it
949 // half-worked.
950 //
951 // `rows > 0 || global_retired` isn't enough either (codex P2 on
952 // PR #3262): if an earlier attempt retired the definition but failed
953 // to remove a registry file — or another channel wrote a stale
954 // record after this process's startup reconcile — a RETRY sees
955 // `rows == 0` AND `global_retired == false` (the tombstone already
956 // exists), so it would skip the sweep and the ghost row would
957 // survive every further Delete until restart. Sweeping
958 // unconditionally makes Delete converge: the sweep is a no-op for an
959 // id with no records, so there is nothing to gate on in the first
960 // place.
961 self.purge_agent_side_effects(id, "agent_def_delete", scope);
962 Ok(rows > 0 || global_retired)
963 }
964
965 /// The [`RecordScope`] every registry operation on `id` must use.
966 ///
967 /// **Read this before adding another registry call.** The wide scope
968 /// matches records by `definition_id`, and a TEMPLATE's id is the
969 /// `definition_id` of every legacy, never-re-keyed launch record
970 /// belonging to a REAL agent launched from it — so a wide match on a
971 /// template deletes / retires / renames those agents' records along
972 /// with the template, making them vanish from the picker. ReAgent found
973 /// exactly that omission at four separate call sites on PR #3262
974 /// (`agent_def_delete`, `instance_delete`, `instance_set_hidden`,
975 /// `instance_rename`), which is why the rule lives here rather than as
976 /// a fourth hand-written `if`.
977 ///
978 /// An id with no row at all is NOT a template: the global definition
979 /// store only holds `is_seeded == 0` user agents, so a cross-channel id
980 /// is always an agent — which is what lets the delete paths still sweep
981 /// on a retry after the local row is already gone.
982 ///
983 /// Takes the connection rather than `&self` because every caller needs
984 /// this read in the SAME critical section as its own UPDATE/DELETE —
985 /// otherwise the row could change kind in between. A `&self` wrapper
986 /// existed briefly and was dead on arrival for exactly that reason
987 /// (ReAgent P2 on PR #3262).
988 pub(in crate::backend::storage) fn scope_for_row(conn: &rusqlite::Connection, id: &str) -> RecordScope {
989 let is_template = conn
990 .query_row(
991 "SELECT is_template FROM db_agents WHERE id = ?1",
992 params![id],
993 |r| r.get::<_, i64>(0),
994 )
995 .map(|v| v != 0)
996 .unwrap_or(false);
997 if is_template {
998 RecordScope::FileKeyOnly
999 } else {
1000 RecordScope::Agent
1001 }
1002 }
1003
1004 /// Purge this store's rows keyed to a deleted agent, without touching
1005 /// `db_agents` itself. For the OTHER physical database an agent has
1006 /// rows in: `agent_def_delete` covers the object store it runs against,
1007 /// and the `deleteagent` handler calls this on `state.identity_store`,
1008 /// which is where the live `db_agent_identity_links` /
1009 /// `db_agent_credentials` / native-memory rows actually are
1010 /// (`SPEC_IDENTITY_STORE_SPLIT_2026_08_17.md`). See
1011 /// [`purge_agent_dependents`] for the table list and why one list
1012 /// serves both stores. Returns rows removed.
1013 pub fn agent_dependents_purge(&self, id: &str) -> Result<usize, StoreError> {
1014 let conn = self.conn.lock().unwrap();
1015 purge_agent_dependents(&conn, id, self.token_index().as_deref())
1016 }
1017
1018 /// The non-SQLite half of deleting an agent row: observations keyed to
1019 /// it, and its records in the host-global instance registry. Shared by
1020 /// [`Self::agent_def_delete`] and [`Self::instance_delete`] — the two
1021 /// names one deletion is reached by since the definition flip (see
1022 /// `instance_delete`'s own doc comment). Best-effort throughout:
1023 /// SQLite has already committed, and neither side failing is a reason
1024 /// to report the delete as failed. `caller` only labels the logs.
1025 ///
1026 /// `scope` must come from `scope_for_row`, read
1027 /// BEFORE the row was deleted — afterwards there is nothing left to ask.
1028 pub(in crate::backend::storage) fn purge_agent_side_effects(&self, id: &str, caller: &'static str, scope: RecordScope) {
1029 // Project-instruction observations are keyed by agent id with no
1030 // foreign key (they record files this agent READS, which is not a
1031 // relationship SQLite can enforce), so nothing else would remove
1032 // them and a future agent reusing this id would inherit somebody
1033 // else's baseline — every file reporting `unchanged` against
1034 // observations that were never made about it. Cleaned up here,
1035 // beside the row it belongs to, rather than left to each caller
1036 // (ReAgent, PR #3162).
1037 if let Err(e) = self.project_instructions_forget(id) {
1038 tracing::warn!(
1039 agent_def_id = %id, caller, error = %e,
1040 "agent delete: project-instruction observations left behind"
1041 );
1042 }
1043 if let Some(reg) = self.registry() {
1044 // An AGENT's records are matched by either key — see
1045 // `Registry::hard_delete_for_agent` for why the file key and the
1046 // record's own `definition_id` can disagree, and what a missed
1047 // record looks like in the picker.
1048 //
1049 // A TEMPLATE's are matched by file key only. A template's id can
1050 // legitimately be the `definition_id` of a legacy,
1051 // never-re-keyed launch record belonging to a real agent
1052 // launched from it, and the wide match would delete that agent's
1053 // record along with the template — making agents disappear from
1054 // the picker instead of the one thing the user asked to delete
1055 // (ReAgent P1 round 3 on PR #3262;
1056 // `agent_def_delete_removes_only_its_own_registry_file` is the
1057 // protection, and
1058 // `deleting_a_template_spares_a_legacy_launch_record_pointing_at_it`
1059 // pins the legacy shape that test doesn't construct).
1060 match reg.hard_delete_for_agent(id, scope) {
1061 Ok(0) => {}
1062 Ok(removed) => tracing::debug!(
1063 agent_def_id = %id, caller, removed,
1064 "registry: purged instance records for deleted agent"
1065 ),
1066 Err(e) => tracing::warn!(
1067 agent_def_id = %id, caller, error = %e,
1068 "registry: failed to mirror agent delete"
1069 ),
1070 }
1071 }
1072 }
1073
1074 /// One-shot grandfather pass for the layer-3 ambient-login opt-in
1075 /// (m0017, spec §2.4 of SPEC_ACCOUNT_DELETE_DEAUTH_LAYERS_2_4_2026_07_14.md).
1076 ///
1077 /// Agents WITHOUT any `db_agent_identity_links` row at migration time
1078 /// were de-facto ambient users → `use_ambient_login = 1`; agents WITH
1079 /// links opted into managed accounts → `0` (honest failure is the new
1080 /// behavior for them). `linked_agent_ids` comes from the SHARED store
1081 /// (the live links table); this method writes `db_agents` — the
1082 /// definition flip's only remaining caller of this is `m0017` on an
1083 /// install upgrading through the whole migration chain for the first
1084 /// time, where `db_agents` is already the sole live source. Returns
1085 /// (rows set to 1, rows set to 0).
1086 pub fn agents_grandfather_ambient_login(
1087 &self,
1088 linked_agent_ids: &std::collections::HashSet<String>,
1089 ) -> Result<(usize, usize), StoreError> {
1090 let conn = self.conn.lock().unwrap();
1091 // Set everyone ambient first, then flip the linked set back to the
1092 // fail-by-default 0 — two passes instead of a dynamic IN() list.
1093 let ambient_defs = conn.execute("UPDATE db_agents SET use_ambient_login = 1", [])?;
1094 let mut linked_rows = 0usize;
1095 for id in linked_agent_ids {
1096 linked_rows += conn.execute(
1097 "UPDATE db_agents SET use_ambient_login = 0 WHERE id = ?1",
1098 params![id],
1099 )?;
1100 }
1101 Ok((ambient_defs.saturating_sub(linked_rows), linked_rows))
1102 }
1103
1104 // AgentContent / AgentSkill / AgentHistory CRUD live in
1105 // `super::content` / `super::skills` / `super::history` —
1106 // each adds an `impl Store {}` block to this type.
1107}