aegis-bridge 2.15.0 → 2.15.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api-contracts.d.ts +20 -1
- package/dist/events.d.ts +1 -1
- package/dist/hooks.js +25 -0
- package/dist/memory-routes.js +47 -0
- package/dist/model-router.d.ts +53 -0
- package/dist/model-router.js +150 -0
- package/dist/server.js +72 -1
- package/package.json +1 -1
package/dist/api-contracts.d.ts
CHANGED
|
@@ -138,7 +138,7 @@ export interface GlobalMetrics {
|
|
|
138
138
|
channel_delivery_ms: LatencySummaryStat;
|
|
139
139
|
};
|
|
140
140
|
}
|
|
141
|
-
export type SSEEventType = 'status' | 'message' | 'approval' | 'ended' | 'heartbeat' | 'stall' | 'dead' | 'system' | 'hook' | 'subagent_start' | 'subagent_stop' | 'verification';
|
|
141
|
+
export type SSEEventType = 'status' | 'message' | 'approval' | 'ended' | 'heartbeat' | 'stall' | 'dead' | 'system' | 'hook' | 'subagent_start' | 'subagent_stop' | 'verification' | 'permission_denied';
|
|
142
142
|
export interface SessionSSEEvent {
|
|
143
143
|
event: SSEEventType;
|
|
144
144
|
sessionId: string;
|
|
@@ -203,3 +203,22 @@ export interface SessionsListResponse {
|
|
|
203
203
|
};
|
|
204
204
|
}
|
|
205
205
|
export type SessionStatusCounts = Record<SessionStatusFilter, number>;
|
|
206
|
+
/** Issue #754: Aggregated session statistics. */
|
|
207
|
+
export interface SessionStats {
|
|
208
|
+
active: number;
|
|
209
|
+
byStatus: Partial<Record<UIState, number>>;
|
|
210
|
+
totalCreated: number;
|
|
211
|
+
totalCompleted: number;
|
|
212
|
+
totalFailed: number;
|
|
213
|
+
}
|
|
214
|
+
/** Issue #754: Bulk-delete request body. */
|
|
215
|
+
export interface BatchDeleteRequest {
|
|
216
|
+
ids?: string[];
|
|
217
|
+
status?: UIState;
|
|
218
|
+
}
|
|
219
|
+
/** Issue #754: Bulk-delete response. */
|
|
220
|
+
export interface BatchDeleteResponse {
|
|
221
|
+
deleted: number;
|
|
222
|
+
notFound: string[];
|
|
223
|
+
errors: string[];
|
|
224
|
+
}
|
package/dist/events.d.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* The monitor pushes events; the SSE route consumes them.
|
|
7
7
|
*/
|
|
8
8
|
export interface SessionSSEEvent {
|
|
9
|
-
event: 'status' | 'message' | 'system' | 'approval' | 'ended' | 'heartbeat' | 'stall' | 'dead' | 'hook' | 'subagent_start' | 'subagent_stop' | 'verification';
|
|
9
|
+
event: 'status' | 'message' | 'system' | 'approval' | 'ended' | 'heartbeat' | 'stall' | 'dead' | 'hook' | 'subagent_start' | 'subagent_stop' | 'verification' | 'permission_denied';
|
|
10
10
|
sessionId: string;
|
|
11
11
|
timestamp: string;
|
|
12
12
|
data: Record<string, unknown>;
|
package/dist/hooks.js
CHANGED
|
@@ -49,12 +49,23 @@ const KNOWN_HOOK_EVENTS = new Set([
|
|
|
49
49
|
'ElicitationResult',
|
|
50
50
|
'FileChanged',
|
|
51
51
|
'CwdChanged',
|
|
52
|
+
// Issue #703 Phase 1: additional lifecycle events
|
|
53
|
+
'PermissionDenied',
|
|
54
|
+
'TaskCreated',
|
|
55
|
+
'Setup',
|
|
56
|
+
'ConfigChange',
|
|
57
|
+
'InstructionsLoaded',
|
|
52
58
|
]);
|
|
53
59
|
/** Hook events that are informational (logged + forwarded to SSE, no status change). */
|
|
54
60
|
const INFORMATIONAL_EVENTS = new Set([
|
|
55
61
|
'Notification',
|
|
56
62
|
'FileChanged',
|
|
57
63
|
'CwdChanged',
|
|
64
|
+
// Issue #703 Phase 1: informational lifecycle events
|
|
65
|
+
'Setup',
|
|
66
|
+
'ConfigChange',
|
|
67
|
+
'InstructionsLoaded',
|
|
68
|
+
'PermissionDenied',
|
|
58
69
|
]);
|
|
59
70
|
/** Map hook event names to the UIState they imply. */
|
|
60
71
|
function hookToUIState(eventName) {
|
|
@@ -76,6 +87,8 @@ function hookToUIState(eventName) {
|
|
|
76
87
|
case 'PreCompact': return 'compacting';
|
|
77
88
|
case 'PermissionRequest': return 'permission_prompt';
|
|
78
89
|
case 'TeammateIdle': return 'idle';
|
|
90
|
+
// Issue #703 Phase 1
|
|
91
|
+
case 'TaskCreated': return 'working';
|
|
79
92
|
default: return null;
|
|
80
93
|
}
|
|
81
94
|
}
|
|
@@ -146,6 +159,18 @@ export function registerHookRoutes(app, deps) {
|
|
|
146
159
|
data: { agentName },
|
|
147
160
|
});
|
|
148
161
|
}
|
|
162
|
+
// Issue #703 Phase 1: PermissionDenied — emit denied event for dashboard/agents
|
|
163
|
+
if (eventName === 'PermissionDenied') {
|
|
164
|
+
deps.eventBus.emit(sessionId, {
|
|
165
|
+
event: 'permission_denied',
|
|
166
|
+
sessionId,
|
|
167
|
+
timestamp: new Date().toISOString(),
|
|
168
|
+
data: {
|
|
169
|
+
toolName: hookBody.tool_name || '',
|
|
170
|
+
reason: hookBody.reason || '',
|
|
171
|
+
},
|
|
172
|
+
});
|
|
173
|
+
}
|
|
149
174
|
// Issue #89 L26: WorktreeCreate/Remove hooks — informational tracking only
|
|
150
175
|
if (eventName === 'WorktreeCreate' || eventName === 'WorktreeRemove') {
|
|
151
176
|
console.log(`Hooks: ${eventName} for session ${sessionId}`);
|
package/dist/memory-routes.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isValidUUID } from './validation.js';
|
|
1
2
|
import { z } from 'zod';
|
|
2
3
|
const setMemorySchema = z.object({
|
|
3
4
|
key: z.string().max(256),
|
|
@@ -50,4 +51,50 @@ export function registerMemoryRoutes(app, bridge) {
|
|
|
50
51
|
return reply.status(404).send({ error: `Key not found: ${key}` });
|
|
51
52
|
return { ok: true };
|
|
52
53
|
});
|
|
54
|
+
// Issue #705: Scoped memory retrieval — GET /v1/memories?scope=project|user|team
|
|
55
|
+
const VALID_SCOPES = new Set(['project', 'user', 'team']);
|
|
56
|
+
app.get('/v1/memories', async (req, reply) => {
|
|
57
|
+
const { scope } = req.query;
|
|
58
|
+
if (!scope || !VALID_SCOPES.has(scope)) {
|
|
59
|
+
return reply.status(400).send({ error: 'scope must be one of: project, user, team' });
|
|
60
|
+
}
|
|
61
|
+
const entries = bridge.list(`${scope}/`);
|
|
62
|
+
return { scope, entries };
|
|
63
|
+
});
|
|
64
|
+
// Issue #705: Session-linked memories — POST /v1/sessions/:id/memories
|
|
65
|
+
const sessionMemoryWriteSchema = z.object({
|
|
66
|
+
key: z.string().min(1).max(200),
|
|
67
|
+
value: z.string().max(100 * 1024),
|
|
68
|
+
ttlSeconds: z.number().int().positive().max(86400 * 30).optional(),
|
|
69
|
+
}).strict();
|
|
70
|
+
app.post('/v1/sessions/:id/memories', async (req, reply) => {
|
|
71
|
+
const { id } = req.params;
|
|
72
|
+
if (!isValidUUID(id))
|
|
73
|
+
return reply.status(400).send({ error: 'Invalid session ID — must be a UUID' });
|
|
74
|
+
const parsed = sessionMemoryWriteSchema.safeParse(req.body ?? {});
|
|
75
|
+
if (!parsed.success)
|
|
76
|
+
return reply.status(400).send({ error: 'Invalid body', details: parsed.error.issues });
|
|
77
|
+
const { key, value, ttlSeconds } = parsed.data;
|
|
78
|
+
const fullKey = `session:${id}/${key}`;
|
|
79
|
+
try {
|
|
80
|
+
const entry = bridge.set(fullKey, value, ttlSeconds);
|
|
81
|
+
return { ok: true, entry };
|
|
82
|
+
}
|
|
83
|
+
catch (e) {
|
|
84
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
85
|
+
if (msg.includes('Invalid key format'))
|
|
86
|
+
return reply.status(400).send({ error: msg });
|
|
87
|
+
if (msg.includes('exceeds maximum size'))
|
|
88
|
+
return reply.status(413).send({ error: msg });
|
|
89
|
+
throw e;
|
|
90
|
+
}
|
|
91
|
+
});
|
|
92
|
+
// Issue #705: Session-linked memories — GET /v1/sessions/:id/memories
|
|
93
|
+
app.get('/v1/sessions/:id/memories', async (req, reply) => {
|
|
94
|
+
const { id } = req.params;
|
|
95
|
+
if (!isValidUUID(id))
|
|
96
|
+
return reply.status(400).send({ error: 'Invalid session ID — must be a UUID' });
|
|
97
|
+
const entries = bridge.list(`session:${id}/`);
|
|
98
|
+
return { sessionId: id, entries };
|
|
99
|
+
});
|
|
53
100
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* model-router.ts — Issue #743: Tiered Model Routing
|
|
3
|
+
*
|
|
4
|
+
* Scores task complexity from metadata (title, labels, description) and routes
|
|
5
|
+
* to the optimal model tier: fast | standard | power.
|
|
6
|
+
*
|
|
7
|
+
* Scoring (0–100):
|
|
8
|
+
* 0–30 → fast (cheapest, e.g. Haiku-class)
|
|
9
|
+
* 31–70 → standard (balanced, e.g. Sonnet-class)
|
|
10
|
+
* 71–100 → power (most capable, e.g. Opus-class)
|
|
11
|
+
*
|
|
12
|
+
* Concrete model names are configurable via environment variables:
|
|
13
|
+
* MODEL_FAST, MODEL_STANDARD, MODEL_POWER
|
|
14
|
+
*/
|
|
15
|
+
import type { FastifyInstance } from 'fastify';
|
|
16
|
+
export type ModelTier = 'fast' | 'standard' | 'power';
|
|
17
|
+
export interface RoutingDecision {
|
|
18
|
+
tier: ModelTier;
|
|
19
|
+
model: string;
|
|
20
|
+
score: number;
|
|
21
|
+
reasoning: string[];
|
|
22
|
+
}
|
|
23
|
+
/** Default model names per tier (overridable via env vars). */
|
|
24
|
+
export declare const MODEL_TIERS: Record<ModelTier, string>;
|
|
25
|
+
/**
|
|
26
|
+
* Score a task 0–100 based on its metadata.
|
|
27
|
+
* Returns the score and a human-readable reasoning list.
|
|
28
|
+
*/
|
|
29
|
+
export declare function scoreTaskComplexity(title: string, labels: string[], description: string): {
|
|
30
|
+
score: number;
|
|
31
|
+
reasoning: string[];
|
|
32
|
+
};
|
|
33
|
+
/** Map a 0–100 score to a model tier. */
|
|
34
|
+
export declare function scoreToTier(score: number): ModelTier;
|
|
35
|
+
/**
|
|
36
|
+
* Route a task to the optimal model tier and concrete model name.
|
|
37
|
+
*
|
|
38
|
+
* @example
|
|
39
|
+
* routeTask({ title: 'fix typo in README', labels: ['docs'] })
|
|
40
|
+
* // → { tier: 'fast', model: 'claude-haiku-4-5', score: 15, reasoning: [...] }
|
|
41
|
+
*/
|
|
42
|
+
export declare function routeTask(opts: {
|
|
43
|
+
title: string;
|
|
44
|
+
labels?: string[];
|
|
45
|
+
description?: string;
|
|
46
|
+
}): RoutingDecision;
|
|
47
|
+
/**
|
|
48
|
+
* Register the model-routing endpoint on the Fastify app.
|
|
49
|
+
*
|
|
50
|
+
* POST /v1/dev/route-task — score a task and return model recommendation.
|
|
51
|
+
* GET /v1/dev/model-tiers — return current model-tier configuration.
|
|
52
|
+
*/
|
|
53
|
+
export declare function registerModelRouterRoutes(app: FastifyInstance): void;
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* model-router.ts — Issue #743: Tiered Model Routing
|
|
3
|
+
*
|
|
4
|
+
* Scores task complexity from metadata (title, labels, description) and routes
|
|
5
|
+
* to the optimal model tier: fast | standard | power.
|
|
6
|
+
*
|
|
7
|
+
* Scoring (0–100):
|
|
8
|
+
* 0–30 → fast (cheapest, e.g. Haiku-class)
|
|
9
|
+
* 31–70 → standard (balanced, e.g. Sonnet-class)
|
|
10
|
+
* 71–100 → power (most capable, e.g. Opus-class)
|
|
11
|
+
*
|
|
12
|
+
* Concrete model names are configurable via environment variables:
|
|
13
|
+
* MODEL_FAST, MODEL_STANDARD, MODEL_POWER
|
|
14
|
+
*/
|
|
15
|
+
import { z } from 'zod';
|
|
16
|
+
/** Keyword signals mapped to model tier. First match in each tier wins. */
|
|
17
|
+
const ROUTING_KEYWORDS = {
|
|
18
|
+
power: [
|
|
19
|
+
'security', 'auth', 'authentication', 'authorization',
|
|
20
|
+
'architecture', 'redesign', 'migration', 'critical',
|
|
21
|
+
'vulnerability', 'injection', 'cryptography', 'encryption',
|
|
22
|
+
'race condition', 'concurrency', 'breaking change',
|
|
23
|
+
'permission', 'privilege', 'escalation',
|
|
24
|
+
],
|
|
25
|
+
standard: [
|
|
26
|
+
'feature', 'enhancement', 'refactor', 'type-safety',
|
|
27
|
+
'integration', 'api', 'endpoint', 'validation',
|
|
28
|
+
'test', 'coverage', 'hook', 'pipeline', 'routing',
|
|
29
|
+
'module', 'performance', 'optimization',
|
|
30
|
+
],
|
|
31
|
+
fast: [
|
|
32
|
+
'typo', 'docs', 'documentation', 'label', 'rename',
|
|
33
|
+
'bump', 'chore', 'formatting', 'comment', 'readme',
|
|
34
|
+
'changelog', 'version', 'lint', 'whitespace',
|
|
35
|
+
],
|
|
36
|
+
};
|
|
37
|
+
/** Default model names per tier (overridable via env vars). */
|
|
38
|
+
export const MODEL_TIERS = {
|
|
39
|
+
fast: process.env.MODEL_FAST ?? 'claude-haiku-4-5',
|
|
40
|
+
standard: process.env.MODEL_STANDARD ?? 'claude-sonnet-4-6',
|
|
41
|
+
power: process.env.MODEL_POWER ?? 'claude-opus-4-6',
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* Score a task 0–100 based on its metadata.
|
|
45
|
+
* Returns the score and a human-readable reasoning list.
|
|
46
|
+
*/
|
|
47
|
+
export function scoreTaskComplexity(title, labels, description) {
|
|
48
|
+
const reasoning = [];
|
|
49
|
+
let score = 35; // baseline: low-standard
|
|
50
|
+
const text = `${title} ${description}`.toLowerCase();
|
|
51
|
+
// Power keywords → raise score to at least power tier threshold
|
|
52
|
+
for (const kw of ROUTING_KEYWORDS.power) {
|
|
53
|
+
if (text.includes(kw)) {
|
|
54
|
+
score = Math.max(score, 75);
|
|
55
|
+
reasoning.push(`power keyword: "${kw}"`);
|
|
56
|
+
break;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
// Fast keywords → lower score to at most fast tier threshold
|
|
60
|
+
for (const kw of ROUTING_KEYWORDS.fast) {
|
|
61
|
+
if (text.includes(kw)) {
|
|
62
|
+
score = Math.min(score, 20);
|
|
63
|
+
reasoning.push(`fast keyword: "${kw}"`);
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
// Standard keywords → minor boost (avoid staying at baseline)
|
|
68
|
+
if (reasoning.length === 0) {
|
|
69
|
+
for (const kw of ROUTING_KEYWORDS.standard) {
|
|
70
|
+
if (text.includes(kw)) {
|
|
71
|
+
score += 5;
|
|
72
|
+
reasoning.push(`standard keyword: "${kw}"`);
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// Label overrides — applied after keyword signals
|
|
78
|
+
for (const label of labels) {
|
|
79
|
+
const l = label.toLowerCase();
|
|
80
|
+
if (l === 'security' || l === 'critical' || l === 'breaking-change') {
|
|
81
|
+
score = Math.max(score, 80);
|
|
82
|
+
reasoning.push(`label override: "${l}" → power tier`);
|
|
83
|
+
}
|
|
84
|
+
else if (l === 'docs' || l === 'documentation' || l === 'chore') {
|
|
85
|
+
score = Math.min(score, 20);
|
|
86
|
+
reasoning.push(`label override: "${l}" → fast tier`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Priority labels
|
|
90
|
+
for (const label of labels) {
|
|
91
|
+
if (label === 'P0' || label === 'P1') {
|
|
92
|
+
score = Math.max(score, 72);
|
|
93
|
+
reasoning.push(`priority label: "${label}" → elevate to power`);
|
|
94
|
+
}
|
|
95
|
+
else if (label === 'P3') {
|
|
96
|
+
score = Math.min(score, 55);
|
|
97
|
+
reasoning.push(`priority label: "P3" → cap at standard`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (reasoning.length === 0)
|
|
101
|
+
reasoning.push('baseline score — no keyword or label signals');
|
|
102
|
+
return { score: Math.max(0, Math.min(100, score)), reasoning };
|
|
103
|
+
}
|
|
104
|
+
/** Map a 0–100 score to a model tier. */
|
|
105
|
+
export function scoreToTier(score) {
|
|
106
|
+
if (score <= 30)
|
|
107
|
+
return 'fast';
|
|
108
|
+
if (score <= 70)
|
|
109
|
+
return 'standard';
|
|
110
|
+
return 'power';
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Route a task to the optimal model tier and concrete model name.
|
|
114
|
+
*
|
|
115
|
+
* @example
|
|
116
|
+
* routeTask({ title: 'fix typo in README', labels: ['docs'] })
|
|
117
|
+
* // → { tier: 'fast', model: 'claude-haiku-4-5', score: 15, reasoning: [...] }
|
|
118
|
+
*/
|
|
119
|
+
export function routeTask(opts) {
|
|
120
|
+
const { title, labels = [], description = '' } = opts;
|
|
121
|
+
const { score, reasoning } = scoreTaskComplexity(title, labels, description);
|
|
122
|
+
const tier = scoreToTier(score);
|
|
123
|
+
const model = MODEL_TIERS[tier];
|
|
124
|
+
return { tier, model, score, reasoning };
|
|
125
|
+
}
|
|
126
|
+
/** Zod schema for POST /v1/dev/route-task request body. */
|
|
127
|
+
const routeTaskSchema = z.object({
|
|
128
|
+
title: z.string().min(1).max(500),
|
|
129
|
+
labels: z.array(z.string().max(100)).max(50).optional(),
|
|
130
|
+
description: z.string().max(10_000).optional(),
|
|
131
|
+
});
|
|
132
|
+
/**
|
|
133
|
+
* Register the model-routing endpoint on the Fastify app.
|
|
134
|
+
*
|
|
135
|
+
* POST /v1/dev/route-task — score a task and return model recommendation.
|
|
136
|
+
* GET /v1/dev/model-tiers — return current model-tier configuration.
|
|
137
|
+
*/
|
|
138
|
+
export function registerModelRouterRoutes(app) {
|
|
139
|
+
app.post('/v1/dev/route-task', async (req, reply) => {
|
|
140
|
+
const parsed = routeTaskSchema.safeParse(req.body ?? {});
|
|
141
|
+
if (!parsed.success) {
|
|
142
|
+
return reply.status(400).send({ error: 'Invalid body', details: parsed.error.issues });
|
|
143
|
+
}
|
|
144
|
+
const { title, labels, description } = parsed.data;
|
|
145
|
+
return routeTask({ title, labels, description });
|
|
146
|
+
});
|
|
147
|
+
app.get('/v1/dev/model-tiers', async () => {
|
|
148
|
+
return { tiers: MODEL_TIERS };
|
|
149
|
+
});
|
|
150
|
+
}
|
package/dist/server.js
CHANGED
|
@@ -36,6 +36,7 @@ import { registerPermissionRoutes } from './permission-routes.js';
|
|
|
36
36
|
import { registerHookRoutes } from './hooks.js';
|
|
37
37
|
import { registerWsTerminalRoute } from './ws-terminal.js';
|
|
38
38
|
import { registerMemoryRoutes } from './memory-routes.js';
|
|
39
|
+
import { registerModelRouterRoutes } from './model-router.js';
|
|
39
40
|
import * as templateStore from './template-store.js';
|
|
40
41
|
import { SwarmMonitor } from './swarm-monitor.js';
|
|
41
42
|
import { killAllSessions } from './signal-cleanup-helper.js';
|
|
@@ -581,15 +582,21 @@ app.get('/v1/events', async (req, reply) => {
|
|
|
581
582
|
writer.startHeartbeat(30_000, 90_000, () => `data: ${JSON.stringify({ event: 'heartbeat', timestamp: new Date().toISOString() })}\n\n`);
|
|
582
583
|
await reply;
|
|
583
584
|
});
|
|
584
|
-
// List sessions (with pagination and
|
|
585
|
+
// List sessions (with pagination, status filter, and project filter)
|
|
585
586
|
app.get('/v1/sessions', async (req) => {
|
|
586
587
|
const page = Math.max(1, parseInt(req.query.page || '1', 10) || 1);
|
|
587
588
|
const limit = Math.min(100, Math.max(1, parseInt(req.query.limit || '20', 10) || 20));
|
|
588
589
|
const statusFilter = req.query.status;
|
|
590
|
+
const projectFilter = req.query.project;
|
|
589
591
|
let all = sessions.listSessions();
|
|
590
592
|
if (statusFilter) {
|
|
591
593
|
all = all.filter(s => s.status === statusFilter);
|
|
592
594
|
}
|
|
595
|
+
// Issue #754: filter by project (workDir prefix/substring match)
|
|
596
|
+
if (projectFilter) {
|
|
597
|
+
const lower = projectFilter.toLowerCase();
|
|
598
|
+
all = all.filter(s => s.workDir.toLowerCase().includes(lower));
|
|
599
|
+
}
|
|
593
600
|
// Sort by createdAt descending (newest first)
|
|
594
601
|
all.sort((a, b) => b.createdAt - a.createdAt);
|
|
595
602
|
const total = all.length;
|
|
@@ -601,6 +608,68 @@ app.get('/v1/sessions', async (req) => {
|
|
|
601
608
|
pagination: { page, limit, total, totalPages },
|
|
602
609
|
};
|
|
603
610
|
});
|
|
611
|
+
// Issue #754: Session statistics endpoint
|
|
612
|
+
app.get('/v1/sessions/stats', async () => {
|
|
613
|
+
const all = sessions.listSessions();
|
|
614
|
+
const byStatus = {};
|
|
615
|
+
for (const s of all) {
|
|
616
|
+
byStatus[s.status] = (byStatus[s.status] ?? 0) + 1;
|
|
617
|
+
}
|
|
618
|
+
const global = metrics.getGlobalMetrics(all.length);
|
|
619
|
+
return {
|
|
620
|
+
active: all.length,
|
|
621
|
+
byStatus,
|
|
622
|
+
totalCreated: global.sessions.total_created,
|
|
623
|
+
totalCompleted: global.sessions.completed,
|
|
624
|
+
totalFailed: global.sessions.failed,
|
|
625
|
+
};
|
|
626
|
+
});
|
|
627
|
+
// Issue #754: Bulk-delete sessions by IDs and/or status
|
|
628
|
+
const batchDeleteSchema = z.object({
|
|
629
|
+
ids: z.array(z.string().uuid()).max(100).optional(),
|
|
630
|
+
status: z.enum([
|
|
631
|
+
'idle', 'working', 'compacting', 'context_warning', 'waiting_for_input',
|
|
632
|
+
'permission_prompt', 'plan_mode', 'ask_question', 'bash_approval',
|
|
633
|
+
'settings', 'error', 'unknown',
|
|
634
|
+
]).optional(),
|
|
635
|
+
}).refine(d => d.ids !== undefined || d.status !== undefined, {
|
|
636
|
+
message: 'At least one of "ids" or "status" is required',
|
|
637
|
+
});
|
|
638
|
+
app.delete('/v1/sessions/batch', async (req, reply) => {
|
|
639
|
+
const parsed = batchDeleteSchema.safeParse(req.body);
|
|
640
|
+
if (!parsed.success) {
|
|
641
|
+
return reply.status(400).send({ error: 'Invalid request body', details: parsed.error.issues });
|
|
642
|
+
}
|
|
643
|
+
const { ids, status } = parsed.data;
|
|
644
|
+
// Collect target session IDs
|
|
645
|
+
const targets = new Set(ids ?? []);
|
|
646
|
+
if (status) {
|
|
647
|
+
for (const s of sessions.listSessions()) {
|
|
648
|
+
if (s.status === status)
|
|
649
|
+
targets.add(s.id);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
let deleted = 0;
|
|
653
|
+
const notFound = [];
|
|
654
|
+
const errors = [];
|
|
655
|
+
for (const id of targets) {
|
|
656
|
+
if (!sessions.getSession(id)) {
|
|
657
|
+
notFound.push(id);
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
try {
|
|
661
|
+
await sessions.killSession(id);
|
|
662
|
+
eventBus.emitEnded(id, 'killed');
|
|
663
|
+
void channels.sessionEnded(makePayload('session.ended', id, 'killed'));
|
|
664
|
+
cleanupTerminatedSessionState(id, { monitor, metrics, toolRegistry });
|
|
665
|
+
deleted++;
|
|
666
|
+
}
|
|
667
|
+
catch (e) {
|
|
668
|
+
errors.push(`${id}: ${e instanceof Error ? e.message : String(e)}`);
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
return reply.status(200).send({ deleted, notFound, errors });
|
|
672
|
+
});
|
|
604
673
|
// Backwards compat: /sessions (no prefix) returns raw array
|
|
605
674
|
app.get('/sessions', async () => sessions.listSessions());
|
|
606
675
|
/** Validate workDir — delegates to validation.ts (Issue #435). */
|
|
@@ -1606,6 +1675,8 @@ async function main() {
|
|
|
1606
1675
|
}
|
|
1607
1676
|
// Register HTTP hook receiver (Issue #169, Issue #87: pass metrics for latency tracking)
|
|
1608
1677
|
registerHookRoutes(app, { sessions, eventBus, metrics });
|
|
1678
|
+
// Issue #743: Register model-routing endpoints
|
|
1679
|
+
registerModelRouterRoutes(app);
|
|
1609
1680
|
// Initialize pipeline manager (Issue #36)
|
|
1610
1681
|
pipelines = new PipelineManager(sessions, eventBus);
|
|
1611
1682
|
// Initialize batch rate limiter (Issue #583)
|