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    query_workspaces(path, "", &[])
173}
174
175/// One listed workspace, read without aggregating every other workspace's
176/// sessions. `None` means the list would not contain it.
177pub fn workspace_record(workspace_id: &str) -> Result<Option<WorkspaceRecord>> {
178    let mut records = query_workspaces(
179        &database_path(),
180        "WHERE w.workspace_id = ?1",
181        &[&workspace_id],
182    )?;
183    Ok(records.pop())
184}
185
186fn query_workspaces(
187    path: &Path,
188    filter: &str,
189    parameters: &[&dyn rusqlite::ToSql],
190) -> Result<Vec<WorkspaceRecord>> {
191    let connection = open_reader(path)?;
192    let mut statement = connection.prepare(&format!(
193        "SELECT w.workspace_id, w.name, w.created_at, w.last_opened_at,
194                count(s.session_id) FILTER (
195                    WHERE s.state NOT IN ('stopped', 'lost', 'destroyed-with-data-loss')
196                )
197          FROM workspaces w
198           LEFT JOIN session_contexts c USING(workspace_id)
199           LEFT JOIN sessions s USING(session_id)
200          {filter}
201          GROUP BY w.workspace_id
202         HAVING w.workspace_id != 'default' OR count(s.session_id) > 0
203          ORDER BY w.last_opened_at DESC, w.created_at DESC, w.workspace_id"
204    ))?;
205    let rows = statement.query_map(parameters, |row| {
206        Ok(WorkspaceRecord {
207            id: row.get(0)?,
208            name: row.get(1)?,
209            created_at: row.get(2)?,
210            last_opened_at: row.get(3)?,
211            session_count: row.get(4)?,
212        })
213    })?;
214    rows.collect::<rusqlite::Result<_>>().map_err(Into::into)
215}
216
217pub fn create_workspace(name: &str) -> Result<WorkspaceRecord> {
218    let name = name.to_owned();
219    submit_database_write("create_workspace", move |_| {
220        create_workspace_at(&database_path(), &name)
221    })
222}
223
224/// Create the named workspace, or return the concurrently-created winner.
225///
226/// Interactive setup uses this operation after presenting a snapshot of the
227/// workspace list. Several selectors can therefore submit the same normalized
228/// name legitimately. Explicit database creation remains strict through
229/// [`create_workspace`].
230pub fn create_or_get_workspace(name: &str) -> Result<WorkspaceRecord> {
231    let name = name.to_owned();
232    submit_database_write("create_or_get_workspace", move |_| {
233        create_or_get_workspace_at(&database_path(), &name)
234    })
235}
236
237pub fn create_or_get_workspace_at(path: &Path, name: &str) -> Result<WorkspaceRecord> {
238    let (name, name_key) = normalize_workspace_name(name)?;
239    let id = new_workspace_id()?;
240    let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
241    let mut connection = open(path)?;
242    refuse_legacy_default_name(&connection, &name_key)?;
243    let transaction = connection.transaction()?;
244    transaction
245        .execute(
246            "INSERT INTO workspaces(workspace_id, name, name_key, created_at, last_opened_at)
247             VALUES (?1, ?2, ?3, ?4, ?4)
248             ON CONFLICT(name_key) DO NOTHING",
249            params![id, name, name_key, now],
250        )
251        .with_context(|| format!("create or find workspace {name:?}"))?;
252    let workspace = transaction.query_row(
253        "SELECT w.workspace_id, w.name, w.created_at, w.last_opened_at,
254                count(s.session_id) FILTER (
255                    WHERE s.state NOT IN ('stopped', 'lost', 'destroyed-with-data-loss')
256                )
257           FROM workspaces w
258           LEFT JOIN session_contexts c USING(workspace_id)
259           LEFT JOIN sessions s USING(session_id)
260          WHERE w.name_key = ?1
261          GROUP BY w.workspace_id",
262        params![name_key],
263        |row| {
264            Ok(WorkspaceRecord {
265                id: row.get(0)?,
266                name: row.get(1)?,
267                created_at: row.get(2)?,
268                last_opened_at: row.get(3)?,
269                session_count: row.get(4)?,
270            })
271        },
272    )?;
273    transaction.commit()?;
274    Ok(workspace)
275}
276
277pub fn create_workspace_at(path: &Path, name: &str) -> Result<WorkspaceRecord> {
278    let (name, name_key) = normalize_workspace_name(name)?;
279    let id = new_workspace_id()?;
280    let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
281    let connection = open(path)?;
282    refuse_legacy_default_name(&connection, &name_key)?;
283    connection
284        .execute(
285            "INSERT INTO workspaces(workspace_id, name, name_key, created_at, last_opened_at)
286             VALUES (?1, ?2, ?3, ?4, ?4)",
287            params![id, name, name_key, now],
288        )
289        .with_context(|| format!("create workspace {name:?}"))?;
290    Ok(WorkspaceRecord {
291        id,
292        name,
293        created_at: now.clone(),
294        last_opened_at: now,
295        session_count: 0,
296    })
297}
298
299pub fn rename_workspace(workspace_id: &str, name: &str) -> Result<()> {
300    let workspace_id = workspace_id.to_owned();
301    let name = name.to_owned();
302    submit_database_write("rename_workspace", move |_| {
303        rename_workspace_at(&database_path(), &workspace_id, &name)
304    })
305}
306
307pub fn rename_workspace_at(path: &Path, workspace_id: &str, name: &str) -> Result<()> {
308    let (name, name_key) = normalize_workspace_name(name)?;
309    let connection = open(path)?;
310    let changed = connection
311        .execute(
312            "UPDATE workspaces SET name = ?2, name_key = ?3 WHERE workspace_id = ?1",
313            params![workspace_id, name, name_key],
314        )
315        .with_context(|| format!("rename workspace to {name:?}"))?;
316    ensure!(changed == 1, "unknown workspace {workspace_id:?}");
317    Ok(())
318}
319
320pub fn touch_workspace(workspace_id: &str) -> Result<()> {
321    let workspace_id = workspace_id.to_owned();
322    submit_database_write("touch_workspace", move |_| {
323        touch_workspace_at(&database_path(), &workspace_id)
324    })
325}
326
327pub fn touch_workspace_at(path: &Path, workspace_id: &str) -> Result<()> {
328    let connection = open(path)?;
329    let now = Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
330    let changed = connection.execute(
331        "UPDATE workspaces SET last_opened_at = ?2 WHERE workspace_id = ?1",
332        params![workspace_id, now],
333    )?;
334    ensure!(changed == 1, "unknown workspace {workspace_id:?}");
335    Ok(())
336}
337
338/// Delete a workspace that owns no active sessions or recoverable drafts.
339///
340/// Inactive session records are global resume history. Their last workspace
341/// id is retained as historical metadata even when that workspace disappears.
342pub fn delete_workspace(workspace_id: &str) -> Result<()> {
343    let workspace_id = workspace_id.to_owned();
344    submit_database_write("delete_workspace", move |_| {
345        delete_workspace_at(&database_path(), &workspace_id)
346    })
347}
348
349pub fn 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    let draft_count: u64 = tx.query_row(
367        "SELECT count(*) FROM detached_drafts WHERE workspace_id = ?1",
368        [workspace_id],
369        |row| row.get(0),
370    )?;
371    ensure!(
372        active_count == 0 && draft_count == 0,
373        "workspace is not empty ({active_count} active sessions, {draft_count} drafts)"
374    );
375    let changed = tx.execute(
376        "DELETE FROM workspaces WHERE workspace_id = ?1",
377        [workspace_id],
378    )?;
379    ensure!(changed == 1, "unknown workspace {workspace_id:?}");
380    tx.commit()?;
381    Ok(())
382}
383
384/// Finish a normal workspace close, retaining history but discarding unsent text.
385pub fn close_workspace(workspace_id: &str) -> Result<()> {
386    let workspace_id = workspace_id.to_owned();
387    submit_database_write("close_workspace", move |_| {
388        close_workspace_at(&database_path(), &workspace_id)
389    })
390}
391
392pub fn close_workspace_at(path: &Path, workspace_id: &str) -> Result<()> {
393    remove_inactive_workspace_at(path, workspace_id, true)
394}
395
396fn remove_inactive_workspace_at(
397    path: &Path,
398    workspace_id: &str,
399    discard_session_drafts: bool,
400) -> Result<()> {
401    let mut connection = open(path)?;
402    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
403    let active_count = {
404        let mut statement = tx.prepare(
405            "SELECT s.state
406               FROM session_contexts c
407               JOIN sessions s USING(session_id)
408              WHERE c.workspace_id = ?1",
409        )?;
410        let states = statement.query_map([workspace_id], |row| row.get::<_, String>(0))?;
411        states
412            .collect::<rusqlite::Result<Vec<_>>>()?
413            .into_iter()
414            .filter(|state| stored_session_state(state).is_active())
415            .count()
416    };
417    ensure!(
418        active_count == 0,
419        "workspace is not empty ({active_count} active sessions remain)"
420    );
421    if discard_session_drafts {
422        tx.execute(
423            "UPDATE sessions SET draft_input = '' WHERE session_id IN
424             (SELECT session_id FROM session_contexts WHERE workspace_id = ?1)",
425            [workspace_id],
426        )?;
427    }
428    tx.execute(
429        "DELETE FROM detached_drafts WHERE workspace_id = ?1",
430        [workspace_id],
431    )?;
432    let changed = tx.execute(
433        "DELETE FROM workspaces WHERE workspace_id = ?1",
434        [workspace_id],
435    )?;
436    ensure!(changed == 1, "unknown workspace {workspace_id:?}");
437    tx.commit()?;
438    Ok(())
439}
440
441/// Move a durable history into the workspace from which it is being resumed.
442///
443/// This is deliberately limited to states accepted by the resume controller;
444/// an active session must never move between live dashboards underneath its
445/// worker or viewers.
446pub fn reassign_resumable_session_workspace(session_id: &str, workspace_id: &str) -> Result<()> {
447    let session_id = session_id.to_owned();
448    let workspace_id = workspace_id.to_owned();
449    submit_database_write("reassign_resumable_session_workspace", move |_| {
450        reassign_resumable_session_workspace_at(&database_path(), &session_id, &workspace_id)
451    })
452}
453
454pub fn reassign_resumable_session_workspace_at(
455    path: &Path,
456    session_id: &str,
457    workspace_id: &str,
458) -> Result<()> {
459    let mut connection = open(path)?;
460    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
461    let (current_workspace, state): (String, String) = tx
462        .query_row(
463            "SELECT c.workspace_id, s.state
464               FROM session_contexts c
465               JOIN sessions s USING(session_id)
466              WHERE c.session_id = ?1",
467            [session_id],
468            |row| Ok((row.get(0)?, row.get(1)?)),
469        )
470        .with_context(|| format!("find resumable session {session_id:?}"))?;
471    ensure!(
472        matches!(
473            stored_session_state(&state),
474            SessionState::Stopped | SessionState::Lost | SessionState::Error
475        ),
476        "session {session_id} is not resumable"
477    );
478    let destination_exists: bool = tx.query_row(
479        "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)",
480        [workspace_id],
481        |row| row.get(0),
482    )?;
483    ensure!(destination_exists, "unknown workspace {workspace_id:?}");
484    if current_workspace != workspace_id {
485        tx.execute(
486            "UPDATE session_contexts SET workspace_id = ?2 WHERE session_id = ?1",
487            params![session_id, workspace_id],
488        )?;
489    }
490    tx.commit()?;
491    Ok(())
492}
493
494/// Move sessions to another workspace, all together or not at all.
495///
496/// The caller passes a session and the sub-agents under it, so a family
497/// never splits across workspaces. This is the one path that moves a session
498/// of any state; the resume path above is limited to stopped sessions.
499pub fn set_sessions_workspace(session_ids: &[String], workspace_id: &str) -> Result<()> {
500    let session_ids = session_ids.to_vec();
501    let workspace_id = workspace_id.to_owned();
502    submit_database_write("set_sessions_workspace", move |_| {
503        set_sessions_workspace_at(&database_path(), &session_ids, &workspace_id)
504    })
505}
506
507pub fn set_sessions_workspace_at(
508    path: &Path,
509    session_ids: &[String],
510    workspace_id: &str,
511) -> Result<()> {
512    let mut connection = open(path)?;
513    let tx = connection.transaction_with_behavior(rusqlite::TransactionBehavior::Immediate)?;
514    let destination_exists: bool = tx.query_row(
515        "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)",
516        [workspace_id],
517        |row| row.get(0),
518    )?;
519    ensure!(destination_exists, "unknown workspace {workspace_id:?}");
520    for session_id in session_ids {
521        let changed = tx.execute(
522            "UPDATE session_contexts SET workspace_id = ?2 WHERE session_id = ?1",
523            params![session_id, workspace_id],
524        )?;
525        ensure!(changed == 1, "unknown session {session_id}");
526    }
527    tx.commit()?;
528    Ok(())
529}
530
531pub fn workspace_for_session_at(path: &Path, session_id: &str) -> Result<Option<String>> {
532    open_reader(path)?
533        .query_row(
534            "SELECT workspace_id FROM session_contexts WHERE session_id = ?1",
535            [session_id],
536            |row| row.get(0),
537        )
538        .optional()
539        .map_err(Into::into)
540}
541
542pub fn session_ids_for_workspace(workspace_id: &str) -> Result<Vec<String>> {
543    session_ids_for_workspace_at(&database_path(), workspace_id)
544}
545
546/// Return sessions whose current or last workspace id matches `workspace_id`.
547/// Callers deciding live membership must additionally check `SessionState`.
548pub fn session_ids_for_workspace_at(path: &Path, workspace_id: &str) -> Result<Vec<String>> {
549    let connection = open_reader(path)?;
550    let mut statement = connection.prepare(
551        "SELECT c.session_id
552           FROM session_contexts c
553           JOIN sessions s USING(session_id)
554          WHERE c.workspace_id = ?1
555          ORDER BY c.created_at, c.session_id",
556    )?;
557    let rows = statement.query_map([workspace_id], |row| row.get(0))?;
558    rows.collect::<rusqlite::Result<_>>().map_err(Into::into)
559}
560
561/// Assign a newly-created session context to a workspace. Existing contexts
562/// remain immutable here; only the guarded resume operation may move one.
563pub fn assign_new_session_workspace(session_id: &str, workspace_id: &str) -> Result<()> {
564    let session_id = session_id.to_owned();
565    let workspace_id = workspace_id.to_owned();
566    submit_database_write("assign_new_session_workspace", move |_| {
567        assign_new_session_workspace_at(&database_path(), &session_id, &workspace_id)
568    })
569}
570
571pub fn assign_new_session_workspace_at(
572    path: &Path,
573    session_id: &str,
574    workspace_id: &str,
575) -> Result<()> {
576    let connection = open(path)?;
577    let current: String = connection
578        .query_row(
579            "SELECT workspace_id FROM session_contexts WHERE session_id = ?1",
580            [session_id],
581            |row| row.get(0),
582        )
583        .with_context(|| format!("find session context {session_id:?}"))?;
584    if current == workspace_id {
585        return Ok(());
586    }
587    ensure!(
588        current == DEFAULT_WORKSPACE_ID,
589        "session {session_id} already belongs to workspace {current}"
590    );
591    let exists: bool = connection.query_row(
592        "SELECT EXISTS(SELECT 1 FROM workspaces WHERE workspace_id = ?1)",
593        [workspace_id],
594        |row| row.get(0),
595    )?;
596    ensure!(exists, "unknown workspace {workspace_id:?}");
597    connection.execute(
598        "UPDATE session_contexts SET workspace_id = ?2 WHERE session_id = ?1",
599        params![session_id, workspace_id],
600    )?;
601    Ok(())
602}