Merge origin/master into worktree/web-plugin-config
Three seams: the tsconfig path map gained a mapping on each side and keeps both; the event-producer matrix is generated, so it was regenerated rather than hand-merged row by row.
This commit is contained in:
@@ -39,9 +39,16 @@ import type {
|
||||
ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame,
|
||||
ModelCatalogFailure, ModelProviderGroup,
|
||||
ModelReasoning, MuxFrame, PromptContentPart, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem,
|
||||
QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, ToolEventView,
|
||||
QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, TaskView, ToolEventView,
|
||||
WorkspaceId, WorkspaceView,
|
||||
} from './api/index.ts'
|
||||
import {
|
||||
sessionLogExportDeps,
|
||||
sessionLogZipFilename,
|
||||
streamSessionLogZip,
|
||||
type SessionLogExportReady,
|
||||
} from './session-export.ts'
|
||||
import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
|
||||
import {
|
||||
SESSION_SEARCH_RESULT_LIMIT,
|
||||
SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS,
|
||||
@@ -49,6 +56,9 @@ import {
|
||||
} from './api/session-search.ts'
|
||||
// Type-only: resolves `ctx.get('sessionProjections')` to the projection registry.
|
||||
import type {} from '@deepseek-ai/dsh-session-projection'
|
||||
// Type-only: resolves `ctx.get('tasks')` to the background task registry.
|
||||
import type {} from '@deepseek-ai/dsh-tasks'
|
||||
import type { TaskSnapshot } from '@deepseek-ai/dsh-tasks'
|
||||
// Type-only: resolves `ctx.get('sessionProjectionCache')` (the cold listing column).
|
||||
import type {} from '@deepseek-ai/dsh-session-projection-cache'
|
||||
// GoalError narrows domain rejections to their stable codes at the wire boundary.
|
||||
@@ -417,6 +427,22 @@ function subscribeSession(queue: FrameQueue<RpcRequest<MuxFrame>>, session: Sess
|
||||
queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Project registry snapshots onto the wire view, dropping the three internal
|
||||
* fields {@link TaskView} documents as absent.
|
||||
*/
|
||||
function taskViews(snapshots: readonly TaskSnapshot[]): TaskView[] {
|
||||
return snapshots.map(task => ({
|
||||
id: task.id,
|
||||
kind: task.kind,
|
||||
label: task.label,
|
||||
status: task.status,
|
||||
...task.detail === undefined ? {} : { detail: task.detail },
|
||||
startedAt: task.startedAt,
|
||||
...task.finishedAt === undefined ? {} : { finishedAt: task.finishedAt },
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the session's conversation has started: no turn has run yet (a
|
||||
* turn is one model-loop execution). Standalone plugin events — command
|
||||
@@ -697,6 +723,16 @@ function historyPage(
|
||||
* registry). An absent registry means the deployment has no projection seam:
|
||||
* the whole block is absent and clients treat every key as capability-absent.
|
||||
*/
|
||||
/**
|
||||
* Which session a transcript read is served from. An attached session is the
|
||||
* live object and keeps appending, so its events and projection baseline are
|
||||
* read together in one synchronous step; a detached one is already a frozen
|
||||
* inspection.
|
||||
*/
|
||||
type HistorySource =
|
||||
| { readonly kind: 'attached'; readonly session: Session }
|
||||
| { readonly kind: 'detached'; readonly header: SessionHeader; readonly events: SessionEvent[] }
|
||||
|
||||
function projectionsFor(ctx: Context, session: Session): SessionProjectionsBlock | undefined {
|
||||
const registry = ctx.get('sessionProjections')
|
||||
if (registry === undefined) return undefined
|
||||
@@ -1340,24 +1376,55 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
return undefined
|
||||
}
|
||||
|
||||
/** Read one transcript cut and optional projection baseline without acquiring an Agent owner. */
|
||||
async function historyStateFor(
|
||||
sessionId: SessionId,
|
||||
includeProjections: boolean,
|
||||
): Promise<{ header: SessionHeader; events: SessionEvent[]; projections?: SessionProjectionsBlock }> {
|
||||
/**
|
||||
* Resolve which session one transcript read is served from, without
|
||||
* acquiring an Agent owner. This is the read's only asynchronous step
|
||||
* besides ensuring the composition; {@link historyCutOf} takes the cut.
|
||||
* @param sessionId - the transcript being read.
|
||||
* @returns the attached session, or the inspected detached header and events.
|
||||
* @throws {@link ApiRemoteSessionNotFound} when no project-backed session has that identity.
|
||||
*/
|
||||
async function historySourceFor(sessionId: SessionId): Promise<HistorySource> {
|
||||
const attached = ctx.sessions.get(sessionId)
|
||||
if (attached !== undefined) {
|
||||
const events = [...attached.events]
|
||||
const projections = includeProjections ? projectionsFor(ctx, attached) : undefined
|
||||
return { header: attached.header, events, ...projections === undefined ? {} : { projections } }
|
||||
}
|
||||
if (attached !== undefined) return { kind: 'attached', session: attached }
|
||||
const inspected = await inspectServable(sessionId)
|
||||
const projections = includeProjections ? detachedProjectionsFor(ctx, inspected.events) : undefined
|
||||
return {
|
||||
header: inspected.meta,
|
||||
events: inspected.events,
|
||||
...projections === undefined ? {} : { projections },
|
||||
return { kind: 'detached', header: inspected.meta, events: inspected.events }
|
||||
}
|
||||
|
||||
/**
|
||||
* The header and events {@link presenterScopeFor} reads to decide which
|
||||
* composition a transcript ran under.
|
||||
* @param source - the live or detached session this read is served from.
|
||||
* @returns that session's creation header and its events.
|
||||
*/
|
||||
function sourceSession(source: HistorySource): PresetBearingSession {
|
||||
if (source.kind === 'detached') return { header: source.header, events: source.events }
|
||||
return { header: source.session.header, events: source.session.events }
|
||||
}
|
||||
|
||||
/**
|
||||
* One transcript cut: the events and the projection baseline that describe
|
||||
* the SAME log position.
|
||||
*
|
||||
* Synchronous, and the two reads sit next to each other, because an attached
|
||||
* session keeps appending: an `await` between them would serve events cut at
|
||||
* N beside a baseline folded to N+1, which is one response describing two
|
||||
* moments. The caller does its awaiting before this call.
|
||||
* @param source - the live or detached session this read is served from.
|
||||
* @param includeProjections - whether the caller asked for the baseline (a tail page does).
|
||||
* @returns the events and, when asked, the baseline for that same position.
|
||||
*/
|
||||
function historyCutOf(
|
||||
source: HistorySource,
|
||||
includeProjections: boolean,
|
||||
): { events: SessionEvent[]; projections?: SessionProjectionsBlock } {
|
||||
if (source.kind === 'detached') {
|
||||
const projections = includeProjections ? detachedProjectionsFor(ctx, source.events) : undefined
|
||||
return { events: source.events, ...projections === undefined ? {} : { projections } }
|
||||
}
|
||||
const events = [...source.session.events]
|
||||
const projections = includeProjections ? projectionsFor(ctx, source.session) : undefined
|
||||
return { events, ...projections === undefined ? {} : { projections } }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2017,9 +2084,22 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
|
||||
async history(request) {
|
||||
const { sessionId, beforeSeq, maxMessages } = request.payload
|
||||
let state: { header: SessionHeader; events: SessionEvent[]; projections?: SessionProjectionsBlock }
|
||||
try {
|
||||
state = await historyStateFor(sessionId, beforeSeq === undefined)
|
||||
const source = await historySourceFor(sessionId)
|
||||
// Both awaits happen BEFORE the cut. Ensuring the recorded
|
||||
// composition's standing mount is what registers its projection
|
||||
// units, so a first cold read would otherwise serve a baseline
|
||||
// missing every preset-owned key; and an attached session keeps
|
||||
// appending, so awaiting between the two reads would pair events cut
|
||||
// at N with a baseline folded to N+1.
|
||||
const scope = await presenterScopeFor(sessionId, sourceSession(source))
|
||||
const cut = historyCutOf(source, beforeSeq === undefined)
|
||||
const page = historyPage(ctx, cut.events, beforeSeq, maxMessages, scope)
|
||||
return ok(request, {
|
||||
events: page.events,
|
||||
hasMore: page.hasMore,
|
||||
...cut.projections === undefined ? {} : { projections: cut.projections },
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof SessionNotFound) {
|
||||
return err(request, { code: 'session-not-found', message: error.message, details: { sessionId } })
|
||||
@@ -2030,12 +2110,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
details: {},
|
||||
})
|
||||
}
|
||||
const page = historyPage(ctx, state.events, beforeSeq, maxMessages, await presenterScopeFor(sessionId, state))
|
||||
return ok(request, {
|
||||
events: page.events,
|
||||
hasMore: page.hasMore,
|
||||
...state.projections === undefined ? {} : { projections: state.projections },
|
||||
})
|
||||
},
|
||||
|
||||
async models(request) {
|
||||
@@ -3223,6 +3297,19 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
queue.push(frame({ type: 'session/queue', sessionId: session.id, items: queueItems(agent) }))
|
||||
}
|
||||
}
|
||||
// Background-task baseline. `ctx.agents.get` is the non-resuming read:
|
||||
// a session with no live Agent owns no tasks, so it correctly sees only
|
||||
// the unowned ones, and listing never revives a cold session. An empty
|
||||
// set sends nothing — absence is how the client reads "no tasks".
|
||||
const tasks = ctx.get('tasks')
|
||||
if (tasks !== undefined) {
|
||||
for (const session of ctx.sessions.list()) {
|
||||
const views = taskViews(tasks.list(ctx.agents.get(session.id)))
|
||||
if (views.length > 0) {
|
||||
queue.push(frame({ type: 'session/tasks', sessionId: session.id, tasks: views }))
|
||||
}
|
||||
}
|
||||
}
|
||||
// Per-session open-call table for result-view pairing. Bounded by the
|
||||
// per-turn call count: entries clear on turn/end; a table miss (stream
|
||||
// opened mid-turn) backscans the session's in-memory events instead.
|
||||
@@ -3250,10 +3337,36 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
}),
|
||||
ctx.on('session/created', (session: Session) => {
|
||||
subscribeSession(queue, session)
|
||||
// The subscribe frame clears the client's task mirror, and a
|
||||
// session born after the stream opened missed the baseline loop.
|
||||
// Unowned tasks are visible to it from birth, so without this it
|
||||
// would show none until the next registry change.
|
||||
const views = tasks === undefined ? [] : taskViews(tasks.list(ctx.agents.get(session.id)))
|
||||
if (views.length > 0) {
|
||||
queue.push(frame({ type: 'session/tasks', sessionId: session.id, tasks: views }))
|
||||
}
|
||||
}),
|
||||
ctx.on('session/disposed', (session: Session) => {
|
||||
openCalls.delete(session.id)
|
||||
}),
|
||||
...tasks === undefined ? [] : [tasks.onTasksChanged((owner) => {
|
||||
if (owner !== undefined) {
|
||||
// The exact owner instance the fence compares against, so the
|
||||
// push stays correct even while that Agent's scope is tearing
|
||||
// down and a lookup by id would already miss.
|
||||
queue.push(frame({ type: 'session/tasks', sessionId: owner.id, tasks: taskViews(tasks.list(owner)) }))
|
||||
return
|
||||
}
|
||||
// An unowned task is visible to every caller, so every subscribed
|
||||
// session's set changed with it.
|
||||
for (const session of ctx.sessions.list()) {
|
||||
queue.push(frame({
|
||||
type: 'session/tasks',
|
||||
sessionId: session.id,
|
||||
tasks: taskViews(tasks.list(ctx.agents.get(session.id))),
|
||||
}))
|
||||
}
|
||||
})],
|
||||
]
|
||||
return queue.iterate(signal, () => {
|
||||
muxQueues.delete(queue)
|
||||
@@ -3375,6 +3488,46 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
},
|
||||
},
|
||||
|
||||
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 },
|
||||
)
|
||||
}
|
||||
const ready: SessionLogExportReady = {
|
||||
sessionQuery: deps.sessionQuery,
|
||||
sessionPersistence: deps.sessionPersistence,
|
||||
attachments: deps.attachments,
|
||||
}
|
||||
let root: SessionRawArtifact | undefined
|
||||
try {
|
||||
root = await deps.sessionPersistence.readRaw(request.sessionId, signal)
|
||||
} catch {
|
||||
// Backend read 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 read 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, signal),
|
||||
{
|
||||
headers: {
|
||||
'content-type': 'application/zip',
|
||||
'content-disposition': `attachment; filename="${sessionLogZipFilename(request.sessionId)}"`,
|
||||
},
|
||||
},
|
||||
)
|
||||
},
|
||||
},
|
||||
|
||||
respond(message: ClientResponse): Promise<RpcReceipt> {
|
||||
// Route by the echoed rpcId (the wire correlation): approvals first,
|
||||
// then questions — the two registries share one id space of UUIDs.
|
||||
|
||||
26
packages/host/apiproxy/src/api/downloads.schema.ts
Normal file
26
packages/host/apiproxy/src/api/downloads.schema.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* downloads domain zod schemas. The GET download surface has no wire
|
||||
* envelope: the request arrives as query parameters (all strings), so its
|
||||
* request schema parses the raw query-parameter object into the method's
|
||||
* exact request shape. SessionId brand cast point: sessionIdSchema, and only
|
||||
* there (hosted in sessions.schema like every other cast).
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { DownloadsApi } from './downloads.ts'
|
||||
import { sessionIdSchema } from './sessions.schema.ts'
|
||||
|
||||
/**
|
||||
* session.export query params → the sessionLog request. `includeDescendants`
|
||||
* accepts exactly `true`/`false`/absent; any other value is rejected (400) so
|
||||
* a misspelled flag cannot silently under-export.
|
||||
*/
|
||||
export const sessionLogQuerySchema = z
|
||||
.object({
|
||||
sessionId: sessionIdSchema,
|
||||
includeDescendants: z.union([z.literal('true'), z.literal('false')]).optional(),
|
||||
})
|
||||
.transform(query => ({
|
||||
sessionId: query.sessionId,
|
||||
...(query.includeDescendants === 'true' ? { includeDescendants: true } : {}),
|
||||
})) satisfies z.ZodType<Parameters<DownloadsApi['sessionLog']>[0]>
|
||||
25
packages/host/apiproxy/src/api/downloads.ts
Normal file
25
packages/host/apiproxy/src/api/downloads.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* downloads domain contract: host-only download surfaces — the GET-download
|
||||
* channel family, the mirror of the SSE-stream `events` domain. No wire
|
||||
* envelope: the carrier's GET routes answer these directly, and the browser
|
||||
* `IApiClient` never exposes them.
|
||||
*/
|
||||
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
|
||||
/** Host-only download surfaces (no wire envelope; absent from IApiClient). */
|
||||
export interface DownloadsApi {
|
||||
/**
|
||||
* Stream one session-log ZIP — the root artifact verbatim plus each subagent
|
||||
* descendant's — as an attachment response. The carrier's GET route answers
|
||||
* this directly; the browser never calls it.
|
||||
* @param request - the root session id and whether to include descendants.
|
||||
* @param signal - cancellation for the underlying reads.
|
||||
* @returns the ZIP attachment response; missing services answer 500 and a
|
||||
* missing root session 404 before any byte is produced.
|
||||
*/
|
||||
sessionLog(
|
||||
request: { sessionId: SessionId; includeDescendants?: boolean },
|
||||
signal: AbortSignal,
|
||||
): Promise<Response>
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { approvalRequestIdSchema } from './approvals.schema.ts'
|
||||
import {
|
||||
contentBlockSchema, messageIdSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema,
|
||||
} from './sessions.schema.ts'
|
||||
import { taskViewSchema } from './tasks.schema.ts'
|
||||
import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts'
|
||||
|
||||
/** Question fields validated strictly against core dsh-user-interaction. */
|
||||
@@ -58,6 +59,7 @@ export const muxFrameSchema = z.discriminatedUnion('type', [
|
||||
message: messageSchema,
|
||||
})),
|
||||
}),
|
||||
z.object({ type: z.literal('session/tasks'), sessionId: sessionIdSchema, tasks: z.array(taskViewSchema) }),
|
||||
// value stays wide: it already passed its unit's own schema on the host,
|
||||
// and deep-validating here would import every domain's schema into the carrier.
|
||||
z.object({ type: z.literal('session/projection'), sessionId: sessionIdSchema, key: z.string().min(1), value: z.unknown(), seq: z.number().int().nonnegative() }),
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { CallId } from '@deepseek-ai/dsh-llm/brand'
|
||||
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types'
|
||||
import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation'
|
||||
import type { RpcError, RpcId, RpcRequest } from './rpc.ts'
|
||||
import type { TaskView } from './tasks.ts'
|
||||
import type { WorkspaceView } from './workspace.ts'
|
||||
|
||||
// Client-side consumers take the render-intent vocabulary from the contract;
|
||||
@@ -81,6 +82,20 @@ export type MuxFrame =
|
||||
* in QueueDock, while pending steering renders at the conversation tail.
|
||||
*/
|
||||
| { type: 'session/queue'; sessionId: SessionId; items: QueuedInboxItem[] }
|
||||
/**
|
||||
* Complete set of background tasks this session can see, after every registry
|
||||
* commit that changes it: registration, the stopping transition, settlement,
|
||||
* and owner-disposal removal. The registry is process-local and holds no
|
||||
* durable event, so — exactly like `session/queue` — the whole snapshot is
|
||||
* what makes a start, a kill, a reconnect, and a second tab converge on one
|
||||
* authoritative value.
|
||||
*
|
||||
* Sent as a subscription baseline only for a session that currently has
|
||||
* tasks; an absent key means an empty set. A change that empties the set
|
||||
* still sends `[]`, since that transition is the only one absence cannot
|
||||
* express.
|
||||
*/
|
||||
| { type: 'session/tasks'; sessionId: SessionId; tasks: TaskView[] }
|
||||
/**
|
||||
* One projection unit's finished value changed (session-projection RFC).
|
||||
* Live push state, never logged — replay recomputes on the host (the
|
||||
|
||||
@@ -16,9 +16,10 @@ import type { GoalsApi } from './goals.ts'
|
||||
import type { SettingsApi } from './settings.ts'
|
||||
import type { CredentialsApi } from './credentials.ts'
|
||||
import type { LlmApi } from './llm.ts'
|
||||
import type { DownloadsApi } from './downloads.ts'
|
||||
import type { ClientResponse, RpcReceipt } from './rpc.ts'
|
||||
|
||||
/** Root interface of the unified API surface. New client-request domain = one new file pair + one field here + one map row. */
|
||||
/** Root interface of the unified API. New client-request domain = one new file pair + one field here + one map row. */
|
||||
export interface ApiProxy {
|
||||
sessions: SessionsApi
|
||||
subagents: SubagentsApi
|
||||
@@ -32,6 +33,8 @@ export interface ApiProxy {
|
||||
settings: SettingsApi
|
||||
credentials: CredentialsApi
|
||||
llm: LlmApi
|
||||
/** Host-only download surfaces (GET, no wire envelope); absent from IApiClient. */
|
||||
downloads: DownloadsApi
|
||||
/** Response entry for server-requests (client-response, echoing their rpcId); not a domain method (four-quadrant model). */
|
||||
respond(message: ClientResponse): Promise<RpcReceipt>
|
||||
}
|
||||
@@ -39,15 +42,15 @@ export interface ApiProxy {
|
||||
// ---- Domain interfaces and payload entities ----
|
||||
export type {
|
||||
HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
|
||||
ModelReasoningEffort, ModelSelection, PromptContentPart, QueueAction, SessionModels, SessionProjectionsBlock,
|
||||
SessionSearchItem,
|
||||
SessionsApi, SessionSummary,
|
||||
ModelReasoningEffort, ModelSelection, PromptContentPart, QueueAction, SessionModels,
|
||||
SessionProjectionsBlock, SessionSearchItem, SessionsApi, SessionSummary,
|
||||
} from './sessions.ts'
|
||||
export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'
|
||||
export type {
|
||||
SubagentAddress, SubagentCatalog, SubagentInterruptReceipt, SubagentListEntry,
|
||||
SubagentPromptReceipt, SubagentsApi,
|
||||
} from './subagents.ts'
|
||||
export type { TaskView } from './tasks.ts'
|
||||
export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts'
|
||||
export type { CommandsApi, CommandDescriptor } from './commands.ts'
|
||||
export type { SkillsApi, SkillEntry } from './skills.ts'
|
||||
@@ -57,6 +60,7 @@ export type { GoalsApi, GoalId, GoalRef } from './goals.ts'
|
||||
export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts'
|
||||
export type { CredentialsApi, CredentialView } from './credentials.ts'
|
||||
export type { ConfigurableProviderView, DiscoveredModelView, LlmApi } from './llm.ts'
|
||||
export type { DownloadsApi } from './downloads.ts'
|
||||
export type { ApprovalResponsePayload } from './approvals.ts'
|
||||
|
||||
export type { QuestionResponsePayload } from './questions.ts'
|
||||
|
||||
@@ -116,7 +116,7 @@ export type RpcResult<T> = { ok: true; value: T } | { ok: false; error: RpcError
|
||||
|
||||
/**
|
||||
* Fold a transport exception into the RpcResult error branch (unified error
|
||||
* surface; 'internal' as the catch-all code). Lives with RpcResult so every
|
||||
* API; 'internal' as the catch-all code). Lives with RpcResult so every
|
||||
* carrier consumer folds the same way.
|
||||
* @param error - the thrown value from the carrier.
|
||||
* @returns the error branch of an RpcResult.
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* sessions domain zod schemas (names derived from map keys: sessionListRequestSchema /
|
||||
* sessionListValueSchema). SessionEvent passthrough = strict envelope (type/seq/time) + wide
|
||||
* data: the merge-extensible event surface keeps an unknown-type branch at the union level,
|
||||
* data: the merge-extensible event API keeps an unknown-type branch at the union level,
|
||||
* with no field-level passthrough. SessionId brand cast point: sessionIdSchema, and only there.
|
||||
*/
|
||||
|
||||
|
||||
33
packages/host/apiproxy/src/api/tasks.schema.ts
Normal file
33
packages/host/apiproxy/src/api/tasks.schema.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* tasks domain zod schemas: the branded task id and the wire view carried by
|
||||
* `session/tasks` frames.
|
||||
*/
|
||||
|
||||
import { z } from 'zod'
|
||||
import type { TaskId } from '@deepseek-ai/dsh-tasks/brand'
|
||||
import type { TaskView } from './tasks.ts'
|
||||
import type { Wire } from './rpc.schema.ts'
|
||||
|
||||
/** TaskId: one brand cast after non-empty string validation. */
|
||||
export const taskIdSchema = z.string().min(1) as unknown as z.ZodType<TaskId>
|
||||
|
||||
/**
|
||||
* One wire task view. `kind` stays an open string because producer plugins
|
||||
* extend the registry's kind map by declaration merging, so the closed set is
|
||||
* not knowable at this boundary.
|
||||
*/
|
||||
export const taskViewSchema = z.object({
|
||||
id: taskIdSchema,
|
||||
kind: z.string().min(1),
|
||||
label: z.string().min(1),
|
||||
status: z.union([
|
||||
z.literal('running'),
|
||||
z.literal('stopping'),
|
||||
z.literal('completed'),
|
||||
z.literal('killed'),
|
||||
z.literal('failed'),
|
||||
]),
|
||||
detail: z.string().optional(),
|
||||
startedAt: z.number().int().nonnegative(),
|
||||
finishedAt: z.number().int().nonnegative().optional(),
|
||||
}) satisfies z.ZodType<Wire<TaskView>>
|
||||
36
packages/host/apiproxy/src/api/tasks.ts
Normal file
36
packages/host/apiproxy/src/api/tasks.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Browser-safe background-task domain contract. The registry's live records
|
||||
* never cross the wire; a view is the subset a human list needs, minted fresh
|
||||
* per push.
|
||||
*/
|
||||
|
||||
import type { TaskId } from '@deepseek-ai/dsh-tasks/brand'
|
||||
|
||||
/**
|
||||
* One background task as the client sees it.
|
||||
*
|
||||
* Three registry fields are deliberately absent. `ownerSession` is redundant
|
||||
* beside the frame's own `sessionId`; `reported` is an internal notice-delivery
|
||||
* bit with no user meaning; `outputLimitBytes` is producer-owned model
|
||||
* presentation policy that never reaches a human surface.
|
||||
*/
|
||||
export interface TaskView {
|
||||
/** Registry-issued `<kind>-N` identity, stable for the task's whole life. */
|
||||
id: TaskId
|
||||
/**
|
||||
* Producer kind (`bash`, `pwsh`, `pty-send`, `subagent`, …). Kept as a bare
|
||||
* string because producer plugins extend the kind map by declaration merging,
|
||||
* so no client build can enumerate the closed set.
|
||||
*/
|
||||
kind: string
|
||||
/** Producer-supplied one-line label: the command, or the delegation description. */
|
||||
label: string
|
||||
/** Current lifecycle state. */
|
||||
status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed'
|
||||
/** Kind-specific status detail ('exit code: 3'), present once the producer supplied one. */
|
||||
detail?: string
|
||||
/** Epoch ms when the task was registered. */
|
||||
startedAt: number
|
||||
/** Epoch ms when the task settled; absent while live. */
|
||||
finishedAt?: number
|
||||
}
|
||||
@@ -411,7 +411,7 @@ export abstract class AbstractApiClient implements IApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
// ---- IApiClient surface (arrow properties so destructured/passed references stay bound) ----
|
||||
// ---- IApiClient API (arrow properties so destructured/passed references stay bound) ----
|
||||
|
||||
readonly sessions: IApiClient['sessions'] = {
|
||||
list: (payload, signal) => this.callUnary('session.list', payload, signal),
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import type { z } from 'zod'
|
||||
import type { ApiProxy, MuxFrame, HostFrame } from '../api/index.ts'
|
||||
import { sessionLogQuerySchema } from '../api/downloads.schema.ts'
|
||||
import type { RequestPayload, ResponseValue, RpcMethodMap } from '../api/rpc-map.ts'
|
||||
import type { ClientRequest, RpcError, RpcRequest, RpcResponse, ServerRequest, ServerResponse } from '../api/rpc.ts'
|
||||
import { RpcId } from '../api/rpc.ts'
|
||||
@@ -249,12 +250,23 @@ export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } {
|
||||
const url = new URL(req.url)
|
||||
const path = url.pathname
|
||||
|
||||
// No-envelope GET channel surface (SSE streams + host-only download):
|
||||
// physical routes that answer directly, without a wire envelope.
|
||||
if (path === '/api/events.mux' && req.method === 'GET') {
|
||||
return sseResponse(api.events.mux({ rpcId: RpcId(randomUUID()), payload: {} }, req.signal))
|
||||
}
|
||||
if (path === '/api/events.host' && req.method === 'GET') {
|
||||
return sseResponse(api.events.host({ rpcId: RpcId(randomUUID()), payload: {} }, req.signal))
|
||||
}
|
||||
if (path === '/api/session.export' && req.method === 'GET') {
|
||||
// Query params are a different boundary from the POST envelope, but
|
||||
// the request still casts its brands only through the domain schema.
|
||||
const parsed = sessionLogQuerySchema.safeParse(Object.fromEntries(url.searchParams))
|
||||
if (!parsed.success) {
|
||||
return new Response('missing or invalid sessionId query parameter', { status: 400 })
|
||||
}
|
||||
return api.downloads.sessionLog(parsed.data, req.signal)
|
||||
}
|
||||
|
||||
if (req.method !== 'POST' || !path.startsWith('/api/')) {
|
||||
return new Response('not found', { status: 404 })
|
||||
|
||||
@@ -72,6 +72,7 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
readonly credentials: ApiProxy['credentials']
|
||||
readonly llm: ApiProxy['llm']
|
||||
readonly events: ApiProxy['events']
|
||||
readonly downloads: ApiProxy['downloads']
|
||||
readonly respond: ApiProxy['respond']
|
||||
|
||||
constructor(ctx: Context, config: Config) {
|
||||
@@ -94,6 +95,7 @@ export class ApiProxyService extends Service implements ApiProxy {
|
||||
this.credentials = api.credentials
|
||||
this.llm = api.llm
|
||||
this.events = api.events
|
||||
this.downloads = api.downloads
|
||||
// createApiProxy returns closures (no `this` capture), so the bind is
|
||||
// behavior-neutral.
|
||||
this.respond = api.respond.bind(api)
|
||||
|
||||
357
packages/host/apiproxy/src/session-export.ts
Normal file
357
packages/host/apiproxy/src/session-export.ts
Normal file
@@ -0,0 +1,357 @@
|
||||
/**
|
||||
* Host-side session-log download: streams one ZIP archive whose files are the
|
||||
* sessions' stored artifact text verbatim plus every referenced media object.
|
||||
* The root artifact sits under its original base name (`session.jsonl`); each
|
||||
* subagent descendant under `subagents/<id>/<filename>`; each image referenced
|
||||
* by any included log under `media/<attachmentId>.<ext>` (content-addressed,
|
||||
* so one archive never duplicates a shared image). No manifest is written —
|
||||
* every file is byte-identical to the backend's durable artifact or attachment
|
||||
* store and self-describing through its own header line or media type.
|
||||
* Compression runs on the host with fflate's streaming Zip API, so the archive
|
||||
* bytes are produced incrementally and the host never holds the whole archive
|
||||
* in one buffer; production yields to the consumer whenever the response queue
|
||||
* fills past its high-water mark, so a slow consumer bounds the accumulation
|
||||
* instead of piling up the whole archive (fflate's callback is synchronous —
|
||||
* this drain point is the only backpressure available).
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Zip, ZipDeflate } from 'fflate'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import type { SessionLineageNode, SessionQueryService } from '@deepseek-ai/dsh-session-query'
|
||||
import type { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionPersistence, SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence'
|
||||
|
||||
/** The services a session-log export needs (absent → the export is unavailable). */
|
||||
export interface SessionLogExportDeps {
|
||||
readonly sessionQuery: SessionQueryService | undefined
|
||||
readonly sessionPersistence: SessionPersistence | undefined
|
||||
readonly attachments: AttachmentStore | undefined
|
||||
}
|
||||
|
||||
/** The export services narrowed to the mounted ones streaming actually reads. */
|
||||
export interface SessionLogExportReady {
|
||||
readonly sessionQuery: SessionQueryService
|
||||
readonly sessionPersistence: SessionPersistence
|
||||
readonly attachments: AttachmentStore
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the persistence, session-query, and attachment services a log export needs.
|
||||
* @param ctx - the composed host context.
|
||||
* @returns the export services (absent when the deployment does not mount them).
|
||||
*/
|
||||
export function sessionLogExportDeps(ctx: Context): SessionLogExportDeps {
|
||||
return {
|
||||
sessionQuery: ctx.get('sessionQuery'),
|
||||
sessionPersistence: ctx.get('sessionPersistence'),
|
||||
attachments: ctx.get('attachments'),
|
||||
}
|
||||
}
|
||||
|
||||
/** One exported file: a stored artifact text or one referenced media object. */
|
||||
export type SessionLogZipEntry =
|
||||
| { readonly path: string; readonly content: string }
|
||||
| { readonly path: string; readonly data: Uint8Array }
|
||||
|
||||
/** Zip extension for each accepted raster media type. */
|
||||
const MEDIA_TYPE_EXTENSIONS: Record<ImageAttachmentRef['mediaType'], string> = {
|
||||
'image/png': 'png',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/webp': 'webp',
|
||||
'image/gif': 'gif',
|
||||
}
|
||||
|
||||
/**
|
||||
* The zip path for one media object: content-addressed by the opaque
|
||||
* attachment id so shared images land once and the id in the log maps back to
|
||||
* the archive entry without a manifest.
|
||||
* @param ref - the durable reference from a session log.
|
||||
* @returns the archive path.
|
||||
*/
|
||||
function mediaEntryPath(ref: ImageAttachmentRef): string {
|
||||
return `media/${String(ref.attachmentId)}.${MEDIA_TYPE_EXTENSIONS[ref.mediaType]}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect every image reference inside one content array, descending into
|
||||
* nested tool results the way the live attachment route does.
|
||||
* @param content - an event content array (or nested tool-result content).
|
||||
* @param refs - the dedupe map being filled (keyed by attachment id).
|
||||
*/
|
||||
function collectImageRefs(content: unknown, refs: Map<string, ImageAttachmentRef>): void {
|
||||
if (!Array.isArray(content)) return
|
||||
const pending: unknown[] = []
|
||||
for (const item of content) pending.push(item)
|
||||
while (pending.length > 0) {
|
||||
const value = pending.pop()
|
||||
if (typeof value !== 'object' || value === null || Array.isArray(value)) continue
|
||||
const block = value as { type?: unknown; attachment?: unknown; content?: unknown }
|
||||
if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) {
|
||||
const ref = block.attachment as ImageAttachmentRef
|
||||
refs.set(String(ref.attachmentId), ref)
|
||||
}
|
||||
if (Array.isArray(block.content)) {
|
||||
for (const item of block.content) pending.push(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect every image reference one session event carries, across the same
|
||||
* carriers the live attachment route scans (direct content, message content,
|
||||
* inserted messages, and completed assistant chunk blocks).
|
||||
* @param event - one parsed JSONL event object.
|
||||
* @param refs - the dedupe map being filled (keyed by attachment id).
|
||||
*/
|
||||
function collectEventImageRefs(event: unknown, refs: Map<string, ImageAttachmentRef>): void {
|
||||
const data = (event as { data?: unknown }).data
|
||||
if (typeof data !== 'object' || data === null) return
|
||||
const carrier = data as {
|
||||
content?: unknown
|
||||
message?: { content?: unknown }
|
||||
inserted?: Array<{ content?: unknown }>
|
||||
chunk?: { type?: unknown; block?: unknown }
|
||||
}
|
||||
collectImageRefs(carrier.content, refs)
|
||||
if (carrier.message !== undefined) collectImageRefs(carrier.message.content, refs)
|
||||
if (carrier.inserted !== undefined) {
|
||||
for (const message of carrier.inserted) collectImageRefs(message.content, refs)
|
||||
}
|
||||
if (carrier.chunk?.type === 'block-end') collectImageRefs([carrier.chunk.block], refs)
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the distinct media references one stored artifact text names.
|
||||
* Lines that fail to parse cannot reference media and are skipped (the
|
||||
* artifact text itself is exported verbatim regardless).
|
||||
* @param content - the stored artifact text.
|
||||
* @returns the dedupe map keyed by attachment id.
|
||||
*/
|
||||
function imageRefsInArtifact(content: string): Map<string, ImageAttachmentRef> {
|
||||
const refs = new Map<string, ImageAttachmentRef>()
|
||||
for (const line of content.split('\n')) {
|
||||
if (line === '') continue
|
||||
let event: unknown
|
||||
try {
|
||||
event = JSON.parse(line)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
collectEventImageRefs(event, refs)
|
||||
}
|
||||
return refs
|
||||
}
|
||||
|
||||
/**
|
||||
* One safe zip path segment from an untrusted session id. Session ids are
|
||||
* host-controlled, but the brand allows any non-empty string, so `../`, dot
|
||||
* segments, and separator characters are neutralized before they can shape
|
||||
* archive entries. Distinct ids may collapse onto one segment (id collision
|
||||
* is impossible for the host-minted UUIDs, so no uniqueness suffix is kept).
|
||||
* @param id - the raw session id.
|
||||
* @returns a filesystem-safe single path segment.
|
||||
*/
|
||||
function safeSessionIdSegment(id: string): string {
|
||||
return id.replace(/[^A-Za-z0-9_-]/g, '_')
|
||||
}
|
||||
|
||||
/**
|
||||
* The export archive filename for one root session.
|
||||
* @param sessionId - the root session id (sanitized to one safe path segment).
|
||||
* @returns the attachment filename for the session's export archive.
|
||||
*/
|
||||
export function sessionLogZipFilename(sessionId: string): string {
|
||||
return `dsh-session-${safeSessionIdSegment(sessionId)}.zip`
|
||||
}
|
||||
|
||||
/**
|
||||
* Yield the export entries in zip order: the preloaded root artifact first,
|
||||
* then every subagent descendant in lineage order (each read from the
|
||||
* persistence backend right before it is yielded and dropped after the
|
||||
* consumer moves on), then every distinct media object referenced by any of
|
||||
* the included logs (read and verified from the attachment store, one archive
|
||||
* entry per attachment id). The host holds at most one descendant's artifact
|
||||
* text and one media object at a time beyond the root.
|
||||
* @param deps - the mounted export services (the caller answered 500 before this runs).
|
||||
* @param root - the already-read root artifact (read by the caller so the
|
||||
* missing-session path can answer cleanly before streaming starts).
|
||||
* @param sessionId - the root session id.
|
||||
* @param includeDescendants - whether to include every subagent descendant.
|
||||
* @param signal - optional cancellation for read work.
|
||||
* @returns the export entries in zip order.
|
||||
*/
|
||||
export async function* sessionLogZipEntries(
|
||||
deps: SessionLogExportReady,
|
||||
root: SessionRawArtifact,
|
||||
sessionId: SessionId,
|
||||
includeDescendants: boolean,
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<SessionLogZipEntry> {
|
||||
const media = new Map<string, ImageAttachmentRef>()
|
||||
const rememberMedia = (content: string): void => {
|
||||
for (const [id, ref] of imageRefsInArtifact(content)) media.set(id, ref)
|
||||
}
|
||||
rememberMedia(root.content)
|
||||
yield { path: root.filename, content: root.content }
|
||||
if (includeDescendants) {
|
||||
const seen = new Set<SessionId>([sessionId])
|
||||
const collect = async function* (
|
||||
nodes: readonly SessionLineageNode[],
|
||||
): AsyncGenerator<SessionLogZipEntry> {
|
||||
for (const node of nodes) {
|
||||
signal?.throwIfAborted()
|
||||
const id = node.session.header.id
|
||||
if (seen.has(id)) continue
|
||||
seen.add(id)
|
||||
const raw = await deps.sessionPersistence.readRaw(id)
|
||||
if (raw === undefined) {
|
||||
throw new Error(`subagent "${id}" has no stored log artifact`)
|
||||
}
|
||||
rememberMedia(raw.content)
|
||||
yield {
|
||||
path: `subagents/${safeSessionIdSegment(id)}/${raw.filename}`,
|
||||
content: raw.content,
|
||||
}
|
||||
yield* collect(node.descendants)
|
||||
}
|
||||
}
|
||||
const lineage = await deps.sessionQuery.traceSession(sessionId)
|
||||
yield* collect(lineage.descendants)
|
||||
}
|
||||
for (const ref of media.values()) {
|
||||
signal?.throwIfAborted()
|
||||
const stored = await deps.attachments.readImage(ref)
|
||||
yield { path: mediaEntryPath(ref), data: stored.data }
|
||||
}
|
||||
}
|
||||
|
||||
/** How many code units of artifact text one zip push carries (bounded encode memory). */
|
||||
const PUSH_CHUNK_CODE_UNITS = 1 << 16
|
||||
|
||||
/** How many bytes of media one zip push carries (bounded memory; images are already size-capped). */
|
||||
const PUSH_CHUNK_BYTES = 1 << 16
|
||||
|
||||
/**
|
||||
* Push one media object's bytes into a deflate stream in bounded chunks,
|
||||
* yielding to a slow consumer between chunks like the artifact path does.
|
||||
* @param deflate - the zip entry's deflate stream.
|
||||
* @param data - the stored image bytes.
|
||||
* @param signal - optional cancellation; throws when aborted.
|
||||
*/
|
||||
async function pushBinaryChunks(
|
||||
deflate: ZipDeflate,
|
||||
data: Uint8Array,
|
||||
controller: ReadableStreamDefaultController<Uint8Array>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
let offset = 0
|
||||
do {
|
||||
signal?.throwIfAborted()
|
||||
const end = Math.min(offset + PUSH_CHUNK_BYTES, data.byteLength)
|
||||
const finalChunk = end >= data.byteLength
|
||||
deflate.push(data.subarray(offset, end), finalChunk)
|
||||
offset = end
|
||||
/* v8 ignore next 2 -- only fires when a slow consumer leaves the queue over-full */
|
||||
if (controller.desiredSize !== null && controller.desiredSize < 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
} while (offset < data.byteLength)
|
||||
}
|
||||
|
||||
/**
|
||||
* Push one artifact's text into a deflate stream in bounded chunks, never
|
||||
* splitting a surrogate pair across a chunk boundary (a lone high surrogate
|
||||
* re-encodes as U+FFFD and would silently corrupt the exported artifact).
|
||||
* @param deflate - the zip entry's deflate stream.
|
||||
* @param content - the artifact text verbatim.
|
||||
* @param signal - optional cancellation; throws when aborted.
|
||||
*/
|
||||
async function pushArtifactChunks(
|
||||
deflate: ZipDeflate,
|
||||
content: string,
|
||||
controller: ReadableStreamDefaultController<Uint8Array>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
const encoder = new TextEncoder()
|
||||
let offset = 0
|
||||
let finalChunk: boolean
|
||||
do {
|
||||
signal?.throwIfAborted()
|
||||
let end = Math.min(offset + PUSH_CHUNK_CODE_UNITS, content.length)
|
||||
if (end < content.length && end - offset > 1) {
|
||||
// Back off one code unit when the boundary lands inside a surrogate
|
||||
// pair: the pair then starts the next chunk whole.
|
||||
const last = content.charCodeAt(end - 1)
|
||||
if (last >= 0xd800 && last <= 0xdbff) end -= 1
|
||||
}
|
||||
finalChunk = end >= content.length
|
||||
deflate.push(encoder.encode(content.slice(offset, end)), finalChunk)
|
||||
offset = end
|
||||
/* v8 ignore next 2 -- only fires when a slow consumer leaves the queue over-full */
|
||||
if (controller.desiredSize !== null && controller.desiredSize < 0) {
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
}
|
||||
} while (!finalChunk)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream one session-log ZIP as a WHATWG ReadableStream. The root artifact is
|
||||
* read and validated by the caller before this is called (missing root or
|
||||
* missing services answer cleanly before any byte is produced); each entry is
|
||||
* then encoded and deflated in bounded chunks as it is produced, so the
|
||||
* archive bytes arrive incrementally. A descendant that fails to read errors
|
||||
* the stream (fail-loud, never silent under-export).
|
||||
* @param deps - the mounted export services (the caller answered 500 before this runs).
|
||||
* @param root - the already-read root artifact (first zip entry).
|
||||
* @param sessionId - the root session id.
|
||||
* @param includeDescendants - whether to include every subagent descendant.
|
||||
* @param signal - optional cancellation for read work.
|
||||
* @returns the zip byte stream.
|
||||
*/
|
||||
export function streamSessionLogZip(
|
||||
deps: SessionLogExportReady,
|
||||
root: SessionRawArtifact,
|
||||
sessionId: SessionId,
|
||||
includeDescendants: boolean,
|
||||
signal?: AbortSignal,
|
||||
): ReadableStream<Uint8Array> {
|
||||
return new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
// fflate invokes the callback synchronously per compressed chunk, so a
|
||||
// single push can enqueue ahead of a slow consumer; pushArtifactChunks
|
||||
// yields between chunks once the queue is over-full, bounding the
|
||||
// accumulation to the queue high-water mark plus one push.
|
||||
const zip = new Zip((error, data, final) => {
|
||||
/* v8 ignore next 3 -- fflate reports only internal zip failures, unreachable for valid inputs */
|
||||
if (error) {
|
||||
controller.error(error)
|
||||
return
|
||||
}
|
||||
/* v8 ignore next -- fflate may emit empty chunks; not controllable from tests */
|
||||
if (data.byteLength > 0) controller.enqueue(data)
|
||||
if (final) controller.close()
|
||||
})
|
||||
void (async () => {
|
||||
try {
|
||||
for await (const entry of sessionLogZipEntries(deps, root, sessionId, includeDescendants, signal)) {
|
||||
const deflate = new ZipDeflate(entry.path, { level: 6 })
|
||||
zip.add(deflate)
|
||||
if ('content' in entry) {
|
||||
await pushArtifactChunks(deflate, entry.content, controller, signal)
|
||||
} else {
|
||||
await pushBinaryChunks(deflate, entry.data, controller, signal)
|
||||
}
|
||||
}
|
||||
zip.end()
|
||||
} catch (error) {
|
||||
// A mid-stream failure (missing descendant, cancellation, read
|
||||
// error) must fail the download rather than ship a truncated archive.
|
||||
/* v8 ignore next -- typed backends reject with Error, and DOMException is one in Node */
|
||||
controller.error(error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
})()
|
||||
},
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user