Skip to main content

mj_controller/database/
state_io.rs

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    // Sessions and their relationships must come from the same WAL snapshot.
13    // Otherwise a concurrent spawn can look orphaned despite intact foreign keys.
14    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        // A relation whose child or parent is not among the sessions this load
54        // returned describes nothing. Keeping it would fail the state check
55        // below and make every later operation fail with it, which is how a
56        // sub-agent spawn came to be refused with "sub-agent ... has no child
57        // session" long after the child in question was gone (#1065). The load
58        // already skips a session whose harness it cannot parse; a relation
59        // that pointed at such a session is the same kind of residue, and
60        // `save_state_to` deletes these rows on the next save.
61        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
102/// The remembered mount sources and project directories by host key, newest
103/// first, as `load_state` reads them. A dashboard reads this again when a
104/// wizard opens, since sessions created after it started add to the history.
105pub 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    // A report outlives nothing: it goes with its child's relation.
181    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
469/// Write every field of a session, including the ones other writers own.
470/// Only a flow that authors the whole record — creation, import, resume, or
471/// orphan adoption — may use this.
472pub(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
618/// Update the columns a lifecycle transition owns, plus the target locator
619/// that provisioning and teardown maintain with them. The row must exist:
620/// a transition never resurrects a session another writer deleted.
621pub(super) fn update_lifecycle_fields(tx: &Transaction<'_>, session: &SessionRecord) -> Result<()> {
622    let previous_error = super::events::previous_session_error(tx, &session.id)?;
623    // Destructured exhaustively and without `..` on purpose. This statement is
624    // the only thing standing between a new `SessionRecord` field and a value
625    // that is set in memory, read back as its default, and never missed until
626    // somebody inspects the database. A new field breaks this binding, and
627    // whoever adds it decides then whether a lifecycle transition owns it.
628    // Everything bound to `_` is owned by `upsert_session` instead.
629    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        // Chosen once, when the session is created.
654        review: _,
655        no_project_memory: _,
656        no_mailbox: _,
657        // A Move can change it along with the profile the session runs on.
658        subagents,
659        additional_mounts: _,
660        container_cpus: _,
661        container_memory: _,
662        container_workspace: _,
663        archived: _,
664        // Written by `replace_targets` below rather than by this statement.
665        target: _,
666        native_session_id: _,
667        acp_session_title: _,
668        session_title_override: _,
669        created_at: _,
670        draft_input: _,
671        // Written by `replace_checkpoint`.
672        checkpoint: _,
673    } = session;
674    let changed = tx.execute(
675        // The detach ordinal only ever moves forward, so a transition that
676        // started before a detach receipt cannot rewind it.
677        "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            // Resolved while a session is provisioned and assigned to the
716            // record right before this write, so the lifecycle path owns it.
717            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        // A session skipped for an unsupported harness has no entry to attach
982        // its target, mounts, or checkpoint to.
983        if let Some(session) = state.sessions.get_mut(&session_id) {
984            session.target = Some(target);
985        }
986    }
987    Ok(())
988}
989
990/// Rewrite a session's attached directories.
991///
992/// `session_mounts.read_only` keeps the meaning older builds understand, so
993/// read-write mounts are stored there as not read-only and recorded again in
994/// `session_mount_access`. Older builds rewrite `session_mounts` without
995/// touching that table, which is what keeps the read-write choice.
996pub(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        // An older build that made the mount read-only left the access row
1065        // behind; its later choice wins.
1066        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
1128/// Fill a missing snapshot without overwriting a concurrent writer. A deleted
1129/// or retargeted session returns `None`; its stale controller copy can be dropped
1130/// by reload instead of preventing every subsequent reload.
1131pub 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        // Concurrent backfills must all use the winning durable value.
1145        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    // A harness Mjolnir no longer supports can still own rows an earlier
1168    // release wrote. Skip such a session with a warning rather than
1169    // failing the whole listing and hiding every other session with it.
1170    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        // An unreadable choice, such as a mode a newer release wrote, falls
1257        // back to `[review]` rather than hiding the session.
1258        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
1301/// Read one complete record with indexed queries from the caller's WAL snapshot.
1302pub 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
1309/// Recovery checks storage under target ownership before touching a process.
1310/// This bounded read also detects a broken store even without a pending write.
1311pub(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        // A corrupt unrelated value makes a full-state read fail. A point read
1354        // must not decode it or any of its related rows.
1355        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}