1use 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
25pub(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 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 pub startup_groups: SnapshotMap<String, Vec<StartupDelivery>>,
123 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
188pub(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 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 #[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 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}