@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.
package/dist/src/api/server.js
CHANGED
|
@@ -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/:
|
|
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.
|
|
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/:
|
|
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.
|
|
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
|
|
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",
|