e58cc13de4
SessionPersistence.readRaw previously used undefined for two unrelated states: a supported backend could not find the requested session, or the backend had no per-session artifact concept at all. The export endpoint consequently reported an existing SQLite-backed session as HTTP 404, which falsely diagnosed storage capability as session absence. Make raw-artifact support an explicit backend capability. Unsupported backends now fail their inherited readRaw path loudly and the host answers 501 before reading, while undefined retains the single meaning of an absent artifact on a supporting backend. First-party backends, test providers, generated API catalogs, bilingual persistence docs, and export error contracts now state that distinction; focused tests cover both the 501 and the inherited rejection.
5.1 KiB
5.1 KiB
Agent Note: Web session-log export as a host-streamed ZIP download
Status: implemented
English | 中文
Problem
The Trajectory view had no way to hand a debugging artifact to a human: the raw session log lived on disk and in the host, the client history face served folded projections (not raw entries), and a session with subagents spans many independent session logs. A bug report needs the complete raw log of the whole tree, in a shape that survives being emailed around.
Decision
- The export is a host-only download, not an RPC:
GET /api/session.export?sessionId=…&includeDescendants=truestreams one ZIP attachment. Every file is a session's stored artifact text verbatim:readRawon the persistence service reads the backend's own durable bytes (the JSONL backend decodes its physical zstd frames, or returns plaintext) — never a reconstruction from parsed events, so packed-chunk rows, key order, and line breaks survive byte-for-byte — under its original base name (session.jsonlat the root,subagents/<id>/session.jsonlfor descendants). Compression runs on the host with fflate's streamingZip/ZipDeflateAPI, each entry deflated in bounded chunks as it is produced, so the response is chunked as it is generated and the host never holds the whole archive in one buffer (at most one descendant's artifact text beyond the preloaded root), 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). No manifest is written — every file is byte-identical to the durable artifact and self-describing through its own header line. - Error vocabulary is HTTP-native: missing services → 500, a backend without per-session raw artifacts → 501, missing root session → 404 (all decided before any byte streams), and a descendant without a stored artifact → the stream errors (fail-loud, never silent under-export). The carrier (
toFetchHandler) already applies the/apitrust fence; the GET branch sits beside the existing SSE GET routes, andApiProxy.downloads.sessionLog(host-only, no wire envelope, absent fromIApiClient) implements it. - The UI just downloads: the 导出 button hands the endpoint directly to the browser's native download manager, so JavaScript neither fetches nor buffers the ZIP; the
session.logRPC that an earlier iteration shipped was removed — the download endpoint is its only consumer, and the repo rule is no public interface without a current owner. The client bundle carries no archive implementation. - The 导出 button lives in the Trajectory toolbar; the plugin exposes
exportLogthrough the view's inject face (components never touch ctx) and resolves the view tab label through the locale service (轨迹in Chinese,Trajectoryin English). In-flight state disables the button during the handoff; a synchronous browser-handoff failure surfaces in a visible alert bar, while HTTP delivery is owned and reported by the browser.
Alternatives considered
session.logdata RPC + client-side zip — shipped first, rejected with the user: the browser pulls the full raw JSON (≈10× the final zip size) and compresses on the main thread; for the 23 MB sessions in real use the host-side stream is strictly better. The RPC was deleted with the migration rather than left as a dead public surface.- Single JSONL with envelope lines for multiple sessions — rejected with the user: mixing sessions in one JSONL loses clean per-file boundaries; a ZIP keeps one canonical file per session.
- jszip — heavier (~100 kB) and its dependency graph pulls readable-stream browser mappings; fflate is purpose-built and small.
- Vendoring fflate's browser entry — the repo vendoring procedure targets cordis-scale pinned sources; a resolveId alias keeps the maintained dependency without shipping a copy (and host-side fflate needs no alias at all).
Consequences
- Export fidelity: every exported file is byte-identical to the backend's durable artifact as of the read moment (a live session may append after the read; the export reflects the durable state at read time). The archive name is
dsh-session-<sanitized-id>.zipand archive paths sanitize ids before they can shape entries. supportsRawArtifactsexplicitly separates backend capability from session absence: unsupported backends such as SQLite reportfalseand the concretereadRawdefault rejects, while the JSONL override reportstrue, owns physical decoding, and reservesundefinedfor an absent artifact.ApiProxy.downloads.sessionLogadds one host-only member to the contract plus a host-side query schema and a GET branch in the fetch handler — no RPC map row, envelope schema, or clientIApiClientsurface.- Fixture mode (no host) answers 404 for the export, which the browser reports as a failed download; the navigation-panes golden snapshot includes the 导出 button.
- Deferred: transcript.md and a report/feedback bundle remain future work; the byte-faithful, manifest-free shape keeps the v2 bundle extension cheap.