1use super::*;
2
3pub fn load_state() -> Result<State> {
4 if let Some(committed) = committed_state()? {
5 return Ok(committed.state.clone());
6 }
7 load_state_from(&database_path())
8}
9
10pub fn load_state_from(path: &Path) -> Result<State> {
11 let mut reader = open_reader(path)?;
12 let connection = reader.transaction()?;
15 load_state_with(&connection)
16}
17
18pub(super) fn load_state_with(connection: &Connection) -> Result<State> {
19 let mut state = State::default();
20 let remembered: Option<String> = connection
21 .query_row(
22 "SELECT policy FROM subagent_preference WHERE singleton = 1",
23 [],
24 |row| row.get(0),
25 )
26 .optional()?;
27 state.last_subagent_policy = remembered
28 .map(|json| mj_core::subagent::parse_stored_policy(&json))
29 .transpose()?
30 .unwrap_or_default();
31 let mut statement = connection.prepare(&format!("{SESSION_QUERY} ORDER BY s.session_id"))?;
32 let rows = statement.query_map([], decode_session)?;
33 for row in rows {
34 if let Some(session) = row? {
35 state.sessions.insert(session.id.clone(), session);
36 }
37 }
38 #[cfg(test)]
39 super::tests::after_state_sessions_read();
40 let mut statement = connection.prepare(
41 "SELECT child_session_id, record_json FROM subagent_sessions ORDER BY child_session_id",
42 )?;
43 let rows = statement.query_map([], |row| {
44 let child_id = row.get::<_, String>(0)?;
45 let json = row.get::<_, String>(1)?;
46 let record = serde_json::from_str::<SubagentRecord>(&json).map_err(|error| {
47 rusqlite::Error::FromSqlConversionFailure(1, Type::Text, Box::new(error))
48 })?;
49 Ok((child_id, record))
50 })?;
51 for row in rows {
52 let (child_id, record) = row?;
53 let missing = if !state.sessions.contains_key(&child_id) {
62 Some("child")
63 } else if !state.sessions.contains_key(&record.parent_session_id) {
64 Some("parent")
65 } else {
66 None
67 };
68 if let Some(missing) = missing {
69 tracing::warn!(
70 child_session_id = child_id,
71 parent_session_id = record.parent_session_id,
72 missing,
73 "dropping a sub-agent relation whose session is not in this state"
74 );
75 continue;
76 }
77 state.subagents.insert(child_id, record);
78 }
79 load_targets(connection, &mut state)?;
80 load_mounts(connection, &mut state)?;
81 load_checkpoints(connection, &mut state)?;
82 state.mount_history = read_mount_history(connection)?.into_iter().collect();
83 let mut statement = connection
84 .prepare("SELECT host, cpus, memory_bytes FROM host_container_sizes ORDER BY host")?;
85 let rows = statement.query_map([], |row| {
86 Ok((
87 row.get::<_, String>(0)?,
88 HostContainerSize {
89 cpus: row.get::<_, i64>(1)? as u64,
90 memory_bytes: row.get::<_, i64>(2)? as u64,
91 },
92 ))
93 })?;
94 for row in rows {
95 let (host, size) = row?;
96 state.container_sizes.insert(host, size);
97 }
98 state.validate()?;
99 Ok(state)
100}
101
102pub fn load_mount_history() -> Result<BTreeMap<String, Vec<PathBuf>>> {
106 read_mount_history(&*open_reader(&database_path())?)
107}
108
109pub(super) fn read_mount_history(
110 connection: &Connection,
111) -> Result<BTreeMap<String, Vec<PathBuf>>> {
112 let mut history = BTreeMap::<String, Vec<PathBuf>>::new();
113 let mut statement =
114 connection.prepare("SELECT host, source FROM mount_history ORDER BY host, ordinal")?;
115 let rows = statement.query_map([], |row| {
116 Ok((
117 row.get::<_, String>(0)?,
118 blob_to_path(row.get_ref(1)?.as_blob()?),
119 ))
120 })?;
121 for row in rows {
122 let (host, source) = row?;
123 history.entry(host).or_default().push(source);
124 }
125 for location in super::projects::read_project_catalog_from(connection)?.locations {
126 let paths = history
127 .entry(format!("project:{}", location.host))
128 .or_default();
129 if !paths.contains(&location.checkout_root) {
130 paths.push(location.checkout_root);
131 }
132 }
133 Ok(history)
134}
135
136pub fn save_state(state: &State) -> Result<()> {
137 let state = state.clone();
138 submit_database_write("save_state", move |_| {
139 save_state_to(&database_path(), &state)
140 })
141}
142
143pub fn save_state_to(path: &Path, state: &State) -> Result<()> {
144 state.validate()?;
145 let mut connection = open(path)?;
146 let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
147 let existing_contexts = existing_contexts(&tx)?;
148 let existing_sessions = {
149 let mut statement = tx.prepare("SELECT session_id FROM sessions")?;
150 statement
151 .query_map([], |row| row.get::<_, String>(0))?
152 .collect::<rusqlite::Result<Vec<_>>>()?
153 };
154 tx.execute(
155 "DELETE FROM subagent_sessions
156 WHERE child_session_id NOT IN (SELECT session_id FROM sessions)
157 OR parent_session_id NOT IN (SELECT session_id FROM sessions)",
158 [],
159 )?;
160 let existing_subagents = {
161 let mut statement =
162 tx.prepare("SELECT child_session_id, parent_session_id FROM subagent_sessions")?;
163 statement
164 .query_map([], |row| {
165 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
166 })?
167 .collect::<rusqlite::Result<Vec<_>>>()?
168 };
169 for (child_id, parent_id) in existing_subagents {
170 if !state.subagents.contains_key(&child_id)
171 || !state.sessions.contains_key(&child_id)
172 || !state.sessions.contains_key(&parent_id)
173 {
174 tx.execute(
175 "DELETE FROM subagent_sessions WHERE child_session_id = ?1",
176 [child_id],
177 )?;
178 }
179 }
180 tx.execute(
182 "DELETE FROM subagent_handbacks
183 WHERE child_session_id NOT IN (SELECT child_session_id FROM subagent_sessions)",
184 [],
185 )?;
186 for session_id in existing_sessions {
187 if !state.sessions.contains_key(&session_id) {
188 tx.execute("DELETE FROM sessions WHERE session_id = ?1", [session_id])?;
189 }
190 }
191 tx.execute("DELETE FROM mount_history", [])?;
192 tx.execute("DELETE FROM host_container_sizes", [])?;
193 for session in state.sessions.values() {
194 if let Some((existing_bundle, existing_workspace)) = existing_contexts.get(&session.id) {
195 ensure!(
196 existing_bundle
197 == &super::projects::session_project_id(&tx, &session.id, &session.bundle_id)?,
198 "session {} was already associated with bundle {}, not {}",
199 session.id,
200 existing_bundle,
201 session.bundle_id
202 );
203 ensure!(
204 existing_workspace == &session.workspace_id,
205 "session {} was already associated with workspace {}, not {}",
206 session.id,
207 existing_workspace,
208 session.workspace_id
209 );
210 }
211 insert_session(&tx, session)?;
212 }
213 for subagent in state.subagents.values() {
214 super::usage::record_child_accounting(&tx, subagent)?;
215 let record_json = serde_json::to_string(subagent)?;
216 tx.execute(
217 "INSERT INTO subagent_sessions(
218 child_session_id, parent_session_id, request_key, record_json
219 ) VALUES (?1, ?2, ?3, ?4)
220 ON CONFLICT(child_session_id) DO UPDATE SET
221 parent_session_id = excluded.parent_session_id,
222 request_key = excluded.request_key,
223 record_json = excluded.record_json",
224 params![
225 subagent.child_session_id,
226 subagent.parent_session_id,
227 subagent.request_key,
228 record_json
229 ],
230 )?;
231 }
232 for (host, sources) in &state.mount_history {
233 for (ordinal, source) in sources.iter().enumerate() {
234 tx.execute(
235 "INSERT INTO mount_history(host, source, ordinal) VALUES (?1, ?2, ?3)",
236 params![host, path_to_blob(source), ordinal as i64],
237 )?;
238 }
239 }
240 for (host, size) in &state.container_sizes {
241 write_host_container_size(&tx, host, *size)?;
242 }
243 tx.commit()?;
244 Ok(())
245}
246
247pub(super) fn existing_contexts(
248 tx: &Transaction<'_>,
249) -> Result<BTreeMap<String, (String, String)>> {
250 let mut statement =
251 tx.prepare("SELECT session_id, bundle_id, workspace_id FROM session_contexts")?;
252 let rows = statement.query_map([], |row| Ok((row.get(0)?, (row.get(1)?, row.get(2)?))))?;
253 rows.collect::<rusqlite::Result<_>>().map_err(Into::into)
254}
255
256pub(super) fn session_exists(tx: &Transaction<'_>, session_id: &str) -> Result<bool> {
257 Ok(tx
258 .query_row(
259 "SELECT 1 FROM sessions WHERE session_id = ?1",
260 [session_id],
261 |_| Ok(()),
262 )
263 .optional()?
264 .is_some())
265}
266
267pub(super) fn write_materialized_session(
268 tx: &Transaction<'_>,
269 materialized: &MaterializedSession,
270) -> Result<()> {
271 let (execution, running_started_at_ms) = materialized_execution_columns(materialized.execution);
272 tx.execute(
273 "INSERT INTO materialized_sessions(
274 session_id, applied_event_ordinal, applied_event_digest, execution_state,
275 running_started_at_ms, session_title, configuration_json, last_activity_at_ms,
276 pending_elicitations_json, active_turn_json, last_turn_outcome_json
277 ) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)
278 ON CONFLICT(session_id) DO UPDATE SET
279 applied_event_ordinal = excluded.applied_event_ordinal,
280 applied_event_digest = excluded.applied_event_digest,
281 execution_state = excluded.execution_state,
282 running_started_at_ms = excluded.running_started_at_ms,
283 session_title = excluded.session_title,
284 configuration_json = excluded.configuration_json,
285 last_activity_at_ms = excluded.last_activity_at_ms,
286 pending_elicitations_json = excluded.pending_elicitations_json,
287 active_turn_json = excluded.active_turn_json,
288 last_turn_outcome_json = excluded.last_turn_outcome_json",
289 params![
290 materialized.session_id,
291 materialized.applied_event_ordinal,
292 materialized.applied_event_digest,
293 execution,
294 running_started_at_ms,
295 materialized.session_title,
296 serde_json::to_string(&materialized.configuration)?,
297 materialized.last_activity_at_ms,
298 serde_json::to_string(&materialized.pending_elicitations)?,
299 materialized
300 .active_turn
301 .as_ref()
302 .map(serde_json::to_string)
303 .transpose()?,
304 materialized
305 .last_turn_outcome
306 .as_ref()
307 .map(serde_json::to_string)
308 .transpose()?,
309 ],
310 )?;
311 tx.execute(
312 "DELETE FROM materialized_transcript_items WHERE session_id = ?1",
313 [materialized.session_id.as_str()],
314 )?;
315 for item in &materialized.transcript {
316 upsert_transcript_item(tx, &materialized.session_id, item)?;
317 }
318 replace_materialized_queue(tx, &materialized.session_id, &materialized.queued_prompts)?;
319 Ok(())
320}
321
322pub(super) fn upsert_transcript_item(
323 tx: &Transaction<'_>,
324 session_id: &str,
325 item: &TranscriptItem,
326) -> Result<()> {
327 let existing = tx
328 .query_row(
329 "SELECT position, latest_content_event_ordinal, created_at_ms, last_changed_at_ms
330 FROM materialized_transcript_items
331 WHERE session_id = ?1 AND stable_id = ?2",
332 params![session_id, item.stable_id],
333 |row| {
334 Ok((
335 row.get::<_, u64>(0)?,
336 row.get::<_, Option<u64>>(1)?,
337 row.get::<_, i64>(2)?,
338 row.get::<_, i64>(3)?,
339 ))
340 },
341 )
342 .optional()?;
343 if let Some((position, latest_content_event_ordinal, created_at_ms, last_changed_at_ms)) =
344 existing
345 {
346 if position != item.position || created_at_ms != item.created_at_ms {
347 return Err(ProjectionIntegrityError(format!(
348 "transcript item {:?} changed immutable identity fields",
349 item.stable_id
350 ))
351 .into());
352 }
353 if item.last_changed_at_ms < last_changed_at_ms {
354 return Err(ProjectionIntegrityError(format!(
355 "transcript item {:?} moved its changed timestamp backwards",
356 item.stable_id
357 ))
358 .into());
359 }
360 if latest_content_event_ordinal.is_some_and(|existing| {
361 item.latest_content_event_ordinal
362 .is_none_or(|next| next < existing)
363 }) {
364 return Err(ProjectionIntegrityError(format!(
365 "transcript item {:?} moved its latest content ordinal backwards",
366 item.stable_id
367 ))
368 .into());
369 }
370 tx.execute(
371 "UPDATE materialized_transcript_items
372 SET latest_content_event_ordinal = ?3, last_changed_at_ms = ?4, body_json = ?5
373 WHERE session_id = ?1 AND stable_id = ?2",
374 params![
375 session_id,
376 item.stable_id,
377 item.latest_content_event_ordinal,
378 item.last_changed_at_ms,
379 serde_json::to_string(&item.body)?,
380 ],
381 )?;
382 } else {
383 tx.execute(
384 "INSERT INTO materialized_transcript_items(
385 session_id, stable_id, position, latest_content_event_ordinal,
386 created_at_ms, last_changed_at_ms, body_json
387 ) VALUES (?1,?2,?3,?4,?5,?6,?7)",
388 params![
389 session_id,
390 item.stable_id,
391 item.position,
392 item.latest_content_event_ordinal,
393 item.created_at_ms,
394 item.last_changed_at_ms,
395 serde_json::to_string(&item.body)?,
396 ],
397 )?;
398 }
399 Ok(())
400}
401
402pub(super) fn replace_materialized_queue(
403 tx: &Transaction<'_>,
404 session_id: &str,
405 queued_prompts: &[MaterializedQueuedPrompt],
406) -> Result<()> {
407 let mut command_ids = BTreeSet::new();
408 for prompt in queued_prompts {
409 if prompt.command_id.trim().is_empty() {
410 bail!("materialized prompt queue has an empty command id");
411 }
412 if !command_ids.insert(prompt.command_id.as_str()) {
413 bail!(
414 "materialized prompt queue contains duplicate command {:?}",
415 prompt.command_id
416 );
417 }
418 }
419 tx.execute(
420 "DELETE FROM materialized_queued_prompts WHERE session_id = ?1",
421 [session_id],
422 )?;
423 for (ordinal, prompt) in queued_prompts.iter().enumerate() {
424 tx.execute(
425 "INSERT INTO materialized_queued_prompts(
426 session_id, ordinal, command_id, kind_json, content_json, queued_at_ms,
427 accepted_ordinal
428 ) VALUES (?1,?2,?3,?4,?5,?6,?7)",
429 params![
430 session_id,
431 ordinal as i64,
432 prompt.command_id,
433 serde_json::to_string(&prompt.kind)?,
434 serde_json::to_string(&prompt.content)?,
435 prompt.queued_at_ms,
436 prompt.accepted_ordinal,
437 ],
438 )?;
439 }
440 Ok(())
441}
442
443pub(super) fn materialized_execution_columns(
444 execution: MaterializedExecutionState,
445) -> (&'static str, Option<i64>) {
446 match execution {
447 MaterializedExecutionState::Idle => ("idle", None),
448 MaterializedExecutionState::Running { started_at_ms } => ("running", Some(started_at_ms)),
449 MaterializedExecutionState::Closing => ("closing", None),
450 MaterializedExecutionState::Closed => ("closed", None),
451 }
452}
453
454pub(super) fn parse_materialized_execution(
455 execution: &str,
456 running_started_at_ms: Option<i64>,
457) -> Result<MaterializedExecutionState> {
458 match (execution, running_started_at_ms) {
459 ("idle", None) => Ok(MaterializedExecutionState::Idle),
460 ("running", Some(started_at_ms)) => {
461 Ok(MaterializedExecutionState::Running { started_at_ms })
462 }
463 ("closing", None) => Ok(MaterializedExecutionState::Closing),
464 ("closed", None) => Ok(MaterializedExecutionState::Closed),
465 _ => bail!("invalid materialized execution state {execution:?}"),
466 }
467}
468
469pub(super) fn insert_session(tx: &Transaction<'_>, session: &SessionRecord) -> Result<()> {
473 let previous_error = super::events::previous_session_error(tx, &session.id)?;
474 let canonical_bundle =
475 super::projects::session_project_id(tx, &session.id, &session.bundle_id)?;
476 tx.execute(
477 "INSERT INTO session_contexts(session_id, bundle_id, created_at, workspace_id)
478 VALUES (?1, ?2, ?3, ?4)
479 ON CONFLICT(session_id) DO NOTHING",
480 params![
481 session.id,
482 canonical_bundle,
483 session.created_at,
484 session.workspace_id
485 ],
486 )?;
487 let (stored_bundle, stored_workspace): (String, String) = tx.query_row(
488 "SELECT bundle_id, workspace_id FROM session_contexts WHERE session_id = ?1",
489 [session.id.as_str()],
490 |row| Ok((row.get(0)?, row.get(1)?)),
491 )?;
492 ensure!(
493 stored_bundle == canonical_bundle,
494 "session {} belongs to bundle {}, not {}",
495 session.id,
496 stored_bundle,
497 session.bundle_id
498 );
499 ensure!(
500 stored_workspace == session.workspace_id,
501 "session {} belongs to workspace {}, not {}",
502 session.id,
503 stored_workspace,
504 session.workspace_id
505 );
506 tx.execute(
507 "INSERT INTO sessions(
508 session_id, title, harness_kind, last_profile, target_template_id, state,
509 native_session_id, acp_session_title, session_title_override, updated_at,
510 viewed_through_event_ordinal, last_error, resource_allocation,
511 last_checkpoint_error, project_directory, managed_worktree,
512 container_cpus, container_memory, archived, draft_input, create_managed_worktree,
513 subagents, container_workspace, build_cache_json, launch_base,
514 target_runtime_json, launch_branch, publication_json, checkout_json, project_json,
515 review_json, no_project_memory, no_mailbox
516 ) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15,?16,?17,?18,?19,?20,?21,?22,?23,?24,?25,?26,?27,?28,?29,?30,?31,?32,?33)
517 ON CONFLICT(session_id) DO UPDATE SET
518 title = excluded.title,
519 harness_kind = excluded.harness_kind,
520 last_profile = excluded.last_profile,
521 target_template_id = excluded.target_template_id,
522 state = excluded.state,
523 native_session_id = excluded.native_session_id,
524 acp_session_title = excluded.acp_session_title,
525 session_title_override = excluded.session_title_override,
526 updated_at = excluded.updated_at,
527 viewed_through_event_ordinal = max(
528 sessions.viewed_through_event_ordinal,
529 excluded.viewed_through_event_ordinal
530 ),
531 last_error = excluded.last_error,
532 resource_allocation = excluded.resource_allocation,
533 last_checkpoint_error = excluded.last_checkpoint_error,
534 project_directory = excluded.project_directory,
535 managed_worktree = excluded.managed_worktree,
536 container_cpus = excluded.container_cpus,
537 container_memory = excluded.container_memory,
538 archived = excluded.archived,
539 create_managed_worktree = excluded.create_managed_worktree,
540 subagents = excluded.subagents,
541 container_workspace = excluded.container_workspace,
542 build_cache_json = excluded.build_cache_json,
543 launch_base = excluded.launch_base,
544 target_runtime_json = excluded.target_runtime_json,
545 launch_branch = excluded.launch_branch,
546 publication_json = excluded.publication_json,
547 checkout_json = excluded.checkout_json,
548 project_json = coalesce(excluded.project_json, sessions.project_json),
549 review_json = excluded.review_json,
550 no_project_memory = excluded.no_project_memory,
551 no_mailbox = excluded.no_mailbox",
552 params![
553 session.id,
554 session.title,
555 session.harness_kind.id(),
556 session.last_profile,
557 session.target_template_id,
558 session.state.as_str(),
559 session.native_session_id,
560 session.acp_session_title,
561 session.session_title_override,
562 session.updated_at,
563 session.viewed_through_event_ordinal,
564 session.last_error,
565 session
566 .resource_allocation
567 .as_ref()
568 .map(serde_json::to_string)
569 .transpose()?,
570 session.last_checkpoint_error,
571 session
572 .project_directory
573 .as_ref()
574 .map(|path| path_to_blob(path)),
575 session
576 .managed_worktree
577 .as_ref()
578 .map(serde_json::to_string)
579 .transpose()?,
580 session.container_cpus,
581 session.container_memory,
582 session.archived,
583 session.draft_input,
584 session.create_managed_worktree,
585 session.subagents.as_ref().map(serde_json::to_string).transpose()?,
586 session
587 .container_workspace
588 .as_ref()
589 .map(|path| path.to_string_lossy().into_owned()),
590 session
591 .build_cache
592 .as_ref()
593 .map(serde_json::to_string)
594 .transpose()?,
595 session.launch_base,
596 session.target_runtime.as_ref().map(serde_json::to_string).transpose()?,
597 session.launch_branch,
598 session.publication.as_ref().map(serde_json::to_string).transpose()?,
599 session.checkout.as_ref().map(serde_json::to_string).transpose()?,
600 session.project.as_ref().map(serde_json::to_string).transpose()?,
601 session.review.as_ref().map(serde_json::to_string).transpose()?,
602 session.no_project_memory,
603 session.no_mailbox,
604 ],
605 )?;
606 tx.execute(
607 "INSERT INTO materialized_sessions(session_id) VALUES (?1)
608 ON CONFLICT(session_id) DO NOTHING",
609 [session.id.as_str()],
610 )?;
611 replace_targets(tx, session)?;
612 replace_mounts(tx, &session.id, &session.additional_mounts)?;
613 replace_checkpoint(tx, session)?;
614 super::events::record_session_fault_transition(tx, session, previous_error)?;
615 Ok(())
616}
617
618pub(super) fn update_lifecycle_fields(tx: &Transaction<'_>, session: &SessionRecord) -> Result<()> {
622 let previous_error = super::events::previous_session_error(tx, &session.id)?;
623 let SessionRecord {
630 target_runtime,
631 id,
632 title,
633 harness_kind,
634 last_profile,
635 target_template_id,
636 state,
637 updated_at,
638 viewed_through_event_ordinal,
639 last_error,
640 resource_allocation,
641 last_checkpoint_error,
642 project_directory,
643 managed_worktree,
644 build_cache,
645 workspace_id: _,
646 bundle_id: _,
647 project,
648 create_managed_worktree: _,
649 launch_base: _,
650 launch_branch: _,
651 checkout: _,
652 publication: _,
653 review: _,
655 no_project_memory: _,
656 no_mailbox: _,
657 subagents,
659 additional_mounts: _,
660 container_cpus: _,
661 container_memory: _,
662 container_workspace: _,
663 archived: _,
664 target: _,
666 native_session_id: _,
667 acp_session_title: _,
668 session_title_override: _,
669 created_at: _,
670 draft_input: _,
671 checkpoint: _,
673 } = session;
674 let changed = tx.execute(
675 "UPDATE sessions
678 SET title = ?2,
679 harness_kind = ?3,
680 last_profile = ?4,
681 target_template_id = ?5,
682 state = ?6,
683 updated_at = ?7,
684 viewed_through_event_ordinal = max(viewed_through_event_ordinal, ?8),
685 last_error = ?9,
686 resource_allocation = ?10,
687 last_checkpoint_error = ?11,
688 project_directory = ?12,
689 managed_worktree = ?13,
690 build_cache_json = ?14,
691 target_runtime_json = ?15,
692 subagents = ?16,
693 project_json = coalesce(?17,project_json)
694 WHERE session_id = ?1",
695 params![
696 id,
697 title,
698 harness_kind.id(),
699 last_profile,
700 target_template_id,
701 state.as_str(),
702 updated_at,
703 viewed_through_event_ordinal,
704 last_error,
705 resource_allocation
706 .as_ref()
707 .map(serde_json::to_string)
708 .transpose()?,
709 last_checkpoint_error,
710 project_directory.as_ref().map(|path| path_to_blob(path)),
711 managed_worktree
712 .as_ref()
713 .map(serde_json::to_string)
714 .transpose()?,
715 build_cache
718 .as_ref()
719 .map(serde_json::to_string)
720 .transpose()?,
721 target_runtime
722 .as_ref()
723 .map(serde_json::to_string)
724 .transpose()?,
725 subagents.as_ref().map(serde_json::to_string).transpose()?,
726 project.as_ref().map(serde_json::to_string).transpose()?,
727 ],
728 )?;
729 if changed != 1 {
730 bail!("unknown session {id}");
731 }
732 super::events::record_session_fault_transition(tx, session, previous_error)?;
733 replace_targets(tx, session)
734}
735
736pub(super) fn replace_targets(tx: &Transaction<'_>, session: &SessionRecord) -> Result<()> {
737 tx.execute(
738 "DELETE FROM session_targets WHERE session_id = ?1",
739 [session.id.as_str()],
740 )?;
741 if let Some(target) = &session.target {
742 insert_target(tx, &session.id, target)?;
743 }
744 Ok(())
745}
746
747pub(super) fn replace_checkpoint(tx: &Transaction<'_>, session: &SessionRecord) -> Result<()> {
748 tx.execute(
749 "DELETE FROM session_checkpoints WHERE session_id = ?1",
750 [session.id.as_str()],
751 )?;
752 if let Some(checkpoint) = &session.checkpoint {
753 tx.execute(
754 "INSERT INTO session_checkpoints(session_id, archive_path, sha256, created_at, event_frontier)
755 VALUES (?1, ?2, ?3, ?4, ?5)",
756 params![
757 session.id,
758 path_to_blob(&checkpoint.archive_path),
759 checkpoint.sha256,
760 checkpoint.created_at,
761 checkpoint.event_frontier,
762 ],
763 )?;
764 }
765 Ok(())
766}
767
768pub(super) fn insert_target(
769 tx: &Transaction<'_>,
770 session_id: &str,
771 target: &TargetLocator,
772) -> Result<()> {
773 let (kind, host, resource, address, workspace, worker_id, workspace_storage, borrowed_from) =
774 match target {
775 TargetLocator::LocalBare { worker_root } => (
776 "local-bare",
777 None,
778 None,
779 None,
780 Some(path_to_blob(worker_root)),
781 None,
782 None,
783 None,
784 ),
785 TargetLocator::LocalPodman {
786 container_id,
787 workspace_storage,
788 borrowed_from,
789 } => (
790 "local-podman",
791 None,
792 Some(container_id.as_str()),
793 None,
794 None,
795 None,
796 Some(serde_json::to_string(workspace_storage)?),
797 borrowed_from.as_deref(),
798 ),
799 TargetLocator::LocalDocker {
800 container_id,
801 borrowed_from,
802 } => (
803 "local-docker",
804 None,
805 Some(container_id.as_str()),
806 None,
807 None,
808 None,
809 None,
810 borrowed_from.as_deref(),
811 ),
812 TargetLocator::SshDocker {
813 host,
814 container_id,
815 borrowed_from,
816 } => (
817 "ssh-docker",
818 Some(host.as_str()),
819 Some(container_id.as_str()),
820 None,
821 None,
822 None,
823 None,
824 borrowed_from.as_deref(),
825 ),
826 TargetLocator::AppleContainer {
827 container_id,
828 borrowed_from,
829 } => (
830 "apple-container",
831 None,
832 Some(container_id.as_str()),
833 None,
834 None,
835 None,
836 None,
837 borrowed_from.as_deref(),
838 ),
839 TargetLocator::AwsEc2 {
840 instance_id,
841 address,
842 } => (
843 "aws-ec2",
844 None,
845 Some(instance_id.as_str()),
846 address.as_deref(),
847 None,
848 None,
849 None,
850 None,
851 ),
852 TargetLocator::SshBare {
853 host,
854 workspace,
855 worker_id,
856 } => (
857 "ssh-bare",
858 Some(host.as_str()),
859 None,
860 None,
861 Some(path_to_blob(workspace)),
862 worker_id.as_deref(),
863 None,
864 None,
865 ),
866 TargetLocator::SshPodman {
867 host,
868 container_id,
869 workspace_storage,
870 borrowed_from,
871 } => (
872 "ssh-podman",
873 Some(host.as_str()),
874 Some(container_id.as_str()),
875 None,
876 None,
877 None,
878 Some(serde_json::to_string(workspace_storage)?),
879 borrowed_from.as_deref(),
880 ),
881 };
882 tx.execute(
883 "INSERT INTO session_targets(session_id, kind, host, resource_id, address, workspace, worker_id, workspace_storage, borrowed_from)
884 VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)",
885 params![
886 session_id,
887 kind,
888 host,
889 resource,
890 address,
891 workspace,
892 worker_id,
893 workspace_storage,
894 borrowed_from
895 ],
896 )?;
897 Ok(())
898}
899
900pub(super) fn load_targets(connection: &Connection, state: &mut State) -> Result<()> {
901 load_targets_selected(connection, state, None)
902}
903
904fn load_targets_selected(
905 connection: &Connection,
906 state: &mut State,
907 id: Option<&str>,
908) -> Result<()> {
909 let predicate = if id.is_some() {
910 " WHERE session_id=?1"
911 } else {
912 ""
913 };
914 let mut statement = connection.prepare(&format!("SELECT session_id, kind, host, resource_id, address, workspace, worker_id, workspace_storage, borrowed_from
915 FROM session_targets{predicate}"))?;
916 let arguments: Vec<&dyn rusqlite::ToSql> = id
917 .as_ref()
918 .map(|id| vec![id as &dyn rusqlite::ToSql])
919 .unwrap_or_default();
920 let rows = statement.query_map(arguments.as_slice(), |row| {
921 let session_id: String = row.get(0)?;
922 let kind: String = row.get(1)?;
923 let host: Option<String> = row.get(2)?;
924 let resource: Option<String> = row.get(3)?;
925 let address: Option<String> = row.get(4)?;
926 let workspace = row.get_ref(5)?.blob_or_null()?.map(blob_to_path);
927 let worker_id: Option<String> = row.get(6)?;
928 let workspace_storage = row
929 .get::<_, Option<String>>(7)?
930 .map(|serialized| {
931 serde_json::from_str(&serialized).map_err(|error| {
932 rusqlite::Error::FromSqlConversionFailure(7, Type::Text, Box::new(error))
933 })
934 })
935 .transpose()?
936 .unwrap_or_default();
937 let borrowed_from: Option<String> = row.get(8)?;
938 let target = match kind.as_str() {
939 "local-bare" => TargetLocator::LocalBare {
940 worker_root: workspace.unwrap(),
941 },
942 "local-podman" => TargetLocator::LocalPodman {
943 borrowed_from,
944 container_id: resource.unwrap(),
945 workspace_storage,
946 },
947 "local-docker" => TargetLocator::LocalDocker {
948 borrowed_from,
949 container_id: resource.unwrap(),
950 },
951 "apple-container" => TargetLocator::AppleContainer {
952 borrowed_from,
953 container_id: resource.unwrap(),
954 },
955 "aws-ec2" => TargetLocator::AwsEc2 {
956 instance_id: resource.unwrap(),
957 address,
958 },
959 "ssh-bare" => TargetLocator::SshBare {
960 host: host.unwrap(),
961 workspace: workspace.unwrap(),
962 worker_id,
963 },
964 "ssh-docker" => TargetLocator::SshDocker {
965 borrowed_from,
966 host: host.unwrap(),
967 container_id: resource.unwrap(),
968 },
969 "ssh-podman" => TargetLocator::SshPodman {
970 borrowed_from,
971 host: host.unwrap(),
972 container_id: resource.unwrap(),
973 workspace_storage,
974 },
975 _ => unreachable!("target kind constrained by schema"),
976 };
977 Ok((session_id, target))
978 })?;
979 for row in rows {
980 let (session_id, target) = row?;
981 if let Some(session) = state.sessions.get_mut(&session_id) {
984 session.target = Some(target);
985 }
986 }
987 Ok(())
988}
989
990pub(super) fn replace_mounts(
997 tx: &rusqlite::Transaction<'_>,
998 session_id: &str,
999 mounts: &[AdditionalMount],
1000) -> Result<()> {
1001 tx.execute(
1002 "DELETE FROM session_mounts WHERE session_id = ?1",
1003 [session_id],
1004 )?;
1005 tx.execute(
1006 "DELETE FROM session_mount_access WHERE session_id = ?1",
1007 [session_id],
1008 )?;
1009 for (ordinal, mount) in mounts.iter().enumerate() {
1010 tx.execute(
1011 "INSERT INTO session_mounts(session_id, ordinal, source, destination, read_only)
1012 VALUES (?1, ?2, ?3, ?4, ?5)",
1013 params![
1014 session_id,
1015 ordinal as i64,
1016 path_to_blob(&mount.source),
1017 path_to_blob(&mount.destination),
1018 mount.access == MountAccess::Ro
1019 ],
1020 )?;
1021 if mount.access == MountAccess::Rw {
1022 tx.execute(
1023 "INSERT INTO session_mount_access(session_id, source, destination, access)
1024 VALUES (?1, ?2, ?3, 'rw')",
1025 params![
1026 session_id,
1027 path_to_blob(&mount.source),
1028 path_to_blob(&mount.destination)
1029 ],
1030 )?;
1031 }
1032 }
1033 Ok(())
1034}
1035
1036pub(super) fn load_mounts(connection: &Connection, state: &mut State) -> Result<()> {
1037 load_mounts_selected(connection, state, None)
1038}
1039
1040fn load_mounts_selected(
1041 connection: &Connection,
1042 state: &mut State,
1043 id: Option<&str>,
1044) -> Result<()> {
1045 let predicate = if id.is_some() {
1046 " WHERE m.session_id=?1"
1047 } else {
1048 ""
1049 };
1050 let mut statement = connection.prepare(&format!(
1051 "SELECT m.session_id, m.source, m.destination, m.read_only, a.access IS NOT NULL
1052 FROM session_mounts m
1053 LEFT JOIN session_mount_access a
1054 ON a.session_id = m.session_id
1055 AND a.source = m.source
1056 AND a.destination = m.destination
1057 {predicate} ORDER BY m.session_id, m.ordinal"
1058 ))?;
1059 let arguments: Vec<&dyn rusqlite::ToSql> = id
1060 .as_ref()
1061 .map(|id| vec![id as &dyn rusqlite::ToSql])
1062 .unwrap_or_default();
1063 let rows = statement.query_map(arguments.as_slice(), |row| {
1064 let access = match (row.get::<_, bool>(3)?, row.get::<_, bool>(4)?) {
1067 (true, _) => MountAccess::Ro,
1068 (false, true) => MountAccess::Rw,
1069 (false, false) => MountAccess::Cow,
1070 };
1071 Ok((
1072 row.get::<_, String>(0)?,
1073 AdditionalMount {
1074 source: blob_to_path(row.get_ref(1)?.as_blob()?),
1075 destination: blob_to_path(row.get_ref(2)?.as_blob()?),
1076 access,
1077 },
1078 ))
1079 })?;
1080 for row in rows {
1081 let (session_id, mount) = row?;
1082 if let Some(session) = state.sessions.get_mut(&session_id) {
1083 session.additional_mounts.push(mount);
1084 }
1085 }
1086 Ok(())
1087}
1088
1089pub(super) fn load_checkpoints(connection: &Connection, state: &mut State) -> Result<()> {
1090 load_checkpoints_selected(connection, state, None)
1091}
1092
1093fn load_checkpoints_selected(
1094 connection: &Connection,
1095 state: &mut State,
1096 id: Option<&str>,
1097) -> Result<()> {
1098 let predicate = if id.is_some() {
1099 " WHERE session_id=?1"
1100 } else {
1101 ""
1102 };
1103 let mut statement = connection.prepare(&format!("SELECT session_id, archive_path, sha256, created_at, event_frontier FROM session_checkpoints{predicate}"))?;
1104 let arguments: Vec<&dyn rusqlite::ToSql> = id
1105 .as_ref()
1106 .map(|id| vec![id as &dyn rusqlite::ToSql])
1107 .unwrap_or_default();
1108 let rows = statement.query_map(arguments.as_slice(), |row| {
1109 Ok((
1110 row.get::<_, String>(0)?,
1111 CheckpointMetadata {
1112 archive_path: blob_to_path(row.get_ref(1)?.as_blob()?),
1113 sha256: row.get(2)?,
1114 created_at: row.get(3)?,
1115 event_frontier: row.get(4)?,
1116 },
1117 ))
1118 })?;
1119 for row in rows {
1120 let (session_id, checkpoint) = row?;
1121 if let Some(session) = state.sessions.get_mut(&session_id) {
1122 session.checkpoint = Some(checkpoint);
1123 }
1124 }
1125 Ok(())
1126}
1127
1128pub fn backfill_target_runtime(
1132 session_id: &str,
1133 target_template_id: &str,
1134 runtime: &mj_core::state::TargetRuntimeSettings,
1135) -> Result<Option<mj_core::state::TargetRuntimeSettings>> {
1136 let session_id = session_id.to_owned();
1137 let target_template_id = target_template_id.to_owned();
1138 let runtime = serde_json::to_string(runtime)?;
1139 submit_database_write("backfill_target_runtime", move |_| {
1140 let mut connection = open(&database_path())?;
1141 let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
1142 tx.execute("UPDATE sessions SET target_runtime_json = ?2 WHERE session_id = ?1 AND target_template_id = ?3 AND target_runtime_json IS NULL",
1143 params![session_id, runtime, target_template_id])?;
1144 let stored: Option<String> = tx.query_row("SELECT target_runtime_json FROM sessions WHERE session_id = ?1 AND target_template_id = ?2",
1146 params![session_id, target_template_id], |row| row.get(0)).optional()?;
1147 let runtime = stored.as_deref().map(serde_json::from_str).transpose()?;
1148 tx.commit()?;
1149 Ok(runtime)
1150 })
1151}
1152
1153const SESSION_QUERY: &str =
1154 "SELECT s.session_id, s.title, s.harness_kind, s.last_profile, c.bundle_id,
1155 s.target_template_id, s.state, s.native_session_id, s.acp_session_title,
1156 s.session_title_override, c.created_at, s.updated_at,
1157 s.viewed_through_event_ordinal, s.last_error, s.resource_allocation,
1158 s.last_checkpoint_error, s.project_directory, s.managed_worktree,
1159 s.draft_input, s.container_cpus, s.container_memory, s.archived
1160 , c.workspace_id, s.create_managed_worktree, s.subagents,
1161 s.container_workspace, s.build_cache_json, s.launch_base, s.target_runtime_json,
1162 s.launch_branch, s.publication_json, s.checkout_json, s.project_json,
1163 s.review_json, s.no_project_memory, s.no_mailbox
1164 FROM sessions s JOIN session_contexts c USING(session_id)";
1165
1166fn decode_session(row: &rusqlite::Row<'_>) -> rusqlite::Result<Option<SessionRecord>> {
1167 let harness_text: String = row.get(2)?;
1171 let Ok(harness_kind) = harness_text.parse() else {
1172 let session_id: String = row.get(0)?;
1173 tracing::warn!(
1174 session_id,
1175 harness = %harness_text,
1176 "session harness is no longer supported; the session is not listed"
1177 );
1178 return Ok(None);
1179 };
1180 Ok(Some(SessionRecord {
1181 project: row
1182 .get::<_, Option<String>>(32)?
1183 .map(|json| {
1184 serde_json::from_str(&json).map_err(|error| {
1185 rusqlite::Error::FromSqlConversionFailure(32, Type::Text, Box::new(error))
1186 })
1187 })
1188 .transpose()?,
1189 target_runtime: row
1190 .get::<_, Option<String>>(28)?
1191 .map(|json| {
1192 serde_json::from_str(&json).map_err(|error| {
1193 rusqlite::Error::FromSqlConversionFailure(28, Type::Text, Box::new(error))
1194 })
1195 })
1196 .transpose()?,
1197 harness_kind,
1198 create_managed_worktree: row.get(23)?,
1199 launch_base: row.get(27)?,
1200 launch_branch: row.get(29)?,
1201 checkout: row
1202 .get::<_, Option<String>>(31)?
1203 .map(|json| {
1204 serde_json::from_str(&json).map_err(|error| {
1205 rusqlite::Error::FromSqlConversionFailure(31, Type::Text, Box::new(error))
1206 })
1207 })
1208 .transpose()?,
1209 publication: row
1210 .get::<_, Option<String>>(30)?
1211 .map(|json| {
1212 serde_json::from_str(&json).map_err(|error| {
1213 rusqlite::Error::FromSqlConversionFailure(30, Type::Text, Box::new(error))
1214 })
1215 })
1216 .transpose()?,
1217 subagents: row
1218 .get::<_, Option<String>>(24)?
1219 .map(|json| {
1220 mj_core::subagent::parse_stored_policy(&json).map_err(|error| {
1221 rusqlite::Error::FromSqlConversionFailure(24, Type::Text, Box::new(error))
1222 })
1223 })
1224 .transpose()?,
1225 container_workspace: row.get::<_, Option<String>>(25)?.map(PathBuf::from),
1226 build_cache: row
1227 .get::<_, Option<String>>(26)?
1228 .as_deref()
1229 .and_then(|text| match serde_json::from_str(text) {
1230 Ok(build_cache) => Some(build_cache),
1231 Err(error) => {
1232 tracing::warn!(%error, "session build cache record is unreadable");
1233 None
1234 }
1235 }),
1236 workspace_id: row.get(22)?,
1237 archived: row.get(21)?,
1238 container_cpus: row.get(19)?,
1239 container_memory: row.get(20)?,
1240 id: row.get(0)?,
1241 title: row.get(1)?,
1242 last_profile: row.get(3)?,
1243 bundle_id: row.get(4)?,
1244 project_directory: row.get_ref(16)?.blob_or_null()?.map(blob_to_path),
1245 managed_worktree: row
1246 .get::<_, Option<String>>(17)?
1247 .map(|json| serde_json::from_str::<ManagedWorktree>(&json))
1248 .transpose()
1249 .map_err(|error| {
1250 rusqlite::Error::FromSqlConversionFailure(
1251 17,
1252 rusqlite::types::Type::Text,
1253 Box::new(error),
1254 )
1255 })?,
1256 review: row
1259 .get::<_, Option<String>>(33)?
1260 .as_deref()
1261 .and_then(|text| match serde_json::from_str(text) {
1262 Ok(review) => Some(review),
1263 Err(error) => {
1264 tracing::warn!(%error, "session review choice is unreadable");
1265 None
1266 }
1267 }),
1268 no_project_memory: row.get(34)?,
1269 no_mailbox: row.get(35)?,
1270 target_template_id: row.get(5)?,
1271 resource_allocation: row
1272 .get::<_, Option<String>>(14)?
1273 .map(|json| serde_json::from_str::<SessionResourceAllocation>(&json))
1274 .transpose()
1275 .map_err(|error| {
1276 rusqlite::Error::FromSqlConversionFailure(
1277 14,
1278 rusqlite::types::Type::Text,
1279 Box::new(error),
1280 )
1281 })?,
1282 additional_mounts: Vec::new(),
1283 state: stored_session_state(&row.get::<_, String>(6)?),
1284 target: None,
1285 native_session_id: row.get(7)?,
1286 acp_session_title: row
1287 .get::<_, Option<String>>(8)?
1288 .as_deref()
1289 .and_then(mj_core::state::normalize_session_title),
1290 session_title_override: row.get(9)?,
1291 created_at: row.get(10)?,
1292 updated_at: row.get(11)?,
1293 viewed_through_event_ordinal: row.get::<_, u64>(12)?,
1294 draft_input: row.get(18)?,
1295 last_error: row.get(13)?,
1296 last_checkpoint_error: row.get(15)?,
1297 checkpoint: None,
1298 }))
1299}
1300
1301pub fn load_session_record(id: &str) -> Result<Option<SessionRecord>> {
1303 if let Some(committed) = committed_state()? {
1304 return Ok(committed.state.sessions.get(id).cloned());
1305 }
1306 read_durable_session_record(id)
1307}
1308
1309pub(crate) fn read_durable_session_record(id: &str) -> Result<Option<SessionRecord>> {
1312 let mut connection = open_reader(&database_path())?;
1313 let snapshot = connection.transaction()?;
1314 load_session_with(&snapshot, id)
1315}
1316
1317pub(super) fn load_session_with(
1318 connection: &Connection,
1319 id: &str,
1320) -> Result<Option<SessionRecord>> {
1321 let record = connection
1322 .query_row(
1323 &format!("{SESSION_QUERY} WHERE s.session_id=?1"),
1324 [id],
1325 decode_session,
1326 )
1327 .optional()?
1328 .flatten();
1329 let Some(record) = record else {
1330 return Ok(None);
1331 };
1332 let mut state = State::default();
1333 state.sessions.insert(id.to_owned(), record);
1334 load_targets_selected(connection, &mut state, Some(id))?;
1335 load_mounts_selected(connection, &mut state, Some(id))?;
1336 load_checkpoints_selected(connection, &mut state, Some(id))?;
1337 state.validate()?;
1338 Ok(state.sessions.remove(id))
1339}
1340
1341#[cfg(test)]
1342mod targeted_read_tests {
1343 use super::*;
1344
1345 #[test]
1346 fn single_session_read_includes_related_rows_and_ignores_unrelated_invalid_records() {
1347 let directory = tempfile::tempdir().unwrap();
1348 let path = directory.path().join("controller.sqlite");
1349 let expected = super::super::tests::session("selected", "project");
1350 save_session_to(&path, &expected).unwrap();
1351 save_session_to(&path, &super::super::tests::session("unrelated", "project")).unwrap();
1352 let connection = open(&path).unwrap();
1353 connection
1356 .execute(
1357 "UPDATE sessions SET resource_allocation = '[]' WHERE session_id = 'unrelated'",
1358 [],
1359 )
1360 .unwrap();
1361 assert!(load_state_from(&path).is_err());
1362 let mut reader = open_reader(&path).unwrap();
1363 let snapshot = reader.transaction().unwrap();
1364 assert_eq!(
1365 load_session_with(&snapshot, "selected").unwrap(),
1366 Some(expected)
1367 );
1368 assert_eq!(load_session_with(&snapshot, "missing").unwrap(), None);
1369 }
1370}