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 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 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 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
510pub(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
637pub(super) fn update_lifecycle_fields(tx: &Transaction<'_>, session: &SessionRecord) -> Result<()> {
641 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 target: _,
674 native_session_id: _,
675 acp_session_title: _,
676 session_title_override: _,
677 created_at: _,
678 draft_input: _,
679 checkpoint: _,
681 } = session;
682 let changed = tx.execute(
683 "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 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 if let Some(session) = state.sessions.get_mut(&session_id) {
967 session.target = Some(target);
968 }
969 }
970 Ok(())
971}
972
973pub(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 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}