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.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +293 -293
- package/dist/client/static/js/index-vx0ernrv.css +1 -0
- package/dist/client/static/js/{index-h05z90mw.js → index-w0cem7n5.js} +601 -601
- package/package.json +1 -1
- package/src/client/components/common/session-actions-menu/index.tsx +201 -0
- package/src/client/components/common/session-delete-modal/index.tsx +128 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +18 -0
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +109 -99
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +18 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +82 -64
- package/src/client/hooks/workspace/session-delete.ts +94 -0
- package/src/server/lib/btw/purge.server.ts +56 -0
- package/src/server/lib/fs/path-segment.test.ts +100 -0
- package/src/server/lib/fs/path-segment.ts +45 -0
- package/src/server/lib/omp/session/delete.server.ts +136 -0
- package/src/server/lib/omp/session/reader.ts +13 -0
- package/src/server/routes/sessions/delete.test.ts +84 -0
- package/src/server/routes/sessions/delete.ts +83 -0
- package/src/server/routes/sessions/index.ts +5 -0
- package/src/shared/lib/workspace/session-state/store.ts +20 -0
- package/dist/client/static/js/index-bsxxt89j.css +0 -1
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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={
|
|
108
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
-
|
|
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>> {
|