Explorar el Código

feat(session-persistence): expose readRaw for per-session artifacts

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.
_Kerman hace 1 mes
padre
commit
80b7f929ea

+ 1 - 1
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

+ 1 - 1
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`
 

+ 2 - 2
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

+ 16 - 1
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<SessionRawArtifact | 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
@@ -342,5 +357,5 @@ abstract listSnapshots(signal?: AbortSignal): Promise<SessionPersistenceSnapshot
 
 Types: [SessionEvent](session.md) · [SessionId](core.md)
 
-Source: [`packages/session/session-persistence/src/index.ts:72`](../../packages/session/session-persistence/src/index.ts)
+Source: [`packages/session/session-persistence/src/index.ts:82`](../../packages/session/session-persistence/src/index.ts)
 <!-- END GENERATED cordis-surface -->

+ 16 - 1
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<SessionRawArtifact | 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
@@ -342,5 +357,5 @@ abstract listSnapshots(signal?: AbortSignal): Promise<SessionPersistenceSnapshot
 
 Types: [SessionEvent](session.md) · [SessionId](core.md)
 
-Source: [`packages/session/session-persistence/src/index.ts:72`](../../packages/session/session-persistence/src/index.ts)
+Source: [`packages/session/session-persistence/src/index.ts:82`](../../packages/session/session-persistence/src/index.ts)
 <!-- END GENERATED cordis-surface -->

+ 8 - 0
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<SessionRawArtifact | undefined>',
+        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<void>',
         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}',

+ 57 - 1
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<SessionRawArtifact | undefined> {
+    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.

+ 20 - 0
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)

+ 21 - 0
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()

+ 28 - 0
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<SessionRawArtifact | undefined> {
+    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

+ 1 - 0
scripts/gen-cordis-catalog.ts

@@ -301,6 +301,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
   SessionLocation: 'persistence.md',
   SessionPreparation: 'persistence.md',
   SessionPersistenceSnapshot: 'persistence.md',
+  SessionRawArtifact: 'persistence.md',
   ConfinedArgv: 'sandbox.md',
   SandboxExecutionPolicy: 'sandbox.md',
   SandboxMode: 'sandbox.md',