@mingozhou/dsh-replay 0.4.1 → 0.4.2

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.
Files changed (53) hide show
  1. package/README.md +225 -148
  2. package/README.zh.md +225 -154
  3. package/assets/icon.png +0 -0
  4. package/lib/client.js +954 -2418
  5. package/lib/core.js +557 -86
  6. package/lib/index.js +1043 -51
  7. package/lib/types/src/client/api.d.ts +44 -0
  8. package/lib/types/src/client/components/AuditView.d.ts +2 -1
  9. package/lib/types/src/client/components/CompareView.d.ts +2 -1
  10. package/lib/types/src/client/components/DashboardView.d.ts +8 -0
  11. package/lib/types/src/client/components/ExportDialog.d.ts +9 -0
  12. package/lib/types/src/client/components/ReplayApp.d.ts +4 -2
  13. package/lib/types/src/client/components/ReplayModal.d.ts +1 -1
  14. package/lib/types/src/client/components/SessionPicker.d.ts +4 -3
  15. package/lib/types/src/client/components/TimelineView.d.ts +2 -1
  16. package/lib/types/src/client/export.d.ts +6 -1
  17. package/lib/types/src/client/i18n.d.ts +8 -1
  18. package/lib/types/src/client/pricing.d.ts +6 -0
  19. package/lib/types/src/core/audit.d.ts +18 -0
  20. package/lib/types/src/core/compare.d.ts +8 -3
  21. package/lib/types/src/core/cost.d.ts +21 -3
  22. package/lib/types/src/core/index.d.ts +1 -0
  23. package/lib/types/src/core/redact.d.ts +40 -0
  24. package/lib/types/src/core/timeline.d.ts +9 -0
  25. package/lib/types/src/index.d.ts +52 -1
  26. package/lib/viewer.js +282 -29
  27. package/package.json +118 -107
  28. package/scripts/build.mjs +7 -1
  29. package/src/client/api.ts +114 -10
  30. package/src/client/components/AuditView.tsx +89 -10
  31. package/src/client/components/CompareView.tsx +89 -13
  32. package/src/client/components/DashboardView.tsx +232 -0
  33. package/src/client/components/ExportDialog.tsx +136 -0
  34. package/src/client/components/ItemDetail.tsx +36 -4
  35. package/src/client/components/OverviewView.tsx +150 -34
  36. package/src/client/components/ReplayApp.tsx +207 -60
  37. package/src/client/components/ReplayModal.tsx +41 -6
  38. package/src/client/components/SessionPicker.tsx +55 -27
  39. package/src/client/components/TimelineView.tsx +106 -41
  40. package/src/client/export.ts +20 -4
  41. package/src/client/i18n.ts +266 -45
  42. package/src/client/index.ts +7 -5
  43. package/src/client/mascot-image.ts +1 -1
  44. package/src/client/pricing.ts +90 -0
  45. package/src/client/styles.css +250 -1
  46. package/src/client/viewer-entry.tsx +25 -4
  47. package/src/core/audit.ts +274 -67
  48. package/src/core/compare.ts +0 -0
  49. package/src/core/cost.ts +94 -16
  50. package/src/core/index.ts +1 -0
  51. package/src/core/redact.ts +182 -0
  52. package/src/core/timeline.ts +107 -12
  53. package/src/index.ts +290 -54
package/src/index.ts CHANGED
@@ -4,15 +4,23 @@
4
4
  * Registers a small read-only HTTP API on the harness web server that the
5
5
  * browser half (and nothing else) consumes:
6
6
  *
7
- * GET /replay/api/sessions → { sessions: [{ header, title }] }
7
+ * GET /replay/api/sessions → { sessions: [{ header, title, live }] }
8
8
  * GET /replay/api/session/<id> → { header, events }
9
+ * GET /replay/api/summary?limit=30 → aggregate stats for the dashboard
10
+ * GET /replay/api/viewer.js → standalone viewer bundle
9
11
  *
10
12
  * Reads go through `ctx.sessionQuery` (live-preferred corpus), so an open
11
13
  * session replays with its in-memory state, not a stale disk artifact.
14
+ *
15
+ * The API is intentionally loopback-only by default: session logs can contain
16
+ * secrets, and this route is registered directly on the web server rather than
17
+ * through the authenticated Connection fetch bridge.
12
18
  */
13
19
  import type { Context } from '@deepseek-ai/cordis'
14
20
  import { readFile } from 'node:fs/promises'
15
21
  import type { IncomingMessage, ServerResponse } from 'node:http'
22
+ import { auditSession, buildTimeline, fromInspection } from './core/index.js'
23
+ import type { ParsedSession, SessionEvent, SessionHeader, StorageRecord } from './core/types.js'
16
24
 
17
25
  export const name = 'dsh-replay'
18
26
 
@@ -21,12 +29,17 @@ export const inject = ['webServer', 'sessionQuery']
21
29
  export interface Config {
22
30
  /** URL prefix for the replay API (exact-prefix route). */
23
31
  routePrefix?: string
32
+ /** Allow non-loopback clients. Off by default because the route is unauthenticated. */
33
+ allowRemote?: boolean
34
+ /** Maximum sessions included in one dashboard summary request. */
35
+ summaryLimit?: number
24
36
  }
25
37
 
26
38
  interface SessionQueryLike {
27
39
  listSessions: (signal?: AbortSignal) => Promise<unknown[]>
28
40
  readSession: (sessionId: string) => Promise<unknown>
29
41
  readTitleSnapshots?: (ids: string[], signal?: AbortSignal) => Promise<unknown[]>
42
+ readTitle?: (id: string, signal?: AbortSignal) => Promise<unknown>
30
43
  }
31
44
 
32
45
  interface WebServerLike {
@@ -37,12 +50,57 @@ interface WebServerLike {
37
50
  }) => () => void
38
51
  }
39
52
 
40
- function json(res: ServerResponse, status: number, body: unknown): void {
53
+ export interface SessionEntry {
54
+ header: Record<string, unknown>
55
+ title?: string
56
+ live?: boolean
57
+ }
58
+
59
+ export interface SessionSummary {
60
+ header: Record<string, unknown>
61
+ title?: string
62
+ live?: boolean
63
+ summary?: {
64
+ events: number
65
+ turns: number
66
+ steps: number
67
+ toolCalls: number
68
+ toolErrors: number
69
+ durationMs: number
70
+ models: string[]
71
+ tokens: {
72
+ input: number
73
+ output: number
74
+ cacheRead: number
75
+ cacheWrite: number
76
+ reasoning: number
77
+ billedInput: number
78
+ }
79
+ modelTotals: Record<string, {
80
+ input: number
81
+ output: number
82
+ cacheRead: number
83
+ cacheWrite: number
84
+ reasoning: number
85
+ billedInput: number
86
+ }>
87
+ findings: number
88
+ critical: number
89
+ serious: number
90
+ warning: number
91
+ info: number
92
+ }
93
+ error?: string
94
+ }
95
+
96
+ function json(res: ServerResponse, status: number, body: unknown, head = false): void {
41
97
  res.writeHead(status, {
42
98
  'content-type': 'application/json; charset=utf-8',
43
99
  'cache-control': 'no-store',
100
+ 'x-content-type-options': 'nosniff',
101
+ 'referrer-policy': 'no-referrer',
44
102
  })
45
- res.end(JSON.stringify(body))
103
+ res.end(head ? undefined : JSON.stringify(body))
46
104
  }
47
105
 
48
106
  function get(obj: unknown, key: string): unknown {
@@ -59,94 +117,272 @@ function headerOf(record: unknown): Record<string, unknown> | undefined {
59
117
  return undefined
60
118
  }
61
119
 
62
- /** Depth-limited recursive search for a non-empty string `title` field. */
63
- function titleOf(record: unknown, depth = 3): string | undefined {
120
+ /** Titles may arrive as a string, `{ title }`, or a settled snapshot wrapper. */
121
+ function titleOf(record: unknown, depth = 4): string | undefined {
122
+ if (typeof record === 'string') return record === '' ? undefined : record
64
123
  if (typeof record !== 'object' || record === null || depth < 0) return undefined
65
124
  const direct = (record as Record<string, unknown>)['title']
66
125
  if (typeof direct === 'string' && direct.length > 0) return direct
67
- for (const value of Object.values(record)) {
68
- if (typeof value === 'object' && value !== null) {
69
- const found = titleOf(value, depth - 1)
70
- if (found !== undefined) return found
71
- }
126
+ for (const key of ['value', 'session', 'meta']) {
127
+ const found = titleOf((record as Record<string, unknown>)[key], depth - 1)
128
+ if (found !== undefined) return found
72
129
  }
73
130
  return undefined
74
131
  }
75
132
 
133
+ function eventsOf(record: unknown): StorageRecord[] {
134
+ const events = get(record, 'events') ?? get(get(record, 'session'), 'events')
135
+ return Array.isArray(events) ? (events as StorageRecord[]) : []
136
+ }
137
+
138
+ function isLoopback(address: string | undefined): boolean {
139
+ if (address === undefined) return false
140
+ return (
141
+ address === '127.0.0.1' ||
142
+ address === '::1' ||
143
+ address === '::ffff:127.0.0.1' ||
144
+ address.startsWith('127.')
145
+ )
146
+ }
147
+
148
+ function hostIsLoopback(host: string | undefined): boolean {
149
+ if (host === undefined || host === '') return true
150
+ const name = host.startsWith('[') ? host.slice(1, host.indexOf(']')) : host.split(':')[0]
151
+ return name === 'localhost' || name === '127.0.0.1' || name === '::1' || name.startsWith('127.')
152
+ }
153
+
154
+ /** Reject cross-site and (by default) non-loopback requests before reading data. */
155
+ function allowed(req: IncomingMessage, allowRemote: boolean): boolean {
156
+ if (!allowRemote && !isLoopback(req.socket.remoteAddress)) return false
157
+ if (!allowRemote && !hostIsLoopback(req.headers.host)) return false
158
+ const origin = req.headers.origin
159
+ if (origin !== undefined) {
160
+ // A static Desktop shell may issue same-process requests with a file://
161
+ // or null origin. The loopback checks above already constrain those.
162
+ if (origin === 'null' || origin.startsWith('file://')) return true
163
+ try {
164
+ const originUrl = new URL(origin)
165
+ if (req.headers.host !== undefined && originUrl.host !== req.headers.host) return false
166
+ } catch {
167
+ return false
168
+ }
169
+ }
170
+ if (req.headers['sec-fetch-site'] === 'cross-site') return false
171
+ return true
172
+ }
173
+
174
+ async function mapLimit<T, R>(
175
+ items: T[],
176
+ limit: number,
177
+ task: (item: T) => Promise<R>,
178
+ ): Promise<R[]> {
179
+ const results = new Array<R>(items.length)
180
+ let index = 0
181
+ const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
182
+ while (index < items.length) {
183
+ const current = index++
184
+ results[current] = await task(items[current])
185
+ }
186
+ })
187
+ await Promise.all(workers)
188
+ return results
189
+ }
190
+
76
191
  export function apply(ctx: Context, config: Config = {}): void {
77
192
  const prefix = config.routePrefix ?? '/replay/api'
193
+ const allowRemote = config.allowRemote === true
194
+ const summaryLimit = Math.min(Math.max(config.summaryLimit ?? 30, 1), 100)
78
195
  const webServer = (ctx as unknown as { webServer: WebServerLike }).webServer
79
196
  const sessionQuery = (ctx as unknown as { sessionQuery: SessionQueryLike }).sessionQuery
80
197
 
198
+ const viewerCache = new Map<string, string>()
199
+ const summaryCache = new Map<string, { key: string; at: number; value: SessionSummary }>()
200
+ const SUMMARY_TTL_MS = 30_000
201
+
202
+ const loadEntries = async (
203
+ options: { withTitles?: boolean } = {},
204
+ ): Promise<{ entries: SessionEntry[]; titles: Map<string, string> }> => {
205
+ const withTitles = options.withTitles !== false
206
+ const records = await sessionQuery.listSessions()
207
+ const entries: SessionEntry[] = []
208
+ for (const record of records) {
209
+ const header = headerOf(record)
210
+ if (header === undefined) continue
211
+ const id = String(header['id'])
212
+ const title = titleOf(record)
213
+ const live = get(record, 'live')
214
+ entries.push({ header, ...(title !== undefined ? { title } : {}), ...(typeof live === 'boolean' ? { live } : {}) })
215
+ }
216
+
217
+ const titles = new Map<string, string>()
218
+ for (const entry of entries) {
219
+ if (entry.title !== undefined) titles.set(String(entry.header['id']), entry.title)
220
+ }
221
+
222
+ if (withTitles && sessionQuery.readTitleSnapshots !== undefined) {
223
+ try {
224
+ const snapshots = await sessionQuery.readTitleSnapshots(
225
+ entries.map(entry => String(entry.header['id'])),
226
+ )
227
+ for (const snapshot of snapshots) {
228
+ // Current engines return { status, value: { session, title } }.
229
+ const value = get(snapshot, 'value') ?? snapshot
230
+ const header = get(value, 'session') ?? get(value, 'header')
231
+ const id = get(header, 'id')
232
+ const title = titleOf(value)
233
+ if (typeof id === 'string' && title !== undefined) titles.set(id, title)
234
+ }
235
+ } catch {
236
+ /* titles are decoration — never fail the listing over them */
237
+ }
238
+ }
239
+
240
+ // Per-id fallback for engines where the batch call misses. `readTitle`
241
+ // returns a string on current engines; older ones returned an object.
242
+ const readTitle = sessionQuery.readTitle
243
+ if (withTitles && readTitle !== undefined) {
244
+ await Promise.all(
245
+ entries
246
+ .filter(entry => !titles.has(String(entry.header['id'])))
247
+ .map(async entry => {
248
+ try {
249
+ const id = String(entry.header['id'])
250
+ const result = await readTitle.call(sessionQuery, id)
251
+ const title = titleOf(result)
252
+ if (title !== undefined) titles.set(id, title)
253
+ } catch {
254
+ /* decoration only */
255
+ }
256
+ }),
257
+ )
258
+ }
259
+
260
+ for (const entry of entries) {
261
+ const id = String(entry.header['id'])
262
+ const title = titles.get(id)
263
+ if (title !== undefined) entry.title = title
264
+ }
265
+ return { entries, titles }
266
+ }
267
+
268
+ const summarize = async (entry: SessionEntry): Promise<SessionSummary> => {
269
+ const id = String(entry.header['id'])
270
+ try {
271
+ const snapshot = await sessionQuery.readSession(id)
272
+ const header = headerOf(snapshot) ?? headerOf(get(snapshot, 'session')) ?? entry.header
273
+ const records = eventsOf(snapshot)
274
+ const lastSeq = records.length > 0 ? Number(get(records.at(-1), 'seq') ?? -1) : -1
275
+ const key = `${id}:${lastSeq}`
276
+ const cached = summaryCache.get(id)
277
+ if (cached !== undefined && cached.key === key && Date.now() - cached.at < SUMMARY_TTL_MS) {
278
+ return cached.value
279
+ }
280
+ const parsed: ParsedSession = fromInspection(
281
+ header as unknown as SessionHeader,
282
+ records,
283
+ )
284
+ const model = buildTimeline(parsed)
285
+ const report = auditSession(parsed)
286
+ const value: SessionSummary = {
287
+ header,
288
+ ...(entry.title !== undefined ? { title: entry.title } : model.title !== undefined ? { title: model.title } : {}),
289
+ ...(entry.live !== undefined ? { live: entry.live } : {}),
290
+ summary: {
291
+ events: model.eventCount,
292
+ turns: model.turns.length,
293
+ steps: model.turns.reduce((sum, turn) => sum + turn.steps.length, 0),
294
+ toolCalls: model.toolCalls.length,
295
+ toolErrors: model.toolCalls.filter(call => call.error !== undefined).length,
296
+ durationMs: model.durationMs,
297
+ models: model.models,
298
+ tokens: model.totals,
299
+ modelTotals: model.modelTotals,
300
+ findings: report.findings.length,
301
+ critical: report.counts.critical,
302
+ serious: report.counts.serious,
303
+ warning: report.counts.warning,
304
+ info: report.counts.info,
305
+ },
306
+ }
307
+ summaryCache.set(id, { key, at: Date.now(), value })
308
+ // Bound the cache; dashboard reads only a page of recent sessions.
309
+ while (summaryCache.size > 100) {
310
+ const oldest = summaryCache.keys().next().value
311
+ if (oldest === undefined) break
312
+ summaryCache.delete(oldest)
313
+ }
314
+ return value
315
+ } catch (error) {
316
+ return {
317
+ header: entry.header,
318
+ ...(entry.title !== undefined ? { title: entry.title } : {}),
319
+ ...(entry.live !== undefined ? { live: entry.live } : {}),
320
+ error: String(error),
321
+ }
322
+ }
323
+ }
324
+
81
325
  const handler = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
326
+ if (!allowed(req, allowRemote)) {
327
+ json(res, 403, { error: 'forbidden' })
328
+ return
329
+ }
82
330
  const url = new URL(req.url ?? '/', 'http://localhost')
83
331
  const rest = url.pathname.slice(prefix.length).replace(/^\//u, '')
332
+ const head = req.method === 'HEAD'
84
333
  try {
85
334
  if (req.method !== 'GET' && req.method !== 'HEAD') {
86
- json(res, 405, { error: 'method not allowed' })
335
+ json(res, 405, { error: 'method not allowed' }, head)
87
336
  return
88
337
  }
89
338
  if (rest === 'sessions') {
90
- const records = await sessionQuery.listSessions()
91
- const headers = records.map(headerOf).filter((h): h is Record<string, unknown> => h !== undefined)
92
- const titles = new Map<string, string>()
93
- if (sessionQuery.readTitleSnapshots !== undefined) {
94
- try {
95
- const snapshots = await sessionQuery.readTitleSnapshots(headers.map(h => String(h['id'])))
96
- snapshots.forEach((snapshot, i) => {
97
- const title = titleOf(snapshot)
98
- const id = headers[i]?.['id']
99
- if (title !== undefined && typeof id === 'string') titles.set(id, title)
100
- })
101
- } catch {
102
- /* titles are decoration — never fail the listing over them */
103
- }
104
- }
105
- // Per-id fallback for engines where the batch call misses.
106
- const readTitle = (sessionQuery as { readTitle?: (id: string) => Promise<unknown> }).readTitle
107
- if (readTitle !== undefined) {
108
- await Promise.all(
109
- headers
110
- .filter(h => !titles.has(String(h['id'])))
111
- .map(async h => {
112
- try {
113
- const title = titleOf(await readTitle.call(sessionQuery, String(h['id'])))
114
- if (title !== undefined) titles.set(String(h['id']), title)
115
- } catch {
116
- /* decoration only */
117
- }
118
- }),
119
- )
120
- }
121
- json(res, 200, {
122
- sessions: headers.map(h => ({ header: h, title: titles.get(String(h['id'])) })),
123
- })
339
+ const { entries } = await loadEntries()
340
+ json(res, 200, { sessions: entries }, head)
341
+ return
342
+ }
343
+ if (rest === 'summary') {
344
+ const limitParam = Number(url.searchParams.get('limit'))
345
+ const limit = Number.isFinite(limitParam) && limitParam > 0
346
+ ? Math.min(Math.floor(limitParam), summaryLimit)
347
+ : summaryLimit
348
+ const { entries } = await loadEntries({ withTitles: false })
349
+ const selected = entries.slice(0, limit)
350
+ const summaries = await mapLimit(selected, 4, summarize)
351
+ json(res, 200, { sessions: summaries, total: entries.length }, head)
124
352
  return
125
353
  }
126
354
  if (rest === 'viewer.js') {
127
355
  // The standalone viewer bundle (sibling artifact of this module),
128
356
  // inlined into exported HTML files by the browser half.
129
- const source = await readFile(new URL('./viewer.js', import.meta.url), 'utf8')
130
- res.writeHead(200, { 'content-type': 'text/javascript; charset=utf-8' })
131
- res.end(source)
357
+ let source = viewerCache.get('viewer')
358
+ if (source === undefined) {
359
+ source = await readFile(new URL('./viewer.js', import.meta.url), 'utf8')
360
+ viewerCache.set('viewer', source)
361
+ }
362
+ res.writeHead(200, {
363
+ 'content-type': 'text/javascript; charset=utf-8',
364
+ 'cache-control': 'no-cache',
365
+ 'x-content-type-options': 'nosniff',
366
+ })
367
+ res.end(head ? undefined : source)
132
368
  return
133
369
  }
134
370
  if (rest.startsWith('session/')) {
135
371
  const id = decodeURIComponent(rest.slice('session/'.length))
136
372
  const snapshot = await sessionQuery.readSession(id)
137
373
  const header = headerOf(snapshot) ?? headerOf(get(snapshot, 'session'))
138
- const events =
139
- get(snapshot, 'events') ?? get(get(snapshot, 'session'), 'events') ?? []
140
374
  if (header === undefined) {
141
- json(res, 404, { error: 'session not found' })
375
+ json(res, 404, { error: 'session not found' }, head)
142
376
  return
143
377
  }
144
- json(res, 200, { header, events })
378
+ json(res, 200, { header, events: eventsOf(snapshot) }, head)
145
379
  return
146
380
  }
147
- json(res, 404, { error: 'unknown replay endpoint' })
381
+ json(res, 404, { error: 'unknown replay endpoint' }, head)
148
382
  } catch (error) {
149
- json(res, 500, { error: String(error) })
383
+ // Do not leak filesystem paths or provider details to the browser.
384
+ console.error('[dsh-replay] request failed', error)
385
+ json(res, 500, { error: 'internal error' }, head)
150
386
  }
151
387
  }
152
388