From 80b7f929ea1cd82f68e47e47996bb6042cd1876f Mon Sep 17 00:00:00 2001 From: _Kerman Date: Mon, 10 Aug 2026 17:47:17 +0800 Subject: [PATCH] feat(session-persistence): expose readRaw for per-session artifacts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The persistence contract gains a concrete readRaw default (undefined for backends without a per-session artifact) and the JSONL backend overrides it with the decode of its physical zstd frames, so a consumer can read the stored artifact text verbatim — the session-log export depends on it. --- docs/config-catalog.i18n.yaml | 2 +- docs/config-catalog.md | 2 +- docs/subsystems/persistence.i18n.yaml | 4 +- docs/subsystems/persistence.md | 17 +++++- docs/subsystems/persistence.zh.md | 17 +++++- .../tool-cordis/src/api-catalog.ts | 8 +++ .../session-persistence-jsonl/src/index.ts | 58 ++++++++++++++++++- .../tests/jsonl.spec.ts | 20 +++++++ .../tests/zstd.spec.ts | 21 +++++++ .../session/session-persistence/src/index.ts | 28 +++++++++ scripts/gen-cordis-catalog.ts | 1 + 11 files changed, 171 insertions(+), 7 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 09c961ef69..e870a59292 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -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 docs/config-catalog.md -config-catalog.md: 51c6ae46eeca1279390c9d9315a6161edd2de618 +config-catalog.md: 0d1d2ddde31a7ea806ec273007d7b5743a553e3b config-catalog.zh.md: dc93f5b4b55b07c52c58405ba4793c2c6eca28df diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 51c6ae46ee..0d1d2ddde3 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1395,7 +1395,7 @@ export interface Config { export type JsonlCompression = 'zstd' | 'none' ``` -Source: [`packages/session/session-persistence-jsonl/src/index.ts:59`](../packages/session/session-persistence-jsonl/src/index.ts) +Source: [`packages/session/session-persistence-jsonl/src/index.ts:60`](../packages/session/session-persistence-jsonl/src/index.ts) ## `@deepseek-ai/dsh-session-persistence-sqlite` diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index 65925a1608..fa128a80d0 100644 --- a/docs/subsystems/persistence.i18n.yaml +++ b/docs/subsystems/persistence.i18n.yaml @@ -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 docs/subsystems/persistence.md -persistence.md: 0266d17393d07c258036f7054a02c4ab9d3c74a2 -persistence.zh.md: ced83440160ae91ae37025d8024068fb8148b0c6 +persistence.md: 25541139de02d2bd3ea743fe530e47a628002695 +persistence.zh.md: d76ff93f5564e7612fd7107cd74e3138c03542d8 diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index 0266d17393..25541139de 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -237,6 +237,21 @@ Durable append-only session storage. Implementations preserve contiguous, lossle */ abstract locate(meta: SessionHeader): SessionLocation | undefined +/** + * Read a session's backend-owned artifact text verbatim — the exact durable + * bytes the backend wrote (decoded from its physical encoding, e.g. a + * decompressed JSONL). The returned `content` is the raw text, not a + * reconstruction from parsed events, so it preserves backend-specific + * serialization (chunk packing, key order, line breaks). Backends without a + * per-session artifact (SQLite) inherit the `undefined` default. + * @param _id - the persisted session to read (unused by the default: no + * per-session artifact). + * @param signal - optional cancellation for backend read work. + * @returns the raw artifact plus its parsed header, or `undefined` when the + * session is absent or the backend owns no per-session artifact. + */ +readRaw(_id: SessionId, signal?: AbortSignal): Promise + /** * Register a new session's metadata. A backend MAY defer the physical write * until the first {@link append} (lazy materialization), in which case a @@ -342,5 +357,5 @@ abstract listSnapshots(signal?: AbortSignal): Promise diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index ced8344016..d76ff93f55 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -237,6 +237,21 @@ Durable append-only session storage. Implementations preserve contiguous, lossle */ abstract locate(meta: SessionHeader): SessionLocation | undefined +/** + * Read a session's backend-owned artifact text verbatim — the exact durable + * bytes the backend wrote (decoded from its physical encoding, e.g. a + * decompressed JSONL). The returned `content` is the raw text, not a + * reconstruction from parsed events, so it preserves backend-specific + * serialization (chunk packing, key order, line breaks). Backends without a + * per-session artifact (SQLite) inherit the `undefined` default. + * @param _id - the persisted session to read (unused by the default: no + * per-session artifact). + * @param signal - optional cancellation for backend read work. + * @returns the raw artifact plus its parsed header, or `undefined` when the + * session is absent or the backend owns no per-session artifact. + */ +readRaw(_id: SessionId, signal?: AbortSignal): Promise + /** * Register a new session's metadata. A backend MAY defer the physical write * until the first {@link append} (lazy materialization), in which case a @@ -342,5 +357,5 @@ abstract listSnapshots(signal?: AbortSignal): Promise diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 9e63aa3ea3..5f4d9a124b 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -688,6 +688,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'abstract locate(meta: SessionHeader): SessionLocation | undefined', jsDoc: '/**\n * Resolve this backend\'s independent local artifact for a session without\n * reading, creating, flushing, or otherwise materializing it. Backends such\n * as SQLite that do not own one artifact per session return `undefined`.\n * @param meta - the immutable session header whose artifact is requested.\n * @returns the backend-specific absolute location, when one exists.\n */', }, + { + signature: 'readRaw(_id: SessionId, signal?: AbortSignal): Promise', + jsDoc: '/**\n * Read a session\'s backend-owned artifact text verbatim — the exact durable\n * bytes the backend wrote (decoded from its physical encoding, e.g. a\n * decompressed JSONL). The returned `content` is the raw text, not a\n * reconstruction from parsed events, so it preserves backend-specific\n * serialization (chunk packing, key order, line breaks). Backends without a\n * per-session artifact (SQLite) inherit the `undefined` default.\n * @param _id - the persisted session to read (unused by the default: no\n * per-session artifact).\n * @param signal - optional cancellation for backend read work.\n * @returns the raw artifact plus its parsed header, or `undefined` when the\n * session is absent or the backend owns no per-session artifact.\n */', + }, { signature: 'abstract create(meta: SessionHeader): Promise', jsDoc: '/**\n * Register a new session\'s metadata. A backend MAY defer the physical write\n * until the first {@link append} (lazy materialization), in which case a\n * created-but-never-appended session is absent from {@link list}\n * — abandoned sessions leave nothing behind.\n * @param meta - the immutable header (id, version, cwd, lineage) to record.\n */', @@ -2739,6 +2743,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionProjectionMap', declaration: 'export interface SessionProjectionMap {\n}', }, + { + name: 'SessionRawArtifact', + declaration: 'export interface SessionRawArtifact {\n readonly meta: SessionHeader;\n readonly filename: string;\n readonly content: string;\n}', + }, { name: 'SessionRecord', declaration: 'export interface SessionRecord {\n header: SessionHeader;\n live: boolean;\n persisted: boolean;\n}', diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index 8d4aad8e7a..b339f2d736 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -18,7 +18,8 @@ import { DEFAULT_PREPARED_SESSION_CACHE_SIZE, DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS, SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot, - type SessionInspection, type SessionPersistenceRevision as PersistenceRevision, type StoredPrefix, + type SessionInspection, type SessionPersistenceRevision as PersistenceRevision, type SessionRawArtifact, + type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' import type { SessionEvent, SessionId, SessionHeader, SessionPreparation } from '@deepseek-ai/dsh-session' import { @@ -233,6 +234,61 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } } + /** + * Read a session's stored artifact text verbatim: the durable file bytes + * decoded from this backend's physical encoding (complete zstd frames + * concatenated, or UTF-8 plaintext). The content is the exact JSONL text the + * backend wrote — never a reconstruction from parsed events — so packed- + * chunk rows, key order, and line breaks survive byte-for-byte. A torn + * final frame is omitted, matching the committed-prefix semantics of every + * other read. + * @param id - the persisted session to read. + * @param signal - optional cancellation for the stat/read/decode work. + * @returns the raw artifact text plus the header parsed from its own first + * line, or `undefined` when the session has no stored artifact. + */ + override async readRaw(id: SessionId, signal?: AbortSignal): Promise { + signal?.throwIfAborted() + await this.ensureRootEncoding() + signal?.throwIfAborted() + const path = await this.findLog(id, signal) + if (path === undefined) return undefined + let buffer: Buffer + // Revision-stable read: a writer appending between stat and readFile + // would yield a torn physical file (see readPrefix). + for (;;) { + signal?.throwIfAborted() + const before = fileRevision(await stat(path, { bigint: true })) + buffer = await readFile(path, { signal }) + signal?.throwIfAborted() + const after = fileRevision(await stat(path, { bigint: true })) + if (before === after) break + } + let content: string + if (this.compression === 'zstd') { + const { frames } = scanZstdFrames(buffer) + if (frames.length === 0) return undefined + const decoder = createZstdFrameDecoder() + const plaintexts: Buffer[] = [] + // The decoder yields views into a reused buffer; copy each frame's + // plaintext immediately so a later concat cannot read overwritten memory. + for (const plaintext of decoder.decode(buffer, frames)) { + signal?.throwIfAborted() + plaintexts.push(Buffer.from(plaintext)) + } + content = Buffer.concat(plaintexts).toString('utf8') + } else { + content = buffer.toString('utf8') + } + const meta = parseHeaderMeta(content.split('\n', 1)[0] as string) + if (meta === undefined || meta.id !== id) { + throw new Error(`corrupt session log: invalid header line in "${path}"`) + } + // The logical artifact name is `session.jsonl` regardless of the physical + // encoding suffix (`.jsonl.zstd` marks compression only). + return { meta, filename: 'session.jsonl', content } + } + /** * Read a stored prefix and convert torn-tail state to the opaque marker the * coordinator can round-trip without knowing the physical encoding. diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index c7b4ab8841..390d0e9048 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -217,6 +217,26 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { expect((await ctx.sessionPersistence.list()).map(h => h.id)).toContain(m.id) }) + it('readRaw returns the stored artifact text verbatim with its original filename', async () => { + const m = meta('raw-read', '/work') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const raw = await ctx.sessionPersistence.readRaw(m.id) + expect(raw).toBeDefined() + expect(raw!.filename).toBe('session.jsonl') + expect(raw!.meta.id).toBe(m.id) + // Byte-identical to the physical file — never a reconstruction. + expect(raw!.content).toBe(await readFile(rawLogPath(root, '/work', m.id), 'utf8')) + expect(raw!.content.split('\n')[0]).toBe(JSON.stringify(toHeaderLine(m))) + const scanned = scanLog(Buffer.from(raw!.content)) + expect(scanned.events.map(event => event.type)).toEqual(oneTurnLog().map(event => event.type)) + }) + + it('readRaw is undefined for an absent session', async () => { + const m = meta('raw-missing', '/work') + expect(await ctx.sessionPersistence.readRaw(m.id)).toBeUndefined() + }) + it('keeps the same location on resume and gives a fork its own location', async () => { const parent = meta('location-parent', '/work') const parentLocation = ctx.sessionPersistence.locate(parent) diff --git a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts index b459fcac43..37d3e6c13b 100644 --- a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts @@ -356,6 +356,27 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => { expect((await ctx.sessionPersistence.load(header.id)).events).toEqual(oneTurnLog()) }) + it('readRaw decodes the compressed artifact back to the original JSONL text', async () => { + const root = await freshRoot() + const ctx = await mount(root) + const header = meta('raw-read-zstd', '/work') + await ctx.sessionPersistence.create(header) + await ctx.sessionPersistence.append(header.id, oneTurnLog()) + + const raw = await ctx.sessionPersistence.readRaw(header.id) + expect(raw).toBeDefined() + // The logical name drops the physical encoding suffix. + expect(raw!.filename).toBe('session.jsonl') + expect(raw!.meta.id).toBe(header.id) + expect(raw!.content).toBe([ + JSON.stringify(toHeaderLine(header)), + ...oneTurnLog().map(e => JSON.stringify(e)), + '', + ].join('\n')) + const scanned = scanLog(Buffer.from(raw!.content)) + expect(scanned.events.map(event => event.type)).toEqual(oneTurnLog().map(event => event.type)) + }) + it('resolves the default when a programmatic wrapper bypasses Loader schema normalization', async () => { const root = await freshRoot() const ctx = new Context() diff --git a/packages/session/session-persistence/src/index.ts b/packages/session/session-persistence/src/index.ts index 5c3df73b30..0889a60edf 100644 --- a/packages/session/session-persistence/src/index.ts +++ b/packages/session/session-persistence/src/index.ts @@ -30,6 +30,16 @@ export interface SessionInspection { readonly events: readonly SessionEvent[] } +/** A backend's own raw artifact text for one session, verbatim. */ +export interface SessionRawArtifact { + /** The session header parsed from the artifact's own first line. */ + readonly meta: SessionHeader + /** The artifact's base filename on disk, without any physical encoding suffix. */ + readonly filename: string + /** The artifact's full text content, decoded from the backend's physical encoding. */ + readonly content: string +} + // The backend-agnostic write-path orchestration first-party backends compose. export { DEFAULT_PREPARED_SESSION_CACHE_SIZE, @@ -83,6 +93,24 @@ export abstract class SessionPersistence extends Service { */ abstract locate(meta: SessionHeader): SessionLocation | undefined + /** + * Read a session's backend-owned artifact text verbatim — the exact durable + * bytes the backend wrote (decoded from its physical encoding, e.g. a + * decompressed JSONL). The returned `content` is the raw text, not a + * reconstruction from parsed events, so it preserves backend-specific + * serialization (chunk packing, key order, line breaks). Backends without a + * per-session artifact (SQLite) inherit the `undefined` default. + * @param _id - the persisted session to read (unused by the default: no + * per-session artifact). + * @param signal - optional cancellation for backend read work. + * @returns the raw artifact plus its parsed header, or `undefined` when the + * session is absent or the backend owns no per-session artifact. + */ + readRaw(_id: SessionId, signal?: AbortSignal): Promise { + signal?.throwIfAborted() + return Promise.resolve(undefined) + } + /** * Register a new session's metadata. A backend MAY defer the physical write * until the first {@link append} (lazy materialization), in which case a diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 885e0cf9d2..05aa92ac87 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -301,6 +301,7 @@ export const LINK_MAP: Readonly> = { SessionLocation: 'persistence.md', SessionPreparation: 'persistence.md', SessionPersistenceSnapshot: 'persistence.md', + SessionRawArtifact: 'persistence.md', ConfinedArgv: 'sandbox.md', SandboxExecutionPolicy: 'sandbox.md', SandboxMode: 'sandbox.md',