bosun 0.35.2 → 0.35.3

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/ui-server.mjs CHANGED
@@ -355,6 +355,15 @@ async function getWorkflowEngineModule() {
355
355
  };
356
356
  _wfEngine.getWorkflowEngine({ services });
357
357
  _wfServicesReady = true;
358
+
359
+ // Resume any runs that were interrupted by a previous shutdown.
360
+ // This must happen AFTER services are wired so node executors work.
361
+ const engine = _wfEngine.getWorkflowEngine();
362
+ if (typeof engine.resumeInterruptedRuns === "function") {
363
+ engine.resumeInterruptedRuns().catch((err) => {
364
+ console.warn("[workflows] Failed to resume interrupted runs:", err.message);
365
+ });
366
+ }
358
367
  } catch (err) {
359
368
  console.warn("[workflows] services setup failed (engine still usable):", err.message);
360
369
  }
@@ -1361,6 +1370,28 @@ let uiInstanceLockHeld = false;
1361
1370
  const logStreamers = new Map();
1362
1371
  let uiDeps = {};
1363
1372
 
1373
+ /**
1374
+ * Resolve the execPrimaryPrompt function. Prefers the injected dependency,
1375
+ * falls back to importing directly from primary-agent.mjs so the chat
1376
+ * agent works even when the UI server starts standalone.
1377
+ */
1378
+ let _fallbackExecPrimaryPrompt = null;
1379
+ async function resolveExecPrimaryPrompt() {
1380
+ if (typeof uiDeps.execPrimaryPrompt === "function") return uiDeps.execPrimaryPrompt;
1381
+ if (_fallbackExecPrimaryPrompt) return _fallbackExecPrimaryPrompt;
1382
+ try {
1383
+ const mod = await import("./primary-agent.mjs");
1384
+ if (typeof mod.execPrimaryPrompt === "function") {
1385
+ _fallbackExecPrimaryPrompt = mod.execPrimaryPrompt;
1386
+ console.log("[ui-server] loaded execPrimaryPrompt fallback from primary-agent.mjs");
1387
+ return _fallbackExecPrimaryPrompt;
1388
+ }
1389
+ } catch (err) {
1390
+ console.warn("[ui-server] failed to load execPrimaryPrompt fallback:", err.message);
1391
+ }
1392
+ return null;
1393
+ }
1394
+
1364
1395
  /**
1365
1396
  * Resolve the bosun config directory. Falls back through:
1366
1397
  * 1. uiDeps.configDir (injected at server start)
@@ -6302,11 +6333,68 @@ async function handleApi(req, res, url) {
6302
6333
  const wfMod = await getWorkflowEngine();
6303
6334
  if (!wfMod) { jsonResponse(res, 503, { ok: false, error: "Workflow engine not available" }); return; }
6304
6335
  const engine = wfMod.getWorkflowEngine();
6305
- const runId = decodeURIComponent(path.replace("/api/workflows/runs/", "")).trim();
6336
+ const subPath = path.replace("/api/workflows/runs/", "");
6337
+ const segments = subPath.split("/").map(decodeURIComponent);
6338
+ const runId = (segments[0] || "").trim();
6339
+ const action = (segments[1] || "").trim();
6340
+
6306
6341
  if (!runId) {
6307
6342
  jsonResponse(res, 400, { ok: false, error: "runId is required" });
6308
6343
  return;
6309
6344
  }
6345
+
6346
+ // ── POST /api/workflows/runs/:id/retry ──────────────────────────
6347
+ // Manual retry endpoint. Accepts { mode: "from_failed" | "from_scratch" }.
6348
+ // If mode is omitted, returns available retry options so the UI can
6349
+ // present a choice to the user.
6350
+ if (action === "retry" && req.method === "POST") {
6351
+ const run = engine.getRunDetail ? engine.getRunDetail(runId) : null;
6352
+ if (!run) {
6353
+ jsonResponse(res, 404, { ok: false, error: "Workflow run not found" });
6354
+ return;
6355
+ }
6356
+ if (run.status !== "failed") {
6357
+ jsonResponse(res, 400, { ok: false, error: `Run status is "${run.status}" — only failed runs can be retried` });
6358
+ return;
6359
+ }
6360
+ const body = await readJsonBody(req);
6361
+ const mode = body?.mode;
6362
+ if (!mode) {
6363
+ // No mode specified — return available retry options so the UI can
6364
+ // present a picker (from scratch vs from failed step).
6365
+ const failedNodes = [];
6366
+ const nodeStatuses = run.detail?.nodeStatuses || {};
6367
+ for (const [nodeId, status] of Object.entries(nodeStatuses)) {
6368
+ if (status === "failed") failedNodes.push(nodeId);
6369
+ }
6370
+ jsonResponse(res, 200, {
6371
+ ok: true,
6372
+ runId,
6373
+ status: run.status,
6374
+ options: [
6375
+ { mode: "from_failed", label: "Retry from last failed step", failedNodes },
6376
+ { mode: "from_scratch", label: "Retry from scratch" },
6377
+ ],
6378
+ });
6379
+ return;
6380
+ }
6381
+ if (mode !== "from_failed" && mode !== "from_scratch") {
6382
+ jsonResponse(res, 400, { ok: false, error: `Invalid mode "${mode}". Use "from_failed" or "from_scratch".` });
6383
+ return;
6384
+ }
6385
+ const result = await engine.retryRun(runId, { mode });
6386
+ const retryStatus = result.ctx?.errors?.length > 0 ? "failed" : "completed";
6387
+ jsonResponse(res, 200, {
6388
+ ok: true,
6389
+ retryRunId: result.retryRunId,
6390
+ originalRunId: result.originalRunId,
6391
+ mode: result.mode,
6392
+ status: retryStatus,
6393
+ });
6394
+ return;
6395
+ }
6396
+
6397
+ // ── GET /api/workflows/runs/:id ─────────────────────────────────
6310
6398
  const run = engine.getRunDetail ? engine.getRunDetail(runId) : null;
6311
6399
  if (!run) {
6312
6400
  jsonResponse(res, 404, { ok: false, error: "Workflow run not found" });
@@ -7217,7 +7305,26 @@ async function handleApi(req, res, url) {
7217
7305
  jsonResponse(res, 404, { ok: false, error: "Session not found" });
7218
7306
  return;
7219
7307
  }
7220
- jsonResponse(res, 200, { ok: true, session });
7308
+ // Support ?limit=N&offset=N for message pagination
7309
+ const reqUrl = new URL(req.url, `http://${req.headers.host || "localhost"}`);
7310
+ const limitParam = reqUrl.searchParams.get("limit");
7311
+ const offsetParam = reqUrl.searchParams.get("offset");
7312
+ if (limitParam) {
7313
+ const limit = Math.max(1, Math.min(Number(limitParam) || 20, 500));
7314
+ const allMessages = session.messages || [];
7315
+ const total = allMessages.length;
7316
+ const offset = offsetParam != null
7317
+ ? Math.max(0, Math.min(Number(offsetParam) || 0, total))
7318
+ : Math.max(0, total - limit);
7319
+ const sliced = allMessages.slice(offset, offset + limit);
7320
+ jsonResponse(res, 200, {
7321
+ ok: true,
7322
+ session: { ...session, messages: sliced },
7323
+ pagination: { total, offset, limit, hasMore: offset > 0 },
7324
+ });
7325
+ } else {
7326
+ jsonResponse(res, 200, { ok: true, session });
7327
+ }
7221
7328
  } catch (err) {
7222
7329
  jsonResponse(res, 500, { ok: false, error: err.message });
7223
7330
  }
@@ -7306,12 +7413,35 @@ async function handleApi(req, res, url) {
7306
7413
  const messageModel = body?.model || undefined;
7307
7414
 
7308
7415
  // Forward to primary agent if applicable (exec records user + assistant events)
7309
- const exec = session.type === "primary" ? uiDeps.execPrimaryPrompt : null;
7416
+ let exec = session.type === "primary" ? uiDeps.execPrimaryPrompt : null;
7417
+ // Fallback: resolve execPrimaryPrompt from primary-agent.mjs if not injected
7418
+ if (!exec && session.type === "primary") {
7419
+ exec = await resolveExecPrimaryPrompt();
7420
+ }
7310
7421
  if (exec) {
7311
7422
  // Don't record user event here — execPrimaryPrompt records it
7312
7423
  // Respond immediately so the UI doesn't block on agent execution
7313
7424
  jsonResponse(res, 200, { ok: true, messageId });
7314
7425
  broadcastUiEvent(["sessions"], "invalidate", { reason: "session-message", sessionId });
7426
+
7427
+ // Build an onEvent callback so intermediate SDK events (thinking,
7428
+ // tool calls, code edits, etc.) are streamed to the UI in real-time
7429
+ // via the existing session-tracker → WebSocket listener pipeline.
7430
+ // Without this, chat/telegram dispatches only show the final
7431
+ // user+assistant pair instead of the full thought stream that Flows
7432
+ // clients see.
7433
+ const streamOnEvent = (err, event) => {
7434
+ // The adapters call onEvent(err, event) or onEvent(event).
7435
+ // Normalise both calling conventions.
7436
+ const ev = event || err;
7437
+ if (!ev) return;
7438
+ try {
7439
+ tracker.recordEvent(sessionId, ev);
7440
+ } catch {
7441
+ /* best-effort — never crash the agent loop */
7442
+ }
7443
+ };
7444
+
7315
7445
  // Fire-and-forget: run agent asynchronously so the request handler
7316
7446
  // doesn't block and the agent doesn't appear "busy" to subsequent
7317
7447
  // messages from chat, telegram, portal, or any other source.
@@ -7322,6 +7452,7 @@ async function handleApi(req, res, url) {
7322
7452
  model: messageModel,
7323
7453
  attachments,
7324
7454
  attachmentsAppended,
7455
+ onEvent: streamOnEvent,
7325
7456
  }).then(() => {
7326
7457
  broadcastUiEvent(["sessions"], "invalidate", { reason: "agent-response", sessionId });
7327
7458
  }).catch((execErr) => {
@@ -7335,14 +7466,20 @@ async function handleApi(req, res, url) {
7335
7466
  broadcastUiEvent(["sessions"], "invalidate", { reason: "agent-error", sessionId });
7336
7467
  });
7337
7468
  } else {
7338
- // No agent — record user event and acknowledge
7469
+ // No agent available — record user event and notify user
7339
7470
  tracker.recordEvent(sessionId, {
7340
7471
  role: "user",
7341
7472
  content: messageContent,
7342
7473
  attachments,
7343
7474
  timestamp: new Date().toISOString(),
7344
7475
  });
7345
- jsonResponse(res, 200, { ok: true, messageId });
7476
+ tracker.recordEvent(sessionId, {
7477
+ role: "system",
7478
+ type: "error",
7479
+ content: "⚠️ No agent is available to process this message. The primary agent may not be initialized — try restarting bosun or check the Logs tab for details.",
7480
+ timestamp: new Date().toISOString(),
7481
+ });
7482
+ jsonResponse(res, 200, { ok: true, messageId, warning: "no_agent_available" });
7346
7483
  broadcastUiEvent(["sessions"], "invalidate", { reason: "session-message", sessionId });
7347
7484
  }
7348
7485
  } catch (err) {