@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
@@ -33,8 +33,30 @@ import {
33
33
  ROLES,
34
34
  } from '../workspaces.mjs';
35
35
  import { getCurrentUserId, getCurrentWorkspaceId } from '../auth.mjs';
36
+ import { withSpan, setCommonAttributes } from '../otel.mjs';
37
+ import { recordTrace } from '../metrics.mjs';
36
38
  import { wrap } from './_shared.mjs';
37
39
 
40
+ /**
41
+ * Common scope helper: extract userId/workspaceId from the request
42
+ * and bind them to the active span. Returns `null` (and lets the
43
+ * caller emit a 401) when no user is attached to the request.
44
+ * Callers decide whether to short-circuit by checking the return
45
+ * value.
46
+ *
47
+ * @param {import('@opentelemetry/api').Span} span
48
+ * @param {import('express').Request} req
49
+ * @returns {string|null}
50
+ */
51
+ function bindUserScope(span, req) {
52
+ const userId = getCurrentUserId(req);
53
+ const workspaceId = getCurrentWorkspaceId(req) || null;
54
+ const ip = req.ip || req.socket?.remoteAddress;
55
+ const userAgent = req.headers?.['user-agent'];
56
+ setCommonAttributes(span, { userId, workspaceId, ip, userAgent });
57
+ return userId || null;
58
+ }
59
+
38
60
  /**
39
61
  * @returns {import('express').Router}
40
62
  */
@@ -42,163 +64,236 @@ export function createWorkspacesRouter() {
42
64
  const router = Router();
43
65
 
44
66
  // GET /api/workspaces — list user's workspaces
45
- router.get('/workspaces', wrap(async (req, res) => {
46
- const userId = getCurrentUserId(req);
67
+ router.get('/workspaces', wrap(withSpan('workspace.list', async (span, req, res) => {
68
+ const userId = bindUserScope(span, req);
47
69
  if (!userId) {
48
- return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
70
+ res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
71
+ recordTrace('workspace.list', { outcome: 'unauthorized' });
72
+ return;
49
73
  }
50
74
  const workspaces = await listWorkspacesWithRoles(userId);
75
+ span.setAttribute('workspace.count', workspaces.length);
51
76
  res.json({ workspaces });
52
- }));
77
+ recordTrace('workspace.list', { outcome: 'ok', count_bucket: workspaces.length === 0 ? '0' : workspaces.length < 5 ? '1-4' : '5+' });
78
+ })));
53
79
 
54
80
  // POST /api/workspaces — create workspace
55
- router.post('/workspaces', wrap(async (req, res) => {
56
- const userId = getCurrentUserId(req);
81
+ router.post('/workspaces', wrap(withSpan('workspace.create', async (span, req, res) => {
82
+ const userId = bindUserScope(span, req);
57
83
  if (!userId) {
58
- return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
84
+ res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
85
+ recordTrace('workspace.create', { outcome: 'unauthorized' });
86
+ return;
59
87
  }
60
88
  const { name } = req.body || {};
61
89
  if (!name || typeof name !== 'string' || !name.trim()) {
62
- return res.status(400).json({ error: 'bad_request', message: 'Workspace name is required' });
90
+ res.status(400).json({ error: 'bad_request', message: 'Workspace name is required' });
91
+ recordTrace('workspace.create', { outcome: 'missing_name' });
92
+ return;
63
93
  }
64
94
  const workspace = await createWorkspace({ name: name.trim(), ownerId: userId });
95
+ span.setAttribute('workspace.id', workspace.id);
65
96
  res.status(201).json({ workspace });
66
- }));
97
+ recordTrace('workspace.create', { outcome: 'created' });
98
+ })));
67
99
 
68
100
  // GET /api/workspaces/:id — get workspace details
69
- router.get('/workspaces/:id', wrap(async (req, res) => {
70
- const userId = getCurrentUserId(req);
101
+ router.get('/workspaces/:id', wrap(withSpan('workspace.get', async (span, req, res) => {
102
+ const userId = bindUserScope(span, req);
103
+ const { id } = req.params;
104
+ span.setAttribute('workspace.id', id);
71
105
  if (!userId) {
72
- return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
106
+ res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
107
+ recordTrace('workspace.get', { outcome: 'unauthorized' });
108
+ return;
73
109
  }
74
- const { id } = req.params;
75
110
  const workspace = await getWorkspace(id);
76
111
  if (!workspace) {
77
- return res.status(404).json({ error: 'not_found', message: 'Workspace not found' });
112
+ res.status(404).json({ error: 'not_found', message: 'Workspace not found' });
113
+ recordTrace('workspace.get', { outcome: 'not_found' });
114
+ return;
78
115
  }
79
116
  const hasAccess = await checkPermission(id, userId, ROLES.VIEWER);
80
117
  if (!hasAccess) {
81
- return res.status(403).json({ error: 'forbidden', message: 'Not a member of this workspace' });
118
+ res.status(403).json({ error: 'forbidden', message: 'Not a member of this workspace' });
119
+ recordTrace('workspace.get', { outcome: 'forbidden' });
120
+ return;
82
121
  }
83
122
  const members = await listMembers(id);
123
+ span.setAttribute('workspace.member_count', members.length);
84
124
  res.json({ workspace, members });
85
- }));
125
+ recordTrace('workspace.get', { outcome: 'ok' });
126
+ })));
86
127
 
87
128
  // DELETE /api/workspaces/:id — delete workspace (admin only)
88
- router.delete('/workspaces/:id', wrap(async (req, res) => {
89
- const userId = getCurrentUserId(req);
129
+ router.delete('/workspaces/:id', wrap(withSpan('workspace.delete', async (span, req, res) => {
130
+ const userId = bindUserScope(span, req);
131
+ const { id } = req.params;
132
+ span.setAttribute('workspace.id', id);
90
133
  if (!userId) {
91
- return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
134
+ res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
135
+ recordTrace('workspace.delete', { outcome: 'unauthorized' });
136
+ return;
92
137
  }
93
- const { id } = req.params;
94
138
  const canDelete = await checkPermission(id, userId, ROLES.ADMIN);
95
139
  if (!canDelete) {
96
- return res.status(403).json({ error: 'forbidden', message: 'Admin role required' });
140
+ res.status(403).json({ error: 'forbidden', message: 'Admin role required' });
141
+ recordTrace('workspace.delete', { outcome: 'forbidden' });
142
+ return;
97
143
  }
98
144
  await deleteWorkspace(id);
99
145
  res.json({ ok: true });
100
- }));
146
+ recordTrace('workspace.delete', { outcome: 'ok' });
147
+ })));
101
148
 
102
149
  // POST /api/workspaces/:id/invites — create invite
103
- router.post('/workspaces/:id/invites', wrap(async (req, res) => {
104
- const userId = getCurrentUserId(req);
150
+ router.post('/workspaces/:id/invites', wrap(withSpan('workspace.invite.create', async (span, req, res) => {
151
+ const userId = bindUserScope(span, req);
152
+ const { id } = req.params;
153
+ span.setAttribute('workspace.id', id);
105
154
  if (!userId) {
106
- return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
155
+ res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
156
+ recordTrace('workspace.invite.create', { outcome: 'unauthorized' });
157
+ return;
107
158
  }
108
- const { id } = req.params;
109
159
  const { email, role } = req.body || {};
110
160
  if (!email || typeof email !== 'string') {
111
- return res.status(400).json({ error: 'bad_request', message: 'Email is required' });
161
+ res.status(400).json({ error: 'bad_request', message: 'Email is required' });
162
+ recordTrace('workspace.invite.create', { outcome: 'missing_email' });
163
+ return;
112
164
  }
113
165
  if (!role || !Object.values(ROLES).includes(role)) {
114
- return res.status(400).json({ error: 'bad_request', message: 'Valid role is required' });
166
+ res.status(400).json({ error: 'bad_request', message: 'Valid role is required' });
167
+ recordTrace('workspace.invite.create', { outcome: 'invalid_role' });
168
+ return;
115
169
  }
116
170
  const canInvite = await checkPermission(id, userId, ROLES.ADMIN);
117
171
  if (!canInvite) {
118
- return res.status(403).json({ error: 'forbidden', message: 'Admin role required to invite' });
172
+ res.status(403).json({ error: 'forbidden', message: 'Admin role required to invite' });
173
+ recordTrace('workspace.invite.create', { outcome: 'forbidden' });
174
+ return;
119
175
  }
176
+ span.setAttribute('workspace.invite.role', role);
120
177
  const result = await createInvite(id, email, role, userId);
121
178
  res.status(201).json(result);
122
- }));
179
+ recordTrace('workspace.invite.create', { outcome: 'created', role });
180
+ })));
123
181
 
124
182
  // GET /api/workspaces/:id/invites — list pending invites
125
- router.get('/workspaces/:id/invites', wrap(async (req, res) => {
126
- const userId = getCurrentUserId(req);
183
+ router.get('/workspaces/:id/invites', wrap(withSpan('workspace.invite.list', async (span, req, res) => {
184
+ const userId = bindUserScope(span, req);
185
+ const { id } = req.params;
186
+ span.setAttribute('workspace.id', id);
127
187
  if (!userId) {
128
- return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
188
+ res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
189
+ recordTrace('workspace.invite.list', { outcome: 'unauthorized' });
190
+ return;
129
191
  }
130
- const { id } = req.params;
131
192
  const canView = await checkPermission(id, userId, ROLES.ADMIN);
132
193
  if (!canView) {
133
- return res.status(403).json({ error: 'forbidden', message: 'Admin role required' });
194
+ res.status(403).json({ error: 'forbidden', message: 'Admin role required' });
195
+ recordTrace('workspace.invite.list', { outcome: 'forbidden' });
196
+ return;
134
197
  }
135
198
  const invites = await listInvites(id);
199
+ span.setAttribute('workspace.invite.count', invites.length);
136
200
  res.json({ invites });
137
- }));
201
+ recordTrace('workspace.invite.list', { outcome: 'ok', count_bucket: invites.length === 0 ? '0' : '1+' });
202
+ })));
138
203
 
139
204
  // DELETE /api/workspaces/:id/invites/:token — revoke invite
140
- router.delete('/workspaces/:id/invites/:token', wrap(async (req, res) => {
141
- const userId = getCurrentUserId(req);
205
+ router.delete('/workspaces/:id/invites/:token', wrap(withSpan('workspace.invite.revoke', async (span, req, res) => {
206
+ const userId = bindUserScope(span, req);
207
+ const { id, token } = req.params;
208
+ span.setAttribute('workspace.id', id);
142
209
  if (!userId) {
143
- return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
210
+ res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
211
+ recordTrace('workspace.invite.revoke', { outcome: 'unauthorized' });
212
+ return;
144
213
  }
145
- const { id, token } = req.params;
146
214
  const canRevoke = await checkPermission(id, userId, ROLES.ADMIN);
147
215
  if (!canRevoke) {
148
- return res.status(403).json({ error: 'forbidden', message: 'Admin role required' });
216
+ res.status(403).json({ error: 'forbidden', message: 'Admin role required' });
217
+ recordTrace('workspace.invite.revoke', { outcome: 'forbidden' });
218
+ return;
149
219
  }
150
220
  await revokeInvite(id, token);
151
221
  res.json({ ok: true });
152
- }));
222
+ recordTrace('workspace.invite.revoke', { outcome: 'ok' });
223
+ })));
153
224
 
154
225
  // POST /api/workspaces/:id/members/:userId — update member role
155
- router.post('/workspaces/:id/members/:userId', wrap(async (req, res) => {
156
- const userId = getCurrentUserId(req);
226
+ router.post('/workspaces/:id/members/:userId', wrap(withSpan('workspace.member.update', async (span, req, res) => {
227
+ const userId = bindUserScope(span, req);
228
+ const { id, userId: targetUserId } = req.params;
229
+ span.setAttribute('workspace.id', id);
230
+ span.setAttribute('workspace.target_user_id', targetUserId);
157
231
  if (!userId) {
158
- return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
232
+ res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
233
+ recordTrace('workspace.member.update', { outcome: 'unauthorized' });
234
+ return;
159
235
  }
160
- const { id, userId: targetUserId } = req.params;
161
236
  const { role } = req.body || {};
162
237
  if (!role || !Object.values(ROLES).includes(role)) {
163
- return res.status(400).json({ error: 'bad_request', message: 'Valid role is required' });
238
+ res.status(400).json({ error: 'bad_request', message: 'Valid role is required' });
239
+ recordTrace('workspace.member.update', { outcome: 'invalid_role' });
240
+ return;
164
241
  }
165
242
  const canUpdate = await checkPermission(id, userId, ROLES.ADMIN);
166
243
  if (!canUpdate) {
167
- return res.status(403).json({ error: 'forbidden', message: 'Admin role required' });
244
+ res.status(403).json({ error: 'forbidden', message: 'Admin role required' });
245
+ recordTrace('workspace.member.update', { outcome: 'forbidden' });
246
+ return;
168
247
  }
248
+ span.setAttribute('workspace.member.new_role', role);
169
249
  await updateMemberRole(id, targetUserId, role);
170
250
  res.json({ ok: true, role });
171
- }));
251
+ recordTrace('workspace.member.update', { outcome: 'ok', role });
252
+ })));
172
253
 
173
254
  // DELETE /api/workspaces/:id/members/:userId — remove member
174
- router.delete('/workspaces/:id/members/:userId', wrap(async (req, res) => {
175
- const userId = getCurrentUserId(req);
255
+ router.delete('/workspaces/:id/members/:userId', wrap(withSpan('workspace.member.remove', async (span, req, res) => {
256
+ const userId = bindUserScope(span, req);
257
+ const { id, userId: targetUserId } = req.params;
258
+ span.setAttribute('workspace.id', id);
259
+ span.setAttribute('workspace.target_user_id', targetUserId);
176
260
  if (!userId) {
177
- return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
261
+ res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
262
+ recordTrace('workspace.member.remove', { outcome: 'unauthorized' });
263
+ return;
178
264
  }
179
- const { id, userId: targetUserId } = req.params;
180
265
  const canRemove = await checkPermission(id, userId, ROLES.ADMIN);
181
266
  if (!canRemove) {
182
- return res.status(403).json({ error: 'forbidden', message: 'Admin role required' });
267
+ res.status(403).json({ error: 'forbidden', message: 'Admin role required' });
268
+ recordTrace('workspace.member.remove', { outcome: 'forbidden' });
269
+ return;
183
270
  }
184
271
  await removeMember(id, targetUserId);
185
272
  res.json({ ok: true });
186
- }));
273
+ recordTrace('workspace.member.remove', { outcome: 'ok' });
274
+ })));
187
275
 
188
276
  // POST /api/invites/:token/accept — accept invite
189
- router.post('/invites/:token/accept', wrap(async (req, res) => {
190
- const userId = getCurrentUserId(req);
277
+ router.post('/invites/:token/accept', wrap(withSpan('workspace.invite.accept', async (span, req, res) => {
278
+ const userId = bindUserScope(span, req);
279
+ const { token } = req.params;
191
280
  if (!userId) {
192
- return res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
281
+ res.status(401).json({ error: 'unauthorized', message: 'Not authenticated' });
282
+ recordTrace('workspace.invite.accept', { outcome: 'unauthorized' });
283
+ return;
193
284
  }
194
- const { token } = req.params;
195
285
  const { email, name } = req.body || {};
196
286
  if (!email || !name) {
197
- return res.status(400).json({ error: 'bad_request', message: 'Email and name are required' });
287
+ res.status(400).json({ error: 'bad_request', message: 'Email and name are required' });
288
+ recordTrace('workspace.invite.accept', { outcome: 'missing_fields' });
289
+ return;
198
290
  }
199
291
  const result = await acceptInvite(token, userId, email, name);
292
+ if (result?.workspace?.id) span.setAttribute('workspace.id', result.workspace.id);
293
+ if (result?.role) span.setAttribute('workspace.member.role', result.role);
200
294
  res.json({ ok: true, workspace: result.workspace, role: result.role });
201
- }));
295
+ recordTrace('workspace.invite.accept', { outcome: 'ok' });
296
+ })));
202
297
 
203
298
  return router;
204
299
  }
@@ -41,7 +41,7 @@ import { loadOrCreateAuth, V2_DEFAULT_PORT } from './v2-auth-file.mjs';
41
41
  import { createV2Router } from './routes-v2/index.mjs';
42
42
  import { counter, gauge, render as renderMetrics } from './metrics.mjs';
43
43
  import { warn } from './logger.mjs';
44
- import { initOtel, shutdownOtel } from './otel.mjs';
44
+ import { initOtel, shutdownOtel, tracer } from './otel.mjs';
45
45
 
46
46
  // v4.7.0 — Prometheus-style HTTP metrics. Bound to the server-wide
47
47
  // registry; render() emits the text exposition format consumed by
@@ -238,25 +238,54 @@ export async function createServer({
238
238
  // limiter middleware (chat / event); if no scope header is
239
239
  // present, log the 429 with scope=unknown (it may be an upstream
240
240
  // 429 proxied from the opencode plugin).
241
+ //
242
+ // v5.1.0 — Also opens an HTTP-server root span on every request
243
+ // via the OpenTelemetry tracer. The span is created inside
244
+ // `startActiveSpan` so the per-route spans below automatically
245
+ // parent under it (the OTel SDK's AsyncHooksContextManager is
246
+ // installed by NodeSDK.start()). HTTP semantic convention
247
+ // attributes (`http.request.method`, `url.path`, `http.route`,
248
+ // `http.response.status_code`, `http.user_agent`) are added up
249
+ // front / on finish. Cost when OTEL is off: zero — the no-op
250
+ // tracer does nothing.
241
251
  app.use((req, res, next) => {
242
- res.on('finish', () => {
243
- const route = req.route?.path ? `${req.baseUrl || ''}${req.route.path}` : 'unmatched';
244
- httpRequestsTotal.inc({
245
- method: req.method,
246
- route,
247
- status: String(res.statusCode),
248
- });
249
- if (res.statusCode === 429) {
250
- const scope = res.getHeader('X-RateLimit-Scope') || 'unknown';
251
- warn('rate_limit_exceeded', {
252
- ip: req.ip || (req.socket && req.socket.remoteAddress) || 'unknown',
253
- scope,
252
+ tracer.startActiveSpan('http.request', { attributes: {
253
+ 'http.request.method': req.method,
254
+ 'url.path': req.originalUrl || req.url || '',
255
+ 'url.scheme': req.protocol || 'http',
256
+ 'http.user_agent': (req.headers && req.headers['user-agent']) || '',
257
+ 'http.client_ip': req.ip || (req.socket && req.socket.remoteAddress) || '',
258
+ 'http.host': req.headers?.host || '',
259
+ } }, (rootSpan) => {
260
+ res.on('finish', () => {
261
+ const route = req.route?.path ? `${req.baseUrl || ''}${req.route.path}` : 'unmatched';
262
+ httpRequestsTotal.inc({
254
263
  method: req.method,
255
264
  route,
265
+ status: String(res.statusCode),
256
266
  });
257
- }
267
+ try {
268
+ rootSpan.setAttribute('http.route', route);
269
+ rootSpan.setAttribute('http.response.status_code', res.statusCode);
270
+ if (res.statusCode >= 500) {
271
+ rootSpan.setStatus({ code: 2 /* ERROR */, message: `HTTP ${res.statusCode}` });
272
+ } else {
273
+ rootSpan.setStatus({ code: 1 /* OK */ });
274
+ }
275
+ } catch { /* never throw from OTel helpers */ }
276
+ try { rootSpan.end(); } catch { /* ignore */ }
277
+ if (res.statusCode === 429) {
278
+ const scope = res.getHeader('X-RateLimit-Scope') || 'unknown';
279
+ warn('rate_limit_exceeded', {
280
+ ip: req.ip || (req.socket && req.socket.remoteAddress) || 'unknown',
281
+ scope,
282
+ method: req.method,
283
+ route,
284
+ });
285
+ }
286
+ });
287
+ next();
258
288
  });
259
- next();
260
289
  });
261
290
 
262
291
  // v4.7.0 — Cache-Control headers for live JSON endpoints. The
@@ -444,6 +473,19 @@ export async function createServer({
444
473
  console.warn('[bizar-dash] headroom startup hook skipped:', err?.message || err);
445
474
  }
446
475
 
476
+ // v5.2 — Background transcription worker for voice notes. Uploads
477
+ // save audio immediately and enqueue the noteId here; the worker
478
+ // drains the queue, calls Whisper (or BIZAR_WHISPER_ENDPOINT), and
479
+ // broadcasts a `voice:updated` event so the dashboard updates in
480
+ // place. Errors here MUST NOT block boot — the worker is best-effort
481
+ // and a missing transcribe just leaves the note with no transcript.
482
+ try {
483
+ const { startTranscriptionWorker } = await import('./workers/transcription-worker.mjs');
484
+ startTranscriptionWorker({ broadcast: localBroadcast });
485
+ } catch (err) {
486
+ console.warn('[bizar-dash] transcription worker failed to start:', err?.message || err);
487
+ }
488
+
447
489
  // All /api/* routes go through apiRouter (after mod routes are checked).
448
490
  // IMPORTANT: mount v2 router FIRST so `/api/v2/*` matches before
449
491
  // apiRouter's internal 404 catch-all (api.mjs line ~109) can swallow it.
@@ -168,6 +168,33 @@ export function getVoiceNote(id) {
168
168
  return loadIndex().find((n) => n.id === id);
169
169
  }
170
170
 
171
+ /**
172
+ * v5.2 — Patch an existing note in place. Used by the background
173
+ * transcription worker to attach the transcript after Whisper returns.
174
+ *
175
+ * Atomic write via the same `atomicWriteJson` helper as `saveVoiceNote`.
176
+ * `updatedAtMs` is refreshed on every call so callers / the UI can see
177
+ * when the note last changed. `createdAtMs` is preserved.
178
+ *
179
+ * If the id is unknown, returns null without touching the index. Callers
180
+ * (e.g. the worker) treat null as "skip and log".
181
+ *
182
+ * @param {string} id
183
+ * @param {Partial<VoiceNote>} patch
184
+ * @returns {VoiceNote|null}
185
+ */
186
+ export function updateVoiceNote(id, patch) {
187
+ if (!id || !patch || typeof patch !== 'object') return null;
188
+ const notes = loadIndex();
189
+ const note = notes.find((n) => n.id === id);
190
+ if (!note) return null;
191
+
192
+ Object.assign(note, patch, { updatedAtMs: Date.now() });
193
+ saveIndex(notes);
194
+ log.info('voice note updated', { id, fields: Object.keys(patch) });
195
+ return note;
196
+ }
197
+
171
198
  /**
172
199
  * @param {string} id
173
200
  * @returns {boolean}