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.
- package/LICENSE +19 -0
- package/README.md +27 -0
- package/cordis.patch.yml +29 -0
- package/lib/index.js +3377 -0
- package/lib/invariant.js +26 -0
- package/lib/types/bridge-url.d.ts +27 -0
- package/lib/types/browser-context.d.ts +38 -0
- package/lib/types/dsh-gateway.d.ts +42 -0
- package/lib/types/event-generation.d.ts +56 -0
- package/lib/types/extension-sessions.d.ts +26 -0
- package/lib/types/host-api.d.ts +47 -0
- package/lib/types/image-relay.d.ts +43 -0
- package/lib/types/index.d.ts +158 -0
- package/lib/types/invariant.d.ts +16 -0
- package/lib/types/remote-host-api.d.ts +12 -0
- package/lib/types/server.d.ts +166 -0
- package/lib/types/session-deferral.d.ts +33 -0
- package/lib/types/session-history.d.ts +30 -0
- package/lib/types/session-purge.d.ts +55 -0
- package/lib/types/session-workspace.d.ts +37 -0
- package/lib/types/token.d.ts +57 -0
- package/lib/types/tools.d.ts +42 -0
- package/lib/types/vision-selfcheck.d.ts +18 -0
- package/lib/types/vision.d.ts +57 -0
- package/package.json +95 -0
- package/src/bridge-url.ts +57 -0
- package/src/browser-context.ts +102 -0
- package/src/dsh-gateway.ts +66 -0
- package/src/event-generation.ts +385 -0
- package/src/extension-sessions.ts +40 -0
- package/src/host-api.ts +64 -0
- package/src/image-relay.ts +118 -0
- package/src/index.ts +575 -0
- package/src/invariant.ts +33 -0
- package/src/remote-host-api.ts +397 -0
- package/src/server.ts +658 -0
- package/src/session-deferral.ts +296 -0
- package/src/session-history.ts +220 -0
- package/src/session-purge.ts +154 -0
- package/src/session-workspace.ts +147 -0
- package/src/token.ts +100 -0
- package/src/tools.ts +301 -0
- package/src/vision-selfcheck.ts +35 -0
- 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
|
+
}
|