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 load_workspace_layout(workspace_id: &str) -> Result<ConversationLayout> {
92    load_workspace_layout_from(&database_path(), workspace_id)
93}
94
95pub fn load_workspace_layout_from(path: &Path, workspace_id: &str) -> Result<ConversationLayout> {
96    let connection = open_reader(path)?;
97    let stored = connection
98        .query_row(
99            "SELECT l.layout
100             FROM workspaces w LEFT JOIN workspace_layouts l USING(workspace_id)
101             WHERE w.workspace_id = ?1",
102            [workspace_id],
103            |row| row.get::<_, Option<String>>(0),
104        )
105        .optional()?
106        .with_context(|| format!("unknown workspace {workspace_id:?}"))?;
107    let Some(stored) = stored else {
108        return Ok(ConversationLayout::default());
109    };
110    let layout: ConversationLayout = serde_json::from_str(&stored)
111        .with_context(|| format!("decode layout for workspace {workspace_id:?}"))?;
112    layout.validate()?;
113    Ok(layout)
114}
115
116pub fn save_workspace_layout(workspace_id: &str, layout: ConversationLayout) -> Result<()> {
117    let workspace_id = workspace_id.to_owned();
118    submit_database_write("save_workspace_layout", move |_| {
119        save_workspace_layout_to(&database_path(), &workspace_id, &layout)
120    })
121}
122
123pub fn save_workspace_layout_to(
124    path: &Path,
125    workspace_id: &str,
126    layout: &ConversationLayout,
127) -> Result<()> {
128    layout.validate()?;
129    let encoded = serde_json::to_string(layout)?;
130    let connection = open(path)?;
131    connection
132        .execute(
133            "INSERT INTO workspace_layouts(workspace_id, layout)
134         VALUES (?1, ?2)
135         ON CONFLICT(workspace_id) DO UPDATE SET layout = excluded.layout",
136            params![workspace_id, encoded],
137        )
138        .with_context(|| format!("save layout for workspace {workspace_id:?}"))?;
139    Ok(())
140}
141
142pub fn list_workspaces_from(path: &Path) -> Result<Vec<WorkspaceRecord>> {
143    let connection = open_reader(path)?;
144    let mut statement = connection.prepare(
145        "SELECT w.workspace_id, w.name, w.created_at, w.last_opened_at,
146                count(s.session_id) FILTER (
147                    WHERE s.state NOT IN ('stopped', 'lost', 'destroyed-with-data-loss')
148                )
149          FROM workspaces w
150           LEFT JOIN session_contexts c USING(workspace_id)
151           LEFT JOIN sessions s USING(session_id)
152          GROUP BY w.workspace_id
153         HAVING w.workspace_id != 'default' OR count(s.session_id) FILTER (
154                    WHERE s.state NOT IN ('stopped', 'lost', 'destroyed-with-data-loss')
155                ) > 0
156          ORDER BY w.last_opened_at DESC, w.created_at DESC, w.workspace_id",
157    )?;
158    let rows = statement.query_map([], |row| {
159        Ok(WorkspaceRecord {
160            id: row.get(0)?,
161            name: row.get(1)?,
162            created_at: row.get(2)?,
163            last_opened_at: row.get(3)?,
164            session_count: row.get(4)?,
165        })
166    })?;
167    rows.collect::<rusqlite::Result<_>>().map_err(Into::into)
168}
169
170pub fn create_workspace(name: &str) -> Result<WorkspaceRecord> {
171    let name = name.to_owned();
172    submit_database_write("create_workspace", move |_| {
173        create_workspace_at(&database_path(), &name)
174    })
175}
176
177/// Create the named workspace, or return the concurrently-created winner.
178///
179/// Interactive setup uses this operation after presenting a snapshot of the
180/// workspace list. Several selectors can therefore submit the same normalized
181/// name legitimately. Explicit database creation remains strict through
182/// [`create_workspace`].
183pub fn create_or_get_workspace(name: &str) -> Result<WorkspaceRecord> {
184    let name = name.to_owned();
185    submit_database_write("create_or_get_workspace", move |_| {
186        create_or_get_workspace_at(&database_path(), &name)
187    })
188}
189
190pub fn create_or_get_workspace_at(path: &Path, name: &str) -> Result<WorkspaceRecord> {
191    let (name, name_key) = normalize_workspace_name(name)?;
192    let id = new_workspace_id()?;
193    let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
194    let mut connection = open(path)?;
195    let transaction = connection.transaction()?;
196    transaction
197        .execute(
198            "INSERT INTO workspaces(workspace_id, name, name_key, created_at, last_opened_at)
199             VALUES (?1, ?2, ?3, ?4, ?4)
200             ON CONFLICT(name_key) DO NOTHING",
201            params![id, name, name_key, now],
202        )
203        .with_context(|| format!("create or find workspace {name:?}"))?;
204    let workspace = transaction.query_row(
205        "SELECT w.workspace_id, w.name, w.created_at, w.last_opened_at,
206                count(s.session_id) FILTER (
207                    WHERE s.state NOT IN ('stopped', 'lost', 'destroyed-with-data-loss')
208                )
209           FROM workspaces w
210           LEFT JOIN session_contexts c USING(workspace_id)
211           LEFT JOIN sessions s USING(session_id)
212          WHERE w.name_key = ?1
213          GROUP BY w.workspace_id",
214        params![name_key],
215        |row| {
216            Ok(WorkspaceRecord {
217                id: row.get(0)?,
218                name: row.get(1)?,
219                created_at: row.get(2)?,
220                last_opened_at: row.get(3)?,
221                session_count: row.get(4)?,
222            })
223        },
224    )?;
225    transaction.commit()?;
226    Ok(workspace)
227}
228
229pub fn create_workspace_at(path: &Path, name: &str) -> Result<WorkspaceRecord> {
230    let (name, name_key) = normalize_workspace_name(name)?;
231    let id = new_workspace_id()?;
232    let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
233    let connection = open(path)?;
234    connection
235        .execute(
236            "INSERT INTO workspaces(workspace_id, name, name_key, created_at, last_opened_at)
237             VALUES (?1, ?2, ?3, ?4, ?4)",
238            params![id, name, name_key, now],
239        )
240        .with_context(|| format!("create workspace {name:?}"))?;
241    Ok(WorkspaceRecord {
242        id,
243        name,
244        created_at: now.clone(),
245        last_opened_at: now,
246        session_count: 0,
247    })
248}
249
250pub fn rename_workspace(workspace_id: &str, name: &str) -> Result<()> {
251    let workspace_id = workspace_id.to_owned();
252    let name = name.to_owned();
253    submit_database_write("rename_workspace", move |_| {
254        rename_workspace_at(&database_path(), &workspace_id, &name)
255    })
256}
257
258pub fn rename_workspace_at(path: &Path, workspace_id: &str, name: &str) -> Result<()> {
259    let (name, name_key) = normalize_workspace_name(name)?;
260    let connection = open(path)?;
261    let changed = connection
262        .execute(
263            "UPDATE workspaces SET name = ?2, name_key = ?3 WHERE workspace_id = ?1",
264            params![workspace_id, name, name_key],
265        )
266        .with_context(|| format!("rename workspace to {name:?}"))?;
267    ensure!(changed == 1, "unknown workspace {workspace_id:?}");
268    Ok(())
269}
270
271pub fn touch_workspace(workspace_id: &str) -> Result<()> {
272    let workspace_id = workspace_id.to_owned();
273    submit_database_write("touch_workspace", move |_| {
274        touch_workspace_at(&database_path(), &workspace_id)
275    })
276}
277
278pub fn touch_workspace_at(path: &Path, workspace_id: &str) -> Result<()> {
279    let connection = open(path)?;
280    let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
281    let changed = connection.execute(
282        "UPDATE workspaces SET last_opened_at = ?2 WHERE workspace_id = ?1",
283        params![workspace_id, now],
284    )?;
285    ensure!(changed == 1, "unknown workspace {workspace_id:?}");
286    Ok(())
287}
288
289/// Delete a workspace that owns no active sessions or recoverable drafts.
290///
291/// Inactive session records are global resume history. Their last workspace
292/// id is retained as historical metadata even when that workspace disappears.
293pub fn delete_workspace(workspace_id: &str) -> Result<()> {
294    let workspace_id = workspace_id.to_owned();
295    submit_database_write("delete_workspace", move |_| {
296        delete_workspace_at(&database_path(), &workspace_id)
297    })
298}
299
300pub fn delete_workspace_at(path: &Path, workspace_id: &str) -> Result<()> {
301    let mut connection = open(path)?;
302    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
303    let active_count = {
304        let mut statement = tx.prepare(
305            "SELECT s.state
306               FROM session_contexts c
307               JOIN sessions s USING(session_id)
308              WHERE c.workspace_id = ?1",
309        )?;
310        let states = statement.query_map([workspace_id], |row| row.get::<_, String>(0))?;
311        states
312            .collect::<rusqlite::Result<Vec<_>>>()?
313            .into_iter()
314            .filter(|state| stored_session_state(state).is_active())
315            .count()
316    };
317    let draft_count: u64 = tx.query_row(
318        "SELECT count(*) FROM detached_drafts WHERE workspace_id = ?1",
319        [workspace_id],
320        |row| row.get(0),
321    )?;
322    ensure!(
323        active_count == 0 && draft_count == 0,
324        "workspace is not empty ({active_count} active sessions, {draft_count} drafts)"
325    );
326    let changed = tx.execute(
327        "DELETE FROM workspaces WHERE workspace_id = ?1",
328        [workspace_id],
329    )?;
330    ensure!(changed == 1, "unknown workspace {workspace_id:?}");
331    tx.commit()?;
332    Ok(())
333}
334
335/// Force-delete a workspace whose active sessions have already been destroyed.
336///
337/// Drops the workspace's detached drafts and the workspace row in one
338/// immediate transaction that re-checks for active sessions, so a session
339/// created while the destruction ran refuses the deletion instead of losing
340/// the drafts. Inactive session records are global history and are preserved,
341/// exactly as in [`delete_workspace`].
342pub fn force_delete_workspace(workspace_id: &str) -> Result<()> {
343    let workspace_id = workspace_id.to_owned();
344    submit_database_write("force_delete_workspace", move |_| {
345        force_delete_workspace_at(&database_path(), &workspace_id)
346    })
347}
348
349pub fn force_delete_workspace_at(path: &Path, workspace_id: &str) -> Result<()> {
350    let mut connection = open(path)?;
351    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
352    let active_count = {
353        let mut statement = tx.prepare(
354            "SELECT s.state
355               FROM session_contexts c
356               JOIN sessions s USING(session_id)
357              WHERE c.workspace_id = ?1",
358        )?;
359        let states = statement.query_map([workspace_id], |row| row.get::<_, String>(0))?;
360        states
361            .collect::<rusqlite::Result<Vec<_>>>()?
362            .into_iter()
363            .filter(|state| stored_session_state(state).is_active())
364            .count()
365    };
366    ensure!(
367        active_count == 0,
368        "workspace is not empty ({active_count} active sessions remain)"
369    );
370    tx.execute(
371        "DELETE FROM detached_drafts WHERE workspace_id = ?1",
372        [workspace_id],
373    )?;
374    let changed = tx.execute(
375        "DELETE FROM workspaces WHERE workspace_id = ?1",
376        [workspace_id],
377    )?;
378    ensure!(changed == 1, "unknown workspace {workspace_id:?}");
379    tx.commit()?;
380    Ok(())
381}
382
383/// Move a durable history into the workspace from which it is being resumed.
384///
385/// This is deliberately limited to states accepted by the resume controller;
386/// an active session must never move between live dashboards underneath its
387/// worker or viewers.
388pub fn reassign_resumable_session_workspace(session_id: &str, workspace_id: &str) -> Result<()> {
389    let session_id = session_id.to_owned();
390    let workspace_id = workspace_id.to_owned();
391    submit_database_write("reassign_resumable_session_workspace", move |_| {
392        reassign_resumable_session_workspace_at(&database_path(), &session_id, &workspace_id)
393    })
394}
395
396pub fn reassign_resumable_session_workspace_at(
397    path: &Path,
398    session_id: &str,
399    workspace_id: &str,
400) -> Result<()> {
401    let mut connection = open(path)?;
402    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
403    let (current_workspace, state): (String, String) = tx
404        .query_row(
405            "SELECT c.workspace_id, s.state
406               FROM session_contexts c
407               JOIN sessions s USING(session_id)
408              WHERE c.session_id = ?1",
409            [session_id],
410            |row| Ok((row.get(0)?, row.get(1)?)),
411        )
412        .with_context(|| format!("find resumable session {session_id:?}"))?;
413    ensure!(
414        matches!(
415            stored_session_state(&state),
416            SessionState::Stopped | SessionState::Lost | SessionState::Error
417        ),
418        "session {session_id} is not resumable"
419    );
420    let destination_exists: bool = tx.query_row(
421        "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)",
422        [workspace_id],
423        |row| row.get(0),
424    )?;
425    ensure!(destination_exists, "unknown workspace {workspace_id:?}");
426    if current_workspace != workspace_id {
427        tx.execute(
428            "UPDATE session_contexts SET workspace_id = ?2 WHERE session_id = ?1",
429            params![session_id, workspace_id],
430        )?;
431    }
432    tx.commit()?;
433    Ok(())
434}
435
436pub fn workspace_for_session_at(path: &Path, session_id: &str) -> Result<Option<String>> {
437    open_reader(path)?
438        .query_row(
439            "SELECT workspace_id FROM session_contexts WHERE session_id = ?1",
440            [session_id],
441            |row| row.get(0),
442        )
443        .optional()
444        .map_err(Into::into)
445}
446
447pub fn session_ids_for_workspace(workspace_id: &str) -> Result<Vec<String>> {
448    session_ids_for_workspace_at(&database_path(), workspace_id)
449}
450
451/// Return sessions whose current or last workspace id matches `workspace_id`.
452/// Callers deciding live membership must additionally check `SessionState`.
453pub fn session_ids_for_workspace_at(path: &Path, workspace_id: &str) -> Result<Vec<String>> {
454    let connection = open_reader(path)?;
455    let mut statement = connection.prepare(
456        "SELECT c.session_id
457           FROM session_contexts c
458           JOIN sessions s USING(session_id)
459          WHERE c.workspace_id = ?1
460          ORDER BY c.created_at, c.session_id",
461    )?;
462    let rows = statement.query_map([workspace_id], |row| row.get(0))?;
463    rows.collect::<rusqlite::Result<_>>().map_err(Into::into)
464}
465
466/// Assign a newly-created session context to a workspace. Existing contexts
467/// remain immutable here; only the guarded resume operation may move one.
468pub fn assign_new_session_workspace(session_id: &str, workspace_id: &str) -> Result<()> {
469    let session_id = session_id.to_owned();
470    let workspace_id = workspace_id.to_owned();
471    submit_database_write("assign_new_session_workspace", move |_| {
472        assign_new_session_workspace_at(&database_path(), &session_id, &workspace_id)
473    })
474}
475
476pub fn assign_new_session_workspace_at(
477    path: &Path,
478    session_id: &str,
479    workspace_id: &str,
480) -> Result<()> {
481    let connection = open(path)?;
482    let current: String = connection
483        .query_row(
484            "SELECT workspace_id FROM session_contexts WHERE session_id = ?1",
485            [session_id],
486            |row| row.get(0),
487        )
488        .with_context(|| format!("find session context {session_id:?}"))?;
489    if current == workspace_id {
490        return Ok(());
491    }
492    ensure!(
493        current == DEFAULT_WORKSPACE_ID,
494        "session {session_id} already belongs to workspace {current}"
495    );
496    let exists: bool = connection.query_row(
497        "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)",
498        [workspace_id],
499        |row| row.get(0),
500    )?;
501    ensure!(exists, "unknown workspace {workspace_id:?}");
502    connection.execute(
503        "UPDATE session_contexts SET workspace_id = ?2 WHERE session_id = ?1",
504        params![session_id, workspace_id],
505    )?;
506    Ok(())
507}