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    remove_inactive_workspace_at(path, workspace_id, false)
351}
352
353/// Finish a normal workspace close, retaining history but discarding unsent text.
354pub fn close_workspace(workspace_id: &str) -> Result<()> {
355    let workspace_id = workspace_id.to_owned();
356    submit_database_write("close_workspace", move |_| {
357        close_workspace_at(&database_path(), &workspace_id)
358    })
359}
360
361pub fn close_workspace_at(path: &Path, workspace_id: &str) -> Result<()> {
362    remove_inactive_workspace_at(path, workspace_id, true)
363}
364
365fn remove_inactive_workspace_at(
366    path: &Path,
367    workspace_id: &str,
368    discard_session_drafts: bool,
369) -> Result<()> {
370    let mut connection = open(path)?;
371    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
372    let active_count = {
373        let mut statement = tx.prepare(
374            "SELECT s.state
375               FROM session_contexts c
376               JOIN sessions s USING(session_id)
377              WHERE c.workspace_id = ?1",
378        )?;
379        let states = statement.query_map([workspace_id], |row| row.get::<_, String>(0))?;
380        states
381            .collect::<rusqlite::Result<Vec<_>>>()?
382            .into_iter()
383            .filter(|state| stored_session_state(state).is_active())
384            .count()
385    };
386    ensure!(
387        active_count == 0,
388        "workspace is not empty ({active_count} active sessions remain)"
389    );
390    if discard_session_drafts {
391        tx.execute(
392            "UPDATE sessions SET draft_input = '' WHERE session_id IN
393             (SELECT session_id FROM session_contexts WHERE workspace_id = ?1)",
394            [workspace_id],
395        )?;
396    }
397    tx.execute(
398        "DELETE FROM detached_drafts WHERE workspace_id = ?1",
399        [workspace_id],
400    )?;
401    let changed = tx.execute(
402        "DELETE FROM workspaces WHERE workspace_id = ?1",
403        [workspace_id],
404    )?;
405    ensure!(changed == 1, "unknown workspace {workspace_id:?}");
406    tx.commit()?;
407    Ok(())
408}
409
410/// Move a durable history into the workspace from which it is being resumed.
411///
412/// This is deliberately limited to states accepted by the resume controller;
413/// an active session must never move between live dashboards underneath its
414/// worker or viewers.
415pub fn reassign_resumable_session_workspace(session_id: &str, workspace_id: &str) -> Result<()> {
416    let session_id = session_id.to_owned();
417    let workspace_id = workspace_id.to_owned();
418    submit_database_write("reassign_resumable_session_workspace", move |_| {
419        reassign_resumable_session_workspace_at(&database_path(), &session_id, &workspace_id)
420    })
421}
422
423pub fn reassign_resumable_session_workspace_at(
424    path: &Path,
425    session_id: &str,
426    workspace_id: &str,
427) -> Result<()> {
428    let mut connection = open(path)?;
429    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
430    let (current_workspace, state): (String, String) = tx
431        .query_row(
432            "SELECT c.workspace_id, s.state
433               FROM session_contexts c
434               JOIN sessions s USING(session_id)
435              WHERE c.session_id = ?1",
436            [session_id],
437            |row| Ok((row.get(0)?, row.get(1)?)),
438        )
439        .with_context(|| format!("find resumable session {session_id:?}"))?;
440    ensure!(
441        matches!(
442            stored_session_state(&state),
443            SessionState::Stopped | SessionState::Lost | SessionState::Error
444        ),
445        "session {session_id} is not resumable"
446    );
447    let destination_exists: bool = tx.query_row(
448        "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)",
449        [workspace_id],
450        |row| row.get(0),
451    )?;
452    ensure!(destination_exists, "unknown workspace {workspace_id:?}");
453    if current_workspace != workspace_id {
454        tx.execute(
455            "UPDATE session_contexts SET workspace_id = ?2 WHERE session_id = ?1",
456            params![session_id, workspace_id],
457        )?;
458    }
459    tx.commit()?;
460    Ok(())
461}
462
463pub fn workspace_for_session_at(path: &Path, session_id: &str) -> Result<Option<String>> {
464    open_reader(path)?
465        .query_row(
466            "SELECT workspace_id FROM session_contexts WHERE session_id = ?1",
467            [session_id],
468            |row| row.get(0),
469        )
470        .optional()
471        .map_err(Into::into)
472}
473
474pub fn session_ids_for_workspace(workspace_id: &str) -> Result<Vec<String>> {
475    session_ids_for_workspace_at(&database_path(), workspace_id)
476}
477
478/// Return sessions whose current or last workspace id matches `workspace_id`.
479/// Callers deciding live membership must additionally check `SessionState`.
480pub fn session_ids_for_workspace_at(path: &Path, workspace_id: &str) -> Result<Vec<String>> {
481    let connection = open_reader(path)?;
482    let mut statement = connection.prepare(
483        "SELECT c.session_id
484           FROM session_contexts c
485           JOIN sessions s USING(session_id)
486          WHERE c.workspace_id = ?1
487          ORDER BY c.created_at, c.session_id",
488    )?;
489    let rows = statement.query_map([workspace_id], |row| row.get(0))?;
490    rows.collect::<rusqlite::Result<_>>().map_err(Into::into)
491}
492
493/// Assign a newly-created session context to a workspace. Existing contexts
494/// remain immutable here; only the guarded resume operation may move one.
495pub fn assign_new_session_workspace(session_id: &str, workspace_id: &str) -> Result<()> {
496    let session_id = session_id.to_owned();
497    let workspace_id = workspace_id.to_owned();
498    submit_database_write("assign_new_session_workspace", move |_| {
499        assign_new_session_workspace_at(&database_path(), &session_id, &workspace_id)
500    })
501}
502
503pub fn assign_new_session_workspace_at(
504    path: &Path,
505    session_id: &str,
506    workspace_id: &str,
507) -> Result<()> {
508    let connection = open(path)?;
509    let current: String = connection
510        .query_row(
511            "SELECT workspace_id FROM session_contexts WHERE session_id = ?1",
512            [session_id],
513            |row| row.get(0),
514        )
515        .with_context(|| format!("find session context {session_id:?}"))?;
516    if current == workspace_id {
517        return Ok(());
518    }
519    ensure!(
520        current == DEFAULT_WORKSPACE_ID,
521        "session {session_id} already belongs to workspace {current}"
522    );
523    let exists: bool = connection.query_row(
524        "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)",
525        [workspace_id],
526        |row| row.get(0),
527    )?;
528    ensure!(exists, "unknown workspace {workspace_id:?}");
529    connection.execute(
530        "UPDATE session_contexts SET workspace_id = ?2 WHERE session_id = ?1",
531        params![session_id, workspace_id],
532    )?;
533    Ok(())
534}