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