@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/fusion.ts
ADDED
|
@@ -0,0 +1,988 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FusionQuery — bypass-analysis data-fusion layer of the host half
|
|
3
|
+
* (design §4.e / M3, T5.1): merges the in-process dsh session-event feed
|
|
4
|
+
* with sidecar socket data into one cross-agent query surface.
|
|
5
|
+
*
|
|
6
|
+
* API facts verified against the installed SDK (authoritative over the
|
|
7
|
+
* design sketch; both design assumptions CONFIRMED):
|
|
8
|
+
*
|
|
9
|
+
* - In-process dsh event feed: `@deepseek-ai/dsh-session` augments the
|
|
10
|
+
* cordis `Events` map with `'session/event'(session, event)`
|
|
11
|
+
* (lib/types/index.d.ts:66 — post-commit, fire-and-forget append feed),
|
|
12
|
+
* plus `'session/created'` (:44) and `'session/disposed'` (:54). A
|
|
13
|
+
* root-scoped listener receives all sessions (scope filtering only
|
|
14
|
+
* narrows agent-scoped listeners). `Session` exposes `id` (:122),
|
|
15
|
+
* `header` (:120 — `cwd?`/`parentSession?`/`createdAt`,
|
|
16
|
+
* types.d.ts:40-78) and the on-demand immutable log snapshot `events`
|
|
17
|
+
* (:174). `SessionEvent` is `{type, seq, time(epoch ms), data}`
|
|
18
|
+
* (types.d.ts:425-457). Titles are NOT header fields: they are
|
|
19
|
+
* log-only `'session/title'` events with `{title: string}` data
|
|
20
|
+
* (`@deepseek-ai/dsh-session-title` lib/types/index.d.ts:37-45,73),
|
|
21
|
+
* folded latest-wins here.
|
|
22
|
+
* - Deep-query service: `@deepseek-ai/dsh-session-query` augments
|
|
23
|
+
* `Context` with `sessionQuery: SessionQueryEngine`
|
|
24
|
+
* (lib/types/index.d.ts:19-23). Methods used: `traceSession` (:123 →
|
|
25
|
+
* `SessionLineageTrace`, types.d.ts:59-76), `readSession` (:62 → full
|
|
26
|
+
* raw log) and `searchSessions` (:42 → `SessionSearchPage
|
|
27
|
+
* <SessionSearchHit>`, types.d.ts:224-235/:255-258). Headless or
|
|
28
|
+
* trimmed compositions may not mount it, so it is resolved lazily on
|
|
29
|
+
* every use and every capability degrades instead of throwing.
|
|
30
|
+
* - Sidecar side: session rows come from the SessionStore board state
|
|
31
|
+
* (`plugin/src/session-store.ts`); `updated_at` is epoch SECONDS
|
|
32
|
+
* (sidecar/model.py:50) while dsh `time` is epoch ms, normalized here
|
|
33
|
+
* to ms. The dsh adapter reuses the raw dsh session id as
|
|
34
|
+
* `session_id` (sidecar/adapters/dsh.py:311-372), which is what makes
|
|
35
|
+
* `session_id` a valid dedup key, and conditionally mirrors the native
|
|
36
|
+
* seq into `extra.seq` (dsh.py:263-269). One dsh record may normalize
|
|
37
|
+
* into several events sharing that seq (content blocks), so timeline
|
|
38
|
+
* dedup identity is seq+kind+text, never seq alone (F1).
|
|
39
|
+
* `SessionView` today drops `extra` and
|
|
40
|
+
* `parent_id`; the store face marks them optional so the extra
|
|
41
|
+
* supplement activates when the wiring supplies fuller rows.
|
|
42
|
+
*
|
|
43
|
+
* Fusion rules (design §4.e):
|
|
44
|
+
* - Same `session_id` dsh session: the in-process feed is the
|
|
45
|
+
* authoritative primary source (real-time); the sidecar row only
|
|
46
|
+
* supplements fields the feed lacks (status estimate, `extra`
|
|
47
|
+
* stats/plan, normalized last-event summary) and serves as the cold
|
|
48
|
+
* fallback when the session is not live in this dsh process. Non-dsh
|
|
49
|
+
* agents only ever have the sidecar source.
|
|
50
|
+
* - Cross-agent correlation key: normalized project path + time window
|
|
51
|
+
* (`getProjectGroups`).
|
|
52
|
+
* - No bulk event retention: dsh timelines are read on demand from the
|
|
53
|
+
* live session's own log snapshot (or `readSession` when cold);
|
|
54
|
+
* sidecar events are kept only in a small bounded per-session ring
|
|
55
|
+
* fed by the wiring, and {@link SidecarReplayFace} is the seam for the
|
|
56
|
+
* daemon `replay` op (T5.2 provides, T5.3 consumes).
|
|
57
|
+
*
|
|
58
|
+
* Pure DI: no cordis/dsh imports. All faces are minimal structural
|
|
59
|
+
* shapes extracted from the d.ts; method-syntax members keep parameter
|
|
60
|
+
* checks bivariant, so the SDK's branded `SessionId` and wider request
|
|
61
|
+
* types remain assignable (same pattern as dsh-inject.ts).
|
|
62
|
+
*
|
|
63
|
+
* @module
|
|
64
|
+
*/
|
|
65
|
+
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// In-process dsh faces (see header for d.ts provenance).
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
|
|
70
|
+
/** `SessionHeader` subset (dsh-session types.d.ts:40-78). */
|
|
71
|
+
export interface DshSessionHeaderFace {
|
|
72
|
+
readonly createdAt: number
|
|
73
|
+
readonly cwd?: string
|
|
74
|
+
readonly parentSession?: string
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `SessionEvent` subset (dsh-session types.d.ts:425-457). */
|
|
78
|
+
export interface DshSessionEventFace {
|
|
79
|
+
readonly type: string
|
|
80
|
+
/** Monotonic sequence number within the session. */
|
|
81
|
+
readonly seq: number
|
|
82
|
+
/** Unix epoch milliseconds. */
|
|
83
|
+
readonly time: number
|
|
84
|
+
readonly data: unknown
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** `Session` subset: identity, header, on-demand log snapshot (index.d.ts:106-174). */
|
|
88
|
+
export interface DshSessionFace {
|
|
89
|
+
readonly id: string
|
|
90
|
+
readonly header: DshSessionHeaderFace
|
|
91
|
+
readonly events: readonly DshSessionEventFace[]
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* In-process dsh session-event subscription face — the `ctx.on` shape
|
|
96
|
+
* for the three `@deepseek-ai/dsh-session` events this layer consumes
|
|
97
|
+
* (index.d.ts:44/:54/:66). Each call returns the disposer.
|
|
98
|
+
*/
|
|
99
|
+
export interface DshEventFace {
|
|
100
|
+
on(
|
|
101
|
+
event: 'session/event',
|
|
102
|
+
handler: (session: DshSessionFace, ev: DshSessionEventFace) => void,
|
|
103
|
+
): () => void
|
|
104
|
+
on(event: 'session/created', handler: (session: DshSessionFace) => void): () => void
|
|
105
|
+
on(event: 'session/disposed', handler: (session: DshSessionFace) => void): () => void
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// sessionQuery faces (dsh-session-query d.ts; lazily resolved, optional).
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
111
|
+
|
|
112
|
+
/** `SessionRecord` subset (session-query types.d.ts:14-21). */
|
|
113
|
+
export interface DshLineageRecordFace {
|
|
114
|
+
readonly header: {
|
|
115
|
+
readonly id: string
|
|
116
|
+
readonly createdAt: number
|
|
117
|
+
readonly cwd?: string
|
|
118
|
+
readonly parentSession?: string
|
|
119
|
+
}
|
|
120
|
+
readonly live: boolean
|
|
121
|
+
readonly persisted: boolean
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** `SessionLineageNode` subset (types.d.ts:52-57). */
|
|
125
|
+
export interface DshLineageNodeFace {
|
|
126
|
+
readonly session: DshLineageRecordFace
|
|
127
|
+
readonly descendants: readonly DshLineageNodeFace[]
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* `SessionLineageTrace` subset (types.d.ts:59-76). The SDK type is a
|
|
132
|
+
* discriminated union on `complete`; this face widens it so both
|
|
133
|
+
* branches are assignable.
|
|
134
|
+
*/
|
|
135
|
+
export interface DshLineageTraceFace {
|
|
136
|
+
readonly target: DshLineageRecordFace
|
|
137
|
+
readonly ancestors: readonly DshLineageRecordFace[]
|
|
138
|
+
readonly descendants: readonly DshLineageNodeFace[]
|
|
139
|
+
readonly complete: boolean
|
|
140
|
+
readonly root?: DshLineageRecordFace
|
|
141
|
+
readonly unresolvedParentId?: string
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** `SessionLogSnapshot` subset (types.d.ts:32-37). */
|
|
145
|
+
export interface DshSessionLogFace {
|
|
146
|
+
readonly events: readonly DshSessionEventFace[]
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** One cross-session full-text hit (`SessionSearchHit`, types.d.ts:255-258). */
|
|
150
|
+
export interface DshSearchHitFace {
|
|
151
|
+
readonly header: { readonly id: string }
|
|
152
|
+
readonly bestMatch: { readonly seq: number; readonly snippet: string }
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Minimal `ctx.sessionQuery` (`SessionQueryEngine`) face: lineage
|
|
157
|
+
* (index.d.ts:123), cold raw-log read (:62), full-text search (:42).
|
|
158
|
+
*/
|
|
159
|
+
export interface SessionQueryFace {
|
|
160
|
+
traceSession(sessionId: string, signal?: AbortSignal): Promise<DshLineageTraceFace>
|
|
161
|
+
readSession(sessionId: string): Promise<DshSessionLogFace>
|
|
162
|
+
searchSessions(request: {
|
|
163
|
+
query: string
|
|
164
|
+
limit?: number
|
|
165
|
+
}): Promise<{ items: readonly DshSearchHitFace[] }>
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
// Sidecar faces (wire shapes of bridge.ts / session-store.ts, kept local
|
|
170
|
+
// per module-ownership rules; the real objects satisfy them structurally).
|
|
171
|
+
// ---------------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
/** One normalized sidecar event (`Event.to_dict`; bridge.ts `SidecarEvent`). */
|
|
174
|
+
export interface SidecarEventFace {
|
|
175
|
+
readonly ts: string
|
|
176
|
+
readonly agent: string
|
|
177
|
+
readonly session_id: string
|
|
178
|
+
readonly kind: string
|
|
179
|
+
readonly text: string
|
|
180
|
+
readonly extra?: Record<string, unknown>
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* One sidecar board row. `SessionView` (session-store.ts) satisfies the
|
|
185
|
+
* required fields; `extra`/`parent_id` are optional because the current
|
|
186
|
+
* board projection drops them — a fuller wiring adapter can supply them
|
|
187
|
+
* and the extra supplement activates automatically.
|
|
188
|
+
*/
|
|
189
|
+
export interface SidecarSessionRowFace {
|
|
190
|
+
readonly agent: string
|
|
191
|
+
readonly session_id: string
|
|
192
|
+
readonly status: string
|
|
193
|
+
readonly title: string
|
|
194
|
+
readonly project: string
|
|
195
|
+
/** Unix epoch SECONDS (sidecar/model.py:50). */
|
|
196
|
+
readonly updated_at: number
|
|
197
|
+
readonly last_event?: { readonly ts: string; readonly kind: string; readonly text: string } | null
|
|
198
|
+
readonly gap?: boolean
|
|
199
|
+
readonly extra?: Record<string, unknown>
|
|
200
|
+
readonly parent_id?: string | null
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** What fusion needs from the session cache (satisfied by SessionStore). */
|
|
204
|
+
export interface FusionStoreFace {
|
|
205
|
+
getBoardState(): {
|
|
206
|
+
sessions: readonly SidecarSessionRowFace[]
|
|
207
|
+
streamHealth: string
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Seam for the daemon `replay {session_id, after_seq}` op (T5.2). Absent
|
|
213
|
+
* until wired; the timeline then relies on the bounded ring alone.
|
|
214
|
+
*/
|
|
215
|
+
export interface SidecarReplayFace {
|
|
216
|
+
replay(request: {
|
|
217
|
+
sessionId: string
|
|
218
|
+
afterSeq?: number | null
|
|
219
|
+
}): Promise<readonly SidecarEventFace[]>
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// Fused result types.
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
/** Provenance of one unified session row. */
|
|
227
|
+
export type UnifiedOrigin = 'dsh-live' | 'sidecar' | 'merged'
|
|
228
|
+
|
|
229
|
+
/** One deduplicated cross-agent session row. */
|
|
230
|
+
export interface UnifiedSession {
|
|
231
|
+
agent: string
|
|
232
|
+
sessionId: string
|
|
233
|
+
origin: UnifiedOrigin
|
|
234
|
+
/** True when the session is live in this dsh process (in-process feed). */
|
|
235
|
+
live: boolean
|
|
236
|
+
/** Sidecar-inferred status; `'unknown'` when only the live feed knows the session. */
|
|
237
|
+
status: string
|
|
238
|
+
title: string
|
|
239
|
+
project: string
|
|
240
|
+
/** Unix epoch ms of the freshest signal across both sources. */
|
|
241
|
+
lastActivityAt: number
|
|
242
|
+
/** Sidecar last-event summary (normalized text), when known. */
|
|
243
|
+
lastEvent: { readonly ts: string; readonly kind: string; readonly text: string } | null
|
|
244
|
+
/** Latest known dsh seq (in-process preferred, sidecar `extra.seq` fallback). */
|
|
245
|
+
lastSeq: number | null
|
|
246
|
+
/** Sidecar-observed seq discontinuity flag. */
|
|
247
|
+
gap: boolean
|
|
248
|
+
parentId: string | null
|
|
249
|
+
/** Sidecar extra supplement (stats/plan/...); `{}` when unknown. */
|
|
250
|
+
extra: Record<string, unknown>
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** One project correlation group (cross-agent view, design §4.e.2). */
|
|
254
|
+
export interface ProjectGroup {
|
|
255
|
+
project: string
|
|
256
|
+
/** Distinct agents active in the group, sorted. */
|
|
257
|
+
agents: string[]
|
|
258
|
+
/** Member sessions, most recent first. */
|
|
259
|
+
sessions: UnifiedSession[]
|
|
260
|
+
lastActivityAt: number
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** One merged timeline entry. */
|
|
264
|
+
export interface TimelineEntry {
|
|
265
|
+
origin: 'dsh' | 'sidecar'
|
|
266
|
+
/** dsh log seq (native or mirrored via sidecar `extra.seq`); null when unknown. */
|
|
267
|
+
seq: number | null
|
|
268
|
+
/** Unix epoch ms. */
|
|
269
|
+
ts: number
|
|
270
|
+
/** dsh native event type for dsh entries, normalized kind for sidecar entries. */
|
|
271
|
+
kind: string
|
|
272
|
+
/** Normalized sidecar text; `''` for dsh entries without a sidecar twin. */
|
|
273
|
+
text: string
|
|
274
|
+
/** Raw dsh event data (undefined for sidecar-only entries). */
|
|
275
|
+
data: unknown
|
|
276
|
+
/** Sidecar extra of this entry or of the deduplicated twin. */
|
|
277
|
+
extra: Record<string, unknown> | null
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** Backward-pagination cursor: identity of the oldest entry of a page. */
|
|
281
|
+
export interface TimelineCursor {
|
|
282
|
+
seq: number | null
|
|
283
|
+
ts: number
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Which sources contributed to a timeline page. */
|
|
287
|
+
export interface TimelineSources {
|
|
288
|
+
dshLive: boolean
|
|
289
|
+
dshCold: boolean
|
|
290
|
+
sidecarReplay: boolean
|
|
291
|
+
sidecarBuffer: boolean
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** One on-demand timeline page (entries ascending). */
|
|
295
|
+
export interface TimelinePage {
|
|
296
|
+
sessionId: string
|
|
297
|
+
entries: TimelineEntry[]
|
|
298
|
+
/** Cursor for the next older page; null when the page reached the log start. */
|
|
299
|
+
cursor: TimelineCursor | null
|
|
300
|
+
sources: TimelineSources
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** `getLineage` result; degrades instead of throwing (design §4.e.4). */
|
|
304
|
+
export interface LineageResult {
|
|
305
|
+
available: boolean
|
|
306
|
+
trace: DshLineageTraceFace | null
|
|
307
|
+
reason: 'session_query_unavailable' | 'trace_failed' | null
|
|
308
|
+
detail?: string
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** Capability advertisement for the UI (honest degradation labelling). */
|
|
312
|
+
export interface FusionCapabilities {
|
|
313
|
+
dshEvents: { available: boolean; liveSessions: number }
|
|
314
|
+
sessionQuery: { available: boolean; reason: 'session_query_unavailable' | null }
|
|
315
|
+
search: { mode: 'full-text' | 'filter-only' }
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** One search match. */
|
|
319
|
+
export interface SearchMatch {
|
|
320
|
+
session: UnifiedSession
|
|
321
|
+
matchedBy: 'full-text' | 'title' | 'project'
|
|
322
|
+
/** Best-match excerpt (full-text hits only). */
|
|
323
|
+
snippet: string | null
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Search result; `filter-only` is the sessionQuery-absent degradation. */
|
|
327
|
+
export interface SearchResult {
|
|
328
|
+
mode: 'full-text' | 'filter-only'
|
|
329
|
+
items: SearchMatch[]
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ---------------------------------------------------------------------------
|
|
333
|
+
// Tunables.
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
/** Default timeline page size. */
|
|
337
|
+
export const DEFAULT_TIMELINE_LIMIT = 100
|
|
338
|
+
/** Default project correlation window (matches `ui.time-window-hours` = 24). */
|
|
339
|
+
export const DEFAULT_PROJECT_WINDOW_MS = 24 * 60 * 60 * 1000
|
|
340
|
+
/** Default search page size. */
|
|
341
|
+
export const DEFAULT_SEARCH_LIMIT = 50
|
|
342
|
+
/** Per-session bound of the sidecar event ring (hints, not a store). */
|
|
343
|
+
export const DEFAULT_MAX_BUFFERED_EVENTS_PER_SESSION = 200
|
|
344
|
+
/** Bound on distinct session rings (least-recently-fed evicted first). */
|
|
345
|
+
export const DEFAULT_MAX_BUFFERED_SESSIONS = 256
|
|
346
|
+
|
|
347
|
+
const DSH_AGENT = 'dsh'
|
|
348
|
+
const KEY_SEP = '\u0000'
|
|
349
|
+
|
|
350
|
+
// ---------------------------------------------------------------------------
|
|
351
|
+
// Internals.
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
|
|
354
|
+
interface DshLiveEntry {
|
|
355
|
+
session: DshSessionFace
|
|
356
|
+
/** Latest-wins fold of `session/title` events; null before the first title. */
|
|
357
|
+
title: string | null
|
|
358
|
+
lastSeq: number | null
|
|
359
|
+
/** Unix epoch ms of the latest observed event. */
|
|
360
|
+
lastEventAt: number | null
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function integerOrNull(value: unknown): number | null {
|
|
364
|
+
return typeof value === 'number' && Number.isInteger(value) ? value : null
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function secondsToMs(seconds: number): number {
|
|
368
|
+
return typeof seconds === 'number' && Number.isFinite(seconds) ? Math.round(seconds * 1000) : 0
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function parseTs(ts: string): number {
|
|
372
|
+
const ms = Date.parse(ts)
|
|
373
|
+
return Number.isFinite(ms) ? ms : 0
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Latest-wins `session/title` payload fold (`{title: string}`). */
|
|
377
|
+
function extractTitle(data: unknown): string | null {
|
|
378
|
+
if (typeof data !== 'object' || data === null || Array.isArray(data)) return null
|
|
379
|
+
const title = (data as Record<string, unknown>)['title']
|
|
380
|
+
return typeof title === 'string' && title !== '' ? title : null
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Correlation-key normalization: strip trailing slashes (keep root `/`). */
|
|
384
|
+
function normalizeProject(project: string): string {
|
|
385
|
+
if (project.length > 1 && project.endsWith('/')) {
|
|
386
|
+
const stripped = project.replace(/\/+$/, '')
|
|
387
|
+
return stripped === '' ? '/' : stripped
|
|
388
|
+
}
|
|
389
|
+
return project
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function describeError(error: unknown): string {
|
|
393
|
+
return error instanceof Error ? error.message : String(error)
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Dedup identity of one sidecar event within a single session's timeline.
|
|
398
|
+
* The dsh adapter legally normalizes ONE dsh record into SEVERAL events
|
|
399
|
+
* sharing the same `extra.seq` (reasoning+text blocks of an assistant
|
|
400
|
+
* message, multi-block user messages, spliced inbox inserts —
|
|
401
|
+
* sidecar/adapters/dsh.py content_block_events), and no per-block ordinal
|
|
402
|
+
* exists on the wire — so seq alone would silently drop sibling events.
|
|
403
|
+
* The identity is therefore `seq+kind+text`: the same underlying event
|
|
404
|
+
* seen through both replay and the ring still collapses (identical
|
|
405
|
+
* normalized kind/text), while same-seq siblings stay distinct. Must stay
|
|
406
|
+
* in sync with the client mirror (client/detail/logic.ts `entryKey`).
|
|
407
|
+
*/
|
|
408
|
+
function sidecarEventKey(ev: SidecarEventFace): string {
|
|
409
|
+
const seq = integerOrNull(ev.extra?.['seq'])
|
|
410
|
+
return seq !== null
|
|
411
|
+
? `s:${seq}${KEY_SEP}${ev.kind}${KEY_SEP}${ev.text}`
|
|
412
|
+
: `t:${ev.ts}${KEY_SEP}${ev.kind}${KEY_SEP}${ev.text}`
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** Strictly-older-than-cursor predicate over the merged ascending order. */
|
|
416
|
+
function isBeforeCursor(entry: TimelineEntry, cursor: TimelineCursor): boolean {
|
|
417
|
+
if (cursor.seq !== null && entry.seq !== null) return entry.seq < cursor.seq
|
|
418
|
+
return entry.ts < cursor.ts
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** One seq-carrying sidecar event as its own timeline entry. */
|
|
422
|
+
function sidecarSeqEntry(seq: number, ev: SidecarEventFace): TimelineEntry {
|
|
423
|
+
return {
|
|
424
|
+
origin: 'sidecar',
|
|
425
|
+
seq,
|
|
426
|
+
ts: parseTs(ev.ts),
|
|
427
|
+
kind: ev.kind,
|
|
428
|
+
text: ev.text,
|
|
429
|
+
data: undefined,
|
|
430
|
+
extra: ev.extra ?? null,
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* Merge dsh events (authoritative seq domain) with sidecar events.
|
|
436
|
+
* One dsh record can normalize into several sidecar events sharing the
|
|
437
|
+
* same `extra.seq` (multi-block messages), so twins are grouped per seq:
|
|
438
|
+
* the FIRST twin folds into the matching dsh entry (normalized text +
|
|
439
|
+
* extra supplement, dsh primary) and every further sibling stays its own
|
|
440
|
+
* entry — dropping siblings would silently lose blocks (F1). Seq-carrying
|
|
441
|
+
* entries keep exact seq order (same-seq groups keep dsh-then-block
|
|
442
|
+
* arrival order via the stable sort); seq-less entries interleave by
|
|
443
|
+
* timestamp.
|
|
444
|
+
*/
|
|
445
|
+
function mergeTimeline(
|
|
446
|
+
dshEvents: readonly DshSessionEventFace[],
|
|
447
|
+
sidecarEvents: readonly SidecarEventFace[],
|
|
448
|
+
): TimelineEntry[] {
|
|
449
|
+
const twinsBySeq = new Map<number, SidecarEventFace[]>()
|
|
450
|
+
const unseqed: TimelineEntry[] = []
|
|
451
|
+
for (const ev of sidecarEvents) {
|
|
452
|
+
const seq = integerOrNull(ev.extra?.['seq'])
|
|
453
|
+
if (seq !== null) {
|
|
454
|
+
const group = twinsBySeq.get(seq)
|
|
455
|
+
if (group === undefined) twinsBySeq.set(seq, [ev])
|
|
456
|
+
else group.push(ev)
|
|
457
|
+
} else {
|
|
458
|
+
unseqed.push({
|
|
459
|
+
origin: 'sidecar',
|
|
460
|
+
seq: null,
|
|
461
|
+
ts: parseTs(ev.ts),
|
|
462
|
+
kind: ev.kind,
|
|
463
|
+
text: ev.text,
|
|
464
|
+
data: undefined,
|
|
465
|
+
extra: ev.extra ?? null,
|
|
466
|
+
})
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const seqDomain: TimelineEntry[] = []
|
|
471
|
+
const dshSeqs = new Set<number>()
|
|
472
|
+
for (const ev of dshEvents) {
|
|
473
|
+
const twins = dshSeqs.has(ev.seq) ? undefined : twinsBySeq.get(ev.seq)
|
|
474
|
+
dshSeqs.add(ev.seq)
|
|
475
|
+
const first = twins?.[0]
|
|
476
|
+
seqDomain.push({
|
|
477
|
+
origin: 'dsh',
|
|
478
|
+
seq: ev.seq,
|
|
479
|
+
ts: ev.time,
|
|
480
|
+
kind: ev.type,
|
|
481
|
+
text: first?.text ?? '',
|
|
482
|
+
data: ev.data,
|
|
483
|
+
extra: first?.extra ?? null,
|
|
484
|
+
})
|
|
485
|
+
if (twins !== undefined) {
|
|
486
|
+
for (let i = 1; i < twins.length; i += 1) {
|
|
487
|
+
const sibling = twins[i]
|
|
488
|
+
if (sibling !== undefined) seqDomain.push(sidecarSeqEntry(ev.seq, sibling))
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
for (const [seq, twins] of twinsBySeq) {
|
|
493
|
+
if (dshSeqs.has(seq)) continue
|
|
494
|
+
for (const ev of twins) seqDomain.push(sidecarSeqEntry(seq, ev))
|
|
495
|
+
}
|
|
496
|
+
// Array.prototype.sort is stable: same-seq entries keep their push
|
|
497
|
+
// order (dsh entry first, then siblings in block/arrival order).
|
|
498
|
+
seqDomain.sort((a, b) => (a.seq ?? 0) - (b.seq ?? 0))
|
|
499
|
+
unseqed.sort((a, b) => a.ts - b.ts)
|
|
500
|
+
|
|
501
|
+
// Two-pointer merge by ts (tie: seq domain first) preserves exact seq
|
|
502
|
+
// order even when event clocks tie or wobble within the seq domain.
|
|
503
|
+
const out: TimelineEntry[] = []
|
|
504
|
+
let i = 0
|
|
505
|
+
let j = 0
|
|
506
|
+
for (;;) {
|
|
507
|
+
const a = seqDomain[i]
|
|
508
|
+
const b = unseqed[j]
|
|
509
|
+
if (a === undefined && b === undefined) break
|
|
510
|
+
if (b === undefined || (a !== undefined && a.ts <= b.ts)) {
|
|
511
|
+
if (a !== undefined) {
|
|
512
|
+
out.push(a)
|
|
513
|
+
i += 1
|
|
514
|
+
}
|
|
515
|
+
} else {
|
|
516
|
+
out.push(b)
|
|
517
|
+
j += 1
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return out
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// ---------------------------------------------------------------------------
|
|
524
|
+
// FusionQuery.
|
|
525
|
+
// ---------------------------------------------------------------------------
|
|
526
|
+
|
|
527
|
+
export interface FusionQueryOptions {
|
|
528
|
+
/** Sidecar session cache (read-only board state). */
|
|
529
|
+
store: FusionStoreFace
|
|
530
|
+
/** In-process dsh event feed; absent → sidecar-only fusion (no error). */
|
|
531
|
+
dshEvents?: DshEventFace | null
|
|
532
|
+
/**
|
|
533
|
+
* Lazy `ctx.sessionQuery` resolver, re-evaluated on every use (the
|
|
534
|
+
* service may mount late or never); undefined/null/throw → absent.
|
|
535
|
+
*/
|
|
536
|
+
getSessionQuery?: (() => SessionQueryFace | null | undefined) | null
|
|
537
|
+
/** Daemon replay op seam (T5.2); absent until wired. */
|
|
538
|
+
replay?: SidecarReplayFace | null
|
|
539
|
+
/** Clock override for tests. */
|
|
540
|
+
now?: () => number
|
|
541
|
+
maxBufferedEventsPerSession?: number
|
|
542
|
+
maxBufferedSessions?: number
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* The fused query surface. Lifecycle: `start()` subscribes to the dsh
|
|
547
|
+
* feed, `stop()` disposes subscriptions and drops all cached state; the
|
|
548
|
+
* wiring feeds the sidecar subscribe stream through
|
|
549
|
+
* {@link ingestSidecarEvent}. All query methods are on-demand pulls.
|
|
550
|
+
*/
|
|
551
|
+
export class FusionQuery {
|
|
552
|
+
private readonly store: FusionStoreFace
|
|
553
|
+
private readonly dshEvents: DshEventFace | null
|
|
554
|
+
private readonly getSessionQueryThunk: (() => SessionQueryFace | null | undefined) | null
|
|
555
|
+
private readonly replaySource: SidecarReplayFace | null
|
|
556
|
+
private readonly now: () => number
|
|
557
|
+
private readonly maxEventsPerSession: number
|
|
558
|
+
private readonly maxSessions: number
|
|
559
|
+
|
|
560
|
+
/** Live in-process dsh sessions keyed by session id. */
|
|
561
|
+
private readonly live = new Map<string, DshLiveEntry>()
|
|
562
|
+
/** Bounded per-session sidecar event rings; insertion order = feed recency. */
|
|
563
|
+
private readonly buffers = new Map<string, SidecarEventFace[]>()
|
|
564
|
+
private disposers: Array<() => void> = []
|
|
565
|
+
private started = false
|
|
566
|
+
|
|
567
|
+
constructor(opts: FusionQueryOptions) {
|
|
568
|
+
this.store = opts.store
|
|
569
|
+
this.dshEvents = opts.dshEvents ?? null
|
|
570
|
+
this.getSessionQueryThunk = opts.getSessionQuery ?? null
|
|
571
|
+
this.replaySource = opts.replay ?? null
|
|
572
|
+
this.now = opts.now ?? Date.now
|
|
573
|
+
this.maxEventsPerSession =
|
|
574
|
+
opts.maxBufferedEventsPerSession ?? DEFAULT_MAX_BUFFERED_EVENTS_PER_SESSION
|
|
575
|
+
this.maxSessions = opts.maxBufferedSessions ?? DEFAULT_MAX_BUFFERED_SESSIONS
|
|
576
|
+
if (this.maxEventsPerSession <= 0 || this.maxSessions <= 0) {
|
|
577
|
+
throw new RangeError('fusion buffer bounds are invalid')
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** Subscribe to the in-process feed (idempotent). */
|
|
582
|
+
start(): void {
|
|
583
|
+
if (this.started) return
|
|
584
|
+
this.started = true
|
|
585
|
+
if (this.dshEvents === null) return
|
|
586
|
+
this.disposers.push(
|
|
587
|
+
this.dshEvents.on('session/created', (session) => {
|
|
588
|
+
this.ensureLive(session)
|
|
589
|
+
}),
|
|
590
|
+
this.dshEvents.on('session/event', (session, ev) => {
|
|
591
|
+
this.handleDshEvent(session, ev)
|
|
592
|
+
}),
|
|
593
|
+
this.dshEvents.on('session/disposed', (session) => {
|
|
594
|
+
this.live.delete(session.id)
|
|
595
|
+
}),
|
|
596
|
+
)
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/** Dispose subscriptions and drop all cached state (idempotent). */
|
|
600
|
+
stop(): void {
|
|
601
|
+
if (!this.started) return
|
|
602
|
+
this.started = false
|
|
603
|
+
const disposers = this.disposers
|
|
604
|
+
this.disposers = []
|
|
605
|
+
for (const dispose of disposers) dispose()
|
|
606
|
+
this.live.clear()
|
|
607
|
+
this.buffers.clear()
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Feed one sidecar subscribe-stream event into the bounded ring
|
|
612
|
+
* (timeline hints only; the stream stays a trigger signal, ADR-2).
|
|
613
|
+
*/
|
|
614
|
+
ingestSidecarEvent(ev: SidecarEventFace): void {
|
|
615
|
+
if (typeof ev.session_id !== 'string' || ev.session_id === '') return
|
|
616
|
+
let ring = this.buffers.get(ev.session_id)
|
|
617
|
+
if (ring === undefined) {
|
|
618
|
+
ring = []
|
|
619
|
+
} else {
|
|
620
|
+
// Delete + re-set keeps Map insertion order as feed recency.
|
|
621
|
+
this.buffers.delete(ev.session_id)
|
|
622
|
+
}
|
|
623
|
+
ring.push(ev)
|
|
624
|
+
if (ring.length > this.maxEventsPerSession) {
|
|
625
|
+
ring.splice(0, ring.length - this.maxEventsPerSession)
|
|
626
|
+
}
|
|
627
|
+
this.buffers.set(ev.session_id, ring)
|
|
628
|
+
while (this.buffers.size > this.maxSessions) {
|
|
629
|
+
const oldest = this.buffers.keys().next()
|
|
630
|
+
if (oldest.done === true) break
|
|
631
|
+
this.buffers.delete(oldest.value)
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
/**
|
|
636
|
+
* Deduplicated cross-agent session list, most recently active first.
|
|
637
|
+
* dsh sessions live in this process win over their sidecar rows
|
|
638
|
+
* (which then only supplement); cold dsh sessions and non-dsh agents
|
|
639
|
+
* come from the sidecar alone.
|
|
640
|
+
*/
|
|
641
|
+
getUnifiedSessions(): UnifiedSession[] {
|
|
642
|
+
const board = this.store.getBoardState()
|
|
643
|
+
const out = new Map<string, UnifiedSession>()
|
|
644
|
+
const mergedIds = new Set<string>()
|
|
645
|
+
for (const row of board.sessions) {
|
|
646
|
+
const liveEntry = row.agent === DSH_AGENT ? this.live.get(row.session_id) : undefined
|
|
647
|
+
if (liveEntry !== undefined) {
|
|
648
|
+
mergedIds.add(row.session_id)
|
|
649
|
+
out.set(`${row.agent}${KEY_SEP}${row.session_id}`, this.mergeRow(liveEntry, row))
|
|
650
|
+
} else {
|
|
651
|
+
out.set(`${row.agent}${KEY_SEP}${row.session_id}`, fromSidecarRow(row))
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
for (const [id, entry] of this.live) {
|
|
655
|
+
if (mergedIds.has(id)) continue
|
|
656
|
+
out.set(`${DSH_AGENT}${KEY_SEP}${id}`, fromDshLive(entry))
|
|
657
|
+
}
|
|
658
|
+
const sessions = [...out.values()]
|
|
659
|
+
sessions.sort(
|
|
660
|
+
(a, b) => b.lastActivityAt - a.lastActivityAt || a.sessionId.localeCompare(b.sessionId),
|
|
661
|
+
)
|
|
662
|
+
return sessions
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Cross-agent project correlation groups within a time window
|
|
667
|
+
* (project path + window is the correlation key, design §4.e.2).
|
|
668
|
+
*/
|
|
669
|
+
getProjectGroups(opts: { windowMs?: number; now?: number } = {}): ProjectGroup[] {
|
|
670
|
+
const windowMs = opts.windowMs ?? DEFAULT_PROJECT_WINDOW_MS
|
|
671
|
+
const cutoff = (opts.now ?? this.now()) - windowMs
|
|
672
|
+
const groups = new Map<string, ProjectGroup>()
|
|
673
|
+
for (const session of this.getUnifiedSessions()) {
|
|
674
|
+
if (session.lastActivityAt < cutoff) continue
|
|
675
|
+
const project = normalizeProject(session.project)
|
|
676
|
+
let group = groups.get(project)
|
|
677
|
+
if (group === undefined) {
|
|
678
|
+
group = { project, agents: [], sessions: [], lastActivityAt: 0 }
|
|
679
|
+
groups.set(project, group)
|
|
680
|
+
}
|
|
681
|
+
group.sessions.push(session)
|
|
682
|
+
if (!group.agents.includes(session.agent)) group.agents.push(session.agent)
|
|
683
|
+
if (session.lastActivityAt > group.lastActivityAt) {
|
|
684
|
+
group.lastActivityAt = session.lastActivityAt
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
const out = [...groups.values()]
|
|
688
|
+
for (const group of out) group.agents.sort()
|
|
689
|
+
out.sort((a, b) => b.lastActivityAt - a.lastActivityAt || a.project.localeCompare(b.project))
|
|
690
|
+
return out
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
/**
|
|
694
|
+
* One merged timeline page for a session, ascending, deduplicated by
|
|
695
|
+
* event identity (seq+kind+text for seq-carrying events — same-seq
|
|
696
|
+
* sibling events from multi-block records all survive), newest window
|
|
697
|
+
* first with a backward cursor. Sources are pulled on demand; a
|
|
698
|
+
* missing/failing source silently narrows the page (provenance is
|
|
699
|
+
* reported in `sources`).
|
|
700
|
+
*/
|
|
701
|
+
async getSessionTimeline(
|
|
702
|
+
sessionId: string,
|
|
703
|
+
opts: { limit?: number; before?: TimelineCursor | null } = {},
|
|
704
|
+
): Promise<TimelinePage> {
|
|
705
|
+
const limit = Math.max(1, Math.floor(opts.limit ?? DEFAULT_TIMELINE_LIMIT))
|
|
706
|
+
const sources: TimelineSources = {
|
|
707
|
+
dshLive: false,
|
|
708
|
+
dshCold: false,
|
|
709
|
+
sidecarReplay: false,
|
|
710
|
+
sidecarBuffer: false,
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
let dshEvents: readonly DshSessionEventFace[] = []
|
|
714
|
+
const liveEntry = this.live.get(sessionId)
|
|
715
|
+
if (liveEntry !== undefined) {
|
|
716
|
+
dshEvents = liveEntry.session.events
|
|
717
|
+
sources.dshLive = true
|
|
718
|
+
} else {
|
|
719
|
+
const engine = this.resolveSessionQuery()
|
|
720
|
+
if (engine !== null) {
|
|
721
|
+
try {
|
|
722
|
+
dshEvents = (await engine.readSession(sessionId)).events
|
|
723
|
+
sources.dshCold = true
|
|
724
|
+
} catch {
|
|
725
|
+
// Cold dsh log unavailable (unknown id / persistence off):
|
|
726
|
+
// the sidecar side still yields an honest partial timeline.
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
const sidecarEvents: SidecarEventFace[] = []
|
|
732
|
+
const seen = new Set<string>()
|
|
733
|
+
const addSidecar = (ev: SidecarEventFace): boolean => {
|
|
734
|
+
const key = sidecarEventKey(ev)
|
|
735
|
+
if (seen.has(key)) return false
|
|
736
|
+
seen.add(key)
|
|
737
|
+
sidecarEvents.push(ev)
|
|
738
|
+
return true
|
|
739
|
+
}
|
|
740
|
+
if (this.replaySource !== null) {
|
|
741
|
+
try {
|
|
742
|
+
const replayed = await this.replaySource.replay({ sessionId })
|
|
743
|
+
for (const ev of replayed) addSidecar(ev)
|
|
744
|
+
sources.sidecarReplay = true
|
|
745
|
+
} catch {
|
|
746
|
+
// Replay op unavailable — fall through to the bounded ring.
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
const ring = this.buffers.get(sessionId)
|
|
750
|
+
if (ring !== undefined && ring.length > 0) {
|
|
751
|
+
sources.sidecarBuffer = true
|
|
752
|
+
for (const ev of ring) addSidecar(ev)
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
const entries = mergeTimeline(dshEvents, sidecarEvents)
|
|
756
|
+
|
|
757
|
+
let endIdx = entries.length
|
|
758
|
+
const before = opts.before ?? null
|
|
759
|
+
if (before !== null) {
|
|
760
|
+
endIdx = 0
|
|
761
|
+
while (endIdx < entries.length) {
|
|
762
|
+
const entry = entries[endIdx]
|
|
763
|
+
if (entry === undefined || !isBeforeCursor(entry, before)) break
|
|
764
|
+
endIdx += 1
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
let startIdx = Math.max(0, endIdx - limit)
|
|
768
|
+
// Never split a same-seq sibling group across the page boundary: the
|
|
769
|
+
// cursor identifies entries by seq (strictly-older predicate), so a
|
|
770
|
+
// group straddling it would lose its older siblings to pagination.
|
|
771
|
+
// Widening keeps the cursor group-aligned at a small bounded overshoot.
|
|
772
|
+
const boundary = entries[startIdx]
|
|
773
|
+
if (boundary !== undefined && boundary.seq !== null) {
|
|
774
|
+
while (startIdx > 0 && entries[startIdx - 1]?.seq === boundary.seq) startIdx -= 1
|
|
775
|
+
}
|
|
776
|
+
const window = entries.slice(startIdx, endIdx)
|
|
777
|
+
const first = window[0]
|
|
778
|
+
const cursor =
|
|
779
|
+
startIdx > 0 && first !== undefined ? { seq: first.seq, ts: first.ts } : null
|
|
780
|
+
return { sessionId, entries: window, cursor, sources }
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* dsh lineage via `sessionQuery.traceSession`; degrades to
|
|
785
|
+
* `trace: null` + reason when the service is absent or the trace
|
|
786
|
+
* fails (never throws).
|
|
787
|
+
*/
|
|
788
|
+
async getLineage(sessionId: string): Promise<LineageResult> {
|
|
789
|
+
const engine = this.resolveSessionQuery()
|
|
790
|
+
if (engine === null) {
|
|
791
|
+
return { available: false, trace: null, reason: 'session_query_unavailable' }
|
|
792
|
+
}
|
|
793
|
+
try {
|
|
794
|
+
const trace = await engine.traceSession(sessionId)
|
|
795
|
+
return { available: true, trace, reason: null }
|
|
796
|
+
} catch (error) {
|
|
797
|
+
return {
|
|
798
|
+
available: false,
|
|
799
|
+
trace: null,
|
|
800
|
+
reason: 'trace_failed',
|
|
801
|
+
detail: describeError(error),
|
|
802
|
+
}
|
|
803
|
+
}
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/**
|
|
807
|
+
* Cross-agent search. With sessionQuery mounted, dsh sessions get
|
|
808
|
+
* full-text ranking (hits first, engine order); without it — or when
|
|
809
|
+
* the engine call fails — the deep query degrades to title/project
|
|
810
|
+
* substring filtering over the unified view (`filter-only`), without
|
|
811
|
+
* error. Non-dsh agents always use the filter path (the sidecar has
|
|
812
|
+
* no search API).
|
|
813
|
+
*/
|
|
814
|
+
async searchSessions(query: string, opts: { limit?: number } = {}): Promise<SearchResult> {
|
|
815
|
+
const limit = Math.max(1, Math.floor(opts.limit ?? DEFAULT_SEARCH_LIMIT))
|
|
816
|
+
const needle = query.trim().toLowerCase()
|
|
817
|
+
const engine = this.resolveSessionQuery()
|
|
818
|
+
let mode: SearchResult['mode'] = engine !== null ? 'full-text' : 'filter-only'
|
|
819
|
+
if (needle === '') return { mode, items: [] }
|
|
820
|
+
|
|
821
|
+
const unified = this.getUnifiedSessions()
|
|
822
|
+
const items: SearchMatch[] = []
|
|
823
|
+
const seen = new Set<string>()
|
|
824
|
+
|
|
825
|
+
if (engine !== null) {
|
|
826
|
+
try {
|
|
827
|
+
const page = await engine.searchSessions({ query, limit })
|
|
828
|
+
const dshById = new Map<string, UnifiedSession>()
|
|
829
|
+
for (const session of unified) {
|
|
830
|
+
if (session.agent === DSH_AGENT) dshById.set(session.sessionId, session)
|
|
831
|
+
}
|
|
832
|
+
for (const hit of page.items) {
|
|
833
|
+
const session = dshById.get(hit.header.id)
|
|
834
|
+
// Hits outside the unified view (evicted from every local
|
|
835
|
+
// source) have nothing to attach to and are skipped.
|
|
836
|
+
if (session === undefined) continue
|
|
837
|
+
const key = `${session.agent}${KEY_SEP}${session.sessionId}`
|
|
838
|
+
if (seen.has(key)) continue
|
|
839
|
+
seen.add(key)
|
|
840
|
+
items.push({ session, matchedBy: 'full-text', snippet: hit.bestMatch.snippet })
|
|
841
|
+
}
|
|
842
|
+
} catch {
|
|
843
|
+
mode = 'filter-only'
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
for (const session of unified) {
|
|
848
|
+
const key = `${session.agent}${KEY_SEP}${session.sessionId}`
|
|
849
|
+
if (seen.has(key)) continue
|
|
850
|
+
if (session.title.toLowerCase().includes(needle)) {
|
|
851
|
+
seen.add(key)
|
|
852
|
+
items.push({ session, matchedBy: 'title', snippet: null })
|
|
853
|
+
} else if (session.project.toLowerCase().includes(needle)) {
|
|
854
|
+
seen.add(key)
|
|
855
|
+
items.push({ session, matchedBy: 'project', snippet: null })
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
return { mode, items: items.slice(0, limit) }
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/** Current capability face (sessionQuery re-resolved on every call). */
|
|
862
|
+
getCapabilities(): FusionCapabilities {
|
|
863
|
+
const engineAvailable = this.resolveSessionQuery() !== null
|
|
864
|
+
return {
|
|
865
|
+
dshEvents: { available: this.dshEvents !== null, liveSessions: this.live.size },
|
|
866
|
+
sessionQuery: {
|
|
867
|
+
available: engineAvailable,
|
|
868
|
+
reason: engineAvailable ? null : 'session_query_unavailable',
|
|
869
|
+
},
|
|
870
|
+
search: { mode: engineAvailable ? 'full-text' : 'filter-only' },
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// -------------------------------------------------------------------------
|
|
875
|
+
|
|
876
|
+
private resolveSessionQuery(): SessionQueryFace | null {
|
|
877
|
+
if (this.getSessionQueryThunk === null) return null
|
|
878
|
+
try {
|
|
879
|
+
return this.getSessionQueryThunk() ?? null
|
|
880
|
+
} catch {
|
|
881
|
+
return null
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Register a live session (first `session/created` or, when the feed
|
|
887
|
+
* attached late, first `session/event`), folding title/seq facts from
|
|
888
|
+
* the existing log tail without copying it.
|
|
889
|
+
*/
|
|
890
|
+
private ensureLive(session: DshSessionFace): DshLiveEntry {
|
|
891
|
+
let entry = this.live.get(session.id)
|
|
892
|
+
if (entry !== undefined) return entry
|
|
893
|
+
const events = session.events
|
|
894
|
+
const tail = events.length > 0 ? events[events.length - 1] : undefined
|
|
895
|
+
let title: string | null = null
|
|
896
|
+
for (let i = events.length - 1; i >= 0; i -= 1) {
|
|
897
|
+
const ev = events[i]
|
|
898
|
+
if (ev !== undefined && ev.type === 'session/title') {
|
|
899
|
+
const candidate = extractTitle(ev.data)
|
|
900
|
+
if (candidate !== null) {
|
|
901
|
+
title = candidate
|
|
902
|
+
break
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
entry = {
|
|
907
|
+
session,
|
|
908
|
+
title,
|
|
909
|
+
lastSeq: tail !== undefined ? tail.seq : null,
|
|
910
|
+
lastEventAt: tail !== undefined ? tail.time : null,
|
|
911
|
+
}
|
|
912
|
+
this.live.set(session.id, entry)
|
|
913
|
+
return entry
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
private handleDshEvent(session: DshSessionFace, ev: DshSessionEventFace): void {
|
|
917
|
+
const entry = this.ensureLive(session)
|
|
918
|
+
if (entry.lastSeq === null || ev.seq > entry.lastSeq) entry.lastSeq = ev.seq
|
|
919
|
+
if (entry.lastEventAt === null || ev.time > entry.lastEventAt) entry.lastEventAt = ev.time
|
|
920
|
+
if (ev.type === 'session/title') {
|
|
921
|
+
const title = extractTitle(ev.data)
|
|
922
|
+
if (title !== null) entry.title = title
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/** Merge one live dsh entry with its sidecar row (dsh primary). */
|
|
927
|
+
private mergeRow(liveEntry: DshLiveEntry, row: SidecarSessionRowFace): UnifiedSession {
|
|
928
|
+
const header = liveEntry.session.header
|
|
929
|
+
const dshActivityMs = liveEntry.lastEventAt ?? header.createdAt
|
|
930
|
+
return {
|
|
931
|
+
agent: DSH_AGENT,
|
|
932
|
+
sessionId: liveEntry.session.id,
|
|
933
|
+
origin: 'merged',
|
|
934
|
+
live: true,
|
|
935
|
+
// The in-process feed has no status estimator; the sidecar's
|
|
936
|
+
// inferred status stays the best-known value (supplement role).
|
|
937
|
+
status: row.status,
|
|
938
|
+
title: liveEntry.title ?? row.title,
|
|
939
|
+
project: header.cwd ?? row.project,
|
|
940
|
+
lastActivityAt: Math.max(dshActivityMs, secondsToMs(row.updated_at)),
|
|
941
|
+
lastEvent: row.last_event ?? null,
|
|
942
|
+
lastSeq: liveEntry.lastSeq ?? integerOrNull(row.extra?.['seq']),
|
|
943
|
+
gap: row.gap === true,
|
|
944
|
+
parentId:
|
|
945
|
+
header.parentSession ?? (typeof row.parent_id === 'string' ? row.parent_id : null),
|
|
946
|
+
extra: row.extra ?? {},
|
|
947
|
+
}
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
/** Cold fallback / non-dsh row: sidecar is the only source. */
|
|
952
|
+
function fromSidecarRow(row: SidecarSessionRowFace): UnifiedSession {
|
|
953
|
+
return {
|
|
954
|
+
agent: row.agent,
|
|
955
|
+
sessionId: row.session_id,
|
|
956
|
+
origin: 'sidecar',
|
|
957
|
+
live: false,
|
|
958
|
+
status: row.status,
|
|
959
|
+
title: row.title,
|
|
960
|
+
project: row.project,
|
|
961
|
+
lastActivityAt: secondsToMs(row.updated_at),
|
|
962
|
+
lastEvent: row.last_event ?? null,
|
|
963
|
+
lastSeq: integerOrNull(row.extra?.['seq']),
|
|
964
|
+
gap: row.gap === true,
|
|
965
|
+
parentId: typeof row.parent_id === 'string' ? row.parent_id : null,
|
|
966
|
+
extra: row.extra ?? {},
|
|
967
|
+
}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
/** Live dsh session the sidecar has not (yet) observed on disk. */
|
|
971
|
+
function fromDshLive(entry: DshLiveEntry): UnifiedSession {
|
|
972
|
+
const header = entry.session.header
|
|
973
|
+
return {
|
|
974
|
+
agent: DSH_AGENT,
|
|
975
|
+
sessionId: entry.session.id,
|
|
976
|
+
origin: 'dsh-live',
|
|
977
|
+
live: true,
|
|
978
|
+
status: 'unknown',
|
|
979
|
+
title: entry.title ?? '',
|
|
980
|
+
project: header.cwd ?? '',
|
|
981
|
+
lastActivityAt: entry.lastEventAt ?? header.createdAt,
|
|
982
|
+
lastEvent: null,
|
|
983
|
+
lastSeq: entry.lastSeq,
|
|
984
|
+
gap: false,
|
|
985
|
+
parentId: header.parentSession ?? null,
|
|
986
|
+
extra: {},
|
|
987
|
+
}
|
|
988
|
+
}
|