@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
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* tests/voice-transcribe-worker.test.mjs
|
|
3
|
+
*
|
|
4
|
+
* v5.2 — Tests for the background transcription worker.
|
|
5
|
+
*
|
|
6
|
+
* The worker is a singleton (in-memory queue + module-level broadcast
|
|
7
|
+
* hook). Every test calls `reset()` at the top to drop any leftover
|
|
8
|
+
* queue, then stubs `transcribe` and `broadcast` via the exported
|
|
9
|
+
* setters so the test never touches the network or the WebSocket.
|
|
10
|
+
*
|
|
11
|
+
* Coverage:
|
|
12
|
+
* - FIFO ordering: notes are processed in the order they were enqueued,
|
|
13
|
+
* verified by tracking which audio buffer each `transcribe()` call
|
|
14
|
+
* sees.
|
|
15
|
+
* - Error isolation: a throw from `transcribe` for note N does NOT
|
|
16
|
+
* stop note N+1; the failing note's transcript stays null, the
|
|
17
|
+
* next one is persisted.
|
|
18
|
+
* - Broadcast on success: `broadcast({ type: 'voice:updated', ... })`
|
|
19
|
+
* fires exactly once per successful transcription with the right
|
|
20
|
+
* payload. Also: no broadcast on null transcript, no broadcast on
|
|
21
|
+
* already-transcribed notes, broadcast throws don't corrupt state.
|
|
22
|
+
* - Input guards: `enqueueTranscription` ignores bad ids.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { describe, it, beforeEach, afterEach } from 'node:test';
|
|
26
|
+
import assert from 'node:assert/strict';
|
|
27
|
+
import { tmpdir } from 'node:os';
|
|
28
|
+
import { join } from 'node:path';
|
|
29
|
+
import { mkdirSync, rmSync, writeFileSync, readFileSync } from 'node:fs';
|
|
30
|
+
|
|
31
|
+
const TEST_VAULT = join(
|
|
32
|
+
tmpdir(),
|
|
33
|
+
`bizar-voice-worker-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
const worker = await import('../src/server/workers/transcription-worker.mjs');
|
|
37
|
+
const voiceStore = await import('../src/server/voice-store.mjs');
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Save a voice note with a stable, unique audio body. The body is
|
|
41
|
+
* overwritten to a deterministic string so tests can identify "which
|
|
42
|
+
* note is this" by audio contents alone (the worker reads the file
|
|
43
|
+
* from disk and passes the buffer to `transcribe()`).
|
|
44
|
+
*/
|
|
45
|
+
async function saveNoteWithAudioFile(label) {
|
|
46
|
+
const audioBuffer = Buffer.from(`audio-${label}-${'x'.repeat(64)}`);
|
|
47
|
+
const saved = await voiceStore.saveVoiceNote({
|
|
48
|
+
audioBuffer,
|
|
49
|
+
transcript: null,
|
|
50
|
+
durationSec: 1,
|
|
51
|
+
vaultPath: TEST_VAULT,
|
|
52
|
+
});
|
|
53
|
+
writeFileSync(saved.audioPath, audioBuffer);
|
|
54
|
+
return saved;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Setup / teardown ────────────────────────────────────────────────────────
|
|
58
|
+
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
mkdirSync(TEST_VAULT, { recursive: true });
|
|
61
|
+
worker.reset();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
worker.reset();
|
|
66
|
+
try { rmSync(TEST_VAULT, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// ── Input guards ────────────────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
describe('enqueueTranscription input guards', () => {
|
|
72
|
+
it('ignores null / empty / non-string ids', () => {
|
|
73
|
+
worker.enqueueTranscription(null);
|
|
74
|
+
worker.enqueueTranscription('');
|
|
75
|
+
worker.enqueueTranscription(undefined);
|
|
76
|
+
worker.enqueueTranscription(42);
|
|
77
|
+
worker.enqueueTranscription({});
|
|
78
|
+
assert.strictEqual(worker.getQueueDepth(), 0);
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// ── FIFO order ──────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
describe('FIFO order', () => {
|
|
85
|
+
it('invokes transcribe in enqueue-order, one at a time', async () => {
|
|
86
|
+
const a = await saveNoteWithAudioFile('a');
|
|
87
|
+
const b = await saveNoteWithAudioFile('b');
|
|
88
|
+
const c = await saveNoteWithAudioFile('c');
|
|
89
|
+
|
|
90
|
+
/** @type {Buffer[]} */
|
|
91
|
+
const seen = [];
|
|
92
|
+
/** @type {string[]} */
|
|
93
|
+
const concurrent = [];
|
|
94
|
+
let inFlight = 0;
|
|
95
|
+
|
|
96
|
+
worker.setTranscribe(async (buf) => {
|
|
97
|
+
seen.push(buf);
|
|
98
|
+
inFlight += 1;
|
|
99
|
+
if (inFlight > 1) concurrent.push('overlap');
|
|
100
|
+
// Yield to mimic real Whisper latency, then return.
|
|
101
|
+
await new Promise((r) => setImmediate(r));
|
|
102
|
+
inFlight -= 1;
|
|
103
|
+
return `text-${seen.length}`;
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
worker.enqueueTranscription(a.id);
|
|
107
|
+
worker.enqueueTranscription(b.id);
|
|
108
|
+
worker.enqueueTranscription(c.id);
|
|
109
|
+
await worker.drainQueue();
|
|
110
|
+
|
|
111
|
+
assert.strictEqual(seen.length, 3, 'all three notes should have been attempted');
|
|
112
|
+
assert.deepStrictEqual(concurrent, [], 'no two transcribes may run concurrently');
|
|
113
|
+
|
|
114
|
+
assert.strictEqual(
|
|
115
|
+
seen[0].toString('utf8'),
|
|
116
|
+
readFileSync(a.audioPath).toString('utf8'),
|
|
117
|
+
'first enqueue must be processed first',
|
|
118
|
+
);
|
|
119
|
+
assert.strictEqual(
|
|
120
|
+
seen[1].toString('utf8'),
|
|
121
|
+
readFileSync(b.audioPath).toString('utf8'),
|
|
122
|
+
'second enqueue must be processed second',
|
|
123
|
+
);
|
|
124
|
+
assert.strictEqual(
|
|
125
|
+
seen[2].toString('utf8'),
|
|
126
|
+
readFileSync(c.audioPath).toString('utf8'),
|
|
127
|
+
'third enqueue must be processed third',
|
|
128
|
+
);
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// ── Error isolation ─────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
describe('error isolation', () => {
|
|
135
|
+
it('a throwing transcription does not block the next item', async () => {
|
|
136
|
+
const a = await saveNoteWithAudioFile('ea');
|
|
137
|
+
const b = await saveNoteWithAudioFile('eb');
|
|
138
|
+
const c = await saveNoteWithAudioFile('ec');
|
|
139
|
+
|
|
140
|
+
const failedBody = readFileSync(a.audioPath).toString('utf8');
|
|
141
|
+
let calls = 0;
|
|
142
|
+
worker.setTranscribe(async (buf) => {
|
|
143
|
+
calls += 1;
|
|
144
|
+
if (buf.toString('utf8') === failedBody) {
|
|
145
|
+
throw new Error('synthetic-whisper-failure');
|
|
146
|
+
}
|
|
147
|
+
return `ok-${calls}`;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
worker.enqueueTranscription(a.id);
|
|
151
|
+
worker.enqueueTranscription(b.id);
|
|
152
|
+
worker.enqueueTranscription(c.id);
|
|
153
|
+
await worker.drainQueue();
|
|
154
|
+
|
|
155
|
+
assert.strictEqual(calls, 3, 'all three notes must be attempted');
|
|
156
|
+
|
|
157
|
+
// The failing note retains null transcript.
|
|
158
|
+
assert.strictEqual(
|
|
159
|
+
voiceStore.getVoiceNote(a.id)?.transcript ?? null,
|
|
160
|
+
null,
|
|
161
|
+
'failing note keeps null transcript',
|
|
162
|
+
);
|
|
163
|
+
// The next two got transcripts.
|
|
164
|
+
assert.ok(voiceStore.getVoiceNote(b.id)?.transcript, 'b should be transcribed');
|
|
165
|
+
assert.ok(voiceStore.getVoiceNote(c.id)?.transcript, 'c should be transcribed');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('null return from transcribe does not block the next item', async () => {
|
|
169
|
+
const a = await saveNoteWithAudioFile('null-a');
|
|
170
|
+
const b = await saveNoteWithAudioFile('null-b');
|
|
171
|
+
|
|
172
|
+
let n = 0;
|
|
173
|
+
worker.setTranscribe(async () => {
|
|
174
|
+
n += 1;
|
|
175
|
+
return n === 1 ? null : 'second-ok';
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
worker.enqueueTranscription(a.id);
|
|
179
|
+
worker.enqueueTranscription(b.id);
|
|
180
|
+
await worker.drainQueue();
|
|
181
|
+
|
|
182
|
+
assert.strictEqual(voiceStore.getVoiceNote(a.id)?.transcript ?? null, null);
|
|
183
|
+
assert.strictEqual(voiceStore.getVoiceNote(b.id)?.transcript, 'second-ok');
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
// ── Broadcast on success ────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
describe('broadcast on success', () => {
|
|
190
|
+
it('sends a voice:updated event with the new transcript', async () => {
|
|
191
|
+
const a = await saveNoteWithAudioFile('ba');
|
|
192
|
+
|
|
193
|
+
const broadcasts = [];
|
|
194
|
+
worker.setBroadcast((msg) => broadcasts.push(msg));
|
|
195
|
+
|
|
196
|
+
worker.setTranscribe(async () => 'hello world');
|
|
197
|
+
worker.enqueueTranscription(a.id);
|
|
198
|
+
await worker.drainQueue();
|
|
199
|
+
|
|
200
|
+
assert.strictEqual(broadcasts.length, 1);
|
|
201
|
+
assert.strictEqual(broadcasts[0].type, 'voice:updated');
|
|
202
|
+
assert.strictEqual(broadcasts[0].noteId, a.id);
|
|
203
|
+
assert.deepStrictEqual(broadcasts[0].patch, { transcript: 'hello world' });
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
it('does not broadcast when transcribe returns null', async () => {
|
|
207
|
+
const a = await saveNoteWithAudioFile('bb');
|
|
208
|
+
|
|
209
|
+
const broadcasts = [];
|
|
210
|
+
worker.setBroadcast((msg) => broadcasts.push(msg));
|
|
211
|
+
|
|
212
|
+
worker.setTranscribe(async () => null);
|
|
213
|
+
worker.enqueueTranscription(a.id);
|
|
214
|
+
await worker.drainQueue();
|
|
215
|
+
|
|
216
|
+
assert.strictEqual(broadcasts.length, 0);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it('does not broadcast when the note is already transcribed', async () => {
|
|
220
|
+
const a = await saveNoteWithAudioFile('bc');
|
|
221
|
+
voiceStore.updateVoiceNote(a.id, { transcript: 'pre-existing' });
|
|
222
|
+
|
|
223
|
+
const broadcasts = [];
|
|
224
|
+
worker.setBroadcast((msg) => broadcasts.push(msg));
|
|
225
|
+
|
|
226
|
+
let called = 0;
|
|
227
|
+
worker.setTranscribe(async () => {
|
|
228
|
+
called += 1;
|
|
229
|
+
return 'should-not-be-called';
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
worker.enqueueTranscription(a.id);
|
|
233
|
+
await worker.drainQueue();
|
|
234
|
+
|
|
235
|
+
assert.strictEqual(called, 0, 'transcribe must not be called for already-done notes');
|
|
236
|
+
assert.strictEqual(broadcasts.length, 0);
|
|
237
|
+
assert.strictEqual(
|
|
238
|
+
voiceStore.getVoiceNote(a.id)?.transcript,
|
|
239
|
+
'pre-existing',
|
|
240
|
+
'transcript must NOT be overwritten',
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('still persists the transcript even if the broadcast throws', async () => {
|
|
245
|
+
const a = await saveNoteWithAudioFile('bd');
|
|
246
|
+
|
|
247
|
+
worker.setBroadcast(() => {
|
|
248
|
+
throw new Error('spy-throws');
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
worker.setTranscribe(async () => 'persisted-text');
|
|
252
|
+
worker.enqueueTranscription(a.id);
|
|
253
|
+
await worker.drainQueue();
|
|
254
|
+
|
|
255
|
+
assert.strictEqual(voiceStore.getVoiceNote(a.id)?.transcript, 'persisted-text');
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
// ── processOne direct API ───────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
describe('processOne', () => {
|
|
262
|
+
it('returns { skipped: "missing" } for unknown note id', async () => {
|
|
263
|
+
const result = await worker.processOne('does-not-exist');
|
|
264
|
+
assert.deepStrictEqual(result, { skipped: 'missing' });
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('returns { skipped: "already_transcribed" } if a transcript is set', async () => {
|
|
268
|
+
const a = await saveNoteWithAudioFile('pa');
|
|
269
|
+
voiceStore.updateVoiceNote(a.id, { transcript: 'done' });
|
|
270
|
+
|
|
271
|
+
let called = 0;
|
|
272
|
+
worker.setTranscribe(async () => {
|
|
273
|
+
called += 1;
|
|
274
|
+
return 'x';
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const result = await worker.processOne(a.id);
|
|
278
|
+
assert.strictEqual(result.skipped, 'already_transcribed');
|
|
279
|
+
assert.strictEqual(called, 0);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it('returns { skipped: "audio_missing" } when the audio file is gone', async () => {
|
|
283
|
+
const a = await saveNoteWithAudioFile('pm');
|
|
284
|
+
// Wipe the audio file from disk.
|
|
285
|
+
rmSync(a.audioPath);
|
|
286
|
+
|
|
287
|
+
const result = await worker.processOne(a.id);
|
|
288
|
+
assert.strictEqual(result.skipped, 'audio_missing');
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// ── drainQueue promise ─────────────────────────────────────────────────────
|
|
293
|
+
|
|
294
|
+
describe('drainQueue', () => {
|
|
295
|
+
it('resolves immediately when the queue is already empty', async () => {
|
|
296
|
+
const start = Date.now();
|
|
297
|
+
await worker.drainQueue();
|
|
298
|
+
const elapsed = Date.now() - start;
|
|
299
|
+
assert.ok(elapsed < 50, `drainQueue() should resolve immediately when idle (took ${elapsed}ms)`);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('resolves once all enqueued work has finished', async () => {
|
|
303
|
+
const a = await saveNoteWithAudioFile('da');
|
|
304
|
+
const b = await saveNoteWithAudioFile('db');
|
|
305
|
+
|
|
306
|
+
worker.setTranscribe(async () => 'ready');
|
|
307
|
+
worker.enqueueTranscription(a.id);
|
|
308
|
+
worker.enqueueTranscription(b.id);
|
|
309
|
+
|
|
310
|
+
await worker.drainQueue();
|
|
311
|
+
|
|
312
|
+
assert.strictEqual(worker.getQueueDepth(), 0);
|
|
313
|
+
assert.ok(voiceStore.getVoiceNote(a.id)?.transcript);
|
|
314
|
+
assert.ok(voiceStore.getVoiceNote(b.id)?.transcript);
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// ── startTranscriptionWorker wiring ─────────────────────────────────────────
|
|
319
|
+
|
|
320
|
+
describe('startTranscriptionWorker', () => {
|
|
321
|
+
it('installs the broadcast hook and starts a polling interval (verified by behaviour)', async () => {
|
|
322
|
+
const a = await saveNoteWithAudioFile('ws');
|
|
323
|
+
|
|
324
|
+
/** @type {Array<{type: string, noteId: string}>} */
|
|
325
|
+
const broadcasts = [];
|
|
326
|
+
worker.startTranscriptionWorker({
|
|
327
|
+
broadcast: (msg) => broadcasts.push(msg),
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// After startTranscriptionWorker, enqueuing must route through the
|
|
331
|
+
// installed broadcast hook.
|
|
332
|
+
worker.setTranscribe(async () => 'broadcast-via-start');
|
|
333
|
+
worker.enqueueTranscription(a.id);
|
|
334
|
+
await worker.drainQueue();
|
|
335
|
+
|
|
336
|
+
assert.strictEqual(broadcasts.length, 1);
|
|
337
|
+
assert.strictEqual(broadcasts[0].type, 'voice:updated');
|
|
338
|
+
assert.strictEqual(broadcasts[0].noteId, a.id);
|
|
339
|
+
|
|
340
|
+
// Stop the interval so the test process can exit cleanly.
|
|
341
|
+
worker.reset();
|
|
342
|
+
});
|
|
343
|
+
});
|
package/cli/bin.mjs
CHANGED
|
@@ -102,6 +102,7 @@ function showHelp() {
|
|
|
102
102
|
memory <subcommand> Manage project memory (Bizar Memory Service)
|
|
103
103
|
headroom <subcommand> Manage Headroom context compression
|
|
104
104
|
minimax <subcommand> Manage MiniMax Token Plan integration
|
|
105
|
+
tailscale <subcommand> Manage Tailscale integration (auth, serve, status)
|
|
105
106
|
mod <subcommand> Manage mods (install/upgrade/list via the dashboard API)
|
|
106
107
|
usage Show compact usage analytics summary (24h rolling)
|
|
107
108
|
doctor Check the BizarHarness install for health issues
|
|
@@ -257,6 +258,19 @@ async function main() {
|
|
|
257
258
|
break;
|
|
258
259
|
}
|
|
259
260
|
|
|
261
|
+
case 'tailscale': {
|
|
262
|
+
const mod = await importCommand('tailscale');
|
|
263
|
+
if (!mod) {
|
|
264
|
+
console.error(chalk.red(` ✗ Could not load tailscale command module`));
|
|
265
|
+
process.exit(EXIT_ERROR);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
dbg('loaded command module:', 'tailscale');
|
|
269
|
+
await mod.run(cmd, cmdArgs, isHelpRequest);
|
|
270
|
+
dbg('command returned:', cmd);
|
|
271
|
+
break;
|
|
272
|
+
}
|
|
273
|
+
|
|
260
274
|
case 'headroom': {
|
|
261
275
|
const mod = await importCommand('headroom');
|
|
262
276
|
if (!mod) {
|
package/cli/commands/dash.mjs
CHANGED
|
@@ -127,7 +127,24 @@ export async function runDash(dashArgs) {
|
|
|
127
127
|
}
|
|
128
128
|
|
|
129
129
|
switch (sub) {
|
|
130
|
-
case 'start':
|
|
130
|
+
case 'start': {
|
|
131
|
+
// v5.2 — Auto-setup Tailscale serve if auth key is available
|
|
132
|
+
const usePort = subOpts.port || 4321;
|
|
133
|
+
if (process.env.TAILSCALE_AUTHKEY || process.env.BIZAR_TAILSCALE_AUTOSETUP === '1') {
|
|
134
|
+
try {
|
|
135
|
+
const { ensureTailscaleAuth, setupTailscaleServe } = await import('./tailscale.mjs');
|
|
136
|
+
const authResult = await ensureTailscaleAuth();
|
|
137
|
+
if (authResult.authenticated) {
|
|
138
|
+
const serveResult = await setupTailscaleServe({ dashboardPort: usePort });
|
|
139
|
+
if (serveResult.ok) {
|
|
140
|
+
console.log(chalk.green(` ✓ Tailscale serve: ${serveResult.url}`));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
} catch (err) {
|
|
144
|
+
// Non-fatal: Tailscale setup errors don't block dashboard start
|
|
145
|
+
console.log(chalk.dim(` ⚠ Tailscale auto-setup skipped: ${err.message}`));
|
|
146
|
+
}
|
|
147
|
+
}
|
|
131
148
|
if (subOpts.bg) {
|
|
132
149
|
// Pass --port, --host, --bind through to the child process
|
|
133
150
|
const bgArgs = ['start', ...(subOpts.subArgs || [])];
|
|
@@ -138,6 +155,7 @@ export async function runDash(dashArgs) {
|
|
|
138
155
|
await dashModule.start(subOpts);
|
|
139
156
|
}
|
|
140
157
|
break;
|
|
158
|
+
}
|
|
141
159
|
case 'stop':
|
|
142
160
|
await dashModule.stop(subOpts);
|
|
143
161
|
break;
|
package/cli/commands/service.mjs
CHANGED
|
@@ -22,6 +22,8 @@ function getBizarConfigDir() {
|
|
|
22
22
|
|
|
23
23
|
export function showServiceHelp() {
|
|
24
24
|
const bizarConfigDir = getBizarConfigDir();
|
|
25
|
+
const tsAuthkey = process.env.TAILSCALE_AUTHKEY ? '(set)' : '(not set)';
|
|
26
|
+
const tsAuto = process.env.BIZAR_TAILSCALE_AUTOSETUP === '1' ? 'enabled' : 'disabled';
|
|
25
27
|
console.log(`
|
|
26
28
|
bizar service — Manage the background service daemon
|
|
27
29
|
|
|
@@ -47,6 +49,11 @@ export function showServiceHelp() {
|
|
|
47
49
|
("BizarDashboardService", ONSTART, HIGHEST) on Windows. After
|
|
48
50
|
install, a normal user does not need to run \`bizar service start\`
|
|
49
51
|
for the dashboard background process — the OS does it at login.
|
|
52
|
+
|
|
53
|
+
Tailscale Integration (v5.2):
|
|
54
|
+
TAILSCALE_AUTHKEY=${tsAuthkey}
|
|
55
|
+
BIZAR_TAILSCALE_AUTOSETUP=${tsAuto}
|
|
56
|
+
Run \`bizar tailscale --help\` for Tailscale management.
|
|
50
57
|
`);
|
|
51
58
|
}
|
|
52
59
|
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* cli/commands/tailscale.mjs
|
|
3
|
+
*
|
|
4
|
+
* v5.2 — Tailscale auth key integration for the dashboard.
|
|
5
|
+
*
|
|
6
|
+
* When TAILSCALE_AUTHKEY is set (or BIZAR_TAILSCALE_AUTOSETUP=1), the
|
|
7
|
+
* dashboard auto-configures `tailscale serve` on startup so the user
|
|
8
|
+
* doesn't need to run `sudo tailscale serve ...` manually.
|
|
9
|
+
*/
|
|
10
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
11
|
+
import { existsSync, writeFileSync, readFileSync } from 'node:fs';
|
|
12
|
+
import { join, dirname } from 'node:path';
|
|
13
|
+
import { homedir } from 'node:os';
|
|
14
|
+
import chalk from 'chalk';
|
|
15
|
+
|
|
16
|
+
const TS_AUTHKEY = process.env.TAILSCALE_AUTHKEY;
|
|
17
|
+
const TS_STATE_DIR = process.env.TAILSCALE_STATE_DIR || join(homedir(), '.local', 'share', 'bizar', 'tailscale');
|
|
18
|
+
const TS_SERVE_CONFIG = join(TS_STATE_DIR, 'serve.json');
|
|
19
|
+
|
|
20
|
+
function ensureStateDir() {
|
|
21
|
+
const { mkdirSync } = require('node:fs');
|
|
22
|
+
try {
|
|
23
|
+
mkdirSync(TS_STATE_DIR, { recursive: true });
|
|
24
|
+
} catch { /* ignore */ }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Check if tailscale binary exists and is executable.
|
|
29
|
+
*/
|
|
30
|
+
export function isTailscaleInstalled() {
|
|
31
|
+
try {
|
|
32
|
+
execFileSync('tailscale', ['version'], { encoding: 'utf8', timeout: 5000 });
|
|
33
|
+
return true;
|
|
34
|
+
} catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check if already authenticated with Tailscale.
|
|
41
|
+
* Returns { authenticated, hostname, backendState }.
|
|
42
|
+
*/
|
|
43
|
+
export async function ensureTailscaleAuth() {
|
|
44
|
+
if (!isTailscaleInstalled()) {
|
|
45
|
+
return { authenticated: false, message: 'Tailscale not installed' };
|
|
46
|
+
}
|
|
47
|
+
try {
|
|
48
|
+
const result = execFileSync('tailscale', ['status', '--json'], { encoding: 'utf8', timeout: 5000 });
|
|
49
|
+
const status = JSON.parse(result);
|
|
50
|
+
return {
|
|
51
|
+
authenticated: !!status.Self?.Online,
|
|
52
|
+
hostname: (status.Self?.DNSName || '').split('.')[0],
|
|
53
|
+
backendState: status.BackendState || 'unknown',
|
|
54
|
+
};
|
|
55
|
+
} catch {
|
|
56
|
+
if (!TS_AUTHKEY) {
|
|
57
|
+
return { authenticated: false, message: 'No TAILSCALE_AUTHKEY env var set' };
|
|
58
|
+
}
|
|
59
|
+
// Authenticate with auth key
|
|
60
|
+
console.log(chalk.dim(' Authenticating with Tailscale...'));
|
|
61
|
+
try {
|
|
62
|
+
execFileSync('tailscale', ['up', '--authkey=' + TS_AUTHKEY, '--hostname=bizar-dash'], { encoding: 'utf8', timeout: 60000 });
|
|
63
|
+
return { authenticated: true, hostname: 'bizar-dash' };
|
|
64
|
+
} catch (err) {
|
|
65
|
+
return { authenticated: false, message: 'Auth failed: ' + (err.message || String(err)) };
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Set up tailscale serve for the dashboard.
|
|
72
|
+
*/
|
|
73
|
+
export async function setupTailscaleServe({ dashboardPort = 4097, path = '/', https = 443 }) {
|
|
74
|
+
const authResult = await ensureTailscaleAuth();
|
|
75
|
+
if (!authResult.authenticated) {
|
|
76
|
+
console.error(chalk.red(' ✗ Tailscale not authenticated'));
|
|
77
|
+
return { ok: false, reason: 'not_authenticated' };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Remove existing serve config
|
|
81
|
+
try {
|
|
82
|
+
execFileSync('tailscale', ['serve', '--https=443', 'off'], { encoding: 'utf8', timeout: 5000 });
|
|
83
|
+
} catch { /* ignore — might not have existing config */ }
|
|
84
|
+
|
|
85
|
+
// Set up new serve config
|
|
86
|
+
try {
|
|
87
|
+
execFileSync('tailscale', [
|
|
88
|
+
'serve', '--bg', `--https=${https}`, `--set-path=${path}`,
|
|
89
|
+
`http://localhost:${dashboardPort}`,
|
|
90
|
+
], { encoding: 'utf8', timeout: 30000 });
|
|
91
|
+
} catch (err) {
|
|
92
|
+
return { ok: false, reason: 'serve_setup_failed', error: err.message };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Get the public URL
|
|
96
|
+
try {
|
|
97
|
+
const statusResult = execFileSync('tailscale', ['status', '--json'], { encoding: 'utf8', timeout: 5000 });
|
|
98
|
+
const status = JSON.parse(statusResult);
|
|
99
|
+
const url = `https://${(status.Self?.DNSName || '').split('.')[0]}`;
|
|
100
|
+
|
|
101
|
+
// Save config
|
|
102
|
+
ensureStateDir();
|
|
103
|
+
writeFileSync(TS_SERVE_CONFIG, JSON.stringify({
|
|
104
|
+
url,
|
|
105
|
+
port: dashboardPort,
|
|
106
|
+
https,
|
|
107
|
+
path,
|
|
108
|
+
setAt: new Date().toISOString(),
|
|
109
|
+
}, null, 2));
|
|
110
|
+
|
|
111
|
+
return { ok: true, url };
|
|
112
|
+
} catch (err) {
|
|
113
|
+
return { ok: false, reason: 'status_check_failed', error: err.message };
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Remove tailscale serve configuration.
|
|
119
|
+
*/
|
|
120
|
+
export function unsetupTailscaleServe() {
|
|
121
|
+
try {
|
|
122
|
+
execFileSync('tailscale', ['serve', 'reset'], { encoding: 'utf8', timeout: 10000 });
|
|
123
|
+
// Remove saved config
|
|
124
|
+
if (existsSync(TS_SERVE_CONFIG)) {
|
|
125
|
+
const { unlinkSync } = require('node:fs');
|
|
126
|
+
try { unlinkSync(TS_SERVE_CONFIG); } catch { /* ignore */ }
|
|
127
|
+
}
|
|
128
|
+
return { ok: true };
|
|
129
|
+
} catch (err) {
|
|
130
|
+
return { ok: false, error: err.message };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Print the saved serve URL (if any).
|
|
136
|
+
*/
|
|
137
|
+
export function getServeUrl() {
|
|
138
|
+
try {
|
|
139
|
+
if (existsSync(TS_SERVE_CONFIG)) {
|
|
140
|
+
const cfg = JSON.parse(readFileSync(TS_SERVE_CONFIG, 'utf8'));
|
|
141
|
+
return cfg.url || null;
|
|
142
|
+
}
|
|
143
|
+
} catch { /* ignore */ }
|
|
144
|
+
return null;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function showTailscaleStatus() {
|
|
148
|
+
if (!isTailscaleInstalled()) {
|
|
149
|
+
console.error(chalk.red(' ✗ tailscale not found or not installed'));
|
|
150
|
+
console.error(' Install: https://tailscale.com/download');
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
const result = execFileSync('tailscale', ['status'], { encoding: 'utf8', timeout: 5000 });
|
|
155
|
+
console.log(result);
|
|
156
|
+
|
|
157
|
+
// Also show serve URL if configured
|
|
158
|
+
const url = getServeUrl();
|
|
159
|
+
if (url) {
|
|
160
|
+
console.log(chalk.dim(` Serve URL: ${url}`));
|
|
161
|
+
}
|
|
162
|
+
} catch (err) {
|
|
163
|
+
console.error(chalk.red(' ✗ Failed to get tailscale status'));
|
|
164
|
+
console.error(' ', err.message);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function showTailscaleHelp() {
|
|
169
|
+
console.log(`
|
|
170
|
+
bizar tailscale — Manage Tailscale integration for the dashboard
|
|
171
|
+
|
|
172
|
+
Usage:
|
|
173
|
+
bizar tailscale status Show Tailscale auth + serve status
|
|
174
|
+
bizar tailscale auth Authenticate with auth key (from env TAILSCALE_AUTHKEY)
|
|
175
|
+
bizar tailscale serve Set up tailscale serve for the dashboard
|
|
176
|
+
bizar tailscale unserve Remove tailscale serve
|
|
177
|
+
bizar tailscale url Print the public dashboard URL
|
|
178
|
+
|
|
179
|
+
Env vars:
|
|
180
|
+
TAILSCALE_AUTHKEY Auth key for non-interactive auth
|
|
181
|
+
TAILSCALE_STATE_DIR Where to store Tailscale state (default ~/.local/share/bizar/tailscale)
|
|
182
|
+
BIZAR_TAILSCALE_AUTOSETUP=1 Auto-setup tailscale serve when running 'bizar dash start'
|
|
183
|
+
|
|
184
|
+
Examples:
|
|
185
|
+
TAILSCALE_AUTHKEY=tskey-... bizar tailscale auth
|
|
186
|
+
TAILSCALE_AUTHKEY=tskey-... bizar tailscale serve
|
|
187
|
+
bizar tailscale unserve
|
|
188
|
+
`);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Main entry point for the `bizar tailscale` CLI command.
|
|
193
|
+
*/
|
|
194
|
+
export async function run(name, args, isHelpRequest) {
|
|
195
|
+
if (isHelpRequest || args.length === 0 || args[0] === 'help') {
|
|
196
|
+
showTailscaleHelp();
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const sub = args[0];
|
|
201
|
+
|
|
202
|
+
switch (sub) {
|
|
203
|
+
case 'status': {
|
|
204
|
+
showTailscaleStatus();
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
case 'auth': {
|
|
208
|
+
const result = await ensureTailscaleAuth();
|
|
209
|
+
if (result.authenticated) {
|
|
210
|
+
console.log(chalk.green(` ✓ Authenticated as ${result.hostname}`));
|
|
211
|
+
} else {
|
|
212
|
+
console.error(chalk.red(` ✗ Not authenticated: ${result.message}`));
|
|
213
|
+
if (!TS_AUTHKEY) {
|
|
214
|
+
console.error(chalk.dim(' Set TAILSCALE_AUTHKEY env var and try again'));
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
break;
|
|
218
|
+
}
|
|
219
|
+
case 'serve': {
|
|
220
|
+
const result = await setupTailscaleServe({ dashboardPort: 4321 });
|
|
221
|
+
if (result.ok) {
|
|
222
|
+
console.log(chalk.green(` ✓ Tailscale serve: ${result.url}`));
|
|
223
|
+
} else {
|
|
224
|
+
console.error(chalk.red(` ✗ Serve setup failed: ${result.reason}`));
|
|
225
|
+
}
|
|
226
|
+
break;
|
|
227
|
+
}
|
|
228
|
+
case 'unset': {
|
|
229
|
+
const result = unsetupTailscaleServe();
|
|
230
|
+
if (result.ok) {
|
|
231
|
+
console.log(chalk.green(' ✓ Tailscale serve removed'));
|
|
232
|
+
} else {
|
|
233
|
+
console.error(chalk.red(` ✗ ${result.error}`));
|
|
234
|
+
}
|
|
235
|
+
break;
|
|
236
|
+
}
|
|
237
|
+
case 'url': {
|
|
238
|
+
const url = getServeUrl();
|
|
239
|
+
if (url) {
|
|
240
|
+
console.log(url);
|
|
241
|
+
} else {
|
|
242
|
+
console.error(chalk.red(' ✗ No serve URL configured'));
|
|
243
|
+
}
|
|
244
|
+
break;
|
|
245
|
+
}
|
|
246
|
+
default: {
|
|
247
|
+
console.error(chalk.red(` ✗ Unknown subcommand: ${sub}`));
|
|
248
|
+
showTailscaleHelp();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@polderlabs/bizar",
|
|
3
|
-
"version": "5.0
|
|
3
|
+
"version": "5.2.0",
|
|
4
4
|
"description": "Norse-pantheon multi-agent system for opencode — 13 agents across 4 cost tiers with cost-aware routing, plans, and a configurable agent harness. v4 ships as a single npm package bundling the dashboard server, opencode plugin, and typed SDK.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|