ompchamber 1.1.0 → 1.2.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/static/css/index.b938f4a693.css +2 -0
- package/dist/client/static/js/77.8022a800b5.js +139 -0
- package/dist/client/static/js/async/{1k.5974df3b87.js → 1k.a411fe5b43.js} +2 -2
- package/dist/client/static/js/async/{3y.71f2ccf3b6.js → 3y.3186ecdd63.js} +5 -5
- package/dist/client/static/js/async/5b.d88304bc13.js +1 -0
- package/dist/client/static/js/async/66.86544ad741.js +93 -0
- package/dist/client/static/js/async/7g.db82aca7ba.js +1 -0
- package/dist/client/static/js/async/7l.fadbbc0583.js +1 -0
- package/dist/client/static/js/async/{dc.856fda6560.js → ar.ad5c8822a8.js} +2 -2
- package/dist/client/static/js/async/cx.9c8268d542.js +226 -0
- package/dist/client/static/js/async/e.6443e739a2.js +79 -0
- package/dist/client/static/js/async/f7.46971e9d6d.js +2 -0
- package/dist/client/static/js/async/g.ee41bfed7f.js +2 -0
- package/dist/client/static/js/async/h1.d7091cef78.js +1 -0
- package/dist/client/static/js/async/hs.e7c59d8090.js +41 -0
- package/dist/client/static/js/async/jl.4911fa3383.js +1 -0
- package/dist/client/static/js/async/{k.7466330d1a.js → k.d16cd09ae7.js} +2 -2
- package/dist/client/static/js/async/{m.c038c71b9f.js → m.2e1ea3db9e.js} +2 -2
- package/dist/client/static/js/async/m3.d2b05f9604.js +2 -0
- package/dist/client/static/js/async/mt.853be2b5aa.js +1 -0
- package/dist/client/static/js/async/{oc.9c060c9779.js → oc.eb22093f05.js} +1 -1
- package/dist/client/static/js/async/{og.634fba99ac.js → og.53f068804c.js} +1 -1
- package/dist/client/static/js/async/{5b.a5ccbc19dc.js → q.b453193538.js} +10 -10
- package/dist/client/static/js/async/q5.88b50ffa95.js +286 -0
- package/dist/client/static/js/async/q8.7f1b48e9cd.js +21 -0
- package/dist/client/static/js/async/{sy.d16be843a7.js → s.aad2ecebc6.js} +1 -1
- package/dist/client/static/js/async/{tr.e878658140.js → tr.2a0d2b7600.js} +1 -1
- package/dist/client/static/js/async/vt.20406a5604.js +1 -0
- package/dist/client/static/js/async/x1.fe8af17016.js +90 -0
- package/dist/client/static/js/async/yg.5a94e1fc8a.js +149 -0
- package/dist/client/static/js/async/yx.af2936c368.js +10 -0
- package/dist/client/static/js/index.57b39ffa7f.js +34 -0
- package/package.json +1 -1
- package/src/client/components/common/file-icon/index.tsx +4 -1
- package/src/client/components/common/image-viewer/index.tsx +224 -0
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +57 -43
- package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +31 -24
- package/src/client/components/settings/Modal.tsx +1 -0
- package/src/client/components/settings/categories/ChatSettings.tsx +6 -2
- package/src/client/components/settings/categories/chat-settings/TitleSection.tsx +99 -0
- package/src/client/components/workspace/btw-panel/AskCard.tsx +155 -0
- package/src/client/components/workspace/btw-panel/Composer.tsx +181 -0
- package/src/client/components/workspace/btw-panel/Messages.tsx +100 -0
- package/src/client/components/workspace/btw-panel/TopicMenu.tsx +106 -0
- package/src/client/components/workspace/btw-panel/attachments.ts +80 -0
- package/src/client/components/workspace/btw-panel/index.tsx +83 -0
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +37 -21
- package/src/client/components/workspace/chat-timeline/MinimapShortcuts.tsx +49 -12
- package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +10 -6
- package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +2 -1
- package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +17 -0
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +44 -40
- package/src/client/components/workspace/chat-timeline/index.tsx +11 -9
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +25 -7
- package/src/client/components/workspace/editor/Toolbar.tsx +32 -23
- package/src/client/components/workspace/editor/index.tsx +45 -36
- package/src/client/hooks/chat/btw/index.ts +180 -0
- package/src/client/hooks/chat/btw/intercept.ts +43 -0
- package/src/client/hooks/chat/btw/mode.ts +179 -0
- package/src/client/hooks/chat/composer/attachments.ts +198 -0
- package/src/client/hooks/chat/composer/drop-entries.ts +204 -0
- package/src/client/hooks/chat/composer/drop-payload.test.ts +97 -0
- package/src/client/hooks/chat/composer/drop-payload.ts +130 -0
- package/src/client/hooks/chat/composer/drop-references.ts +106 -0
- package/src/client/hooks/chat/composer/file-drop.test.ts +194 -0
- package/src/client/hooks/chat/composer/file-drop.ts +226 -0
- package/src/client/hooks/chat/composer/file-reads.test.ts +211 -0
- package/src/client/hooks/chat/composer/file-reads.ts +238 -0
- package/src/client/hooks/chat/composer/index.ts +7 -1
- package/src/client/hooks/chat/composer/pipeline.ts +181 -0
- package/src/client/hooks/chat/omp/index.ts +3 -3
- package/src/client/hooks/chat/omp/prompt-send.ts +5 -5
- package/src/client/hooks/chat/timeline/actions.ts +16 -1
- package/src/client/hooks/chat/timeline/index.ts +16 -8
- package/src/client/hooks/chat/timeline/prompt-text.ts +6 -1
- package/src/client/hooks/chat/timeline/send.ts +29 -65
- package/src/client/hooks/chat/timeline/session-load.ts +2 -1
- package/src/client/hooks/chat/timeline/session-pagination.ts +161 -14
- package/src/client/hooks/chat/timeline/user-turns.ts +149 -0
- package/src/client/hooks/editor/file-actions.ts +49 -0
- package/src/client/hooks/editor/use-file-editor.ts +38 -27
- package/src/client/hooks/editor/use-file-transfer.ts +78 -0
- package/src/client/tailwind.css +22 -0
- package/src/server/db.server.ts +21 -287
- package/src/server/lib/btw/frames.test.ts +68 -0
- package/src/server/lib/btw/frames.ts +56 -0
- package/src/server/lib/btw/prompt.ts +28 -0
- package/src/server/lib/btw/registry.server.ts +141 -0
- package/src/server/lib/btw/runtime.server.ts +324 -0
- package/src/server/lib/btw/schema.server.ts +44 -0
- package/src/server/lib/btw/service.server.ts +171 -0
- package/src/server/lib/btw/session-copy.server.ts +213 -0
- package/src/server/lib/btw/session-copy.test.ts +166 -0
- package/src/server/lib/btw/store.server.ts +240 -0
- package/src/server/lib/db/schema.ts +110 -0
- package/src/server/lib/db/seed.ts +160 -0
- package/src/server/lib/db/workspace-sync.ts +70 -0
- package/src/server/lib/fs/root.ts +37 -0
- package/src/server/lib/omp/rpc/frame-fold.ts +207 -0
- package/src/server/lib/omp/rpc/manager.ts +27 -123
- package/src/server/lib/omp/session/auto-title.server.test.ts +137 -0
- package/src/server/lib/omp/session/auto-title.server.ts +152 -0
- package/src/server/lib/omp/session/blobs.server.test.ts +100 -0
- package/src/server/lib/omp/session/blobs.server.ts +76 -0
- package/src/server/lib/omp/session/file-scan.ts +174 -0
- package/src/server/lib/omp/session/files.test.ts +147 -0
- package/src/server/lib/omp/session/files.ts +52 -35
- package/src/server/lib/omp/session/locator.ts +2 -2
- package/src/server/lib/omp/session/merge-stored.test.ts +118 -0
- package/src/server/lib/omp/session/merge-stored.ts +171 -0
- package/src/server/lib/omp/session/messages.ts +34 -0
- package/src/server/lib/omp/session/reader.ts +2 -2
- package/src/server/lib/omp/session/subagent-presence.ts +9 -6
- package/src/server/lib/queue/delivery.server.test.ts +57 -0
- package/src/server/lib/queue/delivery.server.ts +25 -3
- package/src/server/plugins/ssr.ts +29 -2
- package/src/server/routes/btw/events.ts +70 -0
- package/src/server/routes/btw/index.ts +8 -0
- package/src/server/routes/btw/topics.ts +87 -0
- package/src/server/routes/btw/ws.ts +136 -0
- package/src/server/routes/chat/index.ts +2 -0
- package/src/server/routes/chat/session.ts +1 -115
- package/src/server/routes/chat/turns.ts +87 -0
- package/src/server/routes/fs/index.ts +4 -1
- package/src/server/routes/fs/raw.ts +85 -0
- package/src/server/routes/fs/read.ts +58 -3
- package/src/server/routes/index.ts +7 -1
- package/src/shared/lib/chat/attachments.test.ts +208 -0
- package/src/shared/lib/chat/attachments.ts +200 -15
- package/src/shared/lib/chat/btw/client.ts +94 -0
- package/src/shared/lib/chat/composer/client.test.ts +48 -0
- package/src/shared/lib/chat/composer/client.ts +6 -1
- package/src/shared/lib/chat/composer/composer.test.ts +39 -1
- package/src/shared/lib/chat/composer/trigger.ts +45 -1
- package/src/shared/lib/chat/omp/agent-events.ts +33 -80
- package/src/shared/lib/chat/omp/socket.ts +11 -6
- package/src/shared/lib/chat/omp/sse.ts +11 -6
- package/src/shared/lib/chat/omp/tool-results.ts +102 -0
- package/src/shared/lib/chat/omp/transport.ts +19 -3
- package/src/shared/lib/chat/text-file.ts +148 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -32
- package/src/shared/lib/chat/timeline/stream-coalescer.ts +66 -0
- package/src/shared/lib/chat/timeline/turns.ts +7 -0
- package/src/shared/lib/fs/file-kind.ts +50 -0
- package/src/shared/lib/fs/paths.ts +19 -1
- package/src/shared/lib/markdown/math.test.ts +20 -3
- package/src/shared/lib/omp/session/inlined-attachments.test.ts +82 -0
- package/src/shared/lib/omp/session/mapper.ts +24 -4
- package/src/shared/lib/omp/session/messages-map.ts +16 -3
- package/src/shared/lib/omp/session/messages-parse.ts +2 -2
- package/src/shared/lib/omp/session/parse-message-blocks.ts +85 -5
- package/src/shared/types/btw.ts +65 -0
- package/src/shared/types/chat.ts +39 -1
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/omp/agent.ts +14 -1
- package/src/shared/types/omp/session.ts +4 -1
- package/src/shared/types/settings/state.ts +5 -0
- package/dist/client/static/css/index.b859cc1cc5.css +0 -2
- package/dist/client/static/js/async/5bh.8b2363424f.js +0 -1
- package/dist/client/static/js/async/66.5dfa29f957.js +0 -93
- package/dist/client/static/js/async/7g.a7814350c3.js +0 -1
- package/dist/client/static/js/async/7l.7a6a377ecc.js +0 -1
- package/dist/client/static/js/async/cx.ca5feec570.js +0 -226
- package/dist/client/static/js/async/e.93214d622b.js +0 -79
- package/dist/client/static/js/async/g.a06ca3b3e9.js +0 -2
- package/dist/client/static/js/async/h1.e8d5aa8e80.js +0 -1
- package/dist/client/static/js/async/hs.6d480d0ded.js +0 -41
- package/dist/client/static/js/async/jl.874d352278.js +0 -1
- package/dist/client/static/js/async/mt.0352e976d8.js +0 -1
- package/dist/client/static/js/async/nam.b5663e919f.js +0 -10
- package/dist/client/static/js/async/q.23af2a358f.js +0 -21
- package/dist/client/static/js/async/q5.e683f7c26e.js +0 -286
- package/dist/client/static/js/async/s.222ee974f6.js +0 -2
- package/dist/client/static/js/async/uf.46ad6209fa.js +0 -2
- package/dist/client/static/js/async/vt.42416b707d.js +0 -1
- package/dist/client/static/js/async/x1.da8687159b.js +0 -90
- package/dist/client/static/js/async/yg.35bebd6d0c.js +0 -149
- package/dist/client/static/js/index.6e065017eb.js +0 -33
- package/dist/client/static/js/jp.50817896f3.js +0 -139
- /package/dist/client/static/js/{jp.50817896f3.js.LICENSE.txt → 77.8022a800b5.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{1k.5974df3b87.js.LICENSE.txt → 1k.a411fe5b43.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{3y.71f2ccf3b6.js.LICENSE.txt → 3y.3186ecdd63.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{dc.856fda6560.js.LICENSE.txt → ar.ad5c8822a8.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{m.c038c71b9f.js.LICENSE.txt → f7.46971e9d6d.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{5b.a5ccbc19dc.js.LICENSE.txt → g.ee41bfed7f.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{q.23af2a358f.js.LICENSE.txt → m.2e1ea3db9e.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{s.222ee974f6.js.LICENSE.txt → m3.d2b05f9604.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.a06ca3b3e9.js.LICENSE.txt → q.b453193538.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{uf.46ad6209fa.js.LICENSE.txt → q8.7f1b48e9cd.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{nam.b5663e919f.js.LICENSE.txt → yx.af2936c368.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{index.6e065017eb.js.LICENSE.txt → index.57b39ffa7f.js.LICENSE.txt} +0 -0
package/src/server/db.server.ts
CHANGED
|
@@ -1,27 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Database lifecycle for OMPChamber: resolve the SQLite path, open the
|
|
8
|
+
* `bun:sqlite` client once, bootstrap the schema, and seed demo data in mock
|
|
9
|
+
* mode. The DDL lives in `./db/schema`, the mock presets in `./db/seed` and
|
|
10
|
+
* the workspace discovery sync in `./db/workspace-sync`.
|
|
11
|
+
*/
|
|
12
|
+
|
|
1
13
|
import path from 'path';
|
|
2
14
|
import os from 'os';
|
|
3
15
|
import fs from 'fs';
|
|
4
16
|
import { createDb, type DbClient } from '@/server/lib/db/client';
|
|
17
|
+
import { initSchema } from '@/server/lib/db/schema';
|
|
18
|
+
import { seedMockData } from '@/server/lib/db/seed';
|
|
19
|
+
import { syncWorkspaceFoldersFromDiscovery } from '@/server/lib/db/workspace-sync';
|
|
5
20
|
import { isMockMode } from '@/server/mock.server';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import { SAMPLE_DEVICES_SESSION_ID, getSampleDevicesSession } from '@/client/data/samples/virtual-devices-session';
|
|
9
|
-
import { loadOmpSidebarData } from '@/server/lib/omp/session/reader';
|
|
10
|
-
import { pathExists, projectPathKey } from '@/server/lib/omp/core/paths';
|
|
21
|
+
import { pathExists } from '@/server/lib/omp/core/paths';
|
|
22
|
+
import { migrateLegacyProjectSettings } from '@/shared/lib/workspace/project-settings-migration';
|
|
11
23
|
|
|
12
24
|
let dbPromise: Promise<DbClient> | null = null;
|
|
13
25
|
/** Resolved handle cached by getDb(); promise callbacks never run sync, so
|
|
14
26
|
* hot paths unwrap through `getDbSync` instead of awaiting. */
|
|
15
27
|
let dbResolved: DbClient | null = null;
|
|
16
28
|
|
|
17
|
-
/** SYNC_WORKSPACE env flag (default true when unset). When enabled in real
|
|
18
|
-
* mode, workspace folders are auto-created from discovered omp projects. */
|
|
19
|
-
export function isWorkspaceSyncEnabled(): boolean {
|
|
20
|
-
const raw = (Bun.env.SYNC_WORKSPACE || '').trim().toLowerCase();
|
|
21
|
-
if (raw === '') return true;
|
|
22
|
-
return raw !== 'false' && raw !== '0' && raw !== 'off' && raw !== 'no';
|
|
23
|
-
}
|
|
24
|
-
|
|
25
29
|
export async function getDatabasePath(): Promise<string> {
|
|
26
30
|
if (isMockMode()) {
|
|
27
31
|
return path.join(process.cwd(), 'workspace.db');
|
|
@@ -53,250 +57,19 @@ export async function getDb(): Promise<DbClient> {
|
|
|
53
57
|
const dbPath = await getDatabasePath();
|
|
54
58
|
const db = createDb(dbPath);
|
|
55
59
|
|
|
56
|
-
await db
|
|
57
|
-
CREATE TABLE IF NOT EXISTS workspace_folders (
|
|
58
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
59
|
-
name TEXT NOT NULL,
|
|
60
|
-
is_expanded BOOLEAN DEFAULT 0,
|
|
61
|
-
project_path TEXT,
|
|
62
|
-
model TEXT DEFAULT 'Not selected',
|
|
63
|
-
accent_color TEXT DEFAULT '',
|
|
64
|
-
icon TEXT DEFAULT 'default',
|
|
65
|
-
custom_icon_url TEXT,
|
|
66
|
-
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
67
|
-
);
|
|
68
|
-
|
|
69
|
-
CREATE TABLE IF NOT EXISTS sessions (
|
|
70
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
71
|
-
folder_id INTEGER NOT NULL,
|
|
72
|
-
title TEXT NOT NULL,
|
|
73
|
-
is_active BOOLEAN DEFAULT 0,
|
|
74
|
-
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
|
75
|
-
FOREIGN KEY (folder_id) REFERENCES workspace_folders (id)
|
|
76
|
-
);
|
|
77
|
-
|
|
78
|
-
CREATE TABLE IF NOT EXISTS app_settings (
|
|
79
|
-
key TEXT PRIMARY KEY,
|
|
80
|
-
value TEXT NOT NULL
|
|
81
|
-
);
|
|
82
|
-
|
|
83
|
-
CREATE TABLE IF NOT EXISTS chat_sessions (
|
|
84
|
-
session_id TEXT PRIMARY KEY,
|
|
85
|
-
title TEXT,
|
|
86
|
-
messages TEXT NOT NULL,
|
|
87
|
-
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
88
|
-
);
|
|
89
|
-
|
|
90
|
-
CREATE TABLE IF NOT EXISTS deleted_workspaces (
|
|
91
|
-
project_path TEXT PRIMARY KEY,
|
|
92
|
-
deleted_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
93
|
-
);
|
|
94
|
-
|
|
95
|
-
CREATE TABLE IF NOT EXISTS archived_sessions (
|
|
96
|
-
session_id TEXT PRIMARY KEY,
|
|
97
|
-
archived_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
98
|
-
);
|
|
99
|
-
|
|
100
|
-
CREATE TABLE IF NOT EXISTS session_ui_state (
|
|
101
|
-
session_id TEXT PRIMARY KEY,
|
|
102
|
-
state TEXT NOT NULL DEFAULT '{}',
|
|
103
|
-
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
104
|
-
);
|
|
105
|
-
|
|
106
|
-
CREATE TABLE IF NOT EXISTS queued_messages (
|
|
107
|
-
id TEXT PRIMARY KEY,
|
|
108
|
-
session_id TEXT NOT NULL,
|
|
109
|
-
position INTEGER NOT NULL,
|
|
110
|
-
message TEXT NOT NULL DEFAULT '',
|
|
111
|
-
attachments TEXT NOT NULL DEFAULT '[]',
|
|
112
|
-
provider TEXT,
|
|
113
|
-
model_id TEXT,
|
|
114
|
-
thinking_level TEXT,
|
|
115
|
-
access_mode TEXT,
|
|
116
|
-
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
117
|
-
);
|
|
118
|
-
CREATE INDEX IF NOT EXISTS idx_queued_messages_session ON queued_messages (session_id, position);
|
|
119
|
-
|
|
120
|
-
CREATE TABLE IF NOT EXISTS session_stream_state (
|
|
121
|
-
session_id TEXT PRIMARY KEY,
|
|
122
|
-
status TEXT NOT NULL,
|
|
123
|
-
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
|
124
|
-
);
|
|
125
|
-
`);
|
|
126
|
-
|
|
127
|
-
const { migrateWorkspaceFolderColumns } = await import('@/shared/lib/workspace/schema-migrations');
|
|
128
|
-
await migrateWorkspaceFolderColumns(db);
|
|
129
|
-
// Installs older `queued_messages` schemas carry an invalid FK (see the
|
|
130
|
-
// migration module); rebuild once so real-session queue inserts work.
|
|
131
|
-
const { migrateQueueTableFk } = await import('@/server/lib/queue/schema-migration.server');
|
|
132
|
-
await migrateQueueTableFk(db);
|
|
133
|
-
|
|
134
|
-
await db.exec(`
|
|
135
|
-
CREATE TABLE IF NOT EXISTS files (
|
|
136
|
-
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
137
|
-
session_id INTEGER NOT NULL,
|
|
138
|
-
parent_id INTEGER,
|
|
139
|
-
name TEXT NOT NULL,
|
|
140
|
-
type TEXT NOT NULL,
|
|
141
|
-
is_expanded BOOLEAN DEFAULT 0,
|
|
142
|
-
FOREIGN KEY (session_id) REFERENCES sessions (id),
|
|
143
|
-
FOREIGN KEY (parent_id) REFERENCES files (id)
|
|
144
|
-
);
|
|
145
|
-
`);
|
|
60
|
+
await initSchema(db);
|
|
146
61
|
|
|
147
62
|
// Seed data based on MOCK mode
|
|
148
|
-
const folderCount = await db.get('SELECT COUNT(*) as count FROM workspace_folders');
|
|
149
|
-
|
|
150
63
|
if (isMockMode()) {
|
|
151
|
-
// MOCK=true:
|
|
152
|
-
|
|
153
|
-
await db.exec(`
|
|
154
|
-
INSERT OR IGNORE INTO workspace_folders (id, name, is_expanded) VALUES
|
|
155
|
-
(1, 'Chats', 1),
|
|
156
|
-
(2, 'Workspace', 1),
|
|
157
|
-
(3, 'drrealhandler', 1);
|
|
158
|
-
`);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// Ensure ompchamber, Workspace, drrealhandler folders exist
|
|
162
|
-
await db.run("INSERT OR IGNORE INTO workspace_folders (id, name, is_expanded) VALUES (1, 'ompchamber', 1)");
|
|
163
|
-
await db.run("INSERT OR IGNORE INTO workspace_folders (id, name, is_expanded) VALUES (2, 'Workspace', 1)");
|
|
164
|
-
await db.run("INSERT OR IGNORE INTO workspace_folders (id, name, is_expanded) VALUES (3, 'drrealhandler', 1)");
|
|
165
|
-
await db.run("UPDATE workspace_folders SET name = 'ompchamber' WHERE id = 1");
|
|
166
|
-
|
|
167
|
-
// Seed specific sessions matching screenshot
|
|
168
|
-
const existingChats = await db.all("SELECT * FROM sessions WHERE folder_id = 1");
|
|
169
|
-
const title1 = 'Cek stream subagent di ompweb';
|
|
170
|
-
const title2 = 'Fitur Diff Panel dan Git Status Files';
|
|
171
|
-
const title3 = 'Implementasi Saved State UI per Session ID';
|
|
172
|
-
const title4 = 'Review loading indicator, model & intent title';
|
|
173
|
-
const title5 = 'Status fitur streer dan queue';
|
|
174
|
-
|
|
175
|
-
if (existingChats.length === 0) {
|
|
176
|
-
await db.run("INSERT INTO sessions (folder_id, title, is_active) VALUES (1, ?, 0)", [title1]);
|
|
177
|
-
await db.run("INSERT INTO sessions (folder_id, title, is_active) VALUES (1, ?, 0)", [title2]);
|
|
178
|
-
await db.run("INSERT INTO sessions (folder_id, title, is_active) VALUES (1, ?, 1)", [title3]);
|
|
179
|
-
await db.run("INSERT INTO sessions (folder_id, title, is_active) VALUES (1, ?, 0)", [title4]);
|
|
180
|
-
await db.run("INSERT INTO sessions (folder_id, title, is_active) VALUES (1, ?, 0)", [title5]);
|
|
181
|
-
} else {
|
|
182
|
-
await db.run("UPDATE sessions SET title = ? WHERE id = 1", [title1]);
|
|
183
|
-
await db.run("UPDATE sessions SET title = ? WHERE id = 2", [title2]);
|
|
184
|
-
await db.run("UPDATE sessions SET title = ? WHERE id = 3", [title3]);
|
|
185
|
-
await db.run("UPDATE sessions SET title = ? WHERE id = 4", [title4]);
|
|
186
|
-
await db.run("UPDATE sessions SET title = ? WHERE id = 5", [title5]);
|
|
187
|
-
await db.run("UPDATE sessions SET is_active = 0 WHERE folder_id = 1");
|
|
188
|
-
await db.run("UPDATE sessions SET is_active = 1 WHERE id = 3");
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// Pre-seed sample sessions data
|
|
192
|
-
try {
|
|
193
|
-
const s1 = getSampleToolsSession();
|
|
194
|
-
await db.run('INSERT OR REPLACE INTO chat_sessions (session_id, title, messages, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)', ['1', s1.title, JSON.stringify(s1.messages)]);
|
|
195
|
-
await db.run('INSERT OR REPLACE INTO chat_sessions (session_id, title, messages, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)', [SAMPLE_TOOLS_SESSION_ID, s1.title, JSON.stringify(s1.messages)]);
|
|
196
|
-
|
|
197
|
-
const s2 = getSampleDialogueSession();
|
|
198
|
-
await db.run('INSERT OR REPLACE INTO chat_sessions (session_id, title, messages, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)', ['2', s2.title, JSON.stringify(s2.messages)]);
|
|
199
|
-
await db.run('INSERT OR REPLACE INTO chat_sessions (session_id, title, messages, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)', [SAMPLE_DIALOGUE_SESSION_ID, s2.title, JSON.stringify(s2.messages)]);
|
|
200
|
-
|
|
201
|
-
const s3 = getSampleDevicesSession();
|
|
202
|
-
await db.run('INSERT OR REPLACE INTO chat_sessions (session_id, title, messages, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)', ['3', s3.title, JSON.stringify(s3.messages)]);
|
|
203
|
-
await db.run('INSERT OR REPLACE INTO chat_sessions (session_id, title, messages, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)', [SAMPLE_DEVICES_SESSION_ID, s3.title, JSON.stringify(s3.messages)]);
|
|
204
|
-
const s3Row = await db.get("SELECT id FROM sessions WHERE folder_id = 1 AND title = ?", [title3]);
|
|
205
|
-
if (s3Row) {
|
|
206
|
-
await db.run('INSERT OR REPLACE INTO chat_sessions (session_id, title, messages, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)', [String(s3Row.id), s3.title, JSON.stringify(s3.messages)]);
|
|
207
|
-
}
|
|
208
|
-
} catch (e) {
|
|
209
|
-
console.error('Failed to pre-seed sample sessions:', e);
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
const existingWorkspace = await db.all("SELECT * FROM sessions WHERE folder_id = 2");
|
|
213
|
-
if (existingWorkspace.length === 0) {
|
|
214
|
-
const workspaceTitles = [
|
|
215
|
-
'History Commit 2026-09-05 23:00',
|
|
216
|
-
'History Commit 2026-09-04 23:00',
|
|
217
|
-
'History Commit 2026-09-03 23:00',
|
|
218
|
-
'Error SMSC latency pada jns6.5 smppv2',
|
|
219
|
-
'Penyebab provider_submit_status=-1 di jns6.5 Smartfren SMPP',
|
|
220
|
-
'jns6.5 filevalidator error Mask Code di Release',
|
|
221
|
-
'Union query transaksi 202607 dan 202606'
|
|
222
|
-
];
|
|
223
|
-
for (const t of workspaceTitles) {
|
|
224
|
-
await db.run("INSERT INTO sessions (folder_id, title, is_active) VALUES (2, ?, 0)", [t]);
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
const existingDrreal = await db.all("SELECT * FROM sessions WHERE folder_id = 3");
|
|
229
|
-
if (existingDrreal.length === 0) {
|
|
230
|
-
const drrealTitles = [
|
|
231
|
-
'Buat branch feat/add-provider-err-telco-to-dr-smpp',
|
|
232
|
-
'Update wiki v1.5.0 & testing dari add-configurabel',
|
|
233
|
-
'Konfigurasi rute DR SMPP via config lookup',
|
|
234
|
-
'drrealhandler update v1.5.0',
|
|
235
|
-
'Gitlab wiki clone isi kosong'
|
|
236
|
-
];
|
|
237
|
-
for (const t of drrealTitles) {
|
|
238
|
-
await db.run("INSERT INTO sessions (folder_id, title, is_active) VALUES (3, ?, 0)", [t]);
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
// Add random sessions if we don't have enough to demonstrate the UI
|
|
243
|
-
const sessionCount = await db.get('SELECT COUNT(*) as count FROM sessions');
|
|
244
|
-
if (sessionCount.count < 15) {
|
|
245
|
-
const titles = [
|
|
246
|
-
'Fix header styling issue', 'Implement authentication flow', 'Setup CI/CD pipeline',
|
|
247
|
-
'Debug database connection', 'Update README.md docs', 'Refactor UI components'
|
|
248
|
-
];
|
|
249
|
-
|
|
250
|
-
for (let i = 1; i <= 3; i++) {
|
|
251
|
-
const numSessions = 3;
|
|
252
|
-
for (let j = 0; j < numSessions; j++) {
|
|
253
|
-
const title = titles[Math.floor(Math.random() * titles.length)] + ' #' + Math.floor(Math.random() * 1000);
|
|
254
|
-
await db.run('INSERT INTO sessions (folder_id, title, is_active) VALUES (?, ?, 0)', [i, title]);
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
// Seed random files for all sessions if files table is empty
|
|
260
|
-
const fileCount = await db.get('SELECT COUNT(*) as count FROM files');
|
|
261
|
-
if (fileCount.count === 0) {
|
|
262
|
-
const allSessions = await db.all('SELECT id FROM sessions');
|
|
263
|
-
for (const session of allSessions) {
|
|
264
|
-
// Create root folders
|
|
265
|
-
const res = await db.run('INSERT INTO files (session_id, parent_id, name, type, is_expanded) VALUES (?, NULL, ?, ?, ?)', [session.id, 'src', 'folder', 1]);
|
|
266
|
-
const srcId = res.lastID;
|
|
267
|
-
|
|
268
|
-
const res2 = await db.run('INSERT INTO files (session_id, parent_id, name, type, is_expanded) VALUES (?, NULL, ?, ?, ?)', [session.id, 'components', 'folder', 0]);
|
|
269
|
-
const componentsId = res2.lastID;
|
|
270
|
-
|
|
271
|
-
// Create files in src
|
|
272
|
-
await db.run('INSERT INTO files (session_id, parent_id, name, type, is_expanded) VALUES (?, ?, ?, ?, ?)', [session.id, srcId, 'index.tsx', 'file', 0]);
|
|
273
|
-
await db.run('INSERT INTO files (session_id, parent_id, name, type, is_expanded) VALUES (?, ?, ?, ?, ?)', [session.id, srcId, 'App.tsx', 'file', 0]);
|
|
274
|
-
await db.run('INSERT INTO files (session_id, parent_id, name, type, is_expanded) VALUES (?, ?, ?, ?, ?)', [session.id, srcId, 'styles.css', 'file', 0]);
|
|
275
|
-
|
|
276
|
-
// Create files in components
|
|
277
|
-
await db.run('INSERT INTO files (session_id, parent_id, name, type, is_expanded) VALUES (?, ?, ?, ?, ?)', [session.id, componentsId, 'Button.tsx', 'file', 0]);
|
|
278
|
-
await db.run('INSERT INTO files (session_id, parent_id, name, type, is_expanded) VALUES (?, ?, ?, ?, ?)', [session.id, componentsId, 'Header.tsx', 'file', 0]);
|
|
279
|
-
|
|
280
|
-
// Some root files
|
|
281
|
-
await db.run('INSERT INTO files (session_id, parent_id, name, type, is_expanded) VALUES (?, NULL, ?, ?, ?)', [session.id, 'package.json', 'file', 0]);
|
|
282
|
-
await db.run('INSERT INTO files (session_id, parent_id, name, type, is_expanded) VALUES (?, NULL, ?, ?, ?)', [session.id, 'README.md', 'file', 0]);
|
|
283
|
-
}
|
|
284
|
-
}
|
|
64
|
+
// MOCK=true: demo workspace folders, sample chats, and session files
|
|
65
|
+
await seedMockData(db);
|
|
285
66
|
} else {
|
|
286
67
|
// MOCK=false (Real Data Mode): when SYNC_WORKSPACE is enabled (default),
|
|
287
68
|
// auto-create workspace folders from discovered omp projects. User-made
|
|
288
69
|
// folders are never deleted; only additive sync.
|
|
289
|
-
|
|
290
|
-
try {
|
|
291
|
-
await syncWorkspaceFoldersWithOmp(db);
|
|
292
|
-
} catch (err) {
|
|
293
|
-
// Discovery must never block app startup.
|
|
294
|
-
console.error('OMP workspace sync failed:', err);
|
|
295
|
-
}
|
|
296
|
-
}
|
|
70
|
+
await syncWorkspaceFoldersFromDiscovery(db);
|
|
297
71
|
}
|
|
298
72
|
|
|
299
|
-
const { migrateLegacyProjectSettings } = await import('@/shared/lib/workspace/project-settings-migration');
|
|
300
73
|
await migrateLegacyProjectSettings(db);
|
|
301
74
|
|
|
302
75
|
dbResolved = db;
|
|
@@ -316,42 +89,3 @@ export function getDbSync(): DbClient {
|
|
|
316
89
|
if (!dbResolved) throw new Error('Database not initialized — call getDb() first');
|
|
317
90
|
return dbResolved;
|
|
318
91
|
}
|
|
319
|
-
|
|
320
|
-
/**
|
|
321
|
-
* Additive-only folder ↔ omp project sync: creates a workspace folder for
|
|
322
|
-
* every project discovered from ~/.omp/agent (folder name = lowercase
|
|
323
|
-
* basename, bound via project_path). It never modifies or deletes existing
|
|
324
|
-
* user data — no renames, no re-binding of unbound folders — and it skips
|
|
325
|
-
* projects the user has explicitly deleted (tombstoned in
|
|
326
|
-
* `deleted_workspaces`), so a deleted workspace stays deleted.
|
|
327
|
-
*/
|
|
328
|
-
async function syncWorkspaceFoldersWithOmp(db: DbClient): Promise<void> {
|
|
329
|
-
const { orderedOmpProjects, projectDisplayName } = await import('@/shared/lib/omp/session/sidebar');
|
|
330
|
-
|
|
331
|
-
const data = await loadOmpSidebarData();
|
|
332
|
-
const existing = await db.all('SELECT id, name, project_path FROM workspace_folders');
|
|
333
|
-
const tombstoned = await db.all('SELECT project_path FROM deleted_workspaces');
|
|
334
|
-
|
|
335
|
-
// Compare by canonical key, not raw string: tombstones may predate the
|
|
336
|
-
// directory (unresolved symlink spelling) while project.path is realpath'd.
|
|
337
|
-
const byPath = new Set(existing.map((r) => r.project_path).filter(Boolean).map(projectPathKey));
|
|
338
|
-
const usedNames = new Set(existing.map((r) => (r.name as string).toLowerCase()));
|
|
339
|
-
const deletedPaths = new Set(tombstoned.map((r) => projectPathKey(r.project_path as string)));
|
|
340
|
-
|
|
341
|
-
for (const project of orderedOmpProjects(data)) {
|
|
342
|
-
if (deletedPaths.has(projectPathKey(project.path))) continue;
|
|
343
|
-
if (byPath.has(projectPathKey(project.path))) continue;
|
|
344
|
-
|
|
345
|
-
const name = projectDisplayName(project);
|
|
346
|
-
let candidate = name;
|
|
347
|
-
let suffix = 2;
|
|
348
|
-
while (usedNames.has(candidate)) {
|
|
349
|
-
candidate = `${name}-${suffix++}`;
|
|
350
|
-
}
|
|
351
|
-
usedNames.add(candidate);
|
|
352
|
-
await db.run(
|
|
353
|
-
'INSERT INTO workspace_folders (name, is_expanded, project_path) VALUES (?, 1, ?)',
|
|
354
|
-
[candidate, project.path],
|
|
355
|
-
);
|
|
356
|
-
}
|
|
357
|
-
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*
|
|
5
|
+
* `finalStatus` decides what the panel shows under an answer and whether the
|
|
6
|
+
* promote button is offered, so an abnormal stop read as `complete` would both
|
|
7
|
+
* mislabel a failed turn and offer to branch a conversation from it. The
|
|
8
|
+
* stop-reason vocabulary belongs to omp (and to `turnStoppedAbnormally`), so
|
|
9
|
+
* these cases pin the mapping rather than the wire shape.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, test } from 'bun:test';
|
|
13
|
+
|
|
14
|
+
import { finalAnswer, finalStatus, isAnswerableUiMethod } from '@/server/lib/btw/frames';
|
|
15
|
+
|
|
16
|
+
function assistant(over: Record<string, unknown> = {}): Record<string, unknown> {
|
|
17
|
+
return { role: 'assistant', content: [{ type: 'text', text: 'answer' }], ...over };
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('finalStatus', () => {
|
|
21
|
+
test('a clean turn completes', () => {
|
|
22
|
+
expect(finalStatus([assistant()])).toBe('complete');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
test('a user abort is cancelled, not failed', () => {
|
|
26
|
+
expect(finalStatus([assistant({ stopReason: 'aborted' })])).toBe('cancelled');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test('a provider error is a failure, not a completed answer', () => {
|
|
30
|
+
expect(finalStatus([assistant({ stopReason: 'error' })])).toBe('failed');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('an error carried as flat fields is still a failure', () => {
|
|
34
|
+
// omp writes abnormal stops as flat `errorStatus`/`errorMessage` on the
|
|
35
|
+
// message; a bare `stopReason` check would call this one complete.
|
|
36
|
+
expect(finalStatus([assistant({ errorMessage: 'upstream 500', errorStatus: 500 })])).toBe('failed');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('the newest assistant turn decides', () => {
|
|
40
|
+
expect(finalStatus([assistant({ stopReason: 'aborted' }), assistant()])).toBe('complete');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test('a frame with no assistant message completes', () => {
|
|
44
|
+
expect(finalStatus([])).toBe('complete');
|
|
45
|
+
expect(finalStatus(undefined)).toBe('complete');
|
|
46
|
+
});
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
describe('finalAnswer', () => {
|
|
50
|
+
test('joins the assistant text parts of the newest turn', () => {
|
|
51
|
+
const messages = [assistant({ content: [{ type: 'text', text: 'one ' }, { type: 'text', text: 'two' }] })];
|
|
52
|
+
expect(finalAnswer(messages, 'streamed')).toBe('one two');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('falls back to the streamed text when the frame carries no answer', () => {
|
|
56
|
+
expect(finalAnswer([], 'streamed')).toBe('streamed');
|
|
57
|
+
expect(finalAnswer(undefined, 'streamed')).toBe('streamed');
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('isAnswerableUiMethod', () => {
|
|
62
|
+
test('recognizes the dialogs that park a child, and nothing else', () => {
|
|
63
|
+
expect(isAnswerableUiMethod('select')).toBe(true);
|
|
64
|
+
expect(isAnswerableUiMethod('confirm')).toBe(true);
|
|
65
|
+
expect(isAnswerableUiMethod('notify')).toBe(false);
|
|
66
|
+
expect(isAnswerableUiMethod(undefined)).toBe(false);
|
|
67
|
+
});
|
|
68
|
+
});
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Pure readers for the frames a side session emits — kept apart from the
|
|
8
|
+
* runtime so its lifecycle code stays readable, and so these small pieces of
|
|
9
|
+
* omp wire knowledge are testable on their own.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { isRecord } from '@/shared/lib/util/guards';
|
|
13
|
+
import { turnStoppedAbnormally } from '@/shared/lib/omp/session/turn-error';
|
|
14
|
+
import { ANSWERABLE_UI_METHODS } from '@/server/lib/omp/rpc/constants';
|
|
15
|
+
import type { BtwTurnStatus } from '@/shared/types';
|
|
16
|
+
|
|
17
|
+
/** A side session has no UI, so it must release every dialog omp parks on. */
|
|
18
|
+
export function isAnswerableUiMethod(method: unknown): boolean {
|
|
19
|
+
return typeof method === 'string' && ANSWERABLE_UI_METHODS.has(method);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A turn stopped by the user is 'cancelled'; a provider error is a failure —
|
|
23
|
+
* both are abnormal stops, so the shared predicate decides (it also reads the
|
|
24
|
+
* flat `errorStatus`/`errorMessage` fields, which a bare `stopReason` check
|
|
25
|
+
* would miss). Anything else that produced a terminal `agent_end` completed. */
|
|
26
|
+
export function finalStatus(messages: unknown): BtwTurnStatus {
|
|
27
|
+
if (Array.isArray(messages)) {
|
|
28
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
29
|
+
const message = messages[index];
|
|
30
|
+
if (isRecord(message) && message.role === 'assistant') {
|
|
31
|
+
if (!turnStoppedAbnormally(message)) return 'complete';
|
|
32
|
+
return message.stopReason === 'aborted' ? 'cancelled' : 'failed';
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return 'complete';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The answer as the provider finally reported it; the streamed text is only a
|
|
40
|
+
* fallback for a frame that carried no messages. */
|
|
41
|
+
export function finalAnswer(messages: unknown, streamed: string): string {
|
|
42
|
+
if (!Array.isArray(messages)) return streamed;
|
|
43
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
44
|
+
const message = messages[index];
|
|
45
|
+
if (!isRecord(message) || message.role !== 'assistant') continue;
|
|
46
|
+
const content = message.content;
|
|
47
|
+
if (typeof content === 'string') return content;
|
|
48
|
+
if (!Array.isArray(content)) continue;
|
|
49
|
+
let text = '';
|
|
50
|
+
for (const part of content) {
|
|
51
|
+
if (isRecord(part) && part.type === 'text' && typeof part.text === 'string') text += part.text;
|
|
52
|
+
}
|
|
53
|
+
if (text) return text;
|
|
54
|
+
}
|
|
55
|
+
return streamed;
|
|
56
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The side-question prompt. Byte-for-byte the template omp's own `/btw`
|
|
8
|
+
* sends (`packages/coding-agent/src/prompts/system/btw-user.md`): the side
|
|
9
|
+
* answer must stay short, must not reach for tools, and must not open a
|
|
10
|
+
* follow-up dialogue of its own — follow-ups are explicit user turns.
|
|
11
|
+
*
|
|
12
|
+
* The parent session's history is NOT part of this text: the side session
|
|
13
|
+
* resumes the parent's transcript, so the context arrives as real provider
|
|
14
|
+
* messages and keeps the parent's prompt-cache prefix.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export function buildBtwPrompt(question: string): string {
|
|
18
|
+
return [
|
|
19
|
+
'<btw>',
|
|
20
|
+
'Ephemeral side question for current interactive session.',
|
|
21
|
+
'Answer briefly, directly; use conversation context already provided.',
|
|
22
|
+
'NEVER use tools.',
|
|
23
|
+
'NEVER ask follow-up questions.',
|
|
24
|
+
'Question:',
|
|
25
|
+
question,
|
|
26
|
+
'</btw>',
|
|
27
|
+
].join('\n');
|
|
28
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Process-wide BTW registry: the live topic runtimes, the per-session
|
|
8
|
+
* subscriber fan-out, and the single place that assembles a session's BTW
|
|
9
|
+
* state.
|
|
10
|
+
*
|
|
11
|
+
* It hangs off `globalThis` for the same reason the omp session registry does:
|
|
12
|
+
* a `bun --hot` reload re-evaluates modules but keeps `globalThis`, so a
|
|
13
|
+
* surviving side child stays reachable instead of being orphaned.
|
|
14
|
+
*
|
|
15
|
+
* Reading the state is also the repair point: a turn row still marked
|
|
16
|
+
* `running` whose runtime is gone (the server restarted, or the child was
|
|
17
|
+
* killed) is settled as `interrupted` instead of blocking every later
|
|
18
|
+
* question behind a turn that can never finish.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { listBtwTopics, settleRunningBtwTurns } from '@/server/lib/btw/store.server';
|
|
22
|
+
import { BtwRuntime, type BtwRuntimeContext } from '@/server/lib/btw/runtime.server';
|
|
23
|
+
import type { BtwFrame, BtwState, BtwTopic } from '@/shared/types';
|
|
24
|
+
|
|
25
|
+
interface BtwRegistryHost {
|
|
26
|
+
runtimes: Map<string, BtwRuntime>;
|
|
27
|
+
listeners: Map<string, Set<(frame: BtwFrame) => void>>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
declare global {
|
|
31
|
+
// eslint-disable-next-line no-var
|
|
32
|
+
var __ompBtwRegistry: BtwRegistryHost | undefined;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function registry(): BtwRegistryHost {
|
|
36
|
+
globalThis.__ompBtwRegistry ??= { runtimes: new Map(), listeners: new Map() };
|
|
37
|
+
return globalThis.__ompBtwRegistry;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getBtwRuntime(topicId: string): BtwRuntime | undefined {
|
|
41
|
+
return registry().runtimes.get(topicId);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Every runtime belonging to a session (at most a handful of topics). */
|
|
45
|
+
function sessionBtwRuntimes(sessionId: string): BtwRuntime[] {
|
|
46
|
+
const found: BtwRuntime[] = [];
|
|
47
|
+
for (const runtime of registry().runtimes.values()) {
|
|
48
|
+
if (runtime.sessionId === sessionId) found.push(runtime);
|
|
49
|
+
}
|
|
50
|
+
return found;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The runtime currently carrying a turn for this session, if any. */
|
|
54
|
+
export function findRunningBtwRuntime(sessionId: string): BtwRuntime | undefined {
|
|
55
|
+
return sessionBtwRuntimes(sessionId).find((runtime) => runtime.running);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Get (or create) the runtime for a topic, wiring its output to the session's
|
|
59
|
+
* subscribers. */
|
|
60
|
+
export function ensureBtwRuntime(context: BtwRuntimeContext): BtwRuntime {
|
|
61
|
+
const host = registry();
|
|
62
|
+
const existing = host.runtimes.get(context.topicId);
|
|
63
|
+
if (existing) return existing;
|
|
64
|
+
const runtime = new BtwRuntime(context, {
|
|
65
|
+
publish: (frame) => publishBtw(context.sessionId, frame),
|
|
66
|
+
stateChanged: () => {
|
|
67
|
+
void publishBtwState(context.sessionId);
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
host.runtimes.set(context.topicId, runtime);
|
|
71
|
+
return runtime;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function forgetBtwRuntime(topicId: string): void {
|
|
75
|
+
registry().runtimes.delete(topicId);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Fan out one frame to everyone watching this session's btw stream. */
|
|
79
|
+
export function publishBtw(sessionId: string, frame: BtwFrame): void {
|
|
80
|
+
const listeners = registry().listeners.get(sessionId);
|
|
81
|
+
if (!listeners) return;
|
|
82
|
+
for (const listener of listeners) {
|
|
83
|
+
try {
|
|
84
|
+
listener(frame);
|
|
85
|
+
} catch {
|
|
86
|
+
// A throwing subscriber (closed socket, encode failure) must not starve
|
|
87
|
+
// the remaining ones.
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function subscribeBtw(sessionId: string, listener: (frame: BtwFrame) => void): () => void {
|
|
93
|
+
const host = registry();
|
|
94
|
+
let listeners = host.listeners.get(sessionId);
|
|
95
|
+
if (!listeners) {
|
|
96
|
+
listeners = new Set();
|
|
97
|
+
host.listeners.set(sessionId, listeners);
|
|
98
|
+
}
|
|
99
|
+
listeners.add(listener);
|
|
100
|
+
return () => {
|
|
101
|
+
const current = host.listeners.get(sessionId);
|
|
102
|
+
if (!current) return;
|
|
103
|
+
current.delete(listener);
|
|
104
|
+
if (current.size === 0) host.listeners.delete(sessionId);
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** The session's BTW state, with stale `running` rows repaired. */
|
|
109
|
+
export async function btwStateFor(sessionId: string): Promise<BtwState> {
|
|
110
|
+
const topics = await listBtwTopics(sessionId);
|
|
111
|
+
// A runtime that is mid-`settle()` has already cleared its turn index but has
|
|
112
|
+
// not written the row yet; counting it as gone would race its own UPDATE and
|
|
113
|
+
// rewrite a completed turn as `interrupted`. `busy` covers that window.
|
|
114
|
+
const liveTopicIds = new Set(
|
|
115
|
+
sessionBtwRuntimes(sessionId)
|
|
116
|
+
.filter((runtime) => runtime.running || runtime.busy)
|
|
117
|
+
.map((runtime) => runtime.topicId),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const stale = topics.filter((topic) => topic.turns.some((turn) => turn.status === 'running') && !liveTopicIds.has(topic.id));
|
|
121
|
+
if (stale.length > 0) {
|
|
122
|
+
await Promise.all(stale.map((topic) => settleRunningBtwTurns(topic.id, 'interrupted')));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const repaired: BtwTopic[] = topics.map((topic) =>
|
|
126
|
+
liveTopicIds.has(topic.id)
|
|
127
|
+
? topic
|
|
128
|
+
: { ...topic, turns: topic.turns.map((turn) => (turn.status === 'running' ? { ...turn, status: 'interrupted' as const } : turn)) },
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
topics: repaired,
|
|
133
|
+
runningTopicId: repaired.find((topic) => topic.turns.some((turn) => turn.status === 'running'))?.id ?? null,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Republish the whole state to the session's subscribers. */
|
|
138
|
+
export async function publishBtwState(sessionId: string): Promise<void> {
|
|
139
|
+
const state = await btwStateFor(sessionId);
|
|
140
|
+
publishBtw(sessionId, { type: 'btw_state', state });
|
|
141
|
+
}
|