Skip to main content

mj_controller/database/
committed.rs

1//! The writer publishes immutable records before acknowledging an operation.
2//!
3//! Connection-local triggers collect affected keys, including cascading deletes
4//! and writes made through another connection on the writer thread. Keys are
5//! hints, never commit receipts: after the operation returns we read committed
6//! rows and publish only actual differences. A rolled-back transaction therefore
7//! cannot publish a change. Nothing is added to the durable schema.
8
9use super::*;
10use mj_core::native_agent::{NativeAgentSummary, NativeAgentView};
11use mj_core::snapshot_map::SnapshotMap;
12use rusqlite::functions::FunctionFlags;
13use std::cell::RefCell;
14
15#[derive(Default)]
16struct PendingChanges {
17    path: PathBuf,
18    keys: BTreeSet<(String, String)>,
19}
20
21thread_local! {
22    static PENDING: RefCell<Option<PendingChanges>> = const { RefCell::new(None) };
23}
24
25/// Every writable connection gets the same observer. The collector is active
26/// only inside an accepted writer job and only for that job's database path.
27pub(super) fn observe_connection(connection: &Connection, path: &Path) -> Result<()> {
28    let path = path.to_owned();
29    connection.create_scalar_function(
30        "mj_changed_record",
31        2,
32        FunctionFlags::SQLITE_UTF8,
33        move |arguments| {
34            let kind: String = arguments.get(0)?;
35            let key: String = arguments.get(1)?;
36            PENDING.with(|pending| {
37                if let Some(pending) = pending.borrow_mut().as_mut()
38                    && pending.path == path
39                {
40                    pending.keys.insert((kind, key));
41                }
42            });
43            Ok(0)
44        },
45    )?;
46    for (table, kind, key) in [
47        ("sessions", "session", "session_id"),
48        ("session_contexts", "session", "session_id"),
49        ("session_targets", "session", "session_id"),
50        ("session_mounts", "session", "session_id"),
51        ("session_mount_access", "session", "session_id"),
52        ("session_checkpoints", "session", "session_id"),
53        ("subagent_sessions", "relation", "child_session_id"),
54        ("subagent_preference", "preference", "singleton"),
55        ("mount_history", "mount_history", "host"),
56        ("project_locations", "mount_history", "host"),
57        ("host_container_sizes", "container_size", "host"),
58        ("session_moves", "move", "session_id"),
59        ("native_agents", "native_agent", "owner"),
60        ("startup_steps", "startup", "session_id"),
61        ("subagent_handbacks", "report", "child_session_id"),
62    ] {
63        // Observe tables that exist; opening a connection must not depend on
64        // an unrelated optional table. Its own read/write still reports damage.
65        let exists: bool = connection.query_row(
66            "SELECT EXISTS(SELECT 1 FROM main.sqlite_schema WHERE type='table' AND name=?1)",
67            [table],
68            |row| row.get(0),
69        )?;
70        if !exists {
71            continue;
72        }
73        for (event, references) in [
74            ("INSERT", &["NEW"][..]),
75            ("DELETE", &["OLD"][..]),
76            ("UPDATE", &["OLD", "NEW"][..]),
77        ] {
78            let calls = references
79                .iter()
80                .map(|reference| {
81                    let key = if table == "project_locations" {
82                        format!("'project:' || CAST({reference}.host AS TEXT)")
83                    } else if kind == "native_agent" {
84                        format!("json_array({reference}.owner, {reference}.child)")
85                    } else {
86                        format!("CAST({reference}.{key} AS TEXT)")
87                    };
88                    format!("SELECT mj_changed_record('{kind}', {key});")
89                })
90                .collect::<String>();
91            connection.execute_batch(&format!(
92                "CREATE TEMP TRIGGER mj_observe_{table}_{event} AFTER {event} ON main.{table}
93                 BEGIN {calls} END;"
94            ))?;
95        }
96    }
97    Ok(())
98}
99
100pub(super) fn begin_operation(path: &Path) {
101    PENDING.with(|pending| {
102        assert!(
103            pending.borrow().is_none(),
104            "nested database writer operation"
105        );
106        *pending.borrow_mut() = Some(PendingChanges {
107            path: path.to_owned(),
108            keys: BTreeSet::new(),
109        });
110    });
111}
112
113#[derive(Clone)]
114pub struct CommittedState {
115    pub sequence: u64,
116    pub state: State,
117    pub moves: SnapshotMap<String, mj_core::state::MoveOperation>,
118    pub native_agents: SnapshotMap<String, SnapshotMap<String, NativeAgentSummary>>,
119    /// Each session's latest startup group, keyed by session. A session with
120    /// no group has no entry. API status readers ask this record instead of
121    /// the store, and a change to it publishes a revision.
122    pub startup_groups: SnapshotMap<String, Vec<StartupDelivery>>,
123    /// Each sub-agent child's recorded report, keyed by child session. A
124    /// child with nothing recorded has no entry.
125    pub subagent_reports: SnapshotMap<String, mj_core::subagent::SubagentReport>,
126}
127
128impl CommittedState {
129    pub(super) fn bootstrap(connection: &mut Connection) -> Result<Self> {
130        let transaction =
131            connection.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
132        let state = state_io::load_state_with(&transaction)?;
133        let mut startup_groups = SnapshotMap::new();
134        let session_ids = transaction
135            .prepare("SELECT DISTINCT session_id FROM startup_steps WHERE group_id IS NOT NULL")?
136            .query_map([], |row| row.get::<_, String>(0))?
137            .collect::<rusqlite::Result<Vec<_>>>()?;
138        for session_id in session_ids {
139            let group = startup::load_latest_startup_group_with(&transaction, &session_id)?;
140            if !group.is_empty() {
141                startup_groups.insert(session_id, group);
142            }
143        }
144        let mut subagent_reports = SnapshotMap::new();
145        let children = transaction
146            .prepare("SELECT child_session_id FROM subagent_handbacks")?
147            .query_map([], |row| row.get::<_, String>(0))?
148            .collect::<rusqlite::Result<Vec<_>>>()?;
149        for child in children {
150            if let Some(report) = sessions::load_subagent_report_with(&transaction, &child)? {
151                subagent_reports.insert(child, report);
152            }
153        }
154        let moves = session_move::load_move_operations_with(&transaction)?
155            .into_iter()
156            .map(|operation| (operation.selection.session_id.clone(), operation))
157            .collect();
158        let mut native_agents =
159            SnapshotMap::<String, SnapshotMap<String, NativeAgentSummary>>::new();
160        let mut statement =
161            transaction.prepare("SELECT owner, child, body FROM native_agents WHERE staging=0")?;
162        let rows = statement.query_map([], |row| {
163            Ok((
164                row.get::<_, String>(0)?,
165                row.get::<_, String>(1)?,
166                row.get::<_, String>(2)?,
167            ))
168        })?;
169        for row in rows {
170            let (owner, child, body) = row?;
171            let view: NativeAgentView = serde_json::from_str(&body)?;
172            native_agents
173                .entry(owner)
174                .or_insert_with(SnapshotMap::new)
175                .insert(child, NativeAgentSummary::of(&view));
176        }
177        Ok(Self {
178            sequence: 0,
179            state,
180            moves,
181            native_agents,
182            startup_groups,
183            subagent_reports,
184        })
185    }
186}
187
188/// Reads all changed records from one committed WAL snapshot. The caller owns
189/// the sole write lane, so no later job can overtake this publication.
190pub(super) fn finish_operation(
191    connection: &mut Connection,
192    previous: &CommittedState,
193) -> Result<Option<CommittedState>> {
194    let changes = PENDING
195        .with(|pending| pending.borrow_mut().take())
196        .context("database writer operation has no change collector")?;
197    ensure!(
198        connection.is_autocommit(),
199        "writer job left a transaction open"
200    );
201    if changes.keys.is_empty() {
202        return Ok(None);
203    }
204    let transaction =
205        connection.transaction_with_behavior(rusqlite::TransactionBehavior::Deferred)?;
206    let mut state = previous.state.clone();
207    let mut moves = previous.moves.clone();
208    let mut native_agents = previous.native_agents.clone();
209    let mut startup_groups = previous.startup_groups.clone();
210    let mut subagent_reports = previous.subagent_reports.clone();
211    let mut changed_history = State::default();
212    let mut changed = false;
213    let mut relations = BTreeSet::new();
214    for (kind, key) in &changes.keys {
215        match kind.as_str() {
216            "move" => {
217                let operation = session_move::load_move_operation_with(&transaction, key)?;
218                if moves.get(key) != operation.as_ref() {
219                    match operation {
220                        Some(operation) => {
221                            moves.insert(key.clone(), operation);
222                        }
223                        None => {
224                            moves.remove(key);
225                        }
226                    }
227                    changed = true;
228                }
229            }
230            "native_agent" => {
231                let (owner, child): (String, String) = serde_json::from_str(key)?;
232                let body: Option<String> = transaction
233                    .query_row(
234                        "SELECT body FROM native_agents WHERE owner=?1 AND child=?2 AND staging=0",
235                        params![owner, child],
236                        |row| row.get(0),
237                    )
238                    .optional()?;
239                let summary = body
240                    .map(|body| {
241                        serde_json::from_str::<NativeAgentView>(&body)
242                            .map(|view| NativeAgentSummary::of(&view))
243                    })
244                    .transpose()?;
245                let old = native_agents
246                    .get(&owner)
247                    .and_then(|children| children.get(&child));
248                if old != summary.as_ref() {
249                    if let Some(summary) = summary {
250                        native_agents
251                            .entry(owner)
252                            .or_insert_with(SnapshotMap::new)
253                            .insert(child, summary);
254                    } else if let Some(children) = native_agents.get_mut(&owner) {
255                        children.remove(&child);
256                        if children.is_empty() {
257                            native_agents.remove(&owner);
258                        }
259                    }
260                    changed = true;
261                }
262            }
263            "session" => {
264                let record = state_io::load_session_with(&transaction, key)?;
265                let membership_changed = state.sessions.contains_key(key) != record.is_some();
266                if state.sessions.get(key) != record.as_ref() {
267                    match record {
268                        Some(record) => {
269                            state.sessions.insert(key.clone(), record);
270                        }
271                        None => {
272                            state.sessions.remove(key);
273                        }
274                    }
275                    changed = true;
276                }
277                // A formerly unsupported parent/child may now be readable.
278                // Re-evaluate only its indexed relationships, not all history.
279                if membership_changed {
280                    let mut statement = transaction.prepare(
281                        "SELECT child_session_id FROM subagent_sessions
282                         WHERE parent_session_id=?1 OR child_session_id=?1",
283                    )?;
284                    relations.extend(
285                        statement
286                            .query_map([key], |row| row.get::<_, String>(0))?
287                            .collect::<rusqlite::Result<Vec<_>>>()?,
288                    );
289                }
290            }
291            "relation" => {
292                relations.insert(key.clone());
293                let mut statement = transaction.prepare(
294                    "SELECT child_session_id FROM subagent_sessions WHERE parent_session_id=?1",
295                )?;
296                relations.extend(
297                    statement
298                        .query_map([key], |row| row.get::<_, String>(0))?
299                        .collect::<rusqlite::Result<Vec<_>>>()?,
300                );
301            }
302            "preference" => {
303                let json: Option<String> = transaction
304                    .query_row(
305                        "SELECT policy FROM subagent_preference WHERE singleton=1",
306                        [],
307                        |row| row.get(0),
308                    )
309                    .optional()?;
310                let policy = json
311                    .map(|json| serde_json::from_str(&json))
312                    .transpose()?
313                    .unwrap_or_default();
314                if state.last_subagent_policy != policy {
315                    state.last_subagent_policy = policy;
316                    changed = true;
317                }
318            }
319            "mount_history" => {
320                let paths = state_io::read_mount_history(&transaction)?
321                    .remove(key)
322                    .unwrap_or_default();
323                let paths = (!paths.is_empty()).then_some(paths);
324                if state.mount_history.get(key) != paths.as_ref() {
325                    match paths {
326                        Some(paths) => {
327                            changed_history
328                                .mount_history
329                                .insert(key.clone(), paths.clone());
330                            state.mount_history.insert(key.clone(), paths);
331                        }
332                        None => {
333                            state.mount_history.remove(key);
334                        }
335                    }
336                    changed = true;
337                }
338            }
339            "container_size" => {
340                let size = transaction
341                    .query_row(
342                        "SELECT cpus, memory_bytes FROM host_container_sizes WHERE host=?1",
343                        [key],
344                        |row| {
345                            Ok(HostContainerSize {
346                                cpus: row.get::<_, i64>(0)? as u64,
347                                memory_bytes: row.get::<_, i64>(1)? as u64,
348                            })
349                        },
350                    )
351                    .optional()?;
352                if state.container_sizes.get(key) != size.as_ref() {
353                    match size {
354                        Some(size) => {
355                            changed_history.container_sizes.insert(key.clone(), size);
356                            state.container_sizes.insert(key.clone(), size);
357                        }
358                        None => {
359                            state.container_sizes.remove(key);
360                        }
361                    }
362                    changed = true;
363                }
364            }
365            "startup" => {
366                let group = startup::load_latest_startup_group_with(&transaction, key)?;
367                let group = (!group.is_empty()).then_some(group);
368                if startup_groups.get(key) != group.as_ref() {
369                    match group {
370                        Some(group) => {
371                            startup_groups.insert(key.clone(), group);
372                        }
373                        None => {
374                            startup_groups.remove(key);
375                        }
376                    }
377                    changed = true;
378                }
379            }
380            "report" => {
381                let report = sessions::load_subagent_report_with(&transaction, key)?;
382                if subagent_reports.get(key) != report.as_ref() {
383                    match report {
384                        Some(report) => {
385                            subagent_reports.insert(key.clone(), report);
386                        }
387                        None => {
388                            subagent_reports.remove(key);
389                        }
390                    }
391                    changed = true;
392                }
393            }
394            _ => bail!("unknown committed record kind {kind}"),
395        }
396    }
397    for key in &relations {
398        let json: Option<String> = transaction
399            .query_row(
400                "SELECT record_json FROM subagent_sessions WHERE child_session_id=?1",
401                [key],
402                |row| row.get(0),
403            )
404            .optional()?;
405        let relation: Option<SubagentRecord> =
406            json.map(|json| serde_json::from_str(&json)).transpose()?;
407        let relation = relation.filter(|relation| {
408            state.sessions.contains_key(key)
409                && state.sessions.contains_key(&relation.parent_session_id)
410        });
411        if state.subagents.get(key) != relation.as_ref() {
412            match relation {
413                Some(relation) => {
414                    state.subagents.insert(key.clone(), relation);
415                }
416                None => {
417                    state.subagents.remove(key);
418                }
419            }
420            changed = true;
421        }
422    }
423    for key in &relations {
424        state.validate_subagent(key)?;
425    }
426    changed_history.validate()?;
427    transaction.commit()?;
428    Ok(changed.then(|| CommittedState {
429        sequence: previous.sequence + 1,
430        state,
431        moves,
432        native_agents,
433        startup_groups,
434        subagent_reports,
435    }))
436}
437
438#[cfg(test)]
439mod tests {
440    use super::*;
441
442    #[test]
443    fn publication_failure_stops_mutations_without_replaying_the_committed_write() {
444        let directory = tempfile::tempdir().unwrap();
445        let path = directory.path().join("controller.sqlite");
446        save_session_to(&path, &super::super::tests::session("selected", "project")).unwrap();
447        let owner = start_database_writer_at(&path, false).unwrap();
448        let error = owner
449            .writer
450            .execute("invalid committed record", |connection| {
451                connection.execute("UPDATE sessions SET resource_allocation='[]'", [])?;
452                Ok(())
453            })
454            .unwrap_err();
455        assert!(error.to_string().contains("do not replay"));
456        assert!(owner.writer.committed_state().is_err());
457        assert!(
458            owner
459                .writer
460                .execute("must not execute", |_| -> Result<()> {
461                    panic!("a failed publication must close mutation service");
462                })
463                .is_err()
464        );
465        let connection = open_reader(&path).unwrap();
466        let stored: String = connection
467            .query_row(
468                "SELECT resource_allocation FROM sessions WHERE session_id='selected'",
469                [],
470                |row| row.get(0),
471            )
472            .unwrap();
473        assert_eq!(
474            stored, "[]",
475            "publication failure cannot undo or replay a commit"
476        );
477        assert!(owner.shutdown().is_err());
478    }
479
480    #[test]
481    fn secondary_connections_publish_committed_records_before_the_write_reply() {
482        let directory = tempfile::tempdir().unwrap();
483        let path = directory.path().join("controller.sqlite");
484        let owner = start_database_writer_at(&path, false).unwrap();
485        let before = owner.writer.committed_state().unwrap();
486        let record = super::super::tests::session("created", "project");
487        let saved = record.clone();
488        owner
489            .writer
490            .execute("create on secondary connection", move |_| {
491                save_session_to(&path, &saved)
492            })
493            .unwrap();
494        let after = owner.writer.committed_state().unwrap();
495        assert!(before.state.sessions.is_empty());
496        assert_eq!(after.state.sessions["created"], record);
497        assert_eq!(after.sequence, before.sequence + 1);
498    }
499
500    /// A wait reads a session's startup status and a child's report from the
501    /// published records, so every write to them must publish, keyed to the
502    /// session it changed and equal to what the store holds.
503    #[test]
504    fn startup_groups_and_subagent_reports_are_published_per_session() {
505        let directory = tempfile::tempdir().unwrap();
506        let path = directory.path().join("controller.sqlite");
507        let owner = start_database_writer_at(&path, false).unwrap();
508        let before = owner.writer.committed_state().unwrap();
509        assert!(before.startup_groups.is_empty() && before.subagent_reports.is_empty());
510
511        owner
512            .writer
513            .execute("queue startup", |connection| {
514                connection.execute(
515                    "INSERT INTO startup_steps(session_id,group_id,command_id,step_json,phase)
516                     VALUES ('first','group-1','first:prompt','{}','pending'),
517                            ('second','group-2','second:prompt','{}','pending')",
518                    [],
519                )?;
520                Ok(())
521            })
522            .unwrap();
523        let queued = owner.writer.committed_state().unwrap();
524        assert_eq!(queued.sequence, before.sequence + 1);
525        let reader = open_reader(&path).unwrap();
526        for session in ["first", "second"] {
527            assert_eq!(
528                queued.startup_groups[session],
529                startup::load_latest_startup_group_with(&reader, session).unwrap()
530            );
531        }
532
533        let handback = mj_core::subagent::SubagentHandback {
534            command_id: "task".into(),
535            message: "the report".into(),
536            recorded_at_ms: 7,
537        };
538        let recorded = handback.clone();
539        let report_path = path.clone();
540        owner
541            .writer
542            .execute("finish one session", move |connection| {
543                connection.execute(
544                    "UPDATE startup_steps SET phase='failed',error='refused'
545                     WHERE session_id='first'",
546                    [],
547                )?;
548                sessions::record_subagent_handback_to(&report_path, "first", &recorded)?;
549                Ok(())
550            })
551            .unwrap();
552        let after = owner.writer.committed_state().unwrap();
553        assert_eq!(after.startup_groups["first"][0].phase, "failed");
554        assert_eq!(
555            after.startup_groups["first"][0].error.as_deref(),
556            Some("refused")
557        );
558        assert_eq!(
559            after.subagent_reports["first"].handback.as_ref(),
560            Some(&handback)
561        );
562        assert_eq!(
563            after.startup_groups["second"], queued.startup_groups["second"],
564            "the other session's group is untouched"
565        );
566        assert!(!after.subagent_reports.contains_key("second"));
567
568        // A writer that starts over the same store publishes the same records.
569        owner.shutdown().unwrap();
570        let owner = start_database_writer_at(&path, false).unwrap();
571        let bootstrapped = owner.writer.committed_state().unwrap();
572        assert_eq!(bootstrapped.startup_groups, after.startup_groups);
573        assert_eq!(bootstrapped.subagent_reports, after.subagent_reports);
574
575        owner
576            .writer
577            .execute("drop startup", |connection| {
578                connection.execute("DELETE FROM startup_steps WHERE session_id='first'", [])?;
579                connection.execute("DELETE FROM subagent_handbacks", [])?;
580                Ok(())
581            })
582            .unwrap();
583        let cleared = owner.writer.committed_state().unwrap();
584        assert!(!cleared.startup_groups.contains_key("first"));
585        assert!(cleared.startup_groups.contains_key("second"));
586        assert!(cleared.subagent_reports.is_empty());
587    }
588
589    #[test]
590    fn rollback_and_no_op_updates_do_not_publish_changes() {
591        let directory = tempfile::tempdir().unwrap();
592        let path = directory.path().join("controller.sqlite");
593        save_session_to(&path, &super::super::tests::session("selected", "project")).unwrap();
594        let owner = start_database_writer_at(&path, false).unwrap();
595        let before = owner.writer.committed_state().unwrap();
596        let result: Result<()> = owner.writer.execute("rollback", |connection| {
597            let transaction = connection.transaction()?;
598            transaction.execute("UPDATE sessions SET title='rolled back'", [])?;
599            bail!("operation failed before commit");
600        });
601        assert!(result.is_err());
602        owner
603            .writer
604            .execute("no-op update", |connection| {
605                connection.execute("UPDATE sessions SET title=title", [])?;
606                Ok(())
607            })
608            .unwrap();
609        let after = owner.writer.committed_state().unwrap();
610        assert_eq!(after.sequence, before.sequence);
611        assert_eq!(after.state, before.state);
612    }
613
614    #[test]
615    fn a_committed_write_is_published_even_when_later_work_in_the_operation_fails() {
616        let directory = tempfile::tempdir().unwrap();
617        let path = directory.path().join("controller.sqlite");
618        save_session_to(&path, &super::super::tests::session("selected", "project")).unwrap();
619        let owner = start_database_writer_at(&path, false).unwrap();
620        let result: Result<()> = owner.writer.execute("failure after commit", |connection| {
621            connection.execute("UPDATE sessions SET title='committed'", [])?;
622            bail!("later work failed");
623        });
624        assert!(
625            result
626                .unwrap_err()
627                .to_string()
628                .contains("later work failed")
629        );
630        assert_eq!(
631            owner.writer.committed_state().unwrap().state.sessions["selected"].title,
632            "committed"
633        );
634    }
635
636    #[test]
637    fn deleting_a_session_publishes_its_absence_and_keeps_a_held_snapshot() {
638        let directory = tempfile::tempdir().unwrap();
639        let path = directory.path().join("controller.sqlite");
640        save_session_to(&path, &super::super::tests::session("selected", "project")).unwrap();
641        let owner = start_database_writer_at(&path, false).unwrap();
642        let before = owner.writer.committed_state().unwrap();
643        owner
644            .writer
645            .execute("delete with cascading related rows", |connection| {
646                connection.execute("DELETE FROM sessions WHERE session_id='selected'", [])?;
647                Ok(())
648            })
649            .unwrap();
650        assert!(
651            owner
652                .writer
653                .committed_state()
654                .unwrap()
655                .state
656                .sessions
657                .is_empty()
658        );
659        assert!(before.state.sessions.contains_key("selected"));
660    }
661}