@wopr-network/defcon 1.7.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.
- package/dist/src/api/router.d.ts +2 -1
- package/dist/src/api/server.d.ts +3 -0
- package/dist/src/api/server.js +80 -0
- package/dist/src/engine/engine.js +9 -0
- package/dist/src/engine/event-types.d.ts +8 -0
- package/dist/src/engine/flow-spawner.js +1 -1
- package/dist/src/execution/admin-schemas.d.ts +2 -0
- package/dist/src/execution/admin-schemas.js +2 -0
- package/dist/src/execution/cli.js +12 -0
- package/dist/src/execution/mcp-server.d.ts +1 -0
- package/dist/src/execution/mcp-server.js +68 -24
- package/dist/src/repositories/drizzle/entity.repo.d.ts +2 -1
- package/dist/src/repositories/drizzle/entity.repo.js +7 -1
- package/dist/src/repositories/drizzle/event.repo.d.ts +3 -1
- package/dist/src/repositories/drizzle/event.repo.js +13 -0
- package/dist/src/repositories/drizzle/schema.d.ts +19 -0
- package/dist/src/repositories/drizzle/schema.js +4 -0
- package/dist/src/repositories/interfaces.d.ts +17 -1
- package/dist/src/ui/index.html.d.ts +1 -0
- package/dist/src/ui/index.html.js +465 -0
- package/dist/src/ui/sse.d.ts +8 -0
- package/dist/src/ui/sse.js +23 -0
- package/drizzle/0015_aberrant_luminals.sql +2 -0
- package/drizzle/0015_add_parent_entity_id.sql +2 -0
- package/drizzle/meta/_journal.json +8 -1
- package/package.json +1 -1
package/dist/src/api/router.d.ts
CHANGED
package/dist/src/api/server.d.ts
CHANGED
|
@@ -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;
|
package/dist/src/api/server.js
CHANGED
|
@@ -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));
|
|
@@ -72,6 +72,9 @@ export class Engine {
|
|
|
72
72
|
const entity = await this.entityRepo.get(entityId);
|
|
73
73
|
if (!entity)
|
|
74
74
|
throw new NotFoundError(`Entity "${entityId}" not found`);
|
|
75
|
+
if (entity.state === "cancelled") {
|
|
76
|
+
throw new ValidationError(`Entity "${entityId}" is cancelled and cannot be transitioned`);
|
|
77
|
+
}
|
|
75
78
|
// 2. Load flow at entity's pinned version
|
|
76
79
|
const flow = await this.flowRepo.getAtVersion(entity.flowId, entity.flowVersion);
|
|
77
80
|
if (!flow)
|
|
@@ -466,6 +469,8 @@ export class Engine {
|
|
|
466
469
|
const entity = entityMap.get(pending.entityId);
|
|
467
470
|
if (!entity)
|
|
468
471
|
continue;
|
|
472
|
+
if (entity.state === "cancelled")
|
|
473
|
+
continue;
|
|
469
474
|
// Guard: entity state must still match the invocation's stage — if another worker
|
|
470
475
|
// transitioned the entity between candidate fetch and now, skip this candidate.
|
|
471
476
|
if (entity.state !== pending.stage)
|
|
@@ -545,6 +550,10 @@ export class Engine {
|
|
|
545
550
|
const claimed = await this.entityRepo.claim(flow.id, state.name, worker_id ?? `agent:${role}`);
|
|
546
551
|
if (!claimed)
|
|
547
552
|
continue;
|
|
553
|
+
if (claimed.state === "cancelled") {
|
|
554
|
+
await this.entityRepo.release(claimed.id, worker_id ?? `agent:${role}`);
|
|
555
|
+
continue;
|
|
556
|
+
}
|
|
548
557
|
const claimedVersionedFlow = await this.flowRepo.getAtVersion(claimed.flowId, claimed.flowVersion);
|
|
549
558
|
const claimedEffectiveFlow = claimedVersionedFlow ?? flow;
|
|
550
559
|
const canCreate = await this.checkConcurrency(claimedEffectiveFlow, claimed);
|
|
@@ -24,6 +24,14 @@ export type EngineEvent = {
|
|
|
24
24
|
entityId: string;
|
|
25
25
|
flowId: string;
|
|
26
26
|
emittedAt: Date;
|
|
27
|
+
} | {
|
|
28
|
+
type: "entity.cancelled";
|
|
29
|
+
entityId: string;
|
|
30
|
+
flowId: string;
|
|
31
|
+
cancelledBy: string;
|
|
32
|
+
reason: string | null;
|
|
33
|
+
cascade: boolean;
|
|
34
|
+
emittedAt: Date;
|
|
27
35
|
} | {
|
|
28
36
|
type: "invocation.created";
|
|
29
37
|
entityId: string;
|
|
@@ -11,7 +11,7 @@ export async function executeSpawn(transition, parentEntity, flowRepo, entityRep
|
|
|
11
11
|
const flow = await flowRepo.getByName(transition.spawnFlow);
|
|
12
12
|
if (!flow)
|
|
13
13
|
throw new NotFoundError(`Spawn flow "${transition.spawnFlow}" not found`);
|
|
14
|
-
const childEntity = await entityRepo.create(flow.id, flow.initialState, parentEntity.refs ?? undefined);
|
|
14
|
+
const childEntity = await entityRepo.create(flow.id, flow.initialState, parentEntity.refs ?? undefined, undefined, parentEntity.id);
|
|
15
15
|
try {
|
|
16
16
|
await entityRepo.appendSpawnedChild(parentEntity.id, {
|
|
17
17
|
childId: childEntity.id,
|
|
@@ -119,6 +119,8 @@ export declare const AdminFlowPauseSchema: z.ZodObject<{
|
|
|
119
119
|
}, z.core.$strip>;
|
|
120
120
|
export declare const AdminEntityCancelSchema: z.ZodObject<{
|
|
121
121
|
entity_id: z.ZodString;
|
|
122
|
+
cascade: z.ZodDefault<z.ZodOptional<z.ZodBoolean>>;
|
|
123
|
+
reason: z.ZodOptional<z.ZodString>;
|
|
122
124
|
}, z.core.$strip>;
|
|
123
125
|
export declare const AdminEntityResetSchema: z.ZodObject<{
|
|
124
126
|
entity_id: z.ZodString;
|
|
@@ -128,6 +128,8 @@ export const AdminFlowPauseSchema = z.object({
|
|
|
128
128
|
});
|
|
129
129
|
export const AdminEntityCancelSchema = z.object({
|
|
130
130
|
entity_id: z.string().min(1),
|
|
131
|
+
cascade: z.boolean().optional().default(false),
|
|
132
|
+
reason: z.string().optional(),
|
|
131
133
|
});
|
|
132
134
|
export const AdminEntityResetSchema = z.object({
|
|
133
135
|
entity_id: z.string().min(1),
|
|
@@ -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);
|
|
@@ -184,6 +186,7 @@ program
|
|
|
184
186
|
transitions: transitionLogRepo,
|
|
185
187
|
eventRepo: new DrizzleEventRepository(db),
|
|
186
188
|
engine,
|
|
189
|
+
withTransaction: (fn) => withTransaction(sqlite, fn),
|
|
187
190
|
};
|
|
188
191
|
const reaperInterval = parseInt(opts.reaperInterval, 10);
|
|
189
192
|
if (Number.isNaN(reaperInterval) || reaperInterval < 1000) {
|
|
@@ -208,6 +211,9 @@ program
|
|
|
208
211
|
const workerToken = process.env.DEFCON_WORKER_TOKEN || undefined;
|
|
209
212
|
const startHttp = !opts.mcpOnly;
|
|
210
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
|
+
}
|
|
211
217
|
try {
|
|
212
218
|
validateAdminToken({ adminToken, startHttp, transport: opts.transport });
|
|
213
219
|
}
|
|
@@ -240,13 +246,19 @@ program
|
|
|
240
246
|
sqlite.close();
|
|
241
247
|
process.exit(1);
|
|
242
248
|
}
|
|
249
|
+
const uiSseAdapter = opts.ui ? new UiSseAdapter() : undefined;
|
|
243
250
|
restHttpServer = createHttpServer({
|
|
244
251
|
engine,
|
|
245
252
|
mcpDeps: deps,
|
|
246
253
|
adminToken,
|
|
247
254
|
workerToken,
|
|
248
255
|
corsOrigins: restCorsResult.origins ?? undefined,
|
|
256
|
+
enableUi: !!opts.ui,
|
|
257
|
+
uiSseAdapter,
|
|
249
258
|
});
|
|
259
|
+
if (uiSseAdapter) {
|
|
260
|
+
eventEmitter.register(uiSseAdapter);
|
|
261
|
+
}
|
|
250
262
|
if (adminToken) {
|
|
251
263
|
const wsBroadcaster = new WebSocketBroadcaster({
|
|
252
264
|
server: restHttpServer,
|
|
@@ -11,6 +11,7 @@ export interface McpServerDeps {
|
|
|
11
11
|
eventRepo: IEventRepository;
|
|
12
12
|
engine?: Engine;
|
|
13
13
|
logger?: Logger;
|
|
14
|
+
withTransaction?: <T>(fn: () => T | Promise<T>) => Promise<T>;
|
|
14
15
|
}
|
|
15
16
|
export interface McpServerOpts {
|
|
16
17
|
/** DEFCON_ADMIN_TOKEN — if set, admin.* tools require this token */
|
|
@@ -350,8 +350,16 @@ const TOOL_DEFINITIONS = [
|
|
|
350
350
|
},
|
|
351
351
|
{
|
|
352
352
|
name: "admin.entity.cancel",
|
|
353
|
-
description: "Cancel an entity — fails active invocation, moves to 'cancelled' terminal state.",
|
|
354
|
-
inputSchema: {
|
|
353
|
+
description: "Cancel an entity — fails active invocation, moves to 'cancelled' terminal state. Use cascade to cancel all descendant entities.",
|
|
354
|
+
inputSchema: {
|
|
355
|
+
type: "object",
|
|
356
|
+
properties: {
|
|
357
|
+
entity_id: { type: "string" },
|
|
358
|
+
cascade: { type: "boolean", description: "Recursively cancel all child entities" },
|
|
359
|
+
reason: { type: "string", description: "Cancellation reason" },
|
|
360
|
+
},
|
|
361
|
+
required: ["entity_id"],
|
|
362
|
+
},
|
|
355
363
|
},
|
|
356
364
|
{
|
|
357
365
|
name: "admin.entity.reset",
|
|
@@ -755,31 +763,67 @@ async function handleAdminEntityCancel(deps, args) {
|
|
|
755
763
|
const v = validateInput(AdminEntityCancelSchema, args);
|
|
756
764
|
if (!v.ok)
|
|
757
765
|
return v.result;
|
|
758
|
-
const
|
|
766
|
+
const { entity_id, cascade, reason } = v.data;
|
|
767
|
+
const entity = await deps.entities.get(entity_id);
|
|
759
768
|
if (!entity)
|
|
760
|
-
return errorResult(`Entity not found: ${
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
const
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
if (
|
|
770
|
-
|
|
769
|
+
return errorResult(`Entity not found: ${entity_id}`);
|
|
770
|
+
if (entity.state === "cancelled" && !cascade)
|
|
771
|
+
return errorResult(`Entity ${entity_id} is already cancelled`);
|
|
772
|
+
const cancelled = [];
|
|
773
|
+
const MAX_CANCEL_DEPTH = 100;
|
|
774
|
+
async function cancelOne(eid, depth, visited) {
|
|
775
|
+
if (visited.has(eid))
|
|
776
|
+
return;
|
|
777
|
+
visited.add(eid);
|
|
778
|
+
if (depth > MAX_CANCEL_DEPTH) {
|
|
779
|
+
throw new Error(`cancelOne exceeded maximum recursion depth of ${MAX_CANCEL_DEPTH} — possible cycle or pathologically deep entity tree`);
|
|
780
|
+
}
|
|
781
|
+
const e = await deps.entities.get(eid);
|
|
782
|
+
const alreadyCancelled = !e || e.state === "cancelled";
|
|
783
|
+
if (!alreadyCancelled) {
|
|
784
|
+
const invocations = await deps.invocations.findByEntity(eid);
|
|
785
|
+
for (const inv of invocations) {
|
|
786
|
+
if (inv.completedAt === null && inv.failedAt === null) {
|
|
787
|
+
await deps.invocations.fail(inv.id, reason ?? "Cancelled by admin");
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
await deps.entities.cancelEntity(eid);
|
|
791
|
+
await deps.transitions.record({
|
|
792
|
+
entityId: eid,
|
|
793
|
+
fromState: e.state,
|
|
794
|
+
toState: "cancelled",
|
|
795
|
+
trigger: "admin.cancel",
|
|
796
|
+
invocationId: null,
|
|
797
|
+
timestamp: new Date(),
|
|
798
|
+
});
|
|
799
|
+
if (deps.engine) {
|
|
800
|
+
await deps.engine.emit({
|
|
801
|
+
type: "entity.cancelled",
|
|
802
|
+
entityId: eid,
|
|
803
|
+
flowId: e.flowId,
|
|
804
|
+
cancelledBy: "admin",
|
|
805
|
+
reason: reason ?? null,
|
|
806
|
+
cascade: cascade ?? false,
|
|
807
|
+
emittedAt: new Date(),
|
|
808
|
+
});
|
|
809
|
+
}
|
|
810
|
+
cancelled.push(eid);
|
|
811
|
+
}
|
|
812
|
+
if (cascade) {
|
|
813
|
+
const children = await deps.entities.findByParentId(eid);
|
|
814
|
+
for (const child of children) {
|
|
815
|
+
await cancelOne(child.id, depth + 1, visited);
|
|
816
|
+
}
|
|
771
817
|
}
|
|
772
818
|
}
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
});
|
|
782
|
-
return jsonResult({ cancelled: true, entity_id: v.data.entity_id });
|
|
819
|
+
const run = () => cancelOne(entity_id, 0, new Set());
|
|
820
|
+
if (deps.withTransaction) {
|
|
821
|
+
await deps.withTransaction(run);
|
|
822
|
+
}
|
|
823
|
+
else {
|
|
824
|
+
await run();
|
|
825
|
+
}
|
|
826
|
+
return jsonResult({ cancelled: true, entity_id, cancelled_count: cancelled.length, cancelled_ids: cancelled });
|
|
783
827
|
}
|
|
784
828
|
async function handleAdminEntityReset(deps, args) {
|
|
785
829
|
const v = validateInput(AdminEntityResetSchema, args);
|
|
@@ -6,7 +6,7 @@ export declare class DrizzleEntityRepository implements IEntityRepository {
|
|
|
6
6
|
private db;
|
|
7
7
|
constructor(db: Db);
|
|
8
8
|
private toEntity;
|
|
9
|
-
create(flowId: string, initialState: string, refs?: Refs, flowVersion?: number): Promise<Entity>;
|
|
9
|
+
create(flowId: string, initialState: string, refs?: Refs, flowVersion?: number, parentEntityId?: string): Promise<Entity>;
|
|
10
10
|
get(id: string): Promise<Entity | null>;
|
|
11
11
|
findByFlowAndState(flowId: string, state: string, limit?: number): Promise<Entity[]>;
|
|
12
12
|
hasAnyInFlowAndState(flowId: string, stateNames: string[]): Promise<boolean>;
|
|
@@ -23,6 +23,7 @@ export declare class DrizzleEntityRepository implements IEntityRepository {
|
|
|
23
23
|
reapExpired(ttlMs: number): Promise<string[]>;
|
|
24
24
|
setAffinity(entityId: string, workerId: string, role: string, expiresAt: Date): Promise<void>;
|
|
25
25
|
clearExpiredAffinity(): Promise<string[]>;
|
|
26
|
+
findByParentId(parentEntityId: string): Promise<Entity[]>;
|
|
26
27
|
cancelEntity(entityId: string): Promise<void>;
|
|
27
28
|
resetEntity(entityId: string, targetState: string): Promise<Entity>;
|
|
28
29
|
updateFlowVersion(entityId: string, version: number): Promise<void>;
|
|
@@ -22,9 +22,10 @@ export class DrizzleEntityRepository {
|
|
|
22
22
|
affinityWorkerId: row.affinityWorkerId ?? null,
|
|
23
23
|
affinityRole: row.affinityRole ?? null,
|
|
24
24
|
affinityExpiresAt: row.affinityExpiresAt ? new Date(row.affinityExpiresAt) : null,
|
|
25
|
+
parentEntityId: row.parentEntityId ?? null,
|
|
25
26
|
};
|
|
26
27
|
}
|
|
27
|
-
async create(flowId, initialState, refs, flowVersion) {
|
|
28
|
+
async create(flowId, initialState, refs, flowVersion, parentEntityId) {
|
|
28
29
|
const now = Date.now();
|
|
29
30
|
const id = crypto.randomUUID();
|
|
30
31
|
const row = {
|
|
@@ -41,6 +42,7 @@ export class DrizzleEntityRepository {
|
|
|
41
42
|
affinityWorkerId: null,
|
|
42
43
|
affinityRole: null,
|
|
43
44
|
affinityExpiresAt: null,
|
|
45
|
+
parentEntityId: parentEntityId ?? null,
|
|
44
46
|
};
|
|
45
47
|
await this.db.insert(entities).values(row);
|
|
46
48
|
return this.toEntity(row);
|
|
@@ -188,6 +190,10 @@ export class DrizzleEntityRepository {
|
|
|
188
190
|
.all();
|
|
189
191
|
return rows.map((r) => r.id);
|
|
190
192
|
}
|
|
193
|
+
async findByParentId(parentEntityId) {
|
|
194
|
+
const rows = await this.db.select().from(entities).where(eq(entities.parentEntityId, parentEntityId));
|
|
195
|
+
return rows.map((r) => this.toEntity(r));
|
|
196
|
+
}
|
|
191
197
|
async cancelEntity(entityId) {
|
|
192
198
|
await this.db
|
|
193
199
|
.update(entities)
|
|
@@ -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
|
}
|
|
@@ -1326,6 +1326,25 @@ export declare const entities: import("drizzle-orm/sqlite-core").SQLiteTableWith
|
|
|
1326
1326
|
identity: undefined;
|
|
1327
1327
|
generated: undefined;
|
|
1328
1328
|
}, {}, {}>;
|
|
1329
|
+
parentEntityId: import("drizzle-orm/sqlite-core").SQLiteColumn<{
|
|
1330
|
+
name: "parent_entity_id";
|
|
1331
|
+
tableName: "entities";
|
|
1332
|
+
dataType: "string";
|
|
1333
|
+
columnType: "SQLiteText";
|
|
1334
|
+
data: string;
|
|
1335
|
+
driverParam: string;
|
|
1336
|
+
notNull: false;
|
|
1337
|
+
hasDefault: false;
|
|
1338
|
+
isPrimaryKey: false;
|
|
1339
|
+
isAutoincrement: false;
|
|
1340
|
+
hasRuntimeDefault: false;
|
|
1341
|
+
enumValues: [string, ...string[]];
|
|
1342
|
+
baseColumn: never;
|
|
1343
|
+
identity: undefined;
|
|
1344
|
+
generated: undefined;
|
|
1345
|
+
}, {}, {
|
|
1346
|
+
length: number | undefined;
|
|
1347
|
+
}>;
|
|
1329
1348
|
};
|
|
1330
1349
|
dialect: "sqlite";
|
|
1331
1350
|
}>;
|
|
@@ -95,10 +95,12 @@ export const entities = sqliteTable("entities", {
|
|
|
95
95
|
affinityWorkerId: text("affinity_worker_id"),
|
|
96
96
|
affinityRole: text("affinity_role"),
|
|
97
97
|
affinityExpiresAt: integer("affinity_expires_at"),
|
|
98
|
+
parentEntityId: text("parent_entity_id"),
|
|
98
99
|
}, (table) => ({
|
|
99
100
|
flowStateIdx: index("entities_flow_state_idx").on(table.flowId, table.state),
|
|
100
101
|
claimIdx: index("entities_claim_idx").on(table.flowId, table.state, table.claimedBy),
|
|
101
102
|
affinityIdx: index("entities_affinity_idx").on(table.affinityWorkerId, table.affinityRole, table.affinityExpiresAt),
|
|
103
|
+
parentIdx: index("entities_parent_idx").on(table.parentEntityId),
|
|
102
104
|
}));
|
|
103
105
|
export const invocations = sqliteTable("invocations", {
|
|
104
106
|
id: text("id").primaryKey(),
|
|
@@ -157,4 +159,6 @@ export const events = sqliteTable("events", {
|
|
|
157
159
|
emittedAt: integer("emitted_at").notNull(),
|
|
158
160
|
}, (table) => ({
|
|
159
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),
|
|
160
164
|
}));
|
|
@@ -35,6 +35,7 @@ export interface Entity {
|
|
|
35
35
|
affinityWorkerId: string | null;
|
|
36
36
|
affinityRole: string | null;
|
|
37
37
|
affinityExpiresAt: Date | null;
|
|
38
|
+
parentEntityId: string | null;
|
|
38
39
|
}
|
|
39
40
|
/** A single agent invocation tied to an entity */
|
|
40
41
|
export interface Invocation {
|
|
@@ -224,7 +225,7 @@ export interface CreateGateInput {
|
|
|
224
225
|
/** Data-access contract for entity lifecycle operations. */
|
|
225
226
|
export interface IEntityRepository {
|
|
226
227
|
/** Create a new entity in the given flow's initial state. */
|
|
227
|
-
create(flowId: string, initialState: string, refs?: Refs, flowVersion?: number): Promise<Entity>;
|
|
228
|
+
create(flowId: string, initialState: string, refs?: Refs, flowVersion?: number, parentEntityId?: string): Promise<Entity>;
|
|
228
229
|
/** Get an entity by ID, or null if not found. */
|
|
229
230
|
get(id: string): Promise<Entity | null>;
|
|
230
231
|
/** Find entities in a given flow and state, up to an optional limit. */
|
|
@@ -255,6 +256,8 @@ export interface IEntityRepository {
|
|
|
255
256
|
childFlow: string;
|
|
256
257
|
spawnedAt: string;
|
|
257
258
|
}): Promise<void>;
|
|
259
|
+
/** Find all direct children of a parent entity. */
|
|
260
|
+
findByParentId(parentEntityId: string): Promise<Entity[]>;
|
|
258
261
|
/** Move entity to 'cancelled' terminal state and clear claimedBy/claimedAt. */
|
|
259
262
|
cancelEntity(entityId: string): Promise<void>;
|
|
260
263
|
/** Move entity to targetState and clear claimedBy/claimedAt. Returns the updated entity. */
|
|
@@ -335,10 +338,23 @@ export interface ITransitionLogRepository {
|
|
|
335
338
|
/** Get full transition history for an entity, ordered by timestamp. */
|
|
336
339
|
historyFor(entityId: string): Promise<TransitionLog[]>;
|
|
337
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
|
+
}
|
|
338
350
|
/** Data-access contract for emitting definition-change events. */
|
|
339
351
|
export interface IEventRepository {
|
|
340
352
|
/** Emit a definition change event for a tool action. */
|
|
341
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[]>;
|
|
342
358
|
}
|
|
343
359
|
/** Data-access contract for gate definitions and result recording. */
|
|
344
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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/\"/g,'"').replace(/'/g,''');\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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
|
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
|
+
}
|
|
@@ -106,6 +106,13 @@
|
|
|
106
106
|
"when": 1773031139526,
|
|
107
107
|
"tag": "0014_smiling_crusher_hogan",
|
|
108
108
|
"breakpoints": true
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
"idx": 15,
|
|
112
|
+
"version": "6",
|
|
113
|
+
"when": 1773200000001,
|
|
114
|
+
"tag": "0015_add_parent_entity_id",
|
|
115
|
+
"breakpoints": true
|
|
109
116
|
}
|
|
110
117
|
]
|
|
111
|
-
}
|
|
118
|
+
}
|