Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions infra/monitor.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -914,6 +914,17 @@ async function ensureWorkflowAutomationEngine() {
configDir: repoRoot,
});

if (!engine.__bosunWorkflowStatusBroadcastAttached) {
engine.__bosunWorkflowStatusBroadcastAttached = true;
engine.on("workflow:status", (payload) => {
try {
globalThis.__bosun_broadcastWorkflowStatusEvent?.(payload);
} catch {
// best effort; TUI clients can still read persisted history
}
});
}

const configuredWorkflowProfile =
config?.workflowDefaults && typeof config.workflowDefaults === "object"
? config.workflowDefaults.profile || "balanced"
Expand Down Expand Up @@ -15037,3 +15048,4 @@ export {
};



47 changes: 26 additions & 21 deletions server/ui-server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,27 +19,7 @@ const gzipAsync = promisify(zlibGzip);

const {
createHash,
createHmac,
randomBytes,
timingSafeEqual,
X509Certificate,
argon2: nodeArgon2,
} = nodeCrypto;
const argon2 = typeof nodeArgon2 === "function" ? nodeArgon2 : null;

// ── Response compression + caching helpers ──────────────────────────────────
const GZIP_MIN_BYTES = 1024;
const COMPRESSIBLE_TYPES = /^(text\/|application\/json|application\/javascript|image\/svg)/;

function acceptsGzip(req) {
return String(req?.headers?.["accept-encoding"] || "").includes("gzip");
}

async function compressAndSend(req, res, statusCode, headers, body) {
const buf = typeof body === "string" ? Buffer.from(body) : body;
const ct = headers["Content-Type"] || "";
if (buf.length >= GZIP_MIN_BYTES && COMPRESSIBLE_TYPES.test(ct) && acceptsGzip(req)) {
try {

const compressed = await gzipAsync(buf);
res.writeHead(statusCode, { ...headers, "Content-Encoding": "gzip", "Vary": "Accept-Encoding" });
res.end(compressed);
Expand Down Expand Up @@ -23081,6 +23061,12 @@ export async function startTelegramUiServer(options = {}) {
broadcastUiEvent(["tasks", "tui"], type, task);
}

function broadcastWorkflowStatusEvent(payload) {
broadcastUiEvent(["workflows", "tui"], "workflow:status", payload);
}

globalThis.__bosun_broadcastWorkflowStatusEvent = broadcastWorkflowStatusEvent;

wsServer.on("connection", (socket, req) => {
socket.__channels = new Set(["*"]);
socket.__lastPong = Date.now();
Expand Down Expand Up @@ -23662,3 +23648,22 @@ export { getLocalLanIp };



<<<<<<< HEAD








||||||| a57819d2







=======
>>>>>>> origin/main
13 changes: 13 additions & 0 deletions tests/tui-workflow-status-bridge.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";

describe("tui workflow status bridge architecture", () => {
it("forwards workflow status events through the UI websocket broadcast path", () => {
const monitorSource = readFileSync(resolve(process.cwd(), "infra/monitor.mjs"), "utf8");
const uiServerSource = readFileSync(resolve(process.cwd(), "server/ui-server.mjs"), "utf8");

expect(monitorSource).toContain("workflow:status");
expect(uiServerSource).toContain('broadcastUiEvent(["workflows", "tui"], "workflow:status"');
});
});
18 changes: 18 additions & 0 deletions tests/tui-workflows-architecture.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";

describe("tui workflows screen architecture", () => {
const appSource = readFileSync(resolve(process.cwd(), "ui/tui/App.js"), "utf8");

it("mounts a dedicated workflows screen instead of a simple table", () => {
expect(appSource).toContain('./WorkflowsScreen.js');
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This assertion is brittle and currently mismatches ui/tui/App.js: App imports WorkflowsScreen using double quotes ("./WorkflowsScreen.js"), but the test searches for the single-quoted substring ./WorkflowsScreen.js. Update the check to match the actual source (e.g., look for "./WorkflowsScreen.js" or use a regex that ignores quote style).

Suggested change
expect(appSource).toContain('./WorkflowsScreen.js');
expect(appSource).toMatch(/['"]\.\/WorkflowsScreen\.js['"]/);

Copilot uses AI. Check for mistakes.
expect(appSource).toContain('<${WorkflowsScreen}');
expect(appSource).not.toContain('title="Workflows"\n subtitle=${workflowState.loading ? "Loading configured workflows…"');
});

it("keeps workflows in the tab order", async () => {
const constants = await import("../ui/tui/constants.js");
expect(constants.TAB_ORDER.some((tab) => tab.id === "workflows")).toBe(true);
});
});
119 changes: 119 additions & 0 deletions tests/tui-workflows-screen.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { describe, expect, it } from "vitest";

import {
WORKFLOW_FLASH_DURATION_MS,
WORKFLOW_HISTORY_LIMIT,
buildWorkflowHistoryRows,
buildWorkflowTemplateRows,
createWorkflowTriggerFormState,
reduceWorkflowStatusEvent,
toggleWorkflowTreeNode,
} from "../ui/tui/workflows-screen-helpers.js";

describe("tui workflows screen helpers", () => {
it("builds template rows with required input summaries and live status glyphs", () => {
const rows = buildWorkflowTemplateRows([
{
id: "continuation-loop",
name: "Continuation Loop",
type: "pipeline",
enabled: true,
schedule: "manual",
requiredInputs: {
taskId: { type: "string", required: true },
},
lastRunAt: "2026-03-25T12:00:00.000Z",
lastResult: "success",
},
], {
activeRuns: new Map([["continuation-loop", { runId: "run-1", spinnerFrame: 2 }]]),
flashByWorkflowId: new Map(),
now: Date.parse("2026-03-25T12:00:01.000Z"),
});

expect(rows).toHaveLength(1);
expect(rows[0].name).toContain("Continuation Loop");
expect(rows[0].name).toContain("⠹");
expect(rows[0].scheduleOrTrigger).toBe("manual · taskId*");
expect(rows[0].lastResult).toBe("success");
});

it("keeps only the most recent 50 history rows and surfaces run detail metadata", () => {
const history = Array.from({ length: 55 }, (_, index) => ({
runId: `run-${index + 1}`,
workflowId: "wf-demo",
workflowName: "Demo",
status: index % 2 === 0 ? "completed" : "failed",
startedAt: 1_000 + index,
endedAt: 2_000 + index,
durationMs: 1_000,
triggerSource: "manual",
error: index % 2 === 0 ? null : "boom",
}));

const rows = buildWorkflowHistoryRows(history);
expect(rows).toHaveLength(WORKFLOW_HISTORY_LIMIT);
expect(rows[0].runId).toBe("run-55");
expect(rows.at(-1).runId).toBe("run-6");
expect(rows[1].result).toBe("failed");
});

it("creates inline trigger form state from required input schema", () => {
expect(createWorkflowTriggerFormState({
taskId: { type: "string", required: true, description: "Task id" },
branch: { type: "string", required: false, default: "main" },
})).toEqual({
fields: [
{ id: "taskId", label: "taskId", required: true, value: "", description: "Task id" },
{ id: "branch", label: "branch", required: false, value: "main", description: "" },
],
values: { taskId: "", branch: "main" },
});
});

it("reduces workflow:status events into active runs, flashes, and history entries", () => {
const now = Date.parse("2026-03-25T12:00:00.000Z");
let state = reduceWorkflowStatusEvent(undefined, {
runId: "run-1",
workflowId: "wf-1",
workflowName: "Demo",
eventType: "run:start",
status: "running",
timestamp: now,
}, now);

expect(state.activeRuns.get("wf-1")?.runId).toBe("run-1");

state = reduceWorkflowStatusEvent(state, {
runId: "run-1",
workflowId: "wf-1",
workflowName: "Demo",
eventType: "run:end",
status: "completed",
durationMs: 800,
timestamp: now + 800,
}, now + 800);

expect(state.activeRuns.has("wf-1")).toBe(false);
expect(state.flashByWorkflowId.get("wf-1")?.status).toBe("completed");
expect(state.history[0]).toMatchObject({ runId: "run-1", workflowId: "wf-1", status: "completed" });

const afterFlash = reduceWorkflowStatusEvent(state, {
runId: "run-2",
workflowId: "wf-2",
workflowName: "Other",
eventType: "run:start",
status: "running",
timestamp: now + WORKFLOW_FLASH_DURATION_MS + 1,
}, now + WORKFLOW_FLASH_DURATION_MS + 1);
expect(afterFlash.flashByWorkflowId.has("wf-1")).toBe(false);
});

it("toggles workflow detail tree expansion paths", () => {
const expanded = toggleWorkflowTreeNode(new Set(), "nodes.root.child");
expect(expanded.has("nodes.root.child")).toBe(true);

const collapsed = toggleWorkflowTreeNode(expanded, "nodes.root.child");
expect(collapsed.has("nodes.root.child")).toBe(false);
});
});
93 changes: 93 additions & 0 deletions tests/workflows-screen-helpers.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { describe, expect, it } from "vitest";

import {
WORKFLOW_FLASH_DURATION_MS,
buildWorkflowHistoryRows,
buildWorkflowTemplateRows,
createWorkflowTriggerFormState,
reduceWorkflowStatusEvent,
tickWorkflowStatusState,
} from "../ui/tui/workflows-screen-helpers.js";

describe("workflows-screen-helpers", () => {
it("builds trigger form fields from schema properties", () => {
const form = createWorkflowTriggerFormState({
required: ["taskId"],
properties: {
taskId: { description: "Task id", default: "123" },
note: { required: false, defaultValue: "hello" },
},
});

expect(form.fields).toEqual([
expect.objectContaining({ id: "taskId", required: true, value: "123", description: "Task id" }),
expect.objectContaining({ id: "note", required: false, value: "hello" }),
]);
expect(form.values).toEqual({ taskId: "123", note: "hello" });
});

it("shows running spinner and completion flash in template rows", () => {
const active = reduceWorkflowStatusEvent(undefined, {
eventType: "run:start",
workflowId: "wf-1",
workflowName: "Continuation Loop",
runId: "run-1",
timestamp: 1000,
status: "running",
}, 1000);

const runningRow = buildWorkflowTemplateRows([
{ id: "wf-1", name: "Continuation Loop", requiredInputs: { taskId: { required: true } } },
], {
activeRuns: active.activeRuns,
flashByWorkflowId: active.flashByWorkflowId,
now: 1000,
})[0];

expect(runningRow.name).toMatch(/^[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] /);
expect(runningRow.scheduleOrTrigger).toContain("taskId*");

const done = reduceWorkflowStatusEvent(active, {
eventType: "run:end",
workflowId: "wf-1",
workflowName: "Continuation Loop",
runId: "run-1",
timestamp: 1500,
status: "completed",
durationMs: 500,
}, 1500);

const flashedRow = buildWorkflowTemplateRows([
{ id: "wf-1", name: "Continuation Loop" },
], {
activeRuns: done.activeRuns,
flashByWorkflowId: done.flashByWorkflowId,
now: 1500 + WORKFLOW_FLASH_DURATION_MS - 1,
})[0];

expect(flashedRow.name.startsWith("✔ ")).toBe(true);
});

it("ticks spinner frames for active runs", () => {
const state = reduceWorkflowStatusEvent(undefined, {
eventType: "run:start",
workflowId: "wf-1",
runId: "run-1",
timestamp: 1000,
status: "running",
}, 1000);

const next = tickWorkflowStatusState(state);
expect(next.activeRuns.get("wf-1")?.spinnerFrame).toBe((state.activeRuns.get("wf-1")?.spinnerFrame + 1) % 10);
});

it("caps and sorts recent history rows", () => {
const rows = buildWorkflowHistoryRows([
{ runId: "r1", workflowId: "wf", workflowName: "One", startedAt: 10, endedAt: 1010, status: "completed" },
{ runId: "r2", workflowId: "wf", workflowName: "Two", startedAt: 20, endedAt: 2020, status: "failed", error: "boom" },
]);

expect(rows.map((row) => row.runId)).toEqual(["r2", "r1"]);
expect(rows[0]).toEqual(expect.objectContaining({ result: "failed", error: "boom" }));
});
});
32 changes: 15 additions & 17 deletions ui/tui/App.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,9 @@ import {
MIN_TERMINAL_SIZE,
TAB_ORDER,
} from "./constants.js";
import { useWebSocket } from "./useWebSocket.js";
import { useTasks } from "./useTasks.js";
import { useWorkflows } from "./useWorkflows.js";
import WorkflowsScreen from "./WorkflowsScreen.js";
import { useTasks } from "./useTasks.js";
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

useWebSocket is referenced (wsState = useWebSocket(...)) but the import for it was removed, so this file won’t compile. Re-add the useWebSocket import from ./useWebSocket.js (or remove the usage if intentional).

Suggested change
import { useTasks } from "./useTasks.js";
import { useTasks } from "./useTasks.js";
import { useWebSocket } from "./useWebSocket.js";

Copilot uses AI. Check for mistakes.

const html = htm.bind(React.createElement);

Expand Down Expand Up @@ -183,13 +183,6 @@ export default function App({ config, configDir, host, port, protocol = "ws", in
priority: clip(task.priority || "medium", COLUMN_WIDTHS.priority),
title: clip(task.title || "Untitled task", COLUMN_WIDTHS.title),
})), [combinedTasks]);

const workflowRows = useMemo(() => (workflowState.workflows || []).slice(0, 12).map((workflow) => ({
workflow: clip(workflow.name || workflow.id || "workflow", COLUMN_WIDTHS.workflow),
source: clip(workflow.source || workflow.file || "configured", 24),
enabled: workflow.enabled === false ? "no" : "yes",
})), [workflowState.workflows]);

let body = null;
if (tooSmall) {
body = html`
Expand Down Expand Up @@ -236,14 +229,16 @@ export default function App({ config, configDir, host, port, protocol = "ws", in
<//>
`;
} else if (activeTab === "workflows") {
body = html`
<${ScreenFrame}
title="Workflows"
subtitle=${workflowState.loading ? "Loading configured workflows…" : `Loaded ${workflowState.workflows.length} workflow(s).`}
>
${workflowState.error ? html`<${Text} color=${ANSI_COLORS.danger}>${workflowState.error}<//>` : renderTable(workflowRows)}
<//>
`;
body = workflowState.error
? html`
<${ScreenFrame}
title="Workflows"
subtitle="Workflow screen failed to load."
>
<${Text} color=${ANSI_COLORS.danger}>${workflowState.error}<//>
<//>
`
: html`<${WorkflowsScreen} workflowState=${workflowState} wsState=${wsState} />`;
} else if (activeTab === "telemetry") {
body = html`
<${ScreenFrame}
Expand Down Expand Up @@ -296,3 +291,6 @@ export default function App({ config, configDir, host, port, protocol = "ws", in
<//>
`;
}



Loading
Loading