ompchamber 3.4.0 → 3.6.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 +387 -383
- package/dist/client/static/js/index-7n838fz5.css +1 -0
- package/dist/client/static/js/{index-h05z90mw.js → index-qp2sfmn2.js} +614 -615
- package/package.json +1 -1
- package/src/cli/lib/commands/status.js +7 -0
- 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/layout/session-sidebar/about-modal/UpdateLog.tsx +50 -0
- package/src/client/components/layout/session-sidebar/about-modal/UpdateSection.tsx +12 -17
- 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/components/settings/ProjectSelectorDropdown.tsx +8 -2
- package/src/client/components/settings/SettingsSidebarList.tsx +5 -1
- package/src/client/components/settings/categories/CommandSettings.tsx +47 -9
- package/src/client/components/settings/categories/SkillCatalogSettings.tsx +23 -43
- package/src/client/components/settings/categories/SkillSettings.tsx +51 -7
- package/src/client/components/settings/categories/command-settings/DetailPane.tsx +109 -103
- package/src/client/components/settings/categories/command-settings/SidebarList.tsx +6 -1
- package/src/client/components/settings/categories/skill-settings/DetailPane.tsx +130 -31
- package/src/client/components/settings/categories/skill-settings/SidebarList.tsx +9 -3
- package/src/client/data/settings/command.ts +0 -40
- package/src/client/hooks/settings/workspace-roots.ts +99 -0
- package/src/client/hooks/ui/updates/index.ts +178 -0
- package/src/client/hooks/workspace/session-delete.ts +94 -0
- package/src/server/db.server.ts +41 -11
- package/src/server/index.ts +17 -1
- package/src/server/lib/btw/purge.server.ts +56 -0
- package/src/server/lib/db/client.ts +17 -0
- package/src/server/lib/db/compact-chat-overlay.test.ts +121 -0
- package/src/server/lib/db/compact-chat-overlay.ts +90 -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/lifecycle/fd-pressure.test.ts +66 -0
- package/src/server/lib/lifecycle/fd-pressure.ts +159 -0
- package/src/server/lib/omp/config/commands.test.ts +167 -0
- package/src/server/lib/omp/config/commands.ts +195 -0
- package/src/server/lib/omp/config/scope.test.ts +49 -0
- package/src/server/lib/omp/config/scope.ts +65 -0
- package/src/server/lib/omp/config/skills-catalog.ts +35 -3
- package/src/server/lib/omp/config/skills.test.ts +105 -0
- package/src/server/lib/omp/config/skills.ts +260 -47
- package/src/server/lib/omp/rpc/process.ts +22 -14
- package/src/server/lib/omp/rpc/session-registry.ts +6 -0
- package/src/server/lib/omp/rpc/utility.ts +45 -23
- package/src/server/lib/omp/session/delete.server.ts +136 -0
- package/src/server/lib/omp/session/merge-stored-anchor.test.ts +85 -0
- package/src/server/lib/omp/session/merge-stored-overlay-rows.test.ts +106 -0
- package/src/server/lib/omp/session/merge-stored.ts +78 -11
- package/src/server/lib/omp/session/reader.ts +13 -0
- package/src/server/lib/omp/session/reload.server.test.ts +84 -0
- package/src/server/lib/omp/session/reload.server.ts +47 -0
- package/src/server/lib/omp/session/subagent-presence.ts +40 -9
- package/src/server/lib/terminal/liveness.server.ts +65 -0
- package/src/server/lib/terminal/runtime.server.ts +70 -117
- package/src/server/lib/terminal/session.server.ts +5 -2
- package/src/server/lib/terminal/store.ts +72 -0
- package/src/server/lib/updates/apply.ts +62 -33
- package/src/server/lib/updates/install.ts +9 -2
- package/src/server/lib/updates/omp.ts +37 -9
- package/src/server/lib/updates/single-flight.test.ts +61 -0
- package/src/server/lib/updates/single-flight.ts +68 -0
- package/src/server/routes/chat/session.ts +13 -3
- package/src/server/routes/health/probe.ts +8 -0
- package/src/server/routes/omp/index.ts +0 -2
- 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/server/routes/settings/commands.ts +102 -15
- package/src/server/routes/settings/skills.ts +165 -113
- package/src/server/routes/terminal/sessions.ts +1 -1
- package/src/server/routes/updates/apply.ts +101 -14
- package/src/shared/lib/chat/composer/client.ts +27 -18
- package/src/shared/lib/chat/composer/scope.test.ts +87 -0
- package/src/shared/lib/markdown/katex.ts +11 -1
- package/src/shared/lib/markdown/marked.ts +68 -13
- package/src/shared/lib/markdown/math.test.ts +53 -1
- package/src/shared/lib/updates/progress.test.ts +54 -0
- package/src/shared/lib/updates/progress.ts +50 -0
- package/src/shared/lib/workspace/session-state/store.ts +20 -0
- package/src/shared/types/settings/command.ts +15 -2
- package/src/shared/types/settings/skill.ts +13 -0
- package/dist/client/static/js/index-bsxxt89j.css +0 -1
- package/src/client/hooks/ui/updates.ts +0 -108
- package/src/server/routes/omp/commands.ts +0 -29
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live update state: the check that reports what is available, and the apply
|
|
3
|
+
* that runs it.
|
|
4
|
+
*
|
|
5
|
+
* The apply is a POST whose response is an SSE stream, not a JSON body. An
|
|
6
|
+
* `omp update` or an OMPChamber install runs for minutes; a buffered response
|
|
7
|
+
* left the console with a spinner and nothing else until it finished, and
|
|
8
|
+
* discarded the command output the server had already collected. Every frame
|
|
9
|
+
* the stream carries is folded into `progress`, which survives the modal being
|
|
10
|
+
* closed — the hook lives on the sidebar, so leaving the dialog no longer hides
|
|
11
|
+
* the run.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
15
|
+
import { readSseStream } from '@/shared/lib/chat/read-sse';
|
|
16
|
+
import { appendUpdateLog, type UpdateLogState } from '@/shared/lib/updates/progress';
|
|
17
|
+
import type { UpdateApplyResult, UpdateCheckResult, UpdateTarget } from '@/shared/types/updates';
|
|
18
|
+
|
|
19
|
+
export interface UseUpdatesResult {
|
|
20
|
+
info: UpdateCheckResult | null;
|
|
21
|
+
checking: boolean;
|
|
22
|
+
applying: UpdateTarget | null;
|
|
23
|
+
/** Log of the run in flight (or the last one), null before any apply. */
|
|
24
|
+
progress: UpdateLogState | null;
|
|
25
|
+
error: string | null;
|
|
26
|
+
hasUpdate: boolean;
|
|
27
|
+
check: () => Promise<void>;
|
|
28
|
+
apply: (target: UpdateTarget) => Promise<UpdateApplyResult | null>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function responseMessage(payload: unknown, fallback: string): string {
|
|
32
|
+
if (typeof payload !== 'object' || payload === null) return fallback;
|
|
33
|
+
|
|
34
|
+
if ('error' in payload && typeof payload.error === 'string') return payload.error;
|
|
35
|
+
if ('message' in payload && typeof payload.message === 'string') return payload.message;
|
|
36
|
+
return fallback;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function isUpdateApplyResult(payload: unknown): payload is UpdateApplyResult {
|
|
40
|
+
if (typeof payload !== 'object' || payload === null) return false;
|
|
41
|
+
|
|
42
|
+
return (
|
|
43
|
+
'success' in payload && typeof payload.success === 'boolean' &&
|
|
44
|
+
'target' in payload && (payload.target === 'ompchamber' || payload.target === 'omp') &&
|
|
45
|
+
'manual' in payload && typeof payload.manual === 'boolean' &&
|
|
46
|
+
'message' in payload && typeof payload.message === 'string'
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function useUpdates(): UseUpdatesResult {
|
|
51
|
+
const [info, setInfo] = useState<UpdateCheckResult | null>(null);
|
|
52
|
+
const [checking, setChecking] = useState(false);
|
|
53
|
+
const [applying, setApplying] = useState<UpdateTarget | null>(null);
|
|
54
|
+
const [progress, setProgress] = useState<UpdateLogState | null>(null);
|
|
55
|
+
const [error, setError] = useState<string | null>(null);
|
|
56
|
+
const inFlightCheckRef = useRef<Promise<void> | null>(null);
|
|
57
|
+
const didAutoCheckRef = useRef(false);
|
|
58
|
+
const applyAbortRef = useRef<AbortController | null>(null);
|
|
59
|
+
|
|
60
|
+
const check = useCallback(() => {
|
|
61
|
+
if (inFlightCheckRef.current) return inFlightCheckRef.current;
|
|
62
|
+
|
|
63
|
+
const request = (async () => {
|
|
64
|
+
setChecking(true);
|
|
65
|
+
setError(null);
|
|
66
|
+
|
|
67
|
+
try {
|
|
68
|
+
const response = await fetch('/api/updates/check');
|
|
69
|
+
const payload: unknown = await response.json();
|
|
70
|
+
if (!response.ok) {
|
|
71
|
+
throw new Error(responseMessage(payload, `Update check failed (HTTP ${response.status})`));
|
|
72
|
+
}
|
|
73
|
+
setInfo(payload as UpdateCheckResult);
|
|
74
|
+
} catch (cause) {
|
|
75
|
+
setError(cause instanceof Error ? cause.message : 'Unable to check for updates.');
|
|
76
|
+
} finally {
|
|
77
|
+
setChecking(false);
|
|
78
|
+
inFlightCheckRef.current = null;
|
|
79
|
+
}
|
|
80
|
+
})();
|
|
81
|
+
|
|
82
|
+
inFlightCheckRef.current = request;
|
|
83
|
+
return request;
|
|
84
|
+
}, []);
|
|
85
|
+
|
|
86
|
+
const apply = useCallback(async (target: UpdateTarget): Promise<UpdateApplyResult | null> => {
|
|
87
|
+
setApplying(target);
|
|
88
|
+
setProgress({ lines: [], pending: '', truncated: false });
|
|
89
|
+
setError(null);
|
|
90
|
+
|
|
91
|
+
const controller = new AbortController();
|
|
92
|
+
applyAbortRef.current = controller;
|
|
93
|
+
// The fold is mirrored in a local so each frame is one setState, rather
|
|
94
|
+
// than a read-modify-write against the previous render's state.
|
|
95
|
+
let log: UpdateLogState = { lines: [], pending: '', truncated: false };
|
|
96
|
+
let result: UpdateApplyResult | null = null;
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const response = await fetch('/api/updates/apply', {
|
|
100
|
+
method: 'POST',
|
|
101
|
+
headers: { 'Content-Type': 'application/json', Accept: 'text/event-stream' },
|
|
102
|
+
body: JSON.stringify({ target }),
|
|
103
|
+
signal: controller.signal,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
if (!response.ok && !(response.headers.get('content-type') ?? '').includes('text/event-stream')) {
|
|
107
|
+
const payload: unknown = await response.json().catch(() => null);
|
|
108
|
+
const message = responseMessage(payload, `Update failed (HTTP ${response.status})`);
|
|
109
|
+
// 409 is the server saying another run holds the slot. That is a state,
|
|
110
|
+
// not a failure of this request, so it is reported by toast alone: an
|
|
111
|
+
// error state would replace the update rows with a block the user
|
|
112
|
+
// cannot act on, and its Retry only re-checks.
|
|
113
|
+
if (response.status === 409) return { success: false, target, manual: false, message };
|
|
114
|
+
throw new Error(message);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
await readSseStream(
|
|
118
|
+
response,
|
|
119
|
+
(event, data) => {
|
|
120
|
+
if (event === 'line') {
|
|
121
|
+
log = appendUpdateLog(log, JSON.parse(data) as string);
|
|
122
|
+
setProgress(log);
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (event === 'stage') {
|
|
126
|
+
// A stage is its own line: the updater's heading and the output
|
|
127
|
+
// under it must not be glued into one line.
|
|
128
|
+
log = appendUpdateLog(log, `${JSON.parse(data) as string}\n`);
|
|
129
|
+
setProgress(log);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (event === 'result') {
|
|
133
|
+
const payload: unknown = JSON.parse(data);
|
|
134
|
+
if (isUpdateApplyResult(payload)) result = payload;
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
controller.signal,
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
if (!controller.signal.aborted) await check();
|
|
141
|
+
return result;
|
|
142
|
+
} catch (cause) {
|
|
143
|
+
if (controller.signal.aborted) return null;
|
|
144
|
+
const message = cause instanceof Error ? cause.message : 'Unable to apply the update.';
|
|
145
|
+
setError(message);
|
|
146
|
+
// Returned rather than swallowed: the caller's toast is the only surface
|
|
147
|
+
// the user sees, and a generic "could not be completed" would hide a
|
|
148
|
+
// refusal the server explained — an update already running, in the case
|
|
149
|
+
// that matters most, because a reload has just erased this panel's own
|
|
150
|
+
// record that one was.
|
|
151
|
+
return { success: false, target, manual: false, message };
|
|
152
|
+
} finally {
|
|
153
|
+
if (applyAbortRef.current === controller) {
|
|
154
|
+
applyAbortRef.current = null;
|
|
155
|
+
setApplying(null);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}, [check]);
|
|
159
|
+
|
|
160
|
+
useEffect(() => {
|
|
161
|
+
if (didAutoCheckRef.current) return;
|
|
162
|
+
didAutoCheckRef.current = true;
|
|
163
|
+
void check();
|
|
164
|
+
}, [check]);
|
|
165
|
+
|
|
166
|
+
useEffect(() => () => applyAbortRef.current?.abort(), []);
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
info,
|
|
170
|
+
checking,
|
|
171
|
+
applying,
|
|
172
|
+
progress,
|
|
173
|
+
error,
|
|
174
|
+
hasUpdate: Boolean(info?.ompchamber.updateAvailable || info?.omp.updateAvailable),
|
|
175
|
+
check,
|
|
176
|
+
apply,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
@@ -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
|
+
}
|
package/src/server/db.server.ts
CHANGED
|
@@ -7,7 +7,9 @@
|
|
|
7
7
|
* Database lifecycle for OMPChamber: resolve the SQLite path, open the
|
|
8
8
|
* `bun:sqlite` client once, bootstrap the schema, and seed demo data in mock
|
|
9
9
|
* mode. The DDL lives in `./db/schema`, the mock presets in `./db/seed` and
|
|
10
|
-
* the workspace discovery sync in `./db/workspace-sync`.
|
|
10
|
+
* the workspace discovery sync in `./db/workspace-sync`. The one-time overlay
|
|
11
|
+
* rewrite is NOT here: it is a server-lifecycle concern (`compact-chat-overlay`)
|
|
12
|
+
* and runs from the server bootstrap, so opening a handle can never trigger it.
|
|
11
13
|
*/
|
|
12
14
|
|
|
13
15
|
import path from 'path';
|
|
@@ -21,10 +23,33 @@ import { isMockMode } from '@/server/mock.server';
|
|
|
21
23
|
import { pathExists } from '@/server/lib/omp/core/paths';
|
|
22
24
|
import { migrateLegacyProjectSettings } from '@/shared/lib/workspace/project-settings-migration';
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
*
|
|
27
|
-
|
|
26
|
+
/**
|
|
27
|
+
* The handle and its promise live on `globalThis`, not in module bindings.
|
|
28
|
+
*
|
|
29
|
+
* `bun run --hot` re-evaluates this module on every server-file edit while the
|
|
30
|
+
* process lives on, so a module-level cache is reset each time and the previous
|
|
31
|
+
* `Database` is dropped without ever being closed: measured on Bun 1.4.2 /
|
|
32
|
+
* macOS, each reload of this file left two more descriptors behind (`db.sqlite`
|
|
33
|
+
* and `db.sqlite-wal`), which is 2 per edit toward the 10,240-descriptor point
|
|
34
|
+
* where every `Bun.spawn` in the process starts failing with `EBADF`. The slot
|
|
35
|
+
* keeps ONE handle per process — the same reason the terminal registry, the
|
|
36
|
+
* flock host and the provider cache are anchored here.
|
|
37
|
+
*/
|
|
38
|
+
interface DbSlot {
|
|
39
|
+
/** In-flight open, so concurrent `getDb()` callers share one handle. */
|
|
40
|
+
promise: Promise<DbClient> | null;
|
|
41
|
+
/** Resolved handle for `getDbSync`; hot paths must not await. */
|
|
42
|
+
resolved: DbClient | null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
declare global {
|
|
46
|
+
// eslint-disable-next-line no-var
|
|
47
|
+
var __ompChamberDb: DbSlot | undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function slot(): DbSlot {
|
|
51
|
+
return (globalThis.__ompChamberDb ??= { promise: null, resolved: null });
|
|
52
|
+
}
|
|
28
53
|
|
|
29
54
|
export async function getDatabasePath(): Promise<string> {
|
|
30
55
|
if (isMockMode()) {
|
|
@@ -51,9 +76,13 @@ export async function getDatabasePath(): Promise<string> {
|
|
|
51
76
|
}
|
|
52
77
|
|
|
53
78
|
export async function getDb(): Promise<DbClient> {
|
|
54
|
-
|
|
79
|
+
const state = slot();
|
|
80
|
+
if (state.promise) return state.promise;
|
|
81
|
+
// A reload keeps the previous generation's handle: the schema was already
|
|
82
|
+
// initialized in this process, and re-opening would leak the old one.
|
|
83
|
+
if (state.resolved) return state.resolved;
|
|
55
84
|
|
|
56
|
-
|
|
85
|
+
state.promise = (async () => {
|
|
57
86
|
const dbPath = await getDatabasePath();
|
|
58
87
|
const db = createDb(dbPath);
|
|
59
88
|
|
|
@@ -72,11 +101,11 @@ export async function getDb(): Promise<DbClient> {
|
|
|
72
101
|
|
|
73
102
|
await migrateLegacyProjectSettings(db);
|
|
74
103
|
|
|
75
|
-
|
|
104
|
+
state.resolved = db;
|
|
76
105
|
return db;
|
|
77
106
|
})();
|
|
78
107
|
|
|
79
|
-
return
|
|
108
|
+
return state.promise;
|
|
80
109
|
}
|
|
81
110
|
|
|
82
111
|
/**
|
|
@@ -86,6 +115,7 @@ export async function getDb(): Promise<DbClient> {
|
|
|
86
115
|
* use `getDb()`.
|
|
87
116
|
*/
|
|
88
117
|
export function getDbSync(): DbClient {
|
|
89
|
-
|
|
90
|
-
|
|
118
|
+
const resolved = slot().resolved;
|
|
119
|
+
if (!resolved) throw new Error('Database not initialized — call getDb() first');
|
|
120
|
+
return resolved;
|
|
91
121
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -4,7 +4,8 @@ import shell from '@/../index.html';
|
|
|
4
4
|
import { apiRoutes } from '@/server/routes';
|
|
5
5
|
import { ssrRoutes } from '@/server/plugins/ssr';
|
|
6
6
|
import { setListener } from '@/server/lib/lifecycle/listener';
|
|
7
|
-
import { getDatabasePath } from '@/server/db.server';
|
|
7
|
+
import { getDatabasePath, getDb } from '@/server/db.server';
|
|
8
|
+
import { compactChatOverlayRows } from '@/server/lib/db/compact-chat-overlay';
|
|
8
9
|
import { fetchOmpRegistrySnapshot } from '@/server/lib/models/provider-registry.server';
|
|
9
10
|
import { ompStartupError, ompStartupLogLines } from '@/server/lib/omp/core/startup';
|
|
10
11
|
import { readInstanceRecord, removeInstanceRecord, writeInstanceRecord } from '@/server/lib/lifecycle/instance';
|
|
@@ -106,6 +107,21 @@ await logDetectedRegistry();
|
|
|
106
107
|
|
|
107
108
|
console.log(`[ompchamber] listening on http://${host}:${port}`);
|
|
108
109
|
|
|
110
|
+
/**
|
|
111
|
+
* One-time rewrite of the overlay rows written before `overlayRowsForOmpSession`
|
|
112
|
+
* existed — see the module. Deliberately HERE, after the banner, and NOT inside
|
|
113
|
+
* `getDb()`: the test suite and the CLI reach `getDb()` in real mode too, and
|
|
114
|
+
* merely opening the database must not be able to launch a rewrite of every
|
|
115
|
+
* mirrored conversation. (It could: a `bun test` run compacted a live database.)
|
|
116
|
+
* The pass is marker-keyed, so from the second start it is one indexed SELECT,
|
|
117
|
+
* and it reports its own per-row failures rather than throwing.
|
|
118
|
+
*/
|
|
119
|
+
try {
|
|
120
|
+
await compactChatOverlayRows(await getDb());
|
|
121
|
+
} catch (error) {
|
|
122
|
+
console.error('[chat-overlay] compaction skipped:', error);
|
|
123
|
+
}
|
|
124
|
+
|
|
109
125
|
/**
|
|
110
126
|
* What omp reports it can actually run. A live RPC round-trip: a cold utility
|
|
111
127
|
* process takes seconds, which is why it runs after the listener is up. Side
|
|
@@ -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
|
+
}
|
|
@@ -21,10 +21,27 @@ export interface DbClient {
|
|
|
21
21
|
* reusable, and the app issues thousands of small queries per request cycle
|
|
22
22
|
* (sidebar scans, telemetry probes, file listings).
|
|
23
23
|
*/
|
|
24
|
+
/**
|
|
25
|
+
* How long a blocked write waits for another connection to release the lock.
|
|
26
|
+
*
|
|
27
|
+
* The chamber database is shared: a second chamber on another port writes the
|
|
28
|
+
* same file, the CLI writes it, and a maintenance `VACUUM` takes the exclusive
|
|
29
|
+
* lock outright. Without this, a write that meets any of them fails on the spot
|
|
30
|
+
* with `SQLITE_BUSY: database is locked` — which is exactly what a server log
|
|
31
|
+
* from 2026-09-25 is full of, with no writer at fault.
|
|
32
|
+
*
|
|
33
|
+
* Deliberately modest: `bun:sqlite` is synchronous, so this wait blocks the
|
|
34
|
+
* whole event loop — every request, stream and terminal frame with it. Two
|
|
35
|
+
* seconds clears every realistic holder (a full `VACUUM` of this database
|
|
36
|
+
* measured 134 ms) without freezing the app for long when a lock is stuck.
|
|
37
|
+
*/
|
|
38
|
+
const BUSY_TIMEOUT_MS = 2000;
|
|
39
|
+
|
|
24
40
|
export function createDb(path: string): DbClient {
|
|
25
41
|
const raw = new Database(path, { create: true });
|
|
26
42
|
raw.exec('PRAGMA journal_mode = WAL');
|
|
27
43
|
raw.exec('PRAGMA foreign_keys = ON');
|
|
44
|
+
raw.exec(`PRAGMA busy_timeout = ${BUSY_TIMEOUT_MS}`);
|
|
28
45
|
|
|
29
46
|
const stmts = new Map<string, ReturnType<Database['query']>>();
|
|
30
47
|
const prep = (sql: string) => {
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*
|
|
5
|
+
* The overlay repair. Its whole risk is asymmetric: narrowing a row it should
|
|
6
|
+
* not touch DESTROYS a conversation (for a chamber-created session the DB is the
|
|
7
|
+
* only copy there is), while leaving a row it should narrow merely wastes disk.
|
|
8
|
+
* So the file-existence gate is the behaviour under test, and it is exercised
|
|
9
|
+
* against a real temp tree rather than a stub — `findSessionFileById` is the
|
|
10
|
+
* thing being trusted.
|
|
11
|
+
*
|
|
12
|
+
* The pass has no marker on purpose (see the module): the per-row "did the
|
|
13
|
+
* filter shrink it?" guard is what makes it self-limiting, so a second run must
|
|
14
|
+
* find nothing left to do.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { afterAll, describe, expect, test } from 'bun:test';
|
|
18
|
+
import { mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { createDb, type DbClient } from '@/server/lib/db/client';
|
|
22
|
+
import { initSchema } from '@/server/lib/db/schema';
|
|
23
|
+
import { compactChatOverlayRows } from '@/server/lib/db/compact-chat-overlay';
|
|
24
|
+
|
|
25
|
+
const WITH_FILE = '01a0dddd-1111-7222-8333-444455556666';
|
|
26
|
+
const CHAMBER_ONLY = 'new-1700000000000';
|
|
27
|
+
const NOTICE = 'Context window: 1000000 tokens (1% used)';
|
|
28
|
+
|
|
29
|
+
/** Mirrored shape: the user turn plus assistant rows no reader ever touches. */
|
|
30
|
+
const MIRRORED = [
|
|
31
|
+
{ id: 'msg-1-user', role: 'user', content: 'hello' },
|
|
32
|
+
{ id: 'omp-a1', role: 'ai', content: 'hi there' },
|
|
33
|
+
{ id: 'cmdout-1', role: 'ai', content: '', notice: NOTICE },
|
|
34
|
+
{ id: 'omp-a2', role: 'ai', content: 'more' },
|
|
35
|
+
];
|
|
36
|
+
/** A chamber-created conversation: no session file exists for it. */
|
|
37
|
+
const CHAMBER = [
|
|
38
|
+
{ id: 'msg-9-user', role: 'user', content: 'ask' },
|
|
39
|
+
{ id: 'local-ai', role: 'ai', content: 'answer' },
|
|
40
|
+
];
|
|
41
|
+
|
|
42
|
+
const cleanup: string[] = [];
|
|
43
|
+
afterAll(() => {
|
|
44
|
+
for (const target of cleanup) rmSync(target, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
function tempDir(label: string): string {
|
|
48
|
+
const dir = join(tmpdir(), `overlay-${label}-${crypto.randomUUID()}`);
|
|
49
|
+
cleanup.push(dir);
|
|
50
|
+
mkdirSync(dir, { recursive: true });
|
|
51
|
+
return dir;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A sessions root holding exactly one session file, named the way omp names it. */
|
|
55
|
+
function sessionsRoot(): string {
|
|
56
|
+
const root = tempDir('root');
|
|
57
|
+
const project = join(root, '--proj--');
|
|
58
|
+
mkdirSync(project, { recursive: true });
|
|
59
|
+
writeFileSync(
|
|
60
|
+
join(project, `2026-01-01T00-00-00-000Z_${WITH_FILE}.jsonl`),
|
|
61
|
+
`${JSON.stringify({ type: 'session', id: WITH_FILE, timestamp: '2026-01-01T00:00:00.000Z', cwd: '/' })}\n`,
|
|
62
|
+
);
|
|
63
|
+
return root;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function seededDb() {
|
|
67
|
+
const db = createDb(join(tempDir('db'), 'db.sqlite'));
|
|
68
|
+
await initSchema(db);
|
|
69
|
+
const put = (sessionId: string, messages: unknown[]) =>
|
|
70
|
+
db.run('INSERT OR REPLACE INTO chat_sessions (session_id, title, messages) VALUES (?, ?, ?)', [
|
|
71
|
+
sessionId,
|
|
72
|
+
't',
|
|
73
|
+
JSON.stringify(messages),
|
|
74
|
+
]);
|
|
75
|
+
await put(WITH_FILE, MIRRORED);
|
|
76
|
+
await put(CHAMBER_ONLY, CHAMBER);
|
|
77
|
+
return db;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function storedMessages(db: DbClient, sessionId: string) {
|
|
81
|
+
const row = (await db.get('SELECT messages FROM chat_sessions WHERE session_id = ?', [sessionId])) as {
|
|
82
|
+
messages: string;
|
|
83
|
+
};
|
|
84
|
+
return JSON.parse(row.messages) as { id: string; role: string }[];
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
describe('compactChatOverlayRows', () => {
|
|
88
|
+
test('narrows a mirrored row whose session file exists', async () => {
|
|
89
|
+
const db = await seededDb();
|
|
90
|
+
await compactChatOverlayRows(db, sessionsRoot());
|
|
91
|
+
|
|
92
|
+
const kept = await storedMessages(db, WITH_FILE);
|
|
93
|
+
expect(kept.map((m) => m.id)).toEqual(['msg-1-user', 'cmdout-1']);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('leaves a chamber-created row untouched — the DB is its only copy', async () => {
|
|
97
|
+
const db = await seededDb();
|
|
98
|
+
await compactChatOverlayRows(db, sessionsRoot());
|
|
99
|
+
|
|
100
|
+
expect(await storedMessages(db, CHAMBER_ONLY)).toEqual(CHAMBER);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('finds nothing left to do on a second run', async () => {
|
|
104
|
+
const db = await seededDb();
|
|
105
|
+
const root = sessionsRoot();
|
|
106
|
+
await compactChatOverlayRows(db, root);
|
|
107
|
+
const once = JSON.stringify({
|
|
108
|
+
mirrored: await storedMessages(db, WITH_FILE),
|
|
109
|
+
chamber: await storedMessages(db, CHAMBER_ONLY),
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
await compactChatOverlayRows(db, root);
|
|
113
|
+
|
|
114
|
+
expect(
|
|
115
|
+
JSON.stringify({
|
|
116
|
+
mirrored: await storedMessages(db, WITH_FILE),
|
|
117
|
+
chamber: await storedMessages(db, CHAMBER_ONLY),
|
|
118
|
+
}),
|
|
119
|
+
).toBe(once);
|
|
120
|
+
});
|
|
121
|
+
});
|