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,397 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh 0.2 Host adapter: unary calls through TypertGateway, plus the Session
|
|
3
|
+
* and forwarded-event plumbing assembled from the gateway, history, and
|
|
4
|
+
* event-generation modules.
|
|
5
|
+
*
|
|
6
|
+
* @module dsh-browser-application/src/remote-host-api
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { RespondResult } from '@dsh-browser/protocol'
|
|
10
|
+
import type {
|
|
11
|
+
BrowserHostApi,
|
|
12
|
+
HostEventFrame,
|
|
13
|
+
HostRpcCall,
|
|
14
|
+
HostRpcFailure,
|
|
15
|
+
HostRpcResult,
|
|
16
|
+
} from './host-api.ts'
|
|
17
|
+
import { hostFailure, isRecord } from './host-api.ts'
|
|
18
|
+
import { ExtensionSessionRegistry } from './extension-sessions.ts'
|
|
19
|
+
import { openWireStream, type TypertGatewayLike, type HostConnectionLike } from './dsh-gateway.ts'
|
|
20
|
+
import {
|
|
21
|
+
oneShotSessionSnapshot,
|
|
22
|
+
historyValue,
|
|
23
|
+
historyPageValue,
|
|
24
|
+
optionalNonNegativeInteger,
|
|
25
|
+
optionalPositiveInteger,
|
|
26
|
+
} from './session-history.ts'
|
|
27
|
+
import { EventGeneration, type RemoteEventOutcome } from './event-generation.ts'
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
interface InvokeTarget {
|
|
31
|
+
readonly namespace: string
|
|
32
|
+
readonly method: string
|
|
33
|
+
readonly args: Readonly<Record<string, unknown>>
|
|
34
|
+
readonly adapt?: (value: unknown) => unknown
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
/** Build the dsh 0.2 Host implementation. */
|
|
39
|
+
export function createRemoteHostApi(
|
|
40
|
+
gateway: TypertGatewayLike,
|
|
41
|
+
connection: HostConnectionLike,
|
|
42
|
+
): BrowserHostApi {
|
|
43
|
+
return new RemoteHostApi(gateway, connection)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
class RemoteHostApi implements BrowserHostApi {
|
|
47
|
+
private readonly fetchHandler: ReturnType<HostConnectionLike['createSharedFetchHandler']>
|
|
48
|
+
private readonly extensionSessions = new ExtensionSessionRegistry()
|
|
49
|
+
/** Last known session/follow tip per Session; drives session/page throughSeq. */
|
|
50
|
+
private readonly historyCursors = new Map<string, number>()
|
|
51
|
+
private activeEvents: EventGeneration | undefined
|
|
52
|
+
|
|
53
|
+
constructor(
|
|
54
|
+
private readonly gateway: TypertGatewayLike,
|
|
55
|
+
connection: HostConnectionLike,
|
|
56
|
+
) {
|
|
57
|
+
this.fetchHandler = connection.createSharedFetchHandler('/api')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async call(call: HostRpcCall): Promise<HostRpcResult> {
|
|
61
|
+
if (call.method === 'session.history') return this.sessionHistory(call)
|
|
62
|
+
if (call.method === 'session.models') return this.sessionModels(call)
|
|
63
|
+
if (call.method === 'workspace.list') return this.workspaceList(call)
|
|
64
|
+
|
|
65
|
+
const target = invokeTarget(call)
|
|
66
|
+
if ('error' in target) return { ok: false, error: target.error }
|
|
67
|
+
|
|
68
|
+
try {
|
|
69
|
+
// Deferred Sessions have no history call with which to establish the
|
|
70
|
+
// follower. Open it after materialization and before prompt admission,
|
|
71
|
+
// so the first user/turn events cannot race past the extension.
|
|
72
|
+
if (call.method === 'session.prompt') {
|
|
73
|
+
const sessionId = sessionIdOf(call.payload)
|
|
74
|
+
if (sessionId !== undefined) await this.activeEvents?.ensureSessionFollow(sessionId, call.signal)
|
|
75
|
+
}
|
|
76
|
+
const value = await this.gateway.invoke({
|
|
77
|
+
namespace: target.namespace,
|
|
78
|
+
method: target.method,
|
|
79
|
+
args: target.args,
|
|
80
|
+
signal: call.signal,
|
|
81
|
+
})
|
|
82
|
+
// Only claim ownership after a successful create/prompt. A failed prompt
|
|
83
|
+
// against a Desktop session must not steal later ask_user_question away
|
|
84
|
+
// from the native waterfall.
|
|
85
|
+
if (call.method === 'session.create' || call.method === 'session.prompt') {
|
|
86
|
+
this.extensionSessions.note(sessionIdOf(call.payload))
|
|
87
|
+
this.extensionSessions.note(sessionIdOf(value))
|
|
88
|
+
if (typeof value === 'string') this.extensionSessions.note(value)
|
|
89
|
+
}
|
|
90
|
+
return { ok: true, value: target.adapt?.(value) ?? value }
|
|
91
|
+
} catch (error: unknown) {
|
|
92
|
+
return { ok: false, error: this.failure(error) }
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async *events(signal: AbortSignal): AsyncIterable<HostEventFrame> {
|
|
97
|
+
const generation = new EventGeneration(
|
|
98
|
+
this.gateway,
|
|
99
|
+
this.sendRemoteEventResult.bind(this),
|
|
100
|
+
this.extensionSessions,
|
|
101
|
+
this.noteHistoryCursor.bind(this),
|
|
102
|
+
signal,
|
|
103
|
+
)
|
|
104
|
+
const previous = this.activeEvents
|
|
105
|
+
this.activeEvents = generation
|
|
106
|
+
await previous?.dispose()
|
|
107
|
+
generation.start()
|
|
108
|
+
try {
|
|
109
|
+
yield * generation.events()
|
|
110
|
+
} finally {
|
|
111
|
+
if (this.activeEvents === generation) this.activeEvents = undefined
|
|
112
|
+
await generation.dispose()
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
async respond(rpcId: string, result: RespondResult, signal: AbortSignal): Promise<unknown> {
|
|
117
|
+
const generation = this.activeEvents
|
|
118
|
+
if (generation === undefined) return { accepted: false, reason: 'not-pending' }
|
|
119
|
+
return generation.respond(rpcId, result, signal)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private async sessionHistory(call: HostRpcCall): Promise<HostRpcResult> {
|
|
123
|
+
const sessionId = sessionIdOf(call.payload)
|
|
124
|
+
if (sessionId === undefined) return badRequest('session.history requires a non-empty sessionId')
|
|
125
|
+
let beforeSeq: number | undefined
|
|
126
|
+
let maxMessages: number | undefined
|
|
127
|
+
try {
|
|
128
|
+
beforeSeq = optionalNonNegativeInteger(call.payload, 'beforeSeq')
|
|
129
|
+
maxMessages = optionalPositiveInteger(call.payload, 'maxMessages')
|
|
130
|
+
} catch (error: unknown) {
|
|
131
|
+
return badRequest(error instanceof Error ? error.message : 'session.history pagination is invalid')
|
|
132
|
+
}
|
|
133
|
+
try {
|
|
134
|
+
if (beforeSeq !== undefined) {
|
|
135
|
+
const throughSeq = await this.historyThroughSeq(sessionId, call.signal)
|
|
136
|
+
const page = await this.gateway.invoke({
|
|
137
|
+
namespace: 'session',
|
|
138
|
+
method: 'page',
|
|
139
|
+
args: {
|
|
140
|
+
request: {
|
|
141
|
+
address: { kind: 'session', sessionId },
|
|
142
|
+
throughSeq,
|
|
143
|
+
beforeSeq,
|
|
144
|
+
...(maxMessages === undefined ? {} : { maxMessages }),
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
signal: call.signal,
|
|
148
|
+
})
|
|
149
|
+
return { ok: true, value: historyPageValue(page) }
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const snapshot = this.activeEvents === undefined
|
|
153
|
+
? await oneShotSessionSnapshot(this.gateway, sessionId, call.signal, maxMessages)
|
|
154
|
+
: await this.activeEvents.openSessionHistory(sessionId, call.signal, maxMessages)
|
|
155
|
+
this.noteHistoryCursor(sessionId, snapshot.cursor)
|
|
156
|
+
return { ok: true, value: historyValue(snapshot) }
|
|
157
|
+
} catch (error: unknown) {
|
|
158
|
+
return { ok: false, error: this.failure(error) }
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Combine the deployment catalog with the Session's durable next selection. */
|
|
163
|
+
private async sessionModels(call: HostRpcCall): Promise<HostRpcResult> {
|
|
164
|
+
if (!isRecord(call.payload)) return badRequest('session.models payload must be an object')
|
|
165
|
+
const sessionId = sessionIdOf(call.payload)
|
|
166
|
+
try {
|
|
167
|
+
const catalog = await this.gateway.invoke({
|
|
168
|
+
namespace: 'session', method: 'modelCatalog', args: {}, signal: call.signal,
|
|
169
|
+
})
|
|
170
|
+
// Provisional Sessions request only the catalog and overlay their pending choice.
|
|
171
|
+
const projections = sessionId === undefined ? undefined : await this.gateway.invoke({
|
|
172
|
+
namespace: 'session', method: 'projections', args: { request: { sessionId } }, signal: call.signal,
|
|
173
|
+
})
|
|
174
|
+
const values = isRecord(projections) ? projections.values : undefined
|
|
175
|
+
const modelSelection = isRecord(values) ? values.modelSelection : undefined
|
|
176
|
+
const next = isRecord(modelSelection) ? modelSelectionOf(modelSelection.next) : undefined
|
|
177
|
+
return { ok: true, value: adaptModelCatalog(catalog, next) }
|
|
178
|
+
} catch (error: unknown) {
|
|
179
|
+
return { ok: false, error: this.failure(error) }
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Resolve a Host-legal throughSeq for older history pages.
|
|
185
|
+
* Never invent Number.MAX_SAFE_INTEGER — session/page rejects tips past the log cursor.
|
|
186
|
+
*/
|
|
187
|
+
private async historyThroughSeq(sessionId: string, signal: AbortSignal): Promise<number> {
|
|
188
|
+
const cached = this.historyCursors.get(sessionId)
|
|
189
|
+
if (cached !== undefined) return cached
|
|
190
|
+
const snapshot = this.activeEvents === undefined
|
|
191
|
+
? await oneShotSessionSnapshot(this.gateway, sessionId, signal)
|
|
192
|
+
: await this.activeEvents.openSessionHistory(sessionId, signal)
|
|
193
|
+
this.noteHistoryCursor(sessionId, snapshot.cursor)
|
|
194
|
+
const throughSeq = this.historyCursors.get(sessionId)
|
|
195
|
+
if (throughSeq === undefined) {
|
|
196
|
+
throw new TypeError('session/follow snapshot did not provide a usable history cursor')
|
|
197
|
+
}
|
|
198
|
+
return throughSeq
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
private noteHistoryCursor(sessionId: string, cursor: number): void {
|
|
202
|
+
// Host session/page refuses throughSeq past the durable tip; MAX_SAFE_INTEGER is
|
|
203
|
+
// only a UI sentinel elsewhere and must never be forwarded as a page tip.
|
|
204
|
+
if (!Number.isSafeInteger(cursor) || cursor < -1 || cursor === Number.MAX_SAFE_INTEGER) return
|
|
205
|
+
const previous = this.historyCursors.get(sessionId)
|
|
206
|
+
if (previous === undefined || cursor > previous) this.historyCursors.set(sessionId, cursor)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
private async workspaceList(call: HostRpcCall): Promise<HostRpcResult> {
|
|
210
|
+
try {
|
|
211
|
+
const controller = new AbortController()
|
|
212
|
+
const signal = AbortSignal.any([call.signal, controller.signal])
|
|
213
|
+
const source = await openWireStream(this.gateway, 'workspace/follow', { args: {} }, signal)
|
|
214
|
+
const iterator = source[Symbol.asyncIterator]()
|
|
215
|
+
try {
|
|
216
|
+
const first = await iterator.next()
|
|
217
|
+
if (first.done || !isWorkspaceBaseline(first.value)) {
|
|
218
|
+
throw new TypeError('workspace/follow did not begin with a baseline')
|
|
219
|
+
}
|
|
220
|
+
return { ok: true, value: first.value.value }
|
|
221
|
+
} finally {
|
|
222
|
+
controller.abort(new Error('workspace baseline received'))
|
|
223
|
+
await iterator.return?.()
|
|
224
|
+
}
|
|
225
|
+
} catch (error: unknown) {
|
|
226
|
+
return { ok: false, error: this.failure(error) }
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
private failure(error: unknown): HostRpcFailure {
|
|
231
|
+
try {
|
|
232
|
+
return this.gateway.wireStream.failure(error)
|
|
233
|
+
} catch {
|
|
234
|
+
return hostFailure(error)
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
private async sendRemoteEventResult(
|
|
239
|
+
clientId: string,
|
|
240
|
+
eventId: string,
|
|
241
|
+
outcome: RemoteEventOutcome,
|
|
242
|
+
signal: AbortSignal,
|
|
243
|
+
): Promise<void> {
|
|
244
|
+
const rpcId = crypto.randomUUID()
|
|
245
|
+
const request = new Request('http://dsh.internal/api/$events/result', {
|
|
246
|
+
method: 'POST',
|
|
247
|
+
headers: { 'content-type': 'application/json' },
|
|
248
|
+
body: JSON.stringify({
|
|
249
|
+
type: 'client-request',
|
|
250
|
+
rpcId,
|
|
251
|
+
method: '$events/result',
|
|
252
|
+
payload: { args: { clientId, eventId, outcome } },
|
|
253
|
+
}),
|
|
254
|
+
signal,
|
|
255
|
+
})
|
|
256
|
+
const response = await this.fetchHandler.fetch(request)
|
|
257
|
+
if (!response.ok) {
|
|
258
|
+
throw new Error(`$events/result transport failed with HTTP ${String(response.status)}: ${await response.text()}`)
|
|
259
|
+
}
|
|
260
|
+
const envelope = await response.json() as unknown
|
|
261
|
+
if (!isRecord(envelope) || envelope.type !== 'server-response' || envelope.rpcId !== rpcId
|
|
262
|
+
|| !isRecord(envelope.result) || typeof envelope.result.ok !== 'boolean') {
|
|
263
|
+
throw new TypeError('$events/result returned an invalid server-response')
|
|
264
|
+
}
|
|
265
|
+
if (envelope.result.ok) return
|
|
266
|
+
const error = isRecord(envelope.result.error) ? envelope.result.error : {}
|
|
267
|
+
const failure = new Error(typeof error.message === 'string' ? error.message : '$events/result was rejected') as Error & {
|
|
268
|
+
code?: string
|
|
269
|
+
details?: unknown
|
|
270
|
+
}
|
|
271
|
+
if (typeof error.code === 'string') failure.code = error.code
|
|
272
|
+
if (error.details !== undefined) failure.details = error.details
|
|
273
|
+
throw failure
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
function invokeTarget(call: HostRpcCall): InvokeTarget | { readonly error: HostRpcFailure } {
|
|
279
|
+
if (!isRecord(call.payload)) return { error: badRequestFailure(`${call.method} payload must be an object`) }
|
|
280
|
+
switch (call.method) {
|
|
281
|
+
case 'session.list':
|
|
282
|
+
return { namespace: 'session', method: 'list', args: { _request: call.payload } }
|
|
283
|
+
case 'session.create':
|
|
284
|
+
case 'session.selectModel':
|
|
285
|
+
case 'session.attachment':
|
|
286
|
+
case 'session.cancel':
|
|
287
|
+
case 'workspace.create':
|
|
288
|
+
case 'workspace.rename':
|
|
289
|
+
case 'workspace.archiveSession': {
|
|
290
|
+
const [namespace, method] = call.method.split('.') as [string, string]
|
|
291
|
+
return { namespace, method, args: { request: call.payload } }
|
|
292
|
+
}
|
|
293
|
+
case 'session.prompt':
|
|
294
|
+
return {
|
|
295
|
+
namespace: 'session',
|
|
296
|
+
method: 'prompt',
|
|
297
|
+
args: { request: { requestId: call.rpcId, ...call.payload } },
|
|
298
|
+
}
|
|
299
|
+
case 'settings.describe':
|
|
300
|
+
return { namespace: 'settings', method: 'describe', args: {} }
|
|
301
|
+
case 'settings.mutate':
|
|
302
|
+
return { namespace: 'settings', method: 'mutate', args: call.payload }
|
|
303
|
+
case 'credentials.describe':
|
|
304
|
+
return {
|
|
305
|
+
namespace: 'credentials',
|
|
306
|
+
method: 'describe',
|
|
307
|
+
args: call.payload,
|
|
308
|
+
adapt: value => ({ credentials: value }),
|
|
309
|
+
}
|
|
310
|
+
case 'credentials.set':
|
|
311
|
+
case 'credentials.unset': {
|
|
312
|
+
const method = call.method.slice('credentials.'.length)
|
|
313
|
+
return { namespace: 'credentials', method, args: call.payload, adapt: () => ({}) }
|
|
314
|
+
}
|
|
315
|
+
case 'llm.discoverModels': {
|
|
316
|
+
const { settingsNs, ...request } = call.payload
|
|
317
|
+
if (typeof settingsNs !== 'string' || settingsNs.length === 0) {
|
|
318
|
+
return { error: badRequestFailure('llm.discoverModels requires settingsNs') }
|
|
319
|
+
}
|
|
320
|
+
return {
|
|
321
|
+
namespace: 'llm',
|
|
322
|
+
method: 'discoverModels',
|
|
323
|
+
args: { settingsNs, request },
|
|
324
|
+
adapt: value => ({ models: value }),
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
default:
|
|
328
|
+
return {
|
|
329
|
+
error: {
|
|
330
|
+
code: 'not-found',
|
|
331
|
+
message: `browser bridge Host method ${JSON.stringify(call.method)} is unavailable`,
|
|
332
|
+
details: {},
|
|
333
|
+
},
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
function sessionIdOf(payload: unknown): string | undefined {
|
|
340
|
+
if (!isRecord(payload)) return undefined
|
|
341
|
+
return typeof payload.sessionId === 'string' && payload.sessionId.length > 0
|
|
342
|
+
? payload.sessionId
|
|
343
|
+
: undefined
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Map Host ModelCatalog into the extension's session.models directory shape. */
|
|
347
|
+
function adaptModelCatalog(value: unknown, next?: ReturnType<typeof modelSelectionOf>): unknown {
|
|
348
|
+
if (!isRecord(value)) return value
|
|
349
|
+
const selection = next ?? modelSelectionOf(value.default)
|
|
350
|
+
const groups = Array.isArray(value.groups) ? value.groups : []
|
|
351
|
+
const failures = Array.isArray(value.failures) ? value.failures : []
|
|
352
|
+
const routableProviders = Array.isArray(value.routableProviders)
|
|
353
|
+
? value.routableProviders.filter((entry): entry is string => typeof entry === 'string')
|
|
354
|
+
: []
|
|
355
|
+
const current = selection ?? { provider: 'none', model: 'none' }
|
|
356
|
+
return {
|
|
357
|
+
current,
|
|
358
|
+
routable: selection !== undefined && routableProviders.includes(selection.provider),
|
|
359
|
+
groups,
|
|
360
|
+
failures,
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function modelSelectionOf(value: unknown): {
|
|
365
|
+
provider: string
|
|
366
|
+
model: string
|
|
367
|
+
reasoningEffort?: string
|
|
368
|
+
} | undefined {
|
|
369
|
+
if (!isRecord(value)) return undefined
|
|
370
|
+
const provider = typeof value.provider === 'string' ? value.provider.trim() : ''
|
|
371
|
+
const model = typeof value.model === 'string' ? value.model.trim() : ''
|
|
372
|
+
if (provider === '' || model === '') return undefined
|
|
373
|
+
const reasoningEffort = typeof value.reasoningEffort === 'string' && value.reasoningEffort.trim() !== ''
|
|
374
|
+
? value.reasoningEffort.trim()
|
|
375
|
+
: undefined
|
|
376
|
+
return {
|
|
377
|
+
provider,
|
|
378
|
+
model,
|
|
379
|
+
...(reasoningEffort === undefined ? {} : { reasoningEffort }),
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function badRequest(message: string): HostRpcResult {
|
|
384
|
+
return { ok: false, error: badRequestFailure(message) }
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function badRequestFailure(message: string): HostRpcFailure {
|
|
388
|
+
return { code: 'bad-request', message, details: {} }
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function isWorkspaceBaseline(value: unknown): value is {
|
|
392
|
+
readonly type: 'baseline'
|
|
393
|
+
readonly value: Record<string, unknown>
|
|
394
|
+
} {
|
|
395
|
+
return isRecord(value) && value.type === 'baseline' && isRecord(value.value)
|
|
396
|
+
}
|
|
397
|
+
|