mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
609 lines
26 KiB
TypeScript
609 lines
26 KiB
TypeScript
/**
|
|
* Host-side ApiProxy implementation. Signature discipline: unary takes the
|
|
* narrow RpcRequest<P> and echoes request.rpcId on the RpcResponse<T>.
|
|
*/
|
|
|
|
import { homedir } from 'node:os'
|
|
import { dirname } from 'node:path'
|
|
import type { Context } from '@deepseek-ai/cordis'
|
|
import type { ModelSelection } from '@deepseek-ai/dsh-agent'
|
|
import type {} from '@deepseek-ai/dsh-agent-presets/types'
|
|
import type { SessionId } from '@deepseek-ai/dsh-session'
|
|
import { isUserInvocable } from '@deepseek-ai/dsh-skill'
|
|
import {
|
|
InvalidPresetIdError, PresetExistsError,
|
|
PresetNotWritableError, UnknownPresetError,
|
|
} from '@deepseek-ai/dsh-agent-presets'
|
|
import type {
|
|
ApiProxy, ConfigurableProviderView, CredentialView,
|
|
SettingsNamespaceView,
|
|
} from './api/index.ts'
|
|
import { buildModelCatalog } from '@deepseek-ai/dsh-api-session-controller'
|
|
import { SessionQueryError } from '@deepseek-ai/dsh-session-query'
|
|
import {
|
|
DEFAULT_SESSION_LOG_COMPRESSION_LEVEL,
|
|
flushLiveSessionLog,
|
|
sessionLogExportDeps,
|
|
sessionLogZipFilename,
|
|
streamSessionLogZip,
|
|
type SessionLogExportReady,
|
|
type SessionLogCompressionLevel,
|
|
} from './session-export.ts'
|
|
import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
|
|
// Type-only edges: resolve the command-change stream and `ctx.get('skills')`.
|
|
import type {} from '@deepseek-ai/dsh-commands'
|
|
import type {} from '@deepseek-ai/dsh-skill'
|
|
// The settings/credentials seams: brand guards run at this wire boundary; the
|
|
// service reads stay optional (`ctx.get`) so a composition without either
|
|
// provider still serves every other domain.
|
|
import { SettingsConflictError, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
import type { SettingsDescriptor, SettingsNamespace, SettingsPathOp } from '@deepseek-ai/dsh-settings'
|
|
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
import type { ScopeKey } from '@deepseek-ai/dsh-scope'
|
|
import type { RpcError, RpcRequest, RpcResponse } from './api/rpc.ts'
|
|
import { canOpenNativePath, openNativePath, openNativeTextFile } from './native-path-opener.ts'
|
|
|
|
/** Read live abort state across awaits without treating it as synchronously immutable. */
|
|
function isAborted(signal: AbortSignal): boolean {
|
|
return signal.aborted
|
|
}
|
|
|
|
/** Wrap an ok result echoing the request's rpcId. */
|
|
function ok<T>(request: RpcRequest<unknown>, value: T): RpcResponse<T> {
|
|
return { rpcId: request.rpcId, result: { ok: true, value } }
|
|
}
|
|
|
|
/** Wrap an error result echoing the request's rpcId. */
|
|
function err<T>(request: RpcRequest<unknown>, error: RpcError): RpcResponse<T> {
|
|
return { rpcId: request.rpcId, result: { ok: false, error } }
|
|
}
|
|
|
|
/** Deployment metadata and Host integrations consumed by the API implementation. */
|
|
export interface ApiProxyDefaults {
|
|
/** Current deployment model selection reported by `host.describe`. */
|
|
defaultModelSelection: () => ModelSelection
|
|
/** Project hint reported by `host.describe`; must match Session Controller's default cwd. */
|
|
cwd: string
|
|
/** Native open-with-default-application; injectable for carrier tests. */
|
|
openPath?: (path: string, signal: AbortSignal) => Promise<void>
|
|
/** Native text-editor handoff; injectable for settings-document tests. */
|
|
openTextFile?: (path: string, signal: AbortSignal) => Promise<void>
|
|
/** Validated DEFLATE level for session-log ZIP entries; defaults to 6. */
|
|
sessionExportCompressionLevel?: SessionLogCompressionLevel
|
|
/**
|
|
* Whether handing a path to the native opener can work at all — the
|
|
* `hasDocument` capability the preset roster reports, and the switch
|
|
* between opening a preset directory and answering its path as text.
|
|
* Absent, an injected `openPath` counts as openable and everything else
|
|
* falls back to platform detection ({@link canOpenNativePath}).
|
|
*/
|
|
canOpenPath?: () => boolean
|
|
}
|
|
|
|
/** The roster is absent: this deployment composes no agent presets at all. */
|
|
function noRoster(agentPreset: string): RpcError {
|
|
return {
|
|
code: 'agent-preset-not-found',
|
|
message: 'this deployment composes no agent presets',
|
|
details: { agentPreset, available: [] },
|
|
}
|
|
}
|
|
|
|
/** Map one authoring/roster failure onto its wire code. */
|
|
function presetError(agentPreset: string, error: unknown): RpcError {
|
|
if (error instanceof UnknownPresetError) {
|
|
return {
|
|
code: 'agent-preset-not-found',
|
|
message: error.message,
|
|
details: { agentPreset: error.presetId, available: [...error.available] },
|
|
}
|
|
}
|
|
if (error instanceof PresetNotWritableError) {
|
|
return { code: 'agent-preset-read-only', message: error.message, details: { agentPreset, reason: error.message } }
|
|
}
|
|
if (error instanceof InvalidPresetIdError || error instanceof PresetExistsError) {
|
|
return { code: 'agent-preset-invalid', message: error.message, details: { agentPreset, reason: error.message } }
|
|
}
|
|
return { code: 'internal', message: `agent preset "${agentPreset}": ${String(error)}`, details: {} }
|
|
}
|
|
|
|
/**
|
|
* Implement ApiProxy over a composed host context.
|
|
* @param ctx - a context with the Host spine mounted.
|
|
* @param defaults - host routing and project-directory defaults.
|
|
* @returns the ApiProxy implementation.
|
|
*/
|
|
export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiProxy {
|
|
const sessionExportCompressionLevel = defaults.sessionExportCompressionLevel
|
|
?? DEFAULT_SESSION_LOG_COMPRESSION_LEVEL
|
|
/** Resolve a Session's live or standing preset scope without resuming it. */
|
|
async function sessionScopeFor(
|
|
sessionId: SessionId,
|
|
agentPreset: string | undefined,
|
|
): Promise<ScopeKey | undefined> {
|
|
const live = ctx.get('agents')?.get(sessionId)
|
|
if (live !== undefined) return live
|
|
const presets = ctx.get('agentPresets')
|
|
if (presets === undefined) return undefined
|
|
try {
|
|
return await presets.standingKeyFor(agentPreset)
|
|
} catch {
|
|
// An unknown or unusable recorded preset falls back to the global registry.
|
|
return undefined
|
|
}
|
|
}
|
|
|
|
/** Missing-service report shared by the settings domain (skills-domain stance). */
|
|
function settingsAbsent(): RpcError {
|
|
return { code: 'internal', message: 'settings service is absent: this deployment does not mount a settings provider (e.g. @deepseek-ai/dsh-settings-file) in its composition', details: {} }
|
|
}
|
|
|
|
/** Open one Host-resolved target and map native failures onto the wire vocabulary. */
|
|
async function openTarget(
|
|
request: RpcRequest<unknown>, path: string, signal: AbortSignal,
|
|
open: (path: string, signal: AbortSignal) => Promise<void>,
|
|
): Promise<RpcResponse<{ opened: true }>> {
|
|
try {
|
|
await open(path, signal)
|
|
return ok(request, { opened: true as const })
|
|
} catch (error: unknown) {
|
|
if (signal.aborted) {
|
|
return err(request, {
|
|
code: 'cancelled',
|
|
message: 'path open was aborted',
|
|
details: {},
|
|
})
|
|
}
|
|
return err(request, {
|
|
code: 'internal',
|
|
message: `path open failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
details: {},
|
|
})
|
|
}
|
|
}
|
|
|
|
/** Open one Host-resolved path with its default application. */
|
|
function openPath(
|
|
request: RpcRequest<unknown>, path: string, signal: AbortSignal,
|
|
): Promise<RpcResponse<{ opened: true }>> {
|
|
const open = defaults.openPath
|
|
?? ((target: string, openSignal: AbortSignal) => openNativePath(target, openSignal))
|
|
return openTarget(request, path, signal, open)
|
|
}
|
|
|
|
/** Open one Host-resolved text document in a native editor. */
|
|
function openTextFile(
|
|
request: RpcRequest<unknown>, path: string, signal: AbortSignal,
|
|
): Promise<RpcResponse<{ opened: true }>> {
|
|
const open = defaults.openTextFile
|
|
?? ((target: string, openSignal: AbortSignal) => openNativeTextFile(target, openSignal))
|
|
return openTarget(request, path, signal, open)
|
|
}
|
|
|
|
/** Whether this deployment can hand a path to a native opener at all. */
|
|
function canOpenPaths(): boolean {
|
|
if (defaults.canOpenPath !== undefined) return defaults.canOpenPath()
|
|
// An injected opener is by definition usable; otherwise ask the platform.
|
|
return defaults.openPath !== undefined || canOpenNativePath()
|
|
}
|
|
|
|
/** Missing-service report shared by the credentials domain. */
|
|
function credentialsAbsent(): RpcError {
|
|
return { code: 'internal', message: 'credentials service is absent: this deployment does not mount a credential provider (e.g. @deepseek-ai/dsh-credentials-local) in its composition', details: {} }
|
|
}
|
|
|
|
/** Map one redacted settings descriptor to its wire view. */
|
|
function namespaceView(descriptor: SettingsDescriptor): SettingsNamespaceView {
|
|
return {
|
|
ns: String(descriptor.ns),
|
|
schema: descriptor.schema,
|
|
value: descriptor.value,
|
|
...descriptor.base === undefined ? {} : { base: descriptor.base },
|
|
...descriptor.user === undefined ? {} : { user: descriptor.user },
|
|
applies: descriptor.applies,
|
|
secrets: (descriptor.secrets ?? []).map(secret => ({ path: [...secret.path], set: secret.set })),
|
|
revision: descriptor.revision,
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Run one settings write (merge or wholesale replace) and acknowledge with
|
|
* the namespace's new redacted view. Every seam refusal — unknown or invalid
|
|
* namespace, read-only provider, schema validation, storage — becomes one
|
|
* `settings-rejected` carrying the seam's own message.
|
|
*/
|
|
async function settingsWrite(
|
|
request: RpcRequest<unknown>,
|
|
ns: string,
|
|
mode: 'update' | 'replace' | 'mutate',
|
|
section: object,
|
|
expectedRevision?: number,
|
|
): Promise<RpcResponse<SettingsNamespaceView>> {
|
|
const settings = ctx.get('settings')
|
|
if (settings === undefined) return err(request, settingsAbsent())
|
|
const rejected = (error: unknown): RpcResponse<SettingsNamespaceView> => {
|
|
// A stale writer is its own outcome, not a malformed request: the client
|
|
// must re-read and re-apply rather than treat the write as invalid.
|
|
if (error instanceof SettingsConflictError) {
|
|
return err(request, {
|
|
code: 'settings-conflict',
|
|
message: error.message,
|
|
details: { ns, expected: error.expected, actual: error.actual },
|
|
})
|
|
}
|
|
return err(request, {
|
|
code: 'settings-rejected',
|
|
message: error instanceof Error ? error.message : String(error),
|
|
details: { ns },
|
|
})
|
|
}
|
|
let branded: SettingsNamespace
|
|
try {
|
|
branded = settingsNamespace(ns)
|
|
} catch (error: unknown) {
|
|
// A malformed name can address no registration, so it fails exactly as
|
|
// an unregistered one does.
|
|
return rejected(error)
|
|
}
|
|
try {
|
|
if (mode === 'update') await settings.update(branded, section, expectedRevision)
|
|
else if (mode === 'replace') await settings.replace(branded, section, expectedRevision)
|
|
else await settings.mutate(branded, section as SettingsPathOp[], expectedRevision)
|
|
} catch (error: unknown) {
|
|
return rejected(error)
|
|
}
|
|
const descriptor = settings.describe({ redactSecrets: true }).find(candidate => candidate.ns === branded)
|
|
if (descriptor === undefined) {
|
|
// The write committed but the namespace vanished before this read: only
|
|
// a concurrent registrant disposal can produce it.
|
|
return err(request, { code: 'internal', message: `settings namespace "${ns}" was disposed after the ${mode}`, details: {} })
|
|
}
|
|
return ok(request, namespaceView(descriptor))
|
|
}
|
|
|
|
return {
|
|
host: {
|
|
describe(request) {
|
|
// TODO(apiproxy-version): read the version from apps/cli/package.json.
|
|
const selection = defaults.defaultModelSelection()
|
|
return Promise.resolve(ok(request, {
|
|
version: '0.0.1',
|
|
// This must match the default cwd supplied to Session Controller so
|
|
// the UI's project hint names where a cwd-less create request lands.
|
|
cwd: defaults.cwd,
|
|
// Read live for the same reason: this is what the NEXT session will
|
|
// start from, so a saved default has to be what it reports.
|
|
provider: selection.provider,
|
|
model: selection.model,
|
|
attachedSessions: ctx.agents.list().length,
|
|
home: homedir(),
|
|
canOpenPath: canOpenPaths(),
|
|
}))
|
|
},
|
|
|
|
async openPath(request, signal) {
|
|
return openPath(request, request.payload.path, signal)
|
|
},
|
|
},
|
|
|
|
agentPresets: {
|
|
// Only the desktop opener remains here: the roster, selection, and
|
|
// authoring calls are the AgentPresets service's own Remote namespace.
|
|
async openDocument(request, signal) {
|
|
const { agentPreset } = request.payload
|
|
const presets = ctx.get('agentPresets')
|
|
if (presets === undefined) return err(request, noRoster(agentPreset))
|
|
try {
|
|
const preset = await presets.resolve(agentPreset)
|
|
// Same line as copy/remove draw: the shipped install is not the
|
|
// user's to manage, and pointing an editor into it invites edits an
|
|
// upgrade will silently overwrite.
|
|
if (preset.trust !== 'user') {
|
|
throw new PresetNotWritableError(preset.id, 'it ships with the deployment')
|
|
}
|
|
// The id resolved against the Host's own roots is what selects the
|
|
// directory — no browser payload carries a path in either direction
|
|
// unless the deployment has no opener to hand it to.
|
|
const directory = dirname(preset.path)
|
|
if (!canOpenPaths()) return ok(request, { opened: false as const, path: directory })
|
|
return await openPath(request, directory, signal)
|
|
} catch (error: unknown) {
|
|
return err(request, presetError(agentPreset, error))
|
|
}
|
|
},
|
|
},
|
|
|
|
skills: {
|
|
// Skill lookup never creates or resumes an agent: the session address
|
|
// resolves to a canonical cwd from the host-resident session header, and
|
|
// the view scope is the live agent or the preset's standing key.
|
|
async list(request) {
|
|
const { sessionId } = request.payload
|
|
let cwd: string | undefined
|
|
let agentPreset: string | undefined
|
|
try {
|
|
using observation = await ctx.sessionQuery.observeSession(sessionId)
|
|
if (observation.projections === undefined) {
|
|
throw new Error('skill catalog requires a projected Session observation')
|
|
}
|
|
cwd = observation.header.cwd
|
|
agentPreset = observation.projections.values.agentPreset ?? undefined
|
|
} catch (error: unknown) {
|
|
if (error instanceof SessionQueryError
|
|
&& error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') {
|
|
return err(request, {
|
|
code: 'session-not-found',
|
|
message: `session "${sessionId}" not found`,
|
|
details: { sessionId },
|
|
})
|
|
}
|
|
return err(request, {
|
|
code: 'internal',
|
|
message: `session "${sessionId}" could not be inspected: ${String(error)}`,
|
|
details: {},
|
|
})
|
|
}
|
|
if (cwd === undefined) {
|
|
// Every served session records its project at create time; a
|
|
// cwd-less header is a pre-project legacy log (not served).
|
|
return err(request, { code: 'internal', message: `session "${sessionId}" has no project cwd`, details: {} })
|
|
}
|
|
// The host registry is layered per scope and serves every session. A
|
|
// composition may still realm-mount its own registry instead; that
|
|
// instance is invisible to host contexts, so address it through the
|
|
// live agent (`agents.get` keeps the no-side-effect stance above).
|
|
const live = ctx.agents.get(sessionId)
|
|
const presets = ctx.get('agentPresets')
|
|
const scoped = live === undefined ? undefined : presets?.serviceFor(live, 'skills')
|
|
// A missing service means no composition mounts dsh-skill, not an
|
|
// empty catalog. `ctx.get` also
|
|
// keeps this handler independent of the gateway plugin's inject list
|
|
// (an undeclared `ctx.skills` property read fails the reflect proxy).
|
|
const skillRegistry = scoped ?? ctx.get('skills')
|
|
if (skillRegistry === undefined) {
|
|
return err(request, { code: 'internal', message: 'skill registry is absent: neither this session\'s agent preset nor the host composition mounts @deepseek-ai/dsh-skill', details: {} })
|
|
}
|
|
// Resolve the live or recorded preset scope so the catalog matches the
|
|
// Session composition without resuming its Agent.
|
|
const scope = await sessionScopeFor(sessionId, agentPreset)
|
|
try {
|
|
const skills = (await skillRegistry.list({ cwd, scope })).filter(isUserInvocable)
|
|
return ok(request, {
|
|
skills: skills.map(skill => ({
|
|
name: skill.name,
|
|
description: skill.description,
|
|
...skill.whenToUse === undefined ? {} : { whenToUse: skill.whenToUse },
|
|
modelInvocable: skill.invocation.modelInvocable,
|
|
})),
|
|
})
|
|
} catch (error: unknown) {
|
|
return err(request, { code: 'internal', message: `skill listing failed: ${String(error)}`, details: {} })
|
|
}
|
|
},
|
|
},
|
|
|
|
settings: {
|
|
describe(request) {
|
|
const settings = ctx.get('settings')
|
|
if (settings === undefined) return Promise.resolve(err(request, settingsAbsent()))
|
|
return Promise.resolve(ok(request, {
|
|
writable: settings.writable,
|
|
hasDocument: settings.documentPath !== undefined,
|
|
namespaces: settings.describe({ redactSecrets: true }).map(namespaceView),
|
|
}))
|
|
},
|
|
async openDocument(request, signal) {
|
|
const settings = ctx.get('settings')
|
|
if (settings === undefined) return err(request, settingsAbsent())
|
|
if (isAborted(signal)) {
|
|
return err(request, {
|
|
code: 'cancelled',
|
|
message: 'settings document open was aborted',
|
|
details: {},
|
|
})
|
|
}
|
|
let path: string | undefined
|
|
try {
|
|
path = await settings.prepareDocument()
|
|
} catch (error: unknown) {
|
|
if (isAborted(signal)) {
|
|
return err(request, {
|
|
code: 'cancelled',
|
|
message: 'settings document preparation was aborted',
|
|
details: {},
|
|
})
|
|
}
|
|
return err(request, {
|
|
code: 'internal',
|
|
message: `settings document preparation failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
details: {},
|
|
})
|
|
}
|
|
if (path === undefined) {
|
|
return err(request, {
|
|
code: 'internal',
|
|
message: 'settings provider has no local document to open',
|
|
details: {},
|
|
})
|
|
}
|
|
if (isAborted(signal)) {
|
|
return err(request, {
|
|
code: 'cancelled',
|
|
message: 'settings document open was aborted',
|
|
details: {},
|
|
})
|
|
}
|
|
return openTextFile(request, path, signal)
|
|
},
|
|
update: request => settingsWrite(request, request.payload.ns, 'update', request.payload.patch, request.payload.expectedRevision),
|
|
replace: request => settingsWrite(request, request.payload.ns, 'replace', request.payload.section, request.payload.expectedRevision),
|
|
mutate: request => settingsWrite(request, request.payload.ns, 'mutate', request.payload.ops, request.payload.expectedRevision),
|
|
},
|
|
|
|
credentials: {
|
|
async describe(request) {
|
|
const credentials = ctx.get('credentials')
|
|
if (credentials === undefined) return err(request, credentialsAbsent())
|
|
const entries = await Promise.all(request.payload.refs.map(async (ref) => {
|
|
const info = await credentials.describe(credentialRef(ref))
|
|
const view: CredentialView = {
|
|
configured: info.configured,
|
|
...info.source === undefined ? {} : { source: info.source },
|
|
writable: info.writable,
|
|
}
|
|
return [ref, view] as const
|
|
}))
|
|
return ok(request, { credentials: Object.fromEntries(entries) })
|
|
},
|
|
|
|
async set(request) {
|
|
const credentials = ctx.get('credentials')
|
|
if (credentials === undefined) return err(request, credentialsAbsent())
|
|
const { ref, value } = request.payload
|
|
try {
|
|
await credentials.set(credentialRef(ref), value)
|
|
} catch (error: unknown) {
|
|
return err(request, {
|
|
code: 'credential-rejected',
|
|
message: error instanceof Error ? error.message : String(error),
|
|
details: { ref },
|
|
})
|
|
}
|
|
return ok(request, {})
|
|
},
|
|
|
|
async unset(request) {
|
|
const credentials = ctx.get('credentials')
|
|
if (credentials === undefined) return err(request, credentialsAbsent())
|
|
const { ref } = request.payload
|
|
try {
|
|
await credentials.unset(credentialRef(ref))
|
|
} catch (error: unknown) {
|
|
return err(request, {
|
|
code: 'credential-rejected',
|
|
message: error instanceof Error ? error.message : String(error),
|
|
details: { ref },
|
|
})
|
|
}
|
|
return ok(request, {})
|
|
},
|
|
},
|
|
|
|
llm: {
|
|
providers(request) {
|
|
const registered = ctx.llm.listProviders()
|
|
const active = new Set(registered.map(provider => provider.id))
|
|
const directory = ctx.llm.listConfigurableProviders()
|
|
const declared = new Set(directory.map(entry => entry.provider))
|
|
const views: ConfigurableProviderView[] = directory.map(entry => ({
|
|
provider: entry.provider,
|
|
displayName: entry.displayName,
|
|
settingsNs: entry.settingsNs,
|
|
settingsPath: [...entry.settingsPath],
|
|
active: active.has(entry.provider),
|
|
...entry.declared === undefined ? {} : { declared: entry.declared },
|
|
}))
|
|
// Routes registered without a directory declaration still appear —
|
|
// they exist and serve models — just with no settings address. No
|
|
// adapter claimed them, so nothing can say whether they are shipped.
|
|
for (const provider of registered) {
|
|
if (declared.has(provider.id)) continue
|
|
views.push({
|
|
provider: provider.id,
|
|
displayName: provider.name,
|
|
settingsNs: '',
|
|
settingsPath: [],
|
|
active: true,
|
|
})
|
|
}
|
|
return Promise.resolve(ok(request, { providers: views }))
|
|
},
|
|
|
|
async models(request) {
|
|
return ok(request, await buildModelCatalog(ctx, defaults.defaultModelSelection()))
|
|
},
|
|
|
|
async discoverModels(request, signal) {
|
|
const { settingsNs, provider, baseURL, api, apiKey } = request.payload
|
|
try {
|
|
const models = await ctx.llm.discoverModels(settingsNs, {
|
|
...provider === undefined ? {} : { provider },
|
|
...baseURL === undefined ? {} : { baseURL },
|
|
...api === undefined ? {} : { api },
|
|
...apiKey === undefined ? {} : { apiKey },
|
|
...signal === undefined ? {} : { signal },
|
|
})
|
|
return ok(request, { models })
|
|
} catch (error: unknown) {
|
|
// Every failure here is the user's next move, not a transport fault:
|
|
// a wrong endpoint, a rejected key, or a protocol with no listing all
|
|
// end at the same place — fill the models in by hand. The details
|
|
// repeat only what the caller already sent, never the credential.
|
|
return err(request, {
|
|
code: 'model-discovery-failed',
|
|
message: error instanceof Error ? error.message : String(error),
|
|
details: { settingsNs, ...baseURL === undefined ? {} : { baseURL } },
|
|
})
|
|
}
|
|
},
|
|
},
|
|
|
|
downloads: {
|
|
async sessionLog(request, signal) {
|
|
// Clean error path first: missing services answer 500 and a missing
|
|
// root artifact 404 before any zip byte is produced. The root content
|
|
// read here is reused as the first zip entry, so nothing is read twice.
|
|
const deps = sessionLogExportDeps(ctx)
|
|
if (deps.sessionQuery === undefined || deps.sessionPersistence === undefined || deps.attachments === undefined) {
|
|
return new Response(
|
|
'session log export is unavailable: missing session-query, session-persistence, or attachments service',
|
|
{ status: 500 },
|
|
)
|
|
}
|
|
if (!deps.sessionPersistence.supportsRawArtifacts) {
|
|
return new Response(
|
|
'session log export is unavailable: the persistence backend does not expose per-session raw artifacts',
|
|
{ status: 501 },
|
|
)
|
|
}
|
|
const ready: SessionLogExportReady = {
|
|
sessionQuery: deps.sessionQuery,
|
|
sessionPersistence: deps.sessionPersistence,
|
|
attachments: deps.attachments,
|
|
sessions: deps.sessions,
|
|
}
|
|
let root: SessionRawArtifact | undefined
|
|
try {
|
|
await flushLiveSessionLog(deps, request.sessionId, signal)
|
|
root = await deps.sessionPersistence.readRaw(request.sessionId, signal)
|
|
signal.throwIfAborted()
|
|
} catch {
|
|
signal.throwIfAborted()
|
|
// Root preparation failure: answer 500 without echoing the error,
|
|
// which may carry absolute host paths into the browser error bar.
|
|
return new Response('session log export failed to prepare the stored artifact', { status: 500 })
|
|
}
|
|
if (root === undefined) {
|
|
return new Response('session not found', { status: 404 })
|
|
}
|
|
return new Response(
|
|
streamSessionLogZip(
|
|
ready,
|
|
root,
|
|
request.sessionId,
|
|
request.includeDescendants === true,
|
|
sessionExportCompressionLevel,
|
|
signal,
|
|
),
|
|
{
|
|
headers: {
|
|
'content-type': 'application/zip',
|
|
'content-disposition': `attachment; filename="${sessionLogZipFilename(request.sessionId)}"`,
|
|
},
|
|
},
|
|
)
|
|
},
|
|
},
|
|
}
|
|
}
|