diff --git a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml index d7567cc4df..9fa5a52d57 100644 --- a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.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 .agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md -2026-09-11-turn-changed-files-card.md: c477935fad1fa95ae0d60a67f31a89c38b020087 -2026-09-11-turn-changed-files-card.zh.md: 23ee760f18d6f9a6e9b540ef0e9bde452968a308 +2026-09-11-turn-changed-files-card.md: a14e062d7801de089a7644ae335d246a206fa655 +2026-09-11-turn-changed-files-card.zh.md: 0f69397b58543eeee79e5f5d57eb08aa31618494 diff --git a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md index c477935fad..a14e062d78 100644 --- a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md +++ b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.md @@ -12,7 +12,7 @@ After a turn, users want to see which files the model changed and by how much. T The Host [workspace-changes](../../../../packages/fs/workspace-changes/README.md) plugin records each top-level turn's changed files as a `workspace/changes` Session event, and the [deliverables plugin](../../../../packages/client/ui-deliverables/README.md) renders that event as the changed-files card in place of the mutation-call row. The event is log-only and never model-visible. -The recorder snapshots the working tree with git at turn start and turn end: `add --all` into a private index seeded from the repository's index, then `write-tree`. The two tree ids are diffed with `diff-tree -r -M --numstat`, so the summary contains exactly the turn's changes — the user's earlier uncommitted work, staged or not, is part of the baseline — and commits the model makes mid-turn cannot hide changes. The repository's own index, work tree, and refs are never modified. On a 10k-file repository one snapshot costs about 60 ms and the diff about 10 ms; the baseline runs concurrently with the first model request, and tool execution waits for it. +The recorder snapshots the working tree with git at turn start and turn end: `add --all` into a private index seeded from the repository's index, then `write-tree`. Both write into a private object store under the Harness home while the repository's object store is attached as a read-only alternate, so the user's repository gains no objects and the Harness owns retention; this placement follows the workspace change journal POC (#2973), which keeps every captured byte outside the workspace, and costs nothing measurable because the stat cache lives in the index. The two tree ids are diffed with `diff-tree -r -M --numstat`, so the summary contains exactly the turn's changes — the user's earlier uncommitted work, staged or not, is part of the baseline — and commits the model makes mid-turn cannot hide changes. The repository's own index, objects, work tree, and refs are never modified. On a 10k-file repository one snapshot costs about 60 ms and the diff about 10 ms; the baseline runs concurrently with the first model request, and tool execution waits for it. Git is the default executable on `PATH`; no environment plugin is consulted. Only a working directory inside a git repository is recorded; outside any repository, or without git, the plugin records nothing and the card is absent. Nested repositories and submodules are gitlinks and are not descended into. @@ -40,7 +40,7 @@ The recorder appends inside the turn on `agent/turn-stopping` and again after `t ## Consequences -Every turn with tool results costs two snapshots and one diff on the Host, and writes blob and tree objects for the changed files into the repository's object store; unreferenced trees are pruned by the repository's own garbage collection, after which only the recorded counts remain. Edits the user makes during a turn are attributed to it. Hunk-based counts for uncovered files are sums over edits rather than a first-to-last diff. +Every turn with tool results costs two snapshots and one diff on the Host, and writes blob and tree objects for the changed files into the private store; a store over its configured bound is discarded, after which only the recorded counts remain. Edits the user makes during a turn are attributed to it. Hunk-based counts for uncovered files are sums over edits rather than a first-to-last diff. The Web bundle alone mounts the recorder, so headless, SDK, and ACP logs are unchanged; recorded Web scenarios gain the event and the card only when their workspace is a git repository, which one dedicated scenario seeds. The card replaces the Chinese and English "Files changed" row; prose file mentions still resolve against mutation-call paths and deliveries. diff --git a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md index 23ee760f18..0f69397b58 100644 --- a/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md +++ b/.agents/notes/implemented/feature/2026-09-11-turn-changed-files-card.zh.md @@ -12,7 +12,7 @@ Status: implemented Host 侧的 [workspace-changes](../../../../packages/fs/workspace-changes/README.zh.md) 插件把每个顶层轮次改动的文件记录为 `workspace/changes` Session 事件,[产出物插件](../../../../packages/client/ui-deliverables/README.zh.md)把该事件渲染为改动文件卡片,取代修改调用行。该事件仅写日志,模型永远看不到。 -记录器在轮次开始和结束时用 git 对工作树做快照:以仓库 index 为种子在私有 index 上执行 `add --all`,再执行 `write-tree`。两个 tree id 用 `diff-tree -r -M --numstat` 比较,因此摘要恰好只包含本轮的改动——用户此前未提交的工作,无论是否暂存,都属于基线——模型在轮中做的提交也藏不住改动。仓库自己的 index、工作树和 ref 从不被修改。在一万个文件的仓库上,一次快照约 60 ms,比较约 10 ms;基线与首次模型请求并行,工具执行会等待它。 +记录器在轮次开始和结束时用 git 对工作树做快照:以仓库 index 为种子在私有 index 上执行 `add --all`,再执行 `write-tree`。两者都写入 Harness home 下的私有对象库,仓库自己的对象库以只读 alternate 挂接,因此用户仓库不会多出任何对象,保留策略由 Harness 掌握;这个放置方式学自工作区变更日志 POC(#2973)把捕获的每个字节都放在工作区外的做法,而且因为 stat 缓存在 index 里,它对耗时没有可测量的影响。两个 tree id 用 `diff-tree -r -M --numstat` 比较,因此摘要恰好只包含本轮的改动——用户此前未提交的工作,无论是否暂存,都属于基线——模型在轮中做的提交也藏不住改动。仓库自己的 index、对象、工作树和 ref 从不被修改。在一万个文件的仓库上,一次快照约 60 ms,比较约 10 ms;基线与首次模型请求并行,工具执行会等待它。 git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。只有位于 git 仓库内的工作目录会被记录;不在任何仓库内或没有 git 时,插件不记录,卡片不出现。嵌套仓库和 submodule 是 gitlink,不会深入。 @@ -40,7 +40,7 @@ git 使用 `PATH` 上的默认可执行文件,不询问任何环境插件。 ## Consequences -每个有工具结果的轮次在 Host 上花费两次快照和一次比较,并把改动文件的 blob 与 tree 对象写入仓库对象库;无引用的树由仓库自己的垃圾回收清理,之后只剩记录的计数。用户在轮次进行中的编辑会被算到该轮。未覆盖文件的 hunk 计数是编辑的累加,不是首尾对比。 +每个有工具结果的轮次在 Host 上花费两次快照和一次比较,并把改动文件的 blob 与 tree 对象写入私有对象库;超过配置上限的对象库会被丢弃,之后只剩记录的计数。用户在轮次进行中的编辑会被算到该轮。未覆盖文件的 hunk 计数是编辑的累加,不是首尾对比。 只有 Web bundle 挂载记录器,因此 headless、SDK 与 ACP 日志不变;录制的 Web 场景只有在工作区是 git 仓库时才新增该事件与卡片,一个专门的场景负责种入这样的仓库。卡片取代了中英文的“本轮文件改动”行;正文文件提及仍按修改调用路径与交付解析。 diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index f56df34189..96ab2ca237 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -609,6 +609,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise Implementation internals — click to expand -One `TurnRecorder` per Session serializes its git work. `turn/start` queues the baseline: `rev-parse` locates the repository, then `add --all` into a temporary index seeded from the repository's index and `write-tree` produce the tree id. Every `tools/pre-execute` waits for that queue before a tool runs, so no mutation can precede its baseline. `tool/result` events collect the file-tool hunks. `agent/turn-stopping` records inside the turn: a second snapshot, `diff-tree -r -M --numstat` between the two trees, `check-ignore` for hunk paths inside the work tree, and the appended event. `turn/end` records again only when tool results settled after the last record, which covers aborted, failed, and steered turns; an empty list after an earlier record supersedes it. The repository's index is only read. +One `TurnRecorder` per Session serializes its git work. `turn/start` queues the baseline: `rev-parse` locates the repository and its object store, then `add --all` into a temporary index seeded from the repository's index and `write-tree` produce the tree id. Every command runs with `GIT_OBJECT_DIRECTORY` pointing at the private store and `GIT_ALTERNATE_OBJECT_DIRECTORIES` at the repository's objects, so committed content is read from the repository and new objects never land there. Every `tools/pre-execute` waits for that queue before a tool runs, so no mutation can precede its baseline. `tool/result` events collect the file-tool hunks. `agent/turn-stopping` records inside the turn: a second snapshot, `diff-tree -r -M --numstat` between the two trees, `check-ignore` for hunk paths inside the work tree, and the appended event. `turn/end` records again only when tool results settled after the last record, which covers aborted, failed, and steered turns; an empty list after an earlier record supersedes it. The repository's index is only read. Git runs through the `subprocess` capability with a scrubbed environment, `GIT_TERMINAL_PROMPT=0`, `GIT_OPTIONAL_LOCKS=0`, the configured timeout, and bounded output. A failing step abandons that turn's record with a warning; the next turn starts afresh. Session disposal and plugin disposal abort queued work. @@ -83,7 +85,7 @@ Nothing here enters a model request, so provider cache reuse is unaffected. -- Snapshot trees are unreferenced objects, so a repository's garbage collection removes them after its prune expiry; the recorded counts survive, the trees needed for a later full-file comparison do not. +- Snapshot trees survive only until their private store exceeds `objectStoreMaxBytes` and is discarded; the recorded counts survive, the trees needed for a later full-file comparison do not. - Edits the user makes during a turn are attributed to that turn. - A working directory outside any git repository has no card; a shadow repository under the Harness home is deferred until its exclude rules can replace a missing `.gitignore` reliably. - Hunk-based counts for files outside snapshot coverage are sums over edits, not a first-to-last diff, and cover file tools only. diff --git a/packages/fs/workspace-changes/README.zh.md b/packages/fs/workspace-changes/README.zh.md index 1e400e422c..f8ac055711 100644 --- a/packages/fs/workspace-changes/README.zh.md +++ b/packages/fs/workspace-changes/README.zh.md @@ -35,11 +35,13 @@ kind: "package-reference" | 字段 | 默认值 | 含义 | |---|---|---| +| `dshHome` | `$DSH_HOME`,其次 `~/.dsh` | Harness home,其 `workspace-changes/` 目录为每个仓库存放一个快照对象库 | +| `objectStoreMaxBytes` | `1073741824` | 单个仓库的快照对象库允许的字节数,超过后丢弃并从空开始 | | `timeoutMs` | `30000` | 单条 git 命令允许运行的毫秒数,超时则放弃本轮记录 | | `outputMaxBytes` | `8388608` | 每条命令保留的 git 输出字节数,diff 列表更大时放弃本轮记录 | | `maxFiles` | `500` | 单个事件携带的最大文件数;`total` 仍报告完整数量 | -工作目录位于 git 仓库内且不是子代理来源的 Session 都会被记录;子代理 Session 和不在任何仓库内的工作目录不记录。快照通过私有 index 写入仓库的对象库,仓库自己的 index、工作树和 ref 保持不变,用户此前未提交的改动也不会进入摘要。工作目录内的嵌套仓库和 submodule 记录为 gitlink,其内部改动不会出现。没有 git 时——或者 macOS 上只有 `/usr/bin/git` 的开发者工具桩程序时——本插件不记录任何内容,并记录一次日志。 +工作目录位于 git 仓库内且不是子代理来源的 Session 都会被记录;子代理 Session 和不在任何仓库内的工作目录不记录。快照通过私有 index 写入 Harness home 下的私有对象库,仓库自己的对象库以只读 alternate 的方式挂接;仓库的 index、对象、工作树和 ref 保持不变,用户此前未提交的改动也不会进入摘要。对象库超过 `objectStoreMaxBytes` 时在下次快照前被丢弃,从空开始。工作目录内的嵌套仓库和 submodule 记录为 gitlink,其内部改动不会出现。没有 git 时——或者 macOS 上只有 `/usr/bin/git` 的开发者工具桩程序时——本插件不记录任何内容,并记录一次日志。 文件工具改动但快照覆盖不到的文件,由这些工具随结果持久化的 hunk 补入:匹配忽略模式的文件,以及仓库之外的文件。`/tmp` 与平台临时目录下的文件被排除,除非它们位于工作目录内。这些文件的行数按记录的 hunk 累加,因此同一轮内对一个文件的重复编辑可能把一行计算多次。快照覆盖范围之外通过 shell 命令做出的改动不会被记录。 @@ -53,7 +55,7 @@ kind: "package-reference"
实现细节——点击展开 -每个 Session 一个 `TurnRecorder`,串行化其 git 工作。`turn/start` 排入基线:`rev-parse` 定位仓库,然后以仓库 index 为种子在临时 index 上执行 `add --all` 与 `write-tree` 得到 tree id。每次 `tools/pre-execute` 都等待该队列,因此没有修改能先于其基线发生。`tool/result` 事件收集文件工具的 hunk。`agent/turn-stopping` 在轮内记录:第二次快照、两棵树之间的 `diff-tree -r -M --numstat`、对工作树内 hunk 路径的 `check-ignore`,以及追加事件。`turn/end` 仅在最后一次记录之后仍有工具结果结束时再次记录,这覆盖了中止、失败和被转向的轮次;早先记录之后的空列表会取代它。仓库的 index 只读取。 +每个 Session 一个 `TurnRecorder`,串行化其 git 工作。`turn/start` 排入基线:`rev-parse` 定位仓库及其对象库,然后以仓库 index 为种子在临时 index 上执行 `add --all` 与 `write-tree` 得到 tree id。每条命令都带 `GIT_OBJECT_DIRECTORY` 指向私有对象库、`GIT_ALTERNATE_OBJECT_DIRECTORIES` 指向仓库的 objects,因此已提交内容从仓库读取,新对象不会落进仓库。每次 `tools/pre-execute` 都等待该队列,因此没有修改能先于其基线发生。`tool/result` 事件收集文件工具的 hunk。`agent/turn-stopping` 在轮内记录:第二次快照、两棵树之间的 `diff-tree -r -M --numstat`、对工作树内 hunk 路径的 `check-ignore`,以及追加事件。`turn/end` 仅在最后一次记录之后仍有工具结果结束时再次记录,这覆盖了中止、失败和被转向的轮次;早先记录之后的空列表会取代它。仓库的 index 只读取。 git 通过 `subprocess` 能力运行,使用净化后的环境、`GIT_TERMINAL_PROMPT=0`、`GIT_OPTIONAL_LOCKS=0`、配置的超时与有界输出。任何步骤失败都会放弃本轮记录并给出警告;下一轮重新开始。Session 释放与插件释放会中止排队的工作。 @@ -83,7 +85,7 @@ git 通过 `subprocess` 能力运行,使用净化后的环境、`GIT_TERMINAL_ -- 快照树是无引用对象,仓库的垃圾回收会在其过期期限后删除它们;记录的计数保留,而将来做整文件对比所需的树不会保留。 +- 快照树只保留到其私有对象库超过 `objectStoreMaxBytes` 被丢弃为止;记录的计数保留,而将来做整文件对比所需的树不会保留。 - 用户在轮次进行中自己做的编辑会被算到该轮。 - 不在任何 git 仓库内的工作目录没有卡片;Harness home 下的影子仓库暂缓,直到其排除规则能可靠地代替缺失的 `.gitignore`。 - 快照覆盖范围之外的文件按 hunk 累加计数,不是首尾对比,且只覆盖文件工具。 diff --git a/packages/fs/workspace-changes/package.json b/packages/fs/workspace-changes/package.json index 8d9ea2a04c..5c93a3bfe2 100644 --- a/packages/fs/workspace-changes/package.json +++ b/packages/fs/workspace-changes/package.json @@ -38,6 +38,7 @@ "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^" @@ -47,6 +48,7 @@ "@deepseek-ai/cordis-plugin-include": "workspace:^", "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-home-paths": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", diff --git a/packages/fs/workspace-changes/src/git.ts b/packages/fs/workspace-changes/src/git.ts index f4b4d23ad6..6c59fcda28 100644 --- a/packages/fs/workspace-changes/src/git.ts +++ b/packages/fs/workspace-changes/src/git.ts @@ -1,5 +1,6 @@ /** Git working-tree snapshots, tree diffs, and ignore checks through the subprocess capability. */ -import { copyFile, mkdtemp, rm } from 'node:fs/promises' +import { createHash } from 'node:crypto' +import { copyFile, mkdir, mkdtemp, readdir, rm, stat } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import type { SubprocessRuntime } from '@deepseek-ai/dsh-subprocess' @@ -88,33 +89,67 @@ function ok(result: GitRunResult, what: string): GitRunResult { return result } -/** The repository enclosing a Session working directory. */ +/** The repository enclosing a Session working directory and the private store its snapshots write to. */ export interface GitWorkspace { /** Repository top-level directory, the root every diff path is relative to. */ root: string - /** Absolute git directory holding the index and objects. */ + /** Absolute git directory holding the repository's index. */ gitDir: string + /** Directory under the Harness home that receives every snapshot blob and tree. */ + objectsDir: string + /** Environment that routes object writes to {@link objectsDir} and object reads through the repository's store. */ + env: Readonly> +} + +/** Placement and bound of the private snapshot object stores. */ +export interface ObjectStoreOptions { + /** Directory that holds one object store per repository. */ + home: string + /** Bytes one repository's store may hold; a larger store is discarded before the next snapshot. */ + maxBytes: number +} + +/** Total size of the regular files under a directory; zero when it does not exist. */ +async function directoryBytes(directory: string): Promise { + let total = 0 + const entries = await readdir(directory, { recursive: true, withFileTypes: true }).catch(() => []) + for (const entry of entries) { + if (entry.isFile()) total += (await stat(join(entry.parentPath, entry.name))).size + } + return total } /** - * Locate the repository enclosing a working directory. + * Locate the repository enclosing a working directory and prepare its snapshot + * object store. The repository's own object store is attached read-only as an + * alternate, so snapshots read committed content from it and write nothing + * into it; a store over its byte bound is discarded and starts empty. * @param git - command runner. * @param cwd - absolute Session working directory. + * @param store - snapshot object store placement and bound. * @param signal - cancellation. * @returns the repository, or null when the directory is not inside one. */ -export async function locateGitWorkspace(git: GitRunner, cwd: string, signal: AbortSignal): Promise { - const found = await git.run(['rev-parse', '--show-toplevel', '--absolute-git-dir'], { cwd, signal }) +export async function locateGitWorkspace( + git: GitRunner, cwd: string, store: ObjectStoreOptions, signal: AbortSignal, +): Promise { + const found = await git.run([ + 'rev-parse', '--path-format=absolute', '--show-toplevel', '--absolute-git-dir', '--git-path', 'objects', + ], { cwd, signal }) if (found.exitCode !== 0) return null - const [root, gitDir] = found.stdout.split('\n') as [string, string] - return { root, gitDir } + const [root, gitDir, repositoryObjects] = found.stdout.split('\n') as [string, string, string] + const objectsDir = join(store.home, createHash('sha256').update(gitDir).digest('hex').slice(0, 16)) + if (await directoryBytes(objectsDir) > store.maxBytes) await rm(objectsDir, { recursive: true, force: true }) + await mkdir(objectsDir, { recursive: true }) + return { root, gitDir, objectsDir, env: { GIT_OBJECT_DIRECTORY: objectsDir, GIT_ALTERNATE_OBJECT_DIRECTORIES: repositoryObjects } } } /** * Write the complete work tree, including untracked and modified files but * not ignored ones, as a tree object through a private index seeded from the - * repository's index. That index, the work tree, and every ref stay unchanged; - * an in-progress merge keeps its unmerged entries. + * repository's index. New blobs and the tree land in the private object store; + * the repository's index, object store, work tree, and refs stay unchanged, + * and an in-progress merge keeps its unmerged entries. * @param git - command runner. * @param workspace - addressed repository. * @param signal - cancellation. @@ -126,7 +161,7 @@ export async function snapshotTree(git: GitRunner, workspace: GitWorkspace, sign const index = join(scratch, 'index') // A repository without an index yet (fresh `git init`) starts from scratch. await copyFile(join(workspace.gitDir, 'index'), index).then(() => undefined, () => undefined) - const env = { GIT_INDEX_FILE: index } + const env = { ...workspace.env, GIT_INDEX_FILE: index } ok(await git.run(['add', '--all', '--ignore-errors'], { cwd: workspace.root, env, signal }), `git add in ${workspace.root}`) return ok(await git.run(['write-tree'], { cwd: workspace.root, env, signal }), 'git write-tree').stdout.trim() } finally { @@ -148,7 +183,9 @@ export async function diffTrees( git: GitRunner, workspace: GitWorkspace, before: string, after: string, signal: AbortSignal, ): Promise { if (before === after) return [] - const result = ok(await git.run(['diff-tree', '-r', '-M', '-z', '--numstat', before, after], { cwd: workspace.root, signal }), 'git diff-tree') + const result = ok(await git.run(['diff-tree', '-r', '-M', '-z', '--numstat', before, after], { + cwd: workspace.root, env: workspace.env, signal, + }), 'git diff-tree') if (result.truncated) throw new Error('git diff-tree output exceeded the configured cap') return parseNumstat(result.stdout) } @@ -167,7 +204,9 @@ export async function ignoredPaths( git: GitRunner, workspace: GitWorkspace, paths: readonly string[], signal: AbortSignal, ): Promise> { if (paths.length === 0) return new Set() - const result = await git.run(['check-ignore', '-z', '--stdin'], { cwd: workspace.root, stdin: `${paths.join('\0')}\0`, signal }) + const result = await git.run(['check-ignore', '-z', '--stdin'], { + cwd: workspace.root, env: workspace.env, stdin: `${paths.join('\0')}\0`, signal, + }) if (result.exitCode === 1) return new Set() return new Set(ok(result, 'git check-ignore').stdout.split('\0').filter(path => path !== '')) } diff --git a/packages/fs/workspace-changes/src/index.ts b/packages/fs/workspace-changes/src/index.ts index e6231f0fcf..44bcd4e80c 100644 --- a/packages/fs/workspace-changes/src/index.ts +++ b/packages/fs/workspace-changes/src/index.ts @@ -6,8 +6,10 @@ */ import { realpathSync } from 'node:fs' import { homedir } from 'node:os' +import { join } from 'node:path' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' +import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' import type {} from '@deepseek-ai/dsh-agent' import type { Session } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-subprocess' @@ -24,8 +26,12 @@ export const name = 'workspace-changes' /** Services used to run git and observe turns. */ export const inject = ['subprocess'] -/** Snapshot bounds. Invalid values fail plugin load. */ +/** Snapshot bounds and object store placement. Invalid values fail plugin load. */ export interface Config { + /** Harness home whose `workspace-changes/` directory holds one snapshot object store per repository; `$DSH_HOME`, then `~/.dsh`. */ + dshHome?: string + /** Bytes one repository's snapshot object store may hold before it is discarded and restarted empty. */ + objectStoreMaxBytes: number /** Milliseconds one git command may run before the turn's record is abandoned. */ timeoutMs: number /** Bytes of git output retained per command; a larger diff listing abandons the record. */ @@ -36,6 +42,8 @@ export interface Config { /** Schemastery validation for {@link Config}. */ export const Config: z = z.object({ + dshHome: z.string(), + objectStoreMaxBytes: z.number().default(1024 * 1024 * 1024), timeoutMs: z.number().default(30_000), outputMaxBytes: z.number().default(8 * 1024 * 1024), maxFiles: z.number().default(500), @@ -77,7 +85,10 @@ async function resolveGit(ctx: Context, signal: AbortSignal): Promise | undefined const gitRunner = (): Promise => { runner ??= resolveGit(ctx, lifetime.signal).then((executable) => { @@ -104,7 +116,7 @@ export function apply(ctx: Context, config: Config): void { let recorder = recorders.get(session) if (recorder === undefined) { recorder = new TurnRecorder(session, cwd, { - git: gitRunner(), home, temporaryRoots: roots, maxFiles: config.maxFiles, + git: gitRunner(), objects, home, temporaryRoots: roots, maxFiles: config.maxFiles, warn: (message) => { ctx.logger.warn(message) }, }) recorders.set(session, recorder) diff --git a/packages/fs/workspace-changes/src/recorder.ts b/packages/fs/workspace-changes/src/recorder.ts index 46b4facc0f..79d9735e63 100644 --- a/packages/fs/workspace-changes/src/recorder.ts +++ b/packages/fs/workspace-changes/src/recorder.ts @@ -3,7 +3,7 @@ import { realpath } from 'node:fs/promises' import { relative } from 'node:path' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { FileDiff } from '@deepseek-ai/dsh-tools' -import { diffTrees, ignoredPaths, locateGitWorkspace, snapshotTree, type GitRunner, type GitWorkspace } from './git.ts' +import { diffTrees, ignoredPaths, locateGitWorkspace, snapshotTree, type GitRunner, type GitWorkspace, type ObjectStoreOptions } from './git.ts' import { fileDiffsOf, hunkLineCounts } from './numstat.ts' import { absolutePathOf, compareDisplay, displayPathOf, durablePathOf, isInside, isTemporaryPath, toPosix } from './paths.ts' import type { WorkspaceChangedFile } from './types.ts' @@ -12,6 +12,8 @@ import type { WorkspaceChangedFile } from './types.ts' export interface RecorderEnvironment { /** Resolves to the runner, or null when git is unavailable and no turn records anything. */ git: Promise + /** Where snapshot objects live and how large one repository's store may grow. */ + objects: ObjectStoreOptions /** Canonical absolute home directory abbreviated as `~` in display paths. */ home: string /** Temporary roots whose files never enter a summary. */ @@ -68,7 +70,7 @@ export class TurnRecorder { if (git === null) return // git reports symlink-resolved paths; every comparison uses that form. const cwd = await realpath(this.cwd) - const workspace = await locateGitWorkspace(git, cwd, signal) + const workspace = await locateGitWorkspace(git, cwd, this.env.objects, signal) if (workspace === null) return const tree = await snapshotTree(git, workspace, signal) this.baseline = { git, workspace, tree, cwd } diff --git a/packages/fs/workspace-changes/tests/git.spec.ts b/packages/fs/workspace-changes/tests/git.spec.ts index 7a391d729e..79ab0a868d 100644 --- a/packages/fs/workspace-changes/tests/git.spec.ts +++ b/packages/fs/workspace-changes/tests/git.spec.ts @@ -56,7 +56,7 @@ describe('snapshots and diffs', () => { expect(() => git(cwd, 'merge', 'side')).toThrow() expect(git(cwd, 'status', '--porcelain')).toContain('UU f.txt') const { git: runnerGit } = await runner() - const workspace = await locateGitWorkspace(runnerGit, cwd, signal) + const workspace = await locateGitWorkspace(runnerGit, cwd, { home: await scratchDir('dsh-git-store-', cleanups), maxBytes: 1024 * 1024 }, signal) expect(workspace?.root).toBe(await realpath(cwd)) const tree = await snapshotTree(runnerGit, workspace!, signal) expect(tree).toMatch(/^[0-9a-f]{40,64}$/) @@ -66,13 +66,14 @@ describe('snapshots and diffs', () => { it('fails loudly when the addressed repository cannot be written or diffed', async () => { const cwd = await scratchDir('dsh-git-broken-', cleanups) const { git: runnerGit } = await runner() - expect(await locateGitWorkspace(runnerGit, cwd, signal)).toBeNull() - const broken = { root: cwd, gitDir: join(cwd, 'missing') } + const store = { home: await scratchDir('dsh-git-store-', cleanups), maxBytes: 1024 * 1024 } + expect(await locateGitWorkspace(runnerGit, cwd, store, signal)).toBeNull() + const broken = { root: cwd, gitDir: join(cwd, 'missing'), objectsDir: join(cwd, 'missing-objects'), env: {} } await expect(snapshotTree(runnerGit, broken, signal)).rejects.toThrow('git add in') await expect(ignoredPaths(runnerGit, broken, ['x'], signal)).rejects.toThrow('git check-ignore failed') expect(await ignoredPaths(runnerGit, broken, [], signal)).toEqual(new Set()) git(cwd, 'init', '-q') - const workspace = (await locateGitWorkspace(runnerGit, cwd, signal))! + const workspace = (await locateGitWorkspace(runnerGit, cwd, store, signal))! await expect(diffTrees(runnerGit, workspace, 'a'.repeat(40), 'b'.repeat(40), signal)).rejects.toThrow('git diff-tree failed') await writeFile(join(cwd, 'many.txt'), Array.from({ length: 50 }, (_, index) => `line ${index}`).join('\n')) const before = await snapshotTree(runnerGit, workspace, signal) @@ -94,7 +95,10 @@ describe('TurnRecorder', () => { const warnings: string[] = [] let release!: (runner: GitRunner | null) => void const gate = new Promise((resolve) => { release = resolve }) - const env = { git: gate, home: '', temporaryRoots: temporaryRoots(), maxFiles: 10, warn: (m: string) => { warnings.push(m) } } + const env = { + git: gate, objects: { home: await scratchDir('dsh-git-store-', cleanups), maxBytes: 1024 * 1024 }, + home: '', temporaryRoots: temporaryRoots(), maxFiles: 10, warn: (m: string) => { warnings.push(m) }, + } const disposed = new TurnRecorder(session, cwd, env) disposed.start(1) await new Promise(resolve => setTimeout(resolve, 5)) diff --git a/packages/fs/workspace-changes/tests/loader-composition.spec.ts b/packages/fs/workspace-changes/tests/loader-composition.spec.ts index 0a086da6fb..1d3f87f9a3 100644 --- a/packages/fs/workspace-changes/tests/loader-composition.spec.ts +++ b/packages/fs/workspace-changes/tests/loader-composition.spec.ts @@ -34,6 +34,8 @@ describe('real Loader composition', () => { "- name: '@deepseek-ai/dsh-session'", "- name: '@deepseek-ai/dsh-subprocess-local'", "- name: '@deepseek-ai/dsh-workspace-changes'", + ' config:', + ` dshHome: ${JSON.stringify(join(root, 'home'))}`, '', ].join('\n')) context = new Context() diff --git a/packages/fs/workspace-changes/tests/plugin.spec.ts b/packages/fs/workspace-changes/tests/plugin.spec.ts index 1aa9da3345..413b805a96 100644 --- a/packages/fs/workspace-changes/tests/plugin.spec.ts +++ b/packages/fs/workspace-changes/tests/plugin.spec.ts @@ -1,5 +1,5 @@ /** The plugin records each top-level turn's file changes from real git snapshots. */ -import { mkdir, mkdtemp, realpath, rm, stat, writeFile } from 'node:fs/promises' +import { mkdir, mkdtemp, readdir, realpath, rm, stat, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -22,8 +22,14 @@ async function boot(config: Partial = {}) { cleanups.push(() => ctx.fiber.dispose()) await ctx.plugin(SessionStore) await ctx.plugin(LocalSubprocessRuntime) - const fiber = await ctx.plugin(WorkspaceChanges, config as WorkspaceChanges.Config) - return { ctx, fiber } + const dshHome = config.dshHome ?? await scratchDir('dsh-workspace-changes-home-', cleanups) + const fiber = await ctx.plugin(WorkspaceChanges, { ...config, dshHome } as WorkspaceChanges.Config) + return { ctx, fiber, dshHome } +} + +/** Loose objects the repository itself holds. */ +function looseObjects(cwd: string): number { + return Number(git(cwd, 'count-objects').split(' ')[0]) } async function repository(): Promise { @@ -43,7 +49,8 @@ describe('workspace-changes in a repository', () => { const cwd = await repository() await writeFile(join(cwd, 'b.txt'), 'x user\n') await writeFile(join(cwd, 'u.txt'), 'user untracked\n') - const { ctx } = await boot() + const { ctx, dshHome } = await boot() + const repositoryObjects = looseObjects(cwd) const session = ctx.sessions.create(SessionId('repo'), { meta: { cwd } }) startTurn(session, 1) await settle(ctx, session) @@ -84,6 +91,36 @@ describe('workspace-changes in a repository', () => { expect(git(cwd, 'status', '--porcelain').split('\n').filter(Boolean).sort()).toEqual([ ' M a.txt', ' M b.txt', '?? bin.dat', '?? new.txt', '?? sub/', '?? u.txt', ]) + // Snapshot objects live under the Harness home; the repository's own store is untouched. + expect(looseObjects(cwd)).toBe(repositoryObjects) + const stores = await readdir(join(dshHome, 'workspace-changes')) + expect(stores).toHaveLength(1) + const objects = await readdir(join(dshHome, 'workspace-changes', stores[0]!), { recursive: true }) + expect(objects.some(entry => /^[0-9a-f]{2}\/[0-9a-f]{38,}$/.test(entry))).toBe(true) + }) + + it('discards a snapshot object store that outgrew its bound before the next baseline', async () => { + const cwd = await repository() + const { ctx, dshHome } = await boot({ objectStoreMaxBytes: 1 }) + const session = ctx.sessions.create(SessionId('bounded'), { meta: { cwd } }) + startTurn(session, 1) + await settle(ctx, session) + const [store] = await readdir(join(dshHome, 'workspace-changes')) + const marker = join(dshHome, 'workspace-changes', store!, 'marker') + await writeFile(marker, 'old store') + await writeFile(join(cwd, 'n.txt'), 'n\n') + toolCall(session, 1, 'bash', { command: 'x' }) + endTurn(session, 1) + await settle(ctx, session) + expect(changes(session)).toHaveLength(1) + startTurn(session, 2) + await settle(ctx, session) + await expect(stat(marker)).rejects.toThrow() + await writeFile(join(cwd, 'm.txt'), 'm\n') + toolCall(session, 2, 'bash', { command: 'x' }) + endTurn(session, 2) + await settle(ctx, session) + expect(changes(session).at(-1)!.files.map(file => file.display)).toEqual(['m.txt']) }) it('places files above the working directory and outside the repository by their display rule', async () => { diff --git a/packages/fs/workspace-changes/tsconfig.json b/packages/fs/workspace-changes/tsconfig.json index a349b93708..b0d5795f1d 100644 --- a/packages/fs/workspace-changes/tsconfig.json +++ b/packages/fs/workspace-changes/tsconfig.json @@ -25,6 +25,9 @@ }, { "path": "../../subprocess/subprocess" + }, + { + "path": "../../util/home-paths" } ] } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ababbf38af..b176b19ea5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -6288,6 +6288,9 @@ importers: '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent + '@deepseek-ai/dsh-home-paths': + specifier: workspace:^ + version: link:../../util/home-paths '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm