@wopr-network/defcon 1.1.0 → 1.2.1

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.
@@ -249,20 +249,20 @@ export function createHttpServer(deps) {
249
249
  return mcpResultToApi(result);
250
250
  });
251
251
  // --- Admin: Drain/Undrain Worker ---
252
- router.add("POST", "/api/admin/workers/:workerId/drain", async (req) => {
252
+ router.add("POST", "/api/admin/workers/:worker_id/drain", async (req) => {
253
253
  const authErr = requireAdminToken(deps, req);
254
254
  if (authErr)
255
255
  return authErr;
256
256
  const callerToken = extractBearerToken(req.authorization);
257
- const result = await callToolHandler(deps.mcpDeps, "admin.worker.drain", { worker_id: req.params.workerId }, { adminToken: deps.adminToken, callerToken });
257
+ const result = await callToolHandler(deps.mcpDeps, "admin.worker.drain", { worker_id: req.params.worker_id }, { adminToken: deps.adminToken, callerToken });
258
258
  return mcpResultToApi(result);
259
259
  });
260
- router.add("POST", "/api/admin/workers/:workerId/undrain", async (req) => {
260
+ router.add("POST", "/api/admin/workers/:worker_id/undrain", async (req) => {
261
261
  const authErr = requireAdminToken(deps, req);
262
262
  if (authErr)
263
263
  return authErr;
264
264
  const callerToken = extractBearerToken(req.authorization);
265
- const result = await callToolHandler(deps.mcpDeps, "admin.worker.undrain", { worker_id: req.params.workerId }, { adminToken: deps.adminToken, callerToken });
265
+ const result = await callToolHandler(deps.mcpDeps, "admin.worker.undrain", { worker_id: req.params.worker_id }, { adminToken: deps.adminToken, callerToken });
266
266
  return mcpResultToApi(result);
267
267
  });
268
268
  // --- Admin: Rerun Gate ---
@@ -22,6 +22,7 @@ import { DrizzleInvocationRepository } from "../repositories/drizzle/invocation.
22
22
  import * as schema from "../repositories/drizzle/schema.js";
23
23
  import { entities, entityHistory, flowDefinitions, flowVersions, gateDefinitions, gateResults, invocations, stateDefinitions, transitionRules, } from "../repositories/drizzle/schema.js";
24
24
  import { DrizzleTransitionLogRepository } from "../repositories/drizzle/transition-log.repo.js";
25
+ import { WebSocketBroadcaster } from "../ws/broadcast.js";
25
26
  import { createMcpServer, startStdioServer } from "./mcp-server.js";
26
27
  import { provisionWorktree } from "./provision-worktree.js";
27
28
  const DB_DEFAULT = process.env.DEFCON_DB_PATH ?? "./defcon.db";
@@ -244,6 +245,14 @@ program
244
245
  workerToken,
245
246
  corsOrigin: restCorsResult.origin ?? undefined,
246
247
  });
248
+ if (adminToken) {
249
+ const wsBroadcaster = new WebSocketBroadcaster({
250
+ server: restHttpServer,
251
+ engine,
252
+ adminToken,
253
+ });
254
+ eventEmitter.register(wsBroadcaster);
255
+ }
247
256
  restHttpServer.listen(httpPort, httpHost, () => {
248
257
  const addr = restHttpServer?.address();
249
258
  const boundPort = addr && typeof addr === "object" ? addr.port : httpPort;
@@ -771,6 +771,7 @@ async function handleAdminFlowPause(deps, args) {
771
771
  const flow = await deps.flows.getByName(v.data.flow_name);
772
772
  if (!flow)
773
773
  return errorResult(`Flow not found: ${v.data.flow_name}`);
774
+ await deps.flows.snapshot(flow.id);
774
775
  await deps.flows.update(flow.id, { paused: true });
775
776
  emitDefinitionChanged(deps.eventRepo, flow.id, "admin.flow.pause", { name: v.data.flow_name });
776
777
  return jsonResult({ paused: true, flow: v.data.flow_name });
@@ -782,6 +783,7 @@ async function handleAdminFlowResume(deps, args) {
782
783
  const flow = await deps.flows.getByName(v.data.flow_name);
783
784
  if (!flow)
784
785
  return errorResult(`Flow not found: ${v.data.flow_name}`);
786
+ await deps.flows.snapshot(flow.id);
785
787
  await deps.flows.update(flow.id, { paused: false });
786
788
  emitDefinitionChanged(deps.eventRepo, flow.id, "admin.flow.resume", { name: v.data.flow_name });
787
789
  return jsonResult({ paused: false, flow: v.data.flow_name });
@@ -793,6 +795,12 @@ async function handleAdminEntityCancel(deps, args) {
793
795
  const entity = await deps.entities.get(v.data.entity_id);
794
796
  if (!entity)
795
797
  return errorResult(`Entity not found: ${v.data.entity_id}`);
798
+ const flow = await deps.flows.get(entity.flowId);
799
+ if (!flow)
800
+ return errorResult(`Flow not found for entity: ${v.data.entity_id}`);
801
+ const cancelledState = flow.states.find((s) => s.name === "cancelled");
802
+ if (!cancelledState)
803
+ return errorResult(`State 'cancelled' not found in flow '${flow.name}'`);
796
804
  const invocations = await deps.invocations.findByEntity(v.data.entity_id);
797
805
  for (const inv of invocations) {
798
806
  if (inv.completedAt === null && inv.failedAt === null) {
@@ -800,6 +808,14 @@ async function handleAdminEntityCancel(deps, args) {
800
808
  }
801
809
  }
802
810
  await deps.entities.cancelEntity(v.data.entity_id);
811
+ await deps.transitions.record({
812
+ entityId: v.data.entity_id,
813
+ fromState: entity.state,
814
+ toState: "cancelled",
815
+ trigger: "admin.cancel",
816
+ invocationId: null,
817
+ timestamp: new Date(),
818
+ });
803
819
  return jsonResult({ cancelled: true, entity_id: v.data.entity_id });
804
820
  }
805
821
  async function handleAdminEntityReset(deps, args) {
@@ -822,6 +838,14 @@ async function handleAdminEntityReset(deps, args) {
822
838
  }
823
839
  }
824
840
  const updated = await deps.entities.resetEntity(v.data.entity_id, v.data.target_state);
841
+ await deps.transitions.record({
842
+ entityId: v.data.entity_id,
843
+ fromState: entity.state,
844
+ toState: updated.state,
845
+ trigger: "admin.reset",
846
+ invocationId: null,
847
+ timestamp: new Date(),
848
+ });
825
849
  return jsonResult({ reset: true, entity_id: v.data.entity_id, state: updated.state });
826
850
  }
827
851
  async function handleAdminWorkerDrain(deps, args) {
@@ -97,7 +97,7 @@ export class DrizzleGateRepository {
97
97
  return rows.map(toGateResult);
98
98
  }
99
99
  async clearResult(entityId, gateId) {
100
- this.db
100
+ await this.db
101
101
  .delete(gateResults)
102
102
  .where(and(eq(gateResults.entityId, entityId), eq(gateResults.gateId, gateId)))
103
103
  .run();
@@ -0,0 +1,21 @@
1
+ import type http from "node:http";
2
+ import type { Engine } from "../engine/engine.js";
3
+ import type { EngineEvent, IEventBusAdapter } from "../engine/event-types.js";
4
+ export interface WebSocketBroadcasterDeps {
5
+ server: http.Server;
6
+ engine: Engine;
7
+ adminToken: string;
8
+ }
9
+ export declare class WebSocketBroadcaster implements IEventBusAdapter {
10
+ private wss;
11
+ private engine;
12
+ private adminToken;
13
+ constructor(deps: WebSocketBroadcasterDeps);
14
+ private isWsPath;
15
+ private authenticate;
16
+ private tokenMatches;
17
+ private sendSnapshot;
18
+ emit(event: EngineEvent): Promise<void>;
19
+ get clientCount(): number;
20
+ close(): Promise<void>;
21
+ }
@@ -0,0 +1,105 @@
1
+ import { createHash, timingSafeEqual } from "node:crypto";
2
+ import { WebSocketServer } from "ws";
3
+ export class WebSocketBroadcaster {
4
+ wss;
5
+ engine;
6
+ adminToken;
7
+ constructor(deps) {
8
+ this.engine = deps.engine;
9
+ this.adminToken = deps.adminToken;
10
+ this.wss = new WebSocketServer({ noServer: true });
11
+ deps.server.on("upgrade", (req, socket, head) => {
12
+ if (!this.isWsPath(req.url)) {
13
+ socket.destroy();
14
+ return;
15
+ }
16
+ if (!this.authenticate(req)) {
17
+ socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
18
+ socket.destroy();
19
+ return;
20
+ }
21
+ this.wss.handleUpgrade(req, socket, head, (ws) => {
22
+ this.wss.emit("connection", ws, req);
23
+ });
24
+ });
25
+ this.wss.on("connection", (ws) => {
26
+ this.sendSnapshot(ws);
27
+ ws.on("close", () => {
28
+ // Client removed automatically from wss.clients on close
29
+ });
30
+ });
31
+ }
32
+ isWsPath(url) {
33
+ if (!url)
34
+ return false;
35
+ const pathname = url.split("?")[0];
36
+ return pathname === "/ws";
37
+ }
38
+ authenticate(req) {
39
+ // Try Authorization header first
40
+ const authHeader = req.headers.authorization;
41
+ if (authHeader) {
42
+ const lower = authHeader.toLowerCase();
43
+ if (lower.startsWith("bearer ")) {
44
+ const token = authHeader.slice(7).trim();
45
+ if (token && this.tokenMatches(token))
46
+ return true;
47
+ }
48
+ }
49
+ // Try ?token= query param
50
+ const url = new URL(req.url ?? "/", "http://localhost");
51
+ const queryToken = url.searchParams.get("token");
52
+ if (queryToken && this.tokenMatches(queryToken))
53
+ return true;
54
+ return false;
55
+ }
56
+ tokenMatches(callerToken) {
57
+ const hashA = createHash("sha256").update(this.adminToken).digest();
58
+ const hashB = createHash("sha256").update(callerToken).digest();
59
+ return timingSafeEqual(hashA, hashB);
60
+ }
61
+ async sendSnapshot(ws) {
62
+ try {
63
+ const status = await this.engine.getStatus();
64
+ const msg = JSON.stringify({
65
+ type: "snapshot",
66
+ payload: { status },
67
+ timestamp: new Date().toISOString(),
68
+ });
69
+ if (ws.readyState === ws.OPEN) {
70
+ ws.send(msg);
71
+ }
72
+ }
73
+ catch {
74
+ if (ws.readyState === ws.OPEN) {
75
+ ws.send(JSON.stringify({
76
+ type: "snapshot",
77
+ payload: { status: { flows: {}, activeInvocations: 0, pendingClaims: 0 } },
78
+ timestamp: new Date().toISOString(),
79
+ }));
80
+ }
81
+ }
82
+ }
83
+ async emit(event) {
84
+ const { type, emittedAt, ...rest } = event;
85
+ const msg = JSON.stringify({
86
+ type,
87
+ payload: rest,
88
+ timestamp: emittedAt.toISOString(),
89
+ });
90
+ for (const client of this.wss.clients) {
91
+ if (client.readyState === client.OPEN) {
92
+ client.send(msg);
93
+ }
94
+ }
95
+ }
96
+ get clientCount() {
97
+ return this.wss.clients.size;
98
+ }
99
+ close() {
100
+ for (const client of this.wss.clients) {
101
+ client.terminate();
102
+ }
103
+ return new Promise((resolve) => this.wss.close(() => resolve()));
104
+ }
105
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wopr-network/defcon",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
4
4
  "type": "module",
5
5
  "packageManager": "pnpm@9.15.4",
6
6
  "engines": {
@@ -45,12 +45,14 @@
45
45
  "commander": "^14.0.3",
46
46
  "drizzle-orm": "^0.45.1",
47
47
  "handlebars": "^4.7.8",
48
+ "ws": "^8.19.0",
48
49
  "zod": "^4.3.6"
49
50
  },
50
51
  "devDependencies": {
51
52
  "@biomejs/biome": "^2.4.4",
52
53
  "@types/better-sqlite3": "^7.6.13",
53
54
  "@types/node": "^25.3.3",
55
+ "@types/ws": "^8.18.1",
54
56
  "@vitest/coverage-v8": "^4.0.18",
55
57
  "drizzle-kit": "^0.31.4",
56
58
  "tsx": "^4.21.0",