dsh-browser-application 0.37.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.
Files changed (44) hide show
  1. package/LICENSE +19 -0
  2. package/README.md +27 -0
  3. package/cordis.patch.yml +29 -0
  4. package/lib/index.js +3377 -0
  5. package/lib/invariant.js +26 -0
  6. package/lib/types/bridge-url.d.ts +27 -0
  7. package/lib/types/browser-context.d.ts +38 -0
  8. package/lib/types/dsh-gateway.d.ts +42 -0
  9. package/lib/types/event-generation.d.ts +56 -0
  10. package/lib/types/extension-sessions.d.ts +26 -0
  11. package/lib/types/host-api.d.ts +47 -0
  12. package/lib/types/image-relay.d.ts +43 -0
  13. package/lib/types/index.d.ts +158 -0
  14. package/lib/types/invariant.d.ts +16 -0
  15. package/lib/types/remote-host-api.d.ts +12 -0
  16. package/lib/types/server.d.ts +166 -0
  17. package/lib/types/session-deferral.d.ts +33 -0
  18. package/lib/types/session-history.d.ts +30 -0
  19. package/lib/types/session-purge.d.ts +55 -0
  20. package/lib/types/session-workspace.d.ts +37 -0
  21. package/lib/types/token.d.ts +57 -0
  22. package/lib/types/tools.d.ts +42 -0
  23. package/lib/types/vision-selfcheck.d.ts +18 -0
  24. package/lib/types/vision.d.ts +57 -0
  25. package/package.json +95 -0
  26. package/src/bridge-url.ts +57 -0
  27. package/src/browser-context.ts +102 -0
  28. package/src/dsh-gateway.ts +66 -0
  29. package/src/event-generation.ts +385 -0
  30. package/src/extension-sessions.ts +40 -0
  31. package/src/host-api.ts +64 -0
  32. package/src/image-relay.ts +118 -0
  33. package/src/index.ts +575 -0
  34. package/src/invariant.ts +33 -0
  35. package/src/remote-host-api.ts +397 -0
  36. package/src/server.ts +658 -0
  37. package/src/session-deferral.ts +296 -0
  38. package/src/session-history.ts +220 -0
  39. package/src/session-purge.ts +154 -0
  40. package/src/session-workspace.ts +147 -0
  41. package/src/token.ts +100 -0
  42. package/src/tools.ts +301 -0
  43. package/src/vision-selfcheck.ts +35 -0
  44. package/src/vision.ts +135 -0
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Defer real session creation until the first prompt.
3
+ *
4
+ * The panel calls `session.create` as soon as it connects, but a session that
5
+ * is opened and never used should leave zero trace in the store/GUI. This
6
+ * wrapper answers `session.create` with a provisional id (minted locally,
7
+ * nothing persisted), serves `session.history` for provisional ids as empty,
8
+ * and materializes the real session — same id, original create payload — on
9
+ * the first `session.prompt` for that id. Abandoned provisional ids are
10
+ * pruned after {@link PROVISIONAL_TTL_MS}.
11
+ *
12
+ * Provisional sessions also answer `session.models` from the host-wide
13
+ * `session.modelCatalog` (via the Host API adapter, plus a pending switch)
14
+ * and remember `session.selectModel` until materialization, so the composer can
15
+ * show a model switcher before the first message.
16
+ *
17
+ * @module dsh-browser-application/src/session-deferral
18
+ */
19
+
20
+ import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment'
21
+ import type { BrowserHostApi, HostRpcCall, HostRpcResult } from './host-api.ts'
22
+ import { isRecord } from './host-api.ts'
23
+
24
+ /** Provisional entries older than this are dropped on the next create. */
25
+ const PROVISIONAL_TTL_MS = 30 * 60_000
26
+
27
+ interface ModelSelection {
28
+ provider: string
29
+ model: string
30
+ reasoningEffort?: string
31
+ }
32
+
33
+ interface ProvisionalEntry {
34
+ /** The original create payload, replayed at materialization (keeps cwd/workspaceId). */
35
+ payload: Record<string, unknown>
36
+ createdAt: number
37
+ /** Keep failed model selections retryable after the Host Session was created. */
38
+ materialized?: boolean
39
+ /** Composer switch chosen before the session exists on the Host. */
40
+ selection?: ModelSelection
41
+ }
42
+
43
+ /**
44
+ * Wrap the gateway sessions API so `session.create` returns a provisional id
45
+ * without creating anything; the real session materializes on the first
46
+ * `session.prompt` for that id.
47
+ *
48
+ * @param api - Gateway API implementation.
49
+ * @param enabled - Whether deferral is active; false returns the API untouched.
50
+ * @param imageLimits - actual host image capability, used for the synthetic
51
+ * empty history before the deferred Session exists.
52
+ * @returns the original API when disabled, otherwise the wrapped API.
53
+ */
54
+ export function withSessionDeferral(
55
+ api: BrowserHostApi,
56
+ enabled: boolean,
57
+ imageLimits?: ImageAttachmentLimits,
58
+ ): BrowserHostApi {
59
+ if (!enabled) return api
60
+
61
+ const provisional = new Map<string, ProvisionalEntry>()
62
+ const materializing = new Map<string, Promise<HostRpcResult>>()
63
+
64
+ const prune = (): void => {
65
+ const cutoff = Date.now() - PROVISIONAL_TTL_MS
66
+ for (const [id, entry] of provisional) {
67
+ if (entry.createdAt < cutoff && !entry.materialized && !materializing.has(id)) provisional.delete(id)
68
+ }
69
+ }
70
+
71
+ const mintedId = (payload: Record<string, unknown>): string =>
72
+ typeof payload.sessionId === 'string' ? payload.sessionId : `session-${crypto.randomUUID()}`
73
+
74
+ async function materialize(sessionId: string, entry: ProvisionalEntry, signal: AbortSignal): Promise<HostRpcResult> {
75
+ if (!entry.materialized) {
76
+ const created = await api.call({
77
+ rpcId: crypto.randomUUID(),
78
+ method: 'session.create',
79
+ payload: { ...entry.payload, sessionId },
80
+ signal,
81
+ })
82
+ if (!created.ok) return created
83
+ entry.materialized = true
84
+ }
85
+ // All prompts share this barrier, including those arriving during selection.
86
+ // A changed choice must also succeed before any queued prompt is admitted.
87
+ while (entry.selection !== undefined) {
88
+ const selection = entry.selection
89
+ const selected = await api.call({
90
+ rpcId: crypto.randomUUID(),
91
+ method: 'session.selectModel',
92
+ payload: { sessionId, ...selection },
93
+ signal,
94
+ })
95
+ if (!selected.ok) return selected
96
+ if (entry.selection === selection) delete entry.selection
97
+ }
98
+ provisional.delete(sessionId)
99
+ return { ok: true, value: { sessionId } }
100
+ }
101
+
102
+ return {
103
+ async call(call: HostRpcCall): Promise<HostRpcResult> {
104
+ if (call.method === 'session.create') {
105
+ if (!isRecord(call.payload)) {
106
+ return { ok: false, error: { code: 'bad-request', message: 'session.create payload must be an object', details: {} } }
107
+ }
108
+ prune()
109
+ const sessionId = mintedId(call.payload)
110
+ provisional.set(sessionId, { payload: { ...call.payload }, createdAt: Date.now() })
111
+ return { ok: true, value: { sessionId } }
112
+ }
113
+ if (call.method === 'session.history') {
114
+ const sessionId = sessionIdOf(call.payload)
115
+ if (sessionId === undefined || !provisional.has(sessionId)
116
+ || provisional.get(sessionId)!.materialized) return api.call(call)
117
+ return {
118
+ ok: true,
119
+ value: {
120
+ events: [],
121
+ hasMore: false,
122
+ ...(imageLimits === undefined
123
+ ? {}
124
+ : { projections: { asOfSeq: -1, values: { imageLimits } } }),
125
+ },
126
+ }
127
+ }
128
+ if (call.method === 'session.models') {
129
+ const sessionId = sessionIdOf(call.payload)
130
+ if (sessionId === undefined || !provisional.has(sessionId)) return api.call(call)
131
+ return provisionalModels(api, provisional.get(sessionId)!, call.signal)
132
+ }
133
+ if (call.method === 'session.selectModel') {
134
+ const sessionId = sessionIdOf(call.payload)
135
+ if (sessionId === undefined || !provisional.has(sessionId)) return api.call(call)
136
+ const entry = provisional.get(sessionId)!
137
+ const selected = selectionOf(call.payload)
138
+ if (selected === undefined) {
139
+ return {
140
+ ok: false,
141
+ error: {
142
+ code: 'bad-request',
143
+ message: 'session.selectModel requires provider and model',
144
+ details: {},
145
+ },
146
+ }
147
+ }
148
+ entry.selection = selected
149
+ return { ok: true, value: { selected: { ...selected } } }
150
+ }
151
+ if (call.method !== 'session.prompt') return api.call(call)
152
+ const sessionId = sessionIdOf(call.payload)
153
+ if (sessionId === undefined) return api.call(call)
154
+ const entry = provisional.get(sessionId)
155
+ if (entry === undefined) return api.call(call)
156
+ const existing = materializing.get(sessionId)
157
+ const pending = existing ?? materialize(sessionId, entry, call.signal)
158
+ if (existing === undefined) {
159
+ materializing.set(sessionId, pending)
160
+ void pending.then(
161
+ () => { materializing.delete(sessionId) },
162
+ () => { materializing.delete(sessionId) },
163
+ )
164
+ }
165
+ const created = await pending
166
+ if (!created.ok) return created
167
+ return api.call(call)
168
+ },
169
+ events: signal => api.events(signal),
170
+ respond: (rpcId, result, signal) => api.respond(rpcId, result, signal),
171
+ }
172
+ }
173
+
174
+ function sessionIdOf(payload: unknown): string | undefined {
175
+ if (!isRecord(payload)) return undefined
176
+ return typeof payload.sessionId === 'string' ? payload.sessionId : undefined
177
+ }
178
+
179
+ function selectionOf(payload: unknown): ModelSelection | undefined {
180
+ if (!isRecord(payload)) return undefined
181
+ const provider = typeof payload.provider === 'string' ? payload.provider.trim() : ''
182
+ const model = typeof payload.model === 'string' ? payload.model.trim() : ''
183
+ if (provider === '' || model === '') return undefined
184
+ const reasoningEffort = typeof payload.reasoningEffort === 'string' && payload.reasoningEffort.trim() !== ''
185
+ ? payload.reasoningEffort.trim()
186
+ : undefined
187
+ return {
188
+ provider,
189
+ model,
190
+ ...(reasoningEffort === undefined ? {} : { reasoningEffort }),
191
+ }
192
+ }
193
+
194
+ /** Build a session.models-shaped answer from the host catalog for a provisional id. */
195
+ async function provisionalModels(
196
+ api: BrowserHostApi,
197
+ entry: ProvisionalEntry,
198
+ signal: AbortSignal,
199
+ ): Promise<HostRpcResult> {
200
+ // Inner API (not the deferral wrapper): session.models → session/modelCatalog.
201
+ const catalog = await api.call({
202
+ rpcId: crypto.randomUUID(),
203
+ method: 'session.models',
204
+ payload: {},
205
+ signal,
206
+ })
207
+ if (!catalog.ok) return catalog
208
+ const groups = isRecord(catalog.value) && Array.isArray(catalog.value.groups)
209
+ ? catalog.value.groups
210
+ : []
211
+ const failures = isRecord(catalog.value) && Array.isArray(catalog.value.failures)
212
+ ? catalog.value.failures
213
+ : []
214
+ const catalogCurrent = isRecord(catalog.value) ? modelSelectionOf(catalog.value.current) : undefined
215
+ const current = entry.selection
216
+ ?? catalogCurrent
217
+ ?? await defaultSelection(api, signal)
218
+ ?? firstCatalogSelection(groups)
219
+ if (current === undefined) {
220
+ return {
221
+ ok: true,
222
+ value: {
223
+ current: { provider: 'none', model: 'none' },
224
+ routable: false,
225
+ groups,
226
+ failures,
227
+ },
228
+ }
229
+ }
230
+ return {
231
+ ok: true,
232
+ value: {
233
+ current: { ...current },
234
+ routable: true,
235
+ groups,
236
+ failures,
237
+ },
238
+ }
239
+ }
240
+
241
+ function modelSelectionOf(value: unknown): ModelSelection | undefined {
242
+ if (!isRecord(value)) return undefined
243
+ const provider = typeof value.provider === 'string' ? value.provider.trim() : ''
244
+ const model = typeof value.model === 'string' ? value.model.trim() : ''
245
+ if (provider === '' || model === '') return undefined
246
+ const reasoningEffort = typeof value.reasoningEffort === 'string' && value.reasoningEffort.trim() !== ''
247
+ ? value.reasoningEffort.trim()
248
+ : undefined
249
+ return {
250
+ provider,
251
+ model,
252
+ ...(reasoningEffort === undefined ? {} : { reasoningEffort }),
253
+ }
254
+ }
255
+
256
+ async function defaultSelection(
257
+ api: BrowserHostApi,
258
+ signal: AbortSignal,
259
+ ): Promise<ModelSelection | undefined> {
260
+ const described = await api.call({
261
+ rpcId: crypto.randomUUID(),
262
+ method: 'settings.describe',
263
+ payload: {},
264
+ signal,
265
+ })
266
+ if (!described.ok || !isRecord(described.value) || !Array.isArray(described.value.namespaces)) {
267
+ return undefined
268
+ }
269
+ const defaults = described.value.namespaces.find((candidate) => (
270
+ isRecord(candidate) && candidate.ns === 'agent-default-model'
271
+ ))
272
+ const value = isRecord(defaults) && isRecord(defaults.value) ? defaults.value : undefined
273
+ if (value === undefined) return undefined
274
+ const provider = typeof value.provider === 'string' ? value.provider.trim() : ''
275
+ const model = typeof value.model === 'string' ? value.model.trim() : ''
276
+ if (provider === '' || model === '') return undefined
277
+ const reasoningEffort = typeof value.reasoningEffort === 'string' && value.reasoningEffort.trim() !== ''
278
+ ? value.reasoningEffort.trim()
279
+ : undefined
280
+ return {
281
+ provider,
282
+ model,
283
+ ...(reasoningEffort === undefined ? {} : { reasoningEffort }),
284
+ }
285
+ }
286
+
287
+ function firstCatalogSelection(groups: unknown[]): ModelSelection | undefined {
288
+ for (const group of groups) {
289
+ if (!isRecord(group) || typeof group.id !== 'string' || !Array.isArray(group.models)) continue
290
+ for (const model of group.models) {
291
+ if (!isRecord(model) || typeof model.id !== 'string' || model.id.trim() === '') continue
292
+ return { provider: group.id, model: model.id }
293
+ }
294
+ }
295
+ return undefined
296
+ }
@@ -0,0 +1,220 @@
1
+ /**
2
+ * Session history decoding: the `session/follow` baseline, its records, and the
3
+ * compact chunk-row expansion older logs still use.
4
+ *
5
+ * @module dsh-browser-application/src/session-history
6
+ */
7
+
8
+ import { openWireStream, type TypertGatewayLike } from './dsh-gateway.ts'
9
+ import { isRecord } from './host-api.ts'
10
+
11
+ export interface SessionSnapshot {
12
+ /** Inclusive Host log tip from session/follow; required for later session/page calls. */
13
+ readonly cursor: number
14
+ readonly records: readonly unknown[]
15
+ readonly hasMore: boolean
16
+ readonly projections?: unknown
17
+ readonly assistantStream?: unknown
18
+ readonly snapshotId?: string
19
+ }
20
+
21
+ export async function oneShotSessionSnapshot(
22
+ gateway: TypertGatewayLike,
23
+ sessionId: string,
24
+ outerSignal: AbortSignal,
25
+ maxMessages?: number,
26
+ ): Promise<SessionSnapshot> {
27
+ const controller = new AbortController()
28
+ const signal = AbortSignal.any([outerSignal, controller.signal])
29
+ const source = await openWireStream(gateway,
30
+ 'session/follow',
31
+ {
32
+ args: {
33
+ request: {
34
+ address: { kind: 'session', sessionId },
35
+ assistantStream: true,
36
+ ...(maxMessages === undefined ? {} : { maxMessages }),
37
+ },
38
+ },
39
+ },
40
+ signal,
41
+ )
42
+ const iterator = source[Symbol.asyncIterator]()
43
+ try {
44
+ const first = await iterator.next()
45
+ if (first.done || !isSessionSnapshot(first.value)) {
46
+ throw new TypeError('session/follow did not begin with a snapshot')
47
+ }
48
+ return {
49
+ cursor: first.value.cursor,
50
+ records: first.value.records,
51
+ hasMore: first.value.hasMore,
52
+ ...(first.value.projections === undefined ? {} : { projections: first.value.projections }),
53
+ ...(first.value.assistantStream === undefined ? {} : { assistantStream: first.value.assistantStream }),
54
+ }
55
+ } finally {
56
+ controller.abort(new Error('Session snapshot received'))
57
+ await iterator.return?.()
58
+ }
59
+ }
60
+
61
+ export function historyValue(snapshot: SessionSnapshot): Record<string, unknown> {
62
+ return {
63
+ // V3 records remain durable events with embedded Assistant streams. Keep
64
+ // the legacy chunk-row decoder for older logs/Hosts, without assigning
65
+ // synthetic durable seqs to the new process-local assistant stream.
66
+ events: snapshot.records.flatMap(historyRecordEvents).map(event => ({ event })),
67
+ hasMore: snapshot.hasMore,
68
+ ...(snapshot.projections === undefined ? {} : { projections: snapshot.projections }),
69
+ ...(snapshot.assistantStream === undefined ? {} : { assistantStream: snapshot.assistantStream }),
70
+ ...(snapshot.snapshotId === undefined ? {} : { snapshotId: snapshot.snapshotId }),
71
+ }
72
+ }
73
+
74
+ export function historyPageValue(page: unknown): Record<string, unknown> {
75
+ if (!isRecord(page) || !Array.isArray(page.records) || typeof page.hasMore !== 'boolean') {
76
+ throw new TypeError('session/page returned an invalid history page')
77
+ }
78
+ return historyValue({
79
+ cursor: -1,
80
+ records: page.records,
81
+ hasMore: page.hasMore,
82
+ ...(page.projections === undefined ? {} : { projections: page.projections }),
83
+ })
84
+ }
85
+
86
+ export function optionalNonNegativeInteger(
87
+ payload: unknown,
88
+ key: string,
89
+ ): number | undefined {
90
+ if (!isRecord(payload) || !(key in payload) || payload[key] === undefined) return undefined
91
+ const value = payload[key]
92
+ if (!Number.isSafeInteger(value) || (value as number) < 0 || Object.is(value, -0)) {
93
+ throw new TypeError(`${key} must be a non-negative safe integer`)
94
+ }
95
+ return value as number
96
+ }
97
+
98
+ export function optionalPositiveInteger(
99
+ payload: unknown,
100
+ key: string,
101
+ ): number | undefined {
102
+ if (!isRecord(payload) || !(key in payload) || payload[key] === undefined) return undefined
103
+ const value = payload[key]
104
+ if (!Number.isSafeInteger(value) || (value as number) < 1) {
105
+ throw new TypeError(`${key} must be a positive safe integer`)
106
+ }
107
+ return value as number
108
+ }
109
+
110
+ function historyRecordEvents(record: unknown): Record<string, unknown>[] {
111
+ if (!isRecord(record)
112
+ || (record.type !== 'event' && record.type !== 'chunks')
113
+ || !isRecord(record.event)) {
114
+ throw new TypeError('session history carried an invalid record')
115
+ }
116
+ const event = record.event
117
+ if (!isChunkRowEvent(event)) {
118
+ if (record.type === 'chunks') {
119
+ throw new TypeError('session history chunks record carried a non-chunk event')
120
+ }
121
+ return [event]
122
+ }
123
+
124
+ const data = event.data
125
+ const members = event.type === 'chunkrow/tool-call-chunks' ? data.args : data.texts
126
+ const deltas = data.dt
127
+ if (!Array.isArray(members) || members.length === 0 || members.some(member => typeof member !== 'string')
128
+ || !Array.isArray(deltas) || deltas.length !== members.length - 1
129
+ || deltas.some(delta => !Number.isSafeInteger(delta))) {
130
+ throw new TypeError(`${event.type} carried an invalid compact run`)
131
+ }
132
+ if (members.length - 1 > Number.MAX_SAFE_INTEGER - event.seq) {
133
+ throw new TypeError(`${event.type} sequence range is unsafe`)
134
+ }
135
+
136
+ const events: Record<string, unknown>[] = []
137
+ let time = event.time
138
+ for (let index = 0; index < members.length; index += 1) {
139
+ if (index > 0) time += deltas[index - 1] as number
140
+ if (!Number.isSafeInteger(time)) throw new TypeError(`${event.type} timestamp range is unsafe`)
141
+ const chunk = compactChunk(event.type, data, members[index] as string)
142
+ events.push({
143
+ type: 'assistant/chunk',
144
+ seq: event.seq + index,
145
+ time,
146
+ data: { turn: data.turn, step: data.step, chunk },
147
+ })
148
+ }
149
+ return events
150
+ }
151
+
152
+ type ChunkRowEvent = {
153
+ readonly type: 'chunkrow/text-chunks' | 'chunkrow/reasoning-chunks' | 'chunkrow/tool-call-chunks'
154
+ readonly seq: number
155
+ readonly time: number
156
+ readonly data: Record<string, unknown> & {
157
+ readonly turn: number
158
+ readonly step: number
159
+ readonly index: number
160
+ readonly dt: readonly unknown[]
161
+ readonly texts?: readonly unknown[]
162
+ readonly args?: readonly unknown[]
163
+ }
164
+ }
165
+
166
+ function isChunkRowEvent(event: Record<string, unknown>): event is ChunkRowEvent {
167
+ if (event.type !== 'chunkrow/text-chunks'
168
+ && event.type !== 'chunkrow/reasoning-chunks'
169
+ && event.type !== 'chunkrow/tool-call-chunks') return false
170
+ if (!Number.isSafeInteger(event.seq) || (event.seq as number) < 0 || !Number.isSafeInteger(event.time)
171
+ || !isRecord(event.data)) {
172
+ throw new TypeError(`${String(event.type)} carried an invalid compact envelope`)
173
+ }
174
+ const data = event.data
175
+ if (typeof data.turn !== 'number' || typeof data.step !== 'number' || typeof data.index !== 'number') {
176
+ throw new TypeError(`${String(event.type)} carried invalid compact coordinates`)
177
+ }
178
+ if (event.type === 'chunkrow/tool-call-chunks'
179
+ && (typeof data.id !== 'string' || (data.name !== undefined && typeof data.name !== 'string'))) {
180
+ throw new TypeError(`${event.type} carried an invalid tool identity`)
181
+ }
182
+ return true
183
+ }
184
+
185
+ function compactChunk(
186
+ type: ChunkRowEvent['type'],
187
+ data: ChunkRowEvent['data'],
188
+ member: string,
189
+ ): Record<string, unknown> {
190
+ if (type === 'chunkrow/text-chunks') {
191
+ return { type: 'text-delta', index: data.index, text: member }
192
+ }
193
+ if (type === 'chunkrow/reasoning-chunks') {
194
+ return { type: 'reasoning-delta', index: data.index, text: member }
195
+ }
196
+ return {
197
+ type: 'tool-call-delta',
198
+ index: data.index,
199
+ id: data.id,
200
+ ...(data.name === undefined ? {} : { name: data.name }),
201
+ argumentsDelta: member,
202
+ }
203
+ }
204
+
205
+ export function isSessionSnapshot(value: unknown): value is {
206
+ readonly type: 'snapshot'
207
+ readonly cursor: number
208
+ readonly records: readonly unknown[]
209
+ readonly hasMore: boolean
210
+ readonly projections?: unknown
211
+ readonly assistantStream?: unknown
212
+ } {
213
+ return isRecord(value)
214
+ && value.type === 'snapshot'
215
+ && Number.isSafeInteger(value.cursor)
216
+ && (value.cursor as number) >= -1
217
+ && (value.cursor as number) !== Number.MAX_SAFE_INTEGER
218
+ && Array.isArray(value.records)
219
+ && typeof value.hasMore === 'boolean'
220
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * File-level removal of one session's durable storage under the dsh home.
3
+ *
4
+ * The gateway exposes no session.delete, so the bridge performs the removal
5
+ * itself: this module archives the session under exclusive write ownership,
6
+ * then removes its durable data while retaining the kernel lock's pathname.
7
+ * Session ids are validated against the persisted shape, only data within
8
+ * exact-name directories two levels below the sessions root is removed, and
9
+ * running sessions are refused before anything touches the disk.
10
+ *
11
+ * @module dsh-browser-application/src/session-purge
12
+ */
13
+
14
+ import { lstat, readdir, rm } from 'node:fs/promises'
15
+ import path from 'node:path'
16
+
17
+ /** Stable failure codes surfaced to the panel. Open set: callers must tolerate growth. */
18
+ export type SessionPurgeErrorCode = 'not-found' | 'running' | 'invalid-id' | 'internal'
19
+
20
+ /** Error thrown by {@link purgeSessionFiles}; the server turns it into a wire error. */
21
+ export class SessionPurgeError extends Error {
22
+ constructor(
23
+ readonly code: SessionPurgeErrorCode,
24
+ message: string,
25
+ options?: ErrorOptions,
26
+ ) {
27
+ super(message, options)
28
+ this.name = 'SessionPurgeError'
29
+ }
30
+ }
31
+
32
+ /** Persisted session ids are `session-` plus one lowercase UUID. */
33
+ const SESSION_ID_PATTERN = /^session-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/u
34
+ /** POSIX flock is attached to this inode; unlinking it defeats exclusion. */
35
+ const SESSION_LOCK_FILENAME = 'session.lock'
36
+
37
+ /** Dependencies purging needs from the plugin. */
38
+ export interface SessionPurgeDeps {
39
+ /** The dsh sessions root (`dshHomePath('sessions')`). */
40
+ sessionsRoot: string
41
+ /** Session ids currently running; purging any of these is refused. */
42
+ runningSessionIds: ReadonlySet<string>
43
+ /**
44
+ * Claim the runtime's exclusive write ownership (including its kernel lock).
45
+ * The returned handle must remain held until removal finishes. Opening a
46
+ * read handle, checking for a lock file, or checking only Agent status does
47
+ * not provide exclusion: idle Agents and other processes can own the log.
48
+ */
49
+ acquireOwnership(sessionId: string): Promise<{ close(): Promise<void> }>
50
+ /** Archive while the durable session still exists and exclusive ownership is held. */
51
+ archiveSession(sessionId: string): Promise<void>
52
+ }
53
+
54
+ /**
55
+ * Validate one session id against the persisted shape. Rejects everything
56
+ * that could escape the sessions root (separators, dot segments) before any
57
+ * filesystem call sees it.
58
+ * @param sessionId - untrusted id from the panel.
59
+ * @returns the id when well-formed.
60
+ * @throws SessionPurgeError with code `invalid-id` otherwise.
61
+ */
62
+ export function assertPurgeableSessionId(sessionId: string): string {
63
+ if (!SESSION_ID_PATTERN.test(sessionId)) {
64
+ throw new SessionPurgeError('invalid-id', `session id "${sessionId}" does not match the persisted shape`)
65
+ }
66
+ return sessionId
67
+ }
68
+
69
+ /**
70
+ * Permanently delete a session's data, keeping its directory and lock inode.
71
+ * The runtime refuses ambiguous duplicate session identities across workspaces.
72
+ * @param deps - root and running-set inputs.
73
+ * @param sessionId - validated session id.
74
+ * @returns nothing; throws {@link SessionPurgeError} on refusal or failure.
75
+ */
76
+ export async function purgeSessionFiles(deps: SessionPurgeDeps, sessionId: string): Promise<void> {
77
+ assertPurgeableSessionId(sessionId)
78
+ if (deps.runningSessionIds.has(sessionId)) {
79
+ throw new SessionPurgeError('running', 'refusing to purge a running session; cancel it first')
80
+ }
81
+
82
+ let workspaces: string[]
83
+ try {
84
+ workspaces = await readdir(deps.sessionsRoot, { withFileTypes: true })
85
+ .then((entries) => entries.filter((entry) => entry.isDirectory()).map((entry) => entry.name))
86
+ } catch (error: unknown) {
87
+ throw new SessionPurgeError('internal', `could not read the sessions root "${deps.sessionsRoot}": ${String(error)}`)
88
+ }
89
+
90
+ const targets: string[] = []
91
+ for (const workspace of workspaces) {
92
+ // path.join is safe here: the id pattern above excludes separators and
93
+ // dot segments, so the joined segment cannot escape the workspace dir.
94
+ const candidate = path.join(deps.sessionsRoot, workspace, sessionId)
95
+ try {
96
+ if (!(await lstat(candidate)).isDirectory()) continue
97
+ const entries = await readdir(candidate)
98
+ if (entries.some(entry => entry !== SESSION_LOCK_FILENAME)) targets.push(candidate)
99
+ } catch (error: unknown) {
100
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
101
+ throw new SessionPurgeError('internal', `could not inspect "${candidate}": ${String(error)}`, { cause: error })
102
+ }
103
+ }
104
+ }
105
+
106
+ if (targets.length === 0) {
107
+ throw new SessionPurgeError('not-found', `no durable storage found for session "${sessionId}"`)
108
+ }
109
+ let ownership: { close(): Promise<void> }
110
+ try {
111
+ ownership = await deps.acquireOwnership(sessionId)
112
+ } catch (error: unknown) {
113
+ // Match the public error identity across independently loaded runtime
114
+ // copies without depending on a private JSONL lock implementation.
115
+ if (error instanceof Error && error.name === 'SessionAlreadyOwnedError') {
116
+ throw new SessionPurgeError(
117
+ 'running',
118
+ 'session is still owned by a runtime; release the session or restart that runtime, then retry deletion',
119
+ )
120
+ }
121
+ throw new SessionPurgeError('internal', `could not acquire exclusive session ownership: ${String(error)}`)
122
+ }
123
+ let failure: unknown
124
+ let archived = false
125
+ try {
126
+ // The public archive API checks existence. Calling it after deletion
127
+ // succeeds only accidentally when its header cache already knows this id.
128
+ await deps.archiveSession(sessionId)
129
+ archived = true
130
+ for (const target of targets) {
131
+ if (!(await lstat(target)).isDirectory()) throw new Error(`session directory changed: ${target}`)
132
+ // Re-scan after write-open: opening an old log may materialize V3.
133
+ for (const entry of await readdir(target)) {
134
+ if (entry === SESSION_LOCK_FILENAME) continue
135
+ await rm(path.join(target, entry), { recursive: true, force: true })
136
+ }
137
+ }
138
+ } catch (error: unknown) {
139
+ failure = new SessionPurgeError('internal', archived
140
+ ? `session was archived, but durable cleanup failed: ${String(error)}`
141
+ : `could not archive session; durable data was preserved: ${String(error)}`, { cause: error })
142
+ } finally {
143
+ try {
144
+ await ownership.close()
145
+ } catch (error: unknown) {
146
+ failure = failure === undefined
147
+ ? new SessionPurgeError('internal', `session was archived and cleared, but ownership release failed: ${String(error)}`, { cause: error })
148
+ : new SessionPurgeError('internal', `${String(failure)}; ownership release also failed: ${String(error)}`, {
149
+ cause: new AggregateError([failure, error], 'session purge and ownership release failed'),
150
+ })
151
+ }
152
+ }
153
+ if (failure !== undefined) throw failure
154
+ }