fix(persistence): use normalized project directory names

This commit is contained in:
Turtle
2026-07-24 21:15:53 +08:00
parent 4e295221f3
commit c14f488b00
6 changed files with 45 additions and 28 deletions
@@ -6,7 +6,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence
```
<root>/
--<normalized-cwd>--<hash>/ # readable project directory (or _no-cwd/)
--<normalized-cwd>--/ # readable project directory (or _no-cwd/)
<encoded-id>/ # session-owned directory
session.jsonl.zstd # default: checksummed header frame + append frames
session.jsonl # only with compression: 'none'
@@ -14,7 +14,7 @@ The JSONL durable session-persistence backend — a concrete `SessionPersistence
- The first logical line is the immutable `SessionHeader` tagged `{ type: 'session', version, id, cwd?, createdAt, parentSession?, seedLength?, delegationDepth }`. `delegationDepth` is required on disk and is `0` for a top-level session; a missing or invalid value rejects the log. Every subsequent logical line is one storage record; `assistant/chunk` events are never dropped, and `seq` stays contiguous across the decoded log (`events[i].seq === i`).
- A storage record is a `SessionEvent` JSON verbatim, or — written only under `packChunks` — a **packed chunk row** (`text-chunks` / `reasoning-chunks` / `tool-call-chunks`; bare slash-less tags like the header's `session`, so row tags cannot be confused with event types): one line holding a run of ≥3 consecutive same-block `assistant/chunk` delta events, `seq0`/`time0` plus per-member `dt` gaps reconstructing every member's `seq`/`time` exactly. The lossless codec lives in `@deepseek-ai/dsh-session` (`packChunkRuns`/`decodeStorageRecord`) and whitelists exact shapes — anything unrecognized stores verbatim. Reading is layout-blind: `load` always decodes rows, so packed, unpacked, and mixed files load identically.
- The project directory keeps the normalized cwd readable for navigation and adds a short SHA-256 suffix so paths that normalize alike remain distinct. Its readable prefix is bounded for filesystem component limits. The configured root remains deployment-controlled: it may be project-local, shared, temporary, or centralized.
- The project directory keeps the normalized cwd readable for navigation and is bounded for filesystem component limits. Separator replacement and truncation are intentionally lossy, so cwd strings that normalize alike share a project directory; session ids still select distinct session directories. The configured root remains deployment-controlled: it may be project-local, shared, temporary, or centralized. The [project-session directory decision](../../../.agents/notes/implemented/architecture/2026-07-24-project-session-directories.md) records this tradeoff.
- Session ids are unvalidated branded strings, so they are injectively escaped to a single safe path segment before use (no traversal, no collision). The resulting directory is reserved for additional session-owned artifacts; discovery reads only the fixed transcript filename.
## Config
@@ -8,7 +8,6 @@
* @module dsh-session-persistence-jsonl/format
*/
import { createHash } from 'node:crypto'
import { join } from 'node:path'
import { decodeStorageRecord, packChunkRuns } from '@deepseek-ai/dsh-session'
import type { SessionEvent, SessionHeader, SessionId, StorageRecord } from '@deepseek-ai/dsh-session'
@@ -120,11 +119,11 @@ export function encodeSegment(raw: string): string {
}
/**
* Build the readable, collision-resistant directory key for a project path.
* Build the readable directory key for a project path.
* Filesystem separators and drive separators become `-`; unsafe code units use
* the same `~XXXX` escape as session ids. The readable prefix is bounded for
* filesystem component limits, and the hash suffix keeps distinct or truncated
* paths separate.
* the same `~XXXX` escape as session ids. The key is bounded for filesystem
* component limits. Separator replacement and truncation are intentionally
* lossy, following the common human-navigable project-directory convention.
* @param cwd - the session's project directory.
* @returns a single filesystem-safe project directory name.
*/
@@ -146,9 +145,8 @@ export function projectKey(cwd: string): string {
separatorRun = false
}
}
const hash = createHash('sha256').update(cwd).digest('hex').slice(0, 12)
const slug = readable.replace(/^-+/, '') || 'root'
return `--${slug.slice(0, 200)}--${hash}`
return `--${slug.slice(0, 251)}--`
}
/**
@@ -127,15 +127,13 @@ describe('SessionPersistenceJsonl: format helpers', () => {
expect(() => encodeSegment('')).toThrow(/empty/)
})
it('projectKey keeps the path readable and disambiguates normalized collisions', () => {
expect(projectKey('/Users/qyj/work/deepseek-harness')).toMatch(
/^--Users-qyj-work-deepseek-harness--[a-f0-9]{12}$/,
)
expect(projectKey('/a/b-c')).not.toBe(projectKey('/a-b/c'))
expect(projectKey('C:\\work\\agent')).toMatch(/^--C-work-agent--[a-f0-9]{12}$/)
expect(projectKey('/开发/~agent')).toMatch(/^--~5F00~53D1-~007Eagent--[a-f0-9]{12}$/)
expect(projectKey('/')).toMatch(/^--root--[a-f0-9]{12}$/)
expect(projectKey('/' + 'x'.repeat(1_000))).toHaveLength(216)
it('projectKey normalizes project paths into bounded readable names', () => {
expect(projectKey('/Users/qyj/work/deepseek-harness')).toBe('--Users-qyj-work-deepseek-harness--')
expect(projectKey('/a/b-c')).toBe(projectKey('/a-b/c'))
expect(projectKey('C:\\work\\agent')).toBe('--C-work-agent--')
expect(projectKey('/开发/~agent')).toBe('--~5F00~53D1-~007Eagent--')
expect(projectKey('/')).toBe('--root--')
expect(projectKey('/' + 'x'.repeat(1_000))).toHaveLength(255)
expect(() => projectKey('')).toThrow(/empty project path/)
})
@@ -815,6 +813,23 @@ describe('SessionPersistenceJsonl: edge cases', () => {
expect(ids).toEqual(['p1', 'p2', 'p3'])
})
it('groups sessions whose cwd paths normalize to the same project directory', async () => {
const first = meta('normalized-first', '/a/b-c')
const second = meta('normalized-second', '/a-b/c')
await ctx.sessionPersistence.create(first)
await ctx.sessionPersistence.append(first.id, oneTurnLog())
await ctx.sessionPersistence.create(second)
await ctx.sessionPersistence.append(second.id, oneTurnLog())
expect(projectDir(root, first.cwd)).toBe(projectDir(root, second.cwd))
expect(await readdir(projectDir(root, first.cwd))).toEqual(expect.arrayContaining([
encodeSegment(first.id),
encodeSegment(second.id),
]))
expect((await ctx.sessionPersistence.list()).map(header => header.id).sort())
.toEqual([first.id, second.id].sort())
})
it('list on an empty root returns nothing', async () => {
expect(await ctx.sessionPersistence.list()).toEqual([])
})