ompchamber 3.4.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
- import { Archive, ArchiveRestore, Check, ChevronDown, ChevronRight, CircleQuestionMark, Loader2, Pencil } from 'lucide-preact';
1
+ import { Check, ChevronDown, ChevronRight, CircleQuestionMark, Loader2, MoreHorizontal } from 'lucide-preact';
2
2
  import type { SessionItemData } from '@/shared/types';
3
3
  import { useInlineRename } from '@/client/hooks/ui/inline-rename';
4
+ import { SessionActionsMenu, useRowMenu } from '@/client/components/common/session-actions-menu';
4
5
 
5
6
  export interface MobileSessionRowProps {
6
7
  session: SessionItemData;
@@ -16,6 +17,9 @@ export interface MobileSessionRowProps {
16
17
  onToggleExpand?: () => void;
17
18
  onSelect: () => void;
18
19
  onArchive: () => void;
20
+ /** Omitted for a session that cannot be deleted yet (a pending `new-…` chat,
21
+ * which has no transcript anywhere). */
22
+ onDelete?: () => void;
19
23
  onRename?: (name: string) => void;
20
24
  }
21
25
 
@@ -30,6 +34,7 @@ export function MobileSessionRow({
30
34
  onToggleExpand,
31
35
  onSelect,
32
36
  onArchive,
37
+ onDelete,
33
38
  onRename,
34
39
  }: MobileSessionRowProps) {
35
40
  const {
@@ -41,6 +46,9 @@ export function MobileSessionRow({
41
46
  handleKeyDown,
42
47
  handleBlur,
43
48
  } = useInlineRename(session.title, onRename);
49
+ // One trigger for every row action; also reachable by long-press contextmenu.
50
+ const menu = useRowMenu();
51
+ const hasActions = Boolean(onRename || onArchive || onDelete);
44
52
 
45
53
  // The roster toggle is the row's LAST element, so its glyph lands in the same
46
54
  // column as the folder header's expand chevron — one vertical line of
@@ -71,76 +79,86 @@ export function MobileSessionRow({
71
79
  }
72
80
 
73
81
  return (
74
- <div
75
- className={`w-full rounded-lg flex items-center transition-colors ${
76
- isActive ? 'bg-ink/10 font-medium text-ink' : 'hover:bg-ink/5 text-ink/85'
77
- }`}
78
- >
79
- <button
80
- type="button"
81
- onClick={onSelect}
82
- className="flex-1 text-left pl-2 pr-3 py-2 flex items-center justify-between min-w-0"
82
+ <>
83
+ <div
84
+ onContextMenu={hasActions ? menu.openAtCursor : undefined}
85
+ className={`w-full rounded-lg flex items-center transition-colors ${
86
+ isActive ? 'bg-ink/10 font-medium text-ink' : 'hover:bg-ink/5 text-ink/85'
87
+ }`}
83
88
  >
84
- <div className="flex items-center min-w-0 pr-2">
85
- {/* Status slot, 16px at the same x as the folder header's icon slot,
86
- so the left column reads as one line. Waiting on an answer
87
- outranks the run spinner — the spinner says work is happening,
88
- the question mark says it is YOUR turn (desktop parity). */}
89
- <span className="w-4 flex-shrink-0 flex items-center justify-center">
90
- {awaitingInput ? (
91
- <CircleQuestionMark size={13} className="text-ink/70 animate-pulse" />
92
- ) : status === 'stream' ? (
93
- <Loader2 size={13} className="text-ink/50 animate-spin" />
94
- ) : status ? (
95
- <Check size={13} className="text-ink/50" />
96
- ) : null}
97
- </span>
98
- <span className="text-xs truncate leading-snug ml-2">{title}</span>
99
- </div>
100
-
101
- <span className="text-[11px] text-ink/45 font-mono flex-shrink-0 ml-2">{timeAgo ?? ''}</span>
102
- </button>
103
-
104
- {onRename && (
105
89
  <button
106
90
  type="button"
107
- onClick={startRename}
108
- title="Rename session"
109
- className="flex-shrink-0 p-2 text-ink/35 hover:text-ink rounded-lg cursor-pointer"
91
+ onClick={onSelect}
92
+ className="flex-1 text-left pl-2 pr-3 py-2 flex items-center justify-between min-w-0"
110
93
  >
111
- <Pencil size={14} />
94
+ <div className="flex items-center min-w-0 pr-2">
95
+ {/* Status slot, 16px at the same x as the folder header's icon slot,
96
+ so the left column reads as one line. Waiting on an answer
97
+ outranks the run spinner — the spinner says work is happening,
98
+ the question mark says it is YOUR turn (desktop parity). */}
99
+ <span className="w-4 flex-shrink-0 flex items-center justify-center">
100
+ {awaitingInput ? (
101
+ <CircleQuestionMark size={13} className="text-ink/70 animate-pulse" />
102
+ ) : status === 'stream' ? (
103
+ <Loader2 size={13} className="text-ink/50 animate-spin" />
104
+ ) : status ? (
105
+ <Check size={13} className="text-ink/50" />
106
+ ) : null}
107
+ </span>
108
+ <span className="text-xs truncate leading-snug ml-2">{title}</span>
109
+ </div>
110
+
111
+ <span className="text-[11px] text-ink/45 font-mono flex-shrink-0 ml-2">{timeAgo ?? ''}</span>
112
112
  </button>
113
- )}
114
113
 
115
- {/* A row WITHOUT a roster toggle still has to keep the toggle's column
116
- empty, or its archive glyph would sit where the toggle is and the
117
- trailing icons would step in and out row to row. This `mr-1` is what
118
- reserves that column: it moves the 14px glyph's centre from x=363 to
119
- x=359, the exact x the toggle uses on a row that has one. */}
120
- <button
121
- type="button"
122
- onClick={onArchive}
123
- title={session.is_archived === 1 ? 'Unarchive session' : 'Archive session'}
124
- className={`flex-shrink-0 p-2 text-ink/35 hover:text-ink rounded-lg cursor-pointer ${showChevron ? '' : 'mr-1'}`}
125
- >
126
- {session.is_archived === 1 ? <ArchiveRestore size={14} /> : <Archive size={14} />}
127
- </button>
114
+ {/* One trigger for rename / archive / delete, always visible: a touch
115
+ surface has no hover, and the always-on action buttons this replaced
116
+ consumed a third of a phone's row width.
117
+ `mr-1` only when the row has NO roster toggle — it reserves that
118
+ column so the trailing glyph's centre stays 19px from the row's right
119
+ edge whether the last element is this trigger or the chevron. Without
120
+ it the glyphs step in and out from row to row. */}
121
+ {hasActions && (
122
+ <button
123
+ type="button"
124
+ onClick={menu.openBelow}
125
+ title="Session actions"
126
+ aria-label="Session actions"
127
+ aria-haspopup="menu"
128
+ aria-expanded={menu.anchor !== null}
129
+ className={`flex-shrink-0 p-2 text-ink/35 hover:text-ink rounded-lg cursor-pointer ${showChevron ? '' : 'mr-1'}`}
130
+ >
131
+ <MoreHorizontal size={16} />
132
+ </button>
133
+ )}
128
134
 
129
- {/* Last element, so its glyph sits in the folder header's chevron column.
130
- `p-1.5 mr-1.5` centres a 14px glyph on that column while keeping a
131
- touch-sized tap target. */}
132
- {showChevron && (
133
- <button
134
- type="button"
135
- onClick={onToggleExpand}
136
- title={isExpanded ? 'Collapse subagents' : 'Expand subagents'}
137
- aria-expanded={isExpanded}
138
- aria-label={isExpanded ? 'Collapse subagents' : 'Expand subagents'}
139
- className="flex-shrink-0 p-1.5 mr-1.5 flex items-center justify-center text-ink/50 hover:text-ink rounded-lg cursor-pointer"
140
- >
141
- {isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
142
- </button>
135
+ {/* Last element, so its glyph sits in the folder header's chevron column.
136
+ `p-1.5 mr-1.5` centres a 14px glyph on that column while keeping a
137
+ touch-sized tap target. */}
138
+ {showChevron && (
139
+ <button
140
+ type="button"
141
+ onClick={onToggleExpand}
142
+ title={isExpanded ? 'Collapse subagents' : 'Expand subagents'}
143
+ aria-expanded={isExpanded}
144
+ aria-label={isExpanded ? 'Collapse subagents' : 'Expand subagents'}
145
+ className="flex-shrink-0 p-1.5 mr-1.5 flex items-center justify-center text-ink/50 hover:text-ink rounded-lg cursor-pointer"
146
+ >
147
+ {isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
148
+ </button>
149
+ )}
150
+ </div>
151
+
152
+ {menu.anchor && (
153
+ <SessionActionsMenu
154
+ anchor={menu.anchor}
155
+ isArchived={session.is_archived === 1}
156
+ onRename={onRename ? startRename : undefined}
157
+ onArchive={onArchive}
158
+ onDelete={onDelete}
159
+ onClose={menu.close}
160
+ />
143
161
  )}
144
- </div>
162
+ </>
145
163
  );
146
164
  }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Session deletion, shared by the desktop session row and the mobile one.
8
+ *
9
+ * Both sidebars render the same confirmation dialog and issue the same request,
10
+ * so the state machine (which row is pending, in-flight, why it was refused)
11
+ * lives here rather than in two components that would drift on the URL, the
12
+ * verb, or the event they dispatch afterwards.
13
+ *
14
+ * Two follow-ups are load-bearing and both happen only on success:
15
+ *
16
+ * - `omp:workspace-updated` refreshes the sidebar list. The deleted row is
17
+ * gone from disk, but the client still holds the previous snapshot.
18
+ * - when the deleted session is the one on screen, `?sessionId=` is dropped.
19
+ * Leaving the URL pointing at a removed session renders the chat's "not
20
+ * found" state over a row the user just deleted, and every panel keyed to
21
+ * that id keeps fetching for it.
22
+ */
23
+
24
+ import { useCallback, useState } from 'preact/hooks';
25
+ import { useSearchParams } from '@/client/lib/router/search-params';
26
+ import { forgetSession } from '@/shared/lib/workspace/session-state/store';
27
+ import type { SessionItemData } from '@/shared/types';
28
+
29
+ export interface SessionDeleteActions {
30
+ /** The session awaiting confirmation, or null when the dialog is closed. */
31
+ pending: SessionItemData | null;
32
+ /** True while the DELETE request is in flight. */
33
+ isDeleting: boolean;
34
+ /** The server's refusal reason, shown inside the dialog. */
35
+ error: string | null;
36
+ /** Open the confirmation for this session. */
37
+ requestDelete: (session: SessionItemData) => void;
38
+ /** Close it without deleting. */
39
+ cancelDelete: () => void;
40
+ /** Issue the delete. Resolves once the request settled. */
41
+ confirmDelete: () => Promise<void>;
42
+ }
43
+
44
+ export function useSessionDelete(): SessionDeleteActions {
45
+ const [pending, setPending] = useState<SessionItemData | null>(null);
46
+ const [isDeleting, setIsDeleting] = useState(false);
47
+ const [error, setError] = useState<string | null>(null);
48
+ const [searchParams, setSearchParams] = useSearchParams();
49
+
50
+ const requestDelete = useCallback((session: SessionItemData) => {
51
+ setError(null);
52
+ setPending(session);
53
+ }, []);
54
+
55
+ const cancelDelete = useCallback(() => {
56
+ setError(null);
57
+ setPending(null);
58
+ }, []);
59
+
60
+ const confirmDelete = useCallback(async () => {
61
+ if (!pending || isDeleting) return;
62
+ const sessionId = String(pending.id);
63
+ setIsDeleting(true);
64
+ setError(null);
65
+ try {
66
+ const res = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`, { method: 'DELETE' });
67
+ const body = (await res.json().catch(() => null)) as { error?: string } | null;
68
+ if (!res.ok) {
69
+ // The refusal reasons the server gives (mid-run, pending chat) are
70
+ // actionable, so they stay on screen instead of closing the dialog.
71
+ setError(body?.error || `Could not delete the session (HTTP ${res.status}).`);
72
+ return;
73
+ }
74
+ forgetSession(sessionId);
75
+ if (searchParams.get('sessionId') === sessionId) {
76
+ setSearchParams((prev) => {
77
+ const next = new URLSearchParams(prev);
78
+ next.delete('sessionId');
79
+ // A subagent transcript belongs to the session being removed.
80
+ next.delete('subagent');
81
+ return next;
82
+ }, { replace: true });
83
+ }
84
+ setPending(null);
85
+ window.dispatchEvent(new CustomEvent('omp:workspace-updated'));
86
+ } catch (err) {
87
+ setError(err instanceof Error ? err.message : 'Could not reach the server.');
88
+ } finally {
89
+ setIsDeleting(false);
90
+ }
91
+ }, [pending, isDeleting, searchParams, setSearchParams]);
92
+
93
+ return { pending, isDeleting, error, requestDelete, cancelDelete, confirmDelete };
94
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Deleting a session's side questions.
8
+ *
9
+ * A side question is keyed to the parent session but stored outside it — rows
10
+ * in `btw_topics`/`btw_turns`, a live omp child per topic, and a private
11
+ * transcript directory under `<db dir>/btw/<sessionId>/<topicId>/`. Deleting
12
+ * the chat session therefore leaves all three behind unless they are purged
13
+ * with it: orphan turns still readable in the database, and on disk a full
14
+ * copy of the very transcript the user just deleted.
15
+ *
16
+ * Kept out of `service.server.ts` (which owns the interactive topic
17
+ * operations) so that file's growth stays attributable.
18
+ */
19
+
20
+ import path from 'path';
21
+ import { listBtwTopics, deleteBtwTopic } from '@/server/lib/btw/store.server';
22
+ import { forgetBtwRuntime, getBtwRuntime } from '@/server/lib/btw/registry.server';
23
+ import { getBtwRoot, removeBtwWorkspace, resolveBtwWorkspacePaths } from '@/server/lib/btw/session-copy.server';
24
+ import { isSinglePathSegment } from '@/server/lib/fs/path-segment';
25
+
26
+ /**
27
+ * Drop every topic of `sessionId`, its child, its rows, and its workspace.
28
+ *
29
+ * `sessionId` MUST name one child of the btw root. The last step below removes
30
+ * `join(getBtwRoot(), sessionId)` unconditionally, and `join(root, '.')` is
31
+ * `root` itself — so an id of `.` (or any id with a separator) turns this
32
+ * per-session purge into "delete every session's side questions on disk". The
33
+ * route validates its id; this check is the independent second gate, because
34
+ * this function is reachable on its own and its failure mode is other
35
+ * sessions' data.
36
+ */
37
+ export async function purgeBtwForSession(sessionId: string): Promise<number> {
38
+ if (!isSinglePathSegment(sessionId)) return 0;
39
+
40
+ const topics = await listBtwTopics(sessionId);
41
+ for (const topic of topics) {
42
+ const runtime = getBtwRuntime(topic.id);
43
+ if (runtime) {
44
+ // Disposes the child; a running turn is settled as cancelled rather than
45
+ // left to write into a transcript this purge is about to remove.
46
+ await runtime.dispose();
47
+ forgetBtwRuntime(topic.id);
48
+ }
49
+ await deleteBtwTopic(topic.id);
50
+ await removeBtwWorkspace((await resolveBtwWorkspacePaths(sessionId, topic.id)).dir);
51
+ }
52
+ // The per-session directory as a whole: a topic whose row is already gone can
53
+ // still have a workspace (a crash between the two, a manual row delete).
54
+ await removeBtwWorkspace(path.join(await getBtwRoot(), sessionId));
55
+ return topics.length;
56
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The session-delete blast radius.
8
+ *
9
+ * `DELETE /api/sessions/:sessionId` removes a directory under the sessions root
10
+ * and another under the btw root, both by joining the id onto the root. The
11
+ * case that shipped broken was `.`: `join(root, '.')` is `root` itself, so the
12
+ * btw purge's unconditional workspace removal deleted EVERY session's side
13
+ * questions and the route still answered 404 — an invisible, root-wide wipe
14
+ * from a request that looked like a miss.
15
+ *
16
+ * These tests pin the two properties that made it possible: the guard rejects
17
+ * every value that is not one segment, and the btw purge refuses a non-segment
18
+ * even when called directly (the route is not the only entry point).
19
+ */
20
+
21
+ import { afterAll, beforeAll, describe, expect, test } from 'bun:test';
22
+ import fs from 'fs';
23
+ import path from 'path';
24
+ import { isSinglePathSegment } from '@/server/lib/fs/path-segment';
25
+ import { purgeBtwForSession } from '@/server/lib/btw/purge.server';
26
+
27
+ describe('isSinglePathSegment', () => {
28
+ test('accepts the id shapes the app actually produces', () => {
29
+ // omp session ids are UUIDs; mock ids are integers; pending chats are `new-…`.
30
+ expect(isSinglePathSegment('11111111-2222-3333-4444-555555555555')).toBe(true);
31
+ expect(isSinglePathSegment('3')).toBe(true);
32
+ expect(isSinglePathSegment('new-1758800000000')).toBe(true);
33
+ // A dot INSIDE a name is fine — only a name that resolves to a directory is not.
34
+ expect(isSinglePathSegment('session.v2')).toBe(true);
35
+ });
36
+
37
+ test('refuses the values that resolve somewhere other than one child', () => {
38
+ // The shipped bug: join(root, '.') === root.
39
+ expect(isSinglePathSegment('.')).toBe(false);
40
+ expect(isSinglePathSegment('..')).toBe(false);
41
+ expect(isSinglePathSegment('a/b')).toBe(false);
42
+ expect(isSinglePathSegment('..\\a')).toBe(false);
43
+ expect(isSinglePathSegment('')).toBe(false);
44
+ // A NUL byte truncates at the syscall boundary.
45
+ expect(isSinglePathSegment('a\0b')).toBe(false);
46
+ });
47
+
48
+ test('the guard agrees with the join it is guarding', () => {
49
+ // The property that matters: whenever the guard says yes, joining onto a
50
+ // root stays directly inside it. Asserted against `path.join` itself rather
51
+ // than a restatement of the rule.
52
+ const root = '/tmp/root';
53
+ for (const id of ['11111111-2222-3333-4444-555555555555', '3', 'new-1', 'session.v2']) {
54
+ expect(isSinglePathSegment(id)).toBe(true);
55
+ expect(path.join(root, id).startsWith(`${root}${path.sep}`)).toBe(true);
56
+ expect(path.dirname(path.join(root, id))).toBe(root);
57
+ }
58
+ for (const id of ['.', '..', 'a/b', '../x']) {
59
+ expect(isSinglePathSegment(id)).toBe(false);
60
+ }
61
+ });
62
+ });
63
+
64
+ describe('purgeBtwForSession refuses a non-segment id', () => {
65
+ const root = '/tmp/omc-path-segment-test';
66
+ const btwRoot = path.join(root, 'btw');
67
+ const seeded = [
68
+ path.join(btwRoot, 'sessA', 'topicA', 'session.jsonl'),
69
+ path.join(btwRoot, 'sessB', 'topicB', 'session.jsonl'),
70
+ ];
71
+
72
+ beforeAll(async () => {
73
+ await fs.promises.rm(root, { recursive: true, force: true });
74
+ // Two unrelated sessions' side questions, exactly as the bug destroyed them.
75
+ // The btw root is derived from the database path, so pointing that at this
76
+ // tree is what puts them where the purge would look.
77
+ for (const file of seeded) {
78
+ await fs.promises.mkdir(path.dirname(file), { recursive: true });
79
+ await Bun.write(file, '{"type":"session"}\n');
80
+ }
81
+ Bun.env.OMPCHAMBER_DB_PATH = path.join(root, 'db.sqlite');
82
+ });
83
+
84
+ afterAll(async () => {
85
+ delete Bun.env.OMPCHAMBER_DB_PATH;
86
+ await fs.promises.rm(root, { recursive: true, force: true });
87
+ });
88
+
89
+ test('`.` does not remove the btw root', async () => {
90
+ // The guard returns before `listBtwTopics`, so no database is touched.
91
+ expect(await purgeBtwForSession('.')).toBe(0);
92
+ // Both sessions' transcripts must survive — this is the regression.
93
+ for (const file of seeded) expect(fs.existsSync(file)).toBe(true);
94
+ });
95
+
96
+ test('a separator does not reach outside the root', async () => {
97
+ expect(await purgeBtwForSession('../btw')).toBe(0);
98
+ for (const file of seeded) expect(fs.existsSync(file)).toBe(true);
99
+ });
100
+ });
@@ -0,0 +1,45 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * One rule, two callers: does this id name exactly one child of a root?
8
+ *
9
+ * The purge paths join a session id onto a root to find the directory it owns —
10
+ * `join(sessionsRoot, …)`, `join(btwRoot, sessionId)` — and then remove it
11
+ * recursively. `path.join` NORMALIZES, so a value that is not a plain segment
12
+ * silently retargets the removal:
13
+ *
14
+ * join(root, '.') === root → deletes every child of the root
15
+ * join(root, '..') === dirname(root) → deletes outside the root
16
+ * join(root, 'a/b') → reaches into a sibling subtree
17
+ *
18
+ * Measured: `DELETE /api/sessions/.` (curl needs `--path-as-is`; it strips the
19
+ * trailing dot itself) passed a `/^[A-Za-z0-9._-]+$/` guard, normalized to the
20
+ * btw root, and removed two unrelated sessions' side-question transcripts.
21
+ *
22
+ * The check is deliberately structural rather than an enumeration of dangerous
23
+ * spellings: an id is safe exactly when it is one non-dot segment. Real ids
24
+ * satisfy that by construction — omp session ids are UUIDs and mock ids are
25
+ * integers — so nothing legitimate is refused.
26
+ *
27
+ * `normalize` is compared, not just scanned for separators, because it is the
28
+ * same operation the callers' `join` performs: asserting the two agree is what
29
+ * makes this a proof about the path that will actually be built, rather than a
30
+ * guess about which inputs look suspicious.
31
+ */
32
+
33
+ import { normalize } from 'node:path';
34
+
35
+ /** True when `id` names one child of a root, and nothing else. */
36
+ export function isSinglePathSegment(id: string): boolean {
37
+ if (id.length === 0) return false;
38
+ if (id === '.' || id === '..') return false;
39
+ if (id.includes('/') || id.includes('\\')) return false;
40
+ // A NUL byte would truncate the path at the syscall boundary.
41
+ if (id.includes('\0')) return false;
42
+ // The load-bearing assertion: normalization must be a no-op, so `join(root, id)`
43
+ // cannot resolve anywhere but directly under `root`.
44
+ return normalize(id) === id;
45
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Deleting a session: the transcript on disk, its subagent artifacts, and every
8
+ * chamber-side row keyed to the session id.
9
+ *
10
+ * The transcript is authoritative — omp's own session listing is derived from
11
+ * the JSONL alone — so a delete that removed only the chamber's rows would see
12
+ * the session come straight back on the next sidebar scan. What "delete" means
13
+ * here, in one place:
14
+ *
15
+ * - the session JSONL, plus any `<name>.jsonl.bak-*` copy an earlier rewind
16
+ * left beside it (a full copy of the same transcript: leaving it behind
17
+ * would mean the conversation was not actually deleted);
18
+ * - its sibling artifacts directory, which holds the subagent transcripts;
19
+ * - the `archived_sessions`, `session_ui_state`, `session_stream_state`,
20
+ * `queued_messages` and `chat_sessions` rows for that id;
21
+ * - in mock mode the demo `sessions` row and its `files` tree.
22
+ *
23
+ * The caller owns the process: a live omp child MUST be destroyed before this
24
+ * runs, because omp flushes its session state on shutdown and would otherwise
25
+ * recreate the file this just removed. Side-question rows and workspaces are
26
+ * the btw module's business (`purgeBtwForSession`).
27
+ */
28
+
29
+ import fs from 'fs';
30
+ import path from 'path';
31
+ import { getDb } from '@/server/db.server';
32
+ import type { DbClient } from '@/server/lib/db/client';
33
+ import { findSessionFileById } from '@/server/lib/omp/session/locator';
34
+ import { clearSessionFileCaches } from '@/server/lib/omp/session/files';
35
+ import { invalidateOmpSidebarData } from '@/server/lib/omp/session/reader';
36
+ import { siblingDirForSession } from '@/server/lib/omp/subagent/history/paths';
37
+ import { isSinglePathSegment } from '@/server/lib/fs/path-segment';
38
+
39
+ export interface SessionPurgeResult {
40
+ /** Something keyed to this id existed — a transcript, a mock session row, or
41
+ * a chamber row. False means the id resolved nowhere at all. */
42
+ found: boolean;
43
+ fileRemoved: boolean;
44
+ artifactsRemoved: boolean;
45
+ }
46
+
47
+ /**
48
+ * Chamber rows keyed by a session id. Every statement is a no-op when the id
49
+ * matches nothing, which is what makes one purge serve both modes: real
50
+ * sessions (omp UUIDs, present on disk only) and mock ones (numeric ids in
51
+ * `sessions`).
52
+ *
53
+ * `files` carries a FOREIGN KEY to `sessions` and `foreign_keys` is ON, so its
54
+ * children go before the session row or the delete is refused.
55
+ */
56
+ const SESSION_ROW_DELETES: readonly (readonly [string, string])[] = [
57
+ ['archived_sessions', 'session_id'],
58
+ ['session_ui_state', 'session_id'],
59
+ ['session_stream_state', 'session_id'],
60
+ ['queued_messages', 'session_id'],
61
+ ['chat_sessions', 'session_id'],
62
+ ['files', 'session_id'],
63
+ ];
64
+
65
+ async function purgeChamberRows(db: DbClient, sessionId: string): Promise<number> {
66
+ let changes = 0;
67
+ for (const [table, column] of SESSION_ROW_DELETES) {
68
+ const result = await db.run(`DELETE FROM ${table} WHERE ${column} = ?`, [sessionId]);
69
+ changes += result.changes ?? 0;
70
+ }
71
+ const mockRow = await db.run('DELETE FROM sessions WHERE id = ?', [sessionId]);
72
+ return changes + (mockRow.changes ?? 0);
73
+ }
74
+
75
+ /** Remove the transcript and the `.bak-*` copies beside it. */
76
+ async function removeTranscript(filePath: string): Promise<boolean> {
77
+ const existed = await Bun.file(filePath).exists();
78
+ await fs.promises.rm(filePath, { force: true });
79
+ const dir = path.dirname(filePath);
80
+ const prefix = `${path.basename(filePath)}.bak-`;
81
+ try {
82
+ for (const entry of await fs.promises.readdir(dir)) {
83
+ if (entry.startsWith(prefix)) await fs.promises.rm(path.join(dir, entry), { force: true });
84
+ }
85
+ } catch {
86
+ // A backup that cannot be listed is not worth failing a delete the
87
+ // transcript half of which already succeeded.
88
+ }
89
+ return existed;
90
+ }
91
+
92
+ /**
93
+ * Purge everything the chamber and the agent directory hold for `sessionId`.
94
+ * `sessionId` must be a single safe path segment — it names the artifacts
95
+ * directory — which the route validates before calling.
96
+ */
97
+ export async function purgeSessionData(sessionId: string): Promise<SessionPurgeResult> {
98
+ // Second gate, independent of the route's: an id that is not one segment
99
+ // would make `siblingDirForSession` and the btw purge resolve outside the
100
+ // session it names. Refusing here means a future caller that forgets the
101
+ // route's guard still cannot widen the blast radius.
102
+ if (!isSinglePathSegment(sessionId)) {
103
+ return { found: false, fileRemoved: false, artifactsRemoved: false };
104
+ }
105
+
106
+ const filePath = await findSessionFileById(sessionId);
107
+ let fileRemoved = false;
108
+ let artifactsRemoved = false;
109
+ if (filePath) {
110
+ fileRemoved = await removeTranscript(filePath);
111
+ const artifactsDir = siblingDirForSession(filePath);
112
+ // `stat`, not `Bun.file().exists()`: the latter answers for regular files
113
+ // only and reports a directory as absent, which would claim every session
114
+ // had no subagent transcripts.
115
+ artifactsRemoved = await fs.promises
116
+ .stat(artifactsDir)
117
+ .then((stats) => stats.isDirectory(), () => false);
118
+ await fs.promises.rm(artifactsDir, { recursive: true, force: true });
119
+ }
120
+
121
+ const db = await getDb();
122
+ const rowChanges = await purgeChamberRows(db, sessionId);
123
+
124
+ // Both caches outlive the mutation by their own keys: the file list/scan pair
125
+ // is mtime-keyed (a delete bumps the parent directory, but the memo can still
126
+ // answer first) and the sidebar dataset is TTL'd, so without this the deleted
127
+ // row is served back to the refresh that follows the delete.
128
+ clearSessionFileCaches();
129
+ invalidateOmpSidebarData();
130
+
131
+ return {
132
+ found: Boolean(filePath) || rowChanges > 0,
133
+ fileRemoved,
134
+ artifactsRemoved,
135
+ };
136
+ }
@@ -60,6 +60,19 @@ export async function loadOmpSidebarData(): Promise<OmpSidebarData> {
60
60
  return slot.inFlight;
61
61
  }
62
62
 
63
+ /**
64
+ * Drop the cached dataset so the next load re-scans disk. For a mutation the
65
+ * TTL cannot see: deleting a session removes a row rather than changing one, so
66
+ * nothing in the cached snapshot is stale — it is simply wrong, and the refresh
67
+ * that follows the delete would otherwise serve it back for up to the TTL.
68
+ */
69
+ export function invalidateOmpSidebarData(): void {
70
+ const slot = globalThis.__ompChamberSidebarDataCache;
71
+ if (!slot) return;
72
+ slot.data = undefined as unknown as OmpSidebarData;
73
+ slot.expiresAt = 0;
74
+ }
75
+
63
76
  /** Resolve each unique cwd to its project root; bounded concurrency so 100+
64
77
  * unique cwds don't spawn 100 parallel git processes. */
65
78
  async function resolveRootsByCwd(cwds: string[]): Promise<Map<string, string>> {