diff --git a/packages/cli/src/__tests__/sense-list.test.ts b/packages/cli/src/__tests__/sense-list.test.ts index fa86657..8ce946b 100644 --- a/packages/cli/src/__tests__/sense-list.test.ts +++ b/packages/cli/src/__tests__/sense-list.test.ts @@ -29,6 +29,7 @@ const SAMPLE_SENSES: SenseInfo[] = [ group: "system", throttle: 5000, timeout: 3000, + triggers: ["every 30s", "on: cpu-threshold"], lastSignalTimestamp: 1_700_000_000_000, }, { @@ -36,6 +37,7 @@ const SAMPLE_SENSES: SenseInfo[] = [ group: "system", throttle: 30000, timeout: null, + triggers: [], lastSignalTimestamp: null, }, { @@ -43,6 +45,7 @@ const SAMPLE_SENSES: SenseInfo[] = [ group: "tasks", throttle: 10000, timeout: 30000, + triggers: ["every 1m"], lastSignalTimestamp: null, }, ]; @@ -112,6 +115,13 @@ describe("formatSenseList", () => { expect(output).toContain("—"); }); + it("shows triggers from sense metadata", () => { + const output = formatSenseList(SAMPLE_SENSES); + expect(output).toContain("triggers:"); + expect(output).toContain("every 30s"); + expect(output).toContain("(none)"); + }); + it("shows '(never)' when lastSignalTimestamp is null", () => { const output = formatSenseList(SAMPLE_SENSES); expect(output).toContain("(never)"); @@ -263,6 +273,7 @@ describe("listSensesViaDaemon", () => { group: "system", throttle: 5000, timeout: 3000, + triggers: [], lastSignalTimestamp: 12345, }, ]; diff --git a/packages/cli/src/commands/sense.ts b/packages/cli/src/commands/sense.ts index ac66c4a..9ffbade 100644 --- a/packages/cli/src/commands/sense.ts +++ b/packages/cli/src/commands/sense.ts @@ -2,7 +2,12 @@ import { readFileSync } from "node:fs"; import { join } from "node:path"; import type { DatabaseSync } from "node:sqlite"; -import { type SenseInfo, isPlainRecord, parseNerveConfig } from "@uncaged/nerve-core"; +import { + type SenseInfo, + isPlainRecord, + parseNerveConfig, + senseTriggerLabels, +} from "@uncaged/nerve-core"; import { defineCommand } from "citty"; import { isRemoteDaemonCli } from "../cli-global.js"; @@ -44,6 +49,7 @@ export function formatSenseList(senses: SenseInfo[]): string { lines.push(` group: ${s.group}\n`); lines.push(` throttle: ${formatDuration(s.throttle)}\n`); lines.push(` timeout: ${formatDuration(s.timeout)}\n`); + lines.push(` triggers: ${s.triggers.length > 0 ? s.triggers.join("; ") : "(none)"}\n`); const lastSignal = s.lastSignalTimestamp !== null ? new Date(s.lastSignalTimestamp).toISOString() : "(never)"; lines.push(` last signal: ${lastSignal}\n`); @@ -61,11 +67,13 @@ export function sensesFromConfig(configPath: string): SenseInfo[] { } const result = parseNerveConfig(raw); if (!result.ok) return []; - return Object.entries(result.value.senses).map(([name, cfg]) => ({ + const { senses, reflexes } = result.value; + return Object.entries(senses).map(([name, cfg]) => ({ name, group: cfg.group, throttle: cfg.throttle, timeout: cfg.timeout, + triggers: senseTriggerLabels(name, reflexes), lastSignalTimestamp: null, })); } diff --git a/packages/cli/src/commands/workflow.ts b/packages/cli/src/commands/workflow.ts index eb3f695..e8e9209 100644 --- a/packages/cli/src/commands/workflow.ts +++ b/packages/cli/src/commands/workflow.ts @@ -326,6 +326,7 @@ const workflowDaemonListCommand = defineCommand({ const rows = workflows.map((w) => ({ name: w.name, active: w.activeThreads, + runIds: w.activeRunIds.length > 0 ? w.activeRunIds.join(", ") : "—", queued: w.queuedThreads, concurrency: w.config.concurrency, overflow: w.config.overflow, diff --git a/packages/cli/src/http-transport.ts b/packages/cli/src/http-transport.ts index b929c81..fa05511 100644 --- a/packages/cli/src/http-transport.ts +++ b/packages/cli/src/http-transport.ts @@ -168,7 +168,7 @@ export class HttpTransport implements DaemonTransport { const res = await fetch(`${this.baseUrl}/api/kill-workflow`, { method: "POST", headers: { ...this.baseHeaders(), "Content-Type": "application/json" }, - body: JSON.stringify({ threadId: runId }), + body: JSON.stringify({ runId }), }); const body = await readJsonBody(res); if (res.status === 401) { diff --git a/packages/core/src/daemon-ipc-protocol.ts b/packages/core/src/daemon-ipc-protocol.ts index b95f523..b6c4f87 100644 --- a/packages/core/src/daemon-ipc-protocol.ts +++ b/packages/core/src/daemon-ipc-protocol.ts @@ -11,6 +11,8 @@ import type { SenseInfo } from "./sense.js"; export type WorkflowStatus = { name: string; activeThreads: number; + /** Run IDs currently executing (same identifiers accepted by kill-workflow). */ + activeRunIds: string[]; queuedThreads: number; config: { concurrency: number; overflow: string }; }; diff --git a/packages/core/src/daemon-payload-guards.ts b/packages/core/src/daemon-payload-guards.ts index ebeed88..c665172 100644 --- a/packages/core/src/daemon-payload-guards.ts +++ b/packages/core/src/daemon-payload-guards.ts @@ -10,6 +10,8 @@ export function isSenseInfo(value: unknown): value is SenseInfo { typeof value.group === "string" && (value.throttle === null || typeof value.throttle === "number") && (value.timeout === null || typeof value.timeout === "number") && + Array.isArray(value.triggers) && + value.triggers.every((t: unknown) => typeof t === "string") && (value.lastSignalTimestamp === null || typeof value.lastSignalTimestamp === "number") ); } @@ -22,6 +24,8 @@ export function isWorkflowStatus(value: unknown): value is WorkflowStatus { return ( typeof value.name === "string" && typeof value.activeThreads === "number" && + Array.isArray(value.activeRunIds) && + value.activeRunIds.every((id: unknown) => typeof id === "string") && typeof value.queuedThreads === "number" && typeof cfg.concurrency === "number" && typeof cfg.overflow === "string" diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 859ccda..78ab7c3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -9,6 +9,7 @@ export type { NerveConfig, } from "./config.js"; export type { Signal, SenseInfo, SenseResult } from "./sense.js"; +export { labelSenseReflexTrigger, senseTriggerLabels } from "./sense-trigger-labels.js"; export type { WorkflowMessage, RoleResult, diff --git a/packages/core/src/parse-nerve-config.ts b/packages/core/src/parse-nerve-config.ts index f1a4e05..65ad612 100644 --- a/packages/core/src/parse-nerve-config.ts +++ b/packages/core/src/parse-nerve-config.ts @@ -312,9 +312,7 @@ function parseApiConfig(obj: Record): Result { if (!isLoopbackOnlyApiHost(hostResult.value) && tokenResult.value === null) { return err( - new Error( - "api.host binds to non-loopback address, api.token is required for security", - ), + new Error("api.host binds to non-loopback address, api.token is required for security"), ); } diff --git a/packages/core/src/sense-trigger-labels.ts b/packages/core/src/sense-trigger-labels.ts new file mode 100644 index 0000000..087b9ef --- /dev/null +++ b/packages/core/src/sense-trigger-labels.ts @@ -0,0 +1,36 @@ +import type { ReflexConfig } from "./config.js"; + +function formatIntervalMs(ms: number): string { + const totalSeconds = Math.floor(ms / 1000); + if (totalSeconds < 60) return `${totalSeconds}s`; + const minutes = Math.floor(totalSeconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + const remainingMinutes = minutes % 60; + return `${hours}h ${remainingMinutes}m`; +} + +/** Human-readable label for one sense reflex (interval and/or signal subscriptions). */ +export function labelSenseReflexTrigger(reflex: Extract): string { + const parts: string[] = []; + if (reflex.interval !== null) { + parts.push(`every ${formatIntervalMs(reflex.interval)}`); + } + if (reflex.on.length > 0) { + parts.push(`on: ${reflex.on.join(", ")}`); + } + if (parts.length === 0) { + return "reflex (no interval or on)"; + } + return parts.join(" · "); +} + +/** All reflex trigger descriptions that target the given sense name. */ +export function senseTriggerLabels(senseName: string, reflexes: readonly ReflexConfig[]): string[] { + const out: string[] = []; + for (const ref of reflexes) { + if (ref.kind !== "sense" || ref.sense !== senseName) continue; + out.push(labelSenseReflexTrigger(ref)); + } + return out; +} diff --git a/packages/core/src/sense.ts b/packages/core/src/sense.ts index 7a8e6a0..f5aecfc 100644 --- a/packages/core/src/sense.ts +++ b/packages/core/src/sense.ts @@ -11,6 +11,8 @@ export type SenseInfo = { group: string; throttle: number | null; timeout: number | null; + /** Declarative reflex lines that schedule this sense (derived from nerve.yaml). */ + triggers: string[]; lastSignalTimestamp: number | null; }; diff --git a/packages/daemon/rslib.config.ts b/packages/daemon/rslib.config.ts index 7a26194..09c0bba 100644 --- a/packages/daemon/rslib.config.ts +++ b/packages/daemon/rslib.config.ts @@ -17,5 +17,6 @@ export default defineConfig({ output: { target: "node", cleanDistPath: true, + copy: [{ from: "./src/dashboard.html", to: "./dashboard.html" }], }, }); diff --git a/packages/daemon/src/__tests__/daemon-ipc.test.ts b/packages/daemon/src/__tests__/daemon-ipc.test.ts index a12dbea..5335ee9 100644 --- a/packages/daemon/src/__tests__/daemon-ipc.test.ts +++ b/packages/daemon/src/__tests__/daemon-ipc.test.ts @@ -240,6 +240,7 @@ describe("daemon-ipc — list-senses", () => { group: "system", throttle: 5000, timeout: 3000, + triggers: ["every 30s"], lastSignalTimestamp: 1000, }, { @@ -247,6 +248,7 @@ describe("daemon-ipc — list-senses", () => { group: "system", throttle: 30000, timeout: null, + triggers: [], lastSignalTimestamp: null, }, ]; diff --git a/packages/daemon/src/__tests__/http-api.test.ts b/packages/daemon/src/__tests__/http-api.test.ts index de70e08..add241b 100644 --- a/packages/daemon/src/__tests__/http-api.test.ts +++ b/packages/daemon/src/__tests__/http-api.test.ts @@ -38,6 +38,16 @@ describe("createHttpApiServer — bearer auth", () => { expect(body.version).toBe("0-test"); }); + it("serves GET / dashboard HTML without Authorization when token is configured", async () => { + srv = createHttpApiServer({ port: 0, host: "127.0.0.1", token: "secret" }, handlers); + const { port } = await srv.ready(); + const res = await fetch(`http://127.0.0.1:${String(port)}/`); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toMatch(/text\/html/); + const body = await res.text(); + expect(body).toContain("Nerve"); + }); + it("returns 401 when token is configured and Authorization is missing", async () => { srv = createHttpApiServer({ port: 0, host: "127.0.0.1", token: "secret" }, handlers); const { port } = await srv.ready(); diff --git a/packages/daemon/src/dashboard.html b/packages/daemon/src/dashboard.html new file mode 100644 index 0000000..35ba1ec --- /dev/null +++ b/packages/daemon/src/dashboard.html @@ -0,0 +1,569 @@ + + + + + + Nerve daemon + + + +
+
+

Nerve

+ Disconnected + +
+ + + +
+
+ +
Loading daemon state…
+ +
+
+ + + diff --git a/packages/daemon/src/http-api.ts b/packages/daemon/src/http-api.ts index 7b4c630..297eaf6 100644 --- a/packages/daemon/src/http-api.ts +++ b/packages/daemon/src/http-api.ts @@ -10,6 +10,7 @@ import { type IncomingMessage, type Server, type ServerResponse, createServer } import { isPlainRecord } from "@uncaged/nerve-core"; import type { DaemonHandlerBundle } from "./daemon-handlers.js"; +import { getDashboardHtml } from "./load-dashboard-html.js"; const MAX_REQUEST_BODY_BYTES = 1024 * 1024; @@ -135,6 +136,17 @@ export function createHttpApiServer( return; } + const url = req.url ?? ""; + const path = url.split("?")[0] ?? ""; + + if (req.method === "GET" && path === "/") { + res.statusCode = 200; + res.setHeader("Content-Type", "text/html; charset=utf-8"); + res.setHeader("Cache-Control", "no-store"); + res.end(getDashboardHtml()); + return; + } + if (expectedToken !== null) { const presented = extractBearerToken(req.headers.authorization); if (!bearerTokenMatches(expectedToken, presented)) { @@ -143,9 +155,6 @@ export function createHttpApiServer( } } - const url = req.url ?? ""; - const path = url.split("?")[0] ?? ""; - try { if (req.method === "GET" && path === "/api/health") { sendJson(res, 200, handlers.health()); @@ -217,14 +226,10 @@ export function createHttpApiServer( if (req.method === "POST" && path === "/api/kill-workflow") { const raw = await readRequestBody(req, res); const body = parseJsonBody(raw); - if ( - !isPlainRecord(body) || - typeof body.threadId !== "string" || - body.threadId.length === 0 - ) { + if (!isPlainRecord(body) || typeof body.runId !== "string" || body.runId.length === 0) { sendJson(res, 400, { ok: false, - error: 'Expected JSON body: { "threadId": string, "name"?: string }', + error: 'Expected JSON body: { "runId": string, "name"?: string }', }); return; } @@ -235,10 +240,10 @@ export function createHttpApiServer( const nameForLog = typeof body.name === "string" && body.name.length > 0 ? body.name : null; if (nameForLog !== null) { process.stderr.write( - `[http-api] kill-workflow threadId=${body.threadId} workflowName=${nameForLog}\n`, + `[http-api] kill-workflow runId=${body.runId} workflowName=${nameForLog}\n`, ); } - const result = handlers.killWorkflowByRunId(body.threadId); + const result = handlers.killWorkflowByRunId(body.runId); sendJson(res, result.ok ? 200 : 400, result); return; } diff --git a/packages/daemon/src/kernel.ts b/packages/daemon/src/kernel.ts index 6e01d75..7e2f37a 100644 --- a/packages/daemon/src/kernel.ts +++ b/packages/daemon/src/kernel.ts @@ -8,7 +8,13 @@ import { hostname } from "node:os"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; -import type { HealthInfo, NerveConfig, SenseInfo, Signal } from "@uncaged/nerve-core"; +import { + type HealthInfo, + type NerveConfig, + type SenseInfo, + type Signal, + senseTriggerLabels, +} from "@uncaged/nerve-core"; import { routeSenseComputeOutput } from "@uncaged/nerve-core"; import { createLogStore } from "@uncaged/nerve-store"; @@ -371,6 +377,7 @@ export function createKernel( group: senseConfig.group, throttle: senseConfig.throttle, timeout: senseConfig.timeout, + triggers: senseTriggerLabels(name, config.reflexes), lastSignalTimestamp: lastEntry !== null ? lastEntry.timestamp : null, }; }); diff --git a/packages/daemon/src/load-dashboard-html.ts b/packages/daemon/src/load-dashboard-html.ts new file mode 100644 index 0000000..b11edc7 --- /dev/null +++ b/packages/daemon/src/load-dashboard-html.ts @@ -0,0 +1,15 @@ +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +let cached: string | null = null; + +/** Single-file dashboard HTML; file lives beside this module (src/ dev, dist/ release). */ +export function getDashboardHtml(): string { + if (cached !== null) { + return cached; + } + const dir = dirname(fileURLToPath(import.meta.url)); + cached = readFileSync(join(dir, "dashboard.html"), "utf8"); + return cached; +} diff --git a/packages/daemon/src/workflow-manager.ts b/packages/daemon/src/workflow-manager.ts index b894346..6e3d332 100644 --- a/packages/daemon/src/workflow-manager.ts +++ b/packages/daemon/src/workflow-manager.ts @@ -689,9 +689,13 @@ export function createWorkflowManager( for (const name of names) { const wf = config.workflows[name]; if (wf === undefined) continue; + const state = states.get(name); + const activeRunIds = + state !== undefined ? [...state.active].sort((a, b) => a.localeCompare(b)) : []; out.push({ name, activeThreads: activeCount(name), + activeRunIds, queuedThreads: queueLength(name), config: { concurrency: wf.concurrency, overflow: wf.overflow }, });