@shendeguize/dsh-agent-sidecar 0.1.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/LICENSE +21 -0
- package/README.md +167 -0
- package/cordis.patch.yml +10 -0
- package/lib/client.js +8062 -0
- package/lib/client.js.map +1 -0
- package/lib/index.d.ts +396 -0
- package/lib/index.js +4166 -0
- package/package.json +101 -0
- package/src/analysis.ts +782 -0
- package/src/bridge.ts +841 -0
- package/src/client/analysis/AnalysisPanel.tsx +191 -0
- package/src/client/analysis/analysis.module.css +183 -0
- package/src/client/analysis-glue.ts +331 -0
- package/src/client/api.ts +380 -0
- package/src/client/board/Board.tsx +214 -0
- package/src/client/board/board.module.css +302 -0
- package/src/client/board/logic.ts +556 -0
- package/src/client/board/project-view-logic.ts +361 -0
- package/src/client/board/project-view.module.css +307 -0
- package/src/client/board/project-view.tsx +189 -0
- package/src/client/board/strings.ts +112 -0
- package/src/client/commands.ts +484 -0
- package/src/client/controller.ts +360 -0
- package/src/client/css-modules.d.ts +11 -0
- package/src/client/detail/SessionDetail.tsx +270 -0
- package/src/client/detail/detail.module.css +433 -0
- package/src/client/detail/logic.ts +779 -0
- package/src/client/detail/strings.ts +98 -0
- package/src/client/detail/transport.ts +175 -0
- package/src/client/detail-glue.ts +397 -0
- package/src/client/detail-view.module.css +79 -0
- package/src/client/detail-view.tsx +233 -0
- package/src/client/dsh-tools/LineageTree.tsx +210 -0
- package/src/client/dsh-tools/SearchPanel.tsx +169 -0
- package/src/client/dsh-tools/dsh-tools.module.css +374 -0
- package/src/client/dsh-tools/logic.ts +596 -0
- package/src/client/dsh-tools/strings.ts +90 -0
- package/src/client/index.ts +315 -0
- package/src/client/inject/InjectPanel.tsx +482 -0
- package/src/client/inject/inject.module.css +446 -0
- package/src/client/inject/logic.ts +516 -0
- package/src/client/inject/overlay.module.css +22 -0
- package/src/client/inject-glue.ts +171 -0
- package/src/client/locales/command.ts +48 -0
- package/src/client/locales/en.ts +385 -0
- package/src/client/locales/index.ts +123 -0
- package/src/client/locales/zh.ts +402 -0
- package/src/client/m3-transport.ts +151 -0
- package/src/client/mount.tsx +307 -0
- package/src/client/project-glue.ts +134 -0
- package/src/client/search-glue.ts +143 -0
- package/src/client/settings-card.module.css +359 -0
- package/src/client/settings-card.tsx +565 -0
- package/src/client/settings-glue.ts +130 -0
- package/src/client/sidebar-tab.tsx +494 -0
- package/src/client/sse.ts +366 -0
- package/src/client/widget.tsx +80 -0
- package/src/config.ts +193 -0
- package/src/dsh-inject.ts +240 -0
- package/src/fusion.ts +988 -0
- package/src/guard.ts +274 -0
- package/src/index.ts +950 -0
- package/src/inject-gateway.ts +574 -0
- package/src/routes.ts +1133 -0
- package/src/send-cli.ts +340 -0
- package/src/session-store.ts +184 -0
- package/src/skills-provider.ts +293 -0
- package/src/supervisor.ts +463 -0
package/src/routes.ts
ADDED
|
@@ -0,0 +1,1133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HTTP route layer for the plugin's self-registered namespace (design §4.f).
|
|
3
|
+
*
|
|
4
|
+
* M1 scope: three read endpoints (`GET state`, `GET session/<id>`,
|
|
5
|
+
* `GET stream` SSE) plus a `POST action` placeholder behind the write gate.
|
|
6
|
+
*
|
|
7
|
+
* M2 scope: when an {@link InjectGatewayApi} is wired into the deps,
|
|
8
|
+
* `POST action` becomes a dispatcher over a bounded (≤64 KiB) JSON body
|
|
9
|
+
* `{ type: 'inject.prepare' | 'inject.execute' | 'daemon.retry', ... }`.
|
|
10
|
+
* The inject types pass the write-action gate and drive the gateway's
|
|
11
|
+
* two-phase confirm; `daemon.retry` is daemon management — independent of
|
|
12
|
+
* the injection capability (design §6: `inject.enabled` gates injection
|
|
13
|
+
* only) — so it passes guard layers 1-4 without the write gate. Without a
|
|
14
|
+
* gateway the M1 placeholder contract (write gate, then 501) is preserved.
|
|
15
|
+
* Message bodies never reach the route log (S8), and `outcome: 'unknown'`
|
|
16
|
+
* is answered 200 as a terminal "do not retry" — this layer never re-fires
|
|
17
|
+
* the gateway on it (S6).
|
|
18
|
+
*
|
|
19
|
+
* M3 scope: when a {@link FusionApi} is wired into the deps, the read
|
|
20
|
+
* surface widens (design §4.e / §5, all GET, guard layers 1-4, no write
|
|
21
|
+
* gate):
|
|
22
|
+
* - `GET session/<id>` upgrades its `timeline: null` placeholder to the
|
|
23
|
+
* newest fused timeline page plus a `nextCursor` pagination token, and
|
|
24
|
+
* gains a `unified` row (dsh-live sessions the sidecar has not seen
|
|
25
|
+
* yet resolve here instead of 404).
|
|
26
|
+
* - `GET session/<id>/timeline?cursor=&limit=` pages backward through
|
|
27
|
+
* history (fusion pulls dsh live/cold logs, the daemon `replay` op and
|
|
28
|
+
* the bounded event ring on demand).
|
|
29
|
+
* - `GET lineage/<id>` → fusion.getLineage; ALWAYS 200 with
|
|
30
|
+
* `{available:false, reason}` when sessionQuery is absent or the trace
|
|
31
|
+
* fails (degradation is data, not an error; design §4.e.4).
|
|
32
|
+
* - `GET search?q=&project=&limit=` → fusion.searchSessions (`mode`
|
|
33
|
+
* reports 'full-text' vs the 'filter-only' degradation); a
|
|
34
|
+
* project-only query filters the unified list without the engine.
|
|
35
|
+
* - `GET projects` → fusion.getProjectGroups.
|
|
36
|
+
* Without a fusion the four new paths answer 501 `fusion_not_wired` and
|
|
37
|
+
* `GET session/<id>` keeps the M1 placeholder contract. The SSE stream
|
|
38
|
+
* contract is untouched: live updates stay full-snapshot frames and the
|
|
39
|
+
* client pairs them with timeline pagination (no per-session filter —
|
|
40
|
+
* recorded as not needed for M3).
|
|
41
|
+
*
|
|
42
|
+
* M3 analysis scope (design §4.e.3 / §7-B): when an {@link AnalysisApi} is
|
|
43
|
+
* wired into the deps, `POST action` also dispatches
|
|
44
|
+
* `{ type: 'analysis.request' | 'analysis.followup' | 'analysis.cancel' }`.
|
|
45
|
+
* These pass guard layers 1-4 and then a WRITE GATE OF THEIR OWN —
|
|
46
|
+
* `deps.analysisEnabled` (live `analysis.enabled`, default false), parallel
|
|
47
|
+
* to and independent of the guard's inject gate — closed (or absent) means
|
|
48
|
+
* 403 `analysis_disabled`. Past the gate, a missing/unavailable analysis
|
|
49
|
+
* surface (agents-less composition) answers 501 `analysis_unavailable`
|
|
50
|
+
* (honest degradation, never a crash). `analysis.request` additionally
|
|
51
|
+
* pre-checks that an analysis model is resolvable — no model anywhere
|
|
52
|
+
* answers 403 `analysis_model_unconfigured` before any session is created
|
|
53
|
+
* (A-1) — then resolves its target through the wiring's fusion-backed
|
|
54
|
+
* input adapter (unknown target
|
|
55
|
+
* → 404 `target_not_found`) and drives the engine; result error codes map
|
|
56
|
+
* to `analysis_disabled` 403, `too_many_active` 429, `timeout` 504,
|
|
57
|
+
* `create_failed` 502 — while `cancelled` stays 200 (a terminal fact about
|
|
58
|
+
* the analysis session, carried in the outcome, not a transport failure).
|
|
59
|
+
* Analyzed content (summaries, questions, model replies) never reaches the
|
|
60
|
+
* route log (S8). Note the M1 no-gateway placeholder still wins: analysis
|
|
61
|
+
* dispatch lives inside the gateway-backed action handler.
|
|
62
|
+
*
|
|
63
|
+
* Timeline cursors are opaque `<seq|'-'>~<epoch-ms>` tokens minted by
|
|
64
|
+
* {@link encodeCursor}; clients must round-trip them verbatim.
|
|
65
|
+
*
|
|
66
|
+
* Contract (verified against the dsh source, not docs): the webServer
|
|
67
|
+
* prefix-route handler is plain `node:http` —
|
|
68
|
+
* `(req: IncomingMessage, res: ServerResponse) => void | Promise<void>`,
|
|
69
|
+
* and it owns the full response lifecycle, so SSE long-holds are legal
|
|
70
|
+
* (`packages/host/webserver/src/index.ts:42-48`). A `prefix` route `p`
|
|
71
|
+
* receives `p` and `p/<anything>` (`:38`, `:262`), and handler rejections
|
|
72
|
+
* are caught by the carrier (logged, answered 400; `:181-194`). Internal
|
|
73
|
+
* dispatch via `new URL(req.url, ...)` follows the better-sidebar
|
|
74
|
+
* precedent (`DSH-better-sidebar/src/index.ts:665-694`).
|
|
75
|
+
*
|
|
76
|
+
* Like guard/store/supervisor, this module imports nothing from
|
|
77
|
+
* cordis/dsh: the plugin entry (index.ts) wires it via
|
|
78
|
+
* `ctx.webServer.register({ kind: 'prefix', path: API_PREFIX, handler })`
|
|
79
|
+
* and puts `dispose()` inside a `ctx.effect` disposer.
|
|
80
|
+
*
|
|
81
|
+
* @module
|
|
82
|
+
*/
|
|
83
|
+
|
|
84
|
+
import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
85
|
+
import {
|
|
86
|
+
guardRequest,
|
|
87
|
+
guardWriteAction,
|
|
88
|
+
type GuardOptions,
|
|
89
|
+
type GuardVerdict,
|
|
90
|
+
} from './guard.ts'
|
|
91
|
+
import type { AnalysisEngine, AnalysisInput, AnalysisResult } from './analysis.ts'
|
|
92
|
+
import type { FusionQuery, TimelineCursor, TimelinePage } from './fusion.ts'
|
|
93
|
+
import type { InjectGateway } from './inject-gateway.ts'
|
|
94
|
+
import type { BoardState, SessionStore } from './session-store.ts'
|
|
95
|
+
import type { DaemonSupervisor, PingInfo, SupervisorState } from './supervisor.ts'
|
|
96
|
+
|
|
97
|
+
/** dsh webServer route handler shape (see module doc for the evidence). */
|
|
98
|
+
export type WebRouteHandler = (
|
|
99
|
+
req: IncomingMessage,
|
|
100
|
+
res: ServerResponse,
|
|
101
|
+
) => void | Promise<void>
|
|
102
|
+
|
|
103
|
+
/** Route namespace, per the `/plugins/<package>/` convention (design §4.f). */
|
|
104
|
+
export const API_PREFIX = '/plugins/agent-sidecar/api'
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The slice of {@link InjectGateway} the routes drive. A `Pick` keeps the
|
|
108
|
+
* dependency structural (the class has private state), so the real gateway
|
|
109
|
+
* and plain-object test fakes are equally assignable.
|
|
110
|
+
*/
|
|
111
|
+
export type InjectGatewayApi = Pick<InjectGateway, 'prepare' | 'execute'>
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The slice of {@link FusionQuery} the M3 read endpoints drive. Structural
|
|
115
|
+
* for the same reason as {@link InjectGatewayApi}: the entry wires the real
|
|
116
|
+
* fusion (possibly behind a holder facade) and tests wire plain objects.
|
|
117
|
+
*/
|
|
118
|
+
export type FusionApi = Pick<
|
|
119
|
+
FusionQuery,
|
|
120
|
+
| 'getUnifiedSessions'
|
|
121
|
+
| 'getSessionTimeline'
|
|
122
|
+
| 'getProjectGroups'
|
|
123
|
+
| 'getLineage'
|
|
124
|
+
| 'searchSessions'
|
|
125
|
+
| 'getCapabilities'
|
|
126
|
+
>
|
|
127
|
+
|
|
128
|
+
/** The slice of {@link AnalysisEngine} the analysis actions drive. */
|
|
129
|
+
export type AnalysisEngineApi = Pick<AnalysisEngine, 'request' | 'followup' | 'cancel'>
|
|
130
|
+
|
|
131
|
+
/** Target selector carried by one `analysis.request` action envelope. */
|
|
132
|
+
export interface AnalysisTargetRequest {
|
|
133
|
+
targetKind: 'session' | 'project' | 'cross-agent'
|
|
134
|
+
/** Session id / project path; required for session and project kinds. */
|
|
135
|
+
targetId?: string
|
|
136
|
+
/** Optional user question folded into the analysis input by the adapter. */
|
|
137
|
+
question?: string
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* M3 analysis wiring handed in by the entry: the engine plus the adapter
|
|
142
|
+
* that assembles a bounded {@link AnalysisInput} from fusion data. The
|
|
143
|
+
* routes stay dumb — target resolution and summary assembly are the
|
|
144
|
+
* wiring's job, request/result vocabulary is the engine's.
|
|
145
|
+
*/
|
|
146
|
+
export interface AnalysisApi {
|
|
147
|
+
engine: AnalysisEngineApi
|
|
148
|
+
/**
|
|
149
|
+
* Assemble the bounded analysis input for a target. `null` means the
|
|
150
|
+
* target is unknown to fusion (the routes answer 404 `target_not_found`).
|
|
151
|
+
*/
|
|
152
|
+
buildInput(req: AnalysisTargetRequest): Promise<AnalysisInput | null>
|
|
153
|
+
/**
|
|
154
|
+
* Whether the underlying `ctx.agents` service is bound. `false` answers
|
|
155
|
+
* 501 `analysis_unavailable` before touching the engine (agents-less
|
|
156
|
+
* composition — honest degradation).
|
|
157
|
+
*/
|
|
158
|
+
available(): boolean
|
|
159
|
+
/**
|
|
160
|
+
* Whether an analysis model is resolvable (explicit analysis.provider/
|
|
161
|
+
* model config, or the host's default model selection). `false` answers
|
|
162
|
+
* 403 `analysis_model_unconfigured` on `analysis.request` BEFORE the
|
|
163
|
+
* engine creates a session — honest pre-rejection instead of a modelless
|
|
164
|
+
* agent completing with an empty summary (A-1). Followup/cancel are not
|
|
165
|
+
* gated: an established analysis session carries its model already.
|
|
166
|
+
* Optional: an absent probe skips the pre-check (the create adapter
|
|
167
|
+
* still fails honestly as `create_failed`).
|
|
168
|
+
*/
|
|
169
|
+
modelConfigured?(): boolean
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Everything the route layer consumes; all live objects, none owned here. */
|
|
173
|
+
export interface RoutesDeps {
|
|
174
|
+
store: SessionStore
|
|
175
|
+
supervisor: DaemonSupervisor
|
|
176
|
+
/** Live `inject.enabled` reader shared with the guard's write gate. */
|
|
177
|
+
guardOptions: GuardOptions
|
|
178
|
+
/**
|
|
179
|
+
* M2 injection gateway. When absent (M1 wiring, injection not assembled)
|
|
180
|
+
* `POST action` keeps the placeholder contract: write gate, then 501.
|
|
181
|
+
*/
|
|
182
|
+
injectGateway?: InjectGatewayApi
|
|
183
|
+
/**
|
|
184
|
+
* M3 fusion query surface. When absent the timeline/lineage/search/
|
|
185
|
+
* projects endpoints answer 501 `fusion_not_wired` and `GET session/<id>`
|
|
186
|
+
* keeps the M1 placeholder contract.
|
|
187
|
+
*/
|
|
188
|
+
fusion?: FusionApi
|
|
189
|
+
/**
|
|
190
|
+
* Live `analysis.enabled` reader — the analysis write gate, parallel to
|
|
191
|
+
* (and independent of) the guard's inject gate. Absent reads as CLOSED
|
|
192
|
+
* (fail-closed): every `analysis.*` action answers 403 `analysis_disabled`.
|
|
193
|
+
*/
|
|
194
|
+
analysisEnabled?: () => boolean
|
|
195
|
+
/**
|
|
196
|
+
* M3 analysis surface. When absent (analysis not assembled), `analysis.*`
|
|
197
|
+
* actions that pass the gate answer 501 `analysis_unavailable`.
|
|
198
|
+
*/
|
|
199
|
+
analysis?: AnalysisApi
|
|
200
|
+
log(level: 'info' | 'warn' | 'error', msg: string, meta?: object): void
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export interface RoutesOptions {
|
|
204
|
+
/** Concurrent SSE connection cap; extra connects get 503. Default 8. */
|
|
205
|
+
maxSseClients?: number
|
|
206
|
+
/** SSE comment-frame heartbeat cadence. Default 15000. */
|
|
207
|
+
sseHeartbeatMs?: number
|
|
208
|
+
/**
|
|
209
|
+
* Per-connection bound on frames queued behind a slow socket; exceeding
|
|
210
|
+
* it destroys that connection (the client reconnects and resnapshots).
|
|
211
|
+
* Default 256.
|
|
212
|
+
*/
|
|
213
|
+
sseBufferLimit?: number
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Body of `GET state` and of every SSE `state` event (full snapshot). */
|
|
217
|
+
export interface StateSnapshot {
|
|
218
|
+
daemon: { state: SupervisorState; lastPing: PingInfo | null }
|
|
219
|
+
board: BoardState
|
|
220
|
+
capabilities: { inject: boolean }
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** What `createRoutes` hands back to the plugin entry. */
|
|
224
|
+
export interface Routes {
|
|
225
|
+
/** Mount as the `prefix` route handler for {@link API_PREFIX}. */
|
|
226
|
+
handle: (req: IncomingMessage, res: ServerResponse) => Promise<void>
|
|
227
|
+
/** Close all SSE connections, unsubscribe, clear timers. Idempotent. */
|
|
228
|
+
dispose(): void
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const DEFAULT_MAX_SSE_CLIENTS = 8
|
|
232
|
+
const DEFAULT_SSE_HEARTBEAT_MS = 15_000
|
|
233
|
+
const DEFAULT_SSE_BUFFER_LIMIT = 256
|
|
234
|
+
|
|
235
|
+
const HEARTBEAT_FRAME = ': hb\n\n'
|
|
236
|
+
|
|
237
|
+
/** Bound on the `POST action` JSON body (message cap is 16 KiB + envelope). */
|
|
238
|
+
export const MAX_ACTION_BODY_BYTES = 64 * 1024
|
|
239
|
+
|
|
240
|
+
/** `inject.prepare` rejection code → HTTP status (task spec mapping). */
|
|
241
|
+
const PREPARE_ERROR_STATUS: Readonly<Record<string, number>> = {
|
|
242
|
+
inject_disabled: 403,
|
|
243
|
+
invalid_message: 422,
|
|
244
|
+
target_not_found: 404,
|
|
245
|
+
target_dead: 409,
|
|
246
|
+
too_many_pending: 429,
|
|
247
|
+
// Issued at prepare since M2 review F-6 (no injection path for this
|
|
248
|
+
// agent); same 422 the execute-side defense-in-depth check maps to.
|
|
249
|
+
unsupported_agent: 422,
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* `inject.execute` failed-outcome code → HTTP status. Unlisted codes
|
|
254
|
+
* (executor-native vocab) and codeless failures fall back to 502.
|
|
255
|
+
*/
|
|
256
|
+
const EXECUTE_ERROR_STATUS: Readonly<Record<string, number>> = {
|
|
257
|
+
token_missing: 401,
|
|
258
|
+
token_expired: 401,
|
|
259
|
+
token_reused: 409,
|
|
260
|
+
token_mismatch: 409,
|
|
261
|
+
unsupported_agent: 422,
|
|
262
|
+
executor_error: 502,
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Analysis-engine error code → HTTP status (task spec mapping). `cancelled`
|
|
267
|
+
* stays 200: it is a terminal fact about the analysis session carried in
|
|
268
|
+
* the result outcome, not a transport failure. Unknown codes fall back to
|
|
269
|
+
* 502 like the execute map does.
|
|
270
|
+
*/
|
|
271
|
+
const ANALYSIS_ERROR_STATUS: Readonly<Record<string, number>> = {
|
|
272
|
+
analysis_disabled: 403,
|
|
273
|
+
too_many_active: 429,
|
|
274
|
+
timeout: 504,
|
|
275
|
+
create_failed: 502,
|
|
276
|
+
cancelled: 200,
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const ANALYSIS_ACTION_TYPES = new Set([
|
|
280
|
+
'analysis.request',
|
|
281
|
+
'analysis.followup',
|
|
282
|
+
'analysis.cancel',
|
|
283
|
+
])
|
|
284
|
+
|
|
285
|
+
interface SseClient {
|
|
286
|
+
res: ServerResponse
|
|
287
|
+
/** Frames queued while the socket is backpressured. */
|
|
288
|
+
pending: string[]
|
|
289
|
+
/** True after a `res.write` returned false, until the next 'drain'. */
|
|
290
|
+
blocked: boolean
|
|
291
|
+
closed: boolean
|
|
292
|
+
heartbeat: ReturnType<typeof setInterval> | null
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function writeJson(res: ServerResponse, status: number, body: unknown): void {
|
|
296
|
+
res.writeHead(status, {
|
|
297
|
+
'content-type': 'application/json; charset=utf-8',
|
|
298
|
+
'cache-control': 'no-store',
|
|
299
|
+
})
|
|
300
|
+
res.end(JSON.stringify(body))
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function writeMethodNotAllowed(res: ServerResponse, allow: string): void {
|
|
304
|
+
res.writeHead(405, {
|
|
305
|
+
allow,
|
|
306
|
+
'content-type': 'application/json; charset=utf-8',
|
|
307
|
+
})
|
|
308
|
+
res.end(JSON.stringify({ reason: 'method_not_allowed' }))
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Path inside the namespace ('' for the bare prefix), or null when the
|
|
313
|
+
* request is outside {@link API_PREFIX} or the URL is unparsable. The
|
|
314
|
+
* carrier already parsed the same string to match the route, so the null
|
|
315
|
+
* arms only matter when `handle` is exercised directly.
|
|
316
|
+
*/
|
|
317
|
+
function subpathOf(rawUrl: string | undefined): string | null {
|
|
318
|
+
let pathname: string
|
|
319
|
+
try {
|
|
320
|
+
pathname = new URL(rawUrl ?? '/', 'http://dsh.internal').pathname
|
|
321
|
+
} catch {
|
|
322
|
+
return null
|
|
323
|
+
}
|
|
324
|
+
if (pathname === API_PREFIX) return ''
|
|
325
|
+
if (pathname.startsWith(`${API_PREFIX}/`)) return pathname.slice(API_PREFIX.length + 1)
|
|
326
|
+
return null
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Query string of the request (empty params when the URL is unparsable). */
|
|
330
|
+
function queryOf(rawUrl: string | undefined): URLSearchParams {
|
|
331
|
+
try {
|
|
332
|
+
return new URL(rawUrl ?? '/', 'http://dsh.internal').searchParams
|
|
333
|
+
} catch {
|
|
334
|
+
return new URLSearchParams()
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Timeline pagination token: `<seq|'-'>~<epoch-ms>`. Deliberately not the
|
|
340
|
+
* raw JSON cursor object so the query-string round-trip stays trivial and
|
|
341
|
+
* the wire shape is decoupled from fusion's internal cursor type.
|
|
342
|
+
*/
|
|
343
|
+
function encodeCursor(cursor: TimelineCursor): string {
|
|
344
|
+
return `${cursor.seq === null ? '-' : cursor.seq}~${cursor.ts}`
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function decodeCursor(raw: string): TimelineCursor | null {
|
|
348
|
+
const sep = raw.indexOf('~')
|
|
349
|
+
if (sep <= 0 || sep === raw.length - 1) return null
|
|
350
|
+
const seqPart = raw.slice(0, sep)
|
|
351
|
+
const tsPart = raw.slice(sep + 1)
|
|
352
|
+
const ts = Number(tsPart)
|
|
353
|
+
if (!Number.isInteger(ts) || ts < 0) return null
|
|
354
|
+
if (seqPart === '-') return { seq: null, ts }
|
|
355
|
+
const seq = Number(seqPart)
|
|
356
|
+
if (!Number.isInteger(seq) || seq < 0) return null
|
|
357
|
+
return { seq, ts }
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Bound on caller-supplied page sizes (timeline entries / search hits). */
|
|
361
|
+
const MAX_PAGE_LIMIT = 500
|
|
362
|
+
|
|
363
|
+
/** Search result bound when the caller supplies no limit (fusion default). */
|
|
364
|
+
const DEFAULT_SEARCH_ROUTE_LIMIT = 50
|
|
365
|
+
|
|
366
|
+
/** Match fusion's project correlation key: strip trailing slashes (keep `/`). */
|
|
367
|
+
function normalizeProjectKey(project: string): string {
|
|
368
|
+
if (project.length > 1 && project.endsWith('/')) {
|
|
369
|
+
const stripped = project.replace(/\/+$/, '')
|
|
370
|
+
return stripped === '' ? '/' : stripped
|
|
371
|
+
}
|
|
372
|
+
return project
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Parse an optional positive-integer query param bounded by
|
|
377
|
+
* {@link MAX_PAGE_LIMIT}. `undefined` when absent, `null` when invalid.
|
|
378
|
+
*/
|
|
379
|
+
function parseLimit(params: URLSearchParams, name: string): number | undefined | null {
|
|
380
|
+
const raw = params.get(name)
|
|
381
|
+
if (raw === null || raw === '') return undefined
|
|
382
|
+
const value = Number(raw)
|
|
383
|
+
if (!Number.isInteger(value) || value < 1 || value > MAX_PAGE_LIMIT) return null
|
|
384
|
+
return value
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** JSON wire shape of one timeline page (adds the encoded `nextCursor`). */
|
|
388
|
+
function timelineBody(page: TimelinePage): Record<string, unknown> {
|
|
389
|
+
return {
|
|
390
|
+
sessionId: page.sessionId,
|
|
391
|
+
entries: page.entries,
|
|
392
|
+
cursor: page.cursor,
|
|
393
|
+
nextCursor: page.cursor === null ? null : encodeCursor(page.cursor),
|
|
394
|
+
sources: page.sources,
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/** `event: <name>` + single-line JSON data (JSON.stringify never emits raw newlines). */
|
|
399
|
+
function sseFrame(event: string, data: string): string {
|
|
400
|
+
return `event: ${event}\ndata: ${data}\n\n`
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** Outcome of the bounded body read for `POST action`. */
|
|
404
|
+
type BodyRead = { kind: 'ok'; text: string } | { kind: 'too_large' } | { kind: 'error' }
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Read the request body up to {@link MAX_ACTION_BODY_BYTES}. On overflow the
|
|
408
|
+
* promise settles immediately ('too_large') while the rest of the stream
|
|
409
|
+
* keeps draining, so the keep-alive connection is left in a clean state.
|
|
410
|
+
*/
|
|
411
|
+
function readActionBody(req: IncomingMessage): Promise<BodyRead> {
|
|
412
|
+
return new Promise((resolve) => {
|
|
413
|
+
const chunks: Buffer[] = []
|
|
414
|
+
let size = 0
|
|
415
|
+
let settled = false
|
|
416
|
+
const settle = (result: BodyRead): void => {
|
|
417
|
+
if (settled) return
|
|
418
|
+
settled = true
|
|
419
|
+
resolve(result)
|
|
420
|
+
}
|
|
421
|
+
req.on('data', (chunk: Buffer | string) => {
|
|
422
|
+
if (settled) return // overflow already answered; keep draining
|
|
423
|
+
const buf = typeof chunk === 'string' ? Buffer.from(chunk, 'utf8') : chunk
|
|
424
|
+
size += buf.length
|
|
425
|
+
if (size > MAX_ACTION_BODY_BYTES) {
|
|
426
|
+
settle({ kind: 'too_large' })
|
|
427
|
+
return
|
|
428
|
+
}
|
|
429
|
+
chunks.push(buf)
|
|
430
|
+
})
|
|
431
|
+
req.on('end', () => settle({ kind: 'ok', text: Buffer.concat(chunks).toString('utf8') }))
|
|
432
|
+
req.on('error', () => settle({ kind: 'error' }))
|
|
433
|
+
})
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Build the M1 route surface. All state lives in the returned closure;
|
|
438
|
+
* multiple instances never share anything.
|
|
439
|
+
*/
|
|
440
|
+
export function createRoutes(deps: RoutesDeps, opts: RoutesOptions = {}): Routes {
|
|
441
|
+
const maxSseClients = opts.maxSseClients ?? DEFAULT_MAX_SSE_CLIENTS
|
|
442
|
+
const sseHeartbeatMs = opts.sseHeartbeatMs ?? DEFAULT_SSE_HEARTBEAT_MS
|
|
443
|
+
const sseBufferLimit = opts.sseBufferLimit ?? DEFAULT_SSE_BUFFER_LIMIT
|
|
444
|
+
|
|
445
|
+
const clients = new Set<SseClient>()
|
|
446
|
+
let disposed = false
|
|
447
|
+
|
|
448
|
+
const buildSnapshot = (): StateSnapshot => ({
|
|
449
|
+
daemon: { state: deps.supervisor.state, lastPing: deps.supervisor.lastPing },
|
|
450
|
+
board: deps.store.getBoardState(),
|
|
451
|
+
capabilities: { inject: deps.guardOptions.allowWriteActions() },
|
|
452
|
+
})
|
|
453
|
+
|
|
454
|
+
// ------------------------------------------------------------------ SSE
|
|
455
|
+
|
|
456
|
+
const cleanupClient = (client: SseClient): void => {
|
|
457
|
+
if (client.closed) return
|
|
458
|
+
client.closed = true
|
|
459
|
+
if (client.heartbeat !== null) clearInterval(client.heartbeat)
|
|
460
|
+
client.heartbeat = null
|
|
461
|
+
client.pending.length = 0
|
|
462
|
+
clients.delete(client)
|
|
463
|
+
deps.log('info', 'sse client disconnected', { clients: clients.size })
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const dropClient = (client: SseClient, reason: string): void => {
|
|
467
|
+
deps.log('warn', 'sse client dropped', {
|
|
468
|
+
reason,
|
|
469
|
+
pending: client.pending.length,
|
|
470
|
+
limit: sseBufferLimit,
|
|
471
|
+
})
|
|
472
|
+
cleanupClient(client)
|
|
473
|
+
client.res.destroy()
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const push = (client: SseClient, frame: string): void => {
|
|
477
|
+
if (client.closed) return
|
|
478
|
+
if (client.blocked) {
|
|
479
|
+
client.pending.push(frame)
|
|
480
|
+
if (client.pending.length > sseBufferLimit) {
|
|
481
|
+
dropClient(client, 'buffer_overflow')
|
|
482
|
+
}
|
|
483
|
+
return
|
|
484
|
+
}
|
|
485
|
+
if (!client.res.write(frame)) client.blocked = true
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const flush = (client: SseClient): void => {
|
|
489
|
+
if (client.closed) return
|
|
490
|
+
client.blocked = false
|
|
491
|
+
while (!client.blocked) {
|
|
492
|
+
const frame = client.pending.shift()
|
|
493
|
+
if (frame === undefined) return
|
|
494
|
+
if (!client.res.write(frame)) client.blocked = true
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const acceptStream = (res: ServerResponse): void => {
|
|
499
|
+
if (clients.size >= maxSseClients) {
|
|
500
|
+
deps.log('warn', 'sse connection rejected: client limit reached', {
|
|
501
|
+
max: maxSseClients,
|
|
502
|
+
})
|
|
503
|
+
writeJson(res, 503, { reason: 'too_many_stream_clients' })
|
|
504
|
+
return
|
|
505
|
+
}
|
|
506
|
+
res.writeHead(200, {
|
|
507
|
+
'content-type': 'text/event-stream',
|
|
508
|
+
'cache-control': 'no-cache',
|
|
509
|
+
connection: 'keep-alive',
|
|
510
|
+
})
|
|
511
|
+
const client: SseClient = {
|
|
512
|
+
res,
|
|
513
|
+
pending: [],
|
|
514
|
+
blocked: false,
|
|
515
|
+
closed: false,
|
|
516
|
+
heartbeat: null,
|
|
517
|
+
}
|
|
518
|
+
clients.add(client)
|
|
519
|
+
// 'close' fires for both client aborts and our own end()/destroy().
|
|
520
|
+
res.on('close', () => cleanupClient(client))
|
|
521
|
+
res.on('drain', () => flush(client))
|
|
522
|
+
client.heartbeat = setInterval(() => push(client, HEARTBEAT_FRAME), sseHeartbeatMs)
|
|
523
|
+
deps.log('info', 'sse client connected', { clients: clients.size })
|
|
524
|
+
push(client, sseFrame('state', JSON.stringify(buildSnapshot())))
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** One change → one full snapshot frame to every client (M1 granularity). */
|
|
528
|
+
const onMutation = (): void => {
|
|
529
|
+
if (disposed || clients.size === 0) return
|
|
530
|
+
const frame = sseFrame('state', JSON.stringify(buildSnapshot()))
|
|
531
|
+
for (const client of [...clients]) push(client, frame)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
const unsubscribes: Array<() => void> = [
|
|
535
|
+
deps.store.onChange(onMutation),
|
|
536
|
+
deps.supervisor.onStateChange(onMutation),
|
|
537
|
+
]
|
|
538
|
+
|
|
539
|
+
// --------------------------------------------------------------- routes
|
|
540
|
+
|
|
541
|
+
/** Decoded session id, or null (already answered 404) on a bad escape. */
|
|
542
|
+
const decodeId = (res: ServerResponse, rawId: string): string | null => {
|
|
543
|
+
try {
|
|
544
|
+
const id = decodeURIComponent(rawId)
|
|
545
|
+
if (id !== '') return id
|
|
546
|
+
} catch {
|
|
547
|
+
// fall through to the 404
|
|
548
|
+
}
|
|
549
|
+
writeJson(res, 404, { reason: 'session_not_found' })
|
|
550
|
+
return null
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
const handleSession = async (res: ServerResponse, rawId: string): Promise<void> => {
|
|
554
|
+
const id = decodeId(res, rawId)
|
|
555
|
+
if (id === null) return
|
|
556
|
+
const view = deps.store.getBoardState().sessions.find((s) => s.session_id === id)
|
|
557
|
+
const fusion = deps.fusion
|
|
558
|
+
if (fusion === undefined) {
|
|
559
|
+
// M1 contract preserved: detail == card data, timeline placeholder.
|
|
560
|
+
if (view === undefined) {
|
|
561
|
+
writeJson(res, 404, { reason: 'session_not_found' })
|
|
562
|
+
return
|
|
563
|
+
}
|
|
564
|
+
writeJson(res, 200, {
|
|
565
|
+
session: view,
|
|
566
|
+
timeline: null,
|
|
567
|
+
timelineNote: 'timeline_not_available_until_m3',
|
|
568
|
+
})
|
|
569
|
+
return
|
|
570
|
+
}
|
|
571
|
+
// M3: the unified view also resolves dsh-live sessions the sidecar has
|
|
572
|
+
// not observed on disk yet, so those answer 200 instead of 404.
|
|
573
|
+
const unified = fusion.getUnifiedSessions().find((s) => s.sessionId === id) ?? null
|
|
574
|
+
if (view === undefined && unified === null) {
|
|
575
|
+
writeJson(res, 404, { reason: 'session_not_found' })
|
|
576
|
+
return
|
|
577
|
+
}
|
|
578
|
+
const page = await fusion.getSessionTimeline(id)
|
|
579
|
+
writeJson(res, 200, {
|
|
580
|
+
session: view ?? null,
|
|
581
|
+
unified,
|
|
582
|
+
timeline: timelineBody(page),
|
|
583
|
+
})
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const handleTimeline = async (
|
|
587
|
+
res: ServerResponse,
|
|
588
|
+
rawId: string,
|
|
589
|
+
params: URLSearchParams,
|
|
590
|
+
): Promise<void> => {
|
|
591
|
+
const fusion = deps.fusion
|
|
592
|
+
if (fusion === undefined) {
|
|
593
|
+
writeJson(res, 501, { reason: 'fusion_not_wired' })
|
|
594
|
+
return
|
|
595
|
+
}
|
|
596
|
+
const id = decodeId(res, rawId)
|
|
597
|
+
if (id === null) return
|
|
598
|
+
const rawCursor = params.get('cursor')
|
|
599
|
+
let before: TimelineCursor | null = null
|
|
600
|
+
if (rawCursor !== null && rawCursor !== '') {
|
|
601
|
+
before = decodeCursor(rawCursor)
|
|
602
|
+
if (before === null) {
|
|
603
|
+
writeJson(res, 400, { reason: 'invalid_cursor' })
|
|
604
|
+
return
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
const limit = parseLimit(params, 'limit')
|
|
608
|
+
if (limit === null) {
|
|
609
|
+
writeJson(res, 400, { reason: 'invalid_limit' })
|
|
610
|
+
return
|
|
611
|
+
}
|
|
612
|
+
const page = await fusion.getSessionTimeline(id, { before, limit })
|
|
613
|
+
// Unknown id: no source contributed, nothing buffered, and the board
|
|
614
|
+
// does not list it either → an honest 404 instead of an empty page.
|
|
615
|
+
const anySource =
|
|
616
|
+
page.sources.dshLive ||
|
|
617
|
+
page.sources.dshCold ||
|
|
618
|
+
page.sources.sidecarReplay ||
|
|
619
|
+
page.sources.sidecarBuffer
|
|
620
|
+
if (!anySource && page.entries.length === 0) {
|
|
621
|
+
const known =
|
|
622
|
+
deps.store.getBoardState().sessions.some((s) => s.session_id === id) ||
|
|
623
|
+
fusion.getUnifiedSessions().some((s) => s.sessionId === id)
|
|
624
|
+
if (!known) {
|
|
625
|
+
writeJson(res, 404, { reason: 'session_not_found' })
|
|
626
|
+
return
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
writeJson(res, 200, timelineBody(page))
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
const handleLineage = async (res: ServerResponse, rawId: string): Promise<void> => {
|
|
633
|
+
const fusion = deps.fusion
|
|
634
|
+
if (fusion === undefined) {
|
|
635
|
+
writeJson(res, 501, { reason: 'fusion_not_wired' })
|
|
636
|
+
return
|
|
637
|
+
}
|
|
638
|
+
const id = decodeId(res, rawId)
|
|
639
|
+
if (id === null) return
|
|
640
|
+
// Degradation (sessionQuery absent / trace failed) is DATA, not an
|
|
641
|
+
// error: always 200 with {available, trace, reason} (design §4.e.4).
|
|
642
|
+
writeJson(res, 200, await fusion.getLineage(id))
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const handleSearch = async (res: ServerResponse, params: URLSearchParams): Promise<void> => {
|
|
646
|
+
const fusion = deps.fusion
|
|
647
|
+
if (fusion === undefined) {
|
|
648
|
+
writeJson(res, 501, { reason: 'fusion_not_wired' })
|
|
649
|
+
return
|
|
650
|
+
}
|
|
651
|
+
const query = (params.get('q') ?? '').trim()
|
|
652
|
+
const project = (params.get('project') ?? '').trim()
|
|
653
|
+
if (query === '' && project === '') {
|
|
654
|
+
writeJson(res, 400, {
|
|
655
|
+
reason: 'invalid_request',
|
|
656
|
+
detail: 'search needs q= (text query) and/or project= (project filter)',
|
|
657
|
+
})
|
|
658
|
+
return
|
|
659
|
+
}
|
|
660
|
+
const limit = parseLimit(params, 'limit')
|
|
661
|
+
if (limit === null) {
|
|
662
|
+
writeJson(res, 400, { reason: 'invalid_limit' })
|
|
663
|
+
return
|
|
664
|
+
}
|
|
665
|
+
let mode: 'full-text' | 'filter-only'
|
|
666
|
+
let items: Array<{ session: { project: string }; matchedBy: string; snippet: string | null }>
|
|
667
|
+
if (query !== '') {
|
|
668
|
+
const result = await fusion.searchSessions(query, limit === undefined ? {} : { limit })
|
|
669
|
+
mode = result.mode
|
|
670
|
+
items = result.items
|
|
671
|
+
} else {
|
|
672
|
+
// Project-only search: a plain filter over the unified view.
|
|
673
|
+
mode = 'filter-only'
|
|
674
|
+
items = fusion
|
|
675
|
+
.getUnifiedSessions()
|
|
676
|
+
.map((session) => ({ session, matchedBy: 'project', snippet: null }))
|
|
677
|
+
}
|
|
678
|
+
if (project !== '') {
|
|
679
|
+
// Exact-path filter after trailing-slash normalization (`project` is
|
|
680
|
+
// the group key handed out by GET projects).
|
|
681
|
+
const wanted = normalizeProjectKey(project)
|
|
682
|
+
items = items.filter((item) => normalizeProjectKey(item.session.project) === wanted)
|
|
683
|
+
}
|
|
684
|
+
items = items.slice(0, limit ?? DEFAULT_SEARCH_ROUTE_LIMIT)
|
|
685
|
+
writeJson(res, 200, {
|
|
686
|
+
mode,
|
|
687
|
+
query,
|
|
688
|
+
project: project === '' ? null : project,
|
|
689
|
+
items,
|
|
690
|
+
})
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const handleProjects = (res: ServerResponse): void => {
|
|
694
|
+
const fusion = deps.fusion
|
|
695
|
+
if (fusion === undefined) {
|
|
696
|
+
writeJson(res, 501, { reason: 'fusion_not_wired' })
|
|
697
|
+
return
|
|
698
|
+
}
|
|
699
|
+
writeJson(res, 200, { groups: fusion.getProjectGroups() })
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// -------------------------------------------------------------- actions
|
|
703
|
+
|
|
704
|
+
/**
|
|
705
|
+
* Route-log discipline (S8): only the action type, status and vocabulary
|
|
706
|
+
* codes — never the message body, preview, or gateway detail text.
|
|
707
|
+
*/
|
|
708
|
+
const logAction = (type: string, status: number, meta: object = {}): void => {
|
|
709
|
+
deps.log('info', 'action handled', { type, status, ...meta })
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const handlePrepare = async (
|
|
713
|
+
gateway: InjectGatewayApi,
|
|
714
|
+
envelope: Record<string, unknown>,
|
|
715
|
+
res: ServerResponse,
|
|
716
|
+
): Promise<void> => {
|
|
717
|
+
const rawTarget = envelope.target
|
|
718
|
+
const targetObj =
|
|
719
|
+
typeof rawTarget === 'object' && rawTarget !== null
|
|
720
|
+
? (rawTarget as Record<string, unknown>)
|
|
721
|
+
: undefined
|
|
722
|
+
const agent = targetObj?.agent
|
|
723
|
+
const sessionId = targetObj?.sessionId
|
|
724
|
+
const mode = envelope.mode
|
|
725
|
+
const message = envelope.message
|
|
726
|
+
if (
|
|
727
|
+
typeof agent !== 'string' ||
|
|
728
|
+
typeof sessionId !== 'string' ||
|
|
729
|
+
(mode !== 'queue' && mode !== 'steer') ||
|
|
730
|
+
typeof message !== 'string'
|
|
731
|
+
) {
|
|
732
|
+
logAction('inject.prepare', 400, { reason: 'invalid_request' })
|
|
733
|
+
writeJson(res, 400, {
|
|
734
|
+
reason: 'invalid_request',
|
|
735
|
+
detail: 'inject.prepare needs target{agent,sessionId}, mode queue|steer, and a string message',
|
|
736
|
+
})
|
|
737
|
+
return
|
|
738
|
+
}
|
|
739
|
+
const result = await gateway.prepare({ target: { agent, sessionId }, mode, message })
|
|
740
|
+
if (result.ok) {
|
|
741
|
+
logAction('inject.prepare', 200, { requestId: result.requestId })
|
|
742
|
+
writeJson(res, 200, {
|
|
743
|
+
requestId: result.requestId,
|
|
744
|
+
confirmToken: result.confirmToken,
|
|
745
|
+
plan: result.plan,
|
|
746
|
+
expiresAt: result.expiresAt,
|
|
747
|
+
})
|
|
748
|
+
return
|
|
749
|
+
}
|
|
750
|
+
const status = PREPARE_ERROR_STATUS[result.errorCode] ?? 400
|
|
751
|
+
logAction('inject.prepare', status, { errorCode: result.errorCode })
|
|
752
|
+
writeJson(res, status, {
|
|
753
|
+
reason: result.errorCode,
|
|
754
|
+
...(result.detail !== undefined ? { detail: result.detail } : {}),
|
|
755
|
+
})
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const handleExecute = async (
|
|
759
|
+
gateway: InjectGatewayApi,
|
|
760
|
+
envelope: Record<string, unknown>,
|
|
761
|
+
res: ServerResponse,
|
|
762
|
+
): Promise<void> => {
|
|
763
|
+
const { requestId, confirmToken, message } = envelope
|
|
764
|
+
if (
|
|
765
|
+
typeof requestId !== 'string' ||
|
|
766
|
+
typeof confirmToken !== 'string' ||
|
|
767
|
+
typeof message !== 'string'
|
|
768
|
+
) {
|
|
769
|
+
logAction('inject.execute', 400, { reason: 'invalid_request' })
|
|
770
|
+
writeJson(res, 400, {
|
|
771
|
+
reason: 'invalid_request',
|
|
772
|
+
detail: 'inject.execute needs string requestId, confirmToken and message',
|
|
773
|
+
})
|
|
774
|
+
return
|
|
775
|
+
}
|
|
776
|
+
// Exactly one gateway dispatch per HTTP request. `outcome: 'unknown'` is
|
|
777
|
+
// answered 200 as a terminal "do not retry" — never re-fired here (S6).
|
|
778
|
+
const result = await gateway.execute({ requestId, confirmToken, message })
|
|
779
|
+
const status =
|
|
780
|
+
result.outcome === 'failed'
|
|
781
|
+
? (EXECUTE_ERROR_STATUS[result.errorCode ?? ''] ?? 502)
|
|
782
|
+
: 200
|
|
783
|
+
logAction('inject.execute', status, {
|
|
784
|
+
outcome: result.outcome,
|
|
785
|
+
...(result.errorCode !== undefined ? { errorCode: result.errorCode } : {}),
|
|
786
|
+
...(result.replayed !== undefined ? { replayed: result.replayed } : {}),
|
|
787
|
+
})
|
|
788
|
+
writeJson(res, status, result)
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// ------------------------------------------------------ analysis actions
|
|
792
|
+
|
|
793
|
+
/**
|
|
794
|
+
* Answer one engine result: status per {@link ANALYSIS_ERROR_STATUS},
|
|
795
|
+
* body is the result verbatim (it already carries outcome /
|
|
796
|
+
* analysisSessionId / summary / truncated / disclaimer). The log line
|
|
797
|
+
* keeps only outcome/codes/ids — never summaries or questions (S8).
|
|
798
|
+
*/
|
|
799
|
+
const respondAnalysisResult = (
|
|
800
|
+
type: string,
|
|
801
|
+
res: ServerResponse,
|
|
802
|
+
result: AnalysisResult,
|
|
803
|
+
): void => {
|
|
804
|
+
const status =
|
|
805
|
+
result.errorCode !== undefined
|
|
806
|
+
? (ANALYSIS_ERROR_STATUS[result.errorCode] ?? 502)
|
|
807
|
+
: 200
|
|
808
|
+
logAction(type, status, {
|
|
809
|
+
outcome: result.outcome,
|
|
810
|
+
...(result.errorCode !== undefined ? { errorCode: result.errorCode } : {}),
|
|
811
|
+
...(result.analysisSessionId !== undefined
|
|
812
|
+
? { analysisSessionId: result.analysisSessionId }
|
|
813
|
+
: {}),
|
|
814
|
+
...(result.truncated ? { truncated: true } : {}),
|
|
815
|
+
})
|
|
816
|
+
writeJson(res, status, result)
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
const rejectInvalidAnalysis = (type: string, res: ServerResponse, detail: string): void => {
|
|
820
|
+
logAction(type, 400, { reason: 'invalid_request' })
|
|
821
|
+
writeJson(res, 400, { reason: 'invalid_request', detail })
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
const handleAnalysisRequest = async (
|
|
825
|
+
analysis: AnalysisApi,
|
|
826
|
+
envelope: Record<string, unknown>,
|
|
827
|
+
res: ServerResponse,
|
|
828
|
+
): Promise<void> => {
|
|
829
|
+
const { targetKind, targetId, question } = envelope
|
|
830
|
+
if (
|
|
831
|
+
(targetKind !== 'session' && targetKind !== 'project' && targetKind !== 'cross-agent') ||
|
|
832
|
+
(targetId !== undefined && typeof targetId !== 'string') ||
|
|
833
|
+
(question !== undefined && typeof question !== 'string')
|
|
834
|
+
) {
|
|
835
|
+
rejectInvalidAnalysis(
|
|
836
|
+
'analysis.request',
|
|
837
|
+
res,
|
|
838
|
+
'analysis.request needs targetKind session|project|cross-agent, optional string targetId and question',
|
|
839
|
+
)
|
|
840
|
+
return
|
|
841
|
+
}
|
|
842
|
+
if ((targetKind === 'session' || targetKind === 'project') && (targetId === undefined || targetId === '')) {
|
|
843
|
+
rejectInvalidAnalysis(
|
|
844
|
+
'analysis.request',
|
|
845
|
+
res,
|
|
846
|
+
`analysis.request with targetKind ${targetKind} needs a non-empty targetId`,
|
|
847
|
+
)
|
|
848
|
+
return
|
|
849
|
+
}
|
|
850
|
+
const input = await analysis.buildInput({
|
|
851
|
+
targetKind,
|
|
852
|
+
...(targetId !== undefined ? { targetId } : {}),
|
|
853
|
+
...(question !== undefined ? { question } : {}),
|
|
854
|
+
})
|
|
855
|
+
if (input === null) {
|
|
856
|
+
logAction('analysis.request', 404, { reason: 'target_not_found', targetKind })
|
|
857
|
+
writeJson(res, 404, { reason: 'target_not_found' })
|
|
858
|
+
return
|
|
859
|
+
}
|
|
860
|
+
respondAnalysisResult('analysis.request', res, await analysis.engine.request(input))
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
const handleAnalysisFollowup = async (
|
|
864
|
+
analysis: AnalysisApi,
|
|
865
|
+
envelope: Record<string, unknown>,
|
|
866
|
+
res: ServerResponse,
|
|
867
|
+
): Promise<void> => {
|
|
868
|
+
const { analysisSessionId, question } = envelope
|
|
869
|
+
if (
|
|
870
|
+
typeof analysisSessionId !== 'string' ||
|
|
871
|
+
analysisSessionId === '' ||
|
|
872
|
+
typeof question !== 'string' ||
|
|
873
|
+
question === ''
|
|
874
|
+
) {
|
|
875
|
+
rejectInvalidAnalysis(
|
|
876
|
+
'analysis.followup',
|
|
877
|
+
res,
|
|
878
|
+
'analysis.followup needs non-empty string analysisSessionId and question',
|
|
879
|
+
)
|
|
880
|
+
return
|
|
881
|
+
}
|
|
882
|
+
respondAnalysisResult(
|
|
883
|
+
'analysis.followup',
|
|
884
|
+
res,
|
|
885
|
+
await analysis.engine.followup(analysisSessionId, question),
|
|
886
|
+
)
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const handleAnalysisCancel = async (
|
|
890
|
+
analysis: AnalysisApi,
|
|
891
|
+
envelope: Record<string, unknown>,
|
|
892
|
+
res: ServerResponse,
|
|
893
|
+
): Promise<void> => {
|
|
894
|
+
const { analysisSessionId } = envelope
|
|
895
|
+
if (typeof analysisSessionId !== 'string' || analysisSessionId === '') {
|
|
896
|
+
rejectInvalidAnalysis(
|
|
897
|
+
'analysis.cancel',
|
|
898
|
+
res,
|
|
899
|
+
'analysis.cancel needs a non-empty string analysisSessionId',
|
|
900
|
+
)
|
|
901
|
+
return
|
|
902
|
+
}
|
|
903
|
+
// Idempotent by engine contract: an unknown id is a logged no-op.
|
|
904
|
+
await analysis.engine.cancel(analysisSessionId)
|
|
905
|
+
logAction('analysis.cancel', 200, { analysisSessionId })
|
|
906
|
+
writeJson(res, 200, { ok: true, analysisSessionId })
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/** M2 dispatcher over the action envelope (gateway present, guard 1-4 passed). */
|
|
910
|
+
const handleAction = async (
|
|
911
|
+
gateway: InjectGatewayApi,
|
|
912
|
+
verdict: GuardVerdict,
|
|
913
|
+
req: IncomingMessage,
|
|
914
|
+
res: ServerResponse,
|
|
915
|
+
): Promise<void> => {
|
|
916
|
+
const body = await readActionBody(req)
|
|
917
|
+
if (body.kind === 'too_large') {
|
|
918
|
+
deps.log('warn', 'action rejected', { reason: 'body_too_large', limit: MAX_ACTION_BODY_BYTES })
|
|
919
|
+
writeJson(res, 400, { reason: 'body_too_large' })
|
|
920
|
+
return
|
|
921
|
+
}
|
|
922
|
+
if (body.kind === 'error') {
|
|
923
|
+
deps.log('warn', 'action rejected', { reason: 'body_read_error' })
|
|
924
|
+
writeJson(res, 400, { reason: 'body_read_error' })
|
|
925
|
+
return
|
|
926
|
+
}
|
|
927
|
+
let parsed: unknown
|
|
928
|
+
try {
|
|
929
|
+
parsed = JSON.parse(body.text)
|
|
930
|
+
} catch {
|
|
931
|
+
deps.log('warn', 'action rejected', { reason: 'invalid_json' })
|
|
932
|
+
writeJson(res, 400, { reason: 'invalid_json' })
|
|
933
|
+
return
|
|
934
|
+
}
|
|
935
|
+
const envelope =
|
|
936
|
+
typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
|
|
937
|
+
? (parsed as Record<string, unknown>)
|
|
938
|
+
: null
|
|
939
|
+
|
|
940
|
+
if (envelope !== null) {
|
|
941
|
+
const type = typeof envelope.type === 'string' ? envelope.type : null
|
|
942
|
+
|
|
943
|
+
if (type === 'daemon.retry') {
|
|
944
|
+
// Daemon management is a capability of its own: `inject.enabled`
|
|
945
|
+
// gates injection only (design §6), so retry passes guard layers
|
|
946
|
+
// 1-4 without the write-action gate. retry() itself is a no-op
|
|
947
|
+
// outside FAILED.
|
|
948
|
+
deps.supervisor.retry()
|
|
949
|
+
const state = deps.supervisor.state
|
|
950
|
+
logAction('daemon.retry', 200, { state })
|
|
951
|
+
writeJson(res, 200, { state })
|
|
952
|
+
return
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
if (type === 'inject.prepare' || type === 'inject.execute') {
|
|
956
|
+
const writeVerdict = guardWriteAction(verdict, deps.guardOptions)
|
|
957
|
+
if (!writeVerdict.ok) {
|
|
958
|
+
logAction(type, writeVerdict.status, { reason: writeVerdict.reason })
|
|
959
|
+
writeJson(res, writeVerdict.status, { reason: writeVerdict.reason })
|
|
960
|
+
return
|
|
961
|
+
}
|
|
962
|
+
if (type === 'inject.prepare') {
|
|
963
|
+
await handlePrepare(gateway, envelope, res)
|
|
964
|
+
} else {
|
|
965
|
+
await handleExecute(gateway, envelope, res)
|
|
966
|
+
}
|
|
967
|
+
return
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
if (type !== null && ANALYSIS_ACTION_TYPES.has(type)) {
|
|
971
|
+
// The analysis write gate (guard layers 1-4 already passed via
|
|
972
|
+
// `verdict`): request/followup are gated by analysis.enabled —
|
|
973
|
+
// NOT the guard's inject.enabled gate — and fail-closed when the
|
|
974
|
+
// dep is absent. analysis.cancel deliberately BYPASSES the gate
|
|
975
|
+
// (F3): it is a cleanup/stop-loss action that spends no tokens,
|
|
976
|
+
// and flipping the kill switch off must not lock in-flight
|
|
977
|
+
// sessions out of cancellation until their timeout.
|
|
978
|
+
if (
|
|
979
|
+
type !== 'analysis.cancel' &&
|
|
980
|
+
(deps.analysisEnabled === undefined || !deps.analysisEnabled())
|
|
981
|
+
) {
|
|
982
|
+
logAction(type, 403, { reason: 'analysis_disabled' })
|
|
983
|
+
writeJson(res, 403, { reason: 'analysis_disabled' })
|
|
984
|
+
return
|
|
985
|
+
}
|
|
986
|
+
// Past the gate: no engine wired, or the agents service is not
|
|
987
|
+
// bound in this composition → honest degradation, never a crash.
|
|
988
|
+
const analysis = deps.analysis
|
|
989
|
+
if (analysis === undefined || !analysis.available()) {
|
|
990
|
+
logAction(type, 501, { reason: 'analysis_unavailable' })
|
|
991
|
+
writeJson(res, 501, { reason: 'analysis_unavailable' })
|
|
992
|
+
return
|
|
993
|
+
}
|
|
994
|
+
// Model pre-check (A-1), request only: no explicit analysis
|
|
995
|
+
// provider/model and no host default → 403 before any session is
|
|
996
|
+
// created. Same status family as analysis_disabled: the action is
|
|
997
|
+
// well-formed but the deployment configuration forbids running it.
|
|
998
|
+
if (
|
|
999
|
+
type === 'analysis.request' &&
|
|
1000
|
+
analysis.modelConfigured !== undefined &&
|
|
1001
|
+
!analysis.modelConfigured()
|
|
1002
|
+
) {
|
|
1003
|
+
logAction(type, 403, { reason: 'analysis_model_unconfigured' })
|
|
1004
|
+
writeJson(res, 403, { reason: 'analysis_model_unconfigured' })
|
|
1005
|
+
return
|
|
1006
|
+
}
|
|
1007
|
+
if (type === 'analysis.request') {
|
|
1008
|
+
await handleAnalysisRequest(analysis, envelope, res)
|
|
1009
|
+
} else if (type === 'analysis.followup') {
|
|
1010
|
+
await handleAnalysisFollowup(analysis, envelope, res)
|
|
1011
|
+
} else {
|
|
1012
|
+
await handleAnalysisCancel(analysis, envelope, res)
|
|
1013
|
+
}
|
|
1014
|
+
return
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
deps.log('warn', 'action rejected', { reason: 'unknown_action' })
|
|
1019
|
+
writeJson(res, 400, { reason: 'unknown_action' })
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
const handle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
|
|
1023
|
+
if (disposed) {
|
|
1024
|
+
writeJson(res, 503, { reason: 'shutting_down' })
|
|
1025
|
+
return
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
const verdict = guardRequest(req, deps.guardOptions)
|
|
1029
|
+
const method = (req.method ?? '').toUpperCase()
|
|
1030
|
+
const subpath = subpathOf(req.url)
|
|
1031
|
+
// The gateway-backed action handler reads its own bounded body; every
|
|
1032
|
+
// other body-bearing request is drained so the keep-alive connection is
|
|
1033
|
+
// left in a clean state either way.
|
|
1034
|
+
const actionReadsBody =
|
|
1035
|
+
verdict.ok &&
|
|
1036
|
+
subpath === 'action' &&
|
|
1037
|
+
method === 'POST' &&
|
|
1038
|
+
deps.injectGateway !== undefined
|
|
1039
|
+
if (
|
|
1040
|
+
!actionReadsBody &&
|
|
1041
|
+
(method === 'POST' || method === 'PUT' || method === 'PATCH')
|
|
1042
|
+
) {
|
|
1043
|
+
req.resume()
|
|
1044
|
+
}
|
|
1045
|
+
if (!verdict.ok) {
|
|
1046
|
+
writeJson(res, verdict.status, { reason: verdict.reason })
|
|
1047
|
+
return
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
if (subpath === null || subpath === '') {
|
|
1051
|
+
writeJson(res, 404, { reason: 'not_found' })
|
|
1052
|
+
return
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
if (subpath === 'state') {
|
|
1056
|
+
if (method !== 'GET') return writeMethodNotAllowed(res, 'GET')
|
|
1057
|
+
writeJson(res, 200, buildSnapshot())
|
|
1058
|
+
return
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
if (subpath === 'stream') {
|
|
1062
|
+
if (method !== 'GET') return writeMethodNotAllowed(res, 'GET')
|
|
1063
|
+
acceptStream(res)
|
|
1064
|
+
return
|
|
1065
|
+
}
|
|
1066
|
+
|
|
1067
|
+
if (subpath === 'action') {
|
|
1068
|
+
if (method !== 'POST') return writeMethodNotAllowed(res, 'POST')
|
|
1069
|
+
const gateway = deps.injectGateway
|
|
1070
|
+
if (gateway === undefined) {
|
|
1071
|
+
// M1 wiring (no gateway assembled): keep the placeholder contract —
|
|
1072
|
+
// the write gate is live (403 when inject is off), then 501.
|
|
1073
|
+
const writeVerdict = guardWriteAction(verdict, deps.guardOptions)
|
|
1074
|
+
if (!writeVerdict.ok) {
|
|
1075
|
+
writeJson(res, writeVerdict.status, { reason: writeVerdict.reason })
|
|
1076
|
+
return
|
|
1077
|
+
}
|
|
1078
|
+
writeJson(res, 501, { reason: 'not_implemented_until_m2' })
|
|
1079
|
+
return
|
|
1080
|
+
}
|
|
1081
|
+
await handleAction(gateway, verdict, req, res)
|
|
1082
|
+
return
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
if (subpath === 'projects') {
|
|
1086
|
+
if (method !== 'GET') return writeMethodNotAllowed(res, 'GET')
|
|
1087
|
+
handleProjects(res)
|
|
1088
|
+
return
|
|
1089
|
+
}
|
|
1090
|
+
|
|
1091
|
+
if (subpath === 'search') {
|
|
1092
|
+
if (method !== 'GET') return writeMethodNotAllowed(res, 'GET')
|
|
1093
|
+
await handleSearch(res, queryOf(req.url))
|
|
1094
|
+
return
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
if (subpath.startsWith('lineage/')) {
|
|
1098
|
+
if (method !== 'GET') return writeMethodNotAllowed(res, 'GET')
|
|
1099
|
+
await handleLineage(res, subpath.slice('lineage/'.length))
|
|
1100
|
+
return
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
if (subpath.startsWith('session/')) {
|
|
1104
|
+
if (method !== 'GET') return writeMethodNotAllowed(res, 'GET')
|
|
1105
|
+
const rest = subpath.slice('session/'.length)
|
|
1106
|
+
// `<id>/timeline` splits on the LAST path segment; percent-encoded
|
|
1107
|
+
// ids can never contain a literal '/' so the split is unambiguous.
|
|
1108
|
+
if (rest.endsWith('/timeline')) {
|
|
1109
|
+
await handleTimeline(res, rest.slice(0, -'/timeline'.length), queryOf(req.url))
|
|
1110
|
+
return
|
|
1111
|
+
}
|
|
1112
|
+
await handleSession(res, rest)
|
|
1113
|
+
return
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
writeJson(res, 404, { reason: 'not_found' })
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
const dispose = (): void => {
|
|
1120
|
+
if (disposed) return
|
|
1121
|
+
disposed = true
|
|
1122
|
+
for (const unsubscribe of unsubscribes) unsubscribe()
|
|
1123
|
+
for (const client of [...clients]) {
|
|
1124
|
+
cleanupClient(client)
|
|
1125
|
+
// Graceful end: the terminal chunk lets EventSource/fetch readers see
|
|
1126
|
+
// a clean stream end. Socket lifecycle belongs to the webServer.
|
|
1127
|
+
client.res.end()
|
|
1128
|
+
}
|
|
1129
|
+
deps.log('info', 'routes disposed')
|
|
1130
|
+
}
|
|
1131
|
+
|
|
1132
|
+
return { handle, dispose }
|
|
1133
|
+
}
|