Skip to main content

mj_controller/database/
workspaces.rs

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