@winspan/claude-forge 8.30.0 → 8.33.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/README.md +4 -4
- package/dist/capability/execution-manager.d.ts +38 -1
- package/dist/capability/execution-manager.d.ts.map +1 -1
- package/dist/capability/execution-manager.js +93 -1
- package/dist/capability/execution-manager.js.map +1 -1
- package/dist/capability/executor/background-executor.d.ts +1 -0
- package/dist/capability/executor/background-executor.d.ts.map +1 -1
- package/dist/capability/executor/background-executor.js +27 -4
- package/dist/capability/executor/background-executor.js.map +1 -1
- package/dist/capability/executor/orchestrator.d.ts +15 -2
- package/dist/capability/executor/orchestrator.d.ts.map +1 -1
- package/dist/capability/executor/orchestrator.js +82 -3
- package/dist/capability/executor/orchestrator.js.map +1 -1
- package/dist/capability/executor/worker-auth-probe.d.ts.map +1 -1
- package/dist/capability/executor/worker-auth-probe.js +11 -2
- package/dist/capability/executor/worker-auth-probe.js.map +1 -1
- package/dist/capability/methodologies/bmad.yaml +17 -5
- package/dist/capability/methodologies/code-quality-audit.yaml +26 -0
- package/dist/capability/methodologies/harness-engineering.yaml +12 -6
- package/dist/capability/methodologies/test-coverage-scan.yaml +26 -0
- package/dist/capability/methodology-planner.d.ts +17 -1
- package/dist/capability/methodology-planner.d.ts.map +1 -1
- package/dist/capability/methodology-planner.js +125 -0
- package/dist/capability/methodology-planner.js.map +1 -1
- package/dist/capability/methodology-registry.d.ts.map +1 -1
- package/dist/capability/methodology-registry.js +21 -5
- package/dist/capability/methodology-registry.js.map +1 -1
- package/dist/capability/types.d.ts +2 -0
- package/dist/capability/types.d.ts.map +1 -1
- package/dist/core/ai/provider.d.ts +17 -9
- package/dist/core/ai/provider.d.ts.map +1 -1
- package/dist/core/ai/provider.js +130 -23
- package/dist/core/ai/provider.js.map +1 -1
- package/dist/core/ai/types.d.ts +26 -5
- package/dist/core/ai/types.d.ts.map +1 -1
- package/dist/core/storage/rows.d.ts +153 -0
- package/dist/core/storage/rows.d.ts.map +1 -0
- package/dist/core/storage/rows.js +14 -0
- package/dist/core/storage/rows.js.map +1 -0
- package/dist/core/storage/schema.sql +26 -2
- package/dist/core/storage/sqlite.d.ts +95 -7
- package/dist/core/storage/sqlite.d.ts.map +1 -1
- package/dist/core/storage/sqlite.js +409 -22
- package/dist/core/storage/sqlite.js.map +1 -1
- package/dist/core/utils/token-tracker.d.ts +40 -0
- package/dist/core/utils/token-tracker.d.ts.map +1 -0
- package/dist/core/utils/token-tracker.js +70 -0
- package/dist/core/utils/token-tracker.js.map +1 -0
- package/dist/daemon/handlers/post-tool-use.d.ts +1 -0
- package/dist/daemon/handlers/post-tool-use.d.ts.map +1 -1
- package/dist/daemon/handlers/post-tool-use.js +7 -0
- package/dist/daemon/handlers/post-tool-use.js.map +1 -1
- package/dist/daemon/handlers/stop.d.ts +11 -0
- package/dist/daemon/handlers/stop.d.ts.map +1 -1
- package/dist/daemon/handlers/stop.js +52 -0
- package/dist/daemon/handlers/stop.js.map +1 -1
- package/dist/daemon/handlers/user-prompt.d.ts.map +1 -1
- package/dist/daemon/handlers/user-prompt.js +63 -4
- package/dist/daemon/handlers/user-prompt.js.map +1 -1
- package/dist/daemon/idle-detector.d.ts +35 -0
- package/dist/daemon/idle-detector.d.ts.map +1 -0
- package/dist/daemon/idle-detector.js +56 -0
- package/dist/daemon/idle-detector.js.map +1 -0
- package/dist/daemon/idle-trigger.d.ts +53 -0
- package/dist/daemon/idle-trigger.d.ts.map +1 -0
- package/dist/daemon/idle-trigger.js +153 -0
- package/dist/daemon/idle-trigger.js.map +1 -0
- package/dist/daemon/index.d.ts.map +1 -1
- package/dist/daemon/index.js +30 -2
- package/dist/daemon/index.js.map +1 -1
- package/dist/daemon/routing-observer.d.ts +2 -1
- package/dist/daemon/routing-observer.d.ts.map +1 -1
- package/dist/daemon/routing-observer.js +117 -39
- package/dist/daemon/routing-observer.js.map +1 -1
- package/dist/engine/agent-router.d.ts +6 -0
- package/dist/engine/agent-router.d.ts.map +1 -1
- package/dist/engine/agent-router.js +13 -1
- package/dist/engine/agent-router.js.map +1 -1
- package/dist/engine/conventions/routing.yaml +15 -0
- package/dist/engine/dsl/compiler.d.ts.map +1 -1
- package/dist/engine/dsl/compiler.js +85 -3
- package/dist/engine/dsl/compiler.js.map +1 -1
- package/dist/engine/recommender.d.ts.map +1 -1
- package/dist/engine/recommender.js +10 -1
- package/dist/engine/recommender.js.map +1 -1
- package/dist/intelligence/classifier.d.ts +6 -0
- package/dist/intelligence/classifier.d.ts.map +1 -1
- package/dist/intelligence/classifier.js +57 -0
- package/dist/intelligence/classifier.js.map +1 -1
- package/dist/skills/registry.d.ts +6 -0
- package/dist/skills/registry.d.ts.map +1 -1
- package/dist/skills/registry.js +49 -14
- package/dist/skills/registry.js.map +1 -1
- package/dist/skills/semantic-matcher.d.ts +1 -0
- package/dist/skills/semantic-matcher.d.ts.map +1 -1
- package/dist/skills/semantic-matcher.js +6 -1
- package/dist/skills/semantic-matcher.js.map +1 -1
- package/dist/web/auth-middleware.d.ts +22 -0
- package/dist/web/auth-middleware.d.ts.map +1 -0
- package/dist/web/auth-middleware.js +51 -0
- package/dist/web/auth-middleware.js.map +1 -0
- package/dist/web/routes/agents.d.ts +7 -0
- package/dist/web/routes/agents.d.ts.map +1 -0
- package/dist/web/routes/agents.js +192 -0
- package/dist/web/routes/agents.js.map +1 -0
- package/dist/web/routes/ai.d.ts +10 -0
- package/dist/web/routes/ai.d.ts.map +1 -0
- package/dist/web/routes/ai.js +197 -0
- package/dist/web/routes/ai.js.map +1 -0
- package/dist/web/routes/auth.d.ts +12 -0
- package/dist/web/routes/auth.d.ts.map +1 -0
- package/dist/web/routes/auth.js +20 -0
- package/dist/web/routes/auth.js.map +1 -0
- package/dist/web/routes/events.d.ts +11 -0
- package/dist/web/routes/events.d.ts.map +1 -0
- package/dist/web/routes/events.js +43 -0
- package/dist/web/routes/events.js.map +1 -0
- package/dist/web/routes/execution-trace.d.ts +13 -0
- package/dist/web/routes/execution-trace.d.ts.map +1 -0
- package/dist/web/routes/execution-trace.js +308 -0
- package/dist/web/routes/execution-trace.js.map +1 -0
- package/dist/web/routes/experiments.d.ts +15 -0
- package/dist/web/routes/experiments.d.ts.map +1 -0
- package/dist/web/routes/experiments.js +187 -0
- package/dist/web/routes/experiments.js.map +1 -0
- package/dist/web/routes/methodology.d.ts +12 -0
- package/dist/web/routes/methodology.d.ts.map +1 -0
- package/dist/web/routes/methodology.js +228 -0
- package/dist/web/routes/methodology.js.map +1 -0
- package/dist/web/routes/patch.d.ts +7 -0
- package/dist/web/routes/patch.d.ts.map +1 -0
- package/dist/web/routes/patch.js +106 -0
- package/dist/web/routes/patch.js.map +1 -0
- package/dist/web/routes/routing.d.ts +17 -0
- package/dist/web/routes/routing.d.ts.map +1 -0
- package/dist/web/routes/routing.js +582 -0
- package/dist/web/routes/routing.js.map +1 -0
- package/dist/web/routes/rules.d.ts +7 -0
- package/dist/web/routes/rules.d.ts.map +1 -0
- package/dist/web/routes/rules.js +105 -0
- package/dist/web/routes/rules.js.map +1 -0
- package/dist/web/routes/sessions.d.ts +10 -0
- package/dist/web/routes/sessions.d.ts.map +1 -0
- package/dist/web/routes/sessions.js +234 -0
- package/dist/web/routes/sessions.js.map +1 -0
- package/dist/web/routes/skills.d.ts +10 -0
- package/dist/web/routes/skills.d.ts.map +1 -0
- package/dist/web/routes/skills.js +272 -0
- package/dist/web/routes/skills.js.map +1 -0
- package/dist/web/routes/static.d.ts +19 -0
- package/dist/web/routes/static.d.ts.map +1 -0
- package/dist/web/routes/static.js +61 -0
- package/dist/web/routes/static.js.map +1 -0
- package/dist/web/routes/status.d.ts +7 -0
- package/dist/web/routes/status.d.ts.map +1 -0
- package/dist/web/routes/status.js +28 -0
- package/dist/web/routes/status.js.map +1 -0
- package/dist/web/routes/token-usage.d.ts +7 -0
- package/dist/web/routes/token-usage.d.ts.map +1 -0
- package/dist/web/routes/token-usage.js +33 -0
- package/dist/web/routes/token-usage.js.map +1 -0
- package/dist/web/routes/types.d.ts +40 -0
- package/dist/web/routes/types.d.ts.map +1 -0
- package/dist/web/routes/types.js +52 -0
- package/dist/web/routes/types.js.map +1 -0
- package/dist/web/server.d.ts +7 -4
- package/dist/web/server.d.ts.map +1 -1
- package/dist/web/server.js +60 -2330
- package/dist/web/server.js.map +1 -1
- package/dist/web/ssrf-guard.d.ts +35 -0
- package/dist/web/ssrf-guard.d.ts.map +1 -0
- package/dist/web/ssrf-guard.js +93 -0
- package/dist/web/ssrf-guard.js.map +1 -0
- package/dist/web/static/assets/{AIConfig-nZgwaowr.js → AIConfig-D-vrYoJ3.js} +2 -2
- package/dist/web/static/assets/{AIConfig-nZgwaowr.js.map → AIConfig-D-vrYoJ3.js.map} +1 -1
- package/dist/web/static/assets/{Agents-BZGXKWC7.js → Agents-DAGWYsJj.js} +2 -2
- package/dist/web/static/assets/{Agents-BZGXKWC7.js.map → Agents-DAGWYsJj.js.map} +1 -1
- package/dist/web/static/assets/{Events-CnA3f740.js → Events-BoQ8Fo5k.js} +2 -2
- package/dist/web/static/assets/{Events-CnA3f740.js.map → Events-BoQ8Fo5k.js.map} +1 -1
- package/dist/web/static/assets/{ExecutionTrace-ClPfFIQa.js → ExecutionTrace-sFZ_vHNf.js} +2 -2
- package/dist/web/static/assets/{ExecutionTrace-ClPfFIQa.js.map → ExecutionTrace-sFZ_vHNf.js.map} +1 -1
- package/dist/web/static/assets/Methodologies-C0-Keokj.js +5 -0
- package/dist/web/static/assets/Methodologies-C0-Keokj.js.map +1 -0
- package/dist/web/static/assets/{Sessions-DwWOKgnl.js → Sessions-Bjf-Mvwb.js} +2 -2
- package/dist/web/static/assets/{Sessions-DwWOKgnl.js.map → Sessions-Bjf-Mvwb.js.map} +1 -1
- package/dist/web/static/assets/{Skills-DhM6ALhr.js → Skills-CrLshkrJ.js} +2 -2
- package/dist/web/static/assets/{Skills-DhM6ALhr.js.map → Skills-CrLshkrJ.js.map} +1 -1
- package/dist/web/static/assets/{index-DUYj2ek1.js → index-D23sAOAt.js} +3 -3
- package/dist/web/static/assets/{index-DUYj2ek1.js.map → index-D23sAOAt.js.map} +1 -1
- package/dist/web/static/assets/index-Drpf7sLl.css +1 -0
- package/dist/web/static/index.html +2 -2
- package/package.json +3 -2
- package/dist/web/static/assets/Methodologies-CAXUXeox.js +0 -2
- package/dist/web/static/assets/Methodologies-CAXUXeox.js.map +0 -1
- package/dist/web/static/assets/index-CVWult53.css +0 -1
package/dist/web/server.js
CHANGED
|
@@ -1,2350 +1,80 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Web API server —
|
|
2
|
+
* Web API server — Express entry for the Forge dashboard.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* server.ts is intentionally thin: it wires middleware, composes the route
|
|
5
|
+
* modules under src/web/routes/, and owns lifecycle (start/stop). Each route
|
|
6
|
+
* module is responsible for its own domain; see src/web/routes/types.ts for
|
|
7
|
+
* the shared RouteContext.
|
|
5
8
|
*/
|
|
6
9
|
import express from 'express';
|
|
7
|
-
import fs from 'fs';
|
|
8
|
-
import path from 'path';
|
|
9
|
-
import { fileURLToPath } from 'url';
|
|
10
|
-
import { homedir } from 'os';
|
|
11
|
-
import yaml from 'js-yaml';
|
|
12
|
-
import { WorkerAuthError } from '../capability/executor/worker-auth-probe.js';
|
|
13
|
-
import { Recommender } from '../engine/recommender.js';
|
|
14
10
|
import { logger } from '../core/utils/logger.js';
|
|
15
11
|
import { ErrorHandler } from '../core/utils/error-handler.js';
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
18
|
-
|
|
12
|
+
import { requireAuth } from './auth-middleware.js';
|
|
13
|
+
import { registerAuthRoutes } from './routes/auth.js';
|
|
14
|
+
import { registerStaticAssets, registerStaticFallback } from './routes/static.js';
|
|
15
|
+
import { registerStatusRoutes } from './routes/status.js';
|
|
16
|
+
import { registerEventsRoutes } from './routes/events.js';
|
|
17
|
+
import { registerSessionsRoutes } from './routes/sessions.js';
|
|
18
|
+
import { registerRulesRoutes } from './routes/rules.js';
|
|
19
|
+
import { registerRoutingRoutes } from './routes/routing.js';
|
|
20
|
+
import { registerExperimentsRoutes } from './routes/experiments.js';
|
|
21
|
+
import { registerMethodologyRoutes } from './routes/methodology.js';
|
|
22
|
+
import { registerTokenUsageRoutes } from './routes/token-usage.js';
|
|
23
|
+
import { registerExecutionTraceRoutes } from './routes/execution-trace.js';
|
|
24
|
+
import { registerAIRoutes } from './routes/ai.js';
|
|
25
|
+
import { registerPatchRoutes } from './routes/patch.js';
|
|
26
|
+
import { registerAgentsRoutes } from './routes/agents.js';
|
|
27
|
+
import { registerSkillsRoutes } from './routes/skills.js';
|
|
19
28
|
export class WebServer {
|
|
20
29
|
options;
|
|
21
30
|
app;
|
|
22
31
|
server = null;
|
|
23
|
-
agents;
|
|
24
|
-
router;
|
|
25
32
|
constructor(options) {
|
|
26
33
|
this.options = options;
|
|
27
34
|
this.app = express();
|
|
28
35
|
this.app.use(express.json());
|
|
29
|
-
this.agents = options.agents;
|
|
30
|
-
this.router = options.router;
|
|
31
36
|
this.setupRoutes();
|
|
32
37
|
}
|
|
33
38
|
setupRoutes() {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
filePath: path.join(homedir(), '.claude-forge', 'routing.yaml'),
|
|
51
|
-
backupDir: path.join(homedir(), '.claude-forge', 'backups', 'routing'),
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
throw new Error(`Unsupported targetType: ${targetType}`);
|
|
39
|
+
// Auth gate: write operations (non-GET) always require Bearer token.
|
|
40
|
+
// GET read endpoints stay open for polling & read-only dashboards.
|
|
41
|
+
this.app.use('/api', (req, res, next) => {
|
|
42
|
+
if (req.method === 'GET')
|
|
43
|
+
return next();
|
|
44
|
+
return requireAuth(req, res, next);
|
|
45
|
+
});
|
|
46
|
+
const ctx = {
|
|
47
|
+
storage: this.options.storage,
|
|
48
|
+
ruleEngine: this.options.ruleEngine,
|
|
49
|
+
router: this.options.router,
|
|
50
|
+
agents: this.options.agents,
|
|
51
|
+
skillRegistry: this.options.skillRegistry,
|
|
52
|
+
executionManager: this.options.executionManager,
|
|
53
|
+
methodologyRegistry: this.options.methodologyRegistry,
|
|
54
|
+
methodologyPlanner: this.options.methodologyPlanner,
|
|
55
55
|
};
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
this.app.get('/', (_req, res) => {
|
|
79
|
-
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
80
|
-
res.sendFile('index.html', { root: staticDir });
|
|
81
|
-
});
|
|
82
|
-
logger.info(`[Web] Serving static files from ${staticDir}`);
|
|
83
|
-
}
|
|
84
|
-
else {
|
|
85
|
-
logger.warn(`[Web] Static directory not found in: ${candidates.join(', ')}`);
|
|
86
|
-
}
|
|
87
|
-
// Dashboard: daemon status
|
|
88
|
-
this.app.get('/api/status', (_req, res) => {
|
|
89
|
-
res.json({
|
|
90
|
-
pid: process.pid,
|
|
91
|
-
uptime: process.uptime(),
|
|
92
|
-
memory: process.memoryUsage(),
|
|
93
|
-
eventCount: storage.countEvents({}),
|
|
94
|
-
});
|
|
95
|
-
});
|
|
96
|
-
// Events: recent events
|
|
97
|
-
this.app.get('/api/events', (req, res) => {
|
|
98
|
-
const limit = parseInt(req.query.limit) || 50;
|
|
99
|
-
const projectPath = req.query.project;
|
|
100
|
-
const sessionId = req.query.session;
|
|
101
|
-
const events = storage.queryEvents({ project_path: projectPath, session_id: sessionId, limit });
|
|
102
|
-
res.json(events);
|
|
103
|
-
});
|
|
104
|
-
// Sessions: list with first prompt
|
|
105
|
-
this.app.get('/api/sessions', (req, res) => {
|
|
106
|
-
const limit = parseInt(req.query.limit) || 10;
|
|
107
|
-
const projectPath = req.query.project;
|
|
108
|
-
const sessions = storage.querySessions({ project_path: projectPath, limit });
|
|
109
|
-
res.json(sessions);
|
|
110
|
-
});
|
|
111
|
-
// Injections: query injection history
|
|
112
|
-
this.app.get('/api/injections', (req, res) => {
|
|
113
|
-
const limit = parseInt(req.query.limit) || 50;
|
|
114
|
-
const sessionId = req.query.session;
|
|
115
|
-
const eventId = req.query.event;
|
|
116
|
-
const handler = req.query.handler;
|
|
117
|
-
const injections = storage.queryInjections({
|
|
118
|
-
session_id: sessionId,
|
|
119
|
-
event_id: eventId,
|
|
120
|
-
source_handler: handler,
|
|
121
|
-
limit
|
|
122
|
-
});
|
|
123
|
-
res.json(injections);
|
|
124
|
-
});
|
|
125
|
-
// Decisions: query governance decisions
|
|
126
|
-
this.app.get('/api/decisions', (req, res) => {
|
|
127
|
-
const limit = parseInt(req.query.limit) || 50;
|
|
128
|
-
const sessionId = req.query.session;
|
|
129
|
-
const decisions = storage.queryDecisions({ session_id: sessionId, limit });
|
|
130
|
-
res.json(decisions);
|
|
131
|
-
});
|
|
132
|
-
// Session Detail: task-grouped session data
|
|
133
|
-
this.app.get('/api/sessions/:sessionId/detail', (req, res) => {
|
|
134
|
-
const sessionId = req.params.sessionId;
|
|
135
|
-
const sessions = storage.querySessions({ limit: 1000 });
|
|
136
|
-
const session = sessions.find(s => s.session_id === sessionId || s.session_id.startsWith(sessionId));
|
|
137
|
-
if (!session) {
|
|
138
|
-
res.status(404).json({ error: 'Session not found' });
|
|
139
|
-
return;
|
|
140
|
-
}
|
|
141
|
-
const fullSessionId = session.session_id;
|
|
142
|
-
const tasks = storage.queryTasks({ session_id: fullSessionId });
|
|
143
|
-
const allEvents = storage.queryEvents({ session_id: fullSessionId, limit: 2000 });
|
|
144
|
-
const allInjections = storage.queryInjections({ session_id: fullSessionId, limit: 200 });
|
|
145
|
-
const allQualityIssues = storage.queryQualityIssues({ session_id: fullSessionId });
|
|
146
|
-
const allDecisions = storage.queryDecisions({ session_id: fullSessionId });
|
|
147
|
-
const buildTaskDetail = (events, injections) => {
|
|
148
|
-
const prompts = events
|
|
149
|
-
.filter(e => e.hook_type === 'UserPromptSubmit' && (e.user_prompt || e.tool_input?.user_prompt))
|
|
150
|
-
.map(e => ({ timestamp: e.timestamp, content: e.user_prompt || e.tool_input?.user_prompt }));
|
|
151
|
-
const toolUsage = {};
|
|
152
|
-
events.forEach(e => { if (e.tool_name)
|
|
153
|
-
toolUsage[e.tool_name] = (toolUsage[e.tool_name] || 0) + 1; });
|
|
154
|
-
const filesChanged = [...new Set(events
|
|
155
|
-
.filter(e => (e.tool_name === 'Edit' || e.tool_name === 'Write') && e.tool_input?.file_path)
|
|
156
|
-
.map(e => e.tool_input.file_path))];
|
|
157
|
-
const commits = events
|
|
158
|
-
.filter(e => e.tool_name === 'Bash' && e.tool_input?.command?.includes('git commit'))
|
|
159
|
-
.map(e => {
|
|
160
|
-
const cmd = e.tool_input?.command || '';
|
|
161
|
-
const match = cmd.match(/git commit.*-m\s+["']([^"']+)["']/);
|
|
162
|
-
return { timestamp: e.timestamp, message: match ? match[1] : 'commit' };
|
|
163
|
-
});
|
|
164
|
-
return { prompts, events: events.slice(0, 200), injections, summary: { toolUsage, filesChanged, commits } };
|
|
165
|
-
};
|
|
166
|
-
if (tasks.length === 0) {
|
|
167
|
-
const detail = buildTaskDetail(allEvents, allInjections);
|
|
168
|
-
const virtualTask = {
|
|
169
|
-
id: 'virtual',
|
|
170
|
-
session_id: fullSessionId,
|
|
171
|
-
title: session.first_prompt || '(未分类任务)',
|
|
172
|
-
start_time: session.start_time,
|
|
173
|
-
end_time: session.end_time,
|
|
174
|
-
status: 'completed',
|
|
175
|
-
event_count: allEvents.length,
|
|
176
|
-
...detail,
|
|
177
|
-
qualityIssues: allQualityIssues,
|
|
178
|
-
decisions: allDecisions,
|
|
179
|
-
};
|
|
180
|
-
res.json({ session, tasks: [virtualTask] });
|
|
181
|
-
return;
|
|
182
|
-
}
|
|
183
|
-
const taskDetails = tasks.map(task => {
|
|
184
|
-
const taskEventIds = new Set(storage.getTaskEventIds(task.id));
|
|
185
|
-
const events = allEvents.filter(e => taskEventIds.has(e.event_id ?? ''));
|
|
186
|
-
const injections = allInjections.filter(i => i.event_id && taskEventIds.has(i.event_id));
|
|
187
|
-
const qualityIssues = allQualityIssues.filter(q => events.some(e => e.tool_input?.file_path === q.file_path));
|
|
188
|
-
const decisions = allDecisions.filter(d => events.some(e => Math.abs(new Date(e.timestamp).getTime() - d.timestamp) < 5000));
|
|
189
|
-
const detail = buildTaskDetail(events, injections);
|
|
190
|
-
return { ...task, ...detail, qualityIssues, decisions };
|
|
191
|
-
});
|
|
192
|
-
res.json({ session, tasks: taskDetails });
|
|
193
|
-
});
|
|
194
|
-
// Session timeline: detailed event timeline for a session
|
|
195
|
-
this.app.get('/api/sessions/:id/timeline', (req, res) => {
|
|
196
|
-
const sessionIdPrefix = req.params.id;
|
|
197
|
-
const limit = parseInt(req.query.limit) || 50;
|
|
198
|
-
const offset = parseInt(req.query.offset) || 0;
|
|
199
|
-
const sessions = storage.querySessions({ limit: 1000 });
|
|
200
|
-
const session = sessions.find(s => s.session_id.startsWith(sessionIdPrefix));
|
|
201
|
-
if (!session) {
|
|
202
|
-
res.status(404).json({ error: 'Session not found' });
|
|
203
|
-
return;
|
|
204
|
-
}
|
|
205
|
-
const fullSessionId = session.session_id;
|
|
206
|
-
const events = storage.queryEvents({ session_id: fullSessionId, limit: 2000 });
|
|
207
|
-
const injections = storage.queryInjections({ session_id: fullSessionId, limit: 200 });
|
|
208
|
-
const qualityIssues = storage.queryQualityIssues({ session_id: fullSessionId, resolved: false });
|
|
209
|
-
// Build timeline: merge events and injections, sort by timestamp
|
|
210
|
-
const timeline = [];
|
|
211
|
-
// User inputs
|
|
212
|
-
events
|
|
213
|
-
.filter(e => e.hook_type === 'UserPromptSubmit' && (e.user_prompt || e.tool_input?.user_prompt))
|
|
214
|
-
.forEach(e => {
|
|
215
|
-
timeline.push({
|
|
216
|
-
timestamp: e.timestamp,
|
|
217
|
-
type: 'user_input',
|
|
218
|
-
data: { content: e.user_prompt || e.tool_input?.user_prompt },
|
|
219
|
-
});
|
|
220
|
-
});
|
|
221
|
-
// Injections (truncate content for preview)
|
|
222
|
-
injections.forEach(inj => {
|
|
223
|
-
timeline.push({
|
|
224
|
-
timestamp: inj.timestamp,
|
|
225
|
-
type: 'injection',
|
|
226
|
-
data: {
|
|
227
|
-
source: inj.source_handler,
|
|
228
|
-
injection_type: inj.injection_type,
|
|
229
|
-
content: inj.content.slice(0, 500), // Truncate to 500 chars
|
|
230
|
-
length: inj.content.length,
|
|
231
|
-
truncated: inj.content.length > 500,
|
|
232
|
-
},
|
|
233
|
-
});
|
|
234
|
-
});
|
|
235
|
-
// Tool calls (truncate large fields)
|
|
236
|
-
events
|
|
237
|
-
.filter(e => e.tool_name && e.hook_type === 'PreToolUse')
|
|
238
|
-
.forEach(e => {
|
|
239
|
-
const toolInput = e.tool_input;
|
|
240
|
-
const toolOutput = e.tool_output;
|
|
241
|
-
// Truncate large fields
|
|
242
|
-
const truncateField = (obj, maxLen = 200) => {
|
|
243
|
-
if (!obj)
|
|
244
|
-
return obj;
|
|
245
|
-
if (typeof obj === 'string')
|
|
246
|
-
return obj.length > maxLen ? obj.slice(0, maxLen) + '...' : obj;
|
|
247
|
-
if (Array.isArray(obj))
|
|
248
|
-
return obj.slice(0, 5);
|
|
249
|
-
if (typeof obj === 'object') {
|
|
250
|
-
const result = {};
|
|
251
|
-
for (const [k, v] of Object.entries(obj)) {
|
|
252
|
-
result[k] = truncateField(v, maxLen);
|
|
253
|
-
}
|
|
254
|
-
return result;
|
|
255
|
-
}
|
|
256
|
-
return obj;
|
|
257
|
-
};
|
|
258
|
-
timeline.push({
|
|
259
|
-
timestamp: e.timestamp,
|
|
260
|
-
type: 'tool_call',
|
|
261
|
-
data: {
|
|
262
|
-
tool: e.tool_name,
|
|
263
|
-
input: truncateField(toolInput),
|
|
264
|
-
output: truncateField(toolOutput),
|
|
265
|
-
success: !toolOutput?.error,
|
|
266
|
-
},
|
|
267
|
-
});
|
|
268
|
-
});
|
|
269
|
-
// Quality checks (from PostToolUse events with quality gate results)
|
|
270
|
-
events
|
|
271
|
-
.filter(e => e.hook_type === 'PostToolUse' && e.tool_output?.quality_check)
|
|
272
|
-
.forEach(e => {
|
|
273
|
-
const qc = e.tool_output.quality_check;
|
|
274
|
-
timeline.push({
|
|
275
|
-
timestamp: e.timestamp,
|
|
276
|
-
type: 'quality_check',
|
|
277
|
-
data: qc,
|
|
278
|
-
});
|
|
279
|
-
});
|
|
280
|
-
// Sort by timestamp
|
|
281
|
-
timeline.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
|
|
282
|
-
// Paginate
|
|
283
|
-
const total = timeline.length;
|
|
284
|
-
const paginatedTimeline = timeline.slice(offset, offset + limit);
|
|
285
|
-
// Summary
|
|
286
|
-
const commits = events
|
|
287
|
-
.filter(e => e.tool_name === 'Bash' && e.tool_input?.command?.includes('git commit'))
|
|
288
|
-
.map(e => {
|
|
289
|
-
const cmd = e.tool_input?.command || '';
|
|
290
|
-
const match = cmd.match(/git commit.*-m\s+["']([^"']+)["']/);
|
|
291
|
-
return { message: match ? match[1] : 'commit' };
|
|
292
|
-
});
|
|
293
|
-
const filesChanged = [...new Set(events
|
|
294
|
-
.filter(e => (e.tool_name === 'Edit' || e.tool_name === 'Write') && e.tool_input?.file_path)
|
|
295
|
-
.map(e => e.tool_input.file_path))];
|
|
296
|
-
res.json({
|
|
297
|
-
session,
|
|
298
|
-
timeline: paginatedTimeline,
|
|
299
|
-
total,
|
|
300
|
-
offset,
|
|
301
|
-
limit,
|
|
302
|
-
hasMore: offset + limit < total,
|
|
303
|
-
summary: {
|
|
304
|
-
commits,
|
|
305
|
-
filesChanged,
|
|
306
|
-
unresolvedIssues: qualityIssues,
|
|
307
|
-
},
|
|
308
|
-
});
|
|
309
|
-
});
|
|
310
|
-
// Rules: loaded conventions with statistics
|
|
311
|
-
this.app.get('/api/rules', (_req, res) => {
|
|
312
|
-
const conventions = ruleEngine.getConventions();
|
|
313
|
-
const conventionsArray = Array.from(conventions.entries());
|
|
314
|
-
// Add statistics for each convention
|
|
315
|
-
const conventionsWithStats = conventionsArray.map(([conventionId, conv]) => {
|
|
316
|
-
const decisions = storage.queryDecisions({ limit: 1000 });
|
|
317
|
-
const ruleIds = [...(conv.forbidden || []), ...(conv.escalation || [])].map(r => r.id);
|
|
318
|
-
const stats = {
|
|
319
|
-
totalTriggers: decisions.filter(d => ruleIds.includes(d.rule_id || '')).length,
|
|
320
|
-
blockCount: decisions.filter(d => ruleIds.includes(d.rule_id || '') && d.level === 'block').length,
|
|
321
|
-
warnCount: decisions.filter(d => ruleIds.includes(d.rule_id || '') && d.level === 'warn').length,
|
|
322
|
-
lastTriggered: decisions
|
|
323
|
-
.filter(d => ruleIds.includes(d.rule_id || ''))
|
|
324
|
-
.sort((a, b) => b.timestamp - a.timestamp)[0]?.timestamp || null,
|
|
325
|
-
};
|
|
326
|
-
return {
|
|
327
|
-
id: conventionId,
|
|
328
|
-
name: conv.name,
|
|
329
|
-
version: conv.version,
|
|
330
|
-
description: conv.description,
|
|
331
|
-
stats,
|
|
332
|
-
ruleCount: (conv.forbidden || []).length + (conv.escalation || []).length,
|
|
333
|
-
};
|
|
334
|
-
});
|
|
335
|
-
res.json(conventionsWithStats);
|
|
336
|
-
});
|
|
337
|
-
// Rule detail: get specific rule with trigger history
|
|
338
|
-
this.app.get('/api/rules/:conventionId', (req, res) => {
|
|
339
|
-
const conventionId = req.params.conventionId;
|
|
340
|
-
const conventions = ruleEngine.getConventions();
|
|
341
|
-
const convention = conventions.get(conventionId);
|
|
342
|
-
if (!convention) {
|
|
343
|
-
res.status(404).json({ error: 'Convention not found' });
|
|
344
|
-
return;
|
|
345
|
-
}
|
|
346
|
-
// Get all decisions for this convention's rules
|
|
347
|
-
const ruleIds = [...(convention.forbidden || []), ...(convention.escalation || [])].map(r => r.id);
|
|
348
|
-
const allDecisions = storage.queryDecisions({ limit: 500 });
|
|
349
|
-
const decisions = allDecisions.filter(d => ruleIds.includes(d.rule_id || ''));
|
|
350
|
-
// Group by rule
|
|
351
|
-
const ruleStats = ruleIds.map(ruleId => {
|
|
352
|
-
const ruleDecisions = decisions.filter(d => d.rule_id === ruleId);
|
|
353
|
-
const rule = [...(convention.forbidden || []), ...(convention.escalation || [])].find(r => r.id === ruleId);
|
|
354
|
-
return {
|
|
355
|
-
ruleId,
|
|
356
|
-
rule,
|
|
357
|
-
triggerCount: ruleDecisions.length,
|
|
358
|
-
blockCount: ruleDecisions.filter(d => d.level === 'block').length,
|
|
359
|
-
warnCount: ruleDecisions.filter(d => d.level === 'warn').length,
|
|
360
|
-
recentTriggers: ruleDecisions.slice(0, 10).map(d => ({
|
|
361
|
-
timestamp: d.timestamp,
|
|
362
|
-
level: d.level,
|
|
363
|
-
reason: d.reason,
|
|
364
|
-
})),
|
|
365
|
-
};
|
|
366
|
-
});
|
|
367
|
-
res.json({
|
|
368
|
-
convention,
|
|
369
|
-
ruleStats,
|
|
370
|
-
totalDecisions: decisions.length,
|
|
371
|
-
});
|
|
372
|
-
});
|
|
373
|
-
// Statistics: overall system statistics
|
|
374
|
-
this.app.get('/api/stats', (_req, res) => {
|
|
375
|
-
const sessions = storage.querySessions({ limit: 1000 });
|
|
376
|
-
const events = storage.queryEvents({ limit: 5000 });
|
|
377
|
-
const decisions = storage.queryDecisions({ limit: 1000 });
|
|
378
|
-
// Tool usage distribution
|
|
379
|
-
const toolUsage = {};
|
|
380
|
-
events.forEach(e => {
|
|
381
|
-
if (e.tool_name) {
|
|
382
|
-
toolUsage[e.tool_name] = (toolUsage[e.tool_name] || 0) + 1;
|
|
383
|
-
}
|
|
384
|
-
});
|
|
385
|
-
// Decision level distribution
|
|
386
|
-
const decisionLevels = {
|
|
387
|
-
block: decisions.filter(d => d.level === 'block').length,
|
|
388
|
-
warn: decisions.filter(d => d.level === 'warn').length,
|
|
389
|
-
confirm: decisions.filter(d => d.level === 'confirm').length,
|
|
390
|
-
allow: decisions.filter(d => d.level === 'allow').length,
|
|
391
|
-
};
|
|
392
|
-
// Daily activity (last 7 days, local dates to match DB-stored local ISO strings)
|
|
393
|
-
const today = new Date();
|
|
394
|
-
const dailyActivity = Array.from({ length: 7 }, (_, i) => {
|
|
395
|
-
const d = new Date(today.getFullYear(), today.getMonth(), today.getDate() - (6 - i));
|
|
396
|
-
const dateStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
|
397
|
-
const dayEvents = events.filter(e => e.timestamp.slice(0, 10) === dateStr);
|
|
398
|
-
return {
|
|
399
|
-
date: dateStr,
|
|
400
|
-
eventCount: dayEvents.length,
|
|
401
|
-
sessionCount: new Set(dayEvents.map(e => e.session_id)).size,
|
|
402
|
-
};
|
|
403
|
-
});
|
|
404
|
-
res.json({
|
|
405
|
-
totalSessions: sessions.length,
|
|
406
|
-
totalEvents: storage.countEvents({}),
|
|
407
|
-
totalDecisions: decisions.length,
|
|
408
|
-
toolUsage,
|
|
409
|
-
decisionLevels,
|
|
410
|
-
dailyActivity,
|
|
411
|
-
});
|
|
412
|
-
});
|
|
413
|
-
// ── Agent Routing API ─────────────────────────────────────────────────
|
|
414
|
-
// Overview stats for the Agent Routing page
|
|
415
|
-
this.app.get('/api/routing/stats', (req, res) => {
|
|
416
|
-
const windowHours = parseInt(req.query.window || '168'); // default 7d
|
|
417
|
-
const since = Date.now() - windowHours * 3600 * 1000;
|
|
418
|
-
const events = storage.queryRoutingEvents({ since_ts: since, limit: 5000 });
|
|
419
|
-
const total = events.length;
|
|
420
|
-
const byAgent = {};
|
|
421
|
-
let forced = 0;
|
|
422
|
-
let obeyedCount = 0;
|
|
423
|
-
let refusedCount = 0;
|
|
424
|
-
let unknownCount = 0;
|
|
425
|
-
let fallbackUsedCount = 0;
|
|
426
|
-
const latencies = [];
|
|
427
|
-
const byVersion = {};
|
|
428
|
-
for (const e of events) {
|
|
429
|
-
if (e.is_forced)
|
|
430
|
-
forced++;
|
|
431
|
-
if (e.fallback_used)
|
|
432
|
-
fallbackUsedCount++;
|
|
433
|
-
if (typeof e.classification_ms === 'number')
|
|
434
|
-
latencies.push(e.classification_ms);
|
|
435
|
-
const key = e.routed_to_name ?? '—';
|
|
436
|
-
const bucket = (byAgent[key] ||= { total: 0, obeyed: 0, refused: 0, unknown: 0 });
|
|
437
|
-
bucket.total++;
|
|
438
|
-
if (e.obeyed === 1) {
|
|
439
|
-
bucket.obeyed++;
|
|
440
|
-
obeyedCount++;
|
|
441
|
-
}
|
|
442
|
-
else if (e.obeyed === 0) {
|
|
443
|
-
bucket.refused++;
|
|
444
|
-
refusedCount++;
|
|
445
|
-
}
|
|
446
|
-
else {
|
|
447
|
-
bucket.unknown++;
|
|
448
|
-
unknownCount++;
|
|
449
|
-
}
|
|
450
|
-
const v = e.injection_version ?? '—';
|
|
451
|
-
const vb = (byVersion[v] ||= { total: 0, obeyed: 0 });
|
|
452
|
-
vb.total++;
|
|
453
|
-
if (e.obeyed === 1)
|
|
454
|
-
vb.obeyed++;
|
|
455
|
-
}
|
|
456
|
-
latencies.sort((a, b) => a - b);
|
|
457
|
-
const p = (pct) => latencies.length === 0 ? null : latencies[Math.min(latencies.length - 1, Math.floor(latencies.length * pct))];
|
|
458
|
-
res.json({
|
|
459
|
-
windowHours,
|
|
460
|
-
total,
|
|
461
|
-
forced,
|
|
462
|
-
obeyedCount,
|
|
463
|
-
refusedCount,
|
|
464
|
-
unknownCount,
|
|
465
|
-
obedienceRate: forced === 0 ? null : obeyedCount / forced,
|
|
466
|
-
refusalRate: forced === 0 ? null : refusedCount / forced,
|
|
467
|
-
fallbackUsedCount,
|
|
468
|
-
fallbackRate: total === 0 ? null : fallbackUsedCount / total,
|
|
469
|
-
latency: {
|
|
470
|
-
p50: p(0.5),
|
|
471
|
-
p95: p(0.95),
|
|
472
|
-
p99: p(0.99),
|
|
473
|
-
count: latencies.length,
|
|
474
|
-
},
|
|
475
|
-
byAgent,
|
|
476
|
-
byVersion,
|
|
477
|
-
});
|
|
478
|
-
});
|
|
479
|
-
// Performance analysis for the Agent Routing page
|
|
480
|
-
this.app.get('/api/routing/performance', (req, res) => {
|
|
481
|
-
const windowHours = parseInt(req.query.window || '168');
|
|
482
|
-
const minAttempts = Math.max(1, parseInt(req.query.minAttempts || '10'));
|
|
483
|
-
const since = Date.now() - windowHours * 3600 * 1000;
|
|
484
|
-
const events = storage.queryRoutingEvents({ since_ts: since, limit: 5000 });
|
|
485
|
-
const relevant = events.filter(e => e.routed_to_name);
|
|
486
|
-
const judged = relevant.filter(e => e.obeyed === 0 || e.obeyed === 1);
|
|
487
|
-
const obeyedCount = judged.filter(e => e.obeyed === 1).length;
|
|
488
|
-
const refusedCount = judged.filter(e => e.obeyed === 0).length;
|
|
489
|
-
const unknownCount = relevant.length - judged.length;
|
|
490
|
-
const latencies = relevant
|
|
491
|
-
.map(e => e.classification_ms)
|
|
492
|
-
.filter((ms) => typeof ms === 'number');
|
|
493
|
-
const executionLatencies = relevant
|
|
494
|
-
.map(e => e.total_execution_ms ?? ((typeof e.completed_ts === 'number') ? e.completed_ts - e.ts : null))
|
|
495
|
-
.filter((ms) => typeof ms === 'number');
|
|
496
|
-
const avgClassificationMs = latencies.length === 0
|
|
497
|
-
? null
|
|
498
|
-
: Math.round(latencies.reduce((sum, n) => sum + n, 0) / latencies.length);
|
|
499
|
-
const avgExecutionMs = executionLatencies.length === 0
|
|
500
|
-
? null
|
|
501
|
-
: Math.round(executionLatencies.reduce((sum, n) => sum + n, 0) / executionLatencies.length);
|
|
502
|
-
const sortedExecution = [...executionLatencies].sort((a, b) => a - b);
|
|
503
|
-
const p95ExecutionMs = sortedExecution.length === 0
|
|
504
|
-
? null
|
|
505
|
-
: sortedExecution[Math.min(sortedExecution.length - 1, Math.floor(sortedExecution.length * 0.95))];
|
|
506
|
-
const byAgentMap = new Map();
|
|
507
|
-
const dailyMap = new Map();
|
|
508
|
-
for (const e of relevant) {
|
|
509
|
-
const agent = e.routed_to_name ?? '—';
|
|
510
|
-
const agentBucket = byAgentMap.get(agent) ?? {
|
|
511
|
-
agent,
|
|
512
|
-
total: 0,
|
|
513
|
-
judged: 0,
|
|
514
|
-
obeyed: 0,
|
|
515
|
-
refused: 0,
|
|
516
|
-
unknown: 0,
|
|
517
|
-
latencySum: 0,
|
|
518
|
-
latencyCount: 0,
|
|
519
|
-
executionSum: 0,
|
|
520
|
-
executionCount: 0,
|
|
521
|
-
};
|
|
522
|
-
agentBucket.total++;
|
|
523
|
-
if (e.obeyed === 1) {
|
|
524
|
-
agentBucket.judged++;
|
|
525
|
-
agentBucket.obeyed++;
|
|
526
|
-
}
|
|
527
|
-
else if (e.obeyed === 0) {
|
|
528
|
-
agentBucket.judged++;
|
|
529
|
-
agentBucket.refused++;
|
|
530
|
-
}
|
|
531
|
-
else {
|
|
532
|
-
agentBucket.unknown++;
|
|
533
|
-
}
|
|
534
|
-
if (typeof e.classification_ms === 'number') {
|
|
535
|
-
agentBucket.latencySum += e.classification_ms;
|
|
536
|
-
agentBucket.latencyCount++;
|
|
537
|
-
}
|
|
538
|
-
const executionMs = e.total_execution_ms ?? ((typeof e.completed_ts === 'number') ? e.completed_ts - e.ts : null);
|
|
539
|
-
if (typeof executionMs === 'number') {
|
|
540
|
-
agentBucket.executionSum += executionMs;
|
|
541
|
-
agentBucket.executionCount++;
|
|
542
|
-
}
|
|
543
|
-
byAgentMap.set(agent, agentBucket);
|
|
544
|
-
const date = new Date(e.ts).toISOString().slice(0, 10);
|
|
545
|
-
const dayBucket = dailyMap.get(date) ?? {
|
|
546
|
-
date,
|
|
547
|
-
total: 0,
|
|
548
|
-
obeyed: 0,
|
|
549
|
-
refused: 0,
|
|
550
|
-
unknown: 0,
|
|
551
|
-
latencySum: 0,
|
|
552
|
-
latencyCount: 0,
|
|
553
|
-
executionSum: 0,
|
|
554
|
-
executionCount: 0,
|
|
555
|
-
};
|
|
556
|
-
dayBucket.total++;
|
|
557
|
-
if (e.obeyed === 1)
|
|
558
|
-
dayBucket.obeyed++;
|
|
559
|
-
else if (e.obeyed === 0)
|
|
560
|
-
dayBucket.refused++;
|
|
561
|
-
else
|
|
562
|
-
dayBucket.unknown++;
|
|
563
|
-
if (typeof e.classification_ms === 'number') {
|
|
564
|
-
dayBucket.latencySum += e.classification_ms;
|
|
565
|
-
dayBucket.latencyCount++;
|
|
566
|
-
}
|
|
567
|
-
if (typeof executionMs === 'number') {
|
|
568
|
-
dayBucket.executionSum += executionMs;
|
|
569
|
-
dayBucket.executionCount++;
|
|
570
|
-
}
|
|
571
|
-
dailyMap.set(date, dayBucket);
|
|
572
|
-
}
|
|
573
|
-
const byAgent = Array.from(byAgentMap.values())
|
|
574
|
-
.map(a => ({
|
|
575
|
-
agent: a.agent,
|
|
576
|
-
total: a.total,
|
|
577
|
-
judged: a.judged,
|
|
578
|
-
obeyed: a.obeyed,
|
|
579
|
-
refused: a.refused,
|
|
580
|
-
unknown: a.unknown,
|
|
581
|
-
obedienceRate: a.judged === 0 ? null : a.obeyed / a.judged,
|
|
582
|
-
refusalRate: a.judged === 0 ? null : a.refused / a.judged,
|
|
583
|
-
avgClassificationMs: a.latencyCount === 0 ? null : Math.round(a.latencySum / a.latencyCount),
|
|
584
|
-
avgExecutionMs: a.executionCount === 0 ? null : Math.round(a.executionSum / a.executionCount),
|
|
585
|
-
}))
|
|
586
|
-
.sort((a, b) => b.total - a.total);
|
|
587
|
-
const dailyTrend = Array.from(dailyMap.values())
|
|
588
|
-
.map(d => ({
|
|
589
|
-
date: d.date,
|
|
590
|
-
total: d.total,
|
|
591
|
-
obeyed: d.obeyed,
|
|
592
|
-
refused: d.refused,
|
|
593
|
-
unknown: d.unknown,
|
|
594
|
-
avgClassificationMs: d.latencyCount === 0 ? null : Math.round(d.latencySum / d.latencyCount),
|
|
595
|
-
avgExecutionMs: d.executionCount === 0 ? null : Math.round(d.executionSum / d.executionCount),
|
|
596
|
-
}))
|
|
597
|
-
.sort((a, b) => a.date.localeCompare(b.date));
|
|
598
|
-
const highRefusalAgents = byAgent
|
|
599
|
-
.filter(a => a.judged >= minAttempts && (a.refusalRate ?? 0) > 0)
|
|
600
|
-
.sort((a, b) => {
|
|
601
|
-
const rateDiff = (b.refusalRate ?? 0) - (a.refusalRate ?? 0);
|
|
602
|
-
return rateDiff !== 0 ? rateDiff : b.judged - a.judged;
|
|
603
|
-
})
|
|
604
|
-
.slice(0, 10)
|
|
605
|
-
.map(a => ({
|
|
606
|
-
agent: a.agent,
|
|
607
|
-
totalAttempts: a.judged,
|
|
608
|
-
obeyed: a.obeyed,
|
|
609
|
-
refused: a.refused,
|
|
610
|
-
refusalRate: a.refusalRate,
|
|
611
|
-
avgClassificationMs: a.avgClassificationMs,
|
|
612
|
-
avgExecutionMs: a.avgExecutionMs,
|
|
613
|
-
}));
|
|
614
|
-
res.json({
|
|
615
|
-
windowHours,
|
|
616
|
-
minAttempts,
|
|
617
|
-
summary: {
|
|
618
|
-
totalRouted: relevant.length,
|
|
619
|
-
totalJudged: judged.length,
|
|
620
|
-
obeyed: obeyedCount,
|
|
621
|
-
refused: refusedCount,
|
|
622
|
-
unknown: unknownCount,
|
|
623
|
-
obedienceRate: judged.length === 0 ? null : obeyedCount / judged.length,
|
|
624
|
-
refusalRate: judged.length === 0 ? null : refusedCount / judged.length,
|
|
625
|
-
avgClassificationMs,
|
|
626
|
-
avgExecutionMs,
|
|
627
|
-
p95ExecutionMs,
|
|
628
|
-
},
|
|
629
|
-
byAgent,
|
|
630
|
-
dailyTrend,
|
|
631
|
-
highRefusalAgents,
|
|
632
|
-
});
|
|
633
|
-
});
|
|
634
|
-
// Recent routing events (timeline / detail)
|
|
635
|
-
this.app.get('/api/routing/events', (req, res) => {
|
|
636
|
-
const limit = parseInt(req.query.limit || '50');
|
|
637
|
-
const sessionId = req.query.session;
|
|
638
|
-
const projectPath = req.query.project;
|
|
639
|
-
const agent = req.query.agent;
|
|
640
|
-
const obeyedParam = req.query.obeyed;
|
|
641
|
-
const filter = { limit };
|
|
642
|
-
if (sessionId)
|
|
643
|
-
filter.session_id = sessionId;
|
|
644
|
-
if (projectPath)
|
|
645
|
-
filter.project_path = projectPath;
|
|
646
|
-
if (agent)
|
|
647
|
-
filter.routed_to_name = agent;
|
|
648
|
-
if (obeyedParam === 'null')
|
|
649
|
-
filter.obeyed = null;
|
|
650
|
-
else if (obeyedParam === '0')
|
|
651
|
-
filter.obeyed = 0;
|
|
652
|
-
else if (obeyedParam === '1')
|
|
653
|
-
filter.obeyed = 1;
|
|
654
|
-
const rows = storage.queryRoutingEvents(filter);
|
|
655
|
-
res.json(rows);
|
|
656
|
-
});
|
|
657
|
-
// Methodology Executions: list all executions
|
|
658
|
-
this.app.get('/api/methodology-executions', (req, res) => {
|
|
659
|
-
const limit = parseInt(req.query.limit) || 50;
|
|
660
|
-
const sessionId = req.query.session;
|
|
661
|
-
const db = storage.getDatabase();
|
|
662
|
-
const query = sessionId
|
|
663
|
-
? 'SELECT * FROM methodology_executions WHERE session_id = ? ORDER BY started_at DESC LIMIT ?'
|
|
664
|
-
: 'SELECT * FROM methodology_executions ORDER BY started_at DESC LIMIT ?';
|
|
665
|
-
const params = sessionId ? [sessionId, limit] : [limit];
|
|
666
|
-
const executions = db.prepare(query).all(...params);
|
|
667
|
-
res.json(executions);
|
|
668
|
-
});
|
|
669
|
-
// Methodology Execution Detail: get single execution with phases
|
|
670
|
-
this.app.get('/api/methodology-executions/:id', (req, res) => {
|
|
671
|
-
const executionId = parseInt(req.params.id);
|
|
672
|
-
const db = storage.getDatabase();
|
|
673
|
-
const execution = db.prepare('SELECT * FROM methodology_executions WHERE id = ?').get(executionId);
|
|
674
|
-
if (!execution) {
|
|
675
|
-
res.status(404).json({ error: 'Execution not found' });
|
|
676
|
-
return;
|
|
677
|
-
}
|
|
678
|
-
const phases = db.prepare(`
|
|
679
|
-
SELECT * FROM phase_executions
|
|
680
|
-
WHERE methodology_execution_id = ?
|
|
681
|
-
ORDER BY phase_index ASC
|
|
682
|
-
`).all(executionId);
|
|
683
|
-
res.json({
|
|
684
|
-
...execution,
|
|
685
|
-
plan: JSON.parse(execution.plan_json),
|
|
686
|
-
phases,
|
|
687
|
-
});
|
|
688
|
-
});
|
|
689
|
-
// Methodology Execution: cancel a running execution
|
|
690
|
-
this.app.post('/api/methodology-executions/:id/cancel', async (req, res) => {
|
|
691
|
-
const executionId = parseInt(req.params.id);
|
|
692
|
-
const db = storage.getDatabase();
|
|
693
|
-
const execution = db.prepare('SELECT * FROM methodology_executions WHERE id = ?').get(executionId);
|
|
694
|
-
if (!execution) {
|
|
695
|
-
res.status(404).json({ error: 'Execution not found' });
|
|
696
|
-
return;
|
|
697
|
-
}
|
|
698
|
-
if (execution.status !== 'running') {
|
|
699
|
-
res.status(400).json({ error: `Cannot cancel execution in ${execution.status} state` });
|
|
700
|
-
return;
|
|
701
|
-
}
|
|
702
|
-
// Prefer ExecutionManager — it kills the worker process for background mode
|
|
703
|
-
if (this.options.executionManager) {
|
|
704
|
-
try {
|
|
705
|
-
await this.options.executionManager.cancel(executionId);
|
|
706
|
-
}
|
|
707
|
-
catch (err) {
|
|
708
|
-
logger.warn(`[web] cancel via ExecutionManager failed: ${err}`);
|
|
709
|
-
res.status(500).json({ error: `Cancel failed: ${err instanceof Error ? err.message : String(err)}` });
|
|
710
|
-
return;
|
|
711
|
-
}
|
|
712
|
-
}
|
|
713
|
-
else {
|
|
714
|
-
// Fallback: DB-only cancel (pre-Phase-C behavior)
|
|
715
|
-
db.prepare(`
|
|
716
|
-
UPDATE methodology_executions
|
|
717
|
-
SET status = 'cancelled', completed_at = ?
|
|
718
|
-
WHERE id = ?
|
|
719
|
-
`).run(Date.now(), executionId);
|
|
720
|
-
}
|
|
721
|
-
// Mark any still-running phase rows as cancelled
|
|
722
|
-
db.prepare(`
|
|
723
|
-
UPDATE phase_executions
|
|
724
|
-
SET status = 'cancelled', completed_at = ?
|
|
725
|
-
WHERE methodology_execution_id = ? AND status = 'running'
|
|
726
|
-
`).run(Date.now(), executionId);
|
|
727
|
-
res.json({ success: true, message: 'Execution cancelled' });
|
|
728
|
-
});
|
|
729
|
-
// Methodology Execution: start a new execution (Phase C)
|
|
730
|
-
// Body: { session_id, methodology_id?, plan?, mode: 'foreground' | 'background', requirement? }
|
|
731
|
-
this.app.post('/api/methodology-executions', async (req, res) => {
|
|
732
|
-
const { executionManager, methodologyRegistry } = this.options;
|
|
733
|
-
if (!executionManager || !methodologyRegistry) {
|
|
734
|
-
res.status(503).json({ error: 'Execution manager not available' });
|
|
735
|
-
return;
|
|
736
|
-
}
|
|
737
|
-
const { session_id, methodology_id, plan, mode } = req.body ?? {};
|
|
738
|
-
if (!session_id) {
|
|
739
|
-
res.status(400).json({ error: 'session_id is required' });
|
|
740
|
-
return;
|
|
741
|
-
}
|
|
742
|
-
if (mode !== 'foreground' && mode !== 'background') {
|
|
743
|
-
res.status(400).json({ error: 'mode must be foreground or background' });
|
|
744
|
-
return;
|
|
745
|
-
}
|
|
746
|
-
let planObj = null;
|
|
747
|
-
if (plan && typeof plan === 'object' && Array.isArray(plan.phases)) {
|
|
748
|
-
planObj = plan;
|
|
749
|
-
}
|
|
750
|
-
else if (methodology_id) {
|
|
751
|
-
const methodology = methodologyRegistry.get(methodology_id);
|
|
752
|
-
if (!methodology) {
|
|
753
|
-
res.status(404).json({ error: `methodology ${methodology_id} not found` });
|
|
754
|
-
return;
|
|
755
|
-
}
|
|
756
|
-
const templates = methodology.phase_templates ?? {};
|
|
757
|
-
const phaseIds = Object.keys(templates);
|
|
758
|
-
if (phaseIds.length === 0) {
|
|
759
|
-
res.status(400).json({ error: `methodology ${methodology_id} has no phase_templates` });
|
|
760
|
-
return;
|
|
761
|
-
}
|
|
762
|
-
planObj = {
|
|
763
|
-
methodology_id: methodology.id,
|
|
764
|
-
rationale: `Started via API (default phases from ${methodology.id})`,
|
|
765
|
-
phases: phaseIds.map(id => {
|
|
766
|
-
const tpl = templates[id];
|
|
767
|
-
return {
|
|
768
|
-
id,
|
|
769
|
-
agent: tpl.agent,
|
|
770
|
-
prompt: tpl.description ?? tpl.prompt_template.slice(0, 400),
|
|
771
|
-
rationale: 'default phase from methodology template',
|
|
772
|
-
};
|
|
773
|
-
}),
|
|
774
|
-
};
|
|
775
|
-
}
|
|
776
|
-
else {
|
|
777
|
-
res.status(400).json({ error: 'either methodology_id or plan must be provided' });
|
|
778
|
-
return;
|
|
779
|
-
}
|
|
780
|
-
try {
|
|
781
|
-
const id = executionManager.start({
|
|
782
|
-
session_id,
|
|
783
|
-
methodology_id: planObj.methodology_id,
|
|
784
|
-
plan: planObj,
|
|
785
|
-
mode,
|
|
786
|
-
});
|
|
787
|
-
res.status(201).json({ id, mode });
|
|
788
|
-
}
|
|
789
|
-
catch (err) {
|
|
790
|
-
logger.warn(`[web] start execution failed: ${err}`);
|
|
791
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
792
|
-
const isAuthError = err instanceof WorkerAuthError
|
|
793
|
-
|| (err instanceof Error && err.code === 'AUTH_REQUIRED')
|
|
794
|
-
|| message.includes('Background mode requires');
|
|
795
|
-
if (isAuthError) {
|
|
796
|
-
res.status(503).json({ error: message, code: 'AUTH_REQUIRED' });
|
|
797
|
-
}
|
|
798
|
-
else {
|
|
799
|
-
res.status(500).json({ error: message });
|
|
800
|
-
}
|
|
801
|
-
}
|
|
802
|
-
});
|
|
803
|
-
// Methodology Execution: SSE stream of executor events (Phase C)
|
|
804
|
-
this.app.get('/api/methodology-executions/events', (req, res) => {
|
|
805
|
-
const { executionManager } = this.options;
|
|
806
|
-
if (!executionManager) {
|
|
807
|
-
res.status(503).json({ error: 'Execution manager not available' });
|
|
808
|
-
return;
|
|
809
|
-
}
|
|
810
|
-
const filterId = req.query.execution_id ? parseInt(String(req.query.execution_id)) : null;
|
|
811
|
-
res.setHeader('Content-Type', 'text/event-stream');
|
|
812
|
-
res.setHeader('Cache-Control', 'no-cache');
|
|
813
|
-
res.setHeader('Connection', 'keep-alive');
|
|
814
|
-
res.flushHeaders();
|
|
815
|
-
const unsub = executionManager.subscribe(ev => {
|
|
816
|
-
if (filterId !== null && ev.execution_id !== filterId)
|
|
817
|
-
return;
|
|
818
|
-
try {
|
|
819
|
-
res.write(`event: ${ev.type}\ndata: ${JSON.stringify(ev)}\n\n`);
|
|
820
|
-
}
|
|
821
|
-
catch { /* client gone */ }
|
|
822
|
-
});
|
|
823
|
-
const heartbeat = setInterval(() => {
|
|
824
|
-
try {
|
|
825
|
-
res.write(`: heartbeat ${Date.now()}\n\n`);
|
|
826
|
-
}
|
|
827
|
-
catch { /* ignore */ }
|
|
828
|
-
}, 25_000);
|
|
829
|
-
req.on('close', () => {
|
|
830
|
-
clearInterval(heartbeat);
|
|
831
|
-
unsub();
|
|
832
|
-
});
|
|
833
|
-
});
|
|
834
|
-
// Methodology Execution: delete an execution
|
|
835
|
-
this.app.delete('/api/methodology-executions/:id', (req, res) => {
|
|
836
|
-
const executionId = parseInt(req.params.id);
|
|
837
|
-
const db = storage.getDatabase();
|
|
838
|
-
db.prepare('DELETE FROM phase_executions WHERE methodology_execution_id = ?').run(executionId);
|
|
839
|
-
const result = db.prepare('DELETE FROM methodology_executions WHERE id = ?').run(executionId);
|
|
840
|
-
if (result.changes === 0) {
|
|
841
|
-
res.status(404).json({ error: 'Execution not found' });
|
|
842
|
-
return;
|
|
843
|
-
}
|
|
844
|
-
res.json({ success: true });
|
|
845
|
-
});
|
|
846
|
-
// Methodology: list all available methodologies
|
|
847
|
-
this.app.get('/api/methodologies', (_req, res) => {
|
|
848
|
-
try {
|
|
849
|
-
const candidates = [
|
|
850
|
-
path.join(__dirname, '../capability/methodologies'),
|
|
851
|
-
path.join(__dirname, '../../src/capability/methodologies'),
|
|
852
|
-
];
|
|
853
|
-
const dir = candidates.find(d => fs.existsSync(d));
|
|
854
|
-
if (!dir) {
|
|
855
|
-
res.json([]);
|
|
856
|
-
return;
|
|
857
|
-
}
|
|
858
|
-
const files = fs.readdirSync(dir).filter(f => f.endsWith('.yaml'));
|
|
859
|
-
const methodologies = files.map(f => {
|
|
860
|
-
const content = fs.readFileSync(path.join(dir, f), 'utf-8');
|
|
861
|
-
return yaml.load(content);
|
|
862
|
-
});
|
|
863
|
-
res.json(methodologies);
|
|
864
|
-
}
|
|
865
|
-
catch (err) {
|
|
866
|
-
res.status(500).json({ error: err.message });
|
|
867
|
-
}
|
|
868
|
-
});
|
|
869
|
-
// Refusal clustering (Plan A: SQL GROUP BY by taskType × agent)
|
|
870
|
-
this.app.get('/api/routing/refusals', (req, res) => {
|
|
871
|
-
const windowHours = parseInt(req.query.window || '168');
|
|
872
|
-
const since = Date.now() - windowHours * 3600 * 1000;
|
|
873
|
-
const events = storage.queryRoutingEvents({
|
|
874
|
-
since_ts: since,
|
|
875
|
-
obeyed: 0,
|
|
876
|
-
limit: 1000,
|
|
877
|
-
});
|
|
878
|
-
// Group by (taskType, routed_to_name)
|
|
879
|
-
const groups = new Map();
|
|
880
|
-
for (const e of events) {
|
|
881
|
-
let taskType = 'unknown';
|
|
882
|
-
try {
|
|
883
|
-
const parsed = JSON.parse(e.intent_json ?? '{}');
|
|
884
|
-
if (typeof parsed.taskType === 'string')
|
|
885
|
-
taskType = parsed.taskType;
|
|
886
|
-
}
|
|
887
|
-
catch { /* ignore */ }
|
|
888
|
-
const key = `${taskType}__${e.routed_to_name ?? '—'}`;
|
|
889
|
-
const g = groups.get(key) ?? {
|
|
890
|
-
taskType,
|
|
891
|
-
agent: e.routed_to_name ?? '—',
|
|
892
|
-
count: 0,
|
|
893
|
-
samples: [],
|
|
894
|
-
};
|
|
895
|
-
g.count++;
|
|
896
|
-
if (g.samples.length < 5) {
|
|
897
|
-
g.samples.push({
|
|
898
|
-
prompt: e.prompt.slice(0, 200),
|
|
899
|
-
refusal_reason: e.refusal_reason ?? null,
|
|
900
|
-
ts: e.ts,
|
|
901
|
-
});
|
|
902
|
-
}
|
|
903
|
-
groups.set(key, g);
|
|
904
|
-
}
|
|
905
|
-
const sorted = Array.from(groups.values()).sort((a, b) => b.count - a.count);
|
|
906
|
-
res.json({ windowHours, groups: sorted });
|
|
907
|
-
});
|
|
908
|
-
// Routing violations analysis (Phase 3 Feature 3)
|
|
909
|
-
this.app.get('/api/routing/violations', (req, res) => {
|
|
910
|
-
const windowHours = parseInt(req.query.window || '168'); // default 7d
|
|
911
|
-
const since = Date.now() - windowHours * 3600 * 1000;
|
|
912
|
-
const events = storage.queryRoutingEvents({ since_ts: since, limit: 5000 });
|
|
913
|
-
// Analyze violation patterns: consecutive refusals for same (taskType, agent)
|
|
914
|
-
const patterns = new Map();
|
|
915
|
-
for (const e of events) {
|
|
916
|
-
if (!e.is_forced || e.obeyed === null)
|
|
917
|
-
continue; // only analyze forced routes with judgement
|
|
918
|
-
let taskType = 'unknown';
|
|
919
|
-
try {
|
|
920
|
-
const parsed = JSON.parse(e.intent_json ?? '{}');
|
|
921
|
-
if (typeof parsed.taskType === 'string')
|
|
922
|
-
taskType = parsed.taskType;
|
|
923
|
-
}
|
|
924
|
-
catch { /* ignore */ }
|
|
925
|
-
const key = `${taskType}__${e.routed_to_name ?? '—'}`;
|
|
926
|
-
const p = patterns.get(key) ?? {
|
|
927
|
-
taskType,
|
|
928
|
-
agent: e.routed_to_name ?? '—',
|
|
929
|
-
totalAttempts: 0,
|
|
930
|
-
refusals: 0,
|
|
931
|
-
refusalRate: 0,
|
|
932
|
-
recentRefusals: 0,
|
|
933
|
-
severity: 'low',
|
|
934
|
-
samples: [],
|
|
935
|
-
};
|
|
936
|
-
p.totalAttempts++;
|
|
937
|
-
if (e.obeyed === 0) {
|
|
938
|
-
p.refusals++;
|
|
939
|
-
if (p.samples.length < 5) {
|
|
940
|
-
p.samples.push({
|
|
941
|
-
prompt: e.prompt.slice(0, 200),
|
|
942
|
-
refusal_reason: e.refusal_reason ?? null,
|
|
943
|
-
ts: e.ts,
|
|
944
|
-
});
|
|
945
|
-
}
|
|
946
|
-
}
|
|
947
|
-
patterns.set(key, p);
|
|
948
|
-
}
|
|
949
|
-
// Calculate metrics and severity
|
|
950
|
-
const violations = Array.from(patterns.values())
|
|
951
|
-
.map(p => {
|
|
952
|
-
p.refusalRate = p.totalAttempts === 0 ? 0 : p.refusals / p.totalAttempts;
|
|
953
|
-
// Calculate recent refusals (last 5 attempts for this pattern)
|
|
954
|
-
const recentEvents = events
|
|
955
|
-
.filter(e => {
|
|
956
|
-
let taskType = 'unknown';
|
|
957
|
-
try {
|
|
958
|
-
const parsed = JSON.parse(e.intent_json ?? '{}');
|
|
959
|
-
if (typeof parsed.taskType === 'string')
|
|
960
|
-
taskType = parsed.taskType;
|
|
961
|
-
}
|
|
962
|
-
catch { /* ignore */ }
|
|
963
|
-
return taskType === p.taskType && e.routed_to_name === p.agent;
|
|
964
|
-
})
|
|
965
|
-
.slice(0, 5);
|
|
966
|
-
p.recentRefusals = recentEvents.filter(e => e.obeyed === 0).length;
|
|
967
|
-
// Determine severity
|
|
968
|
-
if (p.refusalRate >= 0.8 && p.totalAttempts >= 5)
|
|
969
|
-
p.severity = 'critical';
|
|
970
|
-
else if (p.refusalRate >= 0.6 && p.totalAttempts >= 3)
|
|
971
|
-
p.severity = 'high';
|
|
972
|
-
else if (p.refusalRate >= 0.4)
|
|
973
|
-
p.severity = 'medium';
|
|
974
|
-
else
|
|
975
|
-
p.severity = 'low';
|
|
976
|
-
return p;
|
|
977
|
-
})
|
|
978
|
-
.filter(p => p.refusals > 0) // only show patterns with at least 1 refusal
|
|
979
|
-
.sort((a, b) => {
|
|
980
|
-
// Sort by severity, then refusal rate
|
|
981
|
-
const severityOrder = { critical: 4, high: 3, medium: 2, low: 1 };
|
|
982
|
-
if (severityOrder[a.severity] !== severityOrder[b.severity]) {
|
|
983
|
-
return severityOrder[b.severity] - severityOrder[a.severity];
|
|
984
|
-
}
|
|
985
|
-
return b.refusalRate - a.refusalRate;
|
|
986
|
-
});
|
|
987
|
-
res.json({ windowHours, violations });
|
|
988
|
-
});
|
|
989
|
-
// Routing config editor API (Phase 3 Feature 2)
|
|
990
|
-
this.app.get('/api/routing/config', (_req, res) => {
|
|
991
|
-
const userPath = path.join(homedir(), '.claude-forge', 'routing.yaml');
|
|
992
|
-
const defaultPath = path.join(__dirname, 'engine', 'conventions', 'routing.yaml');
|
|
993
|
-
let content = '';
|
|
994
|
-
let source = 'none';
|
|
995
|
-
if (fs.existsSync(userPath)) {
|
|
996
|
-
content = fs.readFileSync(userPath, 'utf-8');
|
|
997
|
-
source = 'user';
|
|
998
|
-
}
|
|
999
|
-
else if (fs.existsSync(defaultPath)) {
|
|
1000
|
-
content = fs.readFileSync(defaultPath, 'utf-8');
|
|
1001
|
-
source = 'default';
|
|
1002
|
-
}
|
|
1003
|
-
res.json({ content, source, userPath, defaultPath });
|
|
1004
|
-
});
|
|
1005
|
-
this.app.put('/api/routing/config', (req, res) => {
|
|
1006
|
-
const { content } = req.body;
|
|
1007
|
-
if (typeof content !== 'string') {
|
|
1008
|
-
res.status(400).json({ error: 'content must be a string' });
|
|
1009
|
-
return;
|
|
1010
|
-
}
|
|
1011
|
-
const userPath = path.join(homedir(), '.claude-forge', 'routing.yaml');
|
|
1012
|
-
const dir = path.dirname(userPath);
|
|
1013
|
-
try {
|
|
1014
|
-
// Validate YAML syntax before saving
|
|
1015
|
-
yaml.load(content);
|
|
1016
|
-
// Ensure directory exists
|
|
1017
|
-
if (!fs.existsSync(dir)) {
|
|
1018
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
1019
|
-
}
|
|
1020
|
-
// Write to user override path
|
|
1021
|
-
fs.writeFileSync(userPath, content, 'utf-8');
|
|
1022
|
-
logger.info(`[Web] Routing config updated: ${userPath}`);
|
|
1023
|
-
res.json({ success: true, path: userPath });
|
|
1024
|
-
}
|
|
1025
|
-
catch (err) {
|
|
1026
|
-
logger.warn(`[Web] Failed to save routing config: ${err}`);
|
|
1027
|
-
res.status(400).json({ error: String(err) });
|
|
1028
|
-
}
|
|
1029
|
-
});
|
|
1030
|
-
// ── Phase 5: A/B Testing APIs ────────────────────────────────────────
|
|
1031
|
-
const experimentsPath = path.join(homedir(), '.claude-forge', 'routing-experiments.yaml');
|
|
1032
|
-
this.app.get('/api/routing/experiments/config', (_req, res) => {
|
|
1033
|
-
let content = '';
|
|
1034
|
-
let source = 'none';
|
|
1035
|
-
if (fs.existsSync(experimentsPath)) {
|
|
1036
|
-
content = fs.readFileSync(experimentsPath, 'utf-8');
|
|
1037
|
-
source = 'user';
|
|
1038
|
-
}
|
|
1039
|
-
res.json({ content, source, path: experimentsPath });
|
|
1040
|
-
});
|
|
1041
|
-
this.app.put('/api/routing/experiments/config', async (req, res) => {
|
|
1042
|
-
const { content } = req.body;
|
|
1043
|
-
if (typeof content !== 'string') {
|
|
1044
|
-
res.status(400).json({ error: 'content must be a string' });
|
|
1045
|
-
return;
|
|
1046
|
-
}
|
|
1047
|
-
try {
|
|
1048
|
-
const parsed = yaml.load(content, { schema: yaml.CORE_SCHEMA });
|
|
1049
|
-
// Reuse validateConfig for structural enforcement (weights, groups, etc.).
|
|
1050
|
-
const { validateConfig } = await import('../engine/experiment-router.js');
|
|
1051
|
-
const cfg = validateConfig(parsed);
|
|
1052
|
-
if (!cfg) {
|
|
1053
|
-
res.status(400).json({ error: 'experiments YAML failed validation (see daemon logs)' });
|
|
1054
|
-
return;
|
|
1055
|
-
}
|
|
1056
|
-
const dir = path.dirname(experimentsPath);
|
|
1057
|
-
if (!fs.existsSync(dir))
|
|
1058
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
1059
|
-
fs.writeFileSync(experimentsPath, content, 'utf-8');
|
|
1060
|
-
logger.info(`[Web] Experiments config updated: ${experimentsPath}`);
|
|
1061
|
-
res.json({ success: true, path: experimentsPath });
|
|
1062
|
-
}
|
|
1063
|
-
catch (err) {
|
|
1064
|
-
logger.warn(`[Web] Failed to save experiments config: ${err}`);
|
|
1065
|
-
res.status(400).json({ error: String(err) });
|
|
1066
|
-
}
|
|
1067
|
-
});
|
|
1068
|
-
this.app.get('/api/routing/experiments/analysis', async (_req, res) => {
|
|
1069
|
-
try {
|
|
1070
|
-
if (!fs.existsSync(experimentsPath)) {
|
|
1071
|
-
res.json({ enabled: false, experimentId: null, groups: [] });
|
|
1072
|
-
return;
|
|
1073
|
-
}
|
|
1074
|
-
const raw = fs.readFileSync(experimentsPath, 'utf-8');
|
|
1075
|
-
const parsed = yaml.load(raw, { schema: yaml.CORE_SCHEMA });
|
|
1076
|
-
const { validateConfig } = await import('../engine/experiment-router.js');
|
|
1077
|
-
const cfg = validateConfig(parsed);
|
|
1078
|
-
if (!cfg || !cfg.experiment) {
|
|
1079
|
-
res.json({ enabled: cfg?.enabled ?? false, experimentId: null, groups: [] });
|
|
1080
|
-
return;
|
|
1081
|
-
}
|
|
1082
|
-
const stats = storage.queryExperimentStats(cfg.experiment.id);
|
|
1083
|
-
const groups = cfg.experiment.groups.map(g => {
|
|
1084
|
-
const s = stats.find(row => row.group_id === g.id);
|
|
1085
|
-
const total = s?.total ?? 0;
|
|
1086
|
-
const obeyed = s?.obeyed ?? 0;
|
|
1087
|
-
const refused = s?.refused ?? 0;
|
|
1088
|
-
return {
|
|
1089
|
-
id: g.id,
|
|
1090
|
-
name: g.name,
|
|
1091
|
-
weight: g.weight,
|
|
1092
|
-
total,
|
|
1093
|
-
obeyed,
|
|
1094
|
-
refused,
|
|
1095
|
-
unknown: s?.unknown ?? 0,
|
|
1096
|
-
obeyedRate: total > 0 ? obeyed / total : null,
|
|
1097
|
-
avgClassificationMs: s?.avg_classification_ms ?? null,
|
|
1098
|
-
};
|
|
1099
|
-
});
|
|
1100
|
-
// Simple z-test between the two groups with the largest samples.
|
|
1101
|
-
let zScore = null;
|
|
1102
|
-
let sampleAdequate = false;
|
|
1103
|
-
let suggestedWinner = null;
|
|
1104
|
-
if (groups.length >= 2) {
|
|
1105
|
-
const sorted = [...groups].sort((a, b) => b.total - a.total).slice(0, 2);
|
|
1106
|
-
const [g1, g2] = sorted;
|
|
1107
|
-
sampleAdequate = g1.total >= 50 && g2.total >= 50;
|
|
1108
|
-
if (sampleAdequate) {
|
|
1109
|
-
const p1 = g1.obeyed / g1.total;
|
|
1110
|
-
const p2 = g2.obeyed / g2.total;
|
|
1111
|
-
const pPool = (g1.obeyed + g2.obeyed) / (g1.total + g2.total);
|
|
1112
|
-
const se = Math.sqrt(pPool * (1 - pPool) * (1 / g1.total + 1 / g2.total));
|
|
1113
|
-
zScore = se > 0 ? (p1 - p2) / se : 0;
|
|
1114
|
-
if (Math.abs(zScore) > 1.96) {
|
|
1115
|
-
suggestedWinner = p1 > p2 ? g1.id : g2.id;
|
|
1116
|
-
}
|
|
1117
|
-
}
|
|
1118
|
-
}
|
|
1119
|
-
res.json({
|
|
1120
|
-
enabled: cfg.enabled,
|
|
1121
|
-
experimentId: cfg.experiment.id,
|
|
1122
|
-
experimentName: cfg.experiment.name,
|
|
1123
|
-
startedAt: cfg.experiment.startedAt,
|
|
1124
|
-
endedAt: cfg.experiment.endedAt,
|
|
1125
|
-
groups,
|
|
1126
|
-
zScore,
|
|
1127
|
-
sampleAdequate,
|
|
1128
|
-
suggestedWinner,
|
|
1129
|
-
});
|
|
1130
|
-
}
|
|
1131
|
-
catch (err) {
|
|
1132
|
-
logger.warn(`[Web] Experiments analysis failed: ${err}`);
|
|
1133
|
-
res.status(500).json({ error: String(err) });
|
|
1134
|
-
}
|
|
1135
|
-
});
|
|
1136
|
-
this.app.post('/api/routing/experiments/promote', async (req, res) => {
|
|
1137
|
-
const { groupId } = req.body;
|
|
1138
|
-
if (typeof groupId !== 'string' || groupId.length === 0) {
|
|
1139
|
-
res.status(400).json({ error: 'groupId is required' });
|
|
1140
|
-
return;
|
|
1141
|
-
}
|
|
1142
|
-
try {
|
|
1143
|
-
if (!fs.existsSync(experimentsPath)) {
|
|
1144
|
-
res.status(400).json({ error: 'experiments config does not exist' });
|
|
1145
|
-
return;
|
|
1146
|
-
}
|
|
1147
|
-
const raw = fs.readFileSync(experimentsPath, 'utf-8');
|
|
1148
|
-
const parsed = yaml.load(raw, { schema: yaml.CORE_SCHEMA });
|
|
1149
|
-
const { validateConfig } = await import('../engine/experiment-router.js');
|
|
1150
|
-
const cfg = validateConfig(parsed);
|
|
1151
|
-
if (!cfg || !cfg.experiment) {
|
|
1152
|
-
res.status(400).json({ error: 'experiments config has no active experiment' });
|
|
1153
|
-
return;
|
|
1154
|
-
}
|
|
1155
|
-
const group = cfg.experiment.groups.find(g => g.id === groupId);
|
|
1156
|
-
if (!group) {
|
|
1157
|
-
res.status(404).json({ error: `group '${groupId}' not found` });
|
|
1158
|
-
return;
|
|
1159
|
-
}
|
|
1160
|
-
// Step 1: backup existing routing.yaml if present
|
|
1161
|
-
const routingPath = path.join(homedir(), '.claude-forge', 'routing.yaml');
|
|
1162
|
-
const ts = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1163
|
-
let backupPath = null;
|
|
1164
|
-
if (fs.existsSync(routingPath)) {
|
|
1165
|
-
backupPath = `${routingPath}.bak-${ts}`;
|
|
1166
|
-
fs.copyFileSync(routingPath, backupPath);
|
|
1167
|
-
}
|
|
1168
|
-
else {
|
|
1169
|
-
const dir = path.dirname(routingPath);
|
|
1170
|
-
if (!fs.existsSync(dir))
|
|
1171
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
1172
|
-
}
|
|
1173
|
-
// Step 2: write the winner's rules as the new routing.yaml
|
|
1174
|
-
const newRouting = yaml.dump({ schemaVersion: '1.0', rules: group.rules });
|
|
1175
|
-
fs.writeFileSync(routingPath, newRouting, 'utf-8');
|
|
1176
|
-
// Step 3: mark experiment as ended (enabled=false, endedAt=now)
|
|
1177
|
-
const endedAt = new Date().toISOString();
|
|
1178
|
-
const updated = yaml.dump({
|
|
1179
|
-
schemaVersion: '1.0',
|
|
1180
|
-
enabled: false,
|
|
1181
|
-
experiment: {
|
|
1182
|
-
...cfg.experiment,
|
|
1183
|
-
endedAt,
|
|
1184
|
-
},
|
|
1185
|
-
});
|
|
1186
|
-
fs.writeFileSync(experimentsPath, updated, 'utf-8');
|
|
1187
|
-
logger.info(`[Web] Promoted group '${groupId}' from experiment '${cfg.experiment.id}'; backup: ${backupPath}`);
|
|
1188
|
-
res.json({ promoted: groupId, routingPath, backupPath, endedAt });
|
|
1189
|
-
}
|
|
1190
|
-
catch (err) {
|
|
1191
|
-
logger.warn(`[Web] Failed to promote experiment group: ${err}`);
|
|
1192
|
-
res.status(500).json({ error: String(err) });
|
|
1193
|
-
}
|
|
1194
|
-
});
|
|
1195
|
-
// ── Phase 5 Feature 2: Rule States (auto-disable) ────────────────────
|
|
1196
|
-
this.app.get('/api/routing/rule-states', (req, res) => {
|
|
1197
|
-
const disabledOnly = req.query.disabled === '1' || req.query.disabled === 'true';
|
|
1198
|
-
const rows = storage.listRuleStates({ disabledOnly });
|
|
1199
|
-
res.json({ ruleStates: rows });
|
|
1200
|
-
});
|
|
1201
|
-
this.app.put('/api/routing/rule-states', (req, res) => {
|
|
1202
|
-
const { taskType, agent, disabled, reason } = req.body ?? {};
|
|
1203
|
-
if (typeof taskType !== 'string' || taskType.length === 0) {
|
|
1204
|
-
res.status(400).json({ error: 'taskType is required' });
|
|
1205
|
-
return;
|
|
1206
|
-
}
|
|
1207
|
-
if (typeof agent !== 'string' || agent.length === 0) {
|
|
1208
|
-
res.status(400).json({ error: 'agent is required' });
|
|
1209
|
-
return;
|
|
1210
|
-
}
|
|
1211
|
-
if (typeof disabled !== 'boolean') {
|
|
1212
|
-
res.status(400).json({ error: 'disabled must be boolean' });
|
|
1213
|
-
return;
|
|
1214
|
-
}
|
|
1215
|
-
try {
|
|
1216
|
-
storage.setRuleState({
|
|
1217
|
-
taskType,
|
|
1218
|
-
agent,
|
|
1219
|
-
disabled,
|
|
1220
|
-
reason: typeof reason === 'string' ? reason : null,
|
|
1221
|
-
autoDisabled: false, // manual toggle
|
|
1222
|
-
});
|
|
1223
|
-
logger.info(`[Web] Rule state updated: ${taskType}__${agent} disabled=${disabled}`);
|
|
1224
|
-
res.json({ success: true });
|
|
1225
|
-
}
|
|
1226
|
-
catch (err) {
|
|
1227
|
-
logger.warn(`[Web] Failed to set rule state: ${err}`);
|
|
1228
|
-
res.status(500).json({ error: String(err) });
|
|
1229
|
-
}
|
|
1230
|
-
});
|
|
1231
|
-
// ── Phase 5 Feature 3: Rule Recommendations ──────────────────────────
|
|
1232
|
-
this.app.get('/api/routing/recommendations', (req, res) => {
|
|
1233
|
-
const { router, agents } = this.options;
|
|
1234
|
-
if (!router || !agents) {
|
|
1235
|
-
res.json({ recommendations: [], reason: 'router/agents not injected' });
|
|
1236
|
-
return;
|
|
1237
|
-
}
|
|
1238
|
-
const windowDays = Math.max(1, Math.min(90, parseInt(req.query.days || '7')));
|
|
1239
|
-
try {
|
|
1240
|
-
const recommender = new Recommender(storage, router, agents, {
|
|
1241
|
-
windowMs: windowDays * 24 * 3600 * 1000,
|
|
1242
|
-
});
|
|
1243
|
-
const recommendations = recommender.analyze();
|
|
1244
|
-
res.json({ windowDays, recommendations });
|
|
1245
|
-
}
|
|
1246
|
-
catch (err) {
|
|
1247
|
-
logger.warn(`[Web] Recommender failed: ${err}`);
|
|
1248
|
-
res.status(500).json({ error: String(err) });
|
|
1249
|
-
}
|
|
1250
|
-
});
|
|
1251
|
-
// AI-assisted optimization recommendations
|
|
1252
|
-
this.app.get('/api/routing/ai-optimization', async (req, res) => {
|
|
1253
|
-
const { router, agents } = this.options;
|
|
1254
|
-
if (!router || !agents) {
|
|
1255
|
-
res.status(400).json({ error: 'router/agents not injected' });
|
|
1256
|
-
return;
|
|
1257
|
-
}
|
|
1258
|
-
const windowHours = Math.max(1, Math.min(720, parseInt(req.query.window || '168')));
|
|
1259
|
-
const minAttempts = Math.max(1, parseInt(req.query.minAttempts || '10'));
|
|
1260
|
-
const since = Date.now() - windowHours * 3600 * 1000;
|
|
1261
|
-
try {
|
|
1262
|
-
const config = new ConfigManager().get();
|
|
1263
|
-
const apiKey = config.distill.api_key || process.env.ANTHROPIC_API_KEY || '';
|
|
1264
|
-
if (!apiKey) {
|
|
1265
|
-
res.status(400).json({ error: 'AI API key not configured' });
|
|
1266
|
-
return;
|
|
1267
|
-
}
|
|
1268
|
-
const ai = new ClaudeProvider(apiKey, config.distill.model, config.distill.base_url);
|
|
1269
|
-
const recommender = new Recommender(storage, router, agents, { windowMs: windowHours * 3600 * 1000 });
|
|
1270
|
-
const ruleRecommendations = recommender.analyze();
|
|
1271
|
-
const performanceRes = await (async () => {
|
|
1272
|
-
const events = storage.queryRoutingEvents({ since_ts: since, limit: 5000 });
|
|
1273
|
-
const relevant = events.filter(e => e.routed_to_name);
|
|
1274
|
-
const judged = relevant.filter(e => e.obeyed === 0 || e.obeyed === 1);
|
|
1275
|
-
const byAgent = new Map();
|
|
1276
|
-
for (const e of relevant) {
|
|
1277
|
-
const key = e.routed_to_name ?? '—';
|
|
1278
|
-
const bucket = byAgent.get(key) ?? { total: 0, judged: 0, obeyed: 0, refused: 0, latencySum: 0, latencyCount: 0 };
|
|
1279
|
-
bucket.total++;
|
|
1280
|
-
if (e.obeyed === 1) {
|
|
1281
|
-
bucket.judged++;
|
|
1282
|
-
bucket.obeyed++;
|
|
1283
|
-
}
|
|
1284
|
-
else if (e.obeyed === 0) {
|
|
1285
|
-
bucket.judged++;
|
|
1286
|
-
bucket.refused++;
|
|
1287
|
-
}
|
|
1288
|
-
if (typeof e.classification_ms === 'number') {
|
|
1289
|
-
bucket.latencySum += e.classification_ms;
|
|
1290
|
-
bucket.latencyCount++;
|
|
1291
|
-
}
|
|
1292
|
-
byAgent.set(key, bucket);
|
|
1293
|
-
}
|
|
1294
|
-
return {
|
|
1295
|
-
totalRouted: relevant.length,
|
|
1296
|
-
totalJudged: judged.length,
|
|
1297
|
-
byAgent: Array.from(byAgent.entries()).map(([agent, b]) => ({
|
|
1298
|
-
agent,
|
|
1299
|
-
total: b.total,
|
|
1300
|
-
judged: b.judged,
|
|
1301
|
-
obeyed: b.obeyed,
|
|
1302
|
-
refused: b.refused,
|
|
1303
|
-
refusalRate: b.judged === 0 ? null : b.refused / b.judged,
|
|
1304
|
-
avgClassificationMs: b.latencyCount === 0 ? null : Math.round(b.latencySum / b.latencyCount),
|
|
1305
|
-
})).sort((a, b) => (b.refusalRate ?? 0) - (a.refusalRate ?? 0)),
|
|
1306
|
-
};
|
|
1307
|
-
})();
|
|
1308
|
-
const highRefusalAgents = performanceRes.byAgent
|
|
1309
|
-
.filter(a => a.judged >= minAttempts && (a.refusalRate ?? 0) > 0)
|
|
1310
|
-
.slice(0, 10);
|
|
1311
|
-
const topViolations = (await (async () => {
|
|
1312
|
-
const events = storage.queryRoutingEvents({ since_ts: since, obeyed: 0, limit: 1000 });
|
|
1313
|
-
const patterns = new Map();
|
|
1314
|
-
for (const e of events) {
|
|
1315
|
-
let taskType = 'unknown';
|
|
1316
|
-
try {
|
|
1317
|
-
const parsed = JSON.parse(e.intent_json ?? '{}');
|
|
1318
|
-
if (typeof parsed.taskType === 'string')
|
|
1319
|
-
taskType = parsed.taskType;
|
|
1320
|
-
}
|
|
1321
|
-
catch { /* ignore */ }
|
|
1322
|
-
const key = `${taskType}__${e.routed_to_name ?? '—'}`;
|
|
1323
|
-
const p = patterns.get(key) ?? { taskType, agent: e.routed_to_name ?? '—', total: 0, refusals: 0, refusalRate: 0, samples: [] };
|
|
1324
|
-
p.total++;
|
|
1325
|
-
p.refusals++;
|
|
1326
|
-
if (p.samples.length < 3)
|
|
1327
|
-
p.samples.push(e.prompt.slice(0, 180));
|
|
1328
|
-
patterns.set(key, p);
|
|
1329
|
-
}
|
|
1330
|
-
return Array.from(patterns.values()).map(p => ({
|
|
1331
|
-
...p,
|
|
1332
|
-
refusalRate: p.total === 0 ? 0 : p.refusals / p.total,
|
|
1333
|
-
})).sort((a, b) => b.refusalRate - a.refusalRate).slice(0, 5);
|
|
1334
|
-
})());
|
|
1335
|
-
const prompt = [
|
|
1336
|
-
'You are reviewing Claude Forge routing performance and should produce practical improvement suggestions.',
|
|
1337
|
-
'Return ONLY valid JSON with keys: summary, priorities, suggestedChanges.',
|
|
1338
|
-
'',
|
|
1339
|
-
'Context:',
|
|
1340
|
-
`- windowHours: ${windowHours}`,
|
|
1341
|
-
`- minAttempts: ${minAttempts}`,
|
|
1342
|
-
`- totalRouted: ${performanceRes.totalRouted}`,
|
|
1343
|
-
`- totalJudged: ${performanceRes.totalJudged}`,
|
|
1344
|
-
'',
|
|
1345
|
-
'Performance by agent:',
|
|
1346
|
-
JSON.stringify(highRefusalAgents, null, 2),
|
|
1347
|
-
'',
|
|
1348
|
-
'Top refusal patterns:',
|
|
1349
|
-
JSON.stringify(topViolations, null, 2),
|
|
1350
|
-
'',
|
|
1351
|
-
'Routing rule recommendations:',
|
|
1352
|
-
JSON.stringify(ruleRecommendations.slice(0, 8), null, 2),
|
|
1353
|
-
'',
|
|
1354
|
-
'Please provide:',
|
|
1355
|
-
'- summary: 2-3 sentences summarizing the main issues',
|
|
1356
|
-
'- priorities: array of { area, finding, impact, confidence }',
|
|
1357
|
-
'- suggestedChanges: array of { targetType: agent|skill|routing_rule, targetName, recommendation, rationale, expectedBenefit }',
|
|
1358
|
-
].join('\n');
|
|
1359
|
-
const raw = await ai.complete(prompt, { maxTokens: 2500 });
|
|
1360
|
-
const cleaned = raw.replace(/```json\s*/g, '').replace(/```\s*/g, '');
|
|
1361
|
-
const match = cleaned.match(/\{[\s\S]*\}/);
|
|
1362
|
-
if (!match) {
|
|
1363
|
-
res.status(500).json({ error: 'AI did not return JSON', raw });
|
|
1364
|
-
return;
|
|
1365
|
-
}
|
|
1366
|
-
const parsed = JSON.parse(match[0]);
|
|
1367
|
-
res.json({
|
|
1368
|
-
windowHours,
|
|
1369
|
-
minAttempts,
|
|
1370
|
-
generatedAt: new Date().toISOString(),
|
|
1371
|
-
summary: parsed.summary ?? '',
|
|
1372
|
-
priorities: Array.isArray(parsed.priorities) ? parsed.priorities : [],
|
|
1373
|
-
suggestedChanges: Array.isArray(parsed.suggestedChanges) ? parsed.suggestedChanges : [],
|
|
1374
|
-
evidence: {
|
|
1375
|
-
highRefusalAgents,
|
|
1376
|
-
topViolations,
|
|
1377
|
-
ruleRecommendations: ruleRecommendations.slice(0, 8),
|
|
1378
|
-
},
|
|
1379
|
-
});
|
|
1380
|
-
}
|
|
1381
|
-
catch (err) {
|
|
1382
|
-
logger.warn(`[Web] Failed to generate AI optimization: ${err}`);
|
|
1383
|
-
res.status(500).json({ error: String(err) });
|
|
1384
|
-
}
|
|
1385
|
-
});
|
|
1386
|
-
// ── Patch APIs ────────────────────────────────────────────────────────
|
|
1387
|
-
// POST /api/patch/preview — generate structured patch preview
|
|
1388
|
-
this.app.post('/api/patch/preview', async (req, res) => {
|
|
1389
|
-
const { targetType, targetName, recommendation, rationale } = req.body ?? {};
|
|
1390
|
-
if (!targetType || !targetName || !recommendation) {
|
|
1391
|
-
res.status(400).json({ error: 'targetType, targetName, and recommendation are required' });
|
|
1392
|
-
return;
|
|
1393
|
-
}
|
|
1394
|
-
try {
|
|
1395
|
-
const config = new ConfigManager().get();
|
|
1396
|
-
const apiKey = config.distill.api_key || process.env.ANTHROPIC_API_KEY || '';
|
|
1397
|
-
if (!apiKey) {
|
|
1398
|
-
res.status(400).json({ error: 'AI API key not configured' });
|
|
1399
|
-
return;
|
|
1400
|
-
}
|
|
1401
|
-
const { filePath } = resolvePatchTarget(targetType, targetName);
|
|
1402
|
-
if (!fs.existsSync(filePath)) {
|
|
1403
|
-
res.status(404).json({ error: `Target file not found: ${filePath}` });
|
|
1404
|
-
return;
|
|
1405
|
-
}
|
|
1406
|
-
const currentContent = fs.readFileSync(filePath, 'utf-8');
|
|
1407
|
-
const ai = new ClaudeProvider(apiKey, config.distill.model, config.distill.base_url);
|
|
1408
|
-
const prompt = `You are a code/config optimization assistant. Given the current file content and a recommended change, generate the updated content.
|
|
1409
|
-
|
|
1410
|
-
Current file (${targetType}/${targetName}):
|
|
1411
|
-
\`\`\`
|
|
1412
|
-
${currentContent}
|
|
1413
|
-
\`\`\`
|
|
1414
|
-
|
|
1415
|
-
Recommendation: ${recommendation}
|
|
1416
|
-
${rationale ? `Rationale: ${rationale}` : ''}
|
|
1417
|
-
|
|
1418
|
-
Return ONLY a JSON object with this exact structure (no markdown, no explanation):
|
|
1419
|
-
{
|
|
1420
|
-
"summary": "brief description of what changed",
|
|
1421
|
-
"afterContent": "the complete updated file content",
|
|
1422
|
-
"risk": "low|medium|high"
|
|
1423
|
-
}`;
|
|
1424
|
-
const response = await ai.complete(prompt, { maxTokens: 4096 });
|
|
1425
|
-
const parsed = JSON.parse(response.trim());
|
|
1426
|
-
res.json({
|
|
1427
|
-
targetType,
|
|
1428
|
-
targetName,
|
|
1429
|
-
filePath,
|
|
1430
|
-
before: currentContent,
|
|
1431
|
-
after: parsed.afterContent,
|
|
1432
|
-
summary: parsed.summary,
|
|
1433
|
-
risk: parsed.risk || 'medium',
|
|
1434
|
-
recommendation,
|
|
1435
|
-
rationale: rationale || null,
|
|
1436
|
-
});
|
|
1437
|
-
}
|
|
1438
|
-
catch (err) {
|
|
1439
|
-
logger.warn(`[Web] Patch preview failed: ${err}`);
|
|
1440
|
-
res.status(500).json({ error: String(err) });
|
|
1441
|
-
}
|
|
1442
|
-
});
|
|
1443
|
-
// POST /api/patch/apply — apply patch with backup
|
|
1444
|
-
this.app.post('/api/patch/apply', (req, res) => {
|
|
1445
|
-
const { targetType, targetName, afterContent } = req.body ?? {};
|
|
1446
|
-
if (!targetType || !targetName || typeof afterContent !== 'string') {
|
|
1447
|
-
res.status(400).json({ error: 'targetType, targetName, and afterContent are required' });
|
|
1448
|
-
return;
|
|
1449
|
-
}
|
|
1450
|
-
try {
|
|
1451
|
-
const { filePath, backupDir } = resolvePatchTarget(targetType, targetName);
|
|
1452
|
-
if (!fs.existsSync(filePath)) {
|
|
1453
|
-
res.status(404).json({ error: `Target file not found: ${filePath}` });
|
|
1454
|
-
return;
|
|
1455
|
-
}
|
|
1456
|
-
// Create backup
|
|
1457
|
-
if (!fs.existsSync(backupDir)) {
|
|
1458
|
-
fs.mkdirSync(backupDir, { recursive: true });
|
|
1459
|
-
}
|
|
1460
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1461
|
-
const backupName = `${targetName}_${timestamp}${path.extname(filePath)}`;
|
|
1462
|
-
const backupPath = path.join(backupDir, backupName);
|
|
1463
|
-
fs.copyFileSync(filePath, backupPath);
|
|
1464
|
-
// Apply patch
|
|
1465
|
-
fs.writeFileSync(filePath, afterContent, 'utf-8');
|
|
1466
|
-
logger.info(`[Web] Patch applied to ${targetType}/${targetName}, backup: ${backupPath}`);
|
|
1467
|
-
res.json({
|
|
1468
|
-
success: true,
|
|
1469
|
-
targetType,
|
|
1470
|
-
targetName,
|
|
1471
|
-
filePath,
|
|
1472
|
-
backupPath,
|
|
1473
|
-
timestamp,
|
|
1474
|
-
});
|
|
1475
|
-
}
|
|
1476
|
-
catch (err) {
|
|
1477
|
-
logger.warn(`[Web] Patch apply failed: ${err}`);
|
|
1478
|
-
res.status(500).json({ error: String(err) });
|
|
1479
|
-
}
|
|
1480
|
-
});
|
|
1481
|
-
// ── AI Configuration APIs ─────────────────────────────────────────────
|
|
1482
|
-
// GET /api/config/ai — read current AI config (mask apiKey)
|
|
1483
|
-
this.app.get('/api/config/ai', (_req, res) => {
|
|
1484
|
-
const configManager = new ConfigManager();
|
|
1485
|
-
const config = configManager.get();
|
|
1486
|
-
const maskApiKey = (key) => {
|
|
1487
|
-
if (!key || key.length < 10)
|
|
1488
|
-
return '***';
|
|
1489
|
-
return key.slice(0, 6) + '***' + key.slice(-4);
|
|
1490
|
-
};
|
|
1491
|
-
res.json({
|
|
1492
|
-
api_key: maskApiKey(config.distill.api_key),
|
|
1493
|
-
base_url: config.distill.base_url || '',
|
|
1494
|
-
model: config.distill.model,
|
|
1495
|
-
provider: config.distill.provider,
|
|
1496
|
-
classifier_model: config.distill.classifier_model || '',
|
|
1497
|
-
classifier_timeout: config.distill.classifier_timeout || 10000,
|
|
1498
|
-
});
|
|
1499
|
-
});
|
|
1500
|
-
// PUT /api/config/ai — update AI config
|
|
1501
|
-
this.app.put('/api/config/ai', (req, res) => {
|
|
1502
|
-
const { api_key, base_url, model, provider, classifier_model, classifier_timeout } = req.body ?? {};
|
|
1503
|
-
try {
|
|
1504
|
-
const configManager = new ConfigManager();
|
|
1505
|
-
const config = configManager.get();
|
|
1506
|
-
// Update only provided fields
|
|
1507
|
-
if (typeof api_key === 'string')
|
|
1508
|
-
config.distill.api_key = api_key;
|
|
1509
|
-
if (typeof base_url === 'string') {
|
|
1510
|
-
// Normalize http → https for upstream gateways that force HTTPS redirect
|
|
1511
|
-
// (e.g. iflytek one.iflytek.com returns 302 and drops Authorization header)
|
|
1512
|
-
let url = base_url.trim();
|
|
1513
|
-
if (url.startsWith('http://'))
|
|
1514
|
-
url = 'https://' + url.slice('http://'.length);
|
|
1515
|
-
config.distill.base_url = url;
|
|
1516
|
-
}
|
|
1517
|
-
if (typeof model === 'string')
|
|
1518
|
-
config.distill.model = model;
|
|
1519
|
-
if (typeof provider === 'string')
|
|
1520
|
-
config.distill.provider = provider;
|
|
1521
|
-
if (typeof classifier_model === 'string')
|
|
1522
|
-
config.distill.classifier_model = classifier_model || undefined;
|
|
1523
|
-
if (typeof classifier_timeout === 'number')
|
|
1524
|
-
config.distill.classifier_timeout = classifier_timeout;
|
|
1525
|
-
configManager.save();
|
|
1526
|
-
logger.info('[Web] AI config updated');
|
|
1527
|
-
res.json({ success: true });
|
|
1528
|
-
}
|
|
1529
|
-
catch (err) {
|
|
1530
|
-
logger.warn(`[Web] Failed to update AI config: ${err}`);
|
|
1531
|
-
res.status(500).json({ error: String(err) });
|
|
1532
|
-
}
|
|
1533
|
-
});
|
|
1534
|
-
// GET /api/ai/models — proxy to upstream /v1/models
|
|
1535
|
-
// Supports ?api_key= and ?base_url= to test unsaved config; falls back to config.yaml.
|
|
1536
|
-
this.app.get('/api/ai/models', async (req, res) => {
|
|
1537
|
-
try {
|
|
1538
|
-
const configManager = new ConfigManager();
|
|
1539
|
-
const config = configManager.get();
|
|
1540
|
-
const queryKey = typeof req.query.api_key === 'string' ? req.query.api_key : '';
|
|
1541
|
-
const queryUrl = typeof req.query.base_url === 'string' ? req.query.base_url : '';
|
|
1542
|
-
const apiKey = queryKey || config.distill.api_key;
|
|
1543
|
-
let baseUrl = queryUrl || config.distill.base_url || 'https://api.anthropic.com';
|
|
1544
|
-
// Upstream gateways that force HTTPS drop headers on 302 — normalize proactively.
|
|
1545
|
-
if (baseUrl.startsWith('http://'))
|
|
1546
|
-
baseUrl = 'https://' + baseUrl.slice('http://'.length);
|
|
1547
|
-
if (!apiKey) {
|
|
1548
|
-
res.status(400).json({ error: 'API key not configured' });
|
|
1549
|
-
return;
|
|
1550
|
-
}
|
|
1551
|
-
// Construct models endpoint URL
|
|
1552
|
-
const modelsUrl = baseUrl.endsWith('/v1')
|
|
1553
|
-
? `${baseUrl}/models`
|
|
1554
|
-
: `${baseUrl}/v1/models`;
|
|
1555
|
-
const response = await fetch(modelsUrl, {
|
|
1556
|
-
redirect: 'follow',
|
|
1557
|
-
headers: {
|
|
1558
|
-
'Authorization': `Bearer ${apiKey}`,
|
|
1559
|
-
'x-api-key': apiKey,
|
|
1560
|
-
},
|
|
1561
|
-
});
|
|
1562
|
-
if (!response.ok) {
|
|
1563
|
-
const text = await response.text();
|
|
1564
|
-
logger.warn(`[Web] Upstream /v1/models failed: ${response.status} ${text}`);
|
|
1565
|
-
res.status(response.status).json({ error: text });
|
|
1566
|
-
return;
|
|
1567
|
-
}
|
|
1568
|
-
const data = await response.json();
|
|
1569
|
-
res.json(data);
|
|
1570
|
-
}
|
|
1571
|
-
catch (err) {
|
|
1572
|
-
logger.warn(`[Web] Failed to fetch models: ${err}`);
|
|
1573
|
-
res.status(500).json({ error: String(err) });
|
|
1574
|
-
}
|
|
1575
|
-
});
|
|
1576
|
-
// POST /api/ai/test — test AI connection using provided or saved config
|
|
1577
|
-
this.app.post('/api/ai/test', async (req, res) => {
|
|
1578
|
-
try {
|
|
1579
|
-
const configManager = new ConfigManager();
|
|
1580
|
-
const config = configManager.get();
|
|
1581
|
-
const { api_key, base_url, model } = req.body ?? {};
|
|
1582
|
-
const apiKey = (typeof api_key === 'string' && api_key) || config.distill.api_key;
|
|
1583
|
-
let baseUrl = (typeof base_url === 'string' && base_url) || config.distill.base_url || 'https://api.anthropic.com';
|
|
1584
|
-
const useModel = (typeof model === 'string' && model) || config.distill.model;
|
|
1585
|
-
// Normalize http → https
|
|
1586
|
-
if (baseUrl.startsWith('http://'))
|
|
1587
|
-
baseUrl = 'https://' + baseUrl.slice('http://'.length);
|
|
1588
|
-
if (!apiKey) {
|
|
1589
|
-
res.status(400).json({ error: 'API key not configured' });
|
|
1590
|
-
return;
|
|
1591
|
-
}
|
|
1592
|
-
const messagesUrl = baseUrl.endsWith('/v1')
|
|
1593
|
-
? `${baseUrl}/messages`
|
|
1594
|
-
: `${baseUrl}/v1/messages`;
|
|
1595
|
-
const response = await fetch(messagesUrl, {
|
|
1596
|
-
method: 'POST',
|
|
1597
|
-
redirect: 'follow',
|
|
1598
|
-
headers: {
|
|
1599
|
-
'Content-Type': 'application/json',
|
|
1600
|
-
'x-api-key': apiKey,
|
|
1601
|
-
'anthropic-version': '2023-06-01',
|
|
1602
|
-
},
|
|
1603
|
-
body: JSON.stringify({
|
|
1604
|
-
model: useModel,
|
|
1605
|
-
max_tokens: 10,
|
|
1606
|
-
messages: [{ role: 'user', content: 'ping' }],
|
|
1607
|
-
}),
|
|
1608
|
-
});
|
|
1609
|
-
if (!response.ok) {
|
|
1610
|
-
const text = await response.text();
|
|
1611
|
-
res.status(response.status).json({ error: text });
|
|
1612
|
-
return;
|
|
1613
|
-
}
|
|
1614
|
-
const data = await response.json();
|
|
1615
|
-
res.json({ success: true, model: data.model || useModel });
|
|
1616
|
-
}
|
|
1617
|
-
catch (err) {
|
|
1618
|
-
logger.warn(`[Web] AI connection test failed: ${err}`);
|
|
1619
|
-
res.status(500).json({ error: String(err) });
|
|
1620
|
-
}
|
|
1621
|
-
});
|
|
1622
|
-
// ── Agent & Skill Management APIs ────────────────────────────────────────
|
|
1623
|
-
// GET /api/agents — list all agents (official + user)
|
|
1624
|
-
this.app.get('/api/agents', (_req, res) => {
|
|
1625
|
-
try {
|
|
1626
|
-
const agentsDir = path.join(homedir(), '.claude', 'agents');
|
|
1627
|
-
const official = this.agents?.getAll() || [];
|
|
1628
|
-
const userAgents = [];
|
|
1629
|
-
if (fs.existsSync(agentsDir)) {
|
|
1630
|
-
const files = fs.readdirSync(agentsDir).filter(f => f.endsWith('.md'));
|
|
1631
|
-
for (const file of files) {
|
|
1632
|
-
const name = file.replace('.md', '');
|
|
1633
|
-
if (official.some((a) => a.name === name))
|
|
1634
|
-
continue; // Skip official agents
|
|
1635
|
-
const content = fs.readFileSync(path.join(agentsDir, file), 'utf-8');
|
|
1636
|
-
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
1637
|
-
if (match) {
|
|
1638
|
-
const fm = match[1];
|
|
1639
|
-
const descMatch = fm.match(/description:\s*["']?([^"'\n]+)["']?/);
|
|
1640
|
-
const verMatch = fm.match(/version:\s*["']?([^"'\n]+)["']?/);
|
|
1641
|
-
userAgents.push({
|
|
1642
|
-
name,
|
|
1643
|
-
description: descMatch?.[1] || '',
|
|
1644
|
-
version: verMatch?.[1],
|
|
1645
|
-
});
|
|
1646
|
-
}
|
|
1647
|
-
}
|
|
1648
|
-
}
|
|
1649
|
-
res.json({ official, user: userAgents });
|
|
1650
|
-
}
|
|
1651
|
-
catch (err) {
|
|
1652
|
-
logger.warn(`[Web] Failed to list agents: ${err}`);
|
|
1653
|
-
res.status(500).json({ error: String(err) });
|
|
1654
|
-
}
|
|
1655
|
-
});
|
|
1656
|
-
// GET /api/agents/:name — get agent details
|
|
1657
|
-
this.app.get('/api/agents/:name', (req, res) => {
|
|
1658
|
-
try {
|
|
1659
|
-
const { name } = req.params;
|
|
1660
|
-
const agentsDir = path.join(homedir(), '.claude', 'agents');
|
|
1661
|
-
const filePath = path.join(agentsDir, `${name}.md`);
|
|
1662
|
-
if (!fs.existsSync(filePath)) {
|
|
1663
|
-
res.status(404).json({ error: 'Agent not found' });
|
|
1664
|
-
return;
|
|
1665
|
-
}
|
|
1666
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
1667
|
-
const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
1668
|
-
if (!match) {
|
|
1669
|
-
res.json({ name, content });
|
|
1670
|
-
return;
|
|
1671
|
-
}
|
|
1672
|
-
const fm = match[1];
|
|
1673
|
-
const body = match[2];
|
|
1674
|
-
const descMatch = fm.match(/description:\s*["']?([^"'\n]+)["']?/);
|
|
1675
|
-
const verMatch = fm.match(/version:\s*["']?([^"'\n]+)["']?/);
|
|
1676
|
-
const toolsMatch = fm.match(/tools:\s*(.+)/);
|
|
1677
|
-
res.json({
|
|
1678
|
-
name,
|
|
1679
|
-
description: descMatch?.[1] || '',
|
|
1680
|
-
version: verMatch?.[1],
|
|
1681
|
-
tools: toolsMatch?.[1],
|
|
1682
|
-
content,
|
|
1683
|
-
});
|
|
1684
|
-
}
|
|
1685
|
-
catch (err) {
|
|
1686
|
-
logger.warn(`[Web] Failed to get agent ${req.params.name}: ${err}`);
|
|
1687
|
-
res.status(500).json({ error: String(err) });
|
|
1688
|
-
}
|
|
1689
|
-
});
|
|
1690
|
-
// PUT /api/agents/:name — update agent content
|
|
1691
|
-
this.app.put('/api/agents/:name', (req, res) => {
|
|
1692
|
-
try {
|
|
1693
|
-
const { name } = req.params;
|
|
1694
|
-
const { content } = req.body;
|
|
1695
|
-
if (!content || typeof content !== 'string') {
|
|
1696
|
-
res.status(400).json({ error: 'Missing content' });
|
|
1697
|
-
return;
|
|
1698
|
-
}
|
|
1699
|
-
const agentsDir = path.join(homedir(), '.claude', 'agents');
|
|
1700
|
-
const filePath = path.join(agentsDir, `${name}.md`);
|
|
1701
|
-
if (!fs.existsSync(filePath)) {
|
|
1702
|
-
res.status(404).json({ error: 'Agent not found' });
|
|
1703
|
-
return;
|
|
1704
|
-
}
|
|
1705
|
-
// Backup before editing
|
|
1706
|
-
const backupDir = path.join(homedir(), '.claude-forge', 'backups', 'agents');
|
|
1707
|
-
fs.mkdirSync(backupDir, { recursive: true });
|
|
1708
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1709
|
-
const backupPath = path.join(backupDir, `${name}-${timestamp}.md`);
|
|
1710
|
-
fs.copyFileSync(filePath, backupPath);
|
|
1711
|
-
// Write new content
|
|
1712
|
-
fs.writeFileSync(filePath, content, 'utf-8');
|
|
1713
|
-
logger.info(`[Web] Updated agent ${name} (backup: ${backupPath})`);
|
|
1714
|
-
res.json({ success: true, backup: backupPath });
|
|
1715
|
-
}
|
|
1716
|
-
catch (err) {
|
|
1717
|
-
logger.warn(`[Web] Failed to update agent ${req.params.name}: ${err}`);
|
|
1718
|
-
res.status(500).json({ error: String(err) });
|
|
1719
|
-
}
|
|
1720
|
-
});
|
|
1721
|
-
// GET /api/skills — list all skills (official + user)
|
|
1722
|
-
this.app.get('/api/skills', (_req, res) => {
|
|
1723
|
-
try {
|
|
1724
|
-
const skillsDir = path.join(homedir(), '.claude', 'skills');
|
|
1725
|
-
const skills = [];
|
|
1726
|
-
if (fs.existsSync(skillsDir)) {
|
|
1727
|
-
const files = fs.readdirSync(skillsDir).filter(f => f.endsWith('.md'));
|
|
1728
|
-
for (const file of files) {
|
|
1729
|
-
const name = file.replace('.md', '');
|
|
1730
|
-
const content = fs.readFileSync(path.join(skillsDir, file), 'utf-8');
|
|
1731
|
-
const match = content.match(/^---\n([\s\S]*?)\n---/);
|
|
1732
|
-
if (match) {
|
|
1733
|
-
const fm = match[1];
|
|
1734
|
-
const descMatch = fm.match(/description:\s*["']?([^"'\n]+)["']?/);
|
|
1735
|
-
const verMatch = fm.match(/version:\s*["']?([^"'\n]+)["']?/);
|
|
1736
|
-
const sourceMatch = fm.match(/source:\s*["']?([^"'\n]+)["']?/);
|
|
1737
|
-
skills.push({
|
|
1738
|
-
name,
|
|
1739
|
-
description: descMatch?.[1] || '',
|
|
1740
|
-
version: verMatch?.[1],
|
|
1741
|
-
source: sourceMatch?.[1] || 'user',
|
|
1742
|
-
});
|
|
1743
|
-
}
|
|
1744
|
-
}
|
|
1745
|
-
}
|
|
1746
|
-
res.json({ skills });
|
|
1747
|
-
}
|
|
1748
|
-
catch (err) {
|
|
1749
|
-
logger.warn(`[Web] Failed to list skills: ${err}`);
|
|
1750
|
-
res.status(500).json({ error: String(err) });
|
|
1751
|
-
}
|
|
1752
|
-
});
|
|
1753
|
-
// GET /api/skills/:name — get skill details
|
|
1754
|
-
this.app.get('/api/skills/:name', (req, res) => {
|
|
1755
|
-
try {
|
|
1756
|
-
const { name } = req.params;
|
|
1757
|
-
const skillsDir = path.join(homedir(), '.claude', 'skills');
|
|
1758
|
-
const filePath = path.join(skillsDir, `${name}.md`);
|
|
1759
|
-
if (!fs.existsSync(filePath)) {
|
|
1760
|
-
res.status(404).json({ error: 'Skill not found' });
|
|
1761
|
-
return;
|
|
1762
|
-
}
|
|
1763
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
1764
|
-
const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/);
|
|
1765
|
-
if (!match) {
|
|
1766
|
-
res.json({ name, content });
|
|
1767
|
-
return;
|
|
1768
|
-
}
|
|
1769
|
-
const fm = match[1];
|
|
1770
|
-
const body = match[2];
|
|
1771
|
-
const descMatch = fm.match(/description:\s*["']?([^"'\n]+)["']?/);
|
|
1772
|
-
const verMatch = fm.match(/version:\s*["']?([^"'\n]+)["']?/);
|
|
1773
|
-
const sourceMatch = fm.match(/source:\s*["']?([^"'\n]+)["']?/);
|
|
1774
|
-
res.json({
|
|
1775
|
-
name,
|
|
1776
|
-
description: descMatch?.[1] || '',
|
|
1777
|
-
version: verMatch?.[1],
|
|
1778
|
-
source: sourceMatch?.[1] || 'user',
|
|
1779
|
-
content,
|
|
1780
|
-
});
|
|
1781
|
-
}
|
|
1782
|
-
catch (err) {
|
|
1783
|
-
logger.warn(`[Web] Failed to get skill ${req.params.name}: ${err}`);
|
|
1784
|
-
res.status(500).json({ error: String(err) });
|
|
1785
|
-
}
|
|
1786
|
-
});
|
|
1787
|
-
// PUT /api/skills/:name — update skill content
|
|
1788
|
-
this.app.put('/api/skills/:name', (req, res) => {
|
|
1789
|
-
try {
|
|
1790
|
-
const { name } = req.params;
|
|
1791
|
-
const { content } = req.body;
|
|
1792
|
-
if (!content || typeof content !== 'string') {
|
|
1793
|
-
res.status(400).json({ error: 'Missing content' });
|
|
1794
|
-
return;
|
|
1795
|
-
}
|
|
1796
|
-
const skillsDir = path.join(homedir(), '.claude', 'skills');
|
|
1797
|
-
const filePath = path.join(skillsDir, `${name}.md`);
|
|
1798
|
-
if (!fs.existsSync(filePath)) {
|
|
1799
|
-
res.status(404).json({ error: 'Skill not found' });
|
|
1800
|
-
return;
|
|
1801
|
-
}
|
|
1802
|
-
// Backup before editing
|
|
1803
|
-
const backupDir = path.join(homedir(), '.claude-forge', 'backups', 'skills');
|
|
1804
|
-
fs.mkdirSync(backupDir, { recursive: true });
|
|
1805
|
-
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1806
|
-
const backupPath = path.join(backupDir, `${name}-${timestamp}.md`);
|
|
1807
|
-
fs.copyFileSync(filePath, backupPath);
|
|
1808
|
-
// Write new content
|
|
1809
|
-
fs.writeFileSync(filePath, content, 'utf-8');
|
|
1810
|
-
logger.info(`[Web] Updated skill ${name} (backup: ${backupPath})`);
|
|
1811
|
-
res.json({ success: true, backup: backupPath });
|
|
1812
|
-
}
|
|
1813
|
-
catch (err) {
|
|
1814
|
-
logger.warn(`[Web] Failed to update skill ${req.params.name}: ${err}`);
|
|
1815
|
-
res.status(500).json({ error: String(err) });
|
|
1816
|
-
}
|
|
1817
|
-
});
|
|
1818
|
-
// ── Version Management APIs ────────────────────────────────────────────────
|
|
1819
|
-
// GET /api/agents/:name/versions — list backup versions
|
|
1820
|
-
this.app.get('/api/agents/:name/versions', (req, res) => {
|
|
1821
|
-
try {
|
|
1822
|
-
const { name } = req.params;
|
|
1823
|
-
const backupDir = path.join(homedir(), '.claude-forge', 'backups', 'agents');
|
|
1824
|
-
if (!fs.existsSync(backupDir)) {
|
|
1825
|
-
res.json({ versions: [] });
|
|
1826
|
-
return;
|
|
1827
|
-
}
|
|
1828
|
-
const files = fs.readdirSync(backupDir)
|
|
1829
|
-
.filter(f => f.startsWith(`${name}-`) && f.endsWith('.md'))
|
|
1830
|
-
.map(f => {
|
|
1831
|
-
const filePath = path.join(backupDir, f);
|
|
1832
|
-
const stats = fs.statSync(filePath);
|
|
1833
|
-
const timestampMatch = f.match(/-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)\.md$/);
|
|
1834
|
-
return {
|
|
1835
|
-
filename: f,
|
|
1836
|
-
timestamp: timestampMatch ? timestampMatch[1].replace(/-/g, ':').replace(/T(\d{2}):(\d{2}):(\d{2})/, 'T$1:$2:$3') : '',
|
|
1837
|
-
size: stats.size,
|
|
1838
|
-
mtime: stats.mtime.toISOString(),
|
|
1839
|
-
};
|
|
1840
|
-
})
|
|
1841
|
-
.sort((a, b) => new Date(b.mtime).getTime() - new Date(a.mtime).getTime());
|
|
1842
|
-
res.json({ versions: files });
|
|
1843
|
-
}
|
|
1844
|
-
catch (err) {
|
|
1845
|
-
logger.warn(`[Web] Failed to list agent versions: ${err}`);
|
|
1846
|
-
res.status(500).json({ error: String(err) });
|
|
1847
|
-
}
|
|
1848
|
-
});
|
|
1849
|
-
// GET /api/agents/:name/versions/:timestamp — get specific version content
|
|
1850
|
-
this.app.get('/api/agents/:name/versions/:timestamp', (req, res) => {
|
|
1851
|
-
try {
|
|
1852
|
-
const { name, timestamp } = req.params;
|
|
1853
|
-
const backupDir = path.join(homedir(), '.claude-forge', 'backups', 'agents');
|
|
1854
|
-
const filename = `${name}-${timestamp}.md`;
|
|
1855
|
-
const filePath = path.join(backupDir, filename);
|
|
1856
|
-
if (!fs.existsSync(filePath)) {
|
|
1857
|
-
res.status(404).json({ error: 'Version not found' });
|
|
1858
|
-
return;
|
|
1859
|
-
}
|
|
1860
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
1861
|
-
res.json({ content });
|
|
1862
|
-
}
|
|
1863
|
-
catch (err) {
|
|
1864
|
-
logger.warn(`[Web] Failed to get agent version: ${err}`);
|
|
1865
|
-
res.status(500).json({ error: String(err) });
|
|
1866
|
-
}
|
|
1867
|
-
});
|
|
1868
|
-
// POST /api/agents/:name/rollback — rollback to a specific version
|
|
1869
|
-
this.app.post('/api/agents/:name/rollback', (req, res) => {
|
|
1870
|
-
try {
|
|
1871
|
-
const { name } = req.params;
|
|
1872
|
-
const { timestamp } = req.body;
|
|
1873
|
-
if (!timestamp) {
|
|
1874
|
-
res.status(400).json({ error: 'Missing timestamp' });
|
|
1875
|
-
return;
|
|
1876
|
-
}
|
|
1877
|
-
const agentsDir = path.join(homedir(), '.claude', 'agents');
|
|
1878
|
-
const currentPath = path.join(agentsDir, `${name}.md`);
|
|
1879
|
-
const backupDir = path.join(homedir(), '.claude-forge', 'backups', 'agents');
|
|
1880
|
-
const versionPath = path.join(backupDir, `${name}-${timestamp}.md`);
|
|
1881
|
-
if (!fs.existsSync(currentPath)) {
|
|
1882
|
-
res.status(404).json({ error: 'Agent not found' });
|
|
1883
|
-
return;
|
|
1884
|
-
}
|
|
1885
|
-
if (!fs.existsSync(versionPath)) {
|
|
1886
|
-
res.status(404).json({ error: 'Version not found' });
|
|
1887
|
-
return;
|
|
1888
|
-
}
|
|
1889
|
-
// Backup current version before rollback
|
|
1890
|
-
fs.mkdirSync(backupDir, { recursive: true });
|
|
1891
|
-
const rollbackTimestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1892
|
-
const rollbackBackupPath = path.join(backupDir, `${name}-${rollbackTimestamp}.md`);
|
|
1893
|
-
fs.copyFileSync(currentPath, rollbackBackupPath);
|
|
1894
|
-
// Restore version
|
|
1895
|
-
const versionContent = fs.readFileSync(versionPath, 'utf-8');
|
|
1896
|
-
fs.writeFileSync(currentPath, versionContent, 'utf-8');
|
|
1897
|
-
logger.info(`[Web] Rolled back agent ${name} to ${timestamp} (backup: ${rollbackBackupPath})`);
|
|
1898
|
-
res.json({ success: true, backup: rollbackBackupPath });
|
|
1899
|
-
}
|
|
1900
|
-
catch (err) {
|
|
1901
|
-
logger.warn(`[Web] Failed to rollback agent: ${err}`);
|
|
1902
|
-
res.status(500).json({ error: String(err) });
|
|
1903
|
-
}
|
|
1904
|
-
});
|
|
1905
|
-
// GET /api/skills/:name/versions — list backup versions
|
|
1906
|
-
this.app.get('/api/skills/:name/versions', (req, res) => {
|
|
1907
|
-
try {
|
|
1908
|
-
const { name } = req.params;
|
|
1909
|
-
const backupDir = path.join(homedir(), '.claude-forge', 'backups', 'skills');
|
|
1910
|
-
if (!fs.existsSync(backupDir)) {
|
|
1911
|
-
res.json({ versions: [] });
|
|
1912
|
-
return;
|
|
1913
|
-
}
|
|
1914
|
-
const files = fs.readdirSync(backupDir)
|
|
1915
|
-
.filter(f => f.startsWith(`${name}-`) && f.endsWith('.md'))
|
|
1916
|
-
.map(f => {
|
|
1917
|
-
const filePath = path.join(backupDir, f);
|
|
1918
|
-
const stats = fs.statSync(filePath);
|
|
1919
|
-
const timestampMatch = f.match(/-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z)\.md$/);
|
|
1920
|
-
return {
|
|
1921
|
-
filename: f,
|
|
1922
|
-
timestamp: timestampMatch ? timestampMatch[1].replace(/-/g, ':').replace(/T(\d{2}):(\d{2}):(\d{2})/, 'T$1:$2:$3') : '',
|
|
1923
|
-
size: stats.size,
|
|
1924
|
-
mtime: stats.mtime.toISOString(),
|
|
1925
|
-
};
|
|
1926
|
-
})
|
|
1927
|
-
.sort((a, b) => new Date(b.mtime).getTime() - new Date(a.mtime).getTime());
|
|
1928
|
-
res.json({ versions: files });
|
|
1929
|
-
}
|
|
1930
|
-
catch (err) {
|
|
1931
|
-
logger.warn(`[Web] Failed to list skill versions: ${err}`);
|
|
1932
|
-
res.status(500).json({ error: String(err) });
|
|
1933
|
-
}
|
|
1934
|
-
});
|
|
1935
|
-
// GET /api/skills/:name/versions/:timestamp — get specific version content
|
|
1936
|
-
this.app.get('/api/skills/:name/versions/:timestamp', (req, res) => {
|
|
1937
|
-
try {
|
|
1938
|
-
const { name, timestamp } = req.params;
|
|
1939
|
-
const backupDir = path.join(homedir(), '.claude-forge', 'backups', 'skills');
|
|
1940
|
-
const filename = `${name}-${timestamp}.md`;
|
|
1941
|
-
const filePath = path.join(backupDir, filename);
|
|
1942
|
-
if (!fs.existsSync(filePath)) {
|
|
1943
|
-
res.status(404).json({ error: 'Version not found' });
|
|
1944
|
-
return;
|
|
1945
|
-
}
|
|
1946
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
1947
|
-
res.json({ content });
|
|
1948
|
-
}
|
|
1949
|
-
catch (err) {
|
|
1950
|
-
logger.warn(`[Web] Failed to get skill version: ${err}`);
|
|
1951
|
-
res.status(500).json({ error: String(err) });
|
|
1952
|
-
}
|
|
1953
|
-
});
|
|
1954
|
-
// POST /api/skills/:name/rollback — rollback to a specific version
|
|
1955
|
-
this.app.post('/api/skills/:name/rollback', (req, res) => {
|
|
1956
|
-
try {
|
|
1957
|
-
const { name } = req.params;
|
|
1958
|
-
const { timestamp } = req.body;
|
|
1959
|
-
if (!timestamp) {
|
|
1960
|
-
res.status(400).json({ error: 'Missing timestamp' });
|
|
1961
|
-
return;
|
|
1962
|
-
}
|
|
1963
|
-
const skillsDir = path.join(homedir(), '.claude', 'skills');
|
|
1964
|
-
const currentPath = path.join(skillsDir, `${name}.md`);
|
|
1965
|
-
const backupDir = path.join(homedir(), '.claude-forge', 'backups', 'skills');
|
|
1966
|
-
const versionPath = path.join(backupDir, `${name}-${timestamp}.md`);
|
|
1967
|
-
if (!fs.existsSync(currentPath)) {
|
|
1968
|
-
res.status(404).json({ error: 'Skill not found' });
|
|
1969
|
-
return;
|
|
1970
|
-
}
|
|
1971
|
-
if (!fs.existsSync(versionPath)) {
|
|
1972
|
-
res.status(404).json({ error: 'Version not found' });
|
|
1973
|
-
return;
|
|
1974
|
-
}
|
|
1975
|
-
// Backup current version before rollback
|
|
1976
|
-
fs.mkdirSync(backupDir, { recursive: true });
|
|
1977
|
-
const rollbackTimestamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
1978
|
-
const rollbackBackupPath = path.join(backupDir, `${name}-${rollbackTimestamp}.md`);
|
|
1979
|
-
fs.copyFileSync(currentPath, rollbackBackupPath);
|
|
1980
|
-
// Restore version
|
|
1981
|
-
const versionContent = fs.readFileSync(versionPath, 'utf-8');
|
|
1982
|
-
fs.writeFileSync(currentPath, versionContent, 'utf-8');
|
|
1983
|
-
logger.info(`[Web] Rolled back skill ${name} to ${timestamp} (backup: ${rollbackBackupPath})`);
|
|
1984
|
-
res.json({ success: true, backup: rollbackBackupPath });
|
|
1985
|
-
}
|
|
1986
|
-
catch (err) {
|
|
1987
|
-
logger.warn(`[Web] Failed to rollback skill: ${err}`);
|
|
1988
|
-
res.status(500).json({ error: String(err) });
|
|
1989
|
-
}
|
|
1990
|
-
});
|
|
1991
|
-
// ── Execution Trace APIs ──────────────────────────────────────────────────
|
|
1992
|
-
// GET /api/execution-trace — list execution traces with routing info
|
|
1993
|
-
this.app.get('/api/execution-trace', (req, res) => {
|
|
1994
|
-
try {
|
|
1995
|
-
const limit = parseInt(req.query.limit) || 50;
|
|
1996
|
-
const agentFilter = req.query.agent;
|
|
1997
|
-
const obeyedFilter = req.query.obeyed;
|
|
1998
|
-
const db = storage.getDatabase();
|
|
1999
|
-
let sql = `
|
|
2000
|
-
SELECT
|
|
2001
|
-
re.id,
|
|
2002
|
-
re.session_id,
|
|
2003
|
-
re.route_request_id,
|
|
2004
|
-
re.project_path,
|
|
2005
|
-
re.ts,
|
|
2006
|
-
re.prompt,
|
|
2007
|
-
re.intent_json,
|
|
2008
|
-
re.routed_to_type,
|
|
2009
|
-
re.routed_to_name,
|
|
2010
|
-
re.obeyed,
|
|
2011
|
-
re.classification_ms,
|
|
2012
|
-
re.fallback_used,
|
|
2013
|
-
re.first_tool_name,
|
|
2014
|
-
re.first_tool_ts,
|
|
2015
|
-
re.completed_ts,
|
|
2016
|
-
re.total_execution_ms,
|
|
2017
|
-
re.completion_reason,
|
|
2018
|
-
re.downstream_task_chain,
|
|
2019
|
-
s.status as session_status,
|
|
2020
|
-
s.start_time,
|
|
2021
|
-
s.end_time,
|
|
2022
|
-
s.event_count
|
|
2023
|
-
FROM routing_events re
|
|
2024
|
-
LEFT JOIN sessions s ON re.session_id = s.session_id
|
|
2025
|
-
WHERE 1=1
|
|
2026
|
-
`;
|
|
2027
|
-
const params = [];
|
|
2028
|
-
if (agentFilter) {
|
|
2029
|
-
sql += ` AND re.routed_to_name = ?`;
|
|
2030
|
-
params.push(agentFilter);
|
|
2031
|
-
}
|
|
2032
|
-
if (obeyedFilter === 'true') {
|
|
2033
|
-
sql += ` AND re.obeyed = 1`;
|
|
2034
|
-
}
|
|
2035
|
-
else if (obeyedFilter === 'false') {
|
|
2036
|
-
sql += ` AND re.obeyed = 0`;
|
|
2037
|
-
}
|
|
2038
|
-
sql += ` ORDER BY re.ts DESC LIMIT ?`;
|
|
2039
|
-
params.push(limit);
|
|
2040
|
-
const rows = db.prepare(sql).all(...params);
|
|
2041
|
-
const traces = rows.map(row => ({
|
|
2042
|
-
id: row.id,
|
|
2043
|
-
sessionId: row.session_id,
|
|
2044
|
-
routeRequestId: row.route_request_id,
|
|
2045
|
-
projectPath: row.project_path,
|
|
2046
|
-
timestamp: row.ts,
|
|
2047
|
-
prompt: row.prompt,
|
|
2048
|
-
intent: JSON.parse(row.intent_json || '{}'),
|
|
2049
|
-
routedToType: row.routed_to_type,
|
|
2050
|
-
routedToName: row.routed_to_name,
|
|
2051
|
-
obeyed: row.obeyed,
|
|
2052
|
-
classificationMs: row.classification_ms,
|
|
2053
|
-
fallbackUsed: row.fallback_used === 1,
|
|
2054
|
-
firstTool: row.first_tool_name,
|
|
2055
|
-
firstToolTs: row.first_tool_ts,
|
|
2056
|
-
completedTs: row.completed_ts,
|
|
2057
|
-
totalExecutionMs: row.total_execution_ms,
|
|
2058
|
-
completionReason: row.completion_reason,
|
|
2059
|
-
taskChain: row.downstream_task_chain ? JSON.parse(row.downstream_task_chain) : [],
|
|
2060
|
-
sessionStatus: row.session_status,
|
|
2061
|
-
sessionStart: row.start_time,
|
|
2062
|
-
sessionEnd: row.end_time,
|
|
2063
|
-
eventCount: row.event_count,
|
|
2064
|
-
}));
|
|
2065
|
-
res.json({ traces });
|
|
2066
|
-
}
|
|
2067
|
-
catch (err) {
|
|
2068
|
-
logger.warn(`[Web] Failed to get execution traces: ${err}`);
|
|
2069
|
-
res.status(500).json({ error: String(err) });
|
|
2070
|
-
}
|
|
2071
|
-
});
|
|
2072
|
-
// GET /api/execution-trace/:id — detailed trace for a routing event
|
|
2073
|
-
this.app.get('/api/execution-trace/:id', (req, res) => {
|
|
2074
|
-
try {
|
|
2075
|
-
const id = Number(req.params.id);
|
|
2076
|
-
if (!Number.isFinite(id)) {
|
|
2077
|
-
res.status(400).json({ error: 'Invalid trace id' });
|
|
2078
|
-
return;
|
|
2079
|
-
}
|
|
2080
|
-
const db = storage.getDatabase();
|
|
2081
|
-
// Get routing event by unique id
|
|
2082
|
-
const routing = db.prepare(`
|
|
2083
|
-
SELECT * FROM routing_events WHERE id = ?
|
|
2084
|
-
`).get(id);
|
|
2085
|
-
if (!routing) {
|
|
2086
|
-
res.status(404).json({ error: 'Trace not found' });
|
|
2087
|
-
return;
|
|
2088
|
-
}
|
|
2089
|
-
const sessionId = routing.session_id;
|
|
2090
|
-
const routeRequestId = routing.route_request_id ?? null;
|
|
2091
|
-
// Get all events for this session
|
|
2092
|
-
const events = db.prepare(`
|
|
2093
|
-
SELECT event_id, timestamp, hook_type, tool_name, user_prompt
|
|
2094
|
-
FROM events
|
|
2095
|
-
WHERE session_id = ?
|
|
2096
|
-
ORDER BY timestamp ASC
|
|
2097
|
-
`).all(sessionId);
|
|
2098
|
-
// Get injections
|
|
2099
|
-
const injections = db.prepare(`
|
|
2100
|
-
SELECT injection_type, content, timestamp
|
|
2101
|
-
FROM injections
|
|
2102
|
-
WHERE session_id = ?
|
|
2103
|
-
ORDER BY timestamp ASC
|
|
2104
|
-
`).all(sessionId);
|
|
2105
|
-
// Get Agent/Task tool events tied to this route_request_id (preferred)
|
|
2106
|
-
const agentCallRows = routeRequestId
|
|
2107
|
-
? db.prepare(`
|
|
2108
|
-
SELECT id, tool, args, success, error, timestamp
|
|
2109
|
-
FROM v2_tool_events
|
|
2110
|
-
WHERE route_request_id = ? AND tool IN ('Agent','Task')
|
|
2111
|
-
ORDER BY timestamp ASC
|
|
2112
|
-
`).all(routeRequestId)
|
|
2113
|
-
: [];
|
|
2114
|
-
// Completion signals: PostToolUse for Agent/Task within this session after routing.ts
|
|
2115
|
-
const postUseRows = db.prepare(`
|
|
2116
|
-
SELECT event_id, timestamp, tool_name
|
|
2117
|
-
FROM events
|
|
2118
|
-
WHERE session_id = ? AND hook_type = 'PostToolUse' AND tool_name IN ('Agent','Task')
|
|
2119
|
-
AND timestamp >= ?
|
|
2120
|
-
ORDER BY timestamp ASC
|
|
2121
|
-
`).all(sessionId, new Date(routing.ts).toISOString());
|
|
2122
|
-
const agentCalls = agentCallRows.map(r => {
|
|
2123
|
-
let subagent = null;
|
|
2124
|
-
try {
|
|
2125
|
-
const parsed = JSON.parse(r.args || '{}');
|
|
2126
|
-
if (typeof parsed.subagent_type === 'string')
|
|
2127
|
-
subagent = parsed.subagent_type;
|
|
2128
|
-
}
|
|
2129
|
-
catch { /* ignore */ }
|
|
2130
|
-
const completed = postUseRows.find(p => p.tool_name === r.tool && new Date(p.timestamp).getTime() >= r.timestamp);
|
|
2131
|
-
return {
|
|
2132
|
-
toolEventId: r.id,
|
|
2133
|
-
tool: r.tool,
|
|
2134
|
-
subagent,
|
|
2135
|
-
startedTs: r.timestamp,
|
|
2136
|
-
allowed: r.success === 1,
|
|
2137
|
-
error: r.error,
|
|
2138
|
-
completedTs: completed ? new Date(completed.timestamp).getTime() : null,
|
|
2139
|
-
completedEventId: completed?.event_id ?? null,
|
|
2140
|
-
status: !completed ? 'started' : 'completed',
|
|
2141
|
-
};
|
|
2142
|
-
});
|
|
2143
|
-
res.json({
|
|
2144
|
-
routing: {
|
|
2145
|
-
id: routing.id,
|
|
2146
|
-
sessionId,
|
|
2147
|
-
routeRequestId,
|
|
2148
|
-
prompt: routing.prompt,
|
|
2149
|
-
timestamp: routing.ts,
|
|
2150
|
-
intent: JSON.parse(routing.intent_json || '{}'),
|
|
2151
|
-
routedToType: routing.routed_to_type,
|
|
2152
|
-
routedToName: routing.routed_to_name,
|
|
2153
|
-
obeyed: routing.obeyed,
|
|
2154
|
-
classificationMs: routing.classification_ms,
|
|
2155
|
-
fallbackUsed: routing.fallback_used === 1,
|
|
2156
|
-
refusalReason: routing.refusal_reason,
|
|
2157
|
-
firstTool: routing.first_tool_name,
|
|
2158
|
-
firstToolTs: routing.first_tool_ts,
|
|
2159
|
-
completedTs: routing.completed_ts,
|
|
2160
|
-
totalExecutionMs: routing.total_execution_ms,
|
|
2161
|
-
completionReason: routing.completion_reason,
|
|
2162
|
-
taskChain: routing.downstream_task_chain ? JSON.parse(routing.downstream_task_chain) : [],
|
|
2163
|
-
},
|
|
2164
|
-
events,
|
|
2165
|
-
injections,
|
|
2166
|
-
agentCalls,
|
|
2167
|
-
});
|
|
2168
|
-
}
|
|
2169
|
-
catch (err) {
|
|
2170
|
-
logger.warn(`[Web] Failed to get trace details for ${req.params.id}: ${err}`);
|
|
2171
|
-
res.status(500).json({ error: String(err) });
|
|
2172
|
-
}
|
|
2173
|
-
});
|
|
2174
|
-
// GET /api/execution-trace/:session_id/status — current status snapshot
|
|
2175
|
-
this.app.get('/api/execution-trace/:session_id/status', (req, res) => {
|
|
2176
|
-
try {
|
|
2177
|
-
const { session_id } = req.params;
|
|
2178
|
-
const db = storage.getDatabase();
|
|
2179
|
-
const routing = db.prepare(`SELECT * FROM routing_events WHERE session_id = ? ORDER BY ts DESC LIMIT 1`).get(session_id);
|
|
2180
|
-
if (!routing) {
|
|
2181
|
-
res.status(404).json({ error: 'Trace not found' });
|
|
2182
|
-
return;
|
|
2183
|
-
}
|
|
2184
|
-
const latestToolEvent = db.prepare(`
|
|
2185
|
-
SELECT tool, success, error, timestamp
|
|
2186
|
-
FROM v2_tool_events
|
|
2187
|
-
WHERE session_id = ? AND timestamp >= ?
|
|
2188
|
-
ORDER BY timestamp DESC LIMIT 1
|
|
2189
|
-
`).get(session_id, routing.ts);
|
|
2190
|
-
const latestDecision = db.prepare(`
|
|
2191
|
-
SELECT level, reason, timestamp
|
|
2192
|
-
FROM v2_decisions
|
|
2193
|
-
WHERE session_id = ? AND timestamp >= ?
|
|
2194
|
-
ORDER BY timestamp DESC LIMIT 1
|
|
2195
|
-
`).get(session_id, routing.ts);
|
|
2196
|
-
let status = 'routing';
|
|
2197
|
-
if (latestDecision?.level === 'block' || latestToolEvent?.success === 0)
|
|
2198
|
-
status = 'failed';
|
|
2199
|
-
else if (routing.completed_ts)
|
|
2200
|
-
status = 'completed';
|
|
2201
|
-
else if (routing.first_tool_ts || latestToolEvent)
|
|
2202
|
-
status = 'executing';
|
|
2203
|
-
res.json({
|
|
2204
|
-
status,
|
|
2205
|
-
timestamp: routing.ts,
|
|
2206
|
-
firstTool: routing.first_tool_name ?? latestToolEvent?.tool ?? null,
|
|
2207
|
-
firstToolTs: routing.first_tool_ts ?? latestToolEvent?.timestamp ?? null,
|
|
2208
|
-
completedTs: routing.completed_ts ?? null,
|
|
2209
|
-
totalExecutionMs: routing.total_execution_ms ?? null,
|
|
2210
|
-
completionReason: routing.completion_reason ?? null,
|
|
2211
|
-
error: latestToolEvent?.success === 0 ? latestToolEvent.error : (latestDecision?.level === 'block' ? latestDecision.reason : null),
|
|
2212
|
-
});
|
|
2213
|
-
}
|
|
2214
|
-
catch (err) {
|
|
2215
|
-
logger.warn(`[Web] Failed to get trace status for ${req.params.session_id}: ${err}`);
|
|
2216
|
-
res.status(500).json({ error: String(err) });
|
|
2217
|
-
}
|
|
2218
|
-
});
|
|
2219
|
-
// SSE: execution trace live status stream
|
|
2220
|
-
this.app.get('/api/execution-trace/stream', (req, res) => {
|
|
2221
|
-
res.writeHead(200, {
|
|
2222
|
-
'Content-Type': 'text/event-stream',
|
|
2223
|
-
'Cache-Control': 'no-cache',
|
|
2224
|
-
Connection: 'keep-alive',
|
|
2225
|
-
});
|
|
2226
|
-
res.write('data: {"type":"connected"}\n\n');
|
|
2227
|
-
const filterSession = req.query.session;
|
|
2228
|
-
const readOnlyTools = new Set(['Read', 'Grep', 'Glob', 'LS', 'NotebookRead', 'WebFetch', 'WebSearch']);
|
|
2229
|
-
const writeStatus = (payload) => {
|
|
2230
|
-
if (filterSession && payload.sessionId !== filterSession)
|
|
2231
|
-
return;
|
|
2232
|
-
res.write(`data: ${JSON.stringify(payload)}\n\n`);
|
|
2233
|
-
};
|
|
2234
|
-
const onEvent = (event) => {
|
|
2235
|
-
const hookType = event.hook_type;
|
|
2236
|
-
const toolName = event.tool_name;
|
|
2237
|
-
const sessionId = event.session_id;
|
|
2238
|
-
const timestamp = event.timestamp;
|
|
2239
|
-
if (!sessionId || !hookType)
|
|
2240
|
-
return;
|
|
2241
|
-
if (hookType === 'UserPromptSubmit') {
|
|
2242
|
-
writeStatus({ type: 'execution-status', status: 'routing', sessionId, timestamp, prompt: event.user_prompt ?? null });
|
|
2243
|
-
return;
|
|
2244
|
-
}
|
|
2245
|
-
if (hookType === 'PreToolUse' && toolName && !readOnlyTools.has(toolName)) {
|
|
2246
|
-
writeStatus({ type: 'execution-status', status: 'executing', sessionId, timestamp, tool: toolName });
|
|
2247
|
-
return;
|
|
2248
|
-
}
|
|
2249
|
-
if (hookType === 'Stop') {
|
|
2250
|
-
writeStatus({ type: 'execution-status', status: 'completed', sessionId, timestamp });
|
|
2251
|
-
}
|
|
2252
|
-
};
|
|
2253
|
-
const onToolEvent = (event) => {
|
|
2254
|
-
const sessionId = event.session_id;
|
|
2255
|
-
if (!sessionId)
|
|
2256
|
-
return;
|
|
2257
|
-
if (event.success === false || event.success === 0) {
|
|
2258
|
-
writeStatus({
|
|
2259
|
-
type: 'execution-status',
|
|
2260
|
-
status: 'failed',
|
|
2261
|
-
sessionId,
|
|
2262
|
-
timestamp: event.timestamp ?? Date.now(),
|
|
2263
|
-
tool: event.tool ?? null,
|
|
2264
|
-
error: event.error ?? null,
|
|
2265
|
-
});
|
|
2266
|
-
}
|
|
2267
|
-
};
|
|
2268
|
-
const onDecision = (decision) => {
|
|
2269
|
-
const sessionId = decision.session_id;
|
|
2270
|
-
if (!sessionId)
|
|
2271
|
-
return;
|
|
2272
|
-
if (decision.level === 'block') {
|
|
2273
|
-
writeStatus({
|
|
2274
|
-
type: 'execution-status',
|
|
2275
|
-
status: 'failed',
|
|
2276
|
-
sessionId,
|
|
2277
|
-
timestamp: decision.timestamp ?? Date.now(),
|
|
2278
|
-
error: decision.reason ?? null,
|
|
2279
|
-
});
|
|
2280
|
-
}
|
|
2281
|
-
};
|
|
2282
|
-
storage.on('event', onEvent);
|
|
2283
|
-
storage.on('tool-event', onToolEvent);
|
|
2284
|
-
storage.on('decision', onDecision);
|
|
2285
|
-
req.on('close', () => {
|
|
2286
|
-
storage.removeListener('event', onEvent);
|
|
2287
|
-
storage.removeListener('tool-event', onToolEvent);
|
|
2288
|
-
storage.removeListener('decision', onDecision);
|
|
2289
|
-
});
|
|
2290
|
-
});
|
|
2291
|
-
// SSE: real-time event stream
|
|
2292
|
-
this.app.get('/api/events/stream', (req, res) => {
|
|
2293
|
-
res.writeHead(200, {
|
|
2294
|
-
'Content-Type': 'text/event-stream',
|
|
2295
|
-
'Cache-Control': 'no-cache',
|
|
2296
|
-
Connection: 'keep-alive',
|
|
2297
|
-
});
|
|
2298
|
-
res.write('data: {"type":"connected"}\n\n');
|
|
2299
|
-
const filterSession = req.query.session;
|
|
2300
|
-
const filterProject = req.query.project;
|
|
2301
|
-
const filterHook = req.query.hook;
|
|
2302
|
-
const onEvent = (event) => {
|
|
2303
|
-
if (filterSession && event.session_id !== filterSession)
|
|
2304
|
-
return;
|
|
2305
|
-
if (filterProject && event.project_path !== filterProject)
|
|
2306
|
-
return;
|
|
2307
|
-
if (filterHook && event.hook_type !== filterHook)
|
|
2308
|
-
return;
|
|
2309
|
-
res.write(`data: ${JSON.stringify(event)}\n\n`);
|
|
2310
|
-
};
|
|
2311
|
-
storage.on('event', onEvent);
|
|
2312
|
-
req.on('close', () => {
|
|
2313
|
-
storage.removeListener('event', onEvent);
|
|
2314
|
-
});
|
|
2315
|
-
});
|
|
2316
|
-
// SSE: real-time governance decisions
|
|
2317
|
-
this.app.get('/api/decisions/stream', (req, res) => {
|
|
2318
|
-
res.writeHead(200, {
|
|
2319
|
-
'Content-Type': 'text/event-stream',
|
|
2320
|
-
'Cache-Control': 'no-cache',
|
|
2321
|
-
Connection: 'keep-alive',
|
|
2322
|
-
});
|
|
2323
|
-
res.write('data: {"type":"connected"}\n\n');
|
|
2324
|
-
const filterSession = req.query.session;
|
|
2325
|
-
const onDecision = (decision) => {
|
|
2326
|
-
if (filterSession && decision.session_id !== filterSession)
|
|
2327
|
-
return;
|
|
2328
|
-
res.write(`data: ${JSON.stringify(decision)}\n\n`);
|
|
2329
|
-
};
|
|
2330
|
-
storage.on('decision', onDecision);
|
|
2331
|
-
req.on('close', () => {
|
|
2332
|
-
storage.removeListener('decision', onDecision);
|
|
2333
|
-
});
|
|
2334
|
-
});
|
|
2335
|
-
// SPA fallback: serve index.html for any non-API route
|
|
2336
|
-
// (must be the LAST route to not interfere with API routes)
|
|
2337
|
-
const spaCandidates = [
|
|
2338
|
-
path.join(__dirname, 'static'),
|
|
2339
|
-
path.join(__dirname, '../../src/web/static'),
|
|
2340
|
-
];
|
|
2341
|
-
const spaStaticDir = spaCandidates.find(dir => fs.existsSync(dir));
|
|
2342
|
-
if (spaStaticDir) {
|
|
2343
|
-
this.app.get(/^(?!\/api).*$/, (_req, res) => {
|
|
2344
|
-
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
|
2345
|
-
res.sendFile('index.html', { root: spaStaticDir });
|
|
2346
|
-
});
|
|
2347
|
-
}
|
|
56
|
+
// 1. Auth bootstrap (must be available before everything else)
|
|
57
|
+
registerAuthRoutes(this.app, ctx);
|
|
58
|
+
// 2. Static assets (serves UI; fallback registered last)
|
|
59
|
+
registerStaticAssets(this.app, ctx);
|
|
60
|
+
// 3. API routes, grouped by domain. Order within /api doesn't matter
|
|
61
|
+
// because Express matches exact paths — but execution-trace preserves
|
|
62
|
+
// the original (buggy) definition order intentionally.
|
|
63
|
+
registerStatusRoutes(this.app, ctx);
|
|
64
|
+
registerEventsRoutes(this.app, ctx);
|
|
65
|
+
registerSessionsRoutes(this.app, ctx);
|
|
66
|
+
registerRulesRoutes(this.app, ctx);
|
|
67
|
+
registerRoutingRoutes(this.app, ctx);
|
|
68
|
+
registerExperimentsRoutes(this.app, ctx);
|
|
69
|
+
registerMethodologyRoutes(this.app, ctx);
|
|
70
|
+
registerTokenUsageRoutes(this.app, ctx);
|
|
71
|
+
registerExecutionTraceRoutes(this.app, ctx);
|
|
72
|
+
registerAIRoutes(this.app, ctx);
|
|
73
|
+
registerPatchRoutes(this.app, ctx);
|
|
74
|
+
registerAgentsRoutes(this.app, ctx);
|
|
75
|
+
registerSkillsRoutes(this.app, ctx);
|
|
76
|
+
// 4. SPA catch-all — must be LAST so it can't shadow any API route.
|
|
77
|
+
registerStaticFallback(this.app, ctx);
|
|
2348
78
|
}
|
|
2349
79
|
async start() {
|
|
2350
80
|
return new Promise((resolve, reject) => {
|
|
@@ -2362,7 +92,7 @@ Return ONLY a JSON object with this exact structure (no markdown, no explanation
|
|
|
2362
92
|
}
|
|
2363
93
|
async stop() {
|
|
2364
94
|
if (this.server) {
|
|
2365
|
-
return new Promise(
|
|
95
|
+
return new Promise(resolve => {
|
|
2366
96
|
this.server.close(() => resolve());
|
|
2367
97
|
});
|
|
2368
98
|
}
|