feat(web): include referenced media in the session-log export
The export ZIP now carries every image any included log references under media/<attachmentId>.<ext>, read and verified from the attachment store with one entry per shared image. The endpoint requires the attachments service alongside persistence and session-query; a referenced image that cannot be read fails the stream like a missing descendant.
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
|
||||
README.md: bfca6cc0de90426df642706dda747647fe092c57
|
||||
README.zh.md: f1db336c42cb4e262a10ebb732194ad98089e05f
|
||||
README.md: 5fe19af8069766c56f8926ccef88dc1d9fb3c950
|
||||
README.zh.md: bdb26a63832c1461b4e56798e64c1253a916118d
|
||||
|
||||
@@ -28,7 +28,7 @@ Question responses are validated against their pending request before the first
|
||||
|
||||
`session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface.
|
||||
|
||||
Session-log export is a host-only download surface, not an RPC: `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's `readRaw` — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under `subagents/<id>/`. Compression runs on the host with fflate's streaming Zip API, so the response is chunked as it is produced and the host never holds the whole archive in one buffer, and production yields whenever the response queue fills, so a slow consumer bounds the accumulation (fflate's callback is synchronous — the drain point is the only backpressure). It requires both the persistence and session-query services: a deployment without either answers 500, a missing root session 404, and a descendant without a stored artifact fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; `ApiProxy.downloads.sessionLog` implements it.
|
||||
Session-log export is a host-only download surface, not an RPC: `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's `readRaw` — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under `subagents/<id>/`, and every image any included log references under `media/<attachmentId>.<ext>` (read and verified from the attachment store; a shared image appears once). Compression runs on the host with fflate's streaming Zip API, so the response is chunked as it is produced and the host never holds the whole archive in one buffer, and production yields whenever the response queue fills, so a slow consumer bounds the accumulation (fflate's callback is synchronous — the drain point is the only backpressure). It requires the persistence, session-query, and attachment services: a deployment without any answers 500, a missing root session 404, and a descendant without a stored artifact or a referenced image that cannot be read fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; `ApiProxy.downloads.sessionLog` implements it.
|
||||
|
||||
Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key. Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`.
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中
|
||||
|
||||
`session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。
|
||||
|
||||
会话日志导出是宿主侧的下载面,不是 RPC:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP,其中每个文件都是会话存储工件的逐字原文(持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents/<id>/` 下。压缩在宿主侧用 fflate 的流式 Zip API 完成,响应边生成边分块写出,宿主从不把整个归档放进单个缓冲区,且每当响应队列填满时生产会让出,慢消费者因此只产生有界的积压(fflate 的回调是同步的——让出点是唯一的背压手段)。它要求同时挂载持久化与 session-query 服务:任一缺失应答 500,根会话缺失应答 404,后代缺少存储工件则整个流失败(fail-loud,绝不静默少导出)。端点由传输层挂载,`ApiProxy.downloads.sessionLog` 实现它。
|
||||
会话日志导出是宿主侧的下载面,不是 RPC:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP,其中每个文件都是会话存储工件的逐字原文(持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents/<id>/` 下,每个被任何包含的日志引用的图片放在 `media/<attachmentId>.<ext>` 下(从附件存储读取并校验;共享图片只出现一次)。压缩在宿主侧用 fflate 的流式 Zip API 完成,响应边生成边分块写出,宿主从不把整个归档放进单个缓冲区,且每当响应队列填满时生产会让出,慢消费者因此只产生有界的积压(fflate 的回调是同步的——让出点是唯一的背压手段)。它要求同时挂载持久化、session-query 与附件服务:任一缺失应答 500,根会话缺失应答 404,后代缺少存储工件或引用的图片无法读取则整个流失败(fail-loud,绝不静默少导出)。端点由传输层挂载,`ApiProxy.downloads.sessionLog` 实现它。
|
||||
|
||||
会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。
|
||||
|
||||
|
||||
@@ -3435,15 +3435,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
|
||||
// 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) {
|
||||
if (deps.sessionQuery === undefined || deps.sessionPersistence === undefined || deps.attachments === undefined) {
|
||||
return new Response(
|
||||
'session log export is unavailable: missing session-query or session-persistence service',
|
||||
'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 {
|
||||
|
||||
@@ -1,21 +1,24 @@
|
||||
/**
|
||||
* Host-side session-log download: streams one ZIP archive whose files are the
|
||||
* sessions' stored artifact text verbatim. The root artifact sits under its
|
||||
* original base name (`session.jsonl`); each subagent descendant under
|
||||
* `subagents/<id>/<filename>`. No manifest is written — every file is
|
||||
* byte-identical to the backend's durable artifact and self-describing
|
||||
* through its own header line. 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).
|
||||
* 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'
|
||||
@@ -24,16 +27,18 @@ import type { SessionPersistence, SessionRawArtifact } from '@deepseek-ai/dsh-se
|
||||
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 and session-query services a log export needs.
|
||||
* 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).
|
||||
*/
|
||||
@@ -41,15 +46,102 @@ export function sessionLogExportDeps(ctx: Context): SessionLogExportDeps {
|
||||
return {
|
||||
sessionQuery: ctx.get('sessionQuery'),
|
||||
sessionPersistence: ctx.get('sessionPersistence'),
|
||||
attachments: ctx.get('attachments'),
|
||||
}
|
||||
}
|
||||
|
||||
/** One exported artifact: the stored text plus the zip path it lands at. */
|
||||
export interface SessionLogZipEntry {
|
||||
/** Zip entry path (root filename verbatim; descendants under `subagents/<id>/`). */
|
||||
readonly path: string
|
||||
/** The stored artifact text verbatim. */
|
||||
readonly content: string
|
||||
/** 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
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,10 +168,12 @@ export function sessionLogZipFilename(sessionId: string): string {
|
||||
|
||||
/**
|
||||
* Yield the export entries in zip order: the preloaded root artifact first,
|
||||
* then every subagent descendant in lineage order, each read from the
|
||||
* 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 (the host holds at most one descendant's artifact text at
|
||||
* a time beyond the root).
|
||||
* 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).
|
||||
@@ -95,35 +189,77 @@ export async function* sessionLogZipEntries(
|
||||
includeDescendants: boolean,
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<SessionLogZipEntry> {
|
||||
yield { path: root.filename, content: root.content }
|
||||
if (!includeDescendants) return
|
||||
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`)
|
||||
}
|
||||
yield {
|
||||
path: `subagents/${safeSessionIdSegment(id)}/${raw.filename}`,
|
||||
content: raw.content,
|
||||
}
|
||||
yield* collect(node.descendants)
|
||||
}
|
||||
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 }
|
||||
}
|
||||
const lineage = await deps.sessionQuery.traceSession(sessionId)
|
||||
yield* collect(lineage.descendants)
|
||||
}
|
||||
|
||||
/** 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
|
||||
@@ -202,7 +338,11 @@ export function streamSessionLogZip(
|
||||
for await (const entry of sessionLogZipEntries(deps, root, sessionId, includeDescendants, signal)) {
|
||||
const deflate = new ZipDeflate(entry.path, { level: 6 })
|
||||
zip.add(deflate)
|
||||
await pushArtifactChunks(deflate, entry.content, controller, signal)
|
||||
if ('content' in entry) {
|
||||
await pushArtifactChunks(deflate, entry.content, controller, signal)
|
||||
} else {
|
||||
await pushBinaryChunks(deflate, entry.data, controller, signal)
|
||||
}
|
||||
}
|
||||
zip.end()
|
||||
} catch (error) {
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { unzipSync, strFromU8 } from 'fflate'
|
||||
import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment'
|
||||
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
|
||||
import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type { SessionLineageNode } from '@deepseek-ai/dsh-session-query'
|
||||
@@ -28,11 +29,11 @@ function header(id: string, parentSession?: SessionId): SessionHeader {
|
||||
}
|
||||
}
|
||||
|
||||
function artifact(id: string, parentSession?: SessionId): SessionRawArtifact {
|
||||
function artifact(id: string, parentSession?: SessionId, content?: string): SessionRawArtifact {
|
||||
return {
|
||||
meta: header(id, parentSession),
|
||||
filename: 'session.jsonl',
|
||||
content: `{"type":"session","version":0,"id":"${id}","createdAt":1000}\n{"type":"turn/start","seq":0,"time":2000,"data":{"turn":1}}\n`,
|
||||
content: content ?? `{"type":"session","version":0,"id":"${id}","createdAt":1000}\n{"type":"turn/start","seq":0,"time":2000,"data":{"turn":1}}\n`,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,14 +41,33 @@ function node(id: string, ...descendants: SessionLineageNode[]): SessionLineageN
|
||||
return { session: { header: header(id, sid('session-root')), live: false, persisted: true }, descendants }
|
||||
}
|
||||
|
||||
/** One durable image object served by the fake attachment store. */
|
||||
function storedImage(id: string, mediaType: ImageAttachmentRef['mediaType'] = 'image/png') {
|
||||
return {
|
||||
ref: { attachmentId: sid(id), mediaType, bytes: 4, width: 2, height: 2 } as unknown as ImageAttachmentRef,
|
||||
data: new Uint8Array([1, 2, 3, 4]),
|
||||
}
|
||||
}
|
||||
|
||||
/** A user/message event line carrying one image reference. */
|
||||
function imageEventLine(id: string, mediaType: ImageAttachmentRef['mediaType'] = 'image/png'): string {
|
||||
return `{"type":"user/message","seq":1,"time":1000,"data":{"content":[{"type":"image","attachment":{"attachmentId":"${id}","mediaType":"${mediaType}","bytes":4,"width":2,"height":2}}]}}`
|
||||
}
|
||||
|
||||
async function buildApi(
|
||||
artifacts: Record<string, SessionRawArtifact>,
|
||||
descendants: SessionLineageNode[] = [],
|
||||
services: { query?: boolean; persistence?: boolean | 'throw' } = { query: true, persistence: true },
|
||||
services: {
|
||||
query?: boolean
|
||||
persistence?: boolean | 'throw'
|
||||
attachments?: boolean | ((ref: ImageAttachmentRef) => Promise<ReturnType<typeof storedImage>>)
|
||||
} = {},
|
||||
) {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(UserInteractionService)
|
||||
if (services.query) {
|
||||
const query = services.query ?? true
|
||||
const persistence = services.persistence ?? true
|
||||
if (query) {
|
||||
ctx.provide('sessionQuery', {
|
||||
traceSession: async () => ({
|
||||
target: { header: header('session-root'), live: false, persisted: true },
|
||||
@@ -58,14 +78,25 @@ async function buildApi(
|
||||
}),
|
||||
} as never)
|
||||
}
|
||||
if (services.persistence) {
|
||||
if (persistence) {
|
||||
ctx.provide('sessionPersistence', {
|
||||
readRaw: async (id: SessionId) => {
|
||||
if (services.persistence === 'throw') throw new Error('/host/private/session.jsonl')
|
||||
if (persistence === 'throw') throw new Error('/host/private/session.jsonl')
|
||||
return artifacts[id]
|
||||
},
|
||||
} as never)
|
||||
}
|
||||
if (services.attachments !== false) {
|
||||
const readImage = typeof services.attachments === 'function'
|
||||
? services.attachments
|
||||
: async (ref: ImageAttachmentRef) => storedImage(String(ref.attachmentId), ref.mediaType)
|
||||
ctx.provide('attachments', {
|
||||
imageLimits: {} as never,
|
||||
validateImage: async () => {},
|
||||
saveImage: async () => { throw new Error('export never saves images') },
|
||||
readImage,
|
||||
} as never)
|
||||
}
|
||||
return createApiProxy(ctx, {
|
||||
defaultModelSelection: () => ({ provider: 'p', model: 'm' }),
|
||||
cwd: '/tmp',
|
||||
@@ -226,4 +257,121 @@ describe('session.export download endpoint', () => {
|
||||
expect(body).toBe('session log export failed to read the stored artifact')
|
||||
expect(body).not.toContain('/host/private/')
|
||||
})
|
||||
|
||||
it('includes media objects referenced by the root log under media/<id>.<ext>', async () => {
|
||||
const root = artifact('session-root', undefined, [
|
||||
'{"type":"session","version":0,"id":"session-root","createdAt":1000}',
|
||||
imageEventLine('img-1'),
|
||||
].join('\n') + '\n')
|
||||
const api = await buildApi({ 'session-root': root })
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
const files = unzipSync(await responseBytes(response))
|
||||
expect(Object.keys(files).sort()).toEqual(['media/img-1.png', 'session.jsonl'])
|
||||
expect(files['media/img-1.png']).toEqual(storedImage('img-1').data)
|
||||
})
|
||||
|
||||
it('collects media referenced from nested tool results', async () => {
|
||||
const nested = '{"type":"assistant/message","seq":2,"time":2000,"data":{"content":[{"type":"tool-result","content":[{"type":"image","attachment":{"attachmentId":"nested-1","mediaType":"image/webp","bytes":4,"width":2,"height":2}}]}]}}'
|
||||
const root = artifact('session-root', undefined, [
|
||||
'{"type":"session","version":0,"id":"session-root","createdAt":1000}',
|
||||
nested,
|
||||
].join('\n') + '\n')
|
||||
const api = await buildApi({ 'session-root': root })
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
const files = unzipSync(await responseBytes(response))
|
||||
expect(Object.keys(files).sort()).toEqual(['media/nested-1.webp', 'session.jsonl'])
|
||||
})
|
||||
|
||||
it('scans the wrapped, inserted, and chunk carriers plus non-object content items', async () => {
|
||||
const block = (id: string, mediaType: string) =>
|
||||
`{"type":"image","attachment":{"attachmentId":"${id}","mediaType":"${mediaType}","bytes":4,"width":2,"height":2}}`
|
||||
const wrapped = `{"type":"assistant/message","seq":2,"time":2000,"data":{"message":{"role":"assistant","content":["noise",${block('wrapped-1', 'image/jpeg')}]}}}`
|
||||
const inserted = `{"type":"context/inserted","seq":3,"time":3000,"data":{"inserted":[{"content":[${block('inserted-1', 'image/gif')}]}]}}`
|
||||
const chunk = `{"type":"assistant/chunk","seq":4,"time":4000,"data":{"chunk":{"type":"block-end","block":${block('chunk-1', 'image/png')}}}}`
|
||||
const root = artifact('session-root', undefined, [
|
||||
'{"type":"session","version":0,"id":"session-root","createdAt":1000}',
|
||||
wrapped,
|
||||
inserted,
|
||||
chunk,
|
||||
].join('\n') + '\n')
|
||||
const api = await buildApi({ 'session-root': root })
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
const files = unzipSync(await responseBytes(response))
|
||||
expect(Object.keys(files).sort()).toEqual([
|
||||
'media/chunk-1.png',
|
||||
'media/inserted-1.gif',
|
||||
'media/wrapped-1.jpg',
|
||||
'session.jsonl',
|
||||
])
|
||||
})
|
||||
|
||||
it('deduplicates one media object referenced by several included logs', async () => {
|
||||
const line = imageEventLine('shared-img')
|
||||
const root = artifact('session-root', undefined, [
|
||||
'{"type":"session","version":0,"id":"session-root","createdAt":1000}',
|
||||
line,
|
||||
].join('\n') + '\n')
|
||||
const child = artifact('child-a', sid('session-root'), [
|
||||
'{"type":"session","version":0,"id":"child-a","createdAt":1000}',
|
||||
line,
|
||||
].join('\n') + '\n')
|
||||
const api = await buildApi({ 'session-root': root, 'child-a': child }, [node('child-a')])
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
|
||||
)
|
||||
const files = unzipSync(await responseBytes(response))
|
||||
expect(files['media/shared-img.png']).toEqual(storedImage('shared-img').data)
|
||||
expect(Object.keys(files).filter(name => name.startsWith('media/'))).toEqual(['media/shared-img.png'])
|
||||
})
|
||||
|
||||
it('includes descendant media only when descendants are requested', async () => {
|
||||
const child = artifact('child-a', sid('session-root'), [
|
||||
'{"type":"session","version":0,"id":"child-a","createdAt":1000}',
|
||||
imageEventLine('child-img'),
|
||||
].join('\n') + '\n')
|
||||
const api = await buildApi({ 'session-root': artifact('session-root'), 'child-a': child }, [node('child-a')])
|
||||
const without = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
expect(Object.keys(unzipSync(await responseBytes(without)))).toEqual(['session.jsonl'])
|
||||
const withDescendants = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'),
|
||||
)
|
||||
expect(Object.keys(unzipSync(await responseBytes(withDescendants))).sort()).toEqual([
|
||||
'media/child-img.png',
|
||||
'session.jsonl',
|
||||
'subagents/child-a/session.jsonl',
|
||||
])
|
||||
})
|
||||
|
||||
it('fails the whole export when a referenced image cannot be read', async () => {
|
||||
const root = artifact('session-root', undefined, [
|
||||
'{"type":"session","version":0,"id":"session-root","createdAt":1000}',
|
||||
imageEventLine('gone-img'),
|
||||
].join('\n') + '\n')
|
||||
const api = await buildApi({ 'session-root': root }, [], {
|
||||
attachments: async () => { throw new Error('attachment bytes missing') },
|
||||
})
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.arrayBuffer()).rejects.toThrow('attachment bytes missing')
|
||||
})
|
||||
|
||||
it('answers 500 when the deployment mounts no attachments service', async () => {
|
||||
const api = await buildApi({ 'session-root': artifact('session-root') }, [], { attachments: false })
|
||||
const response = await toFetchHandler(api).fetch(
|
||||
new Request('http://host/api/session.export?sessionId=session-root'),
|
||||
)
|
||||
expect(response.status).toBe(500)
|
||||
expect(await response.text()).toContain('attachments')
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user