Skip to main content

mj_controller/database/
sessions.rs

1use super::*;
2
3/// Persist one operational session without rewriting unrelated controller
4/// state. Dashboard lifecycle jobs use this path so independent jobs can
5/// commit concurrently without restoring stale copies of other sessions.
6pub fn save_session(session: &SessionRecord) -> Result<()> {
7    let session = session.clone();
8    submit_database_write("save_session", move |_| {
9        save_session_to(&database_path(), &session)
10    })
11}
12
13/// Persist a borrowed-target child and its parent relationship atomically.
14pub fn save_subagent_session(
15    session: &SessionRecord,
16    subagent: &mj_core::subagent::SubagentRecord,
17) -> Result<()> {
18    let session = session.clone();
19    let subagent = subagent.clone();
20    submit_database_write("save_subagent_session", move |_| {
21        let mut connection = open(&database_path())?;
22        let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
23        insert_session(&tx, &session)?;
24        tx.execute(
25            "INSERT INTO subagent_sessions(
26                 child_session_id, parent_session_id, request_key, record_json
27             ) VALUES (?1, ?2, ?3, ?4)",
28            params![
29                subagent.child_session_id,
30                subagent.parent_session_id,
31                subagent.request_key,
32                serde_json::to_string(&subagent)?,
33            ],
34        )?;
35        tx.commit()?;
36        Ok(())
37    })
38}
39
40/// Record the child turn whose completion notice the parent already has.
41pub fn mark_subagent_turn_noticed(child_session_id: &str, turn: u64) -> Result<()> {
42    let child_session_id = child_session_id.to_owned();
43    submit_database_write("mark_subagent_turn_noticed", move |_| {
44        let mut relation = load_subagent(&child_session_id)?
45            .with_context(|| format!("unknown sub-agent session {child_session_id}"))?;
46        relation.noticed_turn = Some(turn);
47        let json = serde_json::to_string(&relation)?;
48        let connection = open(&database_path())?;
49        connection.execute(
50            "UPDATE subagent_sessions SET record_json = ?2 WHERE child_session_id = ?1",
51            params![child_session_id, json],
52        )?;
53        Ok(())
54    })
55}
56
57pub fn load_subagent(child_session_id: &str) -> Result<Option<mj_core::subagent::SubagentRecord>> {
58    let connection = open_reader(&database_path())?;
59    connection
60        .query_row(
61            "SELECT record_json FROM subagent_sessions WHERE child_session_id = ?1",
62            [child_session_id],
63            |row| row.get::<_, String>(0),
64        )
65        .optional()?
66        .map(|json| serde_json::from_str(&json).context("decode sub-agent record"))
67        .transpose()
68}
69
70pub fn list_subagents(parent_session_id: &str) -> Result<Vec<mj_core::subagent::SubagentRecord>> {
71    let connection = open_reader(&database_path())?;
72    let mut statement = connection.prepare(
73        "SELECT record_json FROM subagent_sessions
74         WHERE parent_session_id = ?1 ORDER BY rowid",
75    )?;
76    statement
77        .query_map([parent_session_id], |row| row.get::<_, String>(0))?
78        .map(|row| serde_json::from_str(&row?).context("decode sub-agent record"))
79        .collect()
80}
81
82pub fn lookup_subagent_request(
83    parent_session_id: &str,
84    request_key: &str,
85) -> Result<Option<mj_core::subagent::SubagentRecord>> {
86    let connection = open_reader(&database_path())?;
87    connection
88        .query_row(
89            "SELECT record_json FROM subagent_sessions
90             WHERE parent_session_id = ?1 AND request_key = ?2",
91            params![parent_session_id, request_key],
92            |row| row.get::<_, String>(0),
93        )
94        .optional()?
95        .map(|json| serde_json::from_str(&json).context("decode sub-agent record"))
96        .transpose()
97}
98
99/// Persist a session and the container size it most recently launched on its
100/// host in one transaction.
101pub fn save_session_with_container_size(
102    session: &SessionRecord,
103    host: &str,
104    size: HostContainerSize,
105) -> Result<()> {
106    let session = session.clone();
107    let host = host.to_owned();
108    submit_database_write("save_session_with_container_size", move |_| {
109        save_session_with_container_size_to(&database_path(), &session, Some((&host, size)))
110    })
111}
112
113/// Update only the fields a lifecycle transition owns on a session that
114/// already exists. Everything else — display titles, checkpoints, container
115/// settings, and attached directories — stays with its own writer.
116pub fn save_lifecycle_session(session: &SessionRecord) -> Result<()> {
117    let session = session.clone();
118    submit_database_write("save_lifecycle_session", move |_| {
119        save_lifecycle_session_to(&database_path(), &session)
120    })
121}
122
123/// Install a lifecycle transition together with the checkpoint it just
124/// verified and the harness session id that produced it.
125pub fn save_checkpointed_session(session: &SessionRecord) -> Result<()> {
126    let session = session.clone();
127    submit_database_write("save_checkpointed_session", move |_| {
128        save_checkpointed_session_to(&database_path(), &session)
129    })
130}
131
132/// Recover lifecycle rows stranded by a process exit during checkpoint
133/// creation. This must be called once by the top-level controller process
134/// while it owns the controller-store guard, not by per-operation reloads.
135pub fn recover_interrupted_checkpointing_sessions(updated_at: &str) -> Result<usize> {
136    let updated_at = updated_at.to_owned();
137    submit_database_write("recover_interrupted_checkpointing_sessions", move |_| {
138        recover_interrupted_checkpointing_sessions_to(&database_path(), &updated_at)
139    })
140}
141
142/// Change only the user-owned display name. This avoids writing a stale
143/// SessionRecord over independently committed checkpoint or relay metadata.
144pub fn set_session_title_override(session_id: &str, title: &str, updated_at: &str) -> Result<()> {
145    let session_id = session_id.to_owned();
146    let title = title.to_owned();
147    let updated_at = updated_at.to_owned();
148    submit_database_write("set_session_title_override", move |_| {
149        set_session_title_override_to(&database_path(), &session_id, &title, &updated_at)
150    })
151}
152
153/// Rewrite a configured profile id in every persisted session in one SQLite
154/// transaction. Configuration is stored separately, so the controller owns
155/// coordinating this update with the matching config-map rename.
156pub fn rename_profile_references(old_id: &str, new_id: &str) -> Result<usize> {
157    rename_session_reference("last_profile", old_id, new_id)
158}
159
160/// Rewrite a configured target id in every persisted session in one SQLite
161/// transaction.
162pub fn rename_target_references(old_id: &str, new_id: &str) -> Result<usize> {
163    rename_session_reference("target_template_id", old_id, new_id)
164}
165
166pub(super) fn rename_session_reference(
167    column: &'static str,
168    old_id: &str,
169    new_id: &str,
170) -> Result<usize> {
171    ensure!(
172        matches!(column, "last_profile" | "target_template_id"),
173        "unsupported session reference column"
174    );
175    let old_id = old_id.to_owned();
176    let new_id = new_id.to_owned();
177    submit_database_write("rename_session_reference", move |_| {
178        rename_session_reference_at(&database_path(), column, &old_id, &new_id)
179    })
180}
181
182pub(super) fn rename_session_reference_at(
183    path: &Path,
184    column: &str,
185    old_id: &str,
186    new_id: &str,
187) -> Result<usize> {
188    let mut connection = open(path)?;
189    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
190    let changed = tx.execute(
191        &format!("UPDATE sessions SET {column} = ?2 WHERE {column} = ?1"),
192        params![old_id, new_id],
193    )?;
194    tx.commit()?;
195    Ok(changed)
196}
197
198/// Change only whether the resume dialog hides this session. Archiving is a
199/// display choice, so it has its own writer and never rewrites lifecycle,
200/// checkpoint, or title columns another task owns.
201pub fn set_session_archived(session_id: &str, archived: bool) -> Result<()> {
202    let session_id = session_id.to_owned();
203    submit_database_write("set_session_archived", move |_| {
204        set_session_archived_to(&database_path(), &session_id, archived)
205    })
206}
207
208/// Record that the managed target of an otherwise live session is definitively
209/// gone. A verified checkpoint keeps the session recoverable as an error on the
210/// dashboard; without one, the session is lost. The state predicate keeps a
211/// late poll result from overwriting a concurrent lifecycle transition.
212pub fn mark_session_target_missing(
213    session_id: &str,
214    detail: &str,
215    updated_at: &str,
216) -> Result<Option<SessionState>> {
217    let session_id = session_id.to_owned();
218    let detail = detail.to_owned();
219    let updated_at = updated_at.to_owned();
220    submit_database_write("mark_session_target_missing", move |_| {
221        mark_session_target_missing_to(&database_path(), &session_id, &detail, &updated_at)
222    })
223}
224
225pub(super) fn mark_session_target_missing_to(
226    path: &Path,
227    session_id: &str,
228    detail: &str,
229    updated_at: &str,
230) -> Result<Option<SessionState>> {
231    mark_session_target_missing_if_current_to(path, session_id, detail, updated_at, None)
232}
233
234/// Record a definitive worker failure only while the observed session record
235/// is still current. A delayed background write must not invalidate a resume.
236pub fn mark_session_target_missing_if_current(
237    session_id: &str,
238    detail: &str,
239    updated_at: &str,
240    observed_updated_at: &str,
241) -> Result<Option<SessionState>> {
242    let session_id = session_id.to_owned();
243    let detail = detail.to_owned();
244    let updated_at = updated_at.to_owned();
245    let observed_updated_at = observed_updated_at.to_owned();
246    submit_database_write("mark_session_target_missing_if_current", move |_| {
247        mark_session_target_missing_if_current_to(
248            &database_path(),
249            &session_id,
250            &detail,
251            &updated_at,
252            Some(&observed_updated_at),
253        )
254    })
255}
256
257pub(super) fn mark_session_target_missing_if_current_to(
258    path: &Path,
259    session_id: &str,
260    detail: &str,
261    updated_at: &str,
262    observed_updated_at: Option<&str>,
263) -> Result<Option<SessionState>> {
264    let mut connection = open(path)?;
265    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
266    let changed = tx.execute(
267        "UPDATE sessions
268         SET state = CASE
269                 WHEN EXISTS(
270                     SELECT 1 FROM session_checkpoints
271                     WHERE session_checkpoints.session_id = sessions.session_id
272                 ) THEN 'error'
273                 ELSE 'lost'
274             END,
275             last_error = ?2,
276             updated_at = ?3
277         WHERE session_id = ?1
278           AND (?4 IS NULL OR updated_at = ?4)
279           AND state IN ('provisioning', 'running', 'disconnected', 'error')",
280        params![session_id, detail, updated_at, observed_updated_at],
281    )?;
282    ensure!(changed <= 1, "updated {changed} sessions for {session_id}");
283    let state = if changed == 1 {
284        let stored: String = tx.query_row(
285            "SELECT state FROM sessions WHERE session_id = ?1",
286            [session_id],
287            |row| row.get(0),
288        )?;
289        Some(stored_session_state(&stored))
290    } else {
291        None
292    };
293    tx.commit()?;
294    Ok(state)
295}
296
297pub(super) fn set_session_archived_to(path: &Path, session_id: &str, archived: bool) -> Result<()> {
298    let connection = open(path)?;
299    let changed = connection.execute(
300        "UPDATE sessions SET archived = ?2 WHERE session_id = ?1",
301        params![session_id, archived],
302    )?;
303    if changed != 1 {
304        bail!("unknown session {session_id}");
305    }
306    Ok(())
307}
308
309/// Native sessions the resume dialog hides. Hel never writes into a harness
310/// home, so the hidden set lives here instead of in the harness's own store.
311pub fn hidden_native_sessions() -> Result<BTreeSet<(mj_core::config::HarnessKind, String)>> {
312    hidden_native_sessions_from(&database_path())
313}
314
315pub(super) fn hidden_native_sessions_from(
316    path: &Path,
317) -> Result<BTreeSet<(mj_core::config::HarnessKind, String)>> {
318    let connection = open_reader(path)?;
319    let mut statement =
320        connection.prepare("SELECT harness_kind, native_session_id FROM hidden_native_sessions")?;
321    let rows = statement.query_map([], |row| {
322        Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
323    })?;
324    let mut hidden = BTreeSet::new();
325    for row in rows {
326        let (harness, native_session_id) = row?;
327        // Rows for a harness this release no longer supports are ignored, not
328        // fatal; they simply hide nothing.
329        match harness.parse::<mj_core::config::HarnessKind>() {
330            Ok(harness) => {
331                hidden.insert((harness, native_session_id));
332            }
333            Err(_) => tracing::warn!(
334                harness = %harness,
335                "ignoring a hidden native session for a harness that is no longer supported"
336            ),
337        }
338    }
339    Ok(hidden)
340}
341
342/// Hide or reveal one native session in the resume dialog.
343pub fn set_native_session_hidden(
344    harness: mj_core::config::HarnessKind,
345    native_session_id: &str,
346    hidden: bool,
347) -> Result<()> {
348    let native_session_id = native_session_id.to_owned();
349    submit_database_write("set_native_session_hidden", move |_| {
350        set_native_session_hidden_to(&database_path(), harness, &native_session_id, hidden)
351    })
352}
353
354pub(super) fn set_native_session_hidden_to(
355    path: &Path,
356    harness: mj_core::config::HarnessKind,
357    native_session_id: &str,
358    hidden: bool,
359) -> Result<()> {
360    if native_session_id.trim().is_empty() {
361        bail!("native session id is empty");
362    }
363    let connection = open(path)?;
364    if hidden {
365        connection.execute(
366            "INSERT INTO hidden_native_sessions(harness_kind, native_session_id, hidden_at)
367             VALUES (?1, ?2, ?3)
368             ON CONFLICT(harness_kind, native_session_id) DO NOTHING",
369            params![harness.id(), native_session_id, Utc::now().to_rfc3339()],
370        )?;
371    } else {
372        connection.execute(
373            "DELETE FROM hidden_native_sessions
374             WHERE harness_kind = ?1 AND native_session_id = ?2",
375            params![harness.id(), native_session_id],
376        )?;
377    }
378    Ok(())
379}
380
381/// Change only the per-session container provisioning inputs: the size
382/// overrides and the attached directories. Everything else the session row
383/// owns is left to its own writer.
384pub fn set_session_container_settings(
385    session_id: &str,
386    cpus: Option<&str>,
387    memory: Option<&str>,
388    mounts: &[AdditionalMount],
389    updated_at: &str,
390) -> Result<()> {
391    let session_id = session_id.to_owned();
392    let cpus = cpus.map(str::to_owned);
393    let memory = memory.map(str::to_owned);
394    let mounts = mounts.to_vec();
395    let updated_at = updated_at.to_owned();
396    submit_database_write("set_session_container_settings", move |_| {
397        set_session_container_settings_to(
398            &database_path(),
399            &session_id,
400            cpus.as_deref(),
401            memory.as_deref(),
402            &mounts,
403            &updated_at,
404        )
405    })
406}
407
408pub(super) fn set_session_container_settings_to(
409    path: &Path,
410    session_id: &str,
411    cpus: Option<&str>,
412    memory: Option<&str>,
413    mounts: &[AdditionalMount],
414    updated_at: &str,
415) -> Result<()> {
416    if updated_at.trim().is_empty() {
417        bail!("session update timestamp is empty");
418    }
419    crate::targets::validate_additional_mounts(mounts)?;
420    let mut connection = open(path)?;
421    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
422    let changed = tx.execute(
423        "UPDATE sessions
424         SET container_cpus = ?2, container_memory = ?3, updated_at = ?4
425         WHERE session_id = ?1",
426        params![session_id, cpus, memory, updated_at],
427    )?;
428    if changed != 1 {
429        bail!("unknown session {session_id}");
430    }
431    replace_mounts(&tx, session_id, mounts)?;
432    tx.commit()?;
433    Ok(())
434}
435
436pub(super) fn set_session_title_override_to(
437    path: &Path,
438    session_id: &str,
439    title: &str,
440    updated_at: &str,
441) -> Result<()> {
442    if title.trim().is_empty() {
443        bail!("session title is empty");
444    }
445    if updated_at.trim().is_empty() {
446        bail!("session update timestamp is empty");
447    }
448    let connection = open(path)?;
449    let changed = connection.execute(
450        "UPDATE sessions
451         SET session_title_override = ?2, updated_at = ?3
452         WHERE session_id = ?1",
453        params![session_id, title, updated_at],
454    )?;
455    if changed != 1 {
456        bail!("unknown session {session_id}");
457    }
458    Ok(())
459}
460
461/// Persist the latest ACP-provided title without replacing unrelated session
462/// fields that may have changed in another supervised controller task.
463pub fn set_session_acp_title(session_id: &str, title: Option<&str>) -> Result<()> {
464    let session_id = session_id.to_owned();
465    let title = title.map(str::to_owned);
466    submit_database_write("set_session_acp_title", move |_| {
467        set_session_acp_title_to(&database_path(), &session_id, title.as_deref())
468    })
469}
470
471pub(super) fn set_session_acp_title_to(
472    path: &Path,
473    session_id: &str,
474    title: Option<&str>,
475) -> Result<()> {
476    if title.is_some_and(|title| title.trim().is_empty()) {
477        bail!("ACP session title is empty");
478    }
479    let title = title.and_then(mj_core::state::normalize_session_title);
480    let connection = open(path)?;
481    let changed = connection.execute(
482        "UPDATE sessions SET acp_session_title = ?2 WHERE session_id = ?1",
483        params![session_id, title],
484    )?;
485    if changed != 1 {
486        bail!("unknown session {session_id}");
487    }
488    Ok(())
489}
490
491/// Commit the successful handshake for a newly provisioned worker without
492/// replacing checkpoint or display metadata owned by other controller tasks.
493pub fn mark_session_worker_connected(
494    session_id: &str,
495    native_session_id: Option<&str>,
496    updated_at: &str,
497) -> Result<()> {
498    let session_id = session_id.to_owned();
499    let native_session_id = native_session_id.map(str::to_owned);
500    let updated_at = updated_at.to_owned();
501    submit_database_write("mark_session_worker_connected", move |_| {
502        mark_session_worker_connected_to(
503            &database_path(),
504            &session_id,
505            native_session_id.as_deref(),
506            &updated_at,
507        )
508    })
509}
510
511/// Point a session at a native session its worker opened on its own. Only that
512/// column moves: the session's lifecycle state belongs to whatever operation is
513/// running.
514pub fn adopt_native_session_id(session_id: &str, native_session_id: &str) -> Result<()> {
515    let session_id = session_id.to_owned();
516    let native_session_id = native_session_id.to_owned();
517    submit_database_write("adopt_native_session_id", move |_| {
518        adopt_native_session_id_to(&database_path(), &session_id, &native_session_id)
519    })
520}
521
522pub(super) fn adopt_native_session_id_to(
523    path: &Path,
524    session_id: &str,
525    native_session_id: &str,
526) -> Result<()> {
527    let connection = open(path)?;
528    let changed = connection.execute(
529        "UPDATE sessions SET native_session_id = ?2 WHERE session_id = ?1",
530        params![session_id, native_session_id],
531    )?;
532    if changed != 1 {
533        bail!("unknown session {session_id}");
534    }
535    Ok(())
536}
537
538pub(super) fn mark_session_worker_connected_to(
539    path: &Path,
540    session_id: &str,
541    native_session_id: Option<&str>,
542    updated_at: &str,
543) -> Result<()> {
544    if updated_at.trim().is_empty() {
545        bail!("worker connection timestamp is empty");
546    }
547    let connection = open(path)?;
548    let changed = connection.execute(
549        "UPDATE sessions
550         SET state = 'running',
551             native_session_id = coalesce(?2, native_session_id),
552             updated_at = ?3,
553             last_error = NULL
554         WHERE session_id = ?1",
555        params![session_id, native_session_id, updated_at],
556    )?;
557    if changed != 1 {
558        bail!("unknown session {session_id}");
559    }
560    Ok(())
561}
562
563pub(super) fn recover_interrupted_checkpointing_sessions_to(
564    path: &Path,
565    updated_at: &str,
566) -> Result<usize> {
567    if updated_at.trim().is_empty() {
568        bail!("checkpoint recovery timestamp is empty");
569    }
570    let connection = open(path)?;
571    connection
572        .execute(
573            "UPDATE sessions
574             SET state = 'running', updated_at = ?1, last_checkpoint_error = ?2
575             WHERE state = 'checkpointing'",
576            params![
577                updated_at,
578                "checkpointing was interrupted by a controller restart; the target was left running"
579            ],
580        )
581        .context("recover interrupted checkpointing sessions")
582}
583
584pub(super) fn save_session_to(path: &Path, session: &SessionRecord) -> Result<()> {
585    save_session_with_container_size_to(path, session, None)
586}
587
588pub(super) fn save_session_with_container_size_to(
589    path: &Path,
590    session: &SessionRecord,
591    container_size: Option<(&str, HostContainerSize)>,
592) -> Result<()> {
593    validate_session_record(session)?;
594
595    let mut connection = open(path)?;
596    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
597    if let Some(existing_bundle) = tx
598        .query_row(
599            "SELECT bundle_id FROM session_contexts WHERE session_id = ?1",
600            [session.id.as_str()],
601            |row| row.get::<_, String>(0),
602        )
603        .optional()?
604        && existing_bundle != session.bundle_id
605    {
606        bail!(
607            "session {} was already associated with bundle {}, not {}",
608            session.id,
609            existing_bundle,
610            session.bundle_id
611        );
612    }
613    let mut session = session.clone();
614    let moving: bool = tx.query_row(
615        "SELECT EXISTS(SELECT 1 FROM session_moves WHERE session_id=?1
616         AND json_extract(operation_json, '$.phase') IN ('preparing','closing_source','resuming_destination','starting_queue'))",
617        [&session.id], |row| row.get(0),
618    )?;
619    if moving {
620        // A Move may provision for minutes while clients keep editing drafts
621        // and titles. Merge these independently owned fields in this same
622        // transaction rather than restoring the lifecycle's earlier copy.
623        let (draft, title, acp_title, viewed, archived) = tx.query_row(
624            "SELECT draft_input, session_title_override, acp_session_title, viewed_through_event_ordinal, archived
625             FROM sessions WHERE session_id=?1", [&session.id], |row| Ok((
626                row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?, row.get::<_, Option<String>>(2)?,
627                row.get::<_, u64>(3)?, row.get::<_, bool>(4)?,
628            )),
629        )?;
630        session.draft_input = draft;
631        session.session_title_override = title;
632        session.acp_session_title = acp_title;
633        session.viewed_through_event_ordinal = viewed;
634        session.archived = archived;
635    }
636    insert_session(&tx, &session)?;
637    if let Some((host, size)) = container_size {
638        write_host_container_size(&tx, host, size)?;
639    }
640    tx.commit()?;
641    Ok(())
642}
643
644pub(super) fn save_lifecycle_session_to(path: &Path, session: &SessionRecord) -> Result<()> {
645    validate_session_record(session)?;
646
647    let mut connection = open(path)?;
648    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
649    update_lifecycle_fields(&tx, session)?;
650    tx.commit()?;
651    Ok(())
652}
653
654pub(super) fn save_checkpointed_session_to(path: &Path, session: &SessionRecord) -> Result<()> {
655    validate_session_record(session)?;
656
657    let mut connection = open(path)?;
658    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
659    update_lifecycle_fields(&tx, session)?;
660    tx.execute(
661        "UPDATE sessions SET native_session_id = ?2 WHERE session_id = ?1",
662        params![session.id, session.native_session_id],
663    )?;
664    replace_checkpoint(&tx, session)?;
665    tx.commit()?;
666    Ok(())
667}
668
669pub(super) fn validate_session_record(session: &SessionRecord) -> Result<()> {
670    let mut validation = State::default();
671    validation
672        .sessions
673        .insert(session.id.clone(), session.clone());
674    validation.validate()
675}
676
677/// Remove one operational session while retaining its relational history
678/// context and prompt history.
679pub fn delete_session(session_id: &str) -> Result<()> {
680    let session_id = session_id.to_owned();
681    submit_database_write("delete_session", move |_| {
682        delete_session_from(&database_path(), &session_id)
683    })
684}
685
686pub(super) fn delete_session_from(path: &Path, session_id: &str) -> Result<()> {
687    let connection = open(path)?;
688    connection.execute("DELETE FROM sessions WHERE session_id = ?1", [session_id])?;
689    Ok(())
690}
691
692/// Overwrite the unsent chat input carried across a detach. Unlike the read
693/// receipt this is not monotonic: a draft can shrink, and an empty string
694/// clears it.
695pub fn set_session_draft_input(session_id: &str, draft: &str) -> Result<()> {
696    let session_id = session_id.to_owned();
697    let draft = draft.to_owned();
698    submit_database_write("set_session_draft_input", move |_| {
699        set_session_draft_input_at(&database_path(), &session_id, &draft)
700    })
701}
702
703pub(super) fn set_session_draft_input_at(path: &Path, session_id: &str, draft: &str) -> Result<()> {
704    let mut connection = open(path)?;
705    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
706    let updated = tx.execute(
707        "UPDATE sessions SET draft_input = ?2 WHERE session_id = ?1",
708        params![session_id, draft],
709    )?;
710    ensure!(updated == 1, "unknown session {session_id}");
711    tx.commit()?;
712    Ok(())
713}
714
715/// Retire a submitted shared draft without erasing a newer client's edit.
716pub fn clear_session_draft_input_if_matches(session_id: &str, expected: &str) -> Result<()> {
717    let session_id = session_id.to_owned();
718    let expected = expected.to_owned();
719    submit_database_write("clear_session_draft_input_if_matches", move |connection| {
720        connection.execute(
721            "UPDATE sessions SET draft_input = '' WHERE session_id = ?1 AND draft_input = ?2",
722            params![session_id, expected],
723        )?;
724        Ok(())
725    })
726}
727
728pub fn record_recovery_success(
729    session_id: &str,
730    native_session_id: &str,
731    checkpoint: &CheckpointMetadata,
732) -> Result<()> {
733    let session_id = session_id.to_owned();
734    let native_session_id = native_session_id.to_owned();
735    let checkpoint = checkpoint.clone();
736    submit_database_write("record_recovery_success", move |_| {
737        record_recovery_success_to(
738            &database_path(),
739            &session_id,
740            &native_session_id,
741            &checkpoint,
742        )
743    })
744}
745
746pub(super) fn record_recovery_success_to(
747    path: &Path,
748    session_id: &str,
749    native_session_id: &str,
750    checkpoint: &CheckpointMetadata,
751) -> Result<()> {
752    let mut connection = open(path)?;
753    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
754    let changed = tx.execute(
755        "UPDATE sessions
756         SET native_session_id = ?2, last_checkpoint_error = NULL
757         WHERE session_id = ?1",
758        params![session_id, native_session_id],
759    )?;
760    if changed != 1 {
761        bail!("unknown session {session_id}");
762    }
763    tx.execute(
764        "INSERT INTO session_checkpoints(
765             session_id, archive_path, sha256, created_at, event_frontier
766         ) VALUES (?1,?2,?3,?4,?5)
767         ON CONFLICT(session_id) DO UPDATE SET
768             archive_path = excluded.archive_path,
769             sha256 = excluded.sha256,
770             created_at = excluded.created_at,
771             event_frontier = excluded.event_frontier",
772        params![
773            session_id,
774            path_to_blob(&checkpoint.archive_path),
775            checkpoint.sha256,
776            checkpoint.created_at,
777            checkpoint.event_frontier,
778        ],
779    )?;
780    tx.commit()?;
781    Ok(())
782}
783
784pub fn record_recovery_failure(session_id: &str, detail: &str) -> Result<()> {
785    let session_id = session_id.to_owned();
786    let detail = detail.to_owned();
787    submit_database_write("record_recovery_failure", move |_| {
788        record_recovery_failure_to(&database_path(), &session_id, &detail)
789    })
790}
791
792pub(super) fn record_recovery_failure_to(
793    path: &Path,
794    session_id: &str,
795    detail: &str,
796) -> Result<()> {
797    let connection = open(path)?;
798    let changed = connection.execute(
799        "UPDATE sessions SET last_checkpoint_error = ?2 WHERE session_id = ?1",
800        params![session_id, detail],
801    )?;
802    if changed != 1 {
803        bail!("unknown session {session_id}");
804    }
805    Ok(())
806}
807
808/// Re-associate a session with another project bundle.
809///
810/// A session's bundle is otherwise fixed, because prompt history is grouped by
811/// it. Resume calls this when it converts a session between its raw and bundle
812/// representations: the project is the same, so its history follows it, and
813/// only the name Hel files it under changes.
814pub fn rebind_session_bundle(session_id: &str, bundle_id: &str) -> Result<()> {
815    let session_id = session_id.to_owned();
816    let bundle_id = bundle_id.to_owned();
817    submit_database_write("rebind_session_bundle", move |_| {
818        rebind_session_bundle_to(&database_path(), &session_id, &bundle_id)
819    })
820}
821
822pub(super) fn rebind_session_bundle_to(
823    path: &Path,
824    session_id: &str,
825    bundle_id: &str,
826) -> Result<()> {
827    let mut connection = open(path)?;
828    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
829    let changed = tx.execute(
830        "UPDATE session_contexts SET bundle_id = ?2 WHERE session_id = ?1",
831        params![session_id, bundle_id],
832    )?;
833    if changed == 0 {
834        tx.execute(
835            "INSERT INTO session_contexts(session_id, bundle_id, created_at) VALUES (?1, ?2, ?3)",
836            params![session_id, bundle_id, Utc::now().to_rfc3339()],
837        )?;
838    }
839    tx.commit()?;
840    Ok(())
841}