@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.
Files changed (88) hide show
  1. package/bizar-dash/dist/assets/MobileChat-TCputYzr.js +1 -0
  2. package/bizar-dash/dist/assets/MobileChat-TCputYzr.js.map +1 -0
  3. package/bizar-dash/dist/assets/MobileSettings-BxcbL5XT.js +1 -0
  4. package/bizar-dash/dist/assets/MobileSettings-BxcbL5XT.js.map +1 -0
  5. package/bizar-dash/dist/assets/{icons-Bo0iH9EC.js → icons-DRDXfbBP.js} +151 -136
  6. package/bizar-dash/dist/assets/icons-DRDXfbBP.js.map +1 -0
  7. package/bizar-dash/dist/assets/main-D5Ditnrd.js +19 -0
  8. package/bizar-dash/dist/assets/main-D5Ditnrd.js.map +1 -0
  9. package/bizar-dash/dist/assets/{main-DTkNlLrw.css → main-xFpWMd32.css} +1 -1
  10. package/bizar-dash/dist/assets/markdown-C6mXtQxD.js +1 -0
  11. package/bizar-dash/dist/assets/markdown-C6mXtQxD.js.map +1 -0
  12. package/bizar-dash/dist/assets/mobile--17fkfrl.js +1 -0
  13. package/bizar-dash/dist/assets/{mobile-DD-FZrTC.js.map → mobile--17fkfrl.js.map} +1 -1
  14. package/bizar-dash/dist/assets/mobile-j3rOZK6v.js +2 -0
  15. package/bizar-dash/dist/assets/mobile-j3rOZK6v.js.map +1 -0
  16. package/bizar-dash/dist/assets/{react-vendor-DZRUXSPQ.js → react-vendor-Dn4wqh4Z.js} +8 -8
  17. package/bizar-dash/dist/assets/react-vendor-Dn4wqh4Z.js.map +1 -0
  18. package/bizar-dash/dist/assets/useSlashCommands-BG-DhEck.js +2 -0
  19. package/bizar-dash/dist/assets/useSlashCommands-BG-DhEck.js.map +1 -0
  20. package/bizar-dash/dist/assets/vendor-CeHGtduv.js +29 -0
  21. package/bizar-dash/dist/assets/vendor-CeHGtduv.js.map +1 -0
  22. package/bizar-dash/dist/index.html +8 -6
  23. package/bizar-dash/dist/mobile.html +4 -5
  24. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  25. package/bizar-dash/src/server/api.mjs +4 -0
  26. package/bizar-dash/src/server/eval.mjs +93 -3
  27. package/bizar-dash/src/server/metrics.mjs +75 -0
  28. package/bizar-dash/src/server/otel.mjs +141 -14
  29. package/bizar-dash/src/server/plugins/registry.mjs +145 -43
  30. package/bizar-dash/src/server/routes/chat.mjs +46 -9
  31. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +120 -17
  32. package/bizar-dash/src/server/routes/plugins.mjs +131 -16
  33. package/bizar-dash/src/server/routes/tailscale.mjs +46 -0
  34. package/bizar-dash/src/server/routes/voice.mjs +42 -6
  35. package/bizar-dash/src/server/routes/workspaces.mjs +156 -61
  36. package/bizar-dash/src/server/server.mjs +57 -15
  37. package/bizar-dash/src/server/voice-store.mjs +27 -0
  38. package/bizar-dash/src/server/workers/transcription-worker.mjs +213 -0
  39. package/bizar-dash/src/web/App.tsx +9 -0
  40. package/bizar-dash/src/web/MobileApp.tsx +18 -10
  41. package/bizar-dash/src/web/components/EvalDiff.tsx +157 -0
  42. package/bizar-dash/src/web/components/EvalRunCard.tsx +78 -0
  43. package/bizar-dash/src/web/components/PluginCard.tsx +64 -0
  44. package/bizar-dash/src/web/components/PluginPermissions.tsx +36 -0
  45. package/bizar-dash/src/web/components/TailscaleSettings.tsx +161 -0
  46. package/bizar-dash/src/web/components/Toggle.tsx +31 -0
  47. package/bizar-dash/src/web/components/Topbar.tsx +6 -0
  48. package/bizar-dash/src/web/mobile/MobileChat.tsx +138 -0
  49. package/bizar-dash/src/web/mobile/MobileSettings.tsx +196 -0
  50. package/bizar-dash/src/web/styles/main.css +545 -0
  51. package/bizar-dash/src/web/views/Eval.tsx +172 -0
  52. package/bizar-dash/src/web/views/EvalReport.tsx +349 -0
  53. package/bizar-dash/src/web/views/Plugins.tsx +128 -0
  54. package/bizar-dash/src/web/views/Settings.tsx +2 -0
  55. package/bizar-dash/tests/bundle-analysis.test.mjs +5 -4
  56. package/bizar-dash/tests/cli-tailscale.test.mjs +113 -0
  57. package/bizar-dash/tests/components/plugin-permissions.test.tsx +95 -0
  58. package/bizar-dash/tests/eval/fixtures-extra.test.mjs +285 -0
  59. package/bizar-dash/tests/eval-web-ui.test.tsx +220 -0
  60. package/bizar-dash/tests/otel-spans.test.mjs +253 -0
  61. package/bizar-dash/tests/plugins-registry-fallback.test.mjs +259 -0
  62. package/bizar-dash/tests/voice-transcribe-worker.test.mjs +343 -0
  63. package/cli/bin.mjs +14 -0
  64. package/cli/commands/dash.mjs +19 -1
  65. package/cli/commands/service.mjs +7 -0
  66. package/cli/commands/tailscale.mjs +251 -0
  67. package/package.json +1 -1
  68. package/templates/eval-fixtures/citation.json +16 -0
  69. package/templates/eval-fixtures/code-review.json +16 -0
  70. package/templates/eval-fixtures/concise-output.json +16 -0
  71. package/templates/eval-fixtures/context-window.json +16 -0
  72. package/templates/eval-fixtures/error-recovery.json +16 -0
  73. package/templates/eval-fixtures/json-output.json +20 -0
  74. package/templates/eval-fixtures/multi-language.json +16 -0
  75. package/templates/eval-fixtures/safe-paths.json +16 -0
  76. package/templates/eval-fixtures/tool-call-multi-step.json +18 -0
  77. package/templates/eval-fixtures/unicode-handling.json +16 -0
  78. package/bizar-dash/dist/assets/icons-Bo0iH9EC.js.map +0 -1
  79. package/bizar-dash/dist/assets/index-DmpSFPJY.js +0 -9
  80. package/bizar-dash/dist/assets/index-DmpSFPJY.js.map +0 -1
  81. package/bizar-dash/dist/assets/main-C1cpttnv.js +0 -19
  82. package/bizar-dash/dist/assets/main-C1cpttnv.js.map +0 -1
  83. package/bizar-dash/dist/assets/markdown-DIquRulQ.js +0 -29
  84. package/bizar-dash/dist/assets/markdown-DIquRulQ.js.map +0 -1
  85. package/bizar-dash/dist/assets/mobile-BtxQJftK.js +0 -2
  86. package/bizar-dash/dist/assets/mobile-BtxQJftK.js.map +0 -1
  87. package/bizar-dash/dist/assets/mobile-DD-FZrTC.js +0 -1
  88. 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 (default: env/default)
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
- let res;
184
- try {
185
- res = await fetchFn(resolvedUrl, {
186
- headers: { 'User-Agent': 'bizar-registry-client/5.0' },
187
- });
188
- } catch (err) {
189
- const wrap = new Error(
190
- `registry fetch failed for ${resolvedUrl}: ${err.message}`,
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
- if (!res.ok) {
197
- const err = new Error(
198
- `registry fetch returned ${res.status} ${res.statusText} from ${resolvedUrl}`,
199
- );
200
- err.code = 'registry_unreachable';
201
- err.status = res.status;
202
- throw err;
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
- let json;
205
- try {
206
- json = await res.json();
207
- } catch (err) {
208
- const wrap = new Error(
209
- `registry at ${resolvedUrl} is not valid JSON: ${err.message}`,
210
- );
211
- wrap.code = 'invalid_registry';
212
- wrap.cause = err;
213
- throw wrap;
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
- const validated = validateRegistry(json);
216
- _cache = { fetchedAt: now, url: resolvedUrl, data: validated };
217
- return validated;
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
- return res.status(400).json({ error: 'bad_request', message: 'session id is required' });
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
- return res.status(503).json({
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
- return res.status(503).json({
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
- return res.status(502).json({
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
- return res.json({ messages });
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
- return res.status(400).json({ error: 'bad_request', message: 'session id is required' });
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
- return res.status(400).json({ error: 'bad_request', message: '`message` is required' });
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
- return res.status(400).json({ error: 'bad_request', message: '`agent` is required' });
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
- return res.status(503).json({
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
- return res.status(503).json({
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
- return res.status(502).json({
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
- return res.json({ ok: true, messageId: result.messageId });
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
- req.on('close', cleanup);
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
  }