@polderlabs/bizar-plugin 0.6.1 → 0.8.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/index.ts +174 -48
- package/package.json +2 -1
- package/src/background-state.ts +38 -2
- package/src/background.ts +13 -0
- package/src/commands.ts +28 -11
- package/src/dashboard-client.ts +235 -0
- package/src/event-stream.ts +32 -0
- package/src/opencode-runner.ts +362 -0
- package/src/tools/bg-spawn.ts +161 -124
- package/tests/config.test.ts +2 -2
- package/tests/dashboard-client.test.ts +159 -0
package/src/tools/bg-spawn.ts
CHANGED
|
@@ -1,84 +1,86 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* bg-spawn.ts
|
|
2
|
+
* plugins/bizar/src/tools/bg-spawn.ts
|
|
3
3
|
*
|
|
4
|
-
* `bizar_spawn_background` tool
|
|
4
|
+
* v0.8.0 — `bizar_spawn_background` tool, refactored to spawn one
|
|
5
|
+
* `opencode run` subprocess per agent (instead of POSTing to a
|
|
6
|
+
* passive `opencode serve` HTTP API). See opencode-runner.ts for the
|
|
7
|
+
* spawning implementation; see ../opencode-runner.ts for the
|
|
8
|
+
* rationale and the wire format we parse.
|
|
5
9
|
*
|
|
6
|
-
*
|
|
7
|
-
* (MEDIUM-26). The caller's `ctx.agent` is the source of truth.
|
|
8
|
-
*
|
|
9
|
-
* Args:
|
|
10
|
-
* - `agent: string` — the agent to spawn (e.g. "mimir", "thor", "tyr").
|
|
11
|
-
* - `prompt: string` — the user prompt; sent verbatim via
|
|
12
|
-
* `parts: [{ type: "text", text: prompt }]`.
|
|
13
|
-
* - `model?: string` — `"<providerID>/<modelID>"` override (LOW-34).
|
|
14
|
-
* - `timeoutMs?: number` — collect-time timeout, clamped to [1000, 1800000].
|
|
15
|
-
*
|
|
16
|
-
* Returns on success:
|
|
17
|
-
* `{ instanceId, sessionId, status: "pending" }`
|
|
18
|
-
*
|
|
19
|
-
* The "Track BEFORE HTTP" invariant (spec §2.2 / HIGH-21):
|
|
20
|
-
* 1. Validate inputs.
|
|
21
|
-
* 2. Generate `instanceId` and `messageID`.
|
|
22
|
-
* 3. `InstanceManager.add()` — atomic cap check + insert; map entry
|
|
23
|
-
* exists BEFORE any HTTP call.
|
|
24
|
-
* 4. `POST /session` — returns the opencode `sessionId`.
|
|
25
|
-
* 5. `POST /session/{id}/prompt_async` — fire the prompt.
|
|
26
|
-
* 6. On either HTTP failure, mark the instance `failed` and return the
|
|
27
|
-
* error. The map is never left in a half-state.
|
|
10
|
+
* Spec §1, §6.3, §7.1.
|
|
28
11
|
*/
|
|
29
|
-
|
|
30
12
|
import { tool } from "@opencode-ai/plugin";
|
|
31
13
|
import { z } from "zod";
|
|
32
14
|
|
|
33
|
-
import type { InstanceManager } from "../background.js";
|
|
34
15
|
import { generateInstanceId, generateMessageId } from "../background.js";
|
|
35
|
-
import type {
|
|
16
|
+
import type { InstanceManager } from "../background.js";
|
|
36
17
|
import type { Logger } from "../logger.js";
|
|
18
|
+
import { spawnAgent } from "../opencode-runner.js";
|
|
19
|
+
import { resolve as pathResolve } from "node:path";
|
|
20
|
+
import { homedir } from "node:os";
|
|
37
21
|
|
|
38
|
-
|
|
22
|
+
/** Spec §7.3: `timeoutMs` clamped to [1000, 1800000] (1s..30min). */
|
|
23
|
+
const TIMEOUT_MIN_MS = 1000;
|
|
24
|
+
const TIMEOUT_MAX_MS = 1_800_000;
|
|
25
|
+
const TIMEOUT_DEFAULT_MS = 300_000;
|
|
26
|
+
|
|
27
|
+
/** Mirrors plugins/bizar/src/http-client.ts ModelOverride. */
|
|
28
|
+
export interface ModelOverride {
|
|
29
|
+
providerID: string;
|
|
30
|
+
modelID: string;
|
|
31
|
+
}
|
|
39
32
|
|
|
40
33
|
/**
|
|
41
|
-
* Parse
|
|
42
|
-
*
|
|
43
|
-
* - "model" → null (no slash)
|
|
44
|
-
* - "a/b/c" → null (multiple slashes)
|
|
45
|
-
* - "provider/" → null (empty half)
|
|
46
|
-
* - "/model" → null (empty half)
|
|
34
|
+
* Parse "providerID/modelID" into a ModelOverride. Returns null when
|
|
35
|
+
* the input is empty (use the agent's default) or malformed.
|
|
47
36
|
*/
|
|
48
|
-
function parseModel(raw: string): ModelOverride | null {
|
|
49
|
-
if (raw ===
|
|
50
|
-
const
|
|
51
|
-
if (
|
|
52
|
-
const
|
|
53
|
-
|
|
37
|
+
function parseModel(raw: string | undefined): ModelOverride | null {
|
|
38
|
+
if (raw === undefined || raw === null) return null;
|
|
39
|
+
const trimmed = String(raw).trim();
|
|
40
|
+
if (trimmed === "") return null;
|
|
41
|
+
const idx = trimmed.indexOf("/");
|
|
42
|
+
if (idx <= 0 || idx === trimmed.length - 1) return null;
|
|
43
|
+
const providerID = trimmed.slice(0, idx).trim();
|
|
44
|
+
const modelID = trimmed.slice(idx + 1).trim();
|
|
54
45
|
if (!providerID || !modelID) return null;
|
|
55
46
|
return { providerID, modelID };
|
|
56
47
|
}
|
|
57
48
|
|
|
58
|
-
// --- Tool factory ---------------------------------------------------------
|
|
59
|
-
|
|
60
|
-
/** Spec §7.3: `timeoutMs` clamped to [1000, 1800000] (1s..30min). */
|
|
61
|
-
const TIMEOUT_MIN_MS = 1000;
|
|
62
|
-
const TIMEOUT_MAX_MS = 1_800_000;
|
|
63
|
-
const TIMEOUT_DEFAULT_MS = 300_000;
|
|
64
|
-
|
|
65
49
|
export interface BgSpawnDeps {
|
|
66
50
|
instanceManager: InstanceManager;
|
|
67
|
-
http: HttpClient;
|
|
68
51
|
worktree: string;
|
|
69
52
|
logger: Logger;
|
|
70
53
|
}
|
|
71
54
|
|
|
72
55
|
/**
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
56
|
+
* Compute the LogWriter's actual log path for a given instanceId.
|
|
57
|
+
* The plugin's `LogWriter` (../report.ts:147) writes to
|
|
58
|
+
* `${logDir}/${sessionId}.log` where `logDir` defaults to
|
|
59
|
+
* `~/.cache/bizar/logs` (overridable via the `BIZAR_LOG_DIR` env
|
|
60
|
+
* var). The instanceId is what we know at spawn time — the opencode
|
|
61
|
+
* sessionId is generated later by the subprocess and we don't
|
|
62
|
+
* pre-allocate it. We therefore use the instanceId as the log file
|
|
63
|
+
* name and let the runner append to it.
|
|
64
|
+
*/
|
|
65
|
+
function buildLogPath(instanceId: string): string {
|
|
66
|
+
const logDir = process.env.BIZAR_LOG_DIR || pathResolve(homedir(), ".cache", "bizar", "logs");
|
|
67
|
+
return pathResolve(logDir, `${instanceId}.log`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Build the `bizar_spawn_background` tool. The plugin wires the
|
|
72
|
+
* result into `Hooks.tool`. The `deps` closure carries the
|
|
73
|
+
* per-process state (InstanceManager, worktree, logger).
|
|
76
74
|
*/
|
|
77
75
|
export function createBgSpawnTool(deps: BgSpawnDeps) {
|
|
78
76
|
return tool({
|
|
79
77
|
description:
|
|
80
|
-
"Spawn a background agent that runs asynchronously
|
|
81
|
-
"
|
|
78
|
+
"Spawn a background agent that runs asynchronously as a separate `opencode run` subprocess. " +
|
|
79
|
+
"Only Odin may call this tool. " +
|
|
80
|
+
"Returns an instanceId immediately (sub-second), then the agent runs to completion in the background. " +
|
|
81
|
+
"Use `bizar_status` / `bizar_collect` / `bizar_kill` to manage the instance. " +
|
|
82
|
+
"Use `bizar_bg_view` (CLI) to watch all running agents in a tmux split window. " +
|
|
83
|
+
"IMPORTANT: do NOT block waiting for the agent. Return control to the user right after spawning.",
|
|
82
84
|
args: {
|
|
83
85
|
agent: z.string().min(1).describe("Agent name to spawn (e.g. 'mimir', 'thor', 'tyr')."),
|
|
84
86
|
prompt: z.string().min(1).describe("User prompt for the background session."),
|
|
@@ -133,7 +135,7 @@ export function createBgSpawnTool(deps: BgSpawnDeps) {
|
|
|
133
135
|
if (m === null) {
|
|
134
136
|
return {
|
|
135
137
|
output: JSON.stringify({
|
|
136
|
-
error: `model must be in "providerID/modelID" format (e.g. "
|
|
138
|
+
error: `model must be in "providerID/modelID" format (e.g. "opencode/deepseek-v4-flash-free"). Omit to use the agent's default.`,
|
|
137
139
|
}),
|
|
138
140
|
};
|
|
139
141
|
}
|
|
@@ -151,11 +153,14 @@ export function createBgSpawnTool(deps: BgSpawnDeps) {
|
|
|
151
153
|
}
|
|
152
154
|
const timeoutMs = requested;
|
|
153
155
|
|
|
154
|
-
// 4. Generate the instanceId and seed the manager (track BEFORE
|
|
156
|
+
// 4. Generate the instanceId and seed the manager (track BEFORE
|
|
157
|
+
// the subprocess starts, so a fast-exiting agent is still
|
|
158
|
+
// queryable via bizar_status).
|
|
155
159
|
const instanceId = generateInstanceId();
|
|
160
|
+
const logPath = buildLogPath(instanceId);
|
|
156
161
|
const draft = {
|
|
157
162
|
instanceId,
|
|
158
|
-
sessionId: "", // filled in
|
|
163
|
+
sessionId: "", // filled in once the opencode run reports it
|
|
159
164
|
agent: args.agent,
|
|
160
165
|
model: modelOverride
|
|
161
166
|
? `${modelOverride.providerID}/${modelOverride.modelID}`
|
|
@@ -163,7 +168,7 @@ export function createBgSpawnTool(deps: BgSpawnDeps) {
|
|
|
163
168
|
promptPreview: args.prompt.slice(0, 200),
|
|
164
169
|
prompt: args.prompt, // store full prompt for restart support
|
|
165
170
|
parentAgent: ctx.agent,
|
|
166
|
-
logPath
|
|
171
|
+
logPath,
|
|
167
172
|
timeoutMs,
|
|
168
173
|
toolCallCount: 0,
|
|
169
174
|
// v0.5.5 — persistent auto-restart
|
|
@@ -180,25 +185,30 @@ export function createBgSpawnTool(deps: BgSpawnDeps) {
|
|
|
180
185
|
};
|
|
181
186
|
}
|
|
182
187
|
|
|
183
|
-
// 5.
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
+
// 5. Spawn the opencode run subprocess. The runner returns
|
|
189
|
+
// when the opencode child has reported its session id in
|
|
190
|
+
// the structured log stream (typically <500ms).
|
|
191
|
+
const messageID = generateMessageId();
|
|
192
|
+
let spawnRes: Awaited<ReturnType<typeof spawnAgent>>;
|
|
193
|
+
try {
|
|
194
|
+
spawnRes = await spawnAgent({
|
|
195
|
+
prompt: args.prompt,
|
|
188
196
|
agent: args.agent,
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
197
|
+
model: modelOverride,
|
|
198
|
+
worktree: deps.worktree,
|
|
199
|
+
logPath,
|
|
200
|
+
title: `bgr:${args.agent}:${instanceId}:${messageID}`,
|
|
201
|
+
});
|
|
202
|
+
} catch (err: unknown) {
|
|
203
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
194
204
|
await deps.instanceManager.update(instanceId, {
|
|
195
205
|
status: "failed",
|
|
196
|
-
error: `
|
|
206
|
+
error: `spawnAgent threw: ${msg}`,
|
|
197
207
|
completedAt: Date.now(),
|
|
198
208
|
});
|
|
199
209
|
return {
|
|
200
210
|
output: JSON.stringify({
|
|
201
|
-
error: `spawn
|
|
211
|
+
error: `spawn crashed: ${msg}`,
|
|
202
212
|
instanceId,
|
|
203
213
|
sessionId: null,
|
|
204
214
|
status: "failed",
|
|
@@ -206,78 +216,105 @@ export function createBgSpawnTool(deps: BgSpawnDeps) {
|
|
|
206
216
|
};
|
|
207
217
|
}
|
|
208
218
|
|
|
209
|
-
|
|
210
|
-
await deps.instanceManager.update(instanceId, {
|
|
211
|
-
sessionId: sessionRes.value.id,
|
|
212
|
-
status: "running",
|
|
213
|
-
});
|
|
214
|
-
|
|
215
|
-
// 6b. BUGFIX (v0.5.1): Now that the real sessionId is known,
|
|
216
|
-
// attach the SSE event handler for this instance. The track-BEFORE-
|
|
217
|
-
// HTTP invariant is preserved (instance is in the map from step 4),
|
|
218
|
-
// but the per-session event subscription is deferred to here so it
|
|
219
|
-
// can be registered against the real sessionId rather than "".
|
|
220
|
-
// Re-read the instance so the SSE handler sees the updated sessionId.
|
|
221
|
-
const freshInstance = await deps.instanceManager.get(instanceId);
|
|
222
|
-
if (freshInstance) {
|
|
223
|
-
try {
|
|
224
|
-
deps.instanceManager.attachEventHandler(freshInstance);
|
|
225
|
-
} catch (err: unknown) {
|
|
226
|
-
// Event handler attachment is best-effort. If it fails (e.g. the
|
|
227
|
-
// SSE stream is disconnected) the instance is still tracked and
|
|
228
|
-
// can be re-attached later via a reconnect.
|
|
229
|
-
deps.logger.warn(
|
|
230
|
-
`bizar: attachEventHandler failed for ${instanceId}: ${
|
|
231
|
-
err instanceof Error ? err.message : String(err)
|
|
232
|
-
}`,
|
|
233
|
-
);
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
// 7. POST /session/{id}/prompt_async.
|
|
238
|
-
const messageID = generateMessageId();
|
|
239
|
-
const sendRes = await deps.http.sendPrompt(
|
|
240
|
-
{
|
|
241
|
-
sessionId: sessionRes.value.id,
|
|
242
|
-
messageID,
|
|
243
|
-
agent: args.agent,
|
|
244
|
-
...(modelOverride ? { model: modelOverride } : {}),
|
|
245
|
-
parts: [{ type: "text", text: args.prompt }],
|
|
246
|
-
},
|
|
247
|
-
deps.worktree,
|
|
248
|
-
);
|
|
249
|
-
if (!sendRes.ok) {
|
|
219
|
+
if (!spawnRes.ok || !spawnRes.sessionId) {
|
|
250
220
|
await deps.instanceManager.update(instanceId, {
|
|
251
221
|
status: "failed",
|
|
252
|
-
error:
|
|
222
|
+
error: spawnRes.error || "opencode run failed before reporting session id",
|
|
253
223
|
completedAt: Date.now(),
|
|
254
224
|
});
|
|
255
225
|
return {
|
|
256
226
|
output: JSON.stringify({
|
|
257
|
-
error: `spawn failed: ${
|
|
227
|
+
error: `spawn failed: ${spawnRes.error || "no session id"}`,
|
|
258
228
|
instanceId,
|
|
259
|
-
sessionId:
|
|
229
|
+
sessionId: null,
|
|
260
230
|
status: "failed",
|
|
261
231
|
}),
|
|
262
232
|
};
|
|
263
233
|
}
|
|
264
234
|
|
|
235
|
+
// 6. Persist the sessionId in the instance state. The high-level
|
|
236
|
+
// `status` stays "pending" until the runner reports a terminal
|
|
237
|
+
// state; we record the processId and the runner's
|
|
238
|
+
// intermediate states in the dedicated fields.
|
|
239
|
+
await deps.instanceManager.update(instanceId, {
|
|
240
|
+
sessionId: spawnRes.sessionId,
|
|
241
|
+
status: "running",
|
|
242
|
+
processId: spawnRes.processId,
|
|
243
|
+
runnerState: "running",
|
|
244
|
+
sessionIdAt: Date.now(),
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// 7. Wire the runner's exit event to the instance state. When
|
|
248
|
+
// the opencode run subprocess exits, the runner updates
|
|
249
|
+
// the status (done / failed / killed) and triggers the
|
|
250
|
+
// persistent auto-restart flow if appropriate.
|
|
251
|
+
if (spawnRes.processId !== undefined) {
|
|
252
|
+
const { onExit } = await import("../opencode-runner.js");
|
|
253
|
+
onExit(spawnRes.processId, (status) => {
|
|
254
|
+
// Map runner states to BackgroundStatus. The runner reports
|
|
255
|
+
// "starting" | "running" | "done" | "failed" | "killed";
|
|
256
|
+
// BackgroundStatus has "pending" | "running" | "done" |
|
|
257
|
+
// "failed" | "killed" | "timed_out". "starting" maps to
|
|
258
|
+
// "running" (in-flight, no terminal state).
|
|
259
|
+
const mapped: "pending" | "running" | "done" | "failed" | "killed" =
|
|
260
|
+
status.state === "starting" || status.state === "running"
|
|
261
|
+
? "running"
|
|
262
|
+
: status.state;
|
|
263
|
+
|
|
264
|
+
const update: Parameters<InstanceManager["update"]>[1] = {
|
|
265
|
+
status: mapped,
|
|
266
|
+
runnerState: status.state,
|
|
267
|
+
completedAt: status.endedAt ?? Date.now(),
|
|
268
|
+
};
|
|
269
|
+
if (status.exitCode !== undefined) update.exitCode = status.exitCode;
|
|
270
|
+
if (status.error) {
|
|
271
|
+
update.runnerError = status.error;
|
|
272
|
+
update.error = status.error;
|
|
273
|
+
}
|
|
274
|
+
if (status.endedAt !== undefined) update.runnerEndedAt = status.endedAt;
|
|
275
|
+
// Best-effort: if the InstanceManager has gone away (e.g.
|
|
276
|
+
// the plugin restarted), the update silently no-ops.
|
|
277
|
+
deps.instanceManager
|
|
278
|
+
.update(instanceId, update)
|
|
279
|
+
.then(() => {
|
|
280
|
+
// Persistent auto-restart: only for natural failures,
|
|
281
|
+
// not for explicit kills or successes.
|
|
282
|
+
if (status.state === "failed") {
|
|
283
|
+
return deps.instanceManager.maybeAutoRestart(instanceId);
|
|
284
|
+
}
|
|
285
|
+
return undefined;
|
|
286
|
+
})
|
|
287
|
+
.catch((err: unknown) => {
|
|
288
|
+
deps.logger.warn(
|
|
289
|
+
`bizar: bg-spawn exit update failed for ${instanceId}: ${
|
|
290
|
+
err instanceof Error ? err.message : String(err)
|
|
291
|
+
}`,
|
|
292
|
+
);
|
|
293
|
+
});
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// 8. Return the spawn result. v0.8.0 message: the agent is
|
|
298
|
+
// running in the background; Odin should return control to
|
|
299
|
+
// the user immediately and not block waiting.
|
|
265
300
|
return {
|
|
266
301
|
output: JSON.stringify({
|
|
267
302
|
instanceId,
|
|
268
|
-
sessionId:
|
|
269
|
-
|
|
303
|
+
sessionId: spawnRes.sessionId,
|
|
304
|
+
processId: spawnRes.processId,
|
|
305
|
+
status: "running",
|
|
306
|
+
message:
|
|
307
|
+
"Background agent started. It will run to completion in a separate `opencode run` subprocess. " +
|
|
308
|
+
"Use `bizar_status <instanceId>` to check progress, `bizar_collect <instanceId>` to wait for the result, " +
|
|
309
|
+
"or `bizar_kill <instanceId>` to stop it. Run `bizar bg view` in another terminal to watch all running agents live.",
|
|
310
|
+
nextSteps: [
|
|
311
|
+
"Tell the user the agent is running and approximately how long they should expect to wait",
|
|
312
|
+
"If the user wants the result now, call `bizar_collect <instanceId>` (with a reasonable timeout)",
|
|
313
|
+
"If the user wants to stop the agent, call `bizar_kill <instanceId>`",
|
|
314
|
+
"Do NOT block waiting for the result unless the user explicitly asked for it",
|
|
315
|
+
],
|
|
270
316
|
}),
|
|
271
317
|
};
|
|
272
318
|
},
|
|
273
319
|
});
|
|
274
320
|
}
|
|
275
|
-
|
|
276
|
-
// --- Helpers --------------------------------------------------------------
|
|
277
|
-
|
|
278
|
-
function buildLogPath(worktree: string, instanceId: string): string {
|
|
279
|
-
// The log file is owned by the opencode serve child (not by us). The
|
|
280
|
-
// plugin doesn't write to it. We still record the conventional path so
|
|
281
|
-
// the user can `cat` the per-session log for diagnostics.
|
|
282
|
-
return `${worktree}/.opencode/log/${instanceId}.log`;
|
|
283
|
-
}
|
package/tests/config.test.ts
CHANGED
|
@@ -83,9 +83,9 @@ describe("config drift detection", () => {
|
|
|
83
83
|
).toEqual([]);
|
|
84
84
|
});
|
|
85
85
|
|
|
86
|
-
test("plugins/bizar/package.json version is 0.
|
|
86
|
+
test("plugins/bizar/package.json version is 0.8.0", () => {
|
|
87
87
|
const pkg = JSON.parse(readFileSync(PKG_JSON, "utf-8")) as { version?: string };
|
|
88
|
-
expect(pkg.version).toBe("0.
|
|
88
|
+
expect(pkg.version).toBe("0.8.0");
|
|
89
89
|
});
|
|
90
90
|
});
|
|
91
91
|
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dashboard-client.test.ts
|
|
3
|
+
*
|
|
4
|
+
* v0.7.0-alpha.1 — Tests for the SDK-backed dashboard publisher.
|
|
5
|
+
*
|
|
6
|
+
* Verifies:
|
|
7
|
+
* - publish() succeeds when the dashboard is reachable (uses a stub
|
|
8
|
+
* fetch via `createBizarClient`'s `fetch` injection — but our
|
|
9
|
+
* publisher doesn't currently expose fetch injection, so we test
|
|
10
|
+
* via the real `publishV2Event` round-trip from the smoke test
|
|
11
|
+
* pattern instead).
|
|
12
|
+
* - publish() drops events when the publisher is not started.
|
|
13
|
+
* - Graceful degradation: env var override is respected.
|
|
14
|
+
*
|
|
15
|
+
* Network tests (publish to a real dashboard) live in
|
|
16
|
+
* `tests/integration/dashboard-bridge.test.ts` and require a running
|
|
17
|
+
* dashboard server.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, it, expect } from "bun:test";
|
|
21
|
+
import { createDashboardPublisher, type Logger } from "../src/dashboard-client.js";
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Test logger
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
const makeLogger = (): Logger & { lines: string[] } => {
|
|
28
|
+
const lines: string[] = [];
|
|
29
|
+
return {
|
|
30
|
+
lines,
|
|
31
|
+
debug(m) { lines.push(`debug: ${m}`); },
|
|
32
|
+
info(m) { lines.push(`info: ${m}`); },
|
|
33
|
+
warn(m) { lines.push(`warn: ${m}`); },
|
|
34
|
+
error(m) { lines.push(`error: ${m}`); },
|
|
35
|
+
};
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Tests
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
describe("createDashboardPublisher", () => {
|
|
43
|
+
it("publish() drops events when not started", async () => {
|
|
44
|
+
const logger = makeLogger();
|
|
45
|
+
const pub = createDashboardPublisher({ logger });
|
|
46
|
+
// Do NOT call start()
|
|
47
|
+
expect(pub.isReady()).toBe(false);
|
|
48
|
+
|
|
49
|
+
await pub.publish({
|
|
50
|
+
type: "session.created",
|
|
51
|
+
properties: { sessionId: "ses_1", agent: "mimir" },
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Logger should have recorded a debug drop message.
|
|
55
|
+
expect(logger.lines.some((l) => l.includes("not ready"))).toBe(true);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("start() is a no-op when disabled by config", async () => {
|
|
59
|
+
const logger = makeLogger();
|
|
60
|
+
const pub = createDashboardPublisher({ logger, disabled: true });
|
|
61
|
+
await pub.start();
|
|
62
|
+
expect(pub.isReady()).toBe(false);
|
|
63
|
+
expect(logger.lines.some((l) => l.includes("disabled"))).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("start() is a no-op when no password is available", async () => {
|
|
67
|
+
const logger = makeLogger();
|
|
68
|
+
// Make sure no env vars or auth files are present in this test.
|
|
69
|
+
const prevUrl = process.env.BIZAR_DASHBOARD_URL;
|
|
70
|
+
const prevPort = process.env.BIZAR_DASHBOARD_PORT;
|
|
71
|
+
const prevPw = process.env.BIZAR_DASHBOARD_PASSWORD;
|
|
72
|
+
const prevAuthFile = process.env.BIZAR_DASHBOARD_AUTH_FILE;
|
|
73
|
+
const prevAuthFiles = process.env.BIZAR_DASHBOARD_AUTH_FILES;
|
|
74
|
+
delete process.env.BIZAR_DASHBOARD_URL;
|
|
75
|
+
delete process.env.BIZAR_DASHBOARD_PORT;
|
|
76
|
+
delete process.env.BIZAR_DASHBOARD_PASSWORD;
|
|
77
|
+
// Point auth-file discovery at a non-existent path.
|
|
78
|
+
process.env.BIZAR_DASHBOARD_AUTH_FILE = "/tmp/__no_such_auth_file__.json";
|
|
79
|
+
|
|
80
|
+
try {
|
|
81
|
+
const pub = createDashboardPublisher({ logger });
|
|
82
|
+
await pub.start();
|
|
83
|
+
expect(pub.isReady()).toBe(false);
|
|
84
|
+
expect(logger.lines.some((l) => l.includes("not started"))).toBe(true);
|
|
85
|
+
} finally {
|
|
86
|
+
if (prevUrl !== undefined) process.env.BIZAR_DASHBOARD_URL = prevUrl;
|
|
87
|
+
if (prevPort !== undefined) process.env.BIZAR_DASHBOARD_PORT = prevPort;
|
|
88
|
+
if (prevPw !== undefined) process.env.BIZAR_DASHBOARD_PASSWORD = prevPw;
|
|
89
|
+
if (prevAuthFile !== undefined) {
|
|
90
|
+
process.env.BIZAR_DASHBOARD_AUTH_FILE = prevAuthFile;
|
|
91
|
+
} else {
|
|
92
|
+
delete process.env.BIZAR_DASHBOARD_AUTH_FILE;
|
|
93
|
+
}
|
|
94
|
+
if (prevAuthFiles !== undefined) {
|
|
95
|
+
process.env.BIZAR_DASHBOARD_AUTH_FILES = prevAuthFiles;
|
|
96
|
+
} else {
|
|
97
|
+
delete process.env.BIZAR_DASHBOARD_AUTH_FILES;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("start() succeeds when password is provided via env", async () => {
|
|
103
|
+
const logger = makeLogger();
|
|
104
|
+
process.env.BIZAR_DASHBOARD_PASSWORD = "test-password-1234567890abcdef";
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
const pub = createDashboardPublisher({
|
|
108
|
+
logger,
|
|
109
|
+
baseUrl: "http://127.0.0.1:1", // port 1 = unreachable, but SDK client constructs fine
|
|
110
|
+
});
|
|
111
|
+
await pub.start();
|
|
112
|
+
expect(pub.isReady()).toBe(true);
|
|
113
|
+
expect(logger.lines.some((l) => l.includes("publisher started"))).toBe(true);
|
|
114
|
+
pub.stop();
|
|
115
|
+
} finally {
|
|
116
|
+
delete process.env.BIZAR_DASHBOARD_PASSWORD;
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("start() respects BIZAR_DASHBOARD_URL env var", async () => {
|
|
121
|
+
const logger = makeLogger();
|
|
122
|
+
process.env.BIZAR_DASHBOARD_URL = "http://127.0.0.1:4099";
|
|
123
|
+
process.env.BIZAR_DASHBOARD_PASSWORD = "test-password-1234567890abcdef";
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const pub = createDashboardPublisher({ logger });
|
|
127
|
+
await pub.start();
|
|
128
|
+
expect(pub.isReady()).toBe(true);
|
|
129
|
+
expect(
|
|
130
|
+
logger.lines.some((l) => l.includes("url=http://127.0.0.1:4099")),
|
|
131
|
+
).toBe(true);
|
|
132
|
+
pub.stop();
|
|
133
|
+
} finally {
|
|
134
|
+
delete process.env.BIZAR_DASHBOARD_URL;
|
|
135
|
+
delete process.env.BIZAR_DASHBOARD_PASSWORD;
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it("stop() clears ready state and drops queue", async () => {
|
|
140
|
+
const logger = makeLogger();
|
|
141
|
+
process.env.BIZAR_DASHBOARD_PASSWORD = "test-password-1234567890abcdef";
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
const pub = createDashboardPublisher({ logger });
|
|
145
|
+
await pub.start();
|
|
146
|
+
expect(pub.isReady()).toBe(true);
|
|
147
|
+
pub.stop();
|
|
148
|
+
expect(pub.isReady()).toBe(false);
|
|
149
|
+
// Subsequent publish should be a no-op (drops because not ready).
|
|
150
|
+
await pub.publish({
|
|
151
|
+
type: "session.created",
|
|
152
|
+
properties: { sessionId: "ses_x", agent: "thor" },
|
|
153
|
+
});
|
|
154
|
+
// Just verifies it doesn't throw.
|
|
155
|
+
} finally {
|
|
156
|
+
delete process.env.BIZAR_DASHBOARD_PASSWORD;
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
});
|