@polderlabs/bizar 5.0.2 → 5.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/bizar-dash/dist/assets/MobileChat-TCputYzr.js +1 -0
- package/bizar-dash/dist/assets/MobileChat-TCputYzr.js.map +1 -0
- package/bizar-dash/dist/assets/MobileSettings-BxcbL5XT.js +1 -0
- package/bizar-dash/dist/assets/MobileSettings-BxcbL5XT.js.map +1 -0
- package/bizar-dash/dist/assets/{icons-Bo0iH9EC.js → icons-DRDXfbBP.js} +151 -136
- package/bizar-dash/dist/assets/icons-DRDXfbBP.js.map +1 -0
- package/bizar-dash/dist/assets/main-D5Ditnrd.js +19 -0
- package/bizar-dash/dist/assets/main-D5Ditnrd.js.map +1 -0
- package/bizar-dash/dist/assets/{main-DTkNlLrw.css → main-xFpWMd32.css} +1 -1
- package/bizar-dash/dist/assets/markdown-C6mXtQxD.js +1 -0
- package/bizar-dash/dist/assets/markdown-C6mXtQxD.js.map +1 -0
- package/bizar-dash/dist/assets/mobile--17fkfrl.js +1 -0
- package/bizar-dash/dist/assets/{mobile-DD-FZrTC.js.map → mobile--17fkfrl.js.map} +1 -1
- package/bizar-dash/dist/assets/mobile-j3rOZK6v.js +2 -0
- package/bizar-dash/dist/assets/mobile-j3rOZK6v.js.map +1 -0
- package/bizar-dash/dist/assets/{react-vendor-DZRUXSPQ.js → react-vendor-Dn4wqh4Z.js} +8 -8
- package/bizar-dash/dist/assets/react-vendor-Dn4wqh4Z.js.map +1 -0
- package/bizar-dash/dist/assets/useSlashCommands-BG-DhEck.js +2 -0
- package/bizar-dash/dist/assets/useSlashCommands-BG-DhEck.js.map +1 -0
- package/bizar-dash/dist/assets/vendor-CeHGtduv.js +29 -0
- package/bizar-dash/dist/assets/vendor-CeHGtduv.js.map +1 -0
- package/bizar-dash/dist/index.html +8 -6
- package/bizar-dash/dist/mobile.html +4 -5
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/src/server/api.mjs +4 -0
- package/bizar-dash/src/server/eval.mjs +93 -3
- package/bizar-dash/src/server/metrics.mjs +75 -0
- package/bizar-dash/src/server/otel.mjs +141 -14
- package/bizar-dash/src/server/plugins/registry.mjs +145 -43
- package/bizar-dash/src/server/routes/chat.mjs +46 -9
- package/bizar-dash/src/server/routes/opencode-session-detail.mjs +120 -17
- package/bizar-dash/src/server/routes/plugins.mjs +131 -16
- package/bizar-dash/src/server/routes/tailscale.mjs +46 -0
- package/bizar-dash/src/server/routes/voice.mjs +42 -6
- package/bizar-dash/src/server/routes/workspaces.mjs +156 -61
- package/bizar-dash/src/server/server.mjs +57 -15
- package/bizar-dash/src/server/voice-store.mjs +27 -0
- package/bizar-dash/src/server/workers/transcription-worker.mjs +213 -0
- package/bizar-dash/src/web/App.tsx +9 -0
- package/bizar-dash/src/web/MobileApp.tsx +18 -10
- package/bizar-dash/src/web/components/EvalDiff.tsx +157 -0
- package/bizar-dash/src/web/components/EvalRunCard.tsx +78 -0
- package/bizar-dash/src/web/components/PluginCard.tsx +64 -0
- package/bizar-dash/src/web/components/PluginPermissions.tsx +36 -0
- package/bizar-dash/src/web/components/TailscaleSettings.tsx +161 -0
- package/bizar-dash/src/web/components/Toggle.tsx +31 -0
- package/bizar-dash/src/web/components/Topbar.tsx +6 -0
- package/bizar-dash/src/web/mobile/MobileChat.tsx +138 -0
- package/bizar-dash/src/web/mobile/MobileSettings.tsx +196 -0
- package/bizar-dash/src/web/styles/main.css +545 -0
- package/bizar-dash/src/web/views/Eval.tsx +172 -0
- package/bizar-dash/src/web/views/EvalReport.tsx +349 -0
- package/bizar-dash/src/web/views/Plugins.tsx +128 -0
- package/bizar-dash/src/web/views/Settings.tsx +2 -0
- package/bizar-dash/tests/bundle-analysis.test.mjs +5 -4
- package/bizar-dash/tests/cli-tailscale.test.mjs +113 -0
- package/bizar-dash/tests/components/plugin-permissions.test.tsx +95 -0
- package/bizar-dash/tests/eval/fixtures-extra.test.mjs +285 -0
- package/bizar-dash/tests/eval-web-ui.test.tsx +220 -0
- package/bizar-dash/tests/otel-spans.test.mjs +253 -0
- package/bizar-dash/tests/plugins-registry-fallback.test.mjs +259 -0
- package/bizar-dash/tests/voice-transcribe-worker.test.mjs +343 -0
- package/cli/bin.mjs +14 -0
- package/cli/commands/dash.mjs +19 -1
- package/cli/commands/service.mjs +7 -0
- package/cli/commands/tailscale.mjs +251 -0
- package/package.json +1 -1
- package/templates/eval-fixtures/citation.json +16 -0
- package/templates/eval-fixtures/code-review.json +16 -0
- package/templates/eval-fixtures/concise-output.json +16 -0
- package/templates/eval-fixtures/context-window.json +16 -0
- package/templates/eval-fixtures/error-recovery.json +16 -0
- package/templates/eval-fixtures/json-output.json +20 -0
- package/templates/eval-fixtures/multi-language.json +16 -0
- package/templates/eval-fixtures/safe-paths.json +16 -0
- package/templates/eval-fixtures/tool-call-multi-step.json +18 -0
- package/templates/eval-fixtures/unicode-handling.json +16 -0
- package/bizar-dash/dist/assets/icons-Bo0iH9EC.js.map +0 -1
- package/bizar-dash/dist/assets/index-DmpSFPJY.js +0 -9
- package/bizar-dash/dist/assets/index-DmpSFPJY.js.map +0 -1
- package/bizar-dash/dist/assets/main-C1cpttnv.js +0 -19
- package/bizar-dash/dist/assets/main-C1cpttnv.js.map +0 -1
- package/bizar-dash/dist/assets/markdown-DIquRulQ.js +0 -29
- package/bizar-dash/dist/assets/markdown-DIquRulQ.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-BtxQJftK.js +0 -2
- package/bizar-dash/dist/assets/mobile-BtxQJftK.js.map +0 -1
- package/bizar-dash/dist/assets/mobile-DD-FZrTC.js +0 -1
- package/bizar-dash/dist/assets/react-vendor-DZRUXSPQ.js.map +0 -1
|
@@ -31,7 +31,10 @@
|
|
|
31
31
|
* }
|
|
32
32
|
*/
|
|
33
33
|
import { createHash } from 'node:crypto';
|
|
34
|
-
import { createReadStream } from 'node:fs';
|
|
34
|
+
import { createReadStream, readFileSync, writeFileSync, existsSync, mkdirSync } from 'node:fs';
|
|
35
|
+
import { dirname, join } from 'node:path';
|
|
36
|
+
import { fileURLToPath } from 'node:url';
|
|
37
|
+
import * as logger from '../logger.mjs';
|
|
35
38
|
|
|
36
39
|
/**
|
|
37
40
|
* Default registry URL. Override with `BIZAR_REGISTRY_URL` env var.
|
|
@@ -43,6 +46,68 @@ import { createReadStream } from 'node:fs';
|
|
|
43
46
|
const DEFAULT_REGISTRY_URL =
|
|
44
47
|
'https://raw.githubusercontent.com/DrB0rk/bizar-plugins/main/registry.json';
|
|
45
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Fallback registry URLs tried in order when the primary URL fails.
|
|
51
|
+
* BIZAR_REGISTRY_URL env var takes precedence over all if set.
|
|
52
|
+
*/
|
|
53
|
+
const FALLBACK_REGISTRY_URLS = [
|
|
54
|
+
'https://bizar-plugins.bork.deno.net/registry.json',
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* All registry URLs in priority order: explicit override (if any) first,
|
|
59
|
+
* then BIZAR_REGISTRY_URL, then defaults including fallbacks.
|
|
60
|
+
* @returns {string[]}
|
|
61
|
+
*/
|
|
62
|
+
function getRegistryUrls() {
|
|
63
|
+
const urls = [];
|
|
64
|
+
if (process.env.BIZAR_REGISTRY_URL && process.env.BIZAR_REGISTRY_URL.trim()) {
|
|
65
|
+
urls.push(process.env.BIZAR_REGISTRY_URL.trim());
|
|
66
|
+
}
|
|
67
|
+
urls.push(DEFAULT_REGISTRY_URL, ...FALLBACK_REGISTRY_URLS);
|
|
68
|
+
return urls;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Path to the on-disk registry cache. */
|
|
72
|
+
function getCacheFilePath() {
|
|
73
|
+
const homedir = process.env.HOME || process.env.USERPROFILE || tmpdir();
|
|
74
|
+
const cacheDir = join(homedir, '.cache', 'bizar');
|
|
75
|
+
return join(cacheDir, 'registry.json');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Read the registry from the on-disk cache file.
|
|
80
|
+
* Returns null if the file doesn't exist or can't be parsed.
|
|
81
|
+
* @returns {Promise<RegistryShape | null>}
|
|
82
|
+
*/
|
|
83
|
+
export async function readRegistryCache() {
|
|
84
|
+
try {
|
|
85
|
+
const cachePath = getCacheFilePath();
|
|
86
|
+
if (!existsSync(cachePath)) return null;
|
|
87
|
+
const raw = readFileSync(cachePath, 'utf8');
|
|
88
|
+
return JSON.parse(raw);
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Write the registry to the on-disk cache file.
|
|
96
|
+
* @param {RegistryShape} data
|
|
97
|
+
*/
|
|
98
|
+
export function writeRegistryCache(data) {
|
|
99
|
+
try {
|
|
100
|
+
const cachePath = getCacheFilePath();
|
|
101
|
+
const cacheDir = dirname(cachePath);
|
|
102
|
+
if (!existsSync(cacheDir)) {
|
|
103
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
104
|
+
}
|
|
105
|
+
writeFileSync(cachePath, JSON.stringify(data, null, 2), 'utf8');
|
|
106
|
+
} catch (err) {
|
|
107
|
+
logger.warn('failed to write registry cache', { err: err.message });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
46
111
|
/** How long a cached registry is considered fresh. */
|
|
47
112
|
const CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
48
113
|
|
|
@@ -156,65 +221,102 @@ export function validateRegistry(data) {
|
|
|
156
221
|
/**
|
|
157
222
|
* Fetch the registry, returning the validated parsed shape.
|
|
158
223
|
*
|
|
224
|
+
* When no explicit URL is provided, iterates through all registry URLs
|
|
225
|
+
* (BIZAR_REGISTRY_URL env var > defaults > fallbacks) until one succeeds.
|
|
226
|
+
* On complete failure, falls back to the on-disk cache file.
|
|
227
|
+
*
|
|
159
228
|
* Cache: in-memory for 1 hour per URL. A `force: true` option bypasses
|
|
160
229
|
* the cache (used by the `update` CLI subcommand).
|
|
161
230
|
*
|
|
162
231
|
* @param {object} [opts]
|
|
163
|
-
* @param {string} [opts.url] override the URL (
|
|
232
|
+
* @param {string} [opts.url] override the URL (uses all fallbacks if omitted)
|
|
164
233
|
* @param {boolean} [opts.force] skip the cache
|
|
165
234
|
* @param {typeof globalThis.fetch} [opts.fetch] fetch override (tests)
|
|
166
235
|
* @returns {Promise<RegistryShape>}
|
|
167
236
|
*/
|
|
168
237
|
export async function fetchRegistry({ url, force = false, fetch: fetchImpl } = {}) {
|
|
169
|
-
const resolvedUrl = getRegistryUrl(url);
|
|
170
238
|
const now = Date.now();
|
|
171
|
-
if (
|
|
172
|
-
!force &&
|
|
173
|
-
_cache &&
|
|
174
|
-
_cache.url === resolvedUrl &&
|
|
175
|
-
now - _cache.fetchedAt < CACHE_TTL_MS
|
|
176
|
-
) {
|
|
177
|
-
return _cache.data;
|
|
178
|
-
}
|
|
179
239
|
const fetchFn = fetchImpl || globalThis.fetch;
|
|
180
240
|
if (typeof fetchFn !== 'function') {
|
|
181
241
|
throw new Error('fetch is not available in this runtime');
|
|
182
242
|
}
|
|
183
|
-
|
|
184
|
-
try
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
wrap.code = 'registry_unreachable';
|
|
193
|
-
wrap.cause = err;
|
|
194
|
-
throw wrap;
|
|
243
|
+
|
|
244
|
+
// Determine which URLs to try: explicit url takes priority, otherwise use all
|
|
245
|
+
const urls = url ? [url] : getRegistryUrls();
|
|
246
|
+
|
|
247
|
+
// Check in-memory cache first (only valid for the first URL in the list)
|
|
248
|
+
if (!force && _cache && urls.includes(_cache.url)) {
|
|
249
|
+
if (now - _cache.fetchedAt < CACHE_TTL_MS) {
|
|
250
|
+
return _cache.data;
|
|
251
|
+
}
|
|
195
252
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
253
|
+
|
|
254
|
+
let lastError;
|
|
255
|
+
for (const registryUrl of urls) {
|
|
256
|
+
try {
|
|
257
|
+
const res = await fetchFn(registryUrl, {
|
|
258
|
+
headers: { 'User-Agent': 'bizar-registry-client/5.0' },
|
|
259
|
+
});
|
|
260
|
+
if (!res.ok) {
|
|
261
|
+
logger.warn('registry fetch failed, trying next', {
|
|
262
|
+
url: registryUrl,
|
|
263
|
+
status: res.status,
|
|
264
|
+
statusText: res.statusText,
|
|
265
|
+
});
|
|
266
|
+
lastError = new Error(
|
|
267
|
+
`registry fetch returned ${res.status} ${res.statusText} from ${registryUrl}`,
|
|
268
|
+
);
|
|
269
|
+
lastError.code = 'registry_unreachable';
|
|
270
|
+
lastError.status = res.status;
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
let json;
|
|
274
|
+
try {
|
|
275
|
+
json = await res.json();
|
|
276
|
+
} catch (err) {
|
|
277
|
+
logger.warn('registry fetch failed, trying next', {
|
|
278
|
+
url: registryUrl,
|
|
279
|
+
err: err.message,
|
|
280
|
+
});
|
|
281
|
+
lastError = new Error(
|
|
282
|
+
`registry at ${registryUrl} is not valid JSON: ${err.message}`,
|
|
283
|
+
);
|
|
284
|
+
lastError.code = 'invalid_registry';
|
|
285
|
+
lastError.cause = err;
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
const validated = validateRegistry(json);
|
|
289
|
+
_cache = { fetchedAt: now, url: registryUrl, data: validated };
|
|
290
|
+
writeRegistryCache(validated);
|
|
291
|
+
return validated;
|
|
292
|
+
} catch (err) {
|
|
293
|
+
logger.warn('registry fetch failed, trying next', {
|
|
294
|
+
url: registryUrl,
|
|
295
|
+
err: err.message,
|
|
296
|
+
});
|
|
297
|
+
lastError = err;
|
|
298
|
+
}
|
|
203
299
|
}
|
|
204
|
-
|
|
205
|
-
try
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
)
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
300
|
+
|
|
301
|
+
// All URLs failed — try the on-disk cache as last resort,
|
|
302
|
+
// but only when no explicit URL was provided (disk cache is not
|
|
303
|
+
// meaningful when the caller specified a particular endpoint).
|
|
304
|
+
if (!url) {
|
|
305
|
+
const cached = await readRegistryCache();
|
|
306
|
+
if (cached) {
|
|
307
|
+
logger.warn('all registry URLs failed, using disk cache');
|
|
308
|
+
_cache = { fetchedAt: now, url: urls[0], data: cached };
|
|
309
|
+
return cached;
|
|
310
|
+
}
|
|
214
311
|
}
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
312
|
+
|
|
313
|
+
// Nothing worked — throw the last error with a helpful message
|
|
314
|
+
const wrap = new Error(
|
|
315
|
+
`registry fetch failed for all URLs. Last error: ${lastError?.message}`,
|
|
316
|
+
);
|
|
317
|
+
wrap.code = 'registry_unreachable';
|
|
318
|
+
wrap.cause = lastError;
|
|
319
|
+
throw wrap;
|
|
218
320
|
}
|
|
219
321
|
|
|
220
322
|
/**
|
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
import { Router } from 'express';
|
|
25
25
|
import { SpanStatusCode } from '@opentelemetry/api';
|
|
26
26
|
import { warn as logWarn } from '../logger.mjs';
|
|
27
|
-
import { tracer } from '../otel.mjs';
|
|
27
|
+
import { tracer, withSpan, setCommonAttributes } from '../otel.mjs';
|
|
28
|
+
import { recordTrace } from '../metrics.mjs';
|
|
28
29
|
import {
|
|
29
30
|
existsSync,
|
|
30
31
|
mkdirSync,
|
|
@@ -382,15 +383,19 @@ export function createChatRouter({ state, broadcast }) {
|
|
|
382
383
|
});
|
|
383
384
|
}));
|
|
384
385
|
|
|
385
|
-
router.get('/chat/sessions', wrap(async (_req, res) => {
|
|
386
|
+
router.get('/chat/sessions', wrap(withSpan('chat.sessions.list', async (span, _req, res) => {
|
|
386
387
|
const active = projectsStore.active();
|
|
388
|
+
setCommonAttributes(span, { userAgent: _req.headers?.['user-agent'] });
|
|
389
|
+
span.setAttribute('chat.has_active_project', active ? true : false);
|
|
387
390
|
if (!active) {
|
|
388
391
|
res.json({ sessions: [] });
|
|
392
|
+
recordTrace('chat.sessions.list', { has_active_project: false });
|
|
389
393
|
return;
|
|
390
394
|
}
|
|
391
395
|
const dir = join(projectsStore.ensureProjectDir(active.id), 'sessions');
|
|
392
396
|
if (!existsSync(dir)) {
|
|
393
397
|
res.json({ sessions: [] });
|
|
398
|
+
recordTrace('chat.sessions.list', { has_active_project: true, sessions_dir_exists: false });
|
|
394
399
|
return;
|
|
395
400
|
}
|
|
396
401
|
const sessions = readdirSync(dir)
|
|
@@ -400,13 +405,24 @@ export function createChatRouter({ state, broadcast }) {
|
|
|
400
405
|
return { id: f.replace(/\.jsonl$/, ''), file: f, mtime: st.mtimeMs, size: st.size };
|
|
401
406
|
});
|
|
402
407
|
sessions.sort((a, b) => b.mtime - a.mtime);
|
|
408
|
+
span.setAttribute('chat.session_count', sessions.length);
|
|
403
409
|
res.json({ sessions });
|
|
404
|
-
|
|
410
|
+
recordTrace('chat.sessions.list', {
|
|
411
|
+
has_active_project: true,
|
|
412
|
+
session_count_bucket: sessions.length === 0 ? '0' : sessions.length < 10 ? '1-9' : '10+',
|
|
413
|
+
});
|
|
414
|
+
})));
|
|
405
415
|
|
|
406
|
-
router.post('/chat/sessions', wrap(async (req, res) => {
|
|
416
|
+
router.post('/chat/sessions', wrap(withSpan('chat.session.create', async (span, req, res) => {
|
|
407
417
|
const active = projectsStore.active();
|
|
418
|
+
setCommonAttributes(span, {
|
|
419
|
+
ip: req.ip || req.socket?.remoteAddress,
|
|
420
|
+
userAgent: req.headers?.['user-agent'],
|
|
421
|
+
});
|
|
422
|
+
span.setAttribute('chat.has_active_project', active ? true : false);
|
|
408
423
|
if (!active) {
|
|
409
424
|
res.status(400).json({ error: 'no_active_project', message: 'No active project. Pick one in Overview first.' });
|
|
425
|
+
recordTrace('chat.session.create', { outcome: 'no_active_project' });
|
|
410
426
|
return;
|
|
411
427
|
}
|
|
412
428
|
const requestedId = typeof req.body?.id === 'string' ? req.body.id.trim() : '';
|
|
@@ -419,6 +435,7 @@ export function createChatRouter({ state, broadcast }) {
|
|
|
419
435
|
if (!existsSync(file)) {
|
|
420
436
|
writeFileSync(file, '', 'utf8');
|
|
421
437
|
}
|
|
438
|
+
span.setAttribute('chat.session_id', sessionId);
|
|
422
439
|
state.appendActivity({ kind: 'chat.session.create', id: sessionId });
|
|
423
440
|
broadcast({ type: 'chat:session:create', sessionId });
|
|
424
441
|
res.status(201).json({
|
|
@@ -427,29 +444,39 @@ export function createChatRouter({ state, broadcast }) {
|
|
|
427
444
|
mtime: Date.now(),
|
|
428
445
|
size: 0,
|
|
429
446
|
});
|
|
430
|
-
|
|
447
|
+
recordTrace('chat.session.create', { outcome: 'created' });
|
|
448
|
+
})));
|
|
431
449
|
|
|
432
|
-
router.post('/chat/regenerate', wrap(async (req, res) => {
|
|
450
|
+
router.post('/chat/regenerate', wrap(withSpan('chat.regenerate', async (span, req, res) => {
|
|
433
451
|
const { sessionId, messageId } = req.body || {};
|
|
452
|
+
setCommonAttributes(span, {
|
|
453
|
+
ip: req.ip || req.socket?.remoteAddress,
|
|
454
|
+
userAgent: req.headers?.['user-agent'],
|
|
455
|
+
});
|
|
434
456
|
if (!messageId) {
|
|
435
457
|
res.status(400).json({ error: 'bad_request', message: 'messageId is required' });
|
|
458
|
+
recordTrace('chat.regenerate', { outcome: 'missing_message_id' });
|
|
436
459
|
return;
|
|
437
460
|
}
|
|
438
461
|
const active = projectsStore.active();
|
|
462
|
+
span.setAttribute('chat.has_active_project', active ? true : false);
|
|
439
463
|
if (!active) {
|
|
440
464
|
res.status(400).json({ error: 'no_active_project', message: 'no active project' });
|
|
465
|
+
recordTrace('chat.regenerate', { outcome: 'no_active_project' });
|
|
441
466
|
return;
|
|
442
467
|
}
|
|
443
468
|
const dir = projectsStore.ensureProjectDir(active.id);
|
|
444
469
|
const sessionsDir = join(dir, 'sessions');
|
|
445
470
|
if (!existsSync(sessionsDir)) {
|
|
446
471
|
res.status(404).json({ error: 'not_found', message: 'no sessions found' });
|
|
472
|
+
recordTrace('chat.regenerate', { outcome: 'no_sessions' });
|
|
447
473
|
return;
|
|
448
474
|
}
|
|
449
475
|
const allFiles = readdirSync(sessionsDir).filter((f) => f.endsWith('.jsonl'));
|
|
450
476
|
const targetFiles = sessionId ? allFiles.filter((f) => f === `${sessionId}.jsonl`) : allFiles;
|
|
451
477
|
if (!targetFiles.length) {
|
|
452
478
|
res.status(404).json({ error: 'not_found', message: 'session not found' });
|
|
479
|
+
recordTrace('chat.regenerate', { outcome: 'session_not_found' });
|
|
453
480
|
return;
|
|
454
481
|
}
|
|
455
482
|
const full = join(sessionsDir, targetFiles[0]);
|
|
@@ -481,6 +508,7 @@ export function createChatRouter({ state, broadcast }) {
|
|
|
481
508
|
}
|
|
482
509
|
} catch (err) {
|
|
483
510
|
res.status(500).json({ error: 'read_failed', message: err.message });
|
|
511
|
+
recordTrace('chat.regenerate', { outcome: 'read_failed' });
|
|
484
512
|
return;
|
|
485
513
|
}
|
|
486
514
|
if (!lastUserMessage) {
|
|
@@ -503,6 +531,7 @@ export function createChatRouter({ state, broadcast }) {
|
|
|
503
531
|
}
|
|
504
532
|
if (!lastUserMessage) {
|
|
505
533
|
res.status(404).json({ error: 'not_found', message: 'no user message found to regenerate' });
|
|
534
|
+
recordTrace('chat.regenerate', { outcome: 'no_user_message' });
|
|
506
535
|
return;
|
|
507
536
|
}
|
|
508
537
|
const record = {
|
|
@@ -520,6 +549,8 @@ export function createChatRouter({ state, broadcast }) {
|
|
|
520
549
|
} catch {
|
|
521
550
|
/* best effort */
|
|
522
551
|
}
|
|
552
|
+
span.setAttribute('chat.session_id', sessionId || '');
|
|
553
|
+
span.setAttribute('chat.regenerate_agent', record.agent || '');
|
|
523
554
|
state.appendActivity({
|
|
524
555
|
kind: 'chat.regenerate',
|
|
525
556
|
agent: lastUserMessage.agent || null,
|
|
@@ -527,15 +558,21 @@ export function createChatRouter({ state, broadcast }) {
|
|
|
527
558
|
});
|
|
528
559
|
broadcast({ type: 'chat:regenerate', message: record });
|
|
529
560
|
res.status(202).json({ accepted: true, regeneratedMessage: record });
|
|
530
|
-
|
|
561
|
+
recordTrace('chat.regenerate', { outcome: 'regenerated' });
|
|
562
|
+
})));
|
|
531
563
|
|
|
532
|
-
router.post('/chat/audit', wrap(async (req, res) => {
|
|
564
|
+
router.post('/chat/audit', wrap(withSpan('chat.audit', async (span, req, res) => {
|
|
565
|
+
setCommonAttributes(span, {
|
|
566
|
+
ip: req.ip || req.socket?.remoteAddress,
|
|
567
|
+
userAgent: req.headers?.['user-agent'],
|
|
568
|
+
});
|
|
533
569
|
res.json({
|
|
534
570
|
ok: true,
|
|
535
571
|
note: 'audit dispatched — see command.audit in config/opencode.json.template',
|
|
536
572
|
audit: { status: 'queued' },
|
|
537
573
|
});
|
|
538
|
-
|
|
574
|
+
recordTrace('chat.audit', { outcome: 'queued' });
|
|
575
|
+
})));
|
|
539
576
|
|
|
540
577
|
return router;
|
|
541
578
|
}
|
|
@@ -54,6 +54,8 @@ import {
|
|
|
54
54
|
extractContentFromOpencodeMessage,
|
|
55
55
|
} from '../serve-info.mjs';
|
|
56
56
|
import { child as loggerChild } from '../logger.mjs';
|
|
57
|
+
import { tracer, withSpan, setCommonAttributes } from '../otel.mjs';
|
|
58
|
+
import { recordTrace } from '../metrics.mjs';
|
|
57
59
|
import { wrap } from './_shared.mjs';
|
|
58
60
|
|
|
59
61
|
/** Structured logger scoped to this route — emits `{module:'opencode-session-detail', ...}` on every line. */
|
|
@@ -112,28 +114,40 @@ export function createOpencodeSessionDetailRouter() {
|
|
|
112
114
|
// Returns the message history for a session in the dashboard's
|
|
113
115
|
// ChatMessage shape: { id, role, content, ts }.
|
|
114
116
|
// ---------------------------------------------------------------------
|
|
115
|
-
router.get('/opencode-sessions/:id/messages', wrap(async (req, res) => {
|
|
117
|
+
router.get('/opencode-sessions/:id/messages', wrap(withSpan('opencode.session.messages', async (span, req, res) => {
|
|
116
118
|
const sessionId = String(req.params?.id || '');
|
|
119
|
+
setCommonAttributes(span, {
|
|
120
|
+
ip: req.ip || req.socket?.remoteAddress,
|
|
121
|
+
userAgent: req.headers?.['user-agent'],
|
|
122
|
+
});
|
|
123
|
+
span.setAttribute('opencode.session_id', sessionId);
|
|
117
124
|
if (!sessionId) {
|
|
118
|
-
|
|
125
|
+
res.status(400).json({ error: 'bad_request', message: 'session id is required' });
|
|
126
|
+
recordTrace('opencode.session.messages', { outcome: 'missing_session_id' });
|
|
127
|
+
return;
|
|
119
128
|
}
|
|
120
129
|
const info = readServeInfo();
|
|
121
130
|
if (!info) {
|
|
122
|
-
|
|
131
|
+
res.status(503).json({
|
|
123
132
|
error: 'plugin_offline',
|
|
124
133
|
message: 'opencode plugin is not running',
|
|
125
134
|
suggestion: 'Run `bizar doctor` for diagnostics, or start opencode with `opencode serve`',
|
|
126
135
|
});
|
|
136
|
+
recordTrace('opencode.session.messages', { outcome: 'plugin_offline' });
|
|
137
|
+
return;
|
|
127
138
|
}
|
|
128
139
|
const directory = await resolveSessionDirectory(sessionId, info);
|
|
129
140
|
if (!directory) {
|
|
130
|
-
|
|
141
|
+
res.status(503).json({
|
|
131
142
|
error: 'directory_unknown',
|
|
132
143
|
message:
|
|
133
144
|
'Cannot determine the opencode session directory; ensure the opencode plugin is running with serve.json containing worktree.',
|
|
134
145
|
suggestion: 'Create a new session or restart opencode serve.',
|
|
135
146
|
});
|
|
147
|
+
recordTrace('opencode.session.messages', { outcome: 'directory_unknown' });
|
|
148
|
+
return;
|
|
136
149
|
}
|
|
150
|
+
span.setAttribute('opencode.worktree', directory);
|
|
137
151
|
const result = await listOpencodeMessages(info, sessionId, directory, LIST_MESSAGES_TIMEOUT_MS);
|
|
138
152
|
if (!result?.ok) {
|
|
139
153
|
const errMsg = result?.error || 'unknown error from opencode serve';
|
|
@@ -154,17 +168,23 @@ export function createOpencodeSessionDetailRouter() {
|
|
|
154
168
|
cause,
|
|
155
169
|
err: errMsg,
|
|
156
170
|
});
|
|
157
|
-
|
|
171
|
+
span.setAttribute('opencode.error_cause', cause);
|
|
172
|
+
span.setAttribute('opencode.upstream_status', result?.status ?? 0);
|
|
173
|
+
res.status(502).json({
|
|
158
174
|
error: 'opencode_error',
|
|
159
175
|
message: errMsg,
|
|
160
176
|
cause,
|
|
161
177
|
status: result?.status ?? undefined,
|
|
162
178
|
suggestion: 'Check that the opencode serve child is running. Try `bizar doctor` or restart the plugin.',
|
|
163
179
|
});
|
|
180
|
+
recordTrace('opencode.session.messages', { outcome: 'opencode_error', cause });
|
|
181
|
+
return;
|
|
164
182
|
}
|
|
165
183
|
const messages = Array.isArray(result.messages) ? result.messages.map(toChatMessage) : [];
|
|
166
|
-
|
|
167
|
-
|
|
184
|
+
span.setAttribute('opencode.message_count', messages.length);
|
|
185
|
+
res.json({ messages });
|
|
186
|
+
recordTrace('opencode.session.messages', { outcome: 'ok', message_count_bucket: messages.length === 0 ? '0' : messages.length < 50 ? '1-49' : '50+' });
|
|
187
|
+
})));
|
|
168
188
|
|
|
169
189
|
// ---------------------------------------------------------------------
|
|
170
190
|
// POST /opencode-sessions/:id/send
|
|
@@ -172,40 +192,59 @@ export function createOpencodeSessionDetailRouter() {
|
|
|
172
192
|
// Body: { message: string, agent: string } — both required.
|
|
173
193
|
// Returns { ok: true, messageId } or an error envelope.
|
|
174
194
|
// ---------------------------------------------------------------------
|
|
175
|
-
router.post('/opencode-sessions/:id/send', wrap(async (req, res) => {
|
|
195
|
+
router.post('/opencode-sessions/:id/send', wrap(withSpan('opencode.session.send', async (span, req, res) => {
|
|
176
196
|
const sessionId = String(req.params?.id || '');
|
|
197
|
+
setCommonAttributes(span, {
|
|
198
|
+
ip: req.ip || req.socket?.remoteAddress,
|
|
199
|
+
userAgent: req.headers?.['user-agent'],
|
|
200
|
+
});
|
|
201
|
+
span.setAttribute('opencode.session_id', sessionId);
|
|
177
202
|
if (!sessionId) {
|
|
178
|
-
|
|
203
|
+
res.status(400).json({ error: 'bad_request', message: 'session id is required' });
|
|
204
|
+
recordTrace('opencode.session.send', { outcome: 'missing_session_id' });
|
|
205
|
+
return;
|
|
179
206
|
}
|
|
180
207
|
const body = req.body && typeof req.body === 'object' ? req.body : {};
|
|
181
208
|
const message = typeof body.message === 'string' ? body.message.trim() : '';
|
|
182
209
|
const agent = typeof body.agent === 'string' ? body.agent.trim() : '';
|
|
210
|
+
span.setAttribute('opencode.message_length', message.length);
|
|
211
|
+
if (agent) span.setAttribute('opencode.agent', agent);
|
|
183
212
|
if (!message) {
|
|
184
|
-
|
|
213
|
+
res.status(400).json({ error: 'bad_request', message: '`message` is required' });
|
|
214
|
+
recordTrace('opencode.session.send', { outcome: 'missing_message' });
|
|
215
|
+
return;
|
|
185
216
|
}
|
|
186
217
|
if (!agent) {
|
|
187
|
-
|
|
218
|
+
res.status(400).json({ error: 'bad_request', message: '`agent` is required' });
|
|
219
|
+
recordTrace('opencode.session.send', { outcome: 'missing_agent' });
|
|
220
|
+
return;
|
|
188
221
|
}
|
|
189
222
|
const info = readServeInfo();
|
|
190
223
|
if (!info) {
|
|
191
|
-
|
|
224
|
+
res.status(503).json({
|
|
192
225
|
error: 'plugin_offline',
|
|
193
226
|
message: 'opencode plugin is not running',
|
|
194
227
|
suggestion: 'Run `bizar doctor` for diagnostics, or start opencode with `opencode serve`',
|
|
195
228
|
});
|
|
229
|
+
recordTrace('opencode.session.send', { outcome: 'plugin_offline' });
|
|
230
|
+
return;
|
|
196
231
|
}
|
|
197
232
|
const directory = await resolveSessionDirectory(sessionId, info);
|
|
198
233
|
if (!directory) {
|
|
199
|
-
|
|
234
|
+
res.status(503).json({
|
|
200
235
|
error: 'directory_unknown',
|
|
201
236
|
message:
|
|
202
237
|
'Cannot determine the opencode session directory; ensure the opencode plugin is running with serve.json containing worktree.',
|
|
203
238
|
suggestion: 'Create a new session or restart opencode serve.',
|
|
204
239
|
});
|
|
240
|
+
recordTrace('opencode.session.send', { outcome: 'directory_unknown' });
|
|
241
|
+
return;
|
|
205
242
|
}
|
|
243
|
+
span.setAttribute('opencode.worktree', directory);
|
|
206
244
|
// Synthesize a unique messageID so the client can correlate the
|
|
207
245
|
// prompt with the SSE events that opencode emits for it.
|
|
208
246
|
const messageID = `msg_${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
|
|
247
|
+
span.setAttribute('opencode.message_id', messageID);
|
|
209
248
|
const result = await sendOpencodePrompt(
|
|
210
249
|
info,
|
|
211
250
|
{ sessionId, agent, text: message, messageID },
|
|
@@ -227,16 +266,21 @@ export function createOpencodeSessionDetailRouter() {
|
|
|
227
266
|
cause,
|
|
228
267
|
err: errMsg,
|
|
229
268
|
});
|
|
230
|
-
|
|
269
|
+
span.setAttribute('opencode.error_cause', cause);
|
|
270
|
+
res.status(502).json({
|
|
231
271
|
error: 'opencode_error',
|
|
232
272
|
message: errMsg,
|
|
233
273
|
cause,
|
|
234
274
|
status: result?.status ?? undefined,
|
|
235
275
|
suggestion: 'Check that the opencode serve child is running. Try `bizar doctor` or restart the plugin.',
|
|
236
276
|
});
|
|
277
|
+
recordTrace('opencode.session.send', { outcome: 'opencode_error', cause });
|
|
278
|
+
return;
|
|
237
279
|
}
|
|
238
|
-
|
|
239
|
-
|
|
280
|
+
span.setAttribute('opencode.ack_message_id', result.messageId || '');
|
|
281
|
+
res.json({ ok: true, messageId: result.messageId });
|
|
282
|
+
recordTrace('opencode.session.send', { outcome: 'ok' });
|
|
283
|
+
})));
|
|
240
284
|
|
|
241
285
|
// ---------------------------------------------------------------------
|
|
242
286
|
// GET /opencode-sessions/:id/stream (SSE proxy)
|
|
@@ -253,10 +297,42 @@ export function createOpencodeSessionDetailRouter() {
|
|
|
253
297
|
// is ported to serve-info.mjs (`unwrapOpencodeSseEvent`).
|
|
254
298
|
// - We DO mirror the response shape used by `routes-v2/events.mjs`:
|
|
255
299
|
// `event: <type>\ndata: <json>\n\n`.
|
|
300
|
+
// - v5.1.0 — span lifecycle is manual (`startSpan` + `span.end()`)
|
|
301
|
+
// because the response is a long-lived stream. Using `withSpan`
|
|
302
|
+
// would end the span the instant the handler returned, well
|
|
303
|
+
// before the actual SSE close. We pin the same tracer for
|
|
304
|
+
// consistency with the rest of this module.
|
|
256
305
|
// ---------------------------------------------------------------------
|
|
257
306
|
router.get('/opencode-sessions/:id/stream', (req, res) => {
|
|
258
307
|
const sessionId = String(req.params?.id || '');
|
|
308
|
+
const span = tracer.startSpan('opencode.session.stream', {
|
|
309
|
+
attributes: {
|
|
310
|
+
'opencode.session_id': sessionId,
|
|
311
|
+
'http.client_ip': req.ip || req.socket?.remoteAddress || '',
|
|
312
|
+
'http.user_agent': (req.headers && req.headers['user-agent']) || '',
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
let spanEnded = false;
|
|
316
|
+
const endSpan = (extraAttrs) => {
|
|
317
|
+
if (spanEnded) return;
|
|
318
|
+
spanEnded = true;
|
|
319
|
+
try {
|
|
320
|
+
if (extraAttrs && typeof extraAttrs === 'object') {
|
|
321
|
+
for (const [k, v] of Object.entries(extraAttrs)) {
|
|
322
|
+
try { span.setAttribute(k, v); } catch { /* ignore */ }
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
span.setStatus({ code: 1 /* OK */ });
|
|
326
|
+
span.end();
|
|
327
|
+
} catch {
|
|
328
|
+
/* never throw from OTel helper */
|
|
329
|
+
}
|
|
330
|
+
};
|
|
259
331
|
if (!sessionId) {
|
|
332
|
+
try {
|
|
333
|
+
span.setStatus({ code: 2 /* ERROR */, message: 'missing_session_id' });
|
|
334
|
+
span.end();
|
|
335
|
+
} catch { /* ignore */ }
|
|
260
336
|
return res.status(400).json({ error: 'bad_request', message: 'session id is required' });
|
|
261
337
|
}
|
|
262
338
|
if (activeSubscribers >= MAX_SSE_SUBSCRIBERS) {
|
|
@@ -264,6 +340,12 @@ export function createOpencodeSessionDetailRouter() {
|
|
|
264
340
|
error: 'too_many_subscribers',
|
|
265
341
|
message: `SSE subscriber cap reached (${MAX_SSE_SUBSCRIBERS}); try again later.`,
|
|
266
342
|
});
|
|
343
|
+
try {
|
|
344
|
+
span.setAttribute('opencode.stream.outcome', 'too_many_subscribers');
|
|
345
|
+
span.setStatus({ code: 2 /* ERROR */, message: 'too_many_subscribers' });
|
|
346
|
+
span.end();
|
|
347
|
+
} catch { /* ignore */ }
|
|
348
|
+
recordTrace('opencode.session.stream', { outcome: 'too_many_subscribers' });
|
|
267
349
|
return;
|
|
268
350
|
}
|
|
269
351
|
|
|
@@ -280,10 +362,13 @@ export function createOpencodeSessionDetailRouter() {
|
|
|
280
362
|
if (!info) {
|
|
281
363
|
res.write(`event: error\ndata: ${JSON.stringify({ error: 'plugin_offline' })}\n\n`);
|
|
282
364
|
res.end();
|
|
365
|
+
endSpan({ 'opencode.stream.outcome': 'plugin_offline' });
|
|
366
|
+
recordTrace('opencode.session.stream', { outcome: 'plugin_offline' });
|
|
283
367
|
return;
|
|
284
368
|
}
|
|
285
369
|
|
|
286
370
|
activeSubscribers += 1;
|
|
371
|
+
span.setAttribute('opencode.stream.active_subscribers', activeSubscribers);
|
|
287
372
|
|
|
288
373
|
// Upstream fetch + line-buffered SSE parser. Aborted on client
|
|
289
374
|
// disconnect or when the upstream closes.
|
|
@@ -300,6 +385,7 @@ export function createOpencodeSessionDetailRouter() {
|
|
|
300
385
|
}
|
|
301
386
|
}, getSseHeartbeatMs());
|
|
302
387
|
let closed = false;
|
|
388
|
+
const startTs = Date.now();
|
|
303
389
|
const cleanup = () => {
|
|
304
390
|
if (closed) return;
|
|
305
391
|
closed = true;
|
|
@@ -309,8 +395,21 @@ export function createOpencodeSessionDetailRouter() {
|
|
|
309
395
|
if (!res.writableEnded) {
|
|
310
396
|
try { res.end(); } catch { /* ignore */ }
|
|
311
397
|
}
|
|
398
|
+
const lifetimeMs = Date.now() - startTs;
|
|
399
|
+
endSpan({
|
|
400
|
+
'opencode.stream.outcome': closedReason || 'closed',
|
|
401
|
+
'opencode.stream.lifetime_ms': lifetimeMs,
|
|
402
|
+
});
|
|
403
|
+
recordTrace('opencode.session.stream', {
|
|
404
|
+
outcome: closedReason || 'closed',
|
|
405
|
+
lifetime_bucket: lifetimeMs < 1_000 ? '<1s' : lifetimeMs < 60_000 ? '<60s' : '60s+',
|
|
406
|
+
});
|
|
312
407
|
};
|
|
313
|
-
|
|
408
|
+
let closedReason = 'client_closed';
|
|
409
|
+
req.on('close', () => {
|
|
410
|
+
closedReason = 'client_closed';
|
|
411
|
+
cleanup();
|
|
412
|
+
});
|
|
314
413
|
|
|
315
414
|
const upstreamUrl = `${info.baseUrl}/event?directory=${encodeURIComponent(info.worktree || '')}`;
|
|
316
415
|
let upstream;
|
|
@@ -326,6 +425,7 @@ export function createOpencodeSessionDetailRouter() {
|
|
|
326
425
|
} catch (err) {
|
|
327
426
|
const msg = err instanceof Error ? err.message : String(err);
|
|
328
427
|
res.write(`event: error\ndata: ${JSON.stringify({ error: 'upstream_open_failed', message: msg })}\n\n`);
|
|
428
|
+
closedReason = 'upstream_open_failed';
|
|
329
429
|
cleanup();
|
|
330
430
|
return;
|
|
331
431
|
}
|
|
@@ -338,10 +438,12 @@ export function createOpencodeSessionDetailRouter() {
|
|
|
338
438
|
error: 'upstream_status',
|
|
339
439
|
status: r.status,
|
|
340
440
|
})}\n\n`);
|
|
441
|
+
closedReason = 'upstream_bad_status';
|
|
341
442
|
cleanup();
|
|
342
443
|
return;
|
|
343
444
|
}
|
|
344
445
|
await pumpSseStream(r.body, res, sessionId);
|
|
446
|
+
closedReason = 'upstream_eof';
|
|
345
447
|
} catch (err) {
|
|
346
448
|
if (controller.signal.aborted) return; // client disconnected, already cleaned up
|
|
347
449
|
const msg = err instanceof Error ? err.message : String(err);
|
|
@@ -350,6 +452,7 @@ export function createOpencodeSessionDetailRouter() {
|
|
|
350
452
|
res.write(`event: error\ndata: ${JSON.stringify({ error: 'upstream_read_error', message: msg })}\n\n`);
|
|
351
453
|
} catch { /* ignore */ }
|
|
352
454
|
}
|
|
455
|
+
closedReason = 'upstream_read_error';
|
|
353
456
|
} finally {
|
|
354
457
|
cleanup();
|
|
355
458
|
}
|