Skip to main content

mj_controller/database/
workspaces.rs

1use super::*;
2
3/// Every workspace that can hold a session the user sees.
4///
5/// The store keeps a row with the id `default` that older releases put
6/// sessions in when no workspace was named. No new session goes there, and
7/// the row is left out only while it holds no session at all: a store whose
8/// `default` holds sessions, suspended ones included, lists it as an ordinary
9/// workspace named `default`, so none of those sessions is hidden (launch
10/// finding H-3).
11pub fn list_workspaces() -> Result<Vec<WorkspaceRecord>> {
12    list_workspaces_from(&database_path())
13}
14
15/// The `default` row keeps its name whether or not it is listed. While it
16/// holds no session it is not listed, so creating a workspace with its name
17/// would return a workspace nobody can see. Refuse the name then.
18fn refuse_legacy_default_name(connection: &Connection, name_key: &str) -> Result<()> {
19    let hidden: bool = connection.query_row(
20        "SELECT EXISTS(
21             SELECT 1 FROM workspaces w
22              WHERE w.workspace_id = ?1 AND w.name_key = ?2
23                AND NOT EXISTS(
24                    SELECT 1 FROM session_contexts c JOIN sessions s USING(session_id)
25                     WHERE c.workspace_id = w.workspace_id))",
26        [DEFAULT_WORKSPACE_ID, name_key],
27        |row| row.get(0),
28    )?;
29    ensure!(
30        !hidden,
31        "the workspace name {name_key:?} is reserved: it holds sessions made before Mjolnir had workspaces; choose another name"
32    );
33    Ok(())
34}
35
36pub(super) struct DbPaneSize(PaneSize);
37
38impl rusqlite::types::ToSql for DbPaneSize {
39    fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput<'_>> {
40        Ok(match self.0 {
41            PaneSize::Minimized => "minimized",
42            PaneSize::Standard => "standard",
43            PaneSize::Maximized => "maximized",
44        }
45        .into())
46    }
47}
48
49impl rusqlite::types::FromSql for DbPaneSize {
50    fn column_result(value: rusqlite::types::ValueRef<'_>) -> rusqlite::types::FromSqlResult<Self> {
51        match value.as_str()? {
52            "minimized" => Ok(Self(PaneSize::Minimized)),
53            "standard" => Ok(Self(PaneSize::Standard)),
54            "maximized" => Ok(Self(PaneSize::Maximized)),
55            other => Err(rusqlite::types::FromSqlError::Other(
56                format!("unknown pane size {other:?}").into(),
57            )),
58        }
59    }
60}
61
62pub fn load_workspace_pane_sizes(workspace_id: &str) -> Result<PaneSizes> {
63    load_workspace_pane_sizes_from(&database_path(), workspace_id)
64}
65
66pub fn load_workspace_pane_sizes_from(path: &Path, workspace_id: &str) -> Result<PaneSizes> {
67    let connection = open_reader(path)?;
68    let sizes = connection
69        .query_row(
70            "SELECT coalesce(p.sessions, 'standard'), coalesce(p.targets, 'standard'),
71                    coalesce(p.quota, 'standard')
72             FROM workspaces w LEFT JOIN workspace_pane_sizes p USING(workspace_id)
73             WHERE w.workspace_id = ?1",
74            [workspace_id],
75            |row| {
76                Ok(PaneSizes {
77                    sessions: row.get::<_, DbPaneSize>(0)?.0,
78                    targets: row.get::<_, DbPaneSize>(1)?.0,
79                    quota: row.get::<_, DbPaneSize>(2)?.0,
80                })
81            },
82        )
83        .optional()?
84        .with_context(|| format!("unknown workspace {workspace_id:?}"))?;
85    sizes.validate()?;
86    Ok(sizes)
87}
88
89pub fn save_workspace_pane_sizes(workspace_id: &str, sizes: PaneSizes) -> Result<()> {
90    let workspace_id = workspace_id.to_owned();
91    submit_database_write("save_workspace_pane_sizes", move |_| {
92        save_workspace_pane_sizes_to(&database_path(), &workspace_id, sizes)
93    })
94}
95
96pub fn save_workspace_pane_sizes_to(
97    path: &Path,
98    workspace_id: &str,
99    sizes: PaneSizes,
100) -> Result<()> {
101    sizes.validate()?;
102    let connection = open(path)?;
103    connection
104        .execute(
105            "INSERT INTO workspace_pane_sizes(workspace_id, sessions, targets, quota)
106         VALUES (?1, ?2, ?3, ?4)
107         ON CONFLICT(workspace_id) DO UPDATE SET
108             sessions = excluded.sessions, targets = excluded.targets, quota = excluded.quota",
109            params![
110                workspace_id,
111                DbPaneSize(sizes.sessions),
112                DbPaneSize(sizes.targets),
113                DbPaneSize(sizes.quota)
114            ],
115        )
116        .with_context(|| format!("save pane sizes for workspace {workspace_id:?}"))?;
117    Ok(())
118}
119
120pub fn load_workspace_layout(workspace_id: &str) -> Result<ConversationLayout> {
121    load_workspace_layout_from(&database_path(), workspace_id)
122}
123
124pub fn load_workspace_layout_from(path: &Path, workspace_id: &str) -> Result<ConversationLayout> {
125    let connection = open_reader(path)?;
126    let stored = connection
127        .query_row(
128            "SELECT l.layout
129             FROM workspaces w LEFT JOIN workspace_layouts l USING(workspace_id)
130             WHERE w.workspace_id = ?1",
131            [workspace_id],
132            |row| row.get::<_, Option<String>>(0),
133        )
134        .optional()?
135        .with_context(|| format!("unknown workspace {workspace_id:?}"))?;
136    let Some(stored) = stored else {
137        return Ok(ConversationLayout::default());
138    };
139    let layout: ConversationLayout = serde_json::from_str(&stored)
140        .with_context(|| format!("decode layout for workspace {workspace_id:?}"))?;
141    layout.validate()?;
142    Ok(layout)
143}
144
145pub fn save_workspace_layout(workspace_id: &str, layout: ConversationLayout) -> Result<()> {
146    let workspace_id = workspace_id.to_owned();
147    submit_database_write("save_workspace_layout", move |_| {
148        save_workspace_layout_to(&database_path(), &workspace_id, &layout)
149    })
150}
151
152pub fn save_workspace_layout_to(
153    path: &Path,
154    workspace_id: &str,
155    layout: &ConversationLayout,
156) -> Result<()> {
157    layout.validate()?;
158    let encoded = serde_json::to_string(layout)?;
159    let connection = open(path)?;
160    connection
161        .execute(
162            "INSERT INTO workspace_layouts(workspace_id, layout)
163         VALUES (?1, ?2)
164         ON CONFLICT(workspace_id) DO UPDATE SET layout = excluded.layout",
165            params![workspace_id, encoded],
166        )
167        .with_context(|| format!("save layout for workspace {workspace_id:?}"))?;
168    Ok(())
169}
170
171pub fn list_workspaces_from(path: &Path) -> Result<Vec<WorkspaceRecord>> {
172    let connection = open_reader(path)?;
173    let mut statement = connection.prepare(
174        "SELECT w.workspace_id, w.name, w.created_at, w.last_opened_at,
175                count(s.session_id) FILTER (
176                    WHERE s.state NOT IN ('stopped', 'lost', 'destroyed-with-data-loss')
177                )
178          FROM workspaces w
179           LEFT JOIN session_contexts c USING(workspace_id)
180           LEFT JOIN sessions s USING(session_id)
181          GROUP BY w.workspace_id
182         HAVING w.workspace_id != 'default' OR count(s.session_id) > 0
183          ORDER BY w.last_opened_at DESC, w.created_at DESC, w.workspace_id",
184    )?;
185    let rows = statement.query_map([], |row| {
186        Ok(WorkspaceRecord {
187            id: row.get(0)?,
188            name: row.get(1)?,
189            created_at: row.get(2)?,
190            last_opened_at: row.get(3)?,
191            session_count: row.get(4)?,
192        })
193    })?;
194    rows.collect::<rusqlite::Result<_>>().map_err(Into::into)
195}
196
197pub fn create_workspace(name: &str) -> Result<WorkspaceRecord> {
198    let name = name.to_owned();
199    submit_database_write("create_workspace", move |_| {
200        create_workspace_at(&database_path(), &name)
201    })
202}
203
204/// Create the named workspace, or return the concurrently-created winner.
205///
206/// Interactive setup uses this operation after presenting a snapshot of the
207/// workspace list. Several selectors can therefore submit the same normalized
208/// name legitimately. Explicit database creation remains strict through
209/// [`create_workspace`].
210pub fn create_or_get_workspace(name: &str) -> Result<WorkspaceRecord> {
211    let name = name.to_owned();
212    submit_database_write("create_or_get_workspace", move |_| {
213        create_or_get_workspace_at(&database_path(), &name)
214    })
215}
216
217pub fn create_or_get_workspace_at(path: &Path, name: &str) -> Result<WorkspaceRecord> {
218    let (name, name_key) = normalize_workspace_name(name)?;
219    let id = new_workspace_id()?;
220    let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
221    let mut connection = open(path)?;
222    refuse_legacy_default_name(&connection, &name_key)?;
223    let transaction = connection.transaction()?;
224    transaction
225        .execute(
226            "INSERT INTO workspaces(workspace_id, name, name_key, created_at, last_opened_at)
227             VALUES (?1, ?2, ?3, ?4, ?4)
228             ON CONFLICT(name_key) DO NOTHING",
229            params![id, name, name_key, now],
230        )
231        .with_context(|| format!("create or find workspace {name:?}"))?;
232    let workspace = transaction.query_row(
233        "SELECT w.workspace_id, w.name, w.created_at, w.last_opened_at,
234                count(s.session_id) FILTER (
235                    WHERE s.state NOT IN ('stopped', 'lost', 'destroyed-with-data-loss')
236                )
237           FROM workspaces w
238           LEFT JOIN session_contexts c USING(workspace_id)
239           LEFT JOIN sessions s USING(session_id)
240          WHERE w.name_key = ?1
241          GROUP BY w.workspace_id",
242        params![name_key],
243        |row| {
244            Ok(WorkspaceRecord {
245                id: row.get(0)?,
246                name: row.get(1)?,
247                created_at: row.get(2)?,
248                last_opened_at: row.get(3)?,
249                session_count: row.get(4)?,
250            })
251        },
252    )?;
253    transaction.commit()?;
254    Ok(workspace)
255}
256
257pub fn create_workspace_at(path: &Path, name: &str) -> Result<WorkspaceRecord> {
258    let (name, name_key) = normalize_workspace_name(name)?;
259    let id = new_workspace_id()?;
260    let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
261    let connection = open(path)?;
262    refuse_legacy_default_name(&connection, &name_key)?;
263    connection
264        .execute(
265            "INSERT INTO workspaces(workspace_id, name, name_key, created_at, last_opened_at)
266             VALUES (?1, ?2, ?3, ?4, ?4)",
267            params![id, name, name_key, now],
268        )
269        .with_context(|| format!("create workspace {name:?}"))?;
270    Ok(WorkspaceRecord {
271        id,
272        name,
273        created_at: now.clone(),
274        last_opened_at: now,
275        session_count: 0,
276    })
277}
278
279pub fn rename_workspace(workspace_id: &str, name: &str) -> Result<()> {
280    let workspace_id = workspace_id.to_owned();
281    let name = name.to_owned();
282    submit_database_write("rename_workspace", move |_| {
283        rename_workspace_at(&database_path(), &workspace_id, &name)
284    })
285}
286
287pub fn rename_workspace_at(path: &Path, workspace_id: &str, name: &str) -> Result<()> {
288    let (name, name_key) = normalize_workspace_name(name)?;
289    let connection = open(path)?;
290    let changed = connection
291        .execute(
292            "UPDATE workspaces SET name = ?2, name_key = ?3 WHERE workspace_id = ?1",
293            params![workspace_id, name, name_key],
294        )
295        .with_context(|| format!("rename workspace to {name:?}"))?;
296    ensure!(changed == 1, "unknown workspace {workspace_id:?}");
297    Ok(())
298}
299
300pub fn touch_workspace(workspace_id: &str) -> Result<()> {
301    let workspace_id = workspace_id.to_owned();
302    submit_database_write("touch_workspace", move |_| {
303        touch_workspace_at(&database_path(), &workspace_id)
304    })
305}
306
307pub fn touch_workspace_at(path: &Path, workspace_id: &str) -> Result<()> {
308    let connection = open(path)?;
309    let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
310    let changed = connection.execute(
311        "UPDATE workspaces SET last_opened_at = ?2 WHERE workspace_id = ?1",
312        params![workspace_id, now],
313    )?;
314    ensure!(changed == 1, "unknown workspace {workspace_id:?}");
315    Ok(())
316}
317
318/// Delete a workspace that owns no active sessions or recoverable drafts.
319///
320/// Inactive session records are global resume history. Their last workspace
321/// id is retained as historical metadata even when that workspace disappears.
322pub fn delete_workspace(workspace_id: &str) -> Result<()> {
323    let workspace_id = workspace_id.to_owned();
324    submit_database_write("delete_workspace", move |_| {
325        delete_workspace_at(&database_path(), &workspace_id)
326    })
327}
328
329pub fn delete_workspace_at(path: &Path, workspace_id: &str) -> Result<()> {
330    let mut connection = open(path)?;
331    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
332    let active_count = {
333        let mut statement = tx.prepare(
334            "SELECT s.state
335               FROM session_contexts c
336               JOIN sessions s USING(session_id)
337              WHERE c.workspace_id = ?1",
338        )?;
339        let states = statement.query_map([workspace_id], |row| row.get::<_, String>(0))?;
340        states
341            .collect::<rusqlite::Result<Vec<_>>>()?
342            .into_iter()
343            .filter(|state| stored_session_state(state).is_active())
344            .count()
345    };
346    let draft_count: u64 = tx.query_row(
347        "SELECT count(*) FROM detached_drafts WHERE workspace_id = ?1",
348        [workspace_id],
349        |row| row.get(0),
350    )?;
351    ensure!(
352        active_count == 0 && draft_count == 0,
353        "workspace is not empty ({active_count} active sessions, {draft_count} drafts)"
354    );
355    let changed = tx.execute(
356        "DELETE FROM workspaces WHERE workspace_id = ?1",
357        [workspace_id],
358    )?;
359    ensure!(changed == 1, "unknown workspace {workspace_id:?}");
360    tx.commit()?;
361    Ok(())
362}
363
364/// Finish a normal workspace close, retaining history but discarding unsent text.
365pub fn close_workspace(workspace_id: &str) -> Result<()> {
366    let workspace_id = workspace_id.to_owned();
367    submit_database_write("close_workspace", move |_| {
368        close_workspace_at(&database_path(), &workspace_id)
369    })
370}
371
372pub fn close_workspace_at(path: &Path, workspace_id: &str) -> Result<()> {
373    remove_inactive_workspace_at(path, workspace_id, true)
374}
375
376fn remove_inactive_workspace_at(
377    path: &Path,
378    workspace_id: &str,
379    discard_session_drafts: bool,
380) -> Result<()> {
381    let mut connection = open(path)?;
382    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
383    let active_count = {
384        let mut statement = tx.prepare(
385            "SELECT s.state
386               FROM session_contexts c
387               JOIN sessions s USING(session_id)
388              WHERE c.workspace_id = ?1",
389        )?;
390        let states = statement.query_map([workspace_id], |row| row.get::<_, String>(0))?;
391        states
392            .collect::<rusqlite::Result<Vec<_>>>()?
393            .into_iter()
394            .filter(|state| stored_session_state(state).is_active())
395            .count()
396    };
397    ensure!(
398        active_count == 0,
399        "workspace is not empty ({active_count} active sessions remain)"
400    );
401    if discard_session_drafts {
402        tx.execute(
403            "UPDATE sessions SET draft_input = '' WHERE session_id IN
404             (SELECT session_id FROM session_contexts WHERE workspace_id = ?1)",
405            [workspace_id],
406        )?;
407    }
408    tx.execute(
409        "DELETE FROM detached_drafts WHERE workspace_id = ?1",
410        [workspace_id],
411    )?;
412    let changed = tx.execute(
413        "DELETE FROM workspaces WHERE workspace_id = ?1",
414        [workspace_id],
415    )?;
416    ensure!(changed == 1, "unknown workspace {workspace_id:?}");
417    tx.commit()?;
418    Ok(())
419}
420
421/// Move a durable history into the workspace from which it is being resumed.
422///
423/// This is deliberately limited to states accepted by the resume controller;
424/// an active session must never move between live dashboards underneath its
425/// worker or viewers.
426pub fn reassign_resumable_session_workspace(session_id: &str, workspace_id: &str) -> Result<()> {
427    let session_id = session_id.to_owned();
428    let workspace_id = workspace_id.to_owned();
429    submit_database_write("reassign_resumable_session_workspace", move |_| {
430        reassign_resumable_session_workspace_at(&database_path(), &session_id, &workspace_id)
431    })
432}
433
434pub fn reassign_resumable_session_workspace_at(
435    path: &Path,
436    session_id: &str,
437    workspace_id: &str,
438) -> Result<()> {
439    let mut connection = open(path)?;
440    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
441    let (current_workspace, state): (String, String) = tx
442        .query_row(
443            "SELECT c.workspace_id, s.state
444               FROM session_contexts c
445               JOIN sessions s USING(session_id)
446              WHERE c.session_id = ?1",
447            [session_id],
448            |row| Ok((row.get(0)?, row.get(1)?)),
449        )
450        .with_context(|| format!("find resumable session {session_id:?}"))?;
451    ensure!(
452        matches!(
453            stored_session_state(&state),
454            SessionState::Stopped | SessionState::Lost | SessionState::Error
455        ),
456        "session {session_id} is not resumable"
457    );
458    let destination_exists: bool = tx.query_row(
459        "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)",
460        [workspace_id],
461        |row| row.get(0),
462    )?;
463    ensure!(destination_exists, "unknown workspace {workspace_id:?}");
464    if current_workspace != workspace_id {
465        tx.execute(
466            "UPDATE session_contexts SET workspace_id = ?2 WHERE session_id = ?1",
467            params![session_id, workspace_id],
468        )?;
469    }
470    tx.commit()?;
471    Ok(())
472}
473
474pub fn workspace_for_session_at(path: &Path, session_id: &str) -> Result<Option<String>> {
475    open_reader(path)?
476        .query_row(
477            "SELECT workspace_id FROM session_contexts WHERE session_id = ?1",
478            [session_id],
479            |row| row.get(0),
480        )
481        .optional()
482        .map_err(Into::into)
483}
484
485pub fn session_ids_for_workspace(workspace_id: &str) -> Result<Vec<String>> {
486    session_ids_for_workspace_at(&database_path(), workspace_id)
487}
488
489/// Return sessions whose current or last workspace id matches `workspace_id`.
490/// Callers deciding live membership must additionally check `SessionState`.
491pub fn session_ids_for_workspace_at(path: &Path, workspace_id: &str) -> Result<Vec<String>> {
492    let connection = open_reader(path)?;
493    let mut statement = connection.prepare(
494        "SELECT c.session_id
495           FROM session_contexts c
496           JOIN sessions s USING(session_id)
497          WHERE c.workspace_id = ?1
498          ORDER BY c.created_at, c.session_id",
499    )?;
500    let rows = statement.query_map([workspace_id], |row| row.get(0))?;
501    rows.collect::<rusqlite::Result<_>>().map_err(Into::into)
502}
503
504/// Assign a newly-created session context to a workspace. Existing contexts
505/// remain immutable here; only the guarded resume operation may move one.
506pub fn assign_new_session_workspace(session_id: &str, workspace_id: &str) -> Result<()> {
507    let session_id = session_id.to_owned();
508    let workspace_id = workspace_id.to_owned();
509    submit_database_write("assign_new_session_workspace", move |_| {
510        assign_new_session_workspace_at(&database_path(), &session_id, &workspace_id)
511    })
512}
513
514pub fn assign_new_session_workspace_at(
515    path: &Path,
516    session_id: &str,
517    workspace_id: &str,
518) -> Result<()> {
519    let connection = open(path)?;
520    let current: String = connection
521        .query_row(
522            "SELECT workspace_id FROM session_contexts WHERE session_id = ?1",
523            [session_id],
524            |row| row.get(0),
525        )
526        .with_context(|| format!("find session context {session_id:?}"))?;
527    if current == workspace_id {
528        return Ok(());
529    }
530    ensure!(
531        current == DEFAULT_WORKSPACE_ID,
532        "session {session_id} already belongs to workspace {current}"
533    );
534    let exists: bool = connection.query_row(
535        "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)",
536        [workspace_id],
537        |row| row.get(0),
538    )?;
539    ensure!(exists, "unknown workspace {workspace_id:?}");
540    connection.execute(
541        "UPDATE session_contexts SET workspace_id = ?2 WHERE session_id = ?1",
542        params![session_id, workspace_id],
543    )?;
544    Ok(())
545}