Skip to main content

mj_controller/database/
state_io.rs

1use super::*;
2
3pub fn load_state() -> Result<State> {
4    load_state_from(&database_path())
5}
6
7pub fn load_state_from(path: &Path) -> Result<State> {
8    let mut reader = open_reader(path)?;
9    // Sessions and their relationships must come from the same WAL snapshot.
10    // Otherwise a concurrent spawn can look orphaned despite intact foreign keys.
11    let connection = reader.transaction()?;
12    let mut state = State::default();
13    let mut statement = connection.prepare(
14        "SELECT s.session_id, s.title, s.harness_kind, s.last_profile, c.bundle_id,
15                s.target_template_id, s.state, s.native_session_id, s.acp_session_title,
16                s.session_title_override, c.created_at, s.updated_at,
17                s.viewed_through_event_ordinal, s.last_error, s.resource_allocation,
18                s.last_checkpoint_error, s.project_directory, s.managed_worktree,
19                s.draft_input, s.container_cpus, s.container_memory, s.archived
20                , c.workspace_id, s.create_managed_worktree, s.mjolnir_subagents,
21                s.container_workspace, s.build_cache_json, s.launch_base
22         FROM sessions s JOIN session_contexts c USING(session_id)
23         ORDER BY s.session_id",
24    )?;
25    let rows = statement.query_map([], |row| {
26        // A harness Mjolnir no longer supports can still own rows an earlier
27        // release wrote. Skip such a session with a warning rather than
28        // failing the whole listing and hiding every other session with it.
29        let harness_text: String = row.get(2)?;
30        let Ok(harness_kind) = harness_text.parse() else {
31            let session_id: String = row.get(0)?;
32            tracing::warn!(
33                session_id,
34                harness = %harness_text,
35                "session harness is no longer supported; the session is not listed"
36            );
37            return Ok(None);
38        };
39        Ok(Some(SessionRecord {
40            harness_kind,
41            create_managed_worktree: row.get(23)?,
42            launch_base: row.get(27)?,
43            mjolnir_subagents: row.get(24)?,
44            container_workspace: row.get::<_, Option<String>>(25)?.map(PathBuf::from),
45            build_cache: row
46                .get::<_, Option<String>>(26)?
47                .as_deref()
48                .and_then(|text| match serde_json::from_str(text) {
49                    Ok(build_cache) => Some(build_cache),
50                    Err(error) => {
51                        tracing::warn!(%error, "session build cache record is unreadable");
52                        None
53                    }
54                }),
55            workspace_id: row.get(22)?,
56            archived: row.get(21)?,
57            container_cpus: row.get(19)?,
58            container_memory: row.get(20)?,
59            id: row.get(0)?,
60            title: row.get(1)?,
61            last_profile: row.get(3)?,
62            bundle_id: row.get(4)?,
63            project_directory: row.get_ref(16)?.blob_or_null()?.map(blob_to_path),
64            managed_worktree: row
65                .get::<_, Option<String>>(17)?
66                .map(|json| serde_json::from_str::<ManagedWorktree>(&json))
67                .transpose()
68                .map_err(|error| {
69                    rusqlite::Error::FromSqlConversionFailure(
70                        17,
71                        rusqlite::types::Type::Text,
72                        Box::new(error),
73                    )
74                })?,
75            target_template_id: row.get(5)?,
76            resource_allocation: row
77                .get::<_, Option<String>>(14)?
78                .map(|json| serde_json::from_str::<SessionResourceAllocation>(&json))
79                .transpose()
80                .map_err(|error| {
81                    rusqlite::Error::FromSqlConversionFailure(
82                        14,
83                        rusqlite::types::Type::Text,
84                        Box::new(error),
85                    )
86                })?,
87            additional_mounts: Vec::new(),
88            state: stored_session_state(&row.get::<_, String>(6)?),
89            target: None,
90            native_session_id: row.get(7)?,
91            acp_session_title: row
92                .get::<_, Option<String>>(8)?
93                .as_deref()
94                .and_then(mj_core::state::normalize_session_title),
95            session_title_override: row.get(9)?,
96            created_at: row.get(10)?,
97            updated_at: row.get(11)?,
98            viewed_through_event_ordinal: row.get::<_, u64>(12)?,
99            draft_input: row.get(18)?,
100            last_error: row.get(13)?,
101            last_checkpoint_error: row.get(15)?,
102            checkpoint: None,
103        }))
104    })?;
105    for row in rows {
106        if let Some(session) = row? {
107            state.sessions.insert(session.id.clone(), session);
108        }
109    }
110    #[cfg(test)]
111    super::tests::after_state_sessions_read();
112    let mut statement = connection.prepare(
113        "SELECT child_session_id, record_json FROM subagent_sessions ORDER BY child_session_id",
114    )?;
115    let rows = statement.query_map([], |row| {
116        let child_id = row.get::<_, String>(0)?;
117        let json = row.get::<_, String>(1)?;
118        let record = serde_json::from_str::<SubagentRecord>(&json).map_err(|error| {
119            rusqlite::Error::FromSqlConversionFailure(1, Type::Text, Box::new(error))
120        })?;
121        Ok((child_id, record))
122    })?;
123    for row in rows {
124        let (child_id, record) = row?;
125        // A relation whose child or parent is not among the sessions this load
126        // returned describes nothing. Keeping it would fail the state check
127        // below and make every later operation fail with it, which is how a
128        // sub-agent spawn came to be refused with "sub-agent ... has no child
129        // session" long after the child in question was gone (#1065). The load
130        // already skips a session whose harness it cannot parse; a relation
131        // that pointed at such a session is the same kind of residue, and
132        // `save_state_to` deletes these rows on the next save.
133        let missing = if !state.sessions.contains_key(&child_id) {
134            Some("child")
135        } else if !state.sessions.contains_key(&record.parent_session_id) {
136            Some("parent")
137        } else {
138            None
139        };
140        if let Some(missing) = missing {
141            tracing::warn!(
142                child_session_id = child_id,
143                parent_session_id = record.parent_session_id,
144                missing,
145                "dropping a sub-agent relation whose session is not in this state"
146            );
147            continue;
148        }
149        state.subagents.insert(child_id, record);
150    }
151    load_targets(&connection, &mut state)?;
152    load_mounts(&connection, &mut state)?;
153    load_checkpoints(&connection, &mut state)?;
154    let mut statement =
155        connection.prepare("SELECT host, source FROM mount_history ORDER BY host, ordinal")?;
156    let rows = statement.query_map([], |row| {
157        Ok((
158            row.get::<_, String>(0)?,
159            blob_to_path(row.get_ref(1)?.as_blob()?),
160        ))
161    })?;
162    for row in rows {
163        let (host, source) = row?;
164        state.mount_history.entry(host).or_default().push(source);
165    }
166    let mut statement = connection
167        .prepare("SELECT host, cpus, memory_bytes FROM host_container_sizes ORDER BY host")?;
168    let rows = statement.query_map([], |row| {
169        Ok((
170            row.get::<_, String>(0)?,
171            HostContainerSize {
172                cpus: row.get::<_, i64>(1)? as u64,
173                memory_bytes: row.get::<_, i64>(2)? as u64,
174            },
175        ))
176    })?;
177    for row in rows {
178        let (host, size) = row?;
179        state.container_sizes.insert(host, size);
180    }
181    state.validate()?;
182    Ok(state)
183}
184
185pub fn save_state(state: &State) -> Result<()> {
186    let state = state.clone();
187    submit_database_write("save_state", move |_| {
188        save_state_to(&database_path(), &state)
189    })
190}
191
192pub fn save_state_to(path: &Path, state: &State) -> Result<()> {
193    state.validate()?;
194    let mut connection = open(path)?;
195    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
196    let existing_contexts = existing_contexts(&tx)?;
197    let existing_sessions = {
198        let mut statement = tx.prepare("SELECT session_id FROM sessions")?;
199        statement
200            .query_map([], |row| row.get::<_, String>(0))?
201            .collect::<rusqlite::Result<Vec<_>>>()?
202    };
203    tx.execute(
204        "DELETE FROM subagent_sessions
205         WHERE child_session_id NOT IN (SELECT session_id FROM sessions)
206            OR parent_session_id NOT IN (SELECT session_id FROM sessions)",
207        [],
208    )?;
209    let existing_subagents = {
210        let mut statement =
211            tx.prepare("SELECT child_session_id, parent_session_id FROM subagent_sessions")?;
212        statement
213            .query_map([], |row| {
214                Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
215            })?
216            .collect::<rusqlite::Result<Vec<_>>>()?
217    };
218    for (child_id, parent_id) in existing_subagents {
219        if !state.subagents.contains_key(&child_id)
220            || !state.sessions.contains_key(&child_id)
221            || !state.sessions.contains_key(&parent_id)
222        {
223            tx.execute(
224                "DELETE FROM subagent_sessions WHERE child_session_id = ?1",
225                [child_id],
226            )?;
227        }
228    }
229    for session_id in existing_sessions {
230        if !state.sessions.contains_key(&session_id) {
231            tx.execute("DELETE FROM sessions WHERE session_id = ?1", [session_id])?;
232        }
233    }
234    tx.execute("DELETE FROM mount_history", [])?;
235    tx.execute("DELETE FROM host_container_sizes", [])?;
236    for session in state.sessions.values() {
237        if let Some((existing_bundle, existing_workspace)) = existing_contexts.get(&session.id) {
238            ensure!(
239                existing_bundle == &session.bundle_id,
240                "session {} was already associated with bundle {}, not {}",
241                session.id,
242                existing_bundle,
243                session.bundle_id
244            );
245            ensure!(
246                existing_workspace == &session.workspace_id,
247                "session {} was already associated with workspace {}, not {}",
248                session.id,
249                existing_workspace,
250                session.workspace_id
251            );
252        }
253        insert_session(&tx, session)?;
254    }
255    for subagent in state.subagents.values() {
256        let record_json = serde_json::to_string(subagent)?;
257        tx.execute(
258            "INSERT INTO subagent_sessions(
259                 child_session_id, parent_session_id, request_key, record_json
260             ) VALUES (?1, ?2, ?3, ?4)
261             ON CONFLICT(child_session_id) DO UPDATE SET
262                 parent_session_id = excluded.parent_session_id,
263                 request_key = excluded.request_key,
264                 record_json = excluded.record_json",
265            params![
266                subagent.child_session_id,
267                subagent.parent_session_id,
268                subagent.request_key,
269                record_json
270            ],
271        )?;
272    }
273    for (host, sources) in &state.mount_history {
274        for (ordinal, source) in sources.iter().enumerate() {
275            tx.execute(
276                "INSERT INTO mount_history(host, source, ordinal) VALUES (?1, ?2, ?3)",
277                params![host, path_to_blob(source), ordinal as i64],
278            )?;
279        }
280    }
281    for (host, size) in &state.container_sizes {
282        write_host_container_size(&tx, host, *size)?;
283    }
284    tx.commit()?;
285    Ok(())
286}
287
288pub(super) fn existing_contexts(
289    tx: &Transaction<'_>,
290) -> Result<BTreeMap<String, (String, String)>> {
291    let mut statement =
292        tx.prepare("SELECT session_id, bundle_id, workspace_id FROM session_contexts")?;
293    let rows = statement.query_map([], |row| Ok((row.get(0)?, (row.get(1)?, row.get(2)?))))?;
294    rows.collect::<rusqlite::Result<_>>().map_err(Into::into)
295}
296
297pub(super) fn session_exists(tx: &Transaction<'_>, session_id: &str) -> Result<bool> {
298    Ok(tx
299        .query_row(
300            "SELECT 1 FROM sessions WHERE session_id = ?1",
301            [session_id],
302            |_| Ok(()),
303        )
304        .optional()?
305        .is_some())
306}
307
308pub(super) fn write_materialized_session(
309    tx: &Transaction<'_>,
310    materialized: &MaterializedSession,
311) -> Result<()> {
312    let (execution, running_started_at_ms) = materialized_execution_columns(materialized.execution);
313    tx.execute(
314        "INSERT INTO materialized_sessions(
315             session_id, applied_event_ordinal, applied_event_digest, execution_state,
316             running_started_at_ms, session_title, configuration_json, last_activity_at_ms,
317             pending_elicitations_json, active_turn_json, last_turn_outcome_json
318         ) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11)
319         ON CONFLICT(session_id) DO UPDATE SET
320             applied_event_ordinal = excluded.applied_event_ordinal,
321             applied_event_digest = excluded.applied_event_digest,
322             execution_state = excluded.execution_state,
323             running_started_at_ms = excluded.running_started_at_ms,
324             session_title = excluded.session_title,
325             configuration_json = excluded.configuration_json,
326             last_activity_at_ms = excluded.last_activity_at_ms,
327             pending_elicitations_json = excluded.pending_elicitations_json,
328             active_turn_json = excluded.active_turn_json,
329             last_turn_outcome_json = excluded.last_turn_outcome_json",
330        params![
331            materialized.session_id,
332            materialized.applied_event_ordinal,
333            materialized.applied_event_digest,
334            execution,
335            running_started_at_ms,
336            materialized.session_title,
337            serde_json::to_string(&materialized.configuration)?,
338            materialized.last_activity_at_ms,
339            serde_json::to_string(&materialized.pending_elicitations)?,
340            materialized
341                .active_turn
342                .as_ref()
343                .map(serde_json::to_string)
344                .transpose()?,
345            materialized
346                .last_turn_outcome
347                .as_ref()
348                .map(serde_json::to_string)
349                .transpose()?,
350        ],
351    )?;
352    tx.execute(
353        "DELETE FROM materialized_transcript_items WHERE session_id = ?1",
354        [materialized.session_id.as_str()],
355    )?;
356    for item in &materialized.transcript {
357        upsert_transcript_item(tx, &materialized.session_id, item)?;
358    }
359    replace_materialized_queue(tx, &materialized.session_id, &materialized.queued_prompts)?;
360    Ok(())
361}
362
363pub(super) fn upsert_transcript_item(
364    tx: &Transaction<'_>,
365    session_id: &str,
366    item: &TranscriptItem,
367) -> Result<()> {
368    let existing = tx
369        .query_row(
370            "SELECT position, latest_content_event_ordinal, created_at_ms, last_changed_at_ms
371             FROM materialized_transcript_items
372             WHERE session_id = ?1 AND stable_id = ?2",
373            params![session_id, item.stable_id],
374            |row| {
375                Ok((
376                    row.get::<_, u64>(0)?,
377                    row.get::<_, Option<u64>>(1)?,
378                    row.get::<_, i64>(2)?,
379                    row.get::<_, i64>(3)?,
380                ))
381            },
382        )
383        .optional()?;
384    if let Some((position, latest_content_event_ordinal, created_at_ms, last_changed_at_ms)) =
385        existing
386    {
387        if position != item.position || created_at_ms != item.created_at_ms {
388            return Err(ProjectionIntegrityError(format!(
389                "transcript item {:?} changed immutable identity fields",
390                item.stable_id
391            ))
392            .into());
393        }
394        if item.last_changed_at_ms < last_changed_at_ms {
395            return Err(ProjectionIntegrityError(format!(
396                "transcript item {:?} moved its changed timestamp backwards",
397                item.stable_id
398            ))
399            .into());
400        }
401        if latest_content_event_ordinal.is_some_and(|existing| {
402            item.latest_content_event_ordinal
403                .is_none_or(|next| next < existing)
404        }) {
405            return Err(ProjectionIntegrityError(format!(
406                "transcript item {:?} moved its latest content ordinal backwards",
407                item.stable_id
408            ))
409            .into());
410        }
411        tx.execute(
412            "UPDATE materialized_transcript_items
413             SET latest_content_event_ordinal = ?3, last_changed_at_ms = ?4, body_json = ?5
414             WHERE session_id = ?1 AND stable_id = ?2",
415            params![
416                session_id,
417                item.stable_id,
418                item.latest_content_event_ordinal,
419                item.last_changed_at_ms,
420                serde_json::to_string(&item.body)?,
421            ],
422        )?;
423    } else {
424        tx.execute(
425            "INSERT INTO materialized_transcript_items(
426                 session_id, stable_id, position, latest_content_event_ordinal,
427                 created_at_ms, last_changed_at_ms, body_json
428             ) VALUES (?1,?2,?3,?4,?5,?6,?7)",
429            params![
430                session_id,
431                item.stable_id,
432                item.position,
433                item.latest_content_event_ordinal,
434                item.created_at_ms,
435                item.last_changed_at_ms,
436                serde_json::to_string(&item.body)?,
437            ],
438        )?;
439    }
440    Ok(())
441}
442
443pub(super) fn replace_materialized_queue(
444    tx: &Transaction<'_>,
445    session_id: &str,
446    queued_prompts: &[MaterializedQueuedPrompt],
447) -> Result<()> {
448    let mut command_ids = BTreeSet::new();
449    for prompt in queued_prompts {
450        if prompt.command_id.trim().is_empty() {
451            bail!("materialized prompt queue has an empty command id");
452        }
453        if !command_ids.insert(prompt.command_id.as_str()) {
454            bail!(
455                "materialized prompt queue contains duplicate command {:?}",
456                prompt.command_id
457            );
458        }
459    }
460    tx.execute(
461        "DELETE FROM materialized_queued_prompts WHERE session_id = ?1",
462        [session_id],
463    )?;
464    for (ordinal, prompt) in queued_prompts.iter().enumerate() {
465        tx.execute(
466            "INSERT INTO materialized_queued_prompts(
467                 session_id, ordinal, command_id, kind_json, content_json, queued_at_ms,
468                 accepted_ordinal
469             ) VALUES (?1,?2,?3,?4,?5,?6,?7)",
470            params![
471                session_id,
472                ordinal as i64,
473                prompt.command_id,
474                serde_json::to_string(&prompt.kind)?,
475                serde_json::to_string(&prompt.content)?,
476                prompt.queued_at_ms,
477                prompt.accepted_ordinal,
478            ],
479        )?;
480    }
481    Ok(())
482}
483
484pub(super) fn materialized_execution_columns(
485    execution: MaterializedExecutionState,
486) -> (&'static str, Option<i64>) {
487    match execution {
488        MaterializedExecutionState::Idle => ("idle", None),
489        MaterializedExecutionState::Running { started_at_ms } => ("running", Some(started_at_ms)),
490        MaterializedExecutionState::Closing => ("closing", None),
491        MaterializedExecutionState::Closed => ("closed", None),
492    }
493}
494
495pub(super) fn parse_materialized_execution(
496    execution: &str,
497    running_started_at_ms: Option<i64>,
498) -> Result<MaterializedExecutionState> {
499    match (execution, running_started_at_ms) {
500        ("idle", None) => Ok(MaterializedExecutionState::Idle),
501        ("running", Some(started_at_ms)) => {
502            Ok(MaterializedExecutionState::Running { started_at_ms })
503        }
504        ("closing", None) => Ok(MaterializedExecutionState::Closing),
505        ("closed", None) => Ok(MaterializedExecutionState::Closed),
506        _ => bail!("invalid materialized execution state {execution:?}"),
507    }
508}
509
510/// Write every field of a session, including the ones other writers own.
511/// Only a flow that authors the whole record — creation, import, resume, or
512/// orphan adoption — may use this.
513pub(super) fn insert_session(tx: &Transaction<'_>, session: &SessionRecord) -> Result<()> {
514    tx.execute(
515        "INSERT INTO session_contexts(session_id, bundle_id, created_at, workspace_id)
516         VALUES (?1, ?2, ?3, ?4)
517         ON CONFLICT(session_id) DO NOTHING",
518        params![
519            session.id,
520            session.bundle_id,
521            session.created_at,
522            session.workspace_id
523        ],
524    )?;
525    let (stored_bundle, stored_workspace): (String, String) = tx.query_row(
526        "SELECT bundle_id, workspace_id FROM session_contexts WHERE session_id = ?1",
527        [session.id.as_str()],
528        |row| Ok((row.get(0)?, row.get(1)?)),
529    )?;
530    ensure!(
531        stored_bundle == session.bundle_id,
532        "session {} belongs to bundle {}, not {}",
533        session.id,
534        stored_bundle,
535        session.bundle_id
536    );
537    ensure!(
538        stored_workspace == session.workspace_id,
539        "session {} belongs to workspace {}, not {}",
540        session.id,
541        stored_workspace,
542        session.workspace_id
543    );
544    tx.execute(
545        "INSERT INTO sessions(
546             session_id, title, harness_kind, last_profile, target_template_id, state,
547             native_session_id, acp_session_title, session_title_override, updated_at,
548             viewed_through_event_ordinal, last_error, resource_allocation,
549             last_checkpoint_error, project_directory, managed_worktree,
550             container_cpus, container_memory, archived, draft_input, create_managed_worktree,
551             mjolnir_subagents, container_workspace, build_cache_json, launch_base
552         ) 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)
553         ON CONFLICT(session_id) DO UPDATE SET
554             title = excluded.title,
555             harness_kind = excluded.harness_kind,
556             last_profile = excluded.last_profile,
557             target_template_id = excluded.target_template_id,
558             state = excluded.state,
559             native_session_id = excluded.native_session_id,
560             acp_session_title = excluded.acp_session_title,
561             session_title_override = excluded.session_title_override,
562             updated_at = excluded.updated_at,
563             viewed_through_event_ordinal = max(
564                 sessions.viewed_through_event_ordinal,
565                 excluded.viewed_through_event_ordinal
566             ),
567             last_error = excluded.last_error,
568             resource_allocation = excluded.resource_allocation,
569             last_checkpoint_error = excluded.last_checkpoint_error,
570             project_directory = excluded.project_directory,
571             managed_worktree = excluded.managed_worktree,
572             container_cpus = excluded.container_cpus,
573             container_memory = excluded.container_memory,
574             archived = excluded.archived,
575             create_managed_worktree = excluded.create_managed_worktree,
576             mjolnir_subagents = excluded.mjolnir_subagents,
577             container_workspace = excluded.container_workspace,
578             build_cache_json = excluded.build_cache_json,
579             launch_base = excluded.launch_base",
580        params![
581            session.id,
582            session.title,
583            session.harness_kind.id(),
584            session.last_profile,
585            session.target_template_id,
586            session.state.as_str(),
587            session.native_session_id,
588            session.acp_session_title,
589            session.session_title_override,
590            session.updated_at,
591            session.viewed_through_event_ordinal,
592            session.last_error,
593            session
594                .resource_allocation
595                .as_ref()
596                .map(serde_json::to_string)
597                .transpose()?,
598            session.last_checkpoint_error,
599            session
600                .project_directory
601                .as_ref()
602                .map(|path| path_to_blob(path)),
603            session
604                .managed_worktree
605                .as_ref()
606                .map(serde_json::to_string)
607                .transpose()?,
608            session.container_cpus,
609            session.container_memory,
610            session.archived,
611            session.draft_input,
612            session.create_managed_worktree,
613            session.mjolnir_subagents,
614            session
615                .container_workspace
616                .as_ref()
617                .map(|path| path.to_string_lossy().into_owned()),
618            session
619                .build_cache
620                .as_ref()
621                .map(serde_json::to_string)
622                .transpose()?,
623            session.launch_base,
624        ],
625    )?;
626    tx.execute(
627        "INSERT INTO materialized_sessions(session_id) VALUES (?1)
628         ON CONFLICT(session_id) DO NOTHING",
629        [session.id.as_str()],
630    )?;
631    replace_targets(tx, session)?;
632    replace_mounts(tx, &session.id, &session.additional_mounts)?;
633    replace_checkpoint(tx, session)?;
634    Ok(())
635}
636
637/// Update the columns a lifecycle transition owns, plus the target locator
638/// that provisioning and teardown maintain with them. The row must exist:
639/// a transition never resurrects a session another writer deleted.
640pub(super) fn update_lifecycle_fields(tx: &Transaction<'_>, session: &SessionRecord) -> Result<()> {
641    // Destructured exhaustively and without `..` on purpose. This statement is
642    // the only thing standing between a new `SessionRecord` field and a value
643    // that is set in memory, read back as its default, and never missed until
644    // somebody inspects the database. A new field breaks this binding, and
645    // whoever adds it decides then whether a lifecycle transition owns it.
646    // Everything bound to `_` is owned by `upsert_session` instead.
647    let SessionRecord {
648        id,
649        title,
650        harness_kind,
651        last_profile,
652        target_template_id,
653        state,
654        updated_at,
655        viewed_through_event_ordinal,
656        last_error,
657        resource_allocation,
658        last_checkpoint_error,
659        project_directory,
660        managed_worktree,
661        build_cache,
662        workspace_id: _,
663        bundle_id: _,
664        create_managed_worktree: _,
665        launch_base: _,
666        mjolnir_subagents: _,
667        additional_mounts: _,
668        container_cpus: _,
669        container_memory: _,
670        container_workspace: _,
671        archived: _,
672        // Written by `replace_targets` below rather than by this statement.
673        target: _,
674        native_session_id: _,
675        acp_session_title: _,
676        session_title_override: _,
677        created_at: _,
678        draft_input: _,
679        // Written by `replace_checkpoint`.
680        checkpoint: _,
681    } = session;
682    let changed = tx.execute(
683        // The detach ordinal only ever moves forward, so a transition that
684        // started before a detach receipt cannot rewind it.
685        "UPDATE sessions
686         SET title = ?2,
687             harness_kind = ?3,
688             last_profile = ?4,
689             target_template_id = ?5,
690             state = ?6,
691             updated_at = ?7,
692             viewed_through_event_ordinal = max(viewed_through_event_ordinal, ?8),
693             last_error = ?9,
694             resource_allocation = ?10,
695             last_checkpoint_error = ?11,
696             project_directory = ?12,
697             managed_worktree = ?13,
698             build_cache_json = ?14
699         WHERE session_id = ?1",
700        params![
701            id,
702            title,
703            harness_kind.id(),
704            last_profile,
705            target_template_id,
706            state.as_str(),
707            updated_at,
708            viewed_through_event_ordinal,
709            last_error,
710            resource_allocation
711                .as_ref()
712                .map(serde_json::to_string)
713                .transpose()?,
714            last_checkpoint_error,
715            project_directory.as_ref().map(|path| path_to_blob(path)),
716            managed_worktree
717                .as_ref()
718                .map(serde_json::to_string)
719                .transpose()?,
720            // Resolved while a session is provisioned and assigned to the
721            // record right before this write, so the lifecycle path owns it.
722            build_cache
723                .as_ref()
724                .map(serde_json::to_string)
725                .transpose()?,
726        ],
727    )?;
728    if changed != 1 {
729        bail!("unknown session {id}");
730    }
731    replace_targets(tx, session)
732}
733
734pub(super) fn replace_targets(tx: &Transaction<'_>, session: &SessionRecord) -> Result<()> {
735    tx.execute(
736        "DELETE FROM session_targets WHERE session_id = ?1",
737        [session.id.as_str()],
738    )?;
739    if let Some(target) = &session.target {
740        insert_target(tx, &session.id, target)?;
741    }
742    Ok(())
743}
744
745pub(super) fn replace_checkpoint(tx: &Transaction<'_>, session: &SessionRecord) -> Result<()> {
746    tx.execute(
747        "DELETE FROM session_checkpoints WHERE session_id = ?1",
748        [session.id.as_str()],
749    )?;
750    if let Some(checkpoint) = &session.checkpoint {
751        tx.execute(
752            "INSERT INTO session_checkpoints(session_id, archive_path, sha256, created_at, event_frontier)
753             VALUES (?1, ?2, ?3, ?4, ?5)",
754            params![
755                session.id,
756                path_to_blob(&checkpoint.archive_path),
757                checkpoint.sha256,
758                checkpoint.created_at,
759                checkpoint.event_frontier,
760            ],
761        )?;
762    }
763    Ok(())
764}
765
766pub(super) fn insert_target(
767    tx: &Transaction<'_>,
768    session_id: &str,
769    target: &TargetLocator,
770) -> Result<()> {
771    let (kind, host, resource, address, workspace, worker_id, workspace_storage, borrowed_from) =
772        match target {
773            TargetLocator::LocalBare { worker_root } => (
774                "local-bare",
775                None,
776                None,
777                None,
778                Some(path_to_blob(worker_root)),
779                None,
780                None,
781                None,
782            ),
783            TargetLocator::LocalPodman {
784                container_id,
785                workspace_storage,
786                borrowed_from,
787            } => (
788                "local-podman",
789                None,
790                Some(container_id.as_str()),
791                None,
792                None,
793                None,
794                Some(serde_json::to_string(workspace_storage)?),
795                borrowed_from.as_deref(),
796            ),
797            TargetLocator::LocalDocker {
798                container_id,
799                borrowed_from,
800            } => (
801                "local-docker",
802                None,
803                Some(container_id.as_str()),
804                None,
805                None,
806                None,
807                None,
808                borrowed_from.as_deref(),
809            ),
810            TargetLocator::SshDocker {
811                host,
812                container_id,
813                borrowed_from,
814            } => (
815                "ssh-docker",
816                Some(host.as_str()),
817                Some(container_id.as_str()),
818                None,
819                None,
820                None,
821                None,
822                borrowed_from.as_deref(),
823            ),
824            TargetLocator::AppleContainer {
825                container_id,
826                borrowed_from,
827            } => (
828                "apple-container",
829                None,
830                Some(container_id.as_str()),
831                None,
832                None,
833                None,
834                None,
835                borrowed_from.as_deref(),
836            ),
837            TargetLocator::AwsEc2 {
838                instance_id,
839                address,
840            } => (
841                "aws-ec2",
842                None,
843                Some(instance_id.as_str()),
844                address.as_deref(),
845                None,
846                None,
847                None,
848                None,
849            ),
850            TargetLocator::SshBare {
851                host,
852                workspace,
853                worker_id,
854            } => (
855                "ssh-bare",
856                Some(host.as_str()),
857                None,
858                None,
859                Some(path_to_blob(workspace)),
860                worker_id.as_deref(),
861                None,
862                None,
863            ),
864            TargetLocator::SshPodman {
865                host,
866                container_id,
867                workspace_storage,
868                borrowed_from,
869            } => (
870                "ssh-podman",
871                Some(host.as_str()),
872                Some(container_id.as_str()),
873                None,
874                None,
875                None,
876                Some(serde_json::to_string(workspace_storage)?),
877                borrowed_from.as_deref(),
878            ),
879        };
880    tx.execute(
881        "INSERT INTO session_targets(session_id, kind, host, resource_id, address, workspace, worker_id, workspace_storage, borrowed_from)
882         VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)",
883        params![
884            session_id,
885            kind,
886            host,
887            resource,
888            address,
889            workspace,
890            worker_id,
891            workspace_storage,
892            borrowed_from
893        ],
894    )?;
895    Ok(())
896}
897
898pub(super) fn load_targets(connection: &Connection, state: &mut State) -> Result<()> {
899    let mut statement = connection.prepare(
900        "SELECT session_id, kind, host, resource_id, address, workspace, worker_id, workspace_storage, borrowed_from
901         FROM session_targets",
902    )?;
903    let rows = statement.query_map([], |row| {
904        let session_id: String = row.get(0)?;
905        let kind: String = row.get(1)?;
906        let host: Option<String> = row.get(2)?;
907        let resource: Option<String> = row.get(3)?;
908        let address: Option<String> = row.get(4)?;
909        let workspace = row.get_ref(5)?.blob_or_null()?.map(blob_to_path);
910        let worker_id: Option<String> = row.get(6)?;
911        let workspace_storage = row
912            .get::<_, Option<String>>(7)?
913            .map(|serialized| {
914                serde_json::from_str(&serialized).map_err(|error| {
915                    rusqlite::Error::FromSqlConversionFailure(7, Type::Text, Box::new(error))
916                })
917            })
918            .transpose()?
919            .unwrap_or_default();
920        let borrowed_from: Option<String> = row.get(8)?;
921        let target = match kind.as_str() {
922            "local-bare" => TargetLocator::LocalBare {
923                worker_root: workspace.unwrap(),
924            },
925            "local-podman" => TargetLocator::LocalPodman {
926                borrowed_from,
927                container_id: resource.unwrap(),
928                workspace_storage,
929            },
930            "local-docker" => TargetLocator::LocalDocker {
931                borrowed_from,
932                container_id: resource.unwrap(),
933            },
934            "apple-container" => TargetLocator::AppleContainer {
935                borrowed_from,
936                container_id: resource.unwrap(),
937            },
938            "aws-ec2" => TargetLocator::AwsEc2 {
939                instance_id: resource.unwrap(),
940                address,
941            },
942            "ssh-bare" => TargetLocator::SshBare {
943                host: host.unwrap(),
944                workspace: workspace.unwrap(),
945                worker_id,
946            },
947            "ssh-docker" => TargetLocator::SshDocker {
948                borrowed_from,
949                host: host.unwrap(),
950                container_id: resource.unwrap(),
951            },
952            "ssh-podman" => TargetLocator::SshPodman {
953                borrowed_from,
954                host: host.unwrap(),
955                container_id: resource.unwrap(),
956                workspace_storage,
957            },
958            _ => unreachable!("target kind constrained by schema"),
959        };
960        Ok((session_id, target))
961    })?;
962    for row in rows {
963        let (session_id, target) = row?;
964        // A session skipped for an unsupported harness has no entry to attach
965        // its target, mounts, or checkpoint to.
966        if let Some(session) = state.sessions.get_mut(&session_id) {
967            session.target = Some(target);
968        }
969    }
970    Ok(())
971}
972
973/// Rewrite a session's attached directories.
974///
975/// `session_mounts.read_only` keeps the meaning older builds understand, so
976/// read-write mounts are stored there as not read-only and recorded again in
977/// `session_mount_access`. Older builds rewrite `session_mounts` without
978/// touching that table, which is what keeps the read-write choice.
979pub(super) fn replace_mounts(
980    tx: &rusqlite::Transaction<'_>,
981    session_id: &str,
982    mounts: &[AdditionalMount],
983) -> Result<()> {
984    tx.execute(
985        "DELETE FROM session_mounts WHERE session_id = ?1",
986        [session_id],
987    )?;
988    tx.execute(
989        "DELETE FROM session_mount_access WHERE session_id = ?1",
990        [session_id],
991    )?;
992    for (ordinal, mount) in mounts.iter().enumerate() {
993        tx.execute(
994            "INSERT INTO session_mounts(session_id, ordinal, source, destination, read_only)
995             VALUES (?1, ?2, ?3, ?4, ?5)",
996            params![
997                session_id,
998                ordinal as i64,
999                path_to_blob(&mount.source),
1000                path_to_blob(&mount.destination),
1001                mount.access == MountAccess::Ro
1002            ],
1003        )?;
1004        if mount.access == MountAccess::Rw {
1005            tx.execute(
1006                "INSERT INTO session_mount_access(session_id, source, destination, access)
1007                 VALUES (?1, ?2, ?3, 'rw')",
1008                params![
1009                    session_id,
1010                    path_to_blob(&mount.source),
1011                    path_to_blob(&mount.destination)
1012                ],
1013            )?;
1014        }
1015    }
1016    Ok(())
1017}
1018
1019pub(super) fn load_mounts(connection: &Connection, state: &mut State) -> Result<()> {
1020    let mut statement = connection.prepare(
1021        "SELECT m.session_id, m.source, m.destination, m.read_only, a.access IS NOT NULL
1022         FROM session_mounts m
1023         LEFT JOIN session_mount_access a
1024             ON a.session_id = m.session_id
1025             AND a.source = m.source
1026             AND a.destination = m.destination
1027         ORDER BY m.session_id, m.ordinal",
1028    )?;
1029    let rows = statement.query_map([], |row| {
1030        // An older build that made the mount read-only left the access row
1031        // behind; its later choice wins.
1032        let access = match (row.get::<_, bool>(3)?, row.get::<_, bool>(4)?) {
1033            (true, _) => MountAccess::Ro,
1034            (false, true) => MountAccess::Rw,
1035            (false, false) => MountAccess::Cow,
1036        };
1037        Ok((
1038            row.get::<_, String>(0)?,
1039            AdditionalMount {
1040                source: blob_to_path(row.get_ref(1)?.as_blob()?),
1041                destination: blob_to_path(row.get_ref(2)?.as_blob()?),
1042                access,
1043            },
1044        ))
1045    })?;
1046    for row in rows {
1047        let (session_id, mount) = row?;
1048        if let Some(session) = state.sessions.get_mut(&session_id) {
1049            session.additional_mounts.push(mount);
1050        }
1051    }
1052    Ok(())
1053}
1054
1055pub(super) fn load_checkpoints(connection: &Connection, state: &mut State) -> Result<()> {
1056    let mut statement = connection.prepare(
1057        "SELECT session_id, archive_path, sha256, created_at, event_frontier FROM session_checkpoints",
1058    )?;
1059    let rows = statement.query_map([], |row| {
1060        Ok((
1061            row.get::<_, String>(0)?,
1062            CheckpointMetadata {
1063                archive_path: blob_to_path(row.get_ref(1)?.as_blob()?),
1064                sha256: row.get(2)?,
1065                created_at: row.get(3)?,
1066                event_frontier: row.get(4)?,
1067            },
1068        ))
1069    })?;
1070    for row in rows {
1071        let (session_id, checkpoint) = row?;
1072        if let Some(session) = state.sessions.get_mut(&session_id) {
1073            session.checkpoint = Some(checkpoint);
1074        }
1075    }
1076    Ok(())
1077}