ompchamber 1.2.0 → 2.0.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/README.md +131 -147
- package/dist/client/index.html +4 -2
- package/dist/client/static/css/index.5f630421b7.css +2 -0
- package/dist/client/static/js/77.0d81c74cf0.js +139 -0
- package/dist/client/static/js/async/1k.973e8e21fc.js +2 -0
- package/dist/client/static/js/async/{3y.3186ecdd63.js → 3y.3530b824ad.js} +6 -6
- package/dist/client/static/js/async/{q.b453193538.js → 4m.928a1dde85.js} +10 -10
- package/dist/client/static/js/async/66.5dfa29f957.js +93 -0
- package/dist/client/static/js/async/7g.a7814350c3.js +1 -0
- package/dist/client/static/js/async/ar.9303e5f2e8.js +2 -0
- package/dist/client/static/js/async/{cx.9c8268d542.js → cx.3cb0242a3a.js} +1 -1
- package/dist/client/static/js/async/e.93214d622b.js +79 -0
- package/dist/client/static/js/async/g.74ff36419f.js +2 -0
- package/dist/client/static/js/async/{m.2e1ea3db9e.js.LICENSE.txt → g.74ff36419f.js.LICENSE.txt} +5 -0
- package/dist/client/static/js/async/h1.393f066d09.js +1 -0
- package/dist/client/static/js/async/jl.874d352278.js +1 -0
- package/dist/client/static/js/async/{k.d16cd09ae7.js → k.3210286401.js} +1 -1
- package/dist/client/static/js/async/{m.2e1ea3db9e.js → m.eb2e6752e2.js} +3 -3
- package/dist/client/static/js/async/m.eb2e6752e2.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/mt.0352e976d8.js +1 -0
- package/dist/client/static/js/async/{oc.eb22093f05.js → oc.b73042e8bd.js} +1 -1
- package/dist/client/static/js/async/{og.53f068804c.js → og.8eb577893a.js} +1 -1
- package/dist/client/static/js/async/px.fc6877924b.js +1 -0
- package/dist/client/static/js/async/q.33d6779f98.js +21 -0
- package/dist/client/static/js/async/{q5.88b50ffa95.js → q5.97dc1481b6.js} +1 -1
- package/dist/client/static/js/async/s.0f395cdda5.js +94 -0
- package/dist/client/static/js/async/{tr.2a0d2b7600.js → tr.e878658140.js} +1 -1
- package/dist/client/static/js/async/vt.42416b707d.js +1 -0
- package/dist/client/static/js/async/x1.da8687159b.js +90 -0
- package/dist/client/static/js/async/xe.da8dcc2015.js +2 -0
- package/dist/client/static/js/async/xe.da8dcc2015.js.LICENSE.txt +11 -0
- package/dist/client/static/js/async/{xn.6360379f71.js → xn.6510c0abe2.js} +2 -2
- package/dist/client/static/js/async/yg.35bebd6d0c.js +149 -0
- package/dist/client/static/js/async/yv.d99d829136.js +1 -0
- package/dist/client/static/js/index.c05535dbde.js +38 -0
- package/package.json +3 -3
- package/src/client/components/common/ComposerTextarea.tsx +9 -1
- package/src/client/components/common/MarkdownRenderer.tsx +50 -12
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +9 -3
- package/src/client/components/settings/Modal.tsx +11 -2
- package/src/client/components/settings/categories/AppearanceSettings.tsx +7 -30
- package/src/client/components/settings/categories/ProviderSettings.tsx +2 -0
- package/src/client/components/settings/categories/appearance-settings/ThemeCard.tsx +61 -0
- package/src/client/components/settings/categories/appearance-settings/ThemeGrid.tsx +80 -0
- package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +1 -1
- package/src/client/components/settings/categories/provider-settings/AddProviderModal.tsx +43 -6
- package/src/client/components/settings/categories/provider-settings/ModelConfigModal.tsx +54 -54
- package/src/client/components/workspace/SearchPanel.tsx +16 -3
- package/src/client/components/workspace/btw-panel/AskCard.tsx +11 -6
- package/src/client/components/workspace/btw-panel/Messages.tsx +105 -51
- package/src/client/components/workspace/btw-panel/index.tsx +129 -45
- package/src/client/components/workspace/chat-timeline/AiMessageFooter.tsx +30 -14
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +10 -1
- package/src/client/components/workspace/chat-timeline/GeneratingIndicator.tsx +8 -2
- package/src/client/components/workspace/chat-timeline/MessageItem.tsx +27 -16
- package/src/client/components/workspace/chat-timeline/MessageList.tsx +11 -0
- package/src/client/components/workspace/chat-timeline/RunFooter.tsx +8 -0
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +12 -1
- package/src/client/components/workspace/chat-timeline/index.tsx +7 -2
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/QuestionBlock.tsx +42 -15
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.tsx +13 -1
- package/src/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/index.tsx +13 -4
- package/src/client/components/workspace/editor/index.tsx +11 -2
- package/src/client/components/workspace/file-explorer/GitRepoDropdown.tsx +15 -41
- package/src/client/components/workspace/file-explorer/index.tsx +86 -16
- package/src/client/components/workspace/git-panel/index.tsx +60 -78
- package/src/client/components/workspace/terminal-panel/Header.tsx +16 -1
- package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +5 -5
- package/src/client/components/workspace/terminal-panel/index.tsx +6 -2
- package/src/client/components/workspace/terminal-panel/mount.ts +2 -2
- package/src/client/data/theme/terminal.ts +50 -52
- package/src/client/hooks/chat/btw/index.ts +139 -18
- package/src/client/hooks/chat/btw/intercept.ts +8 -8
- package/src/client/hooks/chat/btw/mode.ts +111 -16
- package/src/client/hooks/chat/composer/index.ts +10 -4
- package/src/client/hooks/chat/timeline/access-mode.ts +41 -0
- package/src/client/hooks/chat/timeline/ask-frames.test.ts +87 -0
- package/src/client/hooks/chat/timeline/ask-frames.ts +20 -1
- package/src/client/hooks/chat/timeline/auto-scroll.ts +13 -4
- package/src/client/hooks/chat/timeline/index.ts +8 -20
- package/src/client/hooks/chat/timeline/queue.ts +66 -9
- package/src/client/hooks/chat/timeline/scroll.test.ts +260 -0
- package/src/client/hooks/chat/timeline/scroll.ts +93 -20
- package/src/client/hooks/chat/timeline/session-load.ts +7 -1
- package/src/client/hooks/chat/timeline/session-pagination.test.ts +167 -0
- package/src/client/hooks/chat/timeline/session-pagination.ts +37 -11
- package/src/client/hooks/settings/providers.ts +34 -3
- package/src/client/hooks/ui/open-file.ts +7 -1
- package/src/client/hooks/ui/theme.ts +82 -57
- package/src/client/hooks/workspace/file-tabs.ts +5 -1
- package/src/client/hooks/workspace/repo-scope/index.ts +119 -0
- package/src/client/hooks/workspace/repo-scope/store.test.ts +161 -0
- package/src/client/hooks/workspace/repo-scope/store.ts +201 -0
- package/src/client/hooks/workspace/search-stream.ts +24 -5
- package/src/client/main.tsx +6 -0
- package/src/client/tailwind.css +19 -56
- package/src/server/lib/btw/child.server.ts +41 -0
- package/src/server/lib/btw/lifecycle.server.ts +254 -0
- package/src/server/lib/btw/prompt.ts +13 -5
- package/src/server/lib/btw/registry.server.ts +13 -4
- package/src/server/lib/btw/runtime.server.ts +149 -156
- package/src/server/lib/btw/schema.server.ts +27 -0
- package/src/server/lib/btw/service.server.ts +106 -5
- package/src/server/lib/btw/store.server.ts +57 -6
- package/src/server/lib/db/schema.ts +4 -0
- package/src/server/lib/db/settings-store.test.ts +65 -0
- package/src/server/lib/db/settings-store.ts +25 -0
- package/src/server/lib/fs/atomic-write.ts +42 -4
- package/src/server/lib/fs/config-lock.ts +74 -0
- package/src/server/lib/fs/git-ignore.ts +55 -5
- package/src/server/lib/fs/ripgrep.ts +119 -0
- package/src/server/lib/lifecycle/identity.ts +10 -64
- package/src/server/lib/lifecycle/proc/darwin.ts +238 -0
- package/src/server/lib/lifecycle/proc/index.ts +38 -0
- package/src/server/lib/lifecycle/proc/linux.ts +80 -0
- package/src/server/lib/lifecycle/proc/ps.ts +86 -0
- package/src/server/lib/lifecycle/proc/types.ts +33 -0
- package/src/server/lib/lifecycle/proc/win32.ts +53 -0
- package/src/server/lib/models/provider-registry.server.ts +2 -2
- package/src/server/lib/models/registry.server.ts +1 -1
- package/src/server/lib/omp/config/behavior.ts +17 -17
- package/src/server/lib/omp/config/disabled-providers.ts +84 -35
- package/src/server/lib/omp/config/document.ts +128 -0
- package/src/server/lib/omp/config/extensions.ts +11 -10
- package/src/server/lib/omp/config/mcp.ts +4 -57
- package/src/server/lib/omp/config/model-overrides.ts +123 -0
- package/src/server/lib/omp/config/models-config.ts +92 -0
- package/src/server/lib/omp/config/models-validation.ts +159 -0
- package/src/server/lib/omp/config/path-scoped.ts +74 -0
- package/src/server/lib/omp/config/providers.ts +241 -211
- package/src/server/lib/omp/config/roles.ts +6 -14
- package/src/server/lib/omp/core/auth-credentials.ts +1 -1
- package/src/server/lib/omp/rpc/frame-fold.test.ts +124 -0
- package/src/server/lib/omp/rpc/frame-fold.ts +43 -24
- package/src/server/lib/omp/rpc/lifecycle.test.ts +41 -0
- package/src/server/lib/omp/rpc/manager.ts +21 -21
- package/src/server/lib/omp/rpc/pending-ui-dialogs.ts +11 -7
- package/src/server/lib/omp/rpc/session-commands.ts +17 -29
- package/src/server/lib/omp/rpc/session-registry.ts +5 -66
- package/src/server/lib/omp/rpc/web-state.ts +0 -6
- package/src/server/lib/omp/session/auto-title.server.test.ts +109 -11
- package/src/server/lib/omp/session/auto-title.server.ts +95 -20
- package/src/server/lib/omp/session/sidebar-data.server.ts +11 -6
- package/src/server/lib/queue/delivery-dispatch.server.test.ts +162 -0
- package/src/server/lib/queue/delivery-harness.ts +88 -0
- package/src/server/lib/queue/delivery.server.test.ts +78 -149
- package/src/server/lib/queue/delivery.server.ts +105 -13
- package/src/server/lib/queue/store.server.ts +30 -5
- package/src/server/lib/terminal/foreground.test.ts +164 -0
- package/src/server/lib/terminal/foreground.ts +34 -6
- package/src/server/lib/terminal/history.ts +8 -7
- package/src/server/plugins/compress.ts +33 -5
- package/src/server/plugins/ssr.ts +14 -7
- package/src/server/routes/btw/topics.ts +89 -2
- package/src/server/routes/fs/git.ts +5 -6
- package/src/server/routes/fs/search.ts +17 -26
- package/src/server/routes/sessions/queue.ts +17 -4
- package/src/server/routes/settings/index.ts +2 -0
- package/src/server/routes/settings/model-override.ts +69 -0
- package/src/server/routes/settings/provider-models.ts +35 -12
- package/src/server/routes/settings/providers.ts +31 -1
- package/src/server/routes/settings/root.ts +8 -3
- package/src/shared/lib/chat/btw/client.ts +34 -0
- package/src/shared/lib/chat/timeline/tool-phrases.ts +7 -0
- package/src/shared/lib/code/shiki-themes.ts +9 -5
- package/src/shared/lib/fs/file-tree.ts +17 -12
- package/src/shared/lib/markdown/autolink.test.ts +32 -0
- package/src/shared/lib/markdown/autolink.ts +50 -0
- package/src/shared/lib/markdown/document-urls.test.ts +68 -0
- package/src/shared/lib/markdown/document-urls.ts +118 -0
- package/src/shared/lib/markdown/marked.ts +27 -15
- package/src/shared/lib/markdown/mermaid.ts +2 -2
- package/src/shared/lib/models/catalog.ts +1 -1
- package/src/shared/lib/omp/session/schema-migrations.ts +30 -0
- package/src/shared/lib/omp/session/stream-state.server.test.ts +54 -0
- package/src/shared/lib/omp/session/stream-state.server.ts +70 -21
- package/src/shared/lib/settings/client.ts +10 -4
- package/src/shared/lib/settings/diff.test.ts +31 -0
- package/src/shared/lib/settings/diff.ts +37 -0
- package/src/shared/lib/theme/catalog.test.ts +168 -0
- package/src/shared/lib/theme/catalog.ts +98 -0
- package/src/shared/lib/theme/color.ts +88 -0
- package/src/shared/lib/theme/css.ts +70 -0
- package/src/shared/lib/theme/palettes/chamber.ts +61 -0
- package/src/shared/lib/theme/palettes/dark.ts +313 -0
- package/src/shared/lib/theme/palettes/light.ts +313 -0
- package/src/shared/types/btw.ts +83 -4
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/settings/provider.ts +8 -1
- package/src/shared/types/settings/state.ts +10 -4
- package/src/shared/types/theme.ts +48 -0
- package/dist/client/static/css/index.b938f4a693.css +0 -2
- package/dist/client/static/js/77.8022a800b5.js +0 -139
- package/dist/client/static/js/async/1k.a411fe5b43.js +0 -2
- package/dist/client/static/js/async/66.86544ad741.js +0 -93
- package/dist/client/static/js/async/7g.db82aca7ba.js +0 -1
- package/dist/client/static/js/async/ar.ad5c8822a8.js +0 -2
- package/dist/client/static/js/async/e.6443e739a2.js +0 -79
- package/dist/client/static/js/async/g.ee41bfed7f.js +0 -2
- package/dist/client/static/js/async/h1.d7091cef78.js +0 -1
- package/dist/client/static/js/async/jl.4911fa3383.js +0 -1
- package/dist/client/static/js/async/mt.853be2b5aa.js +0 -1
- package/dist/client/static/js/async/ot.84c71b308f.js +0 -2
- package/dist/client/static/js/async/px.783b6f91f2.js +0 -1
- package/dist/client/static/js/async/q8.7f1b48e9cd.js +0 -21
- package/dist/client/static/js/async/q8.7f1b48e9cd.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/s.aad2ecebc6.js +0 -94
- package/dist/client/static/js/async/vt.20406a5604.js +0 -1
- package/dist/client/static/js/async/x1.fe8af17016.js +0 -90
- package/dist/client/static/js/async/xn.6360379f71.js.LICENSE.txt +0 -6
- package/dist/client/static/js/async/yg.5a94e1fc8a.js +0 -149
- package/dist/client/static/js/async/yv.2fa22f009a.js +0 -1
- package/dist/client/static/js/index.57b39ffa7f.js +0 -34
- package/src/client/components/workspace/btw-panel/Composer.tsx +0 -181
- package/src/client/components/workspace/btw-panel/attachments.ts +0 -80
- /package/dist/client/static/js/{77.8022a800b5.js.LICENSE.txt → 77.0d81c74cf0.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.ee41bfed7f.js.LICENSE.txt → 1k.973e8e21fc.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{3y.3186ecdd63.js.LICENSE.txt → 3y.3530b824ad.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{ot.84c71b308f.js.LICENSE.txt → 4m.928a1dde85.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{q.b453193538.js.LICENSE.txt → ar.9303e5f2e8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{1k.a411fe5b43.js.LICENSE.txt → q.33d6779f98.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{ar.ad5c8822a8.js.LICENSE.txt → xn.6510c0abe2.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{index.57b39ffa7f.js.LICENSE.txt → index.c05535dbde.js.LICENSE.txt} +0 -0
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
20
|
import { getDb } from '@/server/db.server';
|
|
21
|
-
import
|
|
21
|
+
import { isApprovalMode, type ApprovalMode } from '@/shared/lib/omp/config/access-mode';
|
|
22
|
+
import type { BtwModel, BtwTopic, BtwTurn, BtwTurnStatus, ChatMessageData } from '@/shared/types';
|
|
22
23
|
|
|
23
24
|
interface TopicRow {
|
|
24
25
|
id: string;
|
|
@@ -27,6 +28,8 @@ interface TopicRow {
|
|
|
27
28
|
provider: string | null;
|
|
28
29
|
model_id: string | null;
|
|
29
30
|
model_name: string | null;
|
|
31
|
+
thinking_level: string | null;
|
|
32
|
+
approval_mode: string | null;
|
|
30
33
|
leaf_id: string | null;
|
|
31
34
|
promoted_session_id: string | null;
|
|
32
35
|
created_at: number;
|
|
@@ -38,6 +41,7 @@ interface TurnRow {
|
|
|
38
41
|
turn_index: number;
|
|
39
42
|
question: string;
|
|
40
43
|
answer: string;
|
|
44
|
+
messages: string;
|
|
41
45
|
status: string;
|
|
42
46
|
created_at: number;
|
|
43
47
|
updated_at: number;
|
|
@@ -67,6 +71,8 @@ function rowToTopic(row: TopicRow): BtwTopic {
|
|
|
67
71
|
createdAt: row.created_at,
|
|
68
72
|
updatedAt: row.updated_at,
|
|
69
73
|
...(model ? { model } : {}),
|
|
74
|
+
...(row.thinking_level ? { thinkingLevel: row.thinking_level } : {}),
|
|
75
|
+
...(isApprovalMode(row.approval_mode) ? { approvalMode: row.approval_mode } : {}),
|
|
70
76
|
...(row.promoted_session_id ? { promotedSessionId: row.promoted_session_id } : {}),
|
|
71
77
|
turns: [],
|
|
72
78
|
};
|
|
@@ -77,12 +83,25 @@ function rowToTurn(row: TurnRow): BtwTurn {
|
|
|
77
83
|
index: row.turn_index,
|
|
78
84
|
question: row.question,
|
|
79
85
|
answer: row.answer,
|
|
86
|
+
messages: parseMessages(row.messages),
|
|
80
87
|
status: isBtwTurnStatus(row.status) ? row.status : 'interrupted',
|
|
81
88
|
createdAt: row.created_at,
|
|
82
89
|
updatedAt: row.updated_at,
|
|
83
90
|
};
|
|
84
91
|
}
|
|
85
92
|
|
|
93
|
+
/** A turn's stored conversation. A row written before the column existed (or a
|
|
94
|
+
* corrupted one) reads as an empty conversation rather than throwing. */
|
|
95
|
+
function parseMessages(raw: string | null | undefined): ChatMessageData[] {
|
|
96
|
+
if (!raw) return [];
|
|
97
|
+
try {
|
|
98
|
+
const value: unknown = JSON.parse(raw);
|
|
99
|
+
return Array.isArray(value) ? (value as ChatMessageData[]) : [];
|
|
100
|
+
} catch {
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
86
105
|
/** Every topic of a session, oldest first, each with its turns in order. */
|
|
87
106
|
export async function listBtwTopics(sessionId: string): Promise<BtwTopic[]> {
|
|
88
107
|
const db = await getDb();
|
|
@@ -119,6 +138,8 @@ export interface NewBtwTopicInput {
|
|
|
119
138
|
sessionId: string;
|
|
120
139
|
title: string;
|
|
121
140
|
model?: BtwModel;
|
|
141
|
+
thinkingLevel?: string;
|
|
142
|
+
approvalMode?: ApprovalMode;
|
|
122
143
|
/** Parent transcript leaf when the topic was created (promotion guard). */
|
|
123
144
|
leafId?: string | null;
|
|
124
145
|
}
|
|
@@ -133,6 +154,8 @@ export async function createBtwTopic(input: NewBtwTopicInput): Promise<BtwTopic>
|
|
|
133
154
|
provider: input.model?.provider ?? null,
|
|
134
155
|
model_id: input.model?.id ?? null,
|
|
135
156
|
model_name: input.model?.name ?? null,
|
|
157
|
+
thinking_level: input.thinkingLevel ?? null,
|
|
158
|
+
approval_mode: input.approvalMode ?? null,
|
|
136
159
|
leaf_id: input.leafId ?? null,
|
|
137
160
|
promoted_session_id: null,
|
|
138
161
|
created_at: now,
|
|
@@ -140,9 +163,21 @@ export async function createBtwTopic(input: NewBtwTopicInput): Promise<BtwTopic>
|
|
|
140
163
|
};
|
|
141
164
|
await db.run(
|
|
142
165
|
`INSERT INTO btw_topics
|
|
143
|
-
(id, session_id, title, provider, model_id, model_name, leaf_id, promoted_session_id, created_at, updated_at)
|
|
144
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)`,
|
|
145
|
-
[
|
|
166
|
+
(id, session_id, title, provider, model_id, model_name, thinking_level, approval_mode, leaf_id, promoted_session_id, created_at, updated_at)
|
|
167
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)`,
|
|
168
|
+
[
|
|
169
|
+
row.id,
|
|
170
|
+
row.session_id,
|
|
171
|
+
row.title,
|
|
172
|
+
row.provider,
|
|
173
|
+
row.model_id,
|
|
174
|
+
row.model_name,
|
|
175
|
+
row.thinking_level,
|
|
176
|
+
row.approval_mode,
|
|
177
|
+
row.leaf_id,
|
|
178
|
+
now,
|
|
179
|
+
now,
|
|
180
|
+
],
|
|
146
181
|
);
|
|
147
182
|
return rowToTopic(row);
|
|
148
183
|
}
|
|
@@ -165,11 +200,11 @@ export async function appendBtwTurn(topicId: string, question: string): Promise<
|
|
|
165
200
|
return turnIndex;
|
|
166
201
|
}
|
|
167
202
|
|
|
168
|
-
/** Patch one turn's answer and/or status. */
|
|
203
|
+
/** Patch one turn's answer, conversation and/or status. */
|
|
169
204
|
export async function updateBtwTurn(
|
|
170
205
|
topicId: string,
|
|
171
206
|
turnIndex: number,
|
|
172
|
-
patch: { answer?: string; status?: BtwTurnStatus },
|
|
207
|
+
patch: { answer?: string; status?: BtwTurnStatus; messages?: ChatMessageData[] },
|
|
173
208
|
): Promise<void> {
|
|
174
209
|
const db = await getDb();
|
|
175
210
|
const sets: string[] = ['updated_at = ?'];
|
|
@@ -182,6 +217,10 @@ export async function updateBtwTurn(
|
|
|
182
217
|
sets.push('status = ?');
|
|
183
218
|
values.push(patch.status);
|
|
184
219
|
}
|
|
220
|
+
if (patch.messages !== undefined) {
|
|
221
|
+
sets.push('messages = ?');
|
|
222
|
+
values.push(JSON.stringify(patch.messages));
|
|
223
|
+
}
|
|
185
224
|
await db.run(`UPDATE btw_turns SET ${sets.join(', ')} WHERE topic_id = ? AND turn_index = ?`, [
|
|
186
225
|
...values,
|
|
187
226
|
topicId,
|
|
@@ -223,6 +262,18 @@ export async function setBtwTopicModel(topicId: string, model: BtwModel): Promis
|
|
|
223
262
|
]);
|
|
224
263
|
}
|
|
225
264
|
|
|
265
|
+
/** Record the thinking level the side child runs at (`null` clears it). */
|
|
266
|
+
export async function setBtwTopicThinkingLevel(topicId: string, level: string | null): Promise<void> {
|
|
267
|
+
const db = await getDb();
|
|
268
|
+
await db.run('UPDATE btw_topics SET thinking_level = ?, updated_at = ? WHERE id = ?', [level, Date.now(), topicId]);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Record the approval mode the side child was spawned with. */
|
|
272
|
+
export async function setBtwTopicApprovalMode(topicId: string, mode: ApprovalMode): Promise<void> {
|
|
273
|
+
const db = await getDb();
|
|
274
|
+
await db.run('UPDATE btw_topics SET approval_mode = ?, updated_at = ? WHERE id = ?', [mode, Date.now(), topicId]);
|
|
275
|
+
}
|
|
276
|
+
|
|
226
277
|
export async function setBtwTopicPromoted(topicId: string, promotedSessionId: string): Promise<void> {
|
|
227
278
|
const db = await getDb();
|
|
228
279
|
await db.run('UPDATE btw_topics SET promoted_session_id = ?, updated_at = ? WHERE id = ?', [
|
|
@@ -15,6 +15,7 @@ import type { DbClient } from '@/server/lib/db/client';
|
|
|
15
15
|
import { migrateQueueTableFk } from '@/server/lib/queue/schema-migration.server';
|
|
16
16
|
import { ensureBtwSchema } from '@/server/lib/btw/schema.server';
|
|
17
17
|
import { migrateWorkspaceFolderColumns } from '@/shared/lib/workspace/schema-migrations';
|
|
18
|
+
import { migrateSessionStreamStateColumns } from '@/shared/lib/omp/session/schema-migrations';
|
|
18
19
|
|
|
19
20
|
export async function initSchema(db: DbClient): Promise<void> {
|
|
20
21
|
await db.exec(`
|
|
@@ -84,6 +85,7 @@ export async function initSchema(db: DbClient): Promise<void> {
|
|
|
84
85
|
CREATE TABLE IF NOT EXISTS session_stream_state (
|
|
85
86
|
session_id TEXT PRIMARY KEY,
|
|
86
87
|
status TEXT NOT NULL,
|
|
88
|
+
owner_pid INTEGER,
|
|
87
89
|
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
88
90
|
);
|
|
89
91
|
`);
|
|
@@ -92,6 +94,8 @@ export async function initSchema(db: DbClient): Promise<void> {
|
|
|
92
94
|
// Installs older `queued_messages` schemas carry an invalid FK (see the
|
|
93
95
|
// migration module); rebuild once so real-session queue inserts work.
|
|
94
96
|
await migrateQueueTableFk(db);
|
|
97
|
+
// `owner_pid` arrived after `session_stream_state` shipped; see the migration.
|
|
98
|
+
await migrateSessionStreamStateColumns(db);
|
|
95
99
|
// Side-question tables live in their own module (see schema.server.ts).
|
|
96
100
|
await ensureBtwSchema(db);
|
|
97
101
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test';
|
|
2
|
+
|
|
3
|
+
import type { DbClient } from '@/server/lib/db/client';
|
|
4
|
+
import { isPlainObject, mergeSettingsJson, readSettingsJson } from '@/server/lib/db/settings-store';
|
|
5
|
+
|
|
6
|
+
/** Minimal in-memory stand-in for the `app_settings` rows the store talks to. */
|
|
7
|
+
function fakeDb(rows: Record<string, string> = {}) {
|
|
8
|
+
const store = new Map(Object.entries(rows));
|
|
9
|
+
const db = {
|
|
10
|
+
async get(_sql: string, params: unknown[] = []) {
|
|
11
|
+
const value = store.get(String(params[0]));
|
|
12
|
+
return value === undefined ? undefined : { value };
|
|
13
|
+
},
|
|
14
|
+
async run(_sql: string, params: unknown[] = []) {
|
|
15
|
+
store.set(String(params[0]), String(params[1]));
|
|
16
|
+
return { changes: 1, lastID: 0 };
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
// Only `get`/`run` are reachable from the store; the rest of `DbClient` exists
|
|
20
|
+
// for callers this test never exercises.
|
|
21
|
+
return { db: db as unknown as DbClient, read: (key: string) => store.get(key) };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const KEY = 'omp_chamber_settings';
|
|
25
|
+
|
|
26
|
+
describe('mergeSettingsJson', () => {
|
|
27
|
+
test('applies the patch and keeps the keys it did not name', async () => {
|
|
28
|
+
const { db, read } = fakeDb({ [KEY]: JSON.stringify({ theme: 'paper', soundAlerts: true }) });
|
|
29
|
+
await mergeSettingsJson(db, KEY, { theme: 'nord-dark' });
|
|
30
|
+
expect(JSON.parse(read(KEY)!)).toEqual({ theme: 'nord-dark', soundAlerts: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('creates the row when it is absent or not an object', async () => {
|
|
34
|
+
const absent = fakeDb();
|
|
35
|
+
await mergeSettingsJson(absent.db, KEY, { theme: 'nord-dark' });
|
|
36
|
+
expect(JSON.parse(absent.read(KEY)!)).toEqual({ theme: 'nord-dark' });
|
|
37
|
+
|
|
38
|
+
const array = fakeDb({ [KEY]: JSON.stringify(['stale']) });
|
|
39
|
+
await mergeSettingsJson(array.db, KEY, { theme: 'nord-dark' });
|
|
40
|
+
expect(JSON.parse(array.read(KEY)!)).toEqual({ theme: 'nord-dark' });
|
|
41
|
+
|
|
42
|
+
const malformed = fakeDb({ [KEY]: 'not json' });
|
|
43
|
+
await mergeSettingsJson(malformed.db, KEY, { theme: 'nord-dark' });
|
|
44
|
+
expect(JSON.parse(malformed.read(KEY)!)).toEqual({ theme: 'nord-dark' });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('a patch cannot resurrect a value from a stale copy of its siblings', async () => {
|
|
48
|
+
// Two writers, one blob: only the key each of them edited is stored.
|
|
49
|
+
const { db, read } = fakeDb({ [KEY]: JSON.stringify({ theme: 'paper' }) });
|
|
50
|
+
await mergeSettingsJson(db, KEY, { theme: 'nord-dark' });
|
|
51
|
+
await mergeSettingsJson(db, KEY, { chatCompletionSound: false });
|
|
52
|
+
expect(JSON.parse(read(KEY)!)).toEqual({ theme: 'nord-dark', chatCompletionSound: false });
|
|
53
|
+
expect(await readSettingsJson(db, KEY, {})).toEqual({ theme: 'nord-dark', chatCompletionSound: false });
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe('isPlainObject', () => {
|
|
58
|
+
test('accepts objects and rejects the shapes that must replace instead of merge', () => {
|
|
59
|
+
expect(isPlainObject({ a: 1 })).toBe(true);
|
|
60
|
+
expect(isPlainObject(['a'])).toBe(false);
|
|
61
|
+
expect(isPlainObject(null)).toBe(false);
|
|
62
|
+
expect(isPlainObject('a')).toBe(false);
|
|
63
|
+
expect(isPlainObject(7)).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -35,6 +35,31 @@ export async function writeSettingsRaw(db: DbClient, key: string, value: string)
|
|
|
35
35
|
await db.run(UPSERT_VALUE_SQL, [key, value]);
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
/** A JSON object that is not an array — the only shape a key-wise merge applies to. */
|
|
39
|
+
export function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
40
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Shallow-merge a plain-object patch into the object already stored for `key`.
|
|
45
|
+
*
|
|
46
|
+
* A patch is the only thing the settings client can send without republishing
|
|
47
|
+
* values it merely read: the `omp_chamber_settings` blob is written by two
|
|
48
|
+
* writers (the settings modal, the theme picker) and read by many, so a writer
|
|
49
|
+
* that echoed its whole copy would discard whatever another writer stored in
|
|
50
|
+
* between. Merging is what makes a partial write safe; a stored value that is
|
|
51
|
+
* absent or not an object is replaced outright, and arrays still replace
|
|
52
|
+
* because a list has no per-key identity to merge on.
|
|
53
|
+
*/
|
|
54
|
+
export async function mergeSettingsJson(
|
|
55
|
+
db: DbClient,
|
|
56
|
+
key: string,
|
|
57
|
+
patch: Record<string, unknown>,
|
|
58
|
+
): Promise<void> {
|
|
59
|
+
const stored = await readSettingsJson<unknown>(db, key, null);
|
|
60
|
+
await writeSettingsJson(db, key, { ...(isPlainObject(stored) ? stored : {}), ...patch });
|
|
61
|
+
}
|
|
62
|
+
|
|
38
63
|
/** Read every settings row, parsing JSON where possible and keeping raw text otherwise. */
|
|
39
64
|
export async function readAllSettingsJson(db: DbClient): Promise<Record<string, unknown>> {
|
|
40
65
|
const rows = await db.all<{ key: string; value: string }>(SELECT_ALL_SQL);
|
|
@@ -8,18 +8,56 @@
|
|
|
8
8
|
* the target. rename(2) is atomic on POSIX, so a crash mid-write leaves either
|
|
9
9
|
* the old file or the new one — never a truncated file. The temp file is
|
|
10
10
|
* removed (best-effort) when the write or rename fails.
|
|
11
|
+
*
|
|
12
|
+
* Two properties the plain rename does NOT give you, and both are required for
|
|
13
|
+
* omp's config files:
|
|
14
|
+
*
|
|
15
|
+
* - **Mode.** `Bun.write` creates the temp file with the process umask
|
|
16
|
+
* (0644/0664), so overwriting a `0600` file — omp opens its own config temp
|
|
17
|
+
* with `wx, 0o600` — silently loosened the mode of a file holding plaintext
|
|
18
|
+
* API keys. The existing target's mode is carried onto the replacement; only
|
|
19
|
+
* a brand-new file uses `createMode`.
|
|
20
|
+
* - **Symlinks.** Renaming onto a symlinked path replaces the symlink with a
|
|
21
|
+
* regular file. omp resolves the symlink chain and writes to the real target
|
|
22
|
+
* (`Settings.#resolveYamlWritePath`), so a user who links `config.yml` into a
|
|
23
|
+
* dotfiles repo keeps the link. The chain is resolved before the temp file is
|
|
24
|
+
* placed, so the temp lands beside the real target on the same filesystem.
|
|
11
25
|
*/
|
|
12
26
|
|
|
13
27
|
import fs from 'fs';
|
|
14
28
|
import { dirname } from 'path';
|
|
15
29
|
|
|
30
|
+
export interface AtomicWriteOptions {
|
|
31
|
+
/**
|
|
32
|
+
* Mode for a file that does not exist yet. Existing files always keep their
|
|
33
|
+
* own mode. Defaults to `0o644`.
|
|
34
|
+
*/
|
|
35
|
+
createMode?: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
16
38
|
/** Writes `data` to `targetPath` via a sibling temp file then rename (atomic on POSIX). */
|
|
17
|
-
export async function writeFileAtomic(
|
|
18
|
-
|
|
19
|
-
|
|
39
|
+
export async function writeFileAtomic(
|
|
40
|
+
targetPath: string,
|
|
41
|
+
data: string,
|
|
42
|
+
options: AtomicWriteOptions = {},
|
|
43
|
+
): Promise<void> {
|
|
44
|
+
// Missing file (first write) or a dangling link throws — the parent is real.
|
|
45
|
+
let resolved = targetPath;
|
|
46
|
+
try {
|
|
47
|
+
resolved = fs.realpathSync.native(targetPath);
|
|
48
|
+
} catch {}
|
|
49
|
+
let mode = options.createMode ?? 0o644;
|
|
50
|
+
try {
|
|
51
|
+
mode = fs.statSync(resolved).mode & 0o777;
|
|
52
|
+
} catch {}
|
|
53
|
+
await fs.promises.mkdir(dirname(resolved), { recursive: true });
|
|
54
|
+
const tempPath = `${resolved}.tmp-${process.pid}-${crypto.randomUUID()}`;
|
|
20
55
|
try {
|
|
21
56
|
await Bun.write(tempPath, data);
|
|
22
|
-
|
|
57
|
+
// Bun.write ignores `mode` when creating, so the mode is applied explicitly
|
|
58
|
+
// before the rename — a chmod after would leave a window at the default.
|
|
59
|
+
await fs.promises.chmod(tempPath, mode);
|
|
60
|
+
await fs.promises.rename(tempPath, resolved);
|
|
23
61
|
} catch (error) {
|
|
24
62
|
await fs.promises.rm(tempPath, { force: true }).catch(() => undefined);
|
|
25
63
|
throw error;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Cross-process lock for read-modify-write cycles on shared config files.
|
|
8
|
+
*
|
|
9
|
+
* Two writers (the dev server, the installed app, or two provider mutations
|
|
10
|
+
* landing together) editing the same file would otherwise silently lose the
|
|
11
|
+
* earlier mutation when the later rename wins. A lockfile with exclusive
|
|
12
|
+
* create (`wx`) is atomic on every platform; the holder writes its PID and
|
|
13
|
+
* deletes the file on completion. Stale locks (writer crashed) are broken
|
|
14
|
+
* after a grace period.
|
|
15
|
+
*
|
|
16
|
+
* This is deliberately NOT omp's own lock: omp takes an OS-level `flock(2)` on
|
|
17
|
+
* `${path}.lock` (Linux uses abstract Unix sockets, Windows named mutexes), so
|
|
18
|
+
* the two mechanisms do not interop. The window this leaves — a chamber write
|
|
19
|
+
* landing between omp's read and its rename — is bounded by keeping every
|
|
20
|
+
* critical section to a single read-modify-write, and omp re-reads inside its
|
|
21
|
+
* own lock before applying its pending changes.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import fs from 'fs';
|
|
25
|
+
import { dirname } from 'path';
|
|
26
|
+
|
|
27
|
+
const LOCK_TIMEOUT_MS = 3_000;
|
|
28
|
+
const LOCK_STALE_MS = 10_000;
|
|
29
|
+
const LOCK_RETRY_MS = 25;
|
|
30
|
+
|
|
31
|
+
/** Run `fn` while holding an exclusive `${configPath}.lock` lease. */
|
|
32
|
+
export async function withConfigLock<T>(configPath: string, fn: () => T | Promise<T>): Promise<T> {
|
|
33
|
+
const lockPath = `${configPath}.lock`;
|
|
34
|
+
// The config file may not exist yet (first write) — the lockfile needs its
|
|
35
|
+
// parent dir to exist before exclusive-create can succeed.
|
|
36
|
+
await fs.promises.mkdir(dirname(lockPath), { recursive: true });
|
|
37
|
+
const deadline = Date.now() + LOCK_TIMEOUT_MS;
|
|
38
|
+
for (;;) {
|
|
39
|
+
let handle: fs.promises.FileHandle | null = null;
|
|
40
|
+
try {
|
|
41
|
+
handle = await fs.promises.open(lockPath, 'wx');
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error;
|
|
44
|
+
// Held by another process — break it if stale, otherwise wait and retry.
|
|
45
|
+
try {
|
|
46
|
+
if (Date.now() - (await fs.promises.stat(lockPath)).mtimeMs > LOCK_STALE_MS) {
|
|
47
|
+
await fs.promises.unlink(lockPath);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
continue;
|
|
52
|
+
}
|
|
53
|
+
if (Date.now() >= deadline) {
|
|
54
|
+
throw new Error(`Timed out waiting for ${lockPath} (another process holds the config lock)`);
|
|
55
|
+
}
|
|
56
|
+
await Bun.sleep(LOCK_RETRY_MS);
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
await handle.writeFile(String(process.pid));
|
|
61
|
+
} finally {
|
|
62
|
+
await handle.close();
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
return await fn();
|
|
66
|
+
} finally {
|
|
67
|
+
try {
|
|
68
|
+
await fs.promises.unlink(lockPath);
|
|
69
|
+
} catch {
|
|
70
|
+
// Already removed (e.g. by cleanup) — the critical section is done.
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -15,10 +15,46 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Paths must be relative to `dir` and must exist on disk: git resolves
|
|
17
17
|
* directory-only patterns (`build/`) by stat-ing the path.
|
|
18
|
+
*
|
|
19
|
+
* Answers are memoized per path: the panel asks again every time a folder is
|
|
20
|
+
* re-listed or re-expanded, and each question costs a `git` subprocess
|
|
21
|
+
* (measured 5.6 ms). The TTL is short because a `.gitignore` the user just
|
|
22
|
+
* edited should take effect without a reload; a path whose answer is still
|
|
23
|
+
* cached is simply not asked about, so a re-listing of a known folder spawns
|
|
24
|
+
* nothing at all.
|
|
18
25
|
*/
|
|
26
|
+
|
|
27
|
+
const CACHE_TTL_MS = 5_000;
|
|
28
|
+
/** Bound on remembered paths; the oldest answer is evicted past this. */
|
|
29
|
+
const CACHE_MAX_ENTRIES = 20_000;
|
|
30
|
+
|
|
31
|
+
interface IgnoreAnswer {
|
|
32
|
+
ignored: boolean;
|
|
33
|
+
at: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const cache = new Map<string, IgnoreAnswer>();
|
|
37
|
+
|
|
38
|
+
function cacheKey(dir: string, rel: string): string {
|
|
39
|
+
return `${dir}\0${rel}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
19
42
|
export async function collectIgnoredPaths(dir: string, relPaths: string[]): Promise<Set<string>> {
|
|
20
43
|
if (relPaths.length === 0) return new Set();
|
|
21
44
|
|
|
45
|
+
const now = Date.now();
|
|
46
|
+
const ignored = new Set<string>();
|
|
47
|
+
const unknown: string[] = [];
|
|
48
|
+
for (const rel of relPaths) {
|
|
49
|
+
const answer = cache.get(cacheKey(dir, rel));
|
|
50
|
+
if (answer && now - answer.at < CACHE_TTL_MS) {
|
|
51
|
+
if (answer.ignored) ignored.add(rel);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
unknown.push(rel);
|
|
55
|
+
}
|
|
56
|
+
if (unknown.length === 0) return ignored;
|
|
57
|
+
|
|
22
58
|
try {
|
|
23
59
|
const proc = Bun.spawn({
|
|
24
60
|
cmd: ['git', 'check-ignore', '--stdin', '-z'],
|
|
@@ -32,18 +68,32 @@ export async function collectIgnoredPaths(dir: string, relPaths: string[]): Prom
|
|
|
32
68
|
// Drain stdout while the listing is written: a directory big enough to
|
|
33
69
|
// fill both pipe buffers would otherwise deadlock child against parent.
|
|
34
70
|
const stdout = new Response(proc.stdout).text();
|
|
35
|
-
proc.stdin.write(
|
|
71
|
+
proc.stdin.write(unknown.map(rel => `${rel}\0`).join(''));
|
|
36
72
|
// FileSink.end() types as `number | Promise<number>` — EPIPE (child gone)
|
|
37
73
|
// must not surface as an unhandled rejection.
|
|
38
74
|
void Promise.resolve(proc.stdin.end()).catch(() => {});
|
|
39
75
|
|
|
40
76
|
const [text, exitCode] = await Promise.all([stdout, proc.exited]);
|
|
41
77
|
// 0 = at least one path matched, 1 = none, 128 = no repository. Only 0 can
|
|
42
|
-
// carry a match list
|
|
43
|
-
|
|
78
|
+
// carry a match list, but every path still gets a definite answer: an
|
|
79
|
+
// unlisted one is not ignored.
|
|
80
|
+
const matched = exitCode === 0 ? new Set(text.split('\0').filter(Boolean)) : new Set<string>();
|
|
81
|
+
|
|
82
|
+
// Only 0 and 1 mean git actually read the paths; 128 (no repository) is
|
|
83
|
+
// not an answer, so nothing is cached from it and the next listing retries.
|
|
84
|
+
const answered = exitCode === 0 || exitCode === 1;
|
|
85
|
+
const stamped = Date.now();
|
|
86
|
+
for (const rel of unknown) {
|
|
87
|
+
const isIgnored = matched.has(rel);
|
|
88
|
+
if (isIgnored) ignored.add(rel);
|
|
89
|
+
if (answered) {
|
|
90
|
+
if (cache.size >= CACHE_MAX_ENTRIES) cache.clear();
|
|
91
|
+
cache.set(cacheKey(dir, rel), { ignored: isIgnored, at: stamped });
|
|
92
|
+
}
|
|
93
|
+
}
|
|
44
94
|
|
|
45
|
-
return
|
|
95
|
+
return ignored;
|
|
46
96
|
} catch {
|
|
47
|
-
return
|
|
97
|
+
return ignored; // git missing / spawn failed — nothing is dimmed
|
|
48
98
|
}
|
|
49
99
|
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The `rg` behind the Search panel — a native one when the machine has it.
|
|
8
|
+
*
|
|
9
|
+
* The `ripgrep` package is a WASI build (732 KiB of wasm) whose guest sees a
|
|
10
|
+
* single preopened directory. That costs two things. Speed: measured 86–117 ms
|
|
11
|
+
* against 37 ms for the same query in this repo. And correctness: the guest
|
|
12
|
+
* cannot reach the user's global gitignore, so a path git refuses to track —
|
|
13
|
+
* `~/.gitignore` excludes `AGENTS.md` on this machine — was listed as a search
|
|
14
|
+
* hit while the Files and Git panels both hid it.
|
|
15
|
+
*
|
|
16
|
+
* A native `rg` on PATH answers both, but `Bun.which` alone is not enough to
|
|
17
|
+
* find one: the first `rg` on PATH in this repo is `node_modules/.bin/rg`, the
|
|
18
|
+
* WASI shim itself (`#!/usr/bin/env node`). Resolving by hand lets a script be
|
|
19
|
+
* skipped and the next PATH entry considered. When no native binary exists the
|
|
20
|
+
* WASI build still runs, because the panel has to work on a machine that never
|
|
21
|
+
* installed ripgrep.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { join } from 'path';
|
|
25
|
+
import { ripgrep } from 'ripgrep';
|
|
26
|
+
|
|
27
|
+
/** Bytes that open a script rather than an executable (`#!`). */
|
|
28
|
+
const SHEBANG_MAGIC = [0x23, 0x21] as const;
|
|
29
|
+
|
|
30
|
+
export interface RipgrepSink {
|
|
31
|
+
/** Raw stdout, chunk by chunk, as rg flushes it. */
|
|
32
|
+
onStdout: (chunk: Uint8Array) => void;
|
|
33
|
+
/** Raw stderr, chunk by chunk. */
|
|
34
|
+
onStderr: (chunk: Uint8Array) => void;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let resolvedBinary: string | null | undefined;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Path of a native `rg`, or null when PATH holds only scripts.
|
|
41
|
+
*
|
|
42
|
+
* Cached for the process: PATH does not move under a running server, and the
|
|
43
|
+
* answer costs one 2-byte read per candidate. A binary is recognised by *not*
|
|
44
|
+
* starting with `#!` — that covers ELF, Mach-O (thin and fat) and PE without
|
|
45
|
+
* enumerating magics, and a wrong guess only costs a failed spawn, which falls
|
|
46
|
+
* back to the WASI build.
|
|
47
|
+
*/
|
|
48
|
+
export async function nativeRipgrepPath(): Promise<string | null> {
|
|
49
|
+
if (resolvedBinary !== undefined) return resolvedBinary;
|
|
50
|
+
resolvedBinary = null;
|
|
51
|
+
|
|
52
|
+
const executable = process.platform === 'win32' ? 'rg.exe' : 'rg';
|
|
53
|
+
for (const dir of (Bun.env.PATH ?? '').split(process.platform === 'win32' ? ';' : ':')) {
|
|
54
|
+
if (!dir) continue;
|
|
55
|
+
const candidate = join(dir, executable);
|
|
56
|
+
try {
|
|
57
|
+
const head = await Bun.file(candidate).slice(0, 2).bytes();
|
|
58
|
+
if (head.length < 2) continue;
|
|
59
|
+
if (head[0] === SHEBANG_MAGIC[0] && head[1] === SHEBANG_MAGIC[1]) continue;
|
|
60
|
+
resolvedBinary = candidate;
|
|
61
|
+
break;
|
|
62
|
+
} catch {
|
|
63
|
+
// Not there, or not readable — try the next PATH entry.
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return resolvedBinary;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Run `rg` with `args` in `cwd`, streaming both pipes into `sink`.
|
|
71
|
+
*
|
|
72
|
+
* Returns rg's exit code (0 = matches, 1 = none, 2 = error). A native binary
|
|
73
|
+
* that disappears between resolution and spawn falls back to WASI rather than
|
|
74
|
+
* failing the search.
|
|
75
|
+
*/
|
|
76
|
+
export async function runRipgrep(args: string[], cwd: string, sink: RipgrepSink): Promise<number> {
|
|
77
|
+
const binary = await nativeRipgrepPath();
|
|
78
|
+
if (binary) {
|
|
79
|
+
try {
|
|
80
|
+
const proc = Bun.spawn({
|
|
81
|
+
cmd: [binary, ...args],
|
|
82
|
+
cwd,
|
|
83
|
+
stdout: 'pipe',
|
|
84
|
+
stderr: 'pipe',
|
|
85
|
+
});
|
|
86
|
+
// `pipeTo`, not `for await`: the repo's lib set has no
|
|
87
|
+
// `ReadableStream[Symbol.asyncIterator]`, and this is the same shape
|
|
88
|
+
// `lib/omp/rpc/lines.ts` uses to drain a Bun child.
|
|
89
|
+
const pump = (stream: ReadableStream<Uint8Array>, write: (chunk: Uint8Array) => void) =>
|
|
90
|
+
stream.pipeTo(
|
|
91
|
+
new WritableStream({
|
|
92
|
+
write(chunk) {
|
|
93
|
+
write(chunk);
|
|
94
|
+
},
|
|
95
|
+
}),
|
|
96
|
+
).then(
|
|
97
|
+
() => undefined,
|
|
98
|
+
() => undefined,
|
|
99
|
+
);
|
|
100
|
+
// Drained together: rg filling one pipe while the other is unread would
|
|
101
|
+
// otherwise block it against a full buffer.
|
|
102
|
+
await Promise.all([pump(proc.stdout, sink.onStdout), pump(proc.stderr, sink.onStderr)]);
|
|
103
|
+
return await proc.exited;
|
|
104
|
+
} catch {
|
|
105
|
+
// Spawn refused (deleted, not executable) — the WASI build still answers.
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// WASI preopens map the guest "." onto the real target directory.
|
|
110
|
+
const preopens = { '.': cwd };
|
|
111
|
+
const { code } = await ripgrep(args, {
|
|
112
|
+
preopens,
|
|
113
|
+
// ripgrep accepts any `{ write(chunk) }` sink — feed it the raw stream so
|
|
114
|
+
// matches flush incrementally.
|
|
115
|
+
stdout: { write: (chunk: Uint8Array) => sink.onStdout(chunk) },
|
|
116
|
+
stderr: { write: (chunk: Uint8Array) => sink.onStderr(chunk) },
|
|
117
|
+
});
|
|
118
|
+
return code;
|
|
119
|
+
}
|