fix(persistence): use normalized project directory names
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
|
||||
2026-07-24-project-session-directories.md: f65045419d5c749525ebdefcd1875dfc8ea69182
|
||||
2026-07-24-project-session-directories.zh.md: 1b4320d925c85b9b42a6c3ec9ee4ec52f4600786
|
||||
2026-07-24-project-session-directories.md: 2091027e67528855a3d9722bc63347319aada678
|
||||
2026-07-24-project-session-directories.zh.md: a161cff5ac42fb67650bdff593f91b95af471d1b
|
||||
|
||||
@@ -16,12 +16,14 @@ The JSONL backend stores sessions under a readable project key and gives every s
|
||||
|
||||
```text
|
||||
<configured-root>/
|
||||
--<normalized-cwd>--<hash>/
|
||||
--<normalized-cwd>--/
|
||||
<encoded-session-id>/
|
||||
session.jsonl.zstd
|
||||
```
|
||||
|
||||
Raw mode uses `session.jsonl`, and sessions without a cwd use `_no-cwd`. Filesystem and drive separators become `-`, unsafe code units use `~XXXX`, and the readable prefix is bounded to keep the component within filesystem limits. A short SHA-256 suffix distinguishes project paths whose readable forms collide or truncate alike.
|
||||
Raw mode uses `session.jsonl`, and sessions without a cwd use `_no-cwd`. Filesystem and drive separators become `-`, unsafe code units use `~XXXX`, and the readable name is bounded to keep the component within filesystem limits.
|
||||
|
||||
The project key intentionally has no hash suffix. This follows the common human-readable convention used by coding agents and keeps the normalized project path as the complete directory name. The normalization is lossy: paths such as `/a/b-c` and `/a-b/c`, or long paths with the same retained prefix, share one project directory. Their distinct session ids still select separate session directories; reuse of the same session id remains a storage collision and is rejected.
|
||||
|
||||
The configured root remains a deployment choice. The layout neither selects a global root nor requires projects to share one. When a deployment does centralize storage, project paths remain recognizable; a project-local root uses the same deterministic structure.
|
||||
|
||||
@@ -35,7 +37,7 @@ Lazy materialization remains tied to the transcript: `create()` performs no file
|
||||
|
||||
**Put session files directly in each project directory.** This matched Claude Code and pi's basic file organization but left no session-level ownership boundary for future artifacts.
|
||||
|
||||
**Replace separators without a collision suffix.** This is readable but lossy: paths containing literal `-` can collide with paths where `-` represents a separator. Retaining a short hash suffix preserves readable navigation without merging distinct projects.
|
||||
**Add a collision-resistant hash suffix.** This distinguishes paths whose normalized forms collide, but makes the directory name more than the normalized project path. The chosen convention accepts lossy project grouping in exchange for the simpler, recognizable name.
|
||||
|
||||
**Mandate a centralized root.** Rejected because storage placement belongs to deployment configuration. Project grouping is useful when roots are shared and harmless when they are not.
|
||||
|
||||
@@ -45,4 +47,4 @@ Lazy materialization remains tied to the transcript: `create()` performs no file
|
||||
|
||||
Shared stores can be navigated by recognizable project names, while local and custom roots keep their existing configuration freedom. Every session has a directory available for future backend-owned artifacts, and existing transcript consumers still receive a file path.
|
||||
|
||||
Project directory names are longer than the former 12-hex cwd hashes. Very long paths show only a bounded prefix plus their distinguishing hash, and moving a project still selects a different directory because the absolute cwd remains part of storage identity.
|
||||
Project directory names are longer than the former 12-hex cwd hashes. Very long paths show only a bounded prefix. Moving a project usually selects a different directory, but distinct cwd strings that normalize to the same name share one project directory by design.
|
||||
|
||||
@@ -16,12 +16,14 @@ JSONL 后端按可读的项目键存储会话,并为每个会话提供独立
|
||||
|
||||
```text
|
||||
<configured-root>/
|
||||
--<normalized-cwd>--<hash>/
|
||||
--<normalized-cwd>--/
|
||||
<encoded-session-id>/
|
||||
session.jsonl.zstd
|
||||
```
|
||||
|
||||
原始模式使用 `session.jsonl`,没有 cwd 的会话使用 `_no-cwd`。文件系统路径分隔符和驱动器分隔符会转换为 `-`,不安全的代码单元使用 `~XXXX`,可读前缀则限制长度,以确保目录项不超过文件系统限制。短 SHA-256 后缀用于区分可读形式发生冲突或被截断成相同形式的项目路径。
|
||||
原始模式使用 `session.jsonl`,没有 cwd 的会话使用 `_no-cwd`。文件系统路径分隔符和驱动器分隔符会转换为 `-`,不安全的代码单元使用 `~XXXX`,可读名称则限制长度,以确保目录项不超过文件系统限制。
|
||||
|
||||
项目键有意不带哈希后缀。这遵循 coding agent(编码智能体)常用的易读约定,使规范化后的项目路径本身就是完整的目录名。规范化过程有损:`/a/b-c` 与 `/a-b/c` 等路径,或者保留前缀相同的长路径,会共用同一个项目目录。不同的会话 id 仍会选择不同的会话目录;复用相同的会话 id 仍构成存储冲突,系统会予以拒绝。
|
||||
|
||||
根目录由部署配置决定。这种布局既不选择全局根目录,也不要求项目共享根目录。部署选择集中存储时,目录名仍能让项目路径易于辨认;使用项目本地根目录时,也采用同样的确定性结构。
|
||||
|
||||
@@ -35,7 +37,7 @@ JSONL 后端按可读的项目键存储会话,并为每个会话提供独立
|
||||
|
||||
**把会话文件直接放入各项目目录。** 这与 Claude Code 和 pi 的基本文件组织一致,但没有为未来产物提供会话级归属边界。
|
||||
|
||||
**替换分隔符但不添加冲突后缀。** 这种方式可读但有损:路径中的字面 `-` 可能与用 `-` 表示分隔符的路径发生冲突。保留短哈希后缀,既能让不同项目保持区分,又不会牺牲可读的浏览体验。
|
||||
**添加防冲突的哈希后缀。** 这种方式能区分规范化形式相同的路径,但会使目录名不再只是规范化后的项目路径。所选约定接受有损的项目分组,以换取更简单、易于辨认的名称。
|
||||
|
||||
**强制使用集中式根目录。** 不予采纳,因为存储位置属于部署配置。项目分组在根目录共享时有用,在不共享时也没有负面影响。
|
||||
|
||||
@@ -45,4 +47,4 @@ JSONL 后端按可读的项目键存储会话,并为每个会话提供独立
|
||||
|
||||
共享存储可以通过易于辨认的项目名进行浏览,本地根目录和自定义根目录则继续保有现有的配置自由。每个会话都有一个可供后端未来存放自有产物的目录,而现有 transcript 消费方仍会收到文件路径。
|
||||
|
||||
项目目录名比原先由 12 个十六进制字符组成的 cwd 哈希更长。路径很长时,目录名只显示长度受限的前缀和用于区分的哈希;移动项目仍会选择不同的目录,因为绝对 cwd 仍是存储身份的一部分。
|
||||
项目目录名比原先由 12 个十六进制字符组成的 cwd 哈希更长。路径很长时,目录名只显示长度受限的前缀。移动项目通常会选择不同的目录,但按设计,不同的 cwd 字符串如果规范化成相同名称,就会共用同一个项目目录。
|
||||
|
||||
@@ -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([])
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user