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