Skip to main content

agentmux_srv/backend/
operator_config_seed.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Operator Config seed engine: keeps AgentMux's own shipped Global Memory
5//! entries (`is_system=1` rows — see docs/specs/SPEC_GLOBAL_MEMORY_
6//! SYSTEM_TIER_2026_08_24.md for the isolation, docs/specs/SPEC_SYSTEM_
7//! TIER_GLOBAL_MEMORY_SEEDING_2026_09_15.md for this module's own design)
8//! in sync with a compiled-in manifest, every startup — not a one-time
9//! seed like `skill_seed.rs`/`mcp_seed.rs`, because stale Operator Config
10//! (describing a since-changed MuxAPI) would actively mislead an agent
11//! rather than merely being absent.
12//!
13//! The reseed decision (§3.3 of the spec above) is ownership-based, not a
14//! field diff like `agent_seed.rs`'s own `reseed_if_needed`: a human can
15//! edit an Operator Config entry today via Memory → Global's
16//! editor (`GlobalMemoryFullView`), and the *entire* value of an entry is
17//! its content, so
18//! there is no separate "identity field" to diff against safely. Instead
19//! `Store::bundle_reseed_system_if_owned` looks at the latest `db_bundle_
20//! versions` row for each manifest id — if `written_by` is this module's
21//! own reserved identity (`WRITTEN_BY`), it's safe to overwrite; if it's
22//! anything else (an `armory-ui` human edit), the manifest update is
23//! skipped and logged rather than clobbering it. That check-and-write
24//! happens atomically at the `Store` layer, not as two separate calls
25//! composed here — see that method's own doc comment (ReAgent P1, PR
26//! #3244, a check-then-act race in an earlier version of this module).
27//!
28//! "In sync" also covers removal: `prune_entries_removed_from_manifest`
29//! deletes any seeder-owned row whose manifest entry a later AgentMux
30//! release dropped or renamed, so a retired Operator Config note doesn't
31//! keep being injected into every future agent forever (Codex P2, PR
32//! #3244) — mirroring `agent_seed.rs`'s own "remove a seeded agent not in
33//! the manifest" behavior. A human-edited row meets the same fate as
34//! everywhere else in this module: left alone, never deleted out from
35//! under the human who edited it.
36
37use std::sync::Arc;
38
39use serde::Deserialize;
40
41use super::storage::bundles::Bundle;
42use super::storage::store::Store;
43use super::storage::{BundleReseedOutcome, StoreError};
44
45/// The reserved `written_by` identity this seeder stamps on every version it
46/// writes. Never used by any other caller — an agent's own write stamps its
47/// real `AGENTMUX_AGENT_ID`, a human edit in Memory stamps
48/// `"armory-ui"` (`bundle.rs`'s `upsertsystemmemory` handler, via `bundle_
49/// upsert_system_with_version`) — so seeing this exact value on the latest
50/// version is what tells `bundle_reseed_system_if_owned` that nothing but
51/// AgentMux itself has touched the row since.
52pub const WRITTEN_BY: &str = "agentmux-operator-config-seed";
53
54const SEED_MANIFEST: &str = include_str!("../../operator-config-seed.json");
55
56#[derive(Debug, Deserialize)]
57struct SeedManifest {
58    /// The primary reseed decision is still content-hash-based, not this
59    /// number (see this module's own doc comment) — but `version` also
60    /// travels into each write's `source_detail` as a monotonic generation
61    /// guard: `bundle_reseed_system_if_owned` refuses to let a write whose
62    /// generation is lower than what's already recorded win, so an older
63    /// AgentMux build's own (older) manifest can never downgrade a row a
64    /// newer build already seeded on a shared store.db (Codex P2, PR #3244).
65    version: u32,
66    entries: Vec<SeedEntry>,
67}
68
69#[derive(Debug, Deserialize)]
70struct SeedEntry {
71    id: String,
72    name: String,
73    instructions: String,
74    /// The agent kinds this entry is delivered to: `"host"` and/or
75    /// `"container"` (see [`agent_kind`]). Absent or empty means every agent.
76    /// Lives only in the manifest, not in `db_bundles`: a new column would
77    /// bump the schema version, and `check_schema_compat` then locks every
78    /// older build out of a shared store.db
79    /// (SPEC_LAUNCH_CONTEXT_WORKSPACE_RULE_AND_STARTUP_FILES_2026_09_30.md
80    /// §3.2). A local edit of the entry keeps its id, so it keeps its target.
81    #[serde(default)]
82    agent_types: Vec<String>,
83}
84
85/// The kind of agent a Global Memory entry can be targeted at: `"container"`
86/// for a Docker agent, `"host"` for everything else (`agent_type` is
87/// `"host"`, `"standalone"` or `"container"`; the block's `agentMode` carries
88/// the same value, defaulting to `"host"`).
89pub fn agent_kind(agent_mode: &str) -> &'static str {
90    if agent_mode == "container" { "container" } else { "host" }
91}
92
93/// The `agentMode` of the agent on `block_id`, for the delivery paths that
94/// know only the block (the SessionStart hook, `globalmemory:sections`).
95/// `"host"` when the block or the key is missing — srv's default for
96/// `agentMode` everywhere else.
97pub fn agent_mode_of_block(mstore: &Store, block_id: &str) -> String {
98    match mstore.get::<crate::backend::obj::Block>(block_id) {
99        Ok(Some(block)) => crate::backend::obj::meta_get_string(&block.meta, "agentMode", "host"),
100        Ok(None) => "host".to_string(),
101        Err(e) => {
102            tracing::warn!(block_id, error = %e, "operator config: block lookup failed — delivering as a host agent");
103            "host".to_string()
104        }
105    }
106}
107
108/// The [`agent_kind`] of the agent on `block_id`, or `None` when the block
109/// can't be read, for a caller that would rather say nothing than guess (the
110/// LAN fleet feed's `agent_kinds`).
111pub fn agent_kind_of_block(mstore: &Store, block_id: &str) -> Option<&'static str> {
112    let block = mstore.get::<crate::backend::obj::Block>(block_id).ok()??;
113    Some(agent_kind(&crate::backend::obj::meta_get_string(&block.meta, "agentMode", "host")))
114}
115
116/// The embedded manifest, parsed once for [`global_bundles_for_agent`].
117/// `None` if it doesn't parse — `embedded_manifest_parses` pins that it does.
118fn embedded_manifest() -> Option<&'static SeedManifest> {
119    static MANIFEST: std::sync::OnceLock<Option<SeedManifest>> = std::sync::OnceLock::new();
120    MANIFEST.get_or_init(|| serde_json::from_str(SEED_MANIFEST).ok()).as_ref()
121}
122
123/// Global Memory narrowed to what an agent of `agent_mode` is given: drops a
124/// system entry whose manifest entry targets other agent kinds. Workspace
125/// entries and system entries this build's manifest doesn't know always stay.
126///
127/// Every place that composes Global Memory for an agent calls this before
128/// `format_global_bundle_block`/`global_bundle_sections` — the startup file
129/// (both launch paths), the SessionStart hook and `globalmemory:sections` —
130/// so they stay the same block byte for byte.
131pub fn global_bundles_for_agent(bundles: Vec<Bundle>, agent_mode: &str) -> Vec<Bundle> {
132    match embedded_manifest() {
133        Some(manifest) => filter_for_kind(bundles, manifest, agent_kind(agent_mode)),
134        None => bundles,
135    }
136}
137
138fn filter_for_kind(bundles: Vec<Bundle>, manifest: &SeedManifest, kind: &str) -> Vec<Bundle> {
139    bundles
140        .into_iter()
141        .filter(|b| {
142            !b.is_system
143                || manifest
144                    .entries
145                    .iter()
146                    .find(|e| e.id == b.id)
147                    .is_none_or(|e| e.agent_types.is_empty() || e.agent_types.iter().any(|t| t == kind))
148        })
149        .collect()
150}
151
152/// Run Operator Config seeding on startup. Unconditional, every boot, no
153/// feature flag — called from `bootstrap/stores.rs` next to `agent_seed`'s own
154/// call.
155pub fn auto_seed_on_startup(store: &Arc<Store>) {
156    let manifest: SeedManifest = match serde_json::from_str(SEED_MANIFEST) {
157        Ok(m) => m,
158        Err(e) => {
159            tracing::error!("operator config seed: failed to parse manifest: {e}");
160            return;
161        }
162    };
163    seed_from_manifest(store, &manifest);
164}
165
166/// The actual seed pass, separated from `auto_seed_on_startup` purely so
167/// tests can exercise it against a hand-built `SeedManifest` instead of the
168/// embedded JSON.
169fn seed_from_manifest(store: &Arc<Store>, manifest: &SeedManifest) {
170    // Prune BEFORE seeding, not after: when a release renames a manifest
171    // entry's id while keeping the same (UNIQUE) display `name`, the old
172    // id's row must be gone before the new id's INSERT is attempted, or
173    // that insert fails on the name collision with no retry — leaving the
174    // renamed entry absent until the NEXT startup happens to prune the old
175    // row first (Codex P2, PR #3244).
176    prune_entries_removed_from_manifest(store, manifest);
177
178    let mut tally = SeedTally::default();
179
180    // A collision here can be purely an ordering artifact WITHIN this same
181    // manifest, independent of pruning: entry B wants the display name
182    // entry A is about to release via its own update (A kept its id, only
183    // its `name` changed), but B happens to appear before A in the
184    // manifest array, so B's INSERT hits A's still-current name and fails.
185    //
186    // A single retry pass only resolves a 2-entry swap — a longer
187    // dependency CHAIN (C wants a name B is about to release, B wants a
188    // name A is about to release, A releases first) can still leave an
189    // entry stranded after exactly one retry, depending on which order the
190    // chain happens to be walked in. So: keep retrying the still-failing
191    // subset until a full pass makes literally zero progress (nothing in
192    // it succeeded) — each successful entry in a pass can only unblock
193    // entries later in the SAME OR A SUBSEQUENT pass, so a pass with no
194    // successes at all means every remaining failure is a real collision
195    // (e.g. an unrelated user bundle already owns the name), not an
196    // ordering artifact. Bounded by the number of entries: each pass either
197    // shrinks the pending set by at least one or terminates the loop.
198    // Codex P2, PR #3244.
199    let mut pending: Vec<&SeedEntry> = manifest.entries.iter().collect();
200    loop {
201        let mut still_pending = Vec::new();
202        let mut made_progress = false;
203        for entry in pending {
204            if seed_and_tally(store, entry, manifest.version, &mut tally, false) {
205                made_progress = true;
206            } else {
207                still_pending.push(entry);
208            }
209        }
210        if still_pending.is_empty() {
211            break;
212        }
213        if !made_progress {
214            // No entry in this pass succeeded — every remaining failure is
215            // a genuine collision, not something a further pass could
216            // resolve. Log each as such.
217            for entry in still_pending {
218                seed_and_tally(store, entry, manifest.version, &mut tally, true);
219            }
220            break;
221        }
222        pending = still_pending;
223    }
224
225    tally.log(manifest.version);
226}
227
228/// Tallies from a startup seed pass, logged once at the end (`log`).
229#[derive(Default)]
230struct SeedTally {
231    created: usize,
232    updated: usize,
233    unchanged: usize,
234    skipped_local: usize,
235    skipped_older: usize,
236    skipped_collision: usize,
237}
238
239impl SeedTally {
240    fn log(&self, manifest_version: u32) {
241        let Self { created, updated, unchanged, skipped_local, skipped_older, skipped_collision } = *self;
242        if created + updated + skipped_local + skipped_older + skipped_collision > 0 {
243            tracing::info!(
244                "operator config seed: manifest v{manifest_version}: {created} created, {updated} updated, \
245                 {unchanged} unchanged, {skipped_local} skipped (local edit), \
246                 {skipped_older} skipped (older generation), {skipped_collision} skipped (collision)",
247            );
248        }
249    }
250}
251
252/// Seeds one entry and records its outcome in `tally`. Returns `false` only
253/// for a collision-type error (`Err`) — the caller's signal to retry once
254/// more after the full first pass, in case another entry processed later
255/// would have released the colliding name. `is_retry` only affects
256/// logging: a first-pass failure is silent (might still resolve), a
257/// second-pass failure is a real, logged collision.
258fn seed_and_tally(store: &Arc<Store>, entry: &SeedEntry, manifest_version: u32, tally: &mut SeedTally, is_retry: bool) -> bool {
259    match seed_one(store, entry, manifest_version) {
260        Ok(BundleReseedOutcome::Created) => { tally.created += 1; true }
261        Ok(BundleReseedOutcome::Updated) => { tally.updated += 1; true }
262        Ok(BundleReseedOutcome::Unchanged) => { tally.unchanged += 1; true }
263        Ok(BundleReseedOutcome::SkippedLocalEdit) => {
264            tally.skipped_local += 1;
265            tracing::info!(
266                id = %entry.id,
267                "operator config seed: skipping — entry has a local (non-AgentMux) edit, not overwriting"
268            );
269            true
270        }
271        Ok(BundleReseedOutcome::SkippedOlderGeneration) => {
272            tally.skipped_older += 1;
273            tracing::info!(
274                id = %entry.id,
275                manifest_version,
276                "operator config seed: skipping — a newer manifest generation already seeded this row \
277                 (this build's own manifest is older; likely a different AgentMux version sharing this store)"
278            );
279            true
280        }
281        Ok(BundleReseedOutcome::SkippedRetired) => {
282            tally.skipped_older += 1;
283            tracing::info!(
284                id = %entry.id,
285                manifest_version,
286                "operator config seed: skipping — a newer manifest generation already retired this id \
287                 (this build's own manifest still lists it; not resurrecting it)"
288            );
289            true
290        }
291        Err(e) => {
292            if is_retry {
293                tally.skipped_collision += 1;
294                tracing::warn!(
295                    id = %entry.id,
296                    name = %entry.name,
297                    error = %e,
298                    "operator config seed: skipping entry due to upsert error (name collision?)"
299                );
300            }
301            false
302        }
303    }
304}
305
306/// A row for an entry AgentMux itself removed (or renamed to a new id) in a
307/// later release must not linger as a seeder-owned `is_system` row forever —
308/// it would keep being injected into every future agent indefinitely,
309/// exactly the "stale Operator Config actively misleads an agent" failure
310/// mode this whole seeding mechanism exists to prevent (Codex P2, PR #3244).
311/// Only prunes rows still owned by this seeder AND whose own recorded
312/// generation is <= this process's manifest version (`bundle_delete_system_
313/// if_owned` checks both atomically) — on a machine sharing store.db across
314/// multiple AgentMux builds/versions, an OLDER build's manifest simply
315/// doesn't mention an entry a NEWER build already added; without the
316/// generation gate, the older build would misread "not in my manifest" as
317/// "removed" and delete the newer build's own addition. Same "never clobber
318/// a local edit" posture as the reseed path for a human-edited row: left in
319/// place, orphaned from the manifest but not clobbered, for a human to
320/// clean up. Codex P2, PR #3244.
321fn prune_entries_removed_from_manifest(store: &Arc<Store>, manifest: &SeedManifest) {
322    let manifest_ids: std::collections::HashSet<&str> =
323        manifest.entries.iter().map(|e| e.id.as_str()).collect();
324
325    let existing_ids = match store.bundle_list_system_ids() {
326        Ok(ids) => ids,
327        Err(e) => {
328            tracing::error!("operator config seed: failed to list existing system entries for pruning: {e}");
329            return;
330        }
331    };
332
333    let mut pruned = 0usize;
334    for id in existing_ids {
335        if manifest_ids.contains(id.as_str()) {
336            continue;
337        }
338        match store.bundle_delete_system_if_owned(&id, WRITTEN_BY, manifest.version) {
339            Ok(true) => {
340                pruned += 1;
341                tracing::info!(id = %id, "operator config seed: pruned an entry no longer in the manifest");
342            }
343            Ok(false) => {
344                tracing::info!(
345                    id = %id,
346                    "operator config seed: an entry is no longer in this manifest but was left in place \
347                     (either it has a local edit, or its own generation is newer than this manifest's — \
348                     likely a different, newer AgentMux build sharing this store)"
349                );
350            }
351            Err(e) => {
352                tracing::warn!(id = %id, error = %e, "operator config seed: failed to prune an entry no longer in the manifest");
353            }
354        }
355    }
356    if pruned > 0 {
357        tracing::info!("operator config seed: pruned {pruned} entries no longer in the manifest");
358    }
359}
360
361fn seed_one(store: &Arc<Store>, entry: &SeedEntry, manifest_version: u32) -> Result<BundleReseedOutcome, StoreError> {
362    let now = agentmux_common::time::now_ms();
363    let bundle = Bundle {
364        id: entry.id.clone(),
365        name: entry.name.clone(),
366        description: String::new(),
367        is_blank: false,
368        is_global: true,
369        provider: String::new(),
370        model: String::new(),
371        instructions: entry.instructions.clone(),
372        instructions_by_provider: "{}".to_string(),
373        context_files: "[]".to_string(),
374        mcp_servers: "[]".to_string(),
375        skills: "[]".to_string(),
376        sort_order: 0,
377        created_at: now,
378        updated_at: now,
379        is_system: true,
380    };
381
382    // manifest_version travels in source_detail so bundle_reseed_system_if_
383    // owned can refuse to let an older AgentMux build's own (older) manifest
384    // downgrade a newer build's already-seeded content on a shared store.db
385    // (Codex P2, PR #3244).
386    let source_detail = format!(r#"{{"manifest_version":{manifest_version}}}"#);
387    store.bundle_reseed_system_if_owned(&bundle, WRITTEN_BY, "agentmux_operator_config_seed", &source_detail)
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    fn test_store() -> Arc<Store> {
395        Arc::new(Store::open_in_memory().unwrap())
396    }
397
398    fn entry(id: &str, name: &str, instructions: &str) -> SeedEntry {
399        SeedEntry {
400            id: id.to_string(),
401            name: name.to_string(),
402            instructions: instructions.to_string(),
403            agent_types: Vec::new(),
404        }
405    }
406
407    #[test]
408    fn embedded_manifest_parses() {
409        let manifest: SeedManifest = serde_json::from_str(SEED_MANIFEST).expect("valid JSON");
410        assert!(!manifest.entries.is_empty());
411        for e in &manifest.entries {
412            assert!(!e.id.is_empty());
413            assert!(!e.name.is_empty());
414            assert!(!e.instructions.trim().is_empty());
415        }
416    }
417
418    #[test]
419    fn fresh_install_creates_every_entry() {
420        let store = test_store();
421        let e = entry("op-1", "Operator One", "body one");
422        let outcome = seed_one(&store, &e, 1).unwrap();
423        assert!(matches!(outcome, BundleReseedOutcome::Created));
424
425        let bundle = store.bundle_get("op-1").unwrap().expect("row exists");
426        assert!(bundle.is_system);
427        assert!(bundle.is_global);
428        assert_eq!(bundle.instructions, "body one");
429
430        let history = store.bundle_version_list("op-1").unwrap();
431        assert_eq!(history.len(), 1);
432        assert_eq!(history[0].written_by, WRITTEN_BY);
433    }
434
435    #[test]
436    fn unchanged_manifest_is_a_no_op() {
437        let store = test_store();
438        let e = entry("op-1", "Operator One", "body one");
439        seed_one(&store, &e, 1).unwrap();
440
441        let outcome = seed_one(&store, &e, 1).unwrap();
442        assert!(matches!(outcome, BundleReseedOutcome::Unchanged));
443        assert_eq!(store.bundle_version_list("op-1").unwrap().len(), 1, "no new version on no-op");
444    }
445
446    /// Codex P2, PR #3244: content being unchanged must not mean the
447    /// recorded generation stays stale — a newer manifest generation whose
448    /// text happens to match what's already stored (e.g. a revert to an
449    /// earlier generation's exact wording) still needs to record that a
450    /// newer generation has now confirmed this content, or a concurrently
451    /// running build on a generation in between could later pass the
452    /// generation check against the stale recorded value and downgrade it.
453    #[test]
454    fn unchanged_content_still_advances_the_recorded_generation() {
455        let store = test_store();
456        seed_one(&store, &entry("op-1", "Operator One", "the text"), 1).unwrap();
457
458        // Generation 3's manifest text happens to be identical to
459        // generation 1's (a revert).
460        let outcome = seed_one(&store, &entry("op-1", "Operator One", "the text"), 3).unwrap();
461        assert!(matches!(outcome, BundleReseedOutcome::Unchanged), "content itself really is unchanged");
462        assert_eq!(store.bundle_version_list("op-1").unwrap().len(), 2, "the generation bump must still be recorded as a version");
463
464        // A generation-2 build, with its own different text, must not be
465        // able to downgrade generation 3's already-confirmed content just
466        // because 2 > the ORIGINAL (now-stale) recorded generation of 1.
467        let outcome = seed_one(&store, &entry("op-1", "Operator One", "generation 2's own different text"), 2).unwrap();
468        assert!(matches!(outcome, BundleReseedOutcome::SkippedOlderGeneration));
469        assert_eq!(store.bundle_get("op-1").unwrap().unwrap().instructions, "the text");
470    }
471
472    #[test]
473    fn changed_manifest_content_overwrites_when_seeder_owns_it() {
474        let store = test_store();
475        let e1 = entry("op-1", "Operator One", "body one");
476        seed_one(&store, &e1, 1).unwrap();
477
478        let e2 = entry("op-1", "Operator One", "body TWO");
479        let outcome = seed_one(&store, &e2, 2).unwrap();
480        assert!(matches!(outcome, BundleReseedOutcome::Updated));
481
482        let bundle = store.bundle_get("op-1").unwrap().unwrap();
483        assert_eq!(bundle.instructions, "body TWO");
484        assert_eq!(store.bundle_version_list("op-1").unwrap().len(), 2);
485    }
486
487    /// Codex P2, PR #3244: a compatible-schema OLDER build sharing this
488    /// store might still run a pre-#3244 `upsertsystemmemory` handler that
489    /// calls the unversioned `bundle_upsert_system` directly — changing the
490    /// live row's content without ever touching `db_bundle_versions`. The
491    /// latest version would then (wrongly) still show the seeder as the
492    /// last writer. seed_one must notice the live content no longer
493    /// matches what that version claims, and refuse to overwrite it as if
494    /// nothing had happened.
495    #[test]
496    fn unrecorded_edit_from_an_unversioned_writer_is_never_overwritten() {
497        let store = test_store();
498        seed_one(&store, &entry("op-1", "Operator One", "seeded body"), 1).unwrap();
499
500        // Simulate an older build's unversioned write path: bundle_upsert_
501        // system changes db_bundles directly, recording no version at all.
502        let mut edited = store.bundle_get("op-1").unwrap().unwrap();
503        edited.instructions = "edited by an older, unversioned build".to_string();
504        store.bundle_upsert_system(&edited).unwrap();
505
506        // db_bundle_versions still shows the seeder as the last writer —
507        // the whole point of this test.
508        let history = store.bundle_version_list("op-1").unwrap();
509        assert_eq!(history.len(), 1, "the unversioned write left no trace in the version table");
510        assert_eq!(history[0].written_by, WRITTEN_BY);
511
512        let outcome = seed_one(&store, &entry("op-1", "Operator One", "a real manifest update"), 2).unwrap();
513        assert!(matches!(outcome, BundleReseedOutcome::SkippedLocalEdit));
514
515        let bundle = store.bundle_get("op-1").unwrap().unwrap();
516        assert_eq!(bundle.instructions, "edited by an older, unversioned build", "the unrecorded edit must survive");
517    }
518
519    /// ReAgent P2, PR #3244: a row that exists but has ZERO version history
520    /// at all (possible via the still-present unversioned bundle_upsert_
521    /// system, with no prior seed_one call to have created one) must be
522    /// left alone by seed_one, same conservative policy bundle_delete_
523    /// system_if_owned already implements for the identical edge case —
524    /// not silently overwritten by falling through to the unconditional
525    /// upsert.
526    #[test]
527    fn row_with_zero_version_history_is_never_overwritten() {
528        let store = test_store();
529        // Created via the plain, unversioned path directly — no seed_one
530        // call precedes this, so db_bundle_versions has nothing for "op-1".
531        let bundle = Bundle {
532            id: "op-1".to_string(),
533            name: "Operator One".to_string(),
534            description: String::new(),
535            is_blank: false,
536            is_global: true,
537            provider: String::new(),
538            model: String::new(),
539            instructions: "content with no version history".to_string(),
540            instructions_by_provider: "{}".to_string(),
541            context_files: "[]".to_string(),
542            mcp_servers: "[]".to_string(),
543            skills: "[]".to_string(),
544            sort_order: 0,
545            created_at: 0,
546            updated_at: 0,
547            is_system: true,
548        };
549        store.bundle_upsert_system(&bundle).unwrap();
550        assert!(store.bundle_version_list("op-1").unwrap().is_empty(), "precondition: no version history at all");
551
552        let outcome = seed_one(&store, &entry("op-1", "Operator One", "a manifest update"), 1).unwrap();
553        assert!(matches!(outcome, BundleReseedOutcome::SkippedLocalEdit));
554        assert_eq!(store.bundle_get("op-1").unwrap().unwrap().instructions, "content with no version history");
555    }
556
557    /// Codex P2, PR #3244: two AgentMux builds sharing store.db (a supported
558    /// configuration — see "Multiple Instances Run in Parallel" in this
559    /// repo's own CLAUDE.md) must not let an older build's own older
560    /// manifest downgrade a row a newer build already seeded, even though
561    /// both share the same `written_by` seeder identity.
562    #[test]
563    fn older_manifest_generation_never_downgrades_a_newer_seed() {
564        let store = test_store();
565        let e_v3 = entry("op-1", "Operator One", "content from generation 3");
566        seed_one(&store, &e_v3, 3).unwrap();
567
568        // A different (older) AgentMux build, running its own older
569        // manifest (generation 1), starts up and reseeds the same row.
570        let e_v1 = entry("op-1", "Operator One", "content from generation 1");
571        let outcome = seed_one(&store, &e_v1, 1).unwrap();
572        assert!(matches!(outcome, BundleReseedOutcome::SkippedOlderGeneration));
573
574        let bundle = store.bundle_get("op-1").unwrap().unwrap();
575        assert_eq!(bundle.instructions, "content from generation 3", "newer generation's content must survive");
576    }
577
578    /// The load-bearing guarantee (spec §3.3, §5 test plan): a human edit
579    /// through Memory must survive the next startup's reseed even
580    /// though the manifest content differs from what's stored. Simulated via
581    /// `bundle_upsert_system_with_version(..., "armory-ui", ...)` — the exact
582    /// call `upsertsystemmemory` (`bundle.rs`) now makes for a real UI save —
583    /// not a raw `bundle_version_insert`, so this test actually exercises the
584    /// bug ReAgent/Codex flagged on PR #3244: an earlier revision of the real
585    /// handler called the non-versioned `bundle_upsert_system`, which left no
586    /// version behind at all, so the ownership check never saw the edit and
587    /// would have silently overwritten it on the next reseed.
588    #[test]
589    fn locally_edited_entry_is_never_overwritten() {
590        let store = test_store();
591        let e1 = entry("op-1", "Operator One", "body one");
592        seed_one(&store, &e1, 1).unwrap();
593
594        // Simulate a human editing this system entry via the real Memory pane
595        // save path (`upsertsystemmemory` -> `bundle_upsert_system_with_version`).
596        let edited = Bundle {
597            id: "op-1".to_string(),
598            name: "Operator One".to_string(),
599            description: String::new(),
600            is_blank: false,
601            is_global: true,
602            provider: String::new(),
603            model: String::new(),
604            instructions: "human-edited body".to_string(),
605            instructions_by_provider: "{}".to_string(),
606            context_files: "[]".to_string(),
607            mcp_servers: "[]".to_string(),
608            skills: "[]".to_string(),
609            sort_order: 0,
610            created_at: 0,
611            updated_at: 0,
612            is_system: true,
613        };
614        store.bundle_upsert_system_with_version(&edited, "armory-ui", "human", "{}").unwrap();
615
616        let e2 = entry("op-1", "Operator One", "AgentMux wants to change this");
617        let outcome = seed_one(&store, &e2, 2).unwrap();
618        assert!(matches!(outcome, BundleReseedOutcome::SkippedLocalEdit));
619
620        let bundle = store.bundle_get("op-1").unwrap().unwrap();
621        assert_eq!(bundle.instructions, "human-edited body", "seed_one made no db_bundles write on skip");
622    }
623
624    /// Codex P2, PR #3244: an entry AgentMux itself removes from a later
625    /// manifest (renamed id, retired content, ...) must not linger forever
626    /// as a seeder-owned row, still injected into every future agent.
627    #[test]
628    fn seeder_owned_entry_removed_from_manifest_is_pruned() {
629        let store = test_store();
630        seed_one(&store, &entry("op-old", "Old Entry", "old content"), 1).unwrap();
631        assert!(store.bundle_get("op-old").unwrap().is_some());
632
633        // This release's manifest no longer mentions "op-old" at all.
634        let manifest = SeedManifest { version: 2, entries: vec![entry("op-new", "New Entry", "new content")] };
635        // seed_one for the still-present entry, then run the prune pass
636        // directly (mirrors what auto_seed_on_startup does after its loop).
637        seed_one(&store, &manifest.entries[0], manifest.version).unwrap();
638        prune_entries_removed_from_manifest(&store, &manifest);
639
640        assert!(store.bundle_get("op-old").unwrap().is_none(), "seeder-owned, manifest-removed entry must be pruned");
641        assert!(store.bundle_get("op-new").unwrap().is_some(), "entries still in the manifest are untouched");
642    }
643
644    /// ReAgent P1, PR #3244: the delete-side mirror of `unrecorded_edit_
645    /// from_an_unversioned_writer_is_never_overwritten` — a compatible-
646    /// schema OLDER build's unversioned write leaves `written_by` stale at
647    /// the seeder's identity in `db_bundle_versions`, even though the live
648    /// row is now a human's edit. Pruning must notice the live content
649    /// doesn't match what that stale version claims and refuse to delete —
650    /// deleting an unrecorded edit is strictly worse than overwriting one.
651    #[test]
652    fn prune_does_not_delete_an_unrecorded_edit_from_an_unversioned_writer() {
653        let store = test_store();
654        seed_one(&store, &entry("op-old", "Old Entry", "seeded content"), 1).unwrap();
655
656        // Simulate an older build's unversioned write path.
657        let mut edited = store.bundle_get("op-old").unwrap().unwrap();
658        edited.instructions = "edited by an older, unversioned build".to_string();
659        store.bundle_upsert_system(&edited).unwrap();
660
661        // This release's manifest no longer mentions "op-old" — an
662        // ordinary prune would otherwise delete it outright.
663        let manifest = SeedManifest { version: 2, entries: vec![] };
664        prune_entries_removed_from_manifest(&store, &manifest);
665
666        let bundle = store.bundle_get("op-old").unwrap();
667        assert!(bundle.is_some(), "an unrecorded edit must never be deleted");
668        assert_eq!(bundle.unwrap().instructions, "edited by an older, unversioned build");
669    }
670
671    /// The same "never clobber a local edit" guarantee applies to pruning:
672    /// a human-edited entry that AgentMux later removes from its manifest
673    /// must be left in place, not silently deleted out from under them.
674    #[test]
675    fn locally_edited_entry_removed_from_manifest_is_not_pruned() {
676        let store = test_store();
677        seed_one(&store, &entry("op-old", "Old Entry", "old content"), 1).unwrap();
678
679        let edited = {
680            let mut b = store.bundle_get("op-old").unwrap().unwrap();
681            b.instructions = "a human edit worth keeping".to_string();
682            b
683        };
684        store.bundle_upsert_system_with_version(&edited, "armory-ui", "human", "{}").unwrap();
685
686        let manifest = SeedManifest { version: 2, entries: vec![] };
687        prune_entries_removed_from_manifest(&store, &manifest);
688
689        let bundle = store.bundle_get("op-old").unwrap();
690        assert!(bundle.is_some(), "a human-edited entry must survive even after its manifest entry is removed");
691        assert_eq!(bundle.unwrap().instructions, "a human edit worth keeping");
692    }
693
694    /// ReAgent/Codex P2, PR #3244: pruning deliberately retires a row — an
695    /// OLDER build's own manifest (which still lists the retired id) must
696    /// not resurrect it just because the row is gone. Without the
697    /// retirement tombstone, "no db_bundles row" reads exactly like "never
698    /// existed," which is precisely the bug this test guards against.
699    #[test]
700    fn seed_one_does_not_resurrect_an_entry_retired_by_a_newer_generation() {
701        let store = test_store();
702        seed_one(&store, &entry("op-old", "Old Entry", "old content"), 1).unwrap();
703
704        // A newer build (generation 5) prunes it — its own manifest no
705        // longer lists "op-old" at all.
706        assert!(store.bundle_delete_system_if_owned("op-old", WRITTEN_BY, 5).unwrap());
707        assert!(store.bundle_get("op-old").unwrap().is_none());
708
709        // An older build, still on generation 3, whose OWN manifest still
710        // lists "op-old", starts up and tries to seed it.
711        let outcome = seed_one(&store, &entry("op-old", "Old Entry", "old content"), 3).unwrap();
712        assert!(matches!(outcome, BundleReseedOutcome::SkippedRetired));
713        assert!(store.bundle_get("op-old").unwrap().is_none(), "a retired entry must not be resurrected by an older manifest");
714    }
715
716    /// The mirror case: once this process's own manifest generation has
717    /// caught up to (or passed) the retirement generation, and its own
718    /// manifest legitimately re-adds the id, creation must proceed
719    /// normally — a tombstone blocks resurrection by a STALE manifest, not
720    /// a deliberate re-introduction by a manifest that's caught up.
721    #[test]
722    fn seed_one_recreates_a_retired_entry_once_caller_generation_catches_up() {
723        let store = test_store();
724        seed_one(&store, &entry("op-old", "Old Entry", "old content"), 1).unwrap();
725        assert!(store.bundle_delete_system_if_owned("op-old", WRITTEN_BY, 5).unwrap());
726
727        let outcome = seed_one(&store, &entry("op-old", "Reintroduced Entry", "new content"), 5).unwrap();
728        assert!(matches!(outcome, BundleReseedOutcome::Created));
729        assert_eq!(store.bundle_get("op-old").unwrap().unwrap().instructions, "new content");
730    }
731
732    /// An ORDINARY human delete in Memory (`bundle_delete_system`,
733    /// via `deletesystemmemory` — records no version at all) must not leave
734    /// a tombstone behind; a manually deleted entry still comes back
735    /// exactly as before this fix (`deleted_entry_is_recreated_...` below).
736    /// Only a generation-aware prune (`bundle_delete_system_if_owned`)
737    /// leaves a tombstone.
738    #[test]
739    fn seed_one_still_recreates_after_an_ordinary_human_delete_no_tombstone() {
740        let store = test_store();
741        seed_one(&store, &entry("op-old", "Old Entry", "old content"), 1).unwrap();
742        assert!(store.bundle_delete_system("op-old").unwrap());
743
744        let outcome = seed_one(&store, &entry("op-old", "Old Entry", "old content"), 1).unwrap();
745        assert!(matches!(outcome, BundleReseedOutcome::Created));
746    }
747
748    /// Codex P2, PR #3244: on a machine sharing store.db across two
749    /// AgentMux builds, an OLDER build's prune pass must not delete an
750    /// entry a NEWER build already added just because the older build's
751    /// own manifest doesn't mention it yet — "not in my manifest" only
752    /// means "removed" when this process's own generation has caught up to
753    /// (or passed) the row's.
754    #[test]
755    fn prune_does_not_delete_an_entry_from_a_newer_generation_it_does_not_know_about() {
756        let store = test_store();
757        // A newer AgentMux build (generation 5) seeds an entry its own
758        // manifest introduced.
759        seed_one(&store, &entry("op-new", "New Entry", "added in gen 5"), 5).unwrap();
760
761        // An older build, still on generation 3, starts up. Its own
762        // manifest has no idea "op-new" exists.
763        let older_manifest = SeedManifest { version: 3, entries: vec![] };
764        prune_entries_removed_from_manifest(&store, &older_manifest);
765
766        assert!(
767            store.bundle_get("op-new").unwrap().is_some(),
768            "an older build must not delete a newer build's own addition"
769        );
770    }
771
772    /// The mirror-image case: once a build's own manifest generation has
773    /// genuinely caught up to (or passed) an entry's generation, and that
774    /// entry is still absent from its manifest, it really was removed —
775    /// pruning must proceed.
776    #[test]
777    fn prune_still_deletes_once_this_process_generation_has_caught_up() {
778        let store = test_store();
779        seed_one(&store, &entry("op-old", "Old Entry", "added in gen 1"), 1).unwrap();
780
781        let later_manifest = SeedManifest { version: 3, entries: vec![] };
782        prune_entries_removed_from_manifest(&store, &later_manifest);
783
784        assert!(store.bundle_get("op-old").unwrap().is_none(), "a genuinely-removed entry must still be pruned");
785    }
786
787    /// Codex P2, PR #3244: `bundle_delete_system` removes only `db_bundles`,
788    /// leaving the append-only version history behind. A reseed must notice
789    /// the row itself is gone and recreate it, not take the "unchanged"
790    /// shortcut just because the stale history's content hash still matches.
791    #[test]
792    fn deleted_entry_is_recreated_even_when_manifest_content_is_unchanged() {
793        let store = test_store();
794        let e = entry("op-1", "Operator One", "body one");
795        seed_one(&store, &e, 1).unwrap();
796        assert!(store.bundle_delete_system("op-1").unwrap(), "row deleted");
797        assert!(store.bundle_get("op-1").unwrap().is_none());
798        assert_eq!(store.bundle_version_list("op-1").unwrap().len(), 1, "history survives the delete");
799
800        let outcome = seed_one(&store, &e, 1).unwrap();
801        assert!(matches!(outcome, BundleReseedOutcome::Created), "row existence, not history, gates recreation");
802        assert!(store.bundle_get("op-1").unwrap().is_some());
803    }
804
805    #[test]
806    fn name_collision_with_existing_ordinary_bundle_is_skipped_not_fatal() {
807        let store = test_store();
808        // An ordinary (non-system) bundle already owns this name — db_bundles.name is UNIQUE table-wide.
809        let existing = Bundle {
810            id: "user-bundle-1".to_string(),
811            name: "Operator One".to_string(),
812            description: String::new(),
813            is_blank: false,
814            is_global: false,
815            provider: String::new(),
816            model: String::new(),
817            instructions: "unrelated user content".to_string(),
818            instructions_by_provider: "{}".to_string(),
819            context_files: "[]".to_string(),
820            mcp_servers: "[]".to_string(),
821            skills: "[]".to_string(),
822            sort_order: 0,
823            created_at: 0,
824            updated_at: 0,
825            is_system: false,
826        };
827        store.bundle_upsert(&existing).unwrap();
828
829        let e = entry("op-1", "Operator One", "body one");
830        let result = seed_one(&store, &e, 1);
831        assert!(result.is_err(), "name UNIQUE constraint should surface as an error, not silently create op-1");
832        assert!(store.bundle_get("op-1").unwrap().is_none());
833    }
834
835    /// Codex P2, PR #3244: entry B (a brand-new id) wants the display name
836    /// entry A (an existing id, unchanged) is about to release via its own
837    /// update in this SAME manifest — but B appears first in manifest
838    /// order, so its INSERT hits A's still-current name on the first pass.
839    /// The retry pass must resolve this without either entry ending up
840    /// missing, independent of manifest array order.
841    #[test]
842    fn seeding_succeeds_regardless_of_entry_order_within_one_manifest() {
843        let store = test_store();
844        seed_one(&store, &entry("op-a", "Name X", "original A content"), 1).unwrap();
845
846        // Generation 2: A keeps its id but its name changes to "Name Y",
847        // freeing up "Name X" for the brand-new "op-b". B is listed BEFORE
848        // A in this manifest — the ordering this fix must not depend on.
849        let manifest = SeedManifest {
850            version: 2,
851            entries: vec![
852                entry("op-b", "Name X", "B content"),
853                entry("op-a", "Name Y", "updated A content"),
854            ],
855        };
856        seed_from_manifest(&store, &manifest);
857
858        let a = store.bundle_get("op-a").unwrap().expect("A must still exist");
859        assert_eq!(a.name, "Name Y");
860        assert_eq!(a.instructions, "updated A content");
861
862        let b = store.bundle_get("op-b").unwrap().expect("B must exist despite the first-pass ordering collision");
863        assert_eq!(b.name, "Name X");
864        assert_eq!(b.instructions, "B content");
865    }
866
867    /// Codex P2, PR #3244: a single retry pass only resolves a 2-entry
868    /// name swap — a 3-entry dependency CHAIN (C wants B's name, B wants
869    /// A's name, A releases first) needs the retry to repeat until a full
870    /// pass makes no further progress, or the entry at the far end of the
871    /// chain (C here) is stranded until the next startup.
872    #[test]
873    fn seeding_resolves_a_three_entry_name_dependency_chain() {
874        let store = test_store();
875        seed_one(&store, &entry("op-a", "Name A", "original A"), 1).unwrap();
876        seed_one(&store, &entry("op-b", "Name B", "original B"), 1).unwrap();
877
878        // Worst-case order: C (wants B's name) and B (wants A's name) both
879        // appear BEFORE A (the one entry that can succeed outright on pass 1).
880        let manifest = SeedManifest {
881            version: 2,
882            entries: vec![
883                entry("op-c", "Name B", "C content"),
884                entry("op-b", "Name A", "updated B"),
885                entry("op-a", "Name C", "updated A"),
886            ],
887        };
888        seed_from_manifest(&store, &manifest);
889
890        assert_eq!(store.bundle_get("op-a").unwrap().unwrap().name, "Name C");
891        assert_eq!(store.bundle_get("op-b").unwrap().unwrap().name, "Name A");
892        let c = store.bundle_get("op-c").unwrap().expect("C must exist despite being at the end of the dependency chain");
893        assert_eq!(c.name, "Name B");
894        assert_eq!(c.instructions, "C content");
895    }
896
897    #[test]
898    fn auto_seed_on_startup_does_not_panic_on_real_manifest() {
899        // Smoke test: the real embedded manifest seeds cleanly into an
900        // empty store end-to-end (parse -> seed_one -> db writes).
901        let store = test_store();
902        auto_seed_on_startup(&store);
903        let manifest: SeedManifest = serde_json::from_str(SEED_MANIFEST).unwrap();
904        for e in &manifest.entries {
905            let bundle = store.bundle_get(&e.id).unwrap().expect("seeded");
906            assert!(bundle.is_system);
907        }
908    }
909
910    fn global(id: &str, is_system: bool) -> Bundle {
911        Bundle {
912            id: id.to_string(),
913            name: id.to_string(),
914            description: String::new(),
915            is_blank: false,
916            is_global: true,
917            provider: String::new(),
918            model: String::new(),
919            instructions: format!("{id} body"),
920            instructions_by_provider: "{}".to_string(),
921            context_files: "[]".to_string(),
922            mcp_servers: "[]".to_string(),
923            skills: "[]".to_string(),
924            sort_order: 0,
925            created_at: 0,
926            updated_at: 0,
927            is_system,
928        }
929    }
930
931    fn ids(bundles: &[Bundle]) -> Vec<&str> {
932        bundles.iter().map(|b| b.id.as_str()).collect()
933    }
934
935    #[test]
936    fn agent_kind_is_container_only_for_container() {
937        assert_eq!(agent_kind("container"), "container");
938        for mode in ["host", "standalone", "", "anything"] {
939            assert_eq!(agent_kind(mode), "host", "{mode:?}");
940        }
941    }
942
943    #[test]
944    fn targeted_system_entry_reaches_only_its_kind() {
945        let mut host_only = entry("op-host", "Host", "h");
946        host_only.agent_types = vec!["host".to_string()];
947        let manifest = SeedManifest { version: 1, entries: vec![entry("op-all", "All", "a"), host_only] };
948        let bundles = || vec![global("op-all", true), global("op-host", true), global("ws", false)];
949
950        assert_eq!(ids(&filter_for_kind(bundles(), &manifest, "host")), ["op-all", "op-host", "ws"]);
951        assert_eq!(ids(&filter_for_kind(bundles(), &manifest, "container")), ["op-all", "ws"]);
952    }
953
954    #[test]
955    fn unknown_system_entries_and_workspace_entries_always_stay() {
956        // A system row this build's manifest doesn't list (a newer build
957        // seeded it on a shared store) is delivered, not silently dropped.
958        let manifest = SeedManifest { version: 1, entries: vec![] };
959        let kept = filter_for_kind(vec![global("op-newer", true), global("ws", false)], &manifest, "container");
960        assert_eq!(ids(&kept), ["op-newer", "ws"]);
961    }
962
963    #[test]
964    fn workspace_entry_is_host_only_in_the_real_manifest() {
965        let workspace = global("operator-config-workspace", true);
966        let app_api = global("operator-config-app-api", true);
967        let host = global_bundles_for_agent(vec![app_api.clone(), workspace.clone()], "standalone");
968        assert_eq!(ids(&host), ["operator-config-app-api", "operator-config-workspace"]);
969        let container = global_bundles_for_agent(vec![app_api, workspace], "container");
970        assert_eq!(ids(&container), ["operator-config-app-api"]);
971    }
972}
973
974#[cfg(test)]
975mod swarm_broadcast_entry_tests {
976    use super::*;
977
978    fn shipped() -> SeedManifest {
979        serde_json::from_str(SEED_MANIFEST).expect("valid JSON")
980    }
981
982    #[test]
983    fn the_swarm_broadcast_entry_ships_to_every_kind_of_agent() {
984        let manifest = shipped();
985        let entry = manifest.entries.iter().find(|e| e.id == "operator-config-swarm-broadcast").expect("entry present");
986        assert!(entry.agent_types.is_empty(), "no kind filter: container agents receive broadcasts too");
987    }
988
989    #[test]
990    fn it_tells_an_agent_to_follow_a_broadcast_without_stopping_to_ask() {
991        let manifest = shipped();
992        let entry = manifest.entries.iter().find(|e| e.id == "operator-config-swarm-broadcast").unwrap();
993        let text = &entry.instructions;
994        assert!(text.contains("[BROADCAST:FROM=user VIA=swarm"), "names the exact header");
995        assert!(text.contains("do not stop to ask for confirmation"));
996        assert!(text.contains("It is not a jekt"));
997        // The owner's decision does not extend to anything else: the text must not
998        // claim the header proves anything or lifts what AgentMux enforces.
999        assert!(text.contains("decided by AgentMux, not by this line"));
1000    }
1001
1002    #[test]
1003    fn the_header_the_entry_describes_is_the_one_srv_writes() {
1004        // Keep the prose and the producer from drifting apart.
1005        let header = crate::backend::reactive::broadcast_turn_message("agent1", 3, "m1", "x");
1006        let first = header.lines().next().unwrap();
1007        assert!(first.starts_with("[BROADCAST:FROM=user VIA=swarm TO=agent1 RECIPIENTS=3 MSGID=m1 TS="), "{first}");
1008        let manifest = shipped();
1009        let entry = manifest.entries.iter().find(|e| e.id == "operator-config-swarm-broadcast").unwrap();
1010        for field in ["FROM=user", "VIA=swarm", "TO=", "RECIPIENTS=", "MSGID=", "TS="] {
1011            assert!(entry.instructions.contains(field), "entry omits {field}");
1012        }
1013    }
1014
1015    #[test]
1016    fn the_manifest_version_moved_with_the_new_entry() {
1017        assert!(shipped().version >= 8);
1018    }
1019
1020    #[test]
1021    fn the_system_notes_it_describes_are_the_ones_srv_writes() {
1022        use crate::muxbus::delivery_status::notes;
1023        let manifest = shipped();
1024        let entry = manifest.entries.iter().find(|e| e.id == "operator-config-swarm-broadcast").unwrap();
1025        let text = &entry.instructions;
1026        assert!(text.contains(&format!("`{} ", notes::NOTE_MARKER)), "names the marker");
1027        assert!(text.contains("a jekt cannot carry one"));
1028        // The example notes keep the producer's wording (times aside).
1029        let pause = notes::pause_note(0, "MuxBus needs a sign-in");
1030        let after_time = &pause[pause.find(" (").unwrap()..];
1031        assert!(text.contains(after_time), "pause note drifted: {after_time}");
1032        assert!(text.contains("Cloud messages are paused since "));
1033        assert!(text.contains("Cloud messages resumed at "));
1034        assert!(notes::delayed_prefix(Some(0), notes::DELAYED_AFTER_MS + 1).starts_with("(sent "));
1035        assert!(text.contains("(sent 14:30, delivered 14:41)"));
1036        assert!(manifest.version >= 12);
1037    }
1038}