feat(workspace-changes): keep snapshot objects in a private store under the Harness home
Write every snapshot blob and tree into a per-repository object directory under DSH_HOME/workspace-changes with the repository's objects attached as a read-only alternate, so the user's repository gains no objects and the Harness owns retention through objectStoreMaxBytes. The placement follows the workspace change journal POC and costs nothing measurable.
This commit is contained in:
1 parent
63f1f6eca4
commit
d6634272d5
19 files changed
+165
-47
No files matched your search
@@ -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
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 仓库时才新增该事件与卡片,一个专门的场景负责种入这样的仓库。卡片取代了中英文的“本轮文件改动”行;正文文件提及仍按修改调用路径与交付解析。
|
||||
|
||||
|
||||
@@ -609,6 +609,8 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
: [{ id: 'connection', config: { trustedHosts: [options.remoteAuthority] } }],
|
||||
{ id: 'settings', config: { dshHome: harnessHome } },
|
||||
{ id: 'credentials', config: { dshHome: harnessHome } },
|
||||
// Snapshot object stores of git-seeded scenarios stay inside the owned harness home.
|
||||
{ id: 'workspace-changes', config: { dshHome: harnessHome } },
|
||||
// The shipped directory-picker row is the -auto chooser, which resolves
|
||||
// the interaction from the RUNNING host (display, SSH launch, bind). The
|
||||
// lane's goldens are interaction-specific (workspace-management drives
|
||||
|
||||
@@ -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: e776a396ca0c18b02d9e91f0e979e102cf40aba4
|
||||
config-catalog.zh.md: 2eb933afd86b7c3358bb63daa7639b050fb68d1d
|
||||
config-catalog.md: 4cc75e2bf73887a2dca9ee9c2314d70626d1a66a
|
||||
config-catalog.zh.md: df436e5b67c637b0a99416a9a8250aef95da7f50
|
||||
@@ -3454,8 +3454,12 @@ Source: [`packages/workflow/workflow-worker-thread/src/index.ts:32`](../packages
|
||||
Requires: `subprocess`
|
||||
|
||||
```ts config-catalog
|
||||
/** 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. */
|
||||
@@ -3465,7 +3469,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/fs/workspace-changes/src/index.ts:28`](../packages/fs/workspace-changes/src/index.ts)
|
||||
Source: [`packages/fs/workspace-changes/src/index.ts:30`](../packages/fs/workspace-changes/src/index.ts)
|
||||
|
||||
## Loadable plugins with no config
|
||||
|
||||
|
||||
@@ -3456,8 +3456,12 @@ export interface Config {
|
||||
依赖: `subprocess`
|
||||
|
||||
```ts config-catalog
|
||||
/** 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. */
|
||||
@@ -3467,7 +3471,7 @@ export interface Config {
|
||||
}
|
||||
```
|
||||
|
||||
来源: [`packages/fs/workspace-changes/src/index.ts:28`](../packages/fs/workspace-changes/src/index.ts)
|
||||
来源: [`packages/fs/workspace-changes/src/index.ts:30`](../packages/fs/workspace-changes/src/index.ts)
|
||||
|
||||
## 无配置的可加载插件
|
||||
|
||||
|
||||
@@ -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 packages/fs/workspace-changes/README.md
|
||||
README.md: 0c25dbacb039c32cc7cb7f3cb3ec57e251cc3de3
|
||||
README.zh.md: 1e400e422c87f9b9cd86c532ffdf1143daf92717
|
||||
README.md: a6eff33162095afea9064af69f8685d946c3eab3
|
||||
README.zh.md: f8ac055711e0c888c3d5b79d2ce3c28d4fc90f32
|
||||
@@ -35,11 +35,13 @@ The shipped Web bundle mounts this plugin. Mount it in any composition with the
|
||||
|
||||
| Field | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `dshHome` | `$DSH_HOME`, then `~/.dsh` | Harness home whose `workspace-changes/` directory holds one snapshot object store per repository |
|
||||
| `objectStoreMaxBytes` | `1073741824` | Bytes one repository's snapshot object store may hold before it is discarded and restarted empty |
|
||||
| `timeoutMs` | `30000` | Milliseconds one git command may run before the turn's record is abandoned |
|
||||
| `outputMaxBytes` | `8388608` | Bytes of git output retained per command; a larger diff listing abandons the record |
|
||||
| `maxFiles` | `500` | Maximum files carried by one event; `total` still reports the complete count |
|
||||
|
||||
Every Session whose working directory lies inside a git repository and that has no subagent origin is recorded; subagent Sessions and working directories outside any repository are not. Snapshots go into the repository's object store through a private index, so the repository's own index, work tree, and refs stay untouched and the user's earlier uncommitted changes never enter a summary. Nested repositories and submodules inside the working directory are recorded as gitlinks, so their internal changes do not appear. Without git — or, on macOS, with only the developer-tools stub at `/usr/bin/git` — the plugin records nothing and logs that once.
|
||||
Every Session whose working directory lies inside a git repository and that has no subagent origin is recorded; subagent Sessions and working directories outside any repository are not. Snapshots are written through a private index into a private object store under the Harness home, with the repository's own object store attached as a read-only alternate; the repository's index, objects, work tree, and refs stay untouched, and the user's earlier uncommitted changes never enter a summary. A store that outgrows `objectStoreMaxBytes` is discarded before the next snapshot and starts empty. Nested repositories and submodules inside the working directory are recorded as gitlinks, so their internal changes do not appear. Without git — or, on macOS, with only the developer-tools stub at `/usr/bin/git` — the plugin records nothing and logs that once.
|
||||
|
||||
Files the file tools changed but the snapshots do not cover are added from the hunks those tools persist with their results: files matching an ignore pattern and files outside the repository. Files under `/tmp` or the platform temporary directory are excluded unless they lie inside the working directory. Line counts for these files sum over the recorded hunks, so repeated edits to one file in a turn can count a line more than once. Changes made through shell commands outside the snapshot coverage are not recorded.
|
||||
|
||||
@@ -53,7 +55,7 @@ Each file carries a durable `path` — relative to the working directory inside
|
||||
<details>
|
||||
<summary>Implementation internals — click to expand</summary>
|
||||
|
||||
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.
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- 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.
|
||||
|
||||
@@ -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"
|
||||
<details>
|
||||
<summary>实现细节——点击展开</summary>
|
||||
|
||||
每个 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_
|
||||
|
||||
<a id="known-limitations-and-deferred-work"></a>
|
||||
|
||||
- 快照树是无引用对象,仓库的垃圾回收会在其过期期限后删除它们;记录的计数保留,而将来做整文件对比所需的树不会保留。
|
||||
- 快照树只保留到其私有对象库超过 `objectStoreMaxBytes` 被丢弃为止;记录的计数保留,而将来做整文件对比所需的树不会保留。
|
||||
- 用户在轮次进行中自己做的编辑会被算到该轮。
|
||||
- 不在任何 git 仓库内的工作目录没有卡片;Harness home 下的影子仓库暂缓,直到其排除规则能可靠地代替缺失的 `.gitignore`。
|
||||
- 快照覆盖范围之外的文件按 hunk 累加计数,不是首尾对比,且只覆盖文件工具。
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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<Record<string, string>>
|
||||
}
|
||||
|
||||
/** 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<number> {
|
||||
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<GitWorkspace | null> {
|
||||
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<GitWorkspace | null> {
|
||||
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<NumstatEntry[]> {
|
||||
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<Set<string>> {
|
||||
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 !== ''))
|
||||
}
|
||||
@@ -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<Config> = 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<string | n
|
||||
* @param config - validated bounds and placement.
|
||||
*/
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
for (const [field, value] of [['timeoutMs', config.timeoutMs], ['outputMaxBytes', config.outputMaxBytes], ['maxFiles', config.maxFiles]] as const) {
|
||||
for (const [field, value] of [
|
||||
['timeoutMs', config.timeoutMs], ['outputMaxBytes', config.outputMaxBytes], ['maxFiles', config.maxFiles],
|
||||
['objectStoreMaxBytes', config.objectStoreMaxBytes],
|
||||
] as const) {
|
||||
if (!Number.isSafeInteger(value) || value < 1) throw new Error(`workspace-changes requires a positive integer ${field}`)
|
||||
}
|
||||
const lifetime = new AbortController()
|
||||
@@ -89,6 +100,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
})
|
||||
const roots = temporaryRoots()
|
||||
const home = realpathSync.native(homedir())
|
||||
const objects = { home: join(resolveDshHome(config.dshHome), 'workspace-changes'), maxBytes: config.objectStoreMaxBytes }
|
||||
let runner: Promise<GitRunner | null> | undefined
|
||||
const gitRunner = (): Promise<GitRunner | null> => {
|
||||
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)
|
||||
|
||||
@@ -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<GitRunner | null>
|
||||
/** 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 }
|
||||
|
||||
@@ -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<GitRunner | null>((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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<WorkspaceChanges.Config> = {}) {
|
||||
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<string> {
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -25,6 +25,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../subprocess/subprocess"
|
||||
},
|
||||
{
|
||||
"path": "../../util/home-paths"
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+3
@@ -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
|
||||
|
||||
Reference in new issue
Block a user