@wopr-network/defcon 1.8.0 → 1.9.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.
@@ -6,7 +6,8 @@ export interface ParsedRequest {
6
6
  }
7
7
  export interface ApiResponse {
8
8
  status: number;
9
- body: unknown;
9
+ body?: unknown;
10
+ html?: string;
10
11
  }
11
12
  type Handler = (req: ParsedRequest) => Promise<ApiResponse>;
12
13
  interface MatchResult {
@@ -2,6 +2,7 @@ import http from "node:http";
2
2
  import type { Engine } from "../engine/engine.js";
3
3
  import type { McpServerDeps } from "../execution/mcp-server.js";
4
4
  import type { Logger } from "../logger.js";
5
+ import type { UiSseAdapter } from "../ui/sse.js";
5
6
  export interface HttpServerDeps {
6
7
  engine: Engine;
7
8
  mcpDeps: McpServerDeps;
@@ -9,5 +10,7 @@ export interface HttpServerDeps {
9
10
  workerToken?: string;
10
11
  corsOrigins?: string[];
11
12
  logger?: Logger;
13
+ enableUi?: boolean;
14
+ uiSseAdapter?: UiSseAdapter;
12
15
  }
13
16
  export declare function createHttpServer(deps: HttpServerDeps): http.Server;
@@ -2,6 +2,7 @@ import { createHash, timingSafeEqual } from "node:crypto";
2
2
  import http from "node:http";
3
3
  import { callToolHandler } from "../execution/mcp-server.js";
4
4
  import { consoleLogger } from "../logger.js";
5
+ import { UI_HTML } from "../ui/index.html.js";
5
6
  import { Router } from "./router.js";
6
7
  function extractBearerToken(header) {
7
8
  if (!header)
@@ -274,6 +275,52 @@ export function createHttpServer(deps) {
274
275
  const result = await callToolHandler(deps.mcpDeps, "admin.gate.rerun", { entity_id: req.params.id, gate_name: req.params.gateName }, { adminToken: deps.adminToken, callerToken });
275
276
  return mcpResultToApi(result);
276
277
  });
278
+ // --- UI routes (optional, enabled via enableUi) ---
279
+ if (deps.enableUi) {
280
+ router.add("GET", "/ui", async () => {
281
+ // No auth here: browsers navigating to /ui can't send Authorization headers.
282
+ // Auth is handled in the browser (token prompt) and enforced on API calls.
283
+ return { status: 200, html: "UI" };
284
+ });
285
+ router.add("GET", "/api/ui/entity/:id/events", async (req) => {
286
+ const authErr = requireAdminToken(deps, req);
287
+ if (authErr)
288
+ return authErr;
289
+ const limitStr = req.query.get("limit");
290
+ const limit = limitStr !== null ? parseInt(limitStr, 10) : 100;
291
+ if (limitStr !== null && (!Number.isFinite(limit) || limit <= 0)) {
292
+ return { status: 400, body: { error: "invalid limit parameter" } };
293
+ }
294
+ const evts = await deps.mcpDeps.eventRepo.findByEntity(req.params.id, limit);
295
+ return { status: 200, body: evts };
296
+ });
297
+ router.add("GET", "/api/ui/entity/:id/invocations", async (req) => {
298
+ const authErr = requireAdminToken(deps, req);
299
+ if (authErr)
300
+ return authErr;
301
+ const invocations = await deps.mcpDeps.invocations.findByEntity(req.params.id);
302
+ return { status: 200, body: invocations };
303
+ });
304
+ router.add("GET", "/api/ui/entity/:id/gates", async (req) => {
305
+ const authErr = requireAdminToken(deps, req);
306
+ if (authErr)
307
+ return authErr;
308
+ const results = await deps.mcpDeps.gates.resultsFor(req.params.id);
309
+ return { status: 200, body: results };
310
+ });
311
+ router.add("GET", "/api/ui/events/recent", async (req) => {
312
+ const authErr = requireAdminToken(deps, req);
313
+ if (authErr)
314
+ return authErr;
315
+ const limitStr = req.query.get("limit");
316
+ const limit = limitStr !== null ? parseInt(limitStr, 10) : 200;
317
+ if (limitStr !== null && (!Number.isFinite(limit) || limit <= 0)) {
318
+ return { status: 400, body: { error: "invalid limit parameter" } };
319
+ }
320
+ const evts = await deps.mcpDeps.eventRepo.findRecent(limit);
321
+ return { status: 200, body: evts };
322
+ });
323
+ }
277
324
  // --- HTTP server ---
278
325
  const server = http.createServer(async (req, res) => {
279
326
  // CORS
@@ -297,6 +344,35 @@ export function createHttpServer(deps) {
297
344
  return;
298
345
  }
299
346
  const url = new URL(req.url ?? "/", `http://localhost`);
347
+ // SSE endpoint — handled before router (holds connection open)
348
+ if (deps.enableUi && url.pathname === "/api/ui/events" && req.method === "GET") {
349
+ const queryToken = url.searchParams.get("token") ?? undefined;
350
+ const headerToken = extractBearerToken(req.headers.authorization);
351
+ const callerToken = headerToken ?? queryToken;
352
+ const configuredToken = deps.adminToken?.trim() || undefined;
353
+ if (configuredToken) {
354
+ if (!callerToken) {
355
+ res.writeHead(401, { "Content-Type": "application/json" });
356
+ res.end(JSON.stringify({ error: "Unauthorized" }));
357
+ return;
358
+ }
359
+ const hashA = createHash("sha256").update(configuredToken).digest();
360
+ const hashB = createHash("sha256").update(callerToken).digest();
361
+ if (!timingSafeEqual(hashA, hashB)) {
362
+ res.writeHead(401, { "Content-Type": "application/json" });
363
+ res.end(JSON.stringify({ error: "Unauthorized" }));
364
+ return;
365
+ }
366
+ }
367
+ res.writeHead(200, {
368
+ "Content-Type": "text/event-stream",
369
+ "Cache-Control": "no-cache",
370
+ Connection: "keep-alive",
371
+ });
372
+ res.write(":\n\n"); // SSE comment to establish connection
373
+ deps.uiSseAdapter?.addClient(res);
374
+ return;
375
+ }
300
376
  const match = router.match(req.method ?? "GET", url.pathname);
301
377
  if (!match) {
302
378
  res.writeHead(404, { "Content-Type": "application/json" });
@@ -337,6 +413,10 @@ export function createHttpServer(deps) {
337
413
  if (apiRes.status === 204) {
338
414
  res.writeHead(204).end();
339
415
  }
416
+ else if (apiRes.html !== undefined) {
417
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
418
+ res.end(UI_HTML);
419
+ }
340
420
  else {
341
421
  res.writeHead(apiRes.status, { "Content-Type": "application/json" });
342
422
  res.end(JSON.stringify(apiRes.body));
@@ -23,6 +23,7 @@ import { DrizzleInvocationRepository } from "../repositories/drizzle/invocation.
23
23
  import * as schema from "../repositories/drizzle/schema.js";
24
24
  import { entities, entityHistory, flowDefinitions, flowVersions, gateDefinitions, gateResults, invocations, stateDefinitions, transitionRules, } from "../repositories/drizzle/schema.js";
25
25
  import { DrizzleTransitionLogRepository } from "../repositories/drizzle/transition-log.repo.js";
26
+ import { UiSseAdapter } from "../ui/sse.js";
26
27
  import { WebSocketBroadcaster } from "../ws/broadcast.js";
27
28
  import { createMcpServer, startStdioServer } from "./mcp-server.js";
28
29
  import { provisionWorktree } from "./provision-worktree.js";
@@ -153,6 +154,7 @@ program
153
154
  .option("--mcp-only", "Start MCP stdio only (no HTTP REST server)")
154
155
  .option("--http-port <number>", "Port for HTTP REST API", "3000")
155
156
  .option("--http-host <address>", "Host for HTTP REST API", "127.0.0.1")
157
+ .option("--ui", "Enable built-in web UI at /ui")
156
158
  .action(async (opts) => {
157
159
  const { db, sqlite } = openDb(opts.db);
158
160
  const entityRepo = new DrizzleEntityRepository(db);
@@ -209,6 +211,9 @@ program
209
211
  const workerToken = process.env.DEFCON_WORKER_TOKEN || undefined;
210
212
  const startHttp = !opts.mcpOnly;
211
213
  const startMcp = !opts.httpOnly;
214
+ if (opts.mcpOnly && opts.ui) {
215
+ console.warn("Warning: --ui is ignored when --mcp-only is set (HTTP server is disabled)");
216
+ }
212
217
  try {
213
218
  validateAdminToken({ adminToken, startHttp, transport: opts.transport });
214
219
  }
@@ -241,13 +246,19 @@ program
241
246
  sqlite.close();
242
247
  process.exit(1);
243
248
  }
249
+ const uiSseAdapter = opts.ui ? new UiSseAdapter() : undefined;
244
250
  restHttpServer = createHttpServer({
245
251
  engine,
246
252
  mcpDeps: deps,
247
253
  adminToken,
248
254
  workerToken,
249
255
  corsOrigins: restCorsResult.origins ?? undefined,
256
+ enableUi: !!opts.ui,
257
+ uiSseAdapter,
250
258
  });
259
+ if (uiSseAdapter) {
260
+ eventEmitter.register(uiSseAdapter);
261
+ }
251
262
  if (adminToken) {
252
263
  const wsBroadcaster = new WebSocketBroadcaster({
253
264
  server: restHttpServer,
@@ -1,5 +1,5 @@
1
1
  import type { BetterSQLite3Database } from "drizzle-orm/better-sqlite3";
2
- import type { IEventRepository } from "../interfaces.js";
2
+ import type { EventRow, IEventRepository } from "../interfaces.js";
3
3
  import type * as schema from "./schema.js";
4
4
  import { events } from "./schema.js";
5
5
  type Db = BetterSQLite3Database<typeof schema>;
@@ -8,5 +8,7 @@ export declare class DrizzleEventRepository implements IEventRepository {
8
8
  constructor(db: Db);
9
9
  emitDefinitionChanged(flowId: string | null, tool: string, payload: Record<string, unknown>): Promise<void>;
10
10
  findAll(): (typeof events.$inferSelect)[];
11
+ findByEntity(entityId: string, limit?: number): Promise<EventRow[]>;
12
+ findRecent(limit?: number): Promise<EventRow[]>;
11
13
  }
12
14
  export {};
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
+ import { desc, eq } from "drizzle-orm";
2
3
  import { events } from "./schema.js";
3
4
  export class DrizzleEventRepository {
4
5
  db;
@@ -21,4 +22,16 @@ export class DrizzleEventRepository {
21
22
  findAll() {
22
23
  return this.db.select().from(events).all();
23
24
  }
25
+ findByEntity(entityId, limit = 100) {
26
+ return Promise.resolve(this.db
27
+ .select()
28
+ .from(events)
29
+ .where(eq(events.entityId, entityId))
30
+ .orderBy(desc(events.emittedAt))
31
+ .limit(limit)
32
+ .all());
33
+ }
34
+ findRecent(limit = 100) {
35
+ return Promise.resolve(this.db.select().from(events).orderBy(desc(events.emittedAt)).limit(limit).all());
36
+ }
24
37
  }
@@ -159,4 +159,6 @@ export const events = sqliteTable("events", {
159
159
  emittedAt: integer("emitted_at").notNull(),
160
160
  }, (table) => ({
161
161
  typeEmittedIdx: index("events_type_emitted_idx").on(table.type, table.emittedAt),
162
+ entityIdIdx: index("events_entity_id_idx").on(table.entityId),
163
+ emittedAtIdx: index("events_emitted_at_idx").on(table.emittedAt),
162
164
  }));
@@ -338,10 +338,23 @@ export interface ITransitionLogRepository {
338
338
  /** Get full transition history for an entity, ordered by timestamp. */
339
339
  historyFor(entityId: string): Promise<TransitionLog[]>;
340
340
  }
341
+ /** A raw row from the events table. */
342
+ export interface EventRow {
343
+ id: string;
344
+ type: string;
345
+ entityId: string | null;
346
+ flowId: string | null;
347
+ payload: Record<string, unknown> | null;
348
+ emittedAt: number;
349
+ }
341
350
  /** Data-access contract for emitting definition-change events. */
342
351
  export interface IEventRepository {
343
352
  /** Emit a definition change event for a tool action. */
344
353
  emitDefinitionChanged(flowId: string | null, tool: string, payload: Record<string, unknown>): Promise<void>;
354
+ /** Get events for a specific entity, ordered by emittedAt descending. */
355
+ findByEntity(entityId: string, limit?: number): Promise<EventRow[]>;
356
+ /** Get the most recent events across all entities, ordered by emittedAt descending. */
357
+ findRecent(limit?: number): Promise<EventRow[]>;
345
358
  }
346
359
  /** Data-access contract for gate definitions and result recording. */
347
360
  export interface IGateRepository {
@@ -0,0 +1 @@
1
+ export declare const UI_HTML = "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n<title>DEFCON Dashboard</title>\n<style>\n*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }\nbody { background: #0d1117; color: #c9d1d9; font-family: 'Courier New', monospace; font-size: 14px; }\n#auth-overlay { position: fixed; inset: 0; background: #0d1117; display: flex; align-items: center; justify-content: center; z-index: 100; }\n#auth-box { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 32px; width: 360px; }\n#auth-box h2 { color: #58a6ff; margin-bottom: 16px; font-size: 18px; }\n#auth-box input { width: 100%; background: #0d1117; border: 1px solid #30363d; color: #c9d1d9; padding: 8px 12px; border-radius: 4px; font-family: inherit; font-size: 14px; margin-bottom: 12px; }\n#auth-box button { width: 100%; background: #238636; border: none; color: #fff; padding: 8px; border-radius: 4px; cursor: pointer; font-size: 14px; }\n#auth-box button:hover { background: #2ea043; }\nnav { background: #161b22; border-bottom: 1px solid #30363d; padding: 0 24px; display: flex; align-items: center; gap: 0; }\nnav h1 { color: #58a6ff; font-size: 16px; margin-right: 32px; padding: 14px 0; }\n.tab { background: none; border: none; color: #8b949e; padding: 14px 16px; cursor: pointer; font-family: inherit; font-size: 14px; border-bottom: 2px solid transparent; }\n.tab:hover { color: #c9d1d9; }\n.tab.active { color: #58a6ff; border-bottom-color: #58a6ff; }\n.tab-content { display: none; padding: 24px; }\n.tab-content.active { display: block; }\n.search-row { display: flex; gap: 8px; margin-bottom: 20px; }\n.search-row input { flex: 1; background: #161b22; border: 1px solid #30363d; color: #c9d1d9; padding: 8px 12px; border-radius: 4px; font-family: inherit; font-size: 14px; }\n.search-row button, .btn { background: #21262d; border: 1px solid #30363d; color: #c9d1d9; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-family: inherit; font-size: 14px; }\n.search-row button:hover, .btn:hover { background: #30363d; }\n.timeline { display: flex; flex-direction: column; gap: 0; }\n.timeline-item { display: flex; gap: 16px; padding: 12px 0; border-bottom: 1px solid #21262d; }\n.timeline-dot { width: 10px; height: 10px; border-radius: 50%; background: #58a6ff; margin-top: 5px; flex-shrink: 0; }\n.timeline-dot.gate-pass { background: #3fb950; }\n.timeline-dot.gate-fail { background: #f85149; }\n.timeline-dot.invocation { background: #d2a8ff; }\n.timeline-body { flex: 1; }\n.timeline-ts { color: #8b949e; font-size: 12px; margin-bottom: 4px; }\n.timeline-label { font-weight: bold; color: #e6edf3; }\n.timeline-sub { color: #8b949e; font-size: 12px; margin-top: 2px; }\n.badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: bold; }\n.badge-pass { background: #0d4429; color: #3fb950; }\n.badge-fail { background: #490202; color: #f85149; }\n.badge-pending { background: #1c2a3a; color: #58a6ff; }\n.badge-complete { background: #0d2d0d; color: #3fb950; }\n.badge-amber { background: #2d1f00; color: #e3b341; }\n.flow-select { background: #161b22; border: 1px solid #30363d; color: #c9d1d9; padding: 8px 12px; border-radius: 4px; font-family: inherit; font-size: 14px; margin-bottom: 20px; }\n.flow-graph { position: relative; display: flex; gap: 32px; flex-wrap: wrap; align-items: flex-start; padding: 16px; background: #161b22; border: 1px solid #30363d; border-radius: 8px; min-height: 200px; }\n.state-box { background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 12px 16px; min-width: 140px; }\n.state-box.initial { border-color: #58a6ff; }\n.state-box.terminal { border-color: #3fb950; }\n.state-name { font-weight: bold; color: #e6edf3; margin-bottom: 4px; }\n.state-count { color: #8b949e; font-size: 12px; }\n.workers-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }\n.stat-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }\n.stat-card .label { color: #8b949e; font-size: 12px; margin-bottom: 4px; }\n.stat-card .value { color: #e6edf3; font-size: 24px; font-weight: bold; }\ntable { width: 100%; border-collapse: collapse; }\nth { text-align: left; padding: 8px 12px; color: #8b949e; font-size: 12px; border-bottom: 1px solid #30363d; }\ntd { padding: 8px 12px; border-bottom: 1px solid #21262d; vertical-align: top; }\ntd.ts { color: #8b949e; font-size: 12px; white-space: nowrap; }\ntd.type-cell { color: #d2a8ff; font-size: 12px; white-space: nowrap; }\ntd.entity-cell { color: #58a6ff; font-size: 12px; font-family: monospace; }\ntd.payload-cell { color: #8b949e; font-size: 11px; max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }\ntd.payload-cell.expanded { white-space: pre-wrap; word-break: break-all; }\n.filter-row { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; }\n.filter-row select { background: #161b22; border: 1px solid #30363d; color: #c9d1d9; padding: 6px 10px; border-radius: 4px; font-family: inherit; font-size: 13px; }\n.filter-row label { color: #8b949e; font-size: 13px; }\n#sse-status { position: fixed; bottom: 12px; right: 16px; font-size: 11px; color: #8b949e; }\n#sse-status.connected { color: #3fb950; }\n#sse-status.error { color: #f85149; }\n.empty { color: #8b949e; text-align: center; padding: 32px; }\n.error-msg { color: #f85149; margin: 8px 0; font-size: 13px; }\n</style>\n</head>\n<body>\n\n<div id=\"auth-overlay\">\n <div id=\"auth-box\">\n <h2>DEFCON</h2>\n <p style=\"color:#8b949e;margin-bottom:16px;font-size:13px;\">Enter your admin token to continue.</p>\n <input type=\"password\" id=\"token-input\" placeholder=\"Admin token\" autocomplete=\"off\">\n <div id=\"auth-error\" class=\"error-msg\" style=\"display:none\"></div>\n <button onclick=\"doLogin()\">Connect</button>\n </div>\n</div>\n\n<nav>\n <h1>DEFCON</h1>\n <button class=\"tab active\" onclick=\"showTab('entity-timeline', this)\">Timeline</button>\n <button class=\"tab\" onclick=\"showTab('flow-graph', this)\">Flow Graph</button>\n <button class=\"tab\" onclick=\"showTab('worker-dashboard', this)\">Workers</button>\n <button class=\"tab\" onclick=\"showTab('event-log', this)\">Event Log</button>\n</nav>\n\n<!-- Entity Timeline -->\n<div id=\"entity-timeline\" class=\"tab-content active\">\n <div class=\"search-row\">\n <input id=\"entity-id-input\" type=\"text\" placeholder=\"Entity ID...\" onkeydown=\"if(event.key==='Enter')loadTimeline()\">\n <button onclick=\"loadTimeline()\">Load</button>\n </div>\n <div id=\"timeline-container\"><p class=\"empty\">Enter an entity ID to view its timeline.</p></div>\n</div>\n\n<!-- Flow Graph -->\n<div id=\"flow-graph\" class=\"tab-content\">\n <select id=\"flow-select\" class=\"flow-select\" onchange=\"loadFlowGraph()\">\n <option value=\"\">-- Select a flow --</option>\n </select>\n <div id=\"graph-container\"><p class=\"empty\">Select a flow to visualize its state graph.</p></div>\n</div>\n\n<!-- Worker Dashboard -->\n<div id=\"worker-dashboard\" class=\"tab-content\">\n <div style=\"display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;\">\n <h2 style=\"color:#e6edf3;font-size:16px;\">Worker Dashboard</h2>\n <button class=\"btn\" onclick=\"loadDashboard()\">Refresh</button>\n </div>\n <div id=\"dashboard-container\"><p class=\"empty\">Loading...</p></div>\n</div>\n\n<!-- Event Log -->\n<div id=\"event-log\" class=\"tab-content\">\n <div class=\"filter-row\">\n <label>Filter by type:</label>\n <select id=\"event-type-filter\" onchange=\"filterEventLog()\">\n <option value=\"\">All</option>\n </select>\n <button class=\"btn\" onclick=\"loadEventLog()\">Refresh</button>\n </div>\n <div id=\"event-log-container\"><p class=\"empty\">Loading events...</p></div>\n</div>\n\n<div id=\"sse-status\">SSE: disconnected</div>\n\n<script>\nlet TOKEN = '';\nlet sseSource = null;\nlet allEvents = [];\nlet dashboardDebounceTimer = null;\n\nfunction scheduleDashboardRefresh() {\n if (dashboardDebounceTimer) clearTimeout(dashboardDebounceTimer);\n dashboardDebounceTimer = setTimeout(() => {\n dashboardDebounceTimer = null;\n if (document.getElementById('worker-dashboard').classList.contains('active')) {\n loadDashboard();\n }\n }, 100);\n}\n\nfunction ts(ms) {\n return new Date(typeof ms === 'number' ? ms : ms).toLocaleString();\n}\n\nfunction doLogin() {\n const v = document.getElementById('token-input').value.trim();\n if (!v) { showAuthError('Token required'); return; }\n TOKEN = v;\n sessionStorage.setItem('defcon-token', v);\n verifyToken();\n}\n\nfunction showAuthError(msg) {\n const el = document.getElementById('auth-error');\n el.textContent = msg;\n el.style.display = 'block';\n}\n\nfunction verifyToken() {\n fetch('/api/ui/events/recent?limit=1', { headers: { Authorization: 'Bearer ' + TOKEN } })\n .then(r => {\n if (r.ok) {\n document.getElementById('auth-overlay').style.display = 'none';\n initApp();\n } else {\n showAuthError('Invalid token');\n TOKEN = '';\n sessionStorage.removeItem('defcon-token');\n }\n })\n .catch(() => showAuthError('Connection failed'));\n}\n\nfunction initApp() {\n connectSSE();\n loadEventLog();\n loadFlowList();\n loadDashboard();\n}\n\nfunction connectSSE() {\n if (sseSource) sseSource.close();\n // Pass token via Authorization header using a fetch-based SSE reader to\n // avoid exposing it in the URL (which appears in server logs).\n // EventSource does not support custom headers, so we use fetch + ReadableStream.\n const ctrl = new AbortController();\n sseSource = ctrl; // store for close()\n fetch('/api/ui/events', { headers: { Authorization: 'Bearer ' + TOKEN }, signal: ctrl.signal })\n .then(r => {\n if (!r.ok) { handleSseError(); return; }\n const el = document.getElementById('sse-status');\n el.textContent = 'SSE: connected';\n el.className = 'connected';\n const reader = r.body.getReader();\n const decoder = new TextDecoder();\n let buf = '';\n function pump() {\n reader.read().then(({ done, value }) => {\n if (done) { handleSseError(); return; }\n buf += decoder.decode(value, { stream: true });\n const lines = buf.split('\n');\n buf = lines.pop();\n for (const line of lines) {\n if (line.startsWith('data: ')) {\n try {\n const ev = JSON.parse(line.slice(6));\n prependEventRow(ev);\n scheduleDashboardRefresh();\n } catch (_) {}\n }\n }\n pump();\n }).catch(handleSseError);\n }\n pump();\n })\n .catch(handleSseError);\n function handleSseError() {\n const el = document.getElementById('sse-status');\n if (el) { el.textContent = 'SSE: reconnecting...'; el.className = 'error'; }\n setTimeout(() => connectSSE(), 5000);\n }\n}\n sseSource.onopen = () => {\n const el = document.getElementById('sse-status');\n el.textContent = 'SSE: connected';\n el.className = 'connected';\n };\n sseSource.onerror = () => {\n const el = document.getElementById('sse-status');\n el.textContent = 'SSE: reconnecting...';\n el.className = 'error';\n };\n sseSource.onmessage = (e) => {\n try {\n const ev = JSON.parse(e.data);\n prependEventRow(ev);\n if (document.getElementById('worker-dashboard').classList.contains('active')) {\n loadDashboard();\n }\n } catch (_) {}\n };\n}\n\nfunction api(path) {\n return fetch(path, { headers: { Authorization: 'Bearer ' + TOKEN } }).then(r => {\n if (!r.ok) throw new Error('HTTP ' + r.status);\n return r.json();\n });\n}\n\nfunction showTab(id, btn) {\n document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));\n document.querySelectorAll('.tab').forEach(el => el.classList.remove('active'));\n document.getElementById(id).classList.add('active');\n btn.classList.add('active');\n if (id === 'worker-dashboard') loadDashboard();\n if (id === 'flow-graph') loadFlowList();\n}\n\n// \u2500\u2500 Entity Timeline \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nasync function loadTimeline() {\n const id = document.getElementById('entity-id-input').value.trim();\n if (!id) return;\n const el = document.getElementById('timeline-container');\n el.innerHTML = '<p class=\"empty\">Loading...</p>';\n try {\n const [entity, events, invocations, gates] = await Promise.all([\n api('/api/entities/' + encodeURIComponent(id)),\n api('/api/ui/entity/' + encodeURIComponent(id) + '/events'),\n api('/api/ui/entity/' + encodeURIComponent(id) + '/invocations'),\n api('/api/ui/entity/' + encodeURIComponent(id) + '/gates'),\n ]);\n\n // Build merged timeline\n const rows = [];\n\n if (entity && entity.id) {\n rows.push({ t: new Date(entity.createdAt).getTime(), kind: 'entity', label: 'Entity created', sub: 'Flow: ' + entity.flowId + ' | State: ' + entity.state });\n }\n\n for (const ev of (Array.isArray(events) ? events : [])) {\n rows.push({ t: ev.emittedAt, kind: ev.type, label: ev.type, sub: JSON.stringify(ev.payload || {}).slice(0, 120) });\n }\n\n for (const inv of (Array.isArray(invocations) ? invocations : [])) {\n const st = inv.startedAt ? new Date(inv.startedAt).getTime() : (inv.createdAt ? new Date(inv.createdAt).getTime() : 0);\n rows.push({ t: st, kind: 'invocation', label: 'Invocation: ' + inv.stage, sub: 'Status: ' + (inv.completedAt ? 'completed' : inv.failedAt ? 'failed' : 'pending') + (inv.signal ? ' | signal: ' + inv.signal : '') });\n }\n\n for (const g of (Array.isArray(gates) ? gates : [])) {\n rows.push({ t: g.evaluatedAt ? new Date(g.evaluatedAt).getTime() : 0, kind: g.passed ? 'gate-pass' : 'gate-fail', label: 'Gate: ' + g.gateId, sub: g.passed ? 'PASSED' : 'FAILED' + (g.output ? ' \u2014 ' + g.output.slice(0, 80) : '') });\n }\n\n rows.sort((a, b) => a.t - b.t);\n\n if (rows.length === 0) { el.innerHTML = '<p class=\"empty\">No data for this entity.</p>'; return; }\n\n el.innerHTML = '<div class=\"timeline\">' + rows.map(r => {\n let dotClass = 'timeline-dot';\n if (r.kind === 'gate-pass') dotClass += ' gate-pass';\n else if (r.kind === 'gate-fail') dotClass += ' gate-fail';\n else if (r.kind === 'invocation') dotClass += ' invocation';\n return '<div class=\"timeline-item\"><div class=\"' + dotClass + '\"></div><div class=\"timeline-body\"><div class=\"timeline-ts\">' + (r.t ? ts(r.t) : '\u2014') + '</div><div class=\"timeline-label\">' + esc(r.label) + '</div><div class=\"timeline-sub\">' + esc(r.sub || '') + '</div></div></div>';\n }).join('') + '</div>';\n } catch (e) {\n el.innerHTML = '<p class=\"error-msg\">Error: ' + esc(String(e)) + '</p>';\n }\n}\n\n// \u2500\u2500 Flow Graph \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nasync function loadFlowList() {\n try {\n const flows = await api('/api/flows');\n const sel = document.getElementById('flow-select');\n const prev = sel.value;\n sel.innerHTML = '<option value=\"\">-- Select a flow --</option>';\n for (const f of (Array.isArray(flows) ? flows : [])) {\n const opt = document.createElement('option');\n opt.value = f.id;\n opt.textContent = f.name;\n sel.appendChild(opt);\n }\n if (prev) { sel.value = prev; loadFlowGraph(); }\n } catch (_) {}\n}\n\nasync function loadFlowGraph() {\n const id = document.getElementById('flow-select').value;\n const el = document.getElementById('graph-container');\n if (!id) { el.innerHTML = '<p class=\"empty\">Select a flow.</p>'; return; }\n el.innerHTML = '<p class=\"empty\">Loading...</p>';\n try {\n const [flow, status] = await Promise.all([api('/api/flows/' + encodeURIComponent(id)), api('/api/status')]);\n const counts = {};\n if (status && status.flows) {\n for (const fstat of status.flows) {\n if (fstat.flowId === id && fstat.states) {\n for (const s of fstat.states) counts[s.state] = s.count;\n }\n }\n }\n const states = flow.states || [];\n const transitions = flow.transitions || [];\n const initial = flow.initialState;\n const terminalSet = new Set();\n for (const s of states) {\n const hasOut = transitions.some(t => t.fromState === s.name);\n if (!hasOut) terminalSet.add(s.name);\n }\n\n const boxes = states.map(s => {\n let cls = 'state-box';\n if (s.name === initial) cls += ' initial';\n if (terminalSet.has(s.name)) cls += ' terminal';\n return '<div class=\"' + cls + '\"><div class=\"state-name\">' + esc(s.name) + '</div><div class=\"state-count\">' + (counts[s.name] || 0) + ' entities</div>' + (s.agentRole ? '<div class=\"state-count\" style=\"color:#d2a8ff\">' + esc(s.agentRole) + '</div>' : '') + '</div>';\n });\n\n el.innerHTML = '<div class=\"flow-graph\">' + boxes.join('') + '</div>';\n if (transitions.length) {\n const list = transitions.map(t => '<tr><td>' + esc(t.fromState) + '</td><td style=\"color:#8b949e\">\u2192</td><td>' + esc(t.toState) + '</td><td style=\"color:#d2a8ff\">' + esc(t.trigger) + '</td><td>' + (t.gateId ? '<span class=\"badge badge-amber\">gated</span>' : '') + '</td></tr>').join('');\n el.innerHTML += '<h3 style=\"color:#8b949e;font-size:13px;margin:16px 0 8px\">Transitions</h3><table><thead><tr><th>From</th><th></th><th>To</th><th>Trigger</th><th>Gate</th></tr></thead><tbody>' + list + '</tbody></table>';\n }\n } catch (e) {\n el.innerHTML = '<p class=\"error-msg\">Error: ' + esc(String(e)) + '</p>';\n }\n}\n\n// \u2500\u2500 Worker Dashboard \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nasync function loadDashboard() {\n const el = document.getElementById('dashboard-container');\n try {\n const status = await api('/api/status');\n let html = '<div class=\"workers-grid\">';\n html += '<div class=\"stat-card\"><div class=\"label\">Active Invocations</div><div class=\"value\">' + (status.activeInvocations || 0) + '</div></div>';\n html += '<div class=\"stat-card\"><div class=\"label\">Pending Claims</div><div class=\"value\">' + (status.pendingClaims || 0) + '</div></div>';\n html += '<div class=\"stat-card\"><div class=\"label\">Total Entities</div><div class=\"value\">' + (status.totalEntities || 0) + '</div></div>';\n html += '</div>';\n\n if (status.flows && status.flows.length) {\n html += '<h3 style=\"color:#e6edf3;font-size:14px;margin-bottom:12px;\">Flows</h3><table><thead><tr><th>Flow</th><th>State</th><th>Count</th></tr></thead><tbody>';\n for (const f of status.flows) {\n for (const s of (f.states || [])) {\n html += '<tr><td style=\"color:#58a6ff\">' + esc(f.flowName || f.flowId) + '</td><td>' + esc(s.state) + '</td><td>' + s.count + '</td></tr>';\n }\n }\n html += '</tbody></table>';\n }\n el.innerHTML = html;\n } catch (e) {\n el.innerHTML = '<p class=\"error-msg\">Error: ' + esc(String(e)) + '</p>';\n }\n}\n\n// \u2500\u2500 Event Log \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nasync function loadEventLog() {\n const el = document.getElementById('event-log-container');\n try {\n const fetched = await api('/api/ui/events/recent?limit=200');\n const fetchedRows = Array.isArray(fetched) ? fetched : [];\n // Merge: keep SSE-injected events not in the fetched set (by id), then prepend fetched\n const fetchedIds = new Set(fetchedRows.map(e => e.id));\n const sseOnly = allEvents.filter(e => !fetchedIds.has(e.id));\n allEvents = [...sseOnly, ...fetchedRows];\n updateEventTypeFilter();\n renderEventLog();\n } catch (e) {\n el.innerHTML = '<p class=\"error-msg\">Error: ' + esc(String(e)) + '</p>';\n }\n}\n\nfunction prependEventRow(ev) {\n // Convert SSE event to EventRow format\n const row = { id: ev.id || '', type: ev.type || '', entityId: ev.entityId || null, flowId: ev.flowId || null, payload: ev, emittedAt: ev.timestamp ? new Date(ev.timestamp).getTime() : Date.now() };\n allEvents.unshift(row);\n if (allEvents.length > 500) allEvents.pop();\n updateEventTypeFilter();\n renderEventLog();\n}\n\nfunction updateEventTypeFilter() {\n const sel = document.getElementById('event-type-filter');\n const cur = sel.value;\n const types = [...new Set(allEvents.map(e => e.type))].sort();\n sel.innerHTML = '<option value=\"\">All</option>' + types.map(t => '<option value=\"' + esc(t) + '\">' + esc(t) + '</option>').join('');\n if (cur) sel.value = cur;\n}\n\nfunction filterEventLog() { renderEventLog(); }\n\nfunction renderEventLog() {\n const filter = document.getElementById('event-type-filter').value;\n const el = document.getElementById('event-log-container');\n const filtered = filter ? allEvents.filter(e => e.type === filter) : allEvents;\n if (!filtered.length) { el.innerHTML = '<p class=\"empty\">No events.</p>'; return; }\n const rows = filtered.map(e => '<tr><td class=\"ts\">' + ts(e.emittedAt) + '</td><td class=\"type-cell\">' + esc(e.type) + '</td><td class=\"entity-cell\">' + esc(e.entityId || '\u2014') + \"</td><td class=\"payload-cell\" onclick=\"this.classList.toggle('expanded')\">\" + esc(JSON.stringify(e.payload || {})) + '</td></tr>').join('');\n el.innerHTML = '<table><thead><tr><th>Time</th><th>Type</th><th>Entity</th><th>Payload</th></tr></thead><tbody>' + rows + '</tbody></table>';\n}\n\nfunction esc(s) {\n return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/\"/g,'&quot;').replace(/'/g,'&#39;');\n}\n\n// \u2500\u2500 Init \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\n\nconst savedToken = sessionStorage.getItem('defcon-token');\nif (savedToken) {\n TOKEN = savedToken;\n document.getElementById('token-input').value = savedToken;\n verifyToken();\n}\n</script>\n</body>\n</html>";
@@ -0,0 +1,465 @@
1
+ export const UI_HTML = `<!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>DEFCON Dashboard</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+ body { background: #0d1117; color: #c9d1d9; font-family: 'Courier New', monospace; font-size: 14px; }
10
+ #auth-overlay { position: fixed; inset: 0; background: #0d1117; display: flex; align-items: center; justify-content: center; z-index: 100; }
11
+ #auth-box { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 32px; width: 360px; }
12
+ #auth-box h2 { color: #58a6ff; margin-bottom: 16px; font-size: 18px; }
13
+ #auth-box input { width: 100%; background: #0d1117; border: 1px solid #30363d; color: #c9d1d9; padding: 8px 12px; border-radius: 4px; font-family: inherit; font-size: 14px; margin-bottom: 12px; }
14
+ #auth-box button { width: 100%; background: #238636; border: none; color: #fff; padding: 8px; border-radius: 4px; cursor: pointer; font-size: 14px; }
15
+ #auth-box button:hover { background: #2ea043; }
16
+ nav { background: #161b22; border-bottom: 1px solid #30363d; padding: 0 24px; display: flex; align-items: center; gap: 0; }
17
+ nav h1 { color: #58a6ff; font-size: 16px; margin-right: 32px; padding: 14px 0; }
18
+ .tab { background: none; border: none; color: #8b949e; padding: 14px 16px; cursor: pointer; font-family: inherit; font-size: 14px; border-bottom: 2px solid transparent; }
19
+ .tab:hover { color: #c9d1d9; }
20
+ .tab.active { color: #58a6ff; border-bottom-color: #58a6ff; }
21
+ .tab-content { display: none; padding: 24px; }
22
+ .tab-content.active { display: block; }
23
+ .search-row { display: flex; gap: 8px; margin-bottom: 20px; }
24
+ .search-row input { flex: 1; background: #161b22; border: 1px solid #30363d; color: #c9d1d9; padding: 8px 12px; border-radius: 4px; font-family: inherit; font-size: 14px; }
25
+ .search-row button, .btn { background: #21262d; border: 1px solid #30363d; color: #c9d1d9; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-family: inherit; font-size: 14px; }
26
+ .search-row button:hover, .btn:hover { background: #30363d; }
27
+ .timeline { display: flex; flex-direction: column; gap: 0; }
28
+ .timeline-item { display: flex; gap: 16px; padding: 12px 0; border-bottom: 1px solid #21262d; }
29
+ .timeline-dot { width: 10px; height: 10px; border-radius: 50%; background: #58a6ff; margin-top: 5px; flex-shrink: 0; }
30
+ .timeline-dot.gate-pass { background: #3fb950; }
31
+ .timeline-dot.gate-fail { background: #f85149; }
32
+ .timeline-dot.invocation { background: #d2a8ff; }
33
+ .timeline-body { flex: 1; }
34
+ .timeline-ts { color: #8b949e; font-size: 12px; margin-bottom: 4px; }
35
+ .timeline-label { font-weight: bold; color: #e6edf3; }
36
+ .timeline-sub { color: #8b949e; font-size: 12px; margin-top: 2px; }
37
+ .badge { display: inline-block; padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: bold; }
38
+ .badge-pass { background: #0d4429; color: #3fb950; }
39
+ .badge-fail { background: #490202; color: #f85149; }
40
+ .badge-pending { background: #1c2a3a; color: #58a6ff; }
41
+ .badge-complete { background: #0d2d0d; color: #3fb950; }
42
+ .badge-amber { background: #2d1f00; color: #e3b341; }
43
+ .flow-select { background: #161b22; border: 1px solid #30363d; color: #c9d1d9; padding: 8px 12px; border-radius: 4px; font-family: inherit; font-size: 14px; margin-bottom: 20px; }
44
+ .flow-graph { position: relative; display: flex; gap: 32px; flex-wrap: wrap; align-items: flex-start; padding: 16px; background: #161b22; border: 1px solid #30363d; border-radius: 8px; min-height: 200px; }
45
+ .state-box { background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 12px 16px; min-width: 140px; }
46
+ .state-box.initial { border-color: #58a6ff; }
47
+ .state-box.terminal { border-color: #3fb950; }
48
+ .state-name { font-weight: bold; color: #e6edf3; margin-bottom: 4px; }
49
+ .state-count { color: #8b949e; font-size: 12px; }
50
+ .workers-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; margin-bottom: 24px; }
51
+ .stat-card { background: #161b22; border: 1px solid #30363d; border-radius: 8px; padding: 16px; }
52
+ .stat-card .label { color: #8b949e; font-size: 12px; margin-bottom: 4px; }
53
+ .stat-card .value { color: #e6edf3; font-size: 24px; font-weight: bold; }
54
+ table { width: 100%; border-collapse: collapse; }
55
+ th { text-align: left; padding: 8px 12px; color: #8b949e; font-size: 12px; border-bottom: 1px solid #30363d; }
56
+ td { padding: 8px 12px; border-bottom: 1px solid #21262d; vertical-align: top; }
57
+ td.ts { color: #8b949e; font-size: 12px; white-space: nowrap; }
58
+ td.type-cell { color: #d2a8ff; font-size: 12px; white-space: nowrap; }
59
+ td.entity-cell { color: #58a6ff; font-size: 12px; font-family: monospace; }
60
+ td.payload-cell { color: #8b949e; font-size: 11px; max-width: 400px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; cursor: pointer; }
61
+ td.payload-cell.expanded { white-space: pre-wrap; word-break: break-all; }
62
+ .filter-row { display: flex; gap: 8px; margin-bottom: 16px; align-items: center; }
63
+ .filter-row select { background: #161b22; border: 1px solid #30363d; color: #c9d1d9; padding: 6px 10px; border-radius: 4px; font-family: inherit; font-size: 13px; }
64
+ .filter-row label { color: #8b949e; font-size: 13px; }
65
+ #sse-status { position: fixed; bottom: 12px; right: 16px; font-size: 11px; color: #8b949e; }
66
+ #sse-status.connected { color: #3fb950; }
67
+ #sse-status.error { color: #f85149; }
68
+ .empty { color: #8b949e; text-align: center; padding: 32px; }
69
+ .error-msg { color: #f85149; margin: 8px 0; font-size: 13px; }
70
+ </style>
71
+ </head>
72
+ <body>
73
+
74
+ <div id="auth-overlay">
75
+ <div id="auth-box">
76
+ <h2>DEFCON</h2>
77
+ <p style="color:#8b949e;margin-bottom:16px;font-size:13px;">Enter your admin token to continue.</p>
78
+ <input type="password" id="token-input" placeholder="Admin token" autocomplete="off">
79
+ <div id="auth-error" class="error-msg" style="display:none"></div>
80
+ <button onclick="doLogin()">Connect</button>
81
+ </div>
82
+ </div>
83
+
84
+ <nav>
85
+ <h1>DEFCON</h1>
86
+ <button class="tab active" onclick="showTab('entity-timeline', this)">Timeline</button>
87
+ <button class="tab" onclick="showTab('flow-graph', this)">Flow Graph</button>
88
+ <button class="tab" onclick="showTab('worker-dashboard', this)">Workers</button>
89
+ <button class="tab" onclick="showTab('event-log', this)">Event Log</button>
90
+ </nav>
91
+
92
+ <!-- Entity Timeline -->
93
+ <div id="entity-timeline" class="tab-content active">
94
+ <div class="search-row">
95
+ <input id="entity-id-input" type="text" placeholder="Entity ID..." onkeydown="if(event.key==='Enter')loadTimeline()">
96
+ <button onclick="loadTimeline()">Load</button>
97
+ </div>
98
+ <div id="timeline-container"><p class="empty">Enter an entity ID to view its timeline.</p></div>
99
+ </div>
100
+
101
+ <!-- Flow Graph -->
102
+ <div id="flow-graph" class="tab-content">
103
+ <select id="flow-select" class="flow-select" onchange="loadFlowGraph()">
104
+ <option value="">-- Select a flow --</option>
105
+ </select>
106
+ <div id="graph-container"><p class="empty">Select a flow to visualize its state graph.</p></div>
107
+ </div>
108
+
109
+ <!-- Worker Dashboard -->
110
+ <div id="worker-dashboard" class="tab-content">
111
+ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;">
112
+ <h2 style="color:#e6edf3;font-size:16px;">Worker Dashboard</h2>
113
+ <button class="btn" onclick="loadDashboard()">Refresh</button>
114
+ </div>
115
+ <div id="dashboard-container"><p class="empty">Loading...</p></div>
116
+ </div>
117
+
118
+ <!-- Event Log -->
119
+ <div id="event-log" class="tab-content">
120
+ <div class="filter-row">
121
+ <label>Filter by type:</label>
122
+ <select id="event-type-filter" onchange="filterEventLog()">
123
+ <option value="">All</option>
124
+ </select>
125
+ <button class="btn" onclick="loadEventLog()">Refresh</button>
126
+ </div>
127
+ <div id="event-log-container"><p class="empty">Loading events...</p></div>
128
+ </div>
129
+
130
+ <div id="sse-status">SSE: disconnected</div>
131
+
132
+ <script>
133
+ let TOKEN = '';
134
+ let sseSource = null;
135
+ let allEvents = [];
136
+ let dashboardDebounceTimer = null;
137
+
138
+ function scheduleDashboardRefresh() {
139
+ if (dashboardDebounceTimer) clearTimeout(dashboardDebounceTimer);
140
+ dashboardDebounceTimer = setTimeout(() => {
141
+ dashboardDebounceTimer = null;
142
+ if (document.getElementById('worker-dashboard').classList.contains('active')) {
143
+ loadDashboard();
144
+ }
145
+ }, 100);
146
+ }
147
+
148
+ function ts(ms) {
149
+ return new Date(typeof ms === 'number' ? ms : ms).toLocaleString();
150
+ }
151
+
152
+ function doLogin() {
153
+ const v = document.getElementById('token-input').value.trim();
154
+ if (!v) { showAuthError('Token required'); return; }
155
+ TOKEN = v;
156
+ sessionStorage.setItem('defcon-token', v);
157
+ verifyToken();
158
+ }
159
+
160
+ function showAuthError(msg) {
161
+ const el = document.getElementById('auth-error');
162
+ el.textContent = msg;
163
+ el.style.display = 'block';
164
+ }
165
+
166
+ function verifyToken() {
167
+ fetch('/api/ui/events/recent?limit=1', { headers: { Authorization: 'Bearer ' + TOKEN } })
168
+ .then(r => {
169
+ if (r.ok) {
170
+ document.getElementById('auth-overlay').style.display = 'none';
171
+ initApp();
172
+ } else {
173
+ showAuthError('Invalid token');
174
+ TOKEN = '';
175
+ sessionStorage.removeItem('defcon-token');
176
+ }
177
+ })
178
+ .catch(() => showAuthError('Connection failed'));
179
+ }
180
+
181
+ function initApp() {
182
+ connectSSE();
183
+ loadEventLog();
184
+ loadFlowList();
185
+ loadDashboard();
186
+ }
187
+
188
+ function connectSSE() {
189
+ if (sseSource) sseSource.close();
190
+ // Pass token via Authorization header using a fetch-based SSE reader to
191
+ // avoid exposing it in the URL (which appears in server logs).
192
+ // EventSource does not support custom headers, so we use fetch + ReadableStream.
193
+ const ctrl = new AbortController();
194
+ sseSource = ctrl; // store for close()
195
+ fetch('/api/ui/events', { headers: { Authorization: 'Bearer ' + TOKEN }, signal: ctrl.signal })
196
+ .then(r => {
197
+ if (!r.ok) { handleSseError(); return; }
198
+ const el = document.getElementById('sse-status');
199
+ el.textContent = 'SSE: connected';
200
+ el.className = 'connected';
201
+ const reader = r.body.getReader();
202
+ const decoder = new TextDecoder();
203
+ let buf = '';
204
+ function pump() {
205
+ reader.read().then(({ done, value }) => {
206
+ if (done) { handleSseError(); return; }
207
+ buf += decoder.decode(value, { stream: true });
208
+ const lines = buf.split('\n');
209
+ buf = lines.pop();
210
+ for (const line of lines) {
211
+ if (line.startsWith('data: ')) {
212
+ try {
213
+ const ev = JSON.parse(line.slice(6));
214
+ prependEventRow(ev);
215
+ scheduleDashboardRefresh();
216
+ } catch (_) {}
217
+ }
218
+ }
219
+ pump();
220
+ }).catch(handleSseError);
221
+ }
222
+ pump();
223
+ })
224
+ .catch(handleSseError);
225
+ function handleSseError() {
226
+ const el = document.getElementById('sse-status');
227
+ if (el) { el.textContent = 'SSE: reconnecting...'; el.className = 'error'; }
228
+ setTimeout(() => connectSSE(), 5000);
229
+ }
230
+ }
231
+ sseSource.onopen = () => {
232
+ const el = document.getElementById('sse-status');
233
+ el.textContent = 'SSE: connected';
234
+ el.className = 'connected';
235
+ };
236
+ sseSource.onerror = () => {
237
+ const el = document.getElementById('sse-status');
238
+ el.textContent = 'SSE: reconnecting...';
239
+ el.className = 'error';
240
+ };
241
+ sseSource.onmessage = (e) => {
242
+ try {
243
+ const ev = JSON.parse(e.data);
244
+ prependEventRow(ev);
245
+ if (document.getElementById('worker-dashboard').classList.contains('active')) {
246
+ loadDashboard();
247
+ }
248
+ } catch (_) {}
249
+ };
250
+ }
251
+
252
+ function api(path) {
253
+ return fetch(path, { headers: { Authorization: 'Bearer ' + TOKEN } }).then(r => {
254
+ if (!r.ok) throw new Error('HTTP ' + r.status);
255
+ return r.json();
256
+ });
257
+ }
258
+
259
+ function showTab(id, btn) {
260
+ document.querySelectorAll('.tab-content').forEach(el => el.classList.remove('active'));
261
+ document.querySelectorAll('.tab').forEach(el => el.classList.remove('active'));
262
+ document.getElementById(id).classList.add('active');
263
+ btn.classList.add('active');
264
+ if (id === 'worker-dashboard') loadDashboard();
265
+ if (id === 'flow-graph') loadFlowList();
266
+ }
267
+
268
+ // ── Entity Timeline ──────────────────────────────────────────────
269
+
270
+ async function loadTimeline() {
271
+ const id = document.getElementById('entity-id-input').value.trim();
272
+ if (!id) return;
273
+ const el = document.getElementById('timeline-container');
274
+ el.innerHTML = '<p class="empty">Loading...</p>';
275
+ try {
276
+ const [entity, events, invocations, gates] = await Promise.all([
277
+ api('/api/entities/' + encodeURIComponent(id)),
278
+ api('/api/ui/entity/' + encodeURIComponent(id) + '/events'),
279
+ api('/api/ui/entity/' + encodeURIComponent(id) + '/invocations'),
280
+ api('/api/ui/entity/' + encodeURIComponent(id) + '/gates'),
281
+ ]);
282
+
283
+ // Build merged timeline
284
+ const rows = [];
285
+
286
+ if (entity && entity.id) {
287
+ rows.push({ t: new Date(entity.createdAt).getTime(), kind: 'entity', label: 'Entity created', sub: 'Flow: ' + entity.flowId + ' | State: ' + entity.state });
288
+ }
289
+
290
+ for (const ev of (Array.isArray(events) ? events : [])) {
291
+ rows.push({ t: ev.emittedAt, kind: ev.type, label: ev.type, sub: JSON.stringify(ev.payload || {}).slice(0, 120) });
292
+ }
293
+
294
+ for (const inv of (Array.isArray(invocations) ? invocations : [])) {
295
+ const st = inv.startedAt ? new Date(inv.startedAt).getTime() : (inv.createdAt ? new Date(inv.createdAt).getTime() : 0);
296
+ rows.push({ t: st, kind: 'invocation', label: 'Invocation: ' + inv.stage, sub: 'Status: ' + (inv.completedAt ? 'completed' : inv.failedAt ? 'failed' : 'pending') + (inv.signal ? ' | signal: ' + inv.signal : '') });
297
+ }
298
+
299
+ for (const g of (Array.isArray(gates) ? gates : [])) {
300
+ rows.push({ t: g.evaluatedAt ? new Date(g.evaluatedAt).getTime() : 0, kind: g.passed ? 'gate-pass' : 'gate-fail', label: 'Gate: ' + g.gateId, sub: g.passed ? 'PASSED' : 'FAILED' + (g.output ? ' — ' + g.output.slice(0, 80) : '') });
301
+ }
302
+
303
+ rows.sort((a, b) => a.t - b.t);
304
+
305
+ if (rows.length === 0) { el.innerHTML = '<p class="empty">No data for this entity.</p>'; return; }
306
+
307
+ el.innerHTML = '<div class="timeline">' + rows.map(r => {
308
+ let dotClass = 'timeline-dot';
309
+ if (r.kind === 'gate-pass') dotClass += ' gate-pass';
310
+ else if (r.kind === 'gate-fail') dotClass += ' gate-fail';
311
+ else if (r.kind === 'invocation') dotClass += ' invocation';
312
+ return '<div class="timeline-item"><div class="' + dotClass + '"></div><div class="timeline-body"><div class="timeline-ts">' + (r.t ? ts(r.t) : '—') + '</div><div class="timeline-label">' + esc(r.label) + '</div><div class="timeline-sub">' + esc(r.sub || '') + '</div></div></div>';
313
+ }).join('') + '</div>';
314
+ } catch (e) {
315
+ el.innerHTML = '<p class="error-msg">Error: ' + esc(String(e)) + '</p>';
316
+ }
317
+ }
318
+
319
+ // ── Flow Graph ───────────────────────────────────────────────────
320
+
321
+ async function loadFlowList() {
322
+ try {
323
+ const flows = await api('/api/flows');
324
+ const sel = document.getElementById('flow-select');
325
+ const prev = sel.value;
326
+ sel.innerHTML = '<option value="">-- Select a flow --</option>';
327
+ for (const f of (Array.isArray(flows) ? flows : [])) {
328
+ const opt = document.createElement('option');
329
+ opt.value = f.id;
330
+ opt.textContent = f.name;
331
+ sel.appendChild(opt);
332
+ }
333
+ if (prev) { sel.value = prev; loadFlowGraph(); }
334
+ } catch (_) {}
335
+ }
336
+
337
+ async function loadFlowGraph() {
338
+ const id = document.getElementById('flow-select').value;
339
+ const el = document.getElementById('graph-container');
340
+ if (!id) { el.innerHTML = '<p class="empty">Select a flow.</p>'; return; }
341
+ el.innerHTML = '<p class="empty">Loading...</p>';
342
+ try {
343
+ const [flow, status] = await Promise.all([api('/api/flows/' + encodeURIComponent(id)), api('/api/status')]);
344
+ const counts = {};
345
+ if (status && status.flows) {
346
+ for (const fstat of status.flows) {
347
+ if (fstat.flowId === id && fstat.states) {
348
+ for (const s of fstat.states) counts[s.state] = s.count;
349
+ }
350
+ }
351
+ }
352
+ const states = flow.states || [];
353
+ const transitions = flow.transitions || [];
354
+ const initial = flow.initialState;
355
+ const terminalSet = new Set();
356
+ for (const s of states) {
357
+ const hasOut = transitions.some(t => t.fromState === s.name);
358
+ if (!hasOut) terminalSet.add(s.name);
359
+ }
360
+
361
+ const boxes = states.map(s => {
362
+ let cls = 'state-box';
363
+ if (s.name === initial) cls += ' initial';
364
+ if (terminalSet.has(s.name)) cls += ' terminal';
365
+ return '<div class="' + cls + '"><div class="state-name">' + esc(s.name) + '</div><div class="state-count">' + (counts[s.name] || 0) + ' entities</div>' + (s.agentRole ? '<div class="state-count" style="color:#d2a8ff">' + esc(s.agentRole) + '</div>' : '') + '</div>';
366
+ });
367
+
368
+ el.innerHTML = '<div class="flow-graph">' + boxes.join('') + '</div>';
369
+ if (transitions.length) {
370
+ const list = transitions.map(t => '<tr><td>' + esc(t.fromState) + '</td><td style="color:#8b949e">→</td><td>' + esc(t.toState) + '</td><td style="color:#d2a8ff">' + esc(t.trigger) + '</td><td>' + (t.gateId ? '<span class="badge badge-amber">gated</span>' : '') + '</td></tr>').join('');
371
+ el.innerHTML += '<h3 style="color:#8b949e;font-size:13px;margin:16px 0 8px">Transitions</h3><table><thead><tr><th>From</th><th></th><th>To</th><th>Trigger</th><th>Gate</th></tr></thead><tbody>' + list + '</tbody></table>';
372
+ }
373
+ } catch (e) {
374
+ el.innerHTML = '<p class="error-msg">Error: ' + esc(String(e)) + '</p>';
375
+ }
376
+ }
377
+
378
+ // ── Worker Dashboard ─────────────────────────────────────────────
379
+
380
+ async function loadDashboard() {
381
+ const el = document.getElementById('dashboard-container');
382
+ try {
383
+ const status = await api('/api/status');
384
+ let html = '<div class="workers-grid">';
385
+ html += '<div class="stat-card"><div class="label">Active Invocations</div><div class="value">' + (status.activeInvocations || 0) + '</div></div>';
386
+ html += '<div class="stat-card"><div class="label">Pending Claims</div><div class="value">' + (status.pendingClaims || 0) + '</div></div>';
387
+ html += '<div class="stat-card"><div class="label">Total Entities</div><div class="value">' + (status.totalEntities || 0) + '</div></div>';
388
+ html += '</div>';
389
+
390
+ if (status.flows && status.flows.length) {
391
+ html += '<h3 style="color:#e6edf3;font-size:14px;margin-bottom:12px;">Flows</h3><table><thead><tr><th>Flow</th><th>State</th><th>Count</th></tr></thead><tbody>';
392
+ for (const f of status.flows) {
393
+ for (const s of (f.states || [])) {
394
+ html += '<tr><td style="color:#58a6ff">' + esc(f.flowName || f.flowId) + '</td><td>' + esc(s.state) + '</td><td>' + s.count + '</td></tr>';
395
+ }
396
+ }
397
+ html += '</tbody></table>';
398
+ }
399
+ el.innerHTML = html;
400
+ } catch (e) {
401
+ el.innerHTML = '<p class="error-msg">Error: ' + esc(String(e)) + '</p>';
402
+ }
403
+ }
404
+
405
+ // ── Event Log ────────────────────────────────────────────────────
406
+
407
+ async function loadEventLog() {
408
+ const el = document.getElementById('event-log-container');
409
+ try {
410
+ const fetched = await api('/api/ui/events/recent?limit=200');
411
+ const fetchedRows = Array.isArray(fetched) ? fetched : [];
412
+ // Merge: keep SSE-injected events not in the fetched set (by id), then prepend fetched
413
+ const fetchedIds = new Set(fetchedRows.map(e => e.id));
414
+ const sseOnly = allEvents.filter(e => !fetchedIds.has(e.id));
415
+ allEvents = [...sseOnly, ...fetchedRows];
416
+ updateEventTypeFilter();
417
+ renderEventLog();
418
+ } catch (e) {
419
+ el.innerHTML = '<p class="error-msg">Error: ' + esc(String(e)) + '</p>';
420
+ }
421
+ }
422
+
423
+ function prependEventRow(ev) {
424
+ // Convert SSE event to EventRow format
425
+ const row = { id: ev.id || '', type: ev.type || '', entityId: ev.entityId || null, flowId: ev.flowId || null, payload: ev, emittedAt: ev.timestamp ? new Date(ev.timestamp).getTime() : Date.now() };
426
+ allEvents.unshift(row);
427
+ if (allEvents.length > 500) allEvents.pop();
428
+ updateEventTypeFilter();
429
+ renderEventLog();
430
+ }
431
+
432
+ function updateEventTypeFilter() {
433
+ const sel = document.getElementById('event-type-filter');
434
+ const cur = sel.value;
435
+ const types = [...new Set(allEvents.map(e => e.type))].sort();
436
+ sel.innerHTML = '<option value="">All</option>' + types.map(t => '<option value="' + esc(t) + '">' + esc(t) + '</option>').join('');
437
+ if (cur) sel.value = cur;
438
+ }
439
+
440
+ function filterEventLog() { renderEventLog(); }
441
+
442
+ function renderEventLog() {
443
+ const filter = document.getElementById('event-type-filter').value;
444
+ const el = document.getElementById('event-log-container');
445
+ const filtered = filter ? allEvents.filter(e => e.type === filter) : allEvents;
446
+ if (!filtered.length) { el.innerHTML = '<p class="empty">No events.</p>'; return; }
447
+ const rows = filtered.map(e => '<tr><td class="ts">' + ts(e.emittedAt) + '</td><td class="type-cell">' + esc(e.type) + '</td><td class="entity-cell">' + esc(e.entityId || '—') + "</td><td class="payload-cell" onclick="this.classList.toggle('expanded')">" + esc(JSON.stringify(e.payload || {})) + '</td></tr>').join('');
448
+ el.innerHTML = '<table><thead><tr><th>Time</th><th>Type</th><th>Entity</th><th>Payload</th></tr></thead><tbody>' + rows + '</tbody></table>';
449
+ }
450
+
451
+ function esc(s) {
452
+ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;');
453
+ }
454
+
455
+ // ── Init ─────────────────────────────────────────────────────────
456
+
457
+ const savedToken = sessionStorage.getItem('defcon-token');
458
+ if (savedToken) {
459
+ TOKEN = savedToken;
460
+ document.getElementById('token-input').value = savedToken;
461
+ verifyToken();
462
+ }
463
+ </script>
464
+ </body>
465
+ </html>`;
@@ -0,0 +1,8 @@
1
+ import type { ServerResponse } from "node:http";
2
+ import type { EngineEvent, IEventBusAdapter } from "../engine/event-types.js";
3
+ export declare class UiSseAdapter implements IEventBusAdapter {
4
+ private clients;
5
+ addClient(res: ServerResponse): void;
6
+ get clientCount(): number;
7
+ emit(event: EngineEvent): Promise<void>;
8
+ }
@@ -0,0 +1,23 @@
1
+ export class UiSseAdapter {
2
+ clients = new Set();
3
+ addClient(res) {
4
+ this.clients.add(res);
5
+ res.on("close", () => this.clients.delete(res));
6
+ }
7
+ get clientCount() {
8
+ return this.clients.size;
9
+ }
10
+ async emit(event) {
11
+ const { emittedAt, ...rest } = event;
12
+ const msg = JSON.stringify({ ...rest, timestamp: emittedAt.toISOString() });
13
+ const frame = `data: ${msg}\n\n`;
14
+ for (const client of this.clients) {
15
+ try {
16
+ client.write(frame);
17
+ }
18
+ catch {
19
+ this.clients.delete(client);
20
+ }
21
+ }
22
+ }
23
+ }
@@ -0,0 +1,2 @@
1
+ CREATE INDEX `events_entity_id_idx` ON `events` (`entity_id`);--> statement-breakpoint
2
+ CREATE INDEX `events_emitted_at_idx` ON `events` (`emitted_at`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/defcon",
3
- "version": "1.8.0",
3
+ "version": "1.9.0",
4
4
  "type": "module",
5
5
  "packageManager": "pnpm@9.15.4",
6
6
  "engines": {