dsh-rewind-plugin 0.11.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTRIBUTING.md +4 -3
- package/README.en.md +1 -1
- package/README.md +2 -2
- package/SECURITY.md +54 -23
- package/docs/architecture.md +12 -6
- package/docs/compat/audit.md +1 -1
- package/docs/compat/tracking-boundary.md +6 -6
- package/docs/compat/tracking-boundary.zh.md +2 -2
- package/docs/format.md +159 -53
- package/docs/snapshot-auto-cleanup.md +8 -10
- package/docs/snapshot-auto-cleanup.zh.md +2 -4
- package/lib/client.js +1 -1
- package/lib/index.js +1026 -223
- package/lib/types/locales.d.ts +1 -0
- package/lib/types/snapshot.d.ts +397 -83
- package/package.json +1 -1
package/CONTRIBUTING.md
CHANGED
|
@@ -58,9 +58,10 @@ npm run verify:host # end-to-end host verification (full check suite)
|
|
|
58
58
|
## Testing expectations
|
|
59
59
|
|
|
60
60
|
- Pure planning (`rewind.ts`, `hidden.ts`) → unit tests in `tests/`.
|
|
61
|
-
- Store behavior (`snapshot.ts`) → `tests/snapshot.test.ts
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
- Store behavior (`snapshot.ts`) → `tests/snapshot.test.ts` plus the focused
|
|
62
|
+
suites (`snapshot-bytes`, `snapshot-mode`, `downgrade-safety`,
|
|
63
|
+
`parent-guard`), and crash-safety scenarios in `tests/crash-safety.test.ts`
|
|
64
|
+
via the test-only `crash` seam (`RestoreRunOptions.crash`).
|
|
64
65
|
- Harness interaction → the compatibility suites
|
|
65
66
|
(`compat-invariants` / `compat-interop` / `compat-gaps`) and
|
|
66
67
|
`scripts/verify-host.mjs`.
|
package/README.en.md
CHANGED
|
@@ -207,7 +207,7 @@ Contributing guide: [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
|
207
207
|
Releases go out through GitHub Actions Trusted Publishing (OIDC, no stored `NPM_TOKEN`): push a `v<version>` tag and CI publishes with Sigstore provenance.
|
|
208
208
|
|
|
209
209
|
```sh
|
|
210
|
-
npm version patch && git push origin
|
|
210
|
+
npm version patch && git push origin <branch> --tags
|
|
211
211
|
```
|
|
212
212
|
|
|
213
213
|
One-time npm-side setup and the full workflow details: [docs/release/release.md](docs/release/release.md).
|
package/README.md
CHANGED
|
@@ -168,7 +168,7 @@ rm <dsh home>/snapshot-cleanup-last-sweep.json
|
|
|
168
168
|
|
|
169
169
|
## 安全
|
|
170
170
|
|
|
171
|
-
本插件只向会话日志追加回退标记事件,从不删除或改写已记录的历史。工作区文件仅在「回退对话和代码」时被改写,备份存储于
|
|
171
|
+
本插件只向会话日志追加回退标记事件,从不删除或改写已记录的历史。工作区文件仅在「回退对话和代码」时被改写,备份存储于 `<dsh home>/rewind-snapshots/`;还原以备份为唯一来源。不触碰你的 git 仓库,无网络请求,不访问任何凭据。对**长期不活跃**的会话,另有默认关闭的全局自动清理可整目录移除其快照,不影响活动会话与对话日志。完整安全模型:[SECURITY.md](SECURITY.md)。
|
|
172
172
|
|
|
173
173
|
## 开发
|
|
174
174
|
|
|
@@ -192,7 +192,7 @@ node scripts/verify-host.mjs # 端到端验证构建产物
|
|
|
192
192
|
通过 GitHub Actions Trusted Publishing(OIDC,无存储 `NPM_TOKEN`)发布:推送 `v<版本>` tag,CI 即带 Sigstore provenance 发布。
|
|
193
193
|
|
|
194
194
|
```sh
|
|
195
|
-
npm version patch && git push origin
|
|
195
|
+
npm version patch && git push origin <branch> --tags
|
|
196
196
|
```
|
|
197
197
|
|
|
198
198
|
一次性 npm 侧配置与完整流程:见 [docs/release/release.zh.md](docs/release/release.zh.md)。
|
package/SECURITY.md
CHANGED
|
@@ -12,9 +12,8 @@ The plugin runs in the DSH host process and therefore holds the host user's
|
|
|
12
12
|
filesystem authority — it reads and writes files with plain `node:fs`. The
|
|
13
13
|
following are treated as **untrusted inputs**:
|
|
14
14
|
|
|
15
|
-
- **Model arguments** — the `file_path`
|
|
16
|
-
|
|
17
|
-
checkpoint store records).
|
|
15
|
+
- **Model arguments** — the `file_path` field of `write` / `edit` tool calls
|
|
16
|
+
(it names the paths the checkpoint store records).
|
|
18
17
|
- **Session log contents** — events are parsed structurally; a hostile or
|
|
19
18
|
malformed id must never escape the store root.
|
|
20
19
|
- **Current worktree state** — restore planning reconciles against the live
|
|
@@ -57,7 +56,13 @@ to exactly the apply-start state, and per-file failures are reported rather
|
|
|
57
56
|
than hidden.
|
|
58
57
|
|
|
59
58
|
A failed gate fails closed: an invalid target, a missing store, an absent
|
|
60
|
-
backup, or a cancelled invocation aborts the rewind with an error.
|
|
59
|
+
backup, or a cancelled invocation aborts the rewind with an error. A store
|
|
60
|
+
written by a **newer** build is one of those gates: it is refused as a whole —
|
|
61
|
+
no partial restore and no new entry written into it, and the automatic
|
|
62
|
+
format-change clear below is skipped — while the conversation rewind itself
|
|
63
|
+
still works. An explicit clear (`run --current --apply`) or the age-based sweep,
|
|
64
|
+
being a user-directed deletion of whole session directories, is deliberately not
|
|
65
|
+
version-gated.
|
|
61
66
|
|
|
62
67
|
**Automatic store deletion**: two things delete session snapshots. The opt-in
|
|
63
68
|
`snapshot-auto-cleanup` sweep (default off) removes the whole directories of
|
|
@@ -68,7 +73,9 @@ positions that no longer line up. Both stay confined to the store root, use
|
|
|
68
73
|
`lstat` (so they never follow a symlink out of the root), the sweep never targets
|
|
69
74
|
the active session, and neither touches the conversation log. When auto-cleanup
|
|
70
75
|
is disabled (the default), only the format-change clear runs, and only for an
|
|
71
|
-
affected session.
|
|
76
|
+
affected session. The store-format guard runs before that clear, so a session
|
|
77
|
+
whose store is **newer** than this build understands is not cleared by it (see
|
|
78
|
+
`docs/format.md`).
|
|
72
79
|
|
|
73
80
|
## Conversation integrity
|
|
74
81
|
|
|
@@ -91,29 +98,49 @@ transcript.
|
|
|
91
98
|
`[a-zA-Z0-9._-]` (`safeSessionId` / `safeFileId`) before joining the store
|
|
92
99
|
root; `.` and `..` bare values are replaced — hostile ids cannot traverse
|
|
93
100
|
out of the root.
|
|
94
|
-
- **Never written through
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
101
|
+
- **Never written through a link or a moved directory**: two checks guard a
|
|
102
|
+
tracked path before any write or unlink. (1) A path whose last component is a
|
|
103
|
+
symlink or a hard link (`lstat().nlink > 1`) is skipped and reported, never
|
|
104
|
+
restored — a symlink would redirect the write outside the checkpoint, and a
|
|
105
|
+
hard link would clobber every other name of the same inode (e.g.
|
|
106
|
+
pnpm-installed files). (2) Every record this build writes — entry, link and
|
|
107
|
+
journal action — carries the `realpath` of the file's directory at commit time
|
|
108
|
+
(the location pin; best-effort, and absent on released-v1 data or a commit
|
|
109
|
+
whose parent could not be resolved), and a restore re-checks it before
|
|
110
|
+
touching the path, including a post-restart continue or rollback, which reads
|
|
111
|
+
the pin from the journal: a directory that no longer resolves there (a
|
|
112
|
+
repointed or moved ancestor) is refused and reported instead of writing
|
|
113
|
+
outside the recorded location, while data with no pin falls back to check (1)
|
|
114
|
+
alone. A parent chain that
|
|
115
|
+
is gone is still recreated (files whose directory was deleted are restorable),
|
|
116
|
+
but only while its nearest surviving ancestor resolves inside the pin. A
|
|
117
|
+
stable symlinked ancestor resolves identically on both sides, so a symlinked
|
|
118
|
+
workspace or temp root is never a false skip. This matches Claude Code's
|
|
119
|
+
checkpoint behavior since v2.1.216.
|
|
98
120
|
- **Restores name only recorded paths**: the store contains resolved display
|
|
99
121
|
paths of the session's own write-class tool calls (plus boundary re-checks
|
|
100
122
|
over that same tracked set) — a restore can never write an arbitrary path.
|
|
101
123
|
- **Path resolution rule**: relative paths resolve against the session
|
|
102
124
|
workspace cwd, mirroring the fs tools' own rule (`src/session-cwd.ts`).
|
|
103
125
|
- **Bounded backups**: `prune` keeps the newest 100 anchor groups per session
|
|
104
|
-
(`MAX_ANCHOR_GROUPS`),
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
126
|
+
(`MAX_ANCHOR_GROUPS`), except groups a non-terminal restore journal still
|
|
127
|
+
references (pinned so the op can be finished), so backup accumulation stays
|
|
128
|
+
bounded by the cap plus the number of unresolved journals (the exact cap is
|
|
129
|
+
pinned in `docs/format.md`). Across sessions, the opt-in `pruneStale` sweep
|
|
130
|
+
removes whole **long-inactive** session directories (measured by the newest
|
|
131
|
+
member being idle past `maxAgeDays`); it uses `lstat` (no symlink following),
|
|
132
|
+
skips dot-prefixed temp files, and never targets the active session
|
|
133
|
+
(`keepActiveId`).
|
|
110
134
|
|
|
111
135
|
## Crash safety
|
|
112
136
|
|
|
113
|
-
- **
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
137
|
+
- **Ordered commits**: a checkpoint commit places the before-sidecar (a raw
|
|
138
|
+
byte copy) before publishing the entry JSON, so a crash can leave an
|
|
139
|
+
unreferenced sidecar but never an entry whose bytes are missing. Every JSON
|
|
140
|
+
write (checkpoint entries, restore journals) goes to a sibling temp file and
|
|
141
|
+
is renamed over the target: a host crash mid-write can leave only an inert
|
|
142
|
+
`<target>.tmp` — never a readable half-written file — and readers never pick
|
|
143
|
+
up temp files.
|
|
117
144
|
- **Journaled restores**: before mutating anything, the restore captures each
|
|
118
145
|
planned path's pre-restore ("rescue") state and persists an intent journal,
|
|
119
146
|
then marks each action done as it is applied. A crash at any point leaves
|
|
@@ -126,7 +153,9 @@ transcript.
|
|
|
126
153
|
- **Fail-loud vs fail-soft**: a corrupt **journal** is reported
|
|
127
154
|
`recovery-required` — never silently dropped (dropping it would erase the
|
|
128
155
|
interrupted restore's recovery record). Corrupt **checkpoint entries** are
|
|
129
|
-
silently ignored (they only lose one backup, not the recovery path)
|
|
156
|
+
silently ignored (they only lose one backup, not the recovery path), and a
|
|
157
|
+
record whose sidecar is missing or truncated fails only that file — never a
|
|
158
|
+
delete of the live file.
|
|
130
159
|
- **Journal IO never fails the restore**: if the journal cannot be written the
|
|
131
160
|
restore proceeds with pre-journal semantics (crash safety degrades,
|
|
132
161
|
behavior does not).
|
|
@@ -136,9 +165,11 @@ transcript.
|
|
|
136
165
|
- This plugin does **not** sandbox other processes or stop them from changing
|
|
137
166
|
files concurrently.
|
|
138
167
|
- It does **not** provide confidentiality or tamper resistance against the
|
|
139
|
-
same operating-system user:
|
|
140
|
-
default permissions (
|
|
141
|
-
|
|
168
|
+
same operating-system user: **store** files are created with the process
|
|
169
|
+
default permissions (a standard umask applies), and the host user remains
|
|
170
|
+
trusted. Restored **workspace** files do carry their recorded permission
|
|
171
|
+
bits: a content restore reapplies them (`chmod`, best-effort), while a mode
|
|
172
|
+
difference on its own never triggers a restore.
|
|
142
173
|
- It does **not** exclude paths from the store, and it never oversteps: the
|
|
143
174
|
plugin only adds backup/restore on top of permissions DSH already holds. Two
|
|
144
175
|
distinct cases follow:
|
package/docs/architecture.md
CHANGED
|
@@ -81,7 +81,8 @@ Key invariants:
|
|
|
81
81
|
on it, so the marker is appended while idle, outside any turn, as one event.
|
|
82
82
|
- **Restore is reconciled against the live disk** (`planRestore`), so repeated
|
|
83
83
|
rewinds are idempotent and a rewind whose target state already matches is a
|
|
84
|
-
no-op.
|
|
84
|
+
no-op. A path whose directory no longer resolves to its commit-time location
|
|
85
|
+
is skipped and reported, never written through (see `SECURITY.md`).
|
|
85
86
|
|
|
86
87
|
### Marker format history
|
|
87
88
|
|
|
@@ -103,19 +104,24 @@ the `0.9.x` line) and is **removed in the `0.10.x` line**.
|
|
|
103
104
|
## Checkpoint pipeline (Claude Code before-backup model)
|
|
104
105
|
|
|
105
106
|
```
|
|
106
|
-
tools/execute captureBefore: for write / edit
|
|
107
|
-
|
|
107
|
+
tools/execute captureBefore: for write / edit, stage a raw byte copy of
|
|
108
|
+
the file's BEFORE state into the store's .pending/
|
|
109
|
+
(node:fs copyFile, never through a string);
|
|
108
110
|
subagent edits are NOT tracked (Claude Code alignment).
|
|
109
111
|
tools/post-execute commitEntry: anchor = latest user/message seq; skip
|
|
110
|
-
failed calls;
|
|
112
|
+
failed calls; publish the staged bytes as the entry's
|
|
113
|
+
sidecar and write the metadata beside them — including
|
|
114
|
+
where the directory resolved (`realpath`), the location
|
|
115
|
+
pin a restore re-checks.
|
|
111
116
|
session/event user/message boundary: reconcileTracked re-reads every
|
|
112
117
|
(user/message) tracked file and records a new before-backup for any
|
|
113
118
|
whose disk state changed since last seen — external
|
|
114
119
|
edits/deletions enter the record this way.
|
|
115
120
|
prune keeps the newest 100 anchor groups per session, storing
|
|
116
121
|
identical before-content as in-place links that are
|
|
117
|
-
materialized before their group is dropped,
|
|
118
|
-
terminal restore
|
|
122
|
+
materialized before their group is dropped, never dropping
|
|
123
|
+
a group a non-terminal restore journal still references,
|
|
124
|
+
and recycles terminal restore journals.
|
|
119
125
|
pruneStale cross-session auto-cleanup (default off): whole
|
|
120
126
|
long-inactive session dirs past the cutoff are removed;
|
|
121
127
|
the active session is never targeted.
|
package/docs/compat/audit.md
CHANGED
|
@@ -45,7 +45,7 @@ version's shape only (no `Session.events` / `[data-time-hover-root]` /
|
|
|
45
45
|
| I3 step/turn structure | Client turn-tail ordering, unique `step/start` (real turns), no ghost turns; the `user/message` rewind marker adds no step frame | `compat-invariants` I3, `helpers.assertTurnTailOrdering` |
|
|
46
46
|
| I4 fold-service safety | stats / title / goal / projection fold a marker-bearing log without throwing, with predictable values | `compat-invariants` I4 |
|
|
47
47
|
| I5 compact interop | A tool-call orphaned by a cancelled turn is pair-balanced once shadowed by a rewind; a rewind across a compaction checkpoint is explicitly refused; a rewind-then-compact transaction stays legal | `compat-interop` I5, `verify-host` 12/14 |
|
|
48
|
-
| I6 tool pipeline | before-snapshot capture/commit/restore is correct (existing `snapshot.test.ts` + `verify-host` 4–8); cancellation timing never hangs | `verify-host` 4–8, 15 |
|
|
48
|
+
| I6 tool pipeline | before-snapshot capture/commit/restore is correct (existing `snapshot.test.ts` + `verify-host` 4–8) for the tracked tools `write` / `edit` — `str_replace_editor` is an optional DSH package that stopped being a default tool in DSH 0.1.3, so it is not tracked; cancellation timing never hangs | `verify-host` 4–8, 15 |
|
|
49
49
|
| I7 client ordering | A log carrying tool turns and rewind markers (a single `user/message` replace) satisfies the client builder ordering | `compat-interop` I7 |
|
|
50
50
|
| I8 runtime safety | `rewind`/`compact` combinations never leave a dangling step/turn frame | `verify-host` 15 |
|
|
51
51
|
|
|
@@ -9,16 +9,16 @@
|
|
|
9
9
|
## One-line summary
|
|
10
10
|
|
|
11
11
|
Rewind **always** restores files edited with the dedicated editing tools
|
|
12
|
-
(`write` / `edit`
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
12
|
+
(`write` / `edit`) because the plugin snapshots them before the edit.
|
|
13
|
+
Command-line (or hand) edits are covered **only when the file was previously
|
|
14
|
+
registered** by an editing tool in the same conversation — and, in that case,
|
|
15
|
+
the change itself is recorded, but rewinding to a boundary inside an unchanged
|
|
16
|
+
interval is only as precise as the next recorded state (see
|
|
17
17
|
[Rewind precision](#rewind-precision-for-a-registered-file-changed-outside-editing-tools)).
|
|
18
18
|
|
|
19
19
|
## How file changes are classified
|
|
20
20
|
|
|
21
|
-
**Editing tools** (create/overwrite,
|
|
21
|
+
**Editing tools** (create/overwrite, line edits): each call tells the
|
|
22
22
|
plugin *which file* it will change, so the plugin snapshots the file's current
|
|
23
23
|
content **before** the edit and can restore it on rewind. This path is
|
|
24
24
|
deterministic — using them always yields a restorable rewind.
|
|
@@ -4,11 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
## 一句话概括
|
|
6
6
|
|
|
7
|
-
回退**始终能还原**用专用编辑工具(`write` / `edit
|
|
7
|
+
回退**始终能还原**用专用编辑工具(`write` / `edit`)编辑过的文件,因为插件会在编辑前对其快照。**命令行(或手动)编辑**仅当该文件在同一会话中**先前已被某个编辑工具登记**时才会被覆盖——此时变更本身会被记录,但回退到"未变区间中间的边界"时,只能精确到**下一处被记录的状态**(见[已登记文件被编辑工具之外的方式改动时的回退精度](#已登记文件被编辑工具之外的方式改动时的回退精度))。
|
|
8
8
|
|
|
9
9
|
## 文件变更的分类
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
**编辑工具**(创建/覆盖、按行编辑):每次调用都会告诉插件*将要改动哪个文件*,因此插件会在编辑前对文件的当前内容**快照**,回退时能还原。这条路径是**确定性**的——用了就一定可还原。
|
|
12
12
|
|
|
13
13
|
**命令行或手动编辑**:Windows 上的 PowerShell(`pwsh`)写入、Linux 上的 `sed -i`,或手动保存文件。它们**不告诉**插件动了哪个文件,所以**没有可预先快照的对象**。
|
|
14
14
|
|
package/docs/format.md
CHANGED
|
@@ -16,63 +16,105 @@ Deleting the root only removes file backups; the store rebuilds from scratch.
|
|
|
16
16
|
```
|
|
17
17
|
<root>/
|
|
18
18
|
└── <sessionId>/ # safeSessionId(sessionId)
|
|
19
|
+
├── store # store-format marker ("2")
|
|
20
|
+
├── format # DSH session-format marker (session.header.version)
|
|
19
21
|
├── <anchorSeq>/ # decimal seq of the anchoring user/message
|
|
20
|
-
│
|
|
21
|
-
└──
|
|
22
|
+
│ ├── <base>.json # one committed before-backup (metadata)
|
|
23
|
+
│ └── <base>.before # its raw byte sidecar (the before content)
|
|
24
|
+
├── .pending/ # captures staged but not yet committed
|
|
25
|
+
│ └── <base>.before
|
|
26
|
+
├── rescue/<opId>/<n>.before # one pre-restore ("rescue") copy per op
|
|
27
|
+
└── journal-<opId>.json # one restore-op journal
|
|
22
28
|
```
|
|
23
29
|
|
|
24
30
|
- `sessionId` is sanitized to `[a-zA-Z0-9._-]`; the bare values `.` and `..`
|
|
25
31
|
are replaced (`safeSessionId`) so a hostile id cannot traverse out of the
|
|
26
32
|
root.
|
|
27
|
-
- `callId` is sanitized to `[a-zA-Z0-9._-]` (`safeFileId`)
|
|
33
|
+
- `callId` is sanitized to `[a-zA-Z0-9._-]` (`safeFileId`) and extended with an
|
|
34
|
+
8-hex digest of the unsanitized id — `<base>` is
|
|
35
|
+
`<safeFileId(callId)>-<sha256(callId)[0..8]>`, so two call ids that sanitize
|
|
36
|
+
to the same name (e.g. `a:b` / `a_b`) cannot collide. Readers never infer a
|
|
37
|
+
name: every reference names its file, so pre-digest (released v1) names keep
|
|
38
|
+
resolving.
|
|
28
39
|
- `<anchorSeq>` is a decimal integer; directories with non-integer names are
|
|
29
40
|
ignored by readers.
|
|
30
|
-
-
|
|
31
|
-
|
|
32
|
-
|
|
41
|
+
- Checkpoint entries are read from those numeric directories only
|
|
42
|
+
(`<anchorSeq>/<base>.json`). Neither marker is `.json`, and a stray `.json` in
|
|
43
|
+
the session root is ignored.
|
|
44
|
+
- Journal files are recognized by the `journal-` prefix (current format) or the
|
|
45
|
+
released `restore-journal-` prefix (read-only compatibility).
|
|
46
|
+
- `store` is the session's store-format marker (a decimal version, written
|
|
47
|
+
atomically); a missing marker means the released v1 string format. `format` is
|
|
48
|
+
the DSH **session**-format marker the snapshots were anchored under (see
|
|
49
|
+
`docs/snapshot-auto-cleanup.md`) — the two are independent.
|
|
33
50
|
|
|
34
51
|
## Checkpoint entry
|
|
35
52
|
|
|
36
|
-
One JSON file per before-backup, named `<
|
|
53
|
+
One JSON file per before-backup, named `<base>.json` next to its `<base>.before`
|
|
54
|
+
sidecar:
|
|
37
55
|
|
|
38
56
|
```ts
|
|
39
|
-
interface
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
57
|
+
interface CheckpointEntryJson { // one `<base>.json`
|
|
58
|
+
store: 2 // on-disk store format (absent/1 = the released v1 string format)
|
|
59
|
+
callId: string // the tool call that mutated the file
|
|
60
|
+
file: string // resolved display path (absolute)
|
|
61
|
+
parent?: string // realpath of the file's directory at commit time (the location pin)
|
|
62
|
+
blob: string | null // sibling sidecar file name; null = the file was created
|
|
63
|
+
size: number // byte length of the sidecar (0 when `blob` is null)
|
|
64
|
+
mode?: number // permission bits, applied when content is restored
|
|
65
|
+
lossy?: true // recorded from v1 content that had already lost bytes
|
|
66
|
+
time: number // epoch ms, strictly increasing within a store instance
|
|
45
67
|
}
|
|
46
68
|
```
|
|
47
69
|
|
|
70
|
+
A dedup link (see below) carries `callId`, `file`, `ref`, `time` and an
|
|
71
|
+
optional `parent` instead of `blob` and `size`.
|
|
72
|
+
|
|
48
73
|
Semantics:
|
|
49
74
|
|
|
50
|
-
- **`
|
|
51
|
-
|
|
52
|
-
|
|
75
|
+
- **`blob` is the pre-edit state**: a string names the sidecar holding the exact
|
|
76
|
+
before bytes (copied raw, never through a JS string, so binary and non-UTF-8
|
|
77
|
+
files round-trip byte-exactly); `null` means the call created the file — valid
|
|
78
|
+
only with `size: 0` and never with `lossy`. The name is an invariant: it must
|
|
79
|
+
equal the entry's own file name minus `.json` plus `.before`.
|
|
80
|
+
- **`anchorSeq` is the parent directory**, deliberately not a field of the
|
|
81
|
+
entry: rewinding to message N applies every entry anchored at or after N (the
|
|
82
|
+
boundary is inclusive).
|
|
83
|
+
- **`parent` is the location pin**: the `realpath` of `dirname(file)` at commit
|
|
84
|
+
time. A restore refuses the path when its directory no longer resolves there.
|
|
85
|
+
Only the path's FINAL component is checked for links (`lstat().nlink > 1` or a
|
|
86
|
+
symlink; see `SECURITY.md`), so a repointed ancestor directory would otherwise
|
|
87
|
+
redirect the write — or the unlink of a recorded creation — outside the
|
|
88
|
+
recorded location. Absent means "no pin" and falls back to the final-component
|
|
89
|
+
check alone.
|
|
53
90
|
- **`time` is the ordering key within an anchor group**: it is monotonic per
|
|
54
91
|
store instance (bumped past the previous commit), so same-millisecond
|
|
55
92
|
commits stay capture-ordered and a re-read always picks the same "earliest"
|
|
56
93
|
entry per path.
|
|
94
|
+
- **`mode` never decides an action** (a mode-only difference is a no-op); it is
|
|
95
|
+
applied, best-effort, only when content is written back.
|
|
96
|
+
- **`lossy` marks a record that had already lost bytes** (v1 content that was
|
|
97
|
+
decoded lossily, or a materialized link to one): it is comparable but never
|
|
98
|
+
written back, so a lossy record can never overwrite a live file.
|
|
57
99
|
- Synthetic re-check entries (external edits/deletions seen at a user-message
|
|
58
100
|
boundary) use `callId = recheck-<anchorSeq>-<sha256(path) first 8 hex>`.
|
|
59
101
|
|
|
60
102
|
### Dedup link entry
|
|
61
103
|
|
|
62
104
|
A tracked file that records the same `before` content as its immediately-prior
|
|
63
|
-
entry for that path is stored as a **link** instead of a
|
|
64
|
-
carries a `ref` (the `<anchorSeq>/<
|
|
65
|
-
`
|
|
66
|
-
resolves the `ref` back to the terminal real snapshot; `
|
|
67
|
-
means "the file was created". A `ref` is validated as
|
|
68
|
-
`<digits>/<
|
|
69
|
-
hostile ref cannot escape the store root when followed
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
105
|
+
entry for that path is stored as a **link** instead of a second byte copy: the
|
|
106
|
+
entry carries a `ref` (the `<anchorSeq>/<base>.json` of that prior entry) and
|
|
107
|
+
omits `blob`/`size`, so identical content is never duplicated across entries. A
|
|
108
|
+
reader resolves the `ref` back to the terminal real snapshot; `blob: null` (i.e.
|
|
109
|
+
a recorded creation) still means "the file was created". A `ref` is validated as
|
|
110
|
+
a single-level, `<digits>/<file>.json` relative reference (no traversal) so a
|
|
111
|
+
corrupt or hostile ref cannot escape the store root when followed; because refs
|
|
112
|
+
name the actual file, a link may point at a released-v1 entry. Because links
|
|
113
|
+
reference prior entries, `prune` materializes a surviving link whose `ref` lands
|
|
114
|
+
on a group it is about to drop before deleting that group, so no kept link is
|
|
115
|
+
left dangling; the materialized entry keeps the bytes and the link's own
|
|
116
|
+
location pin, but not the referent's `mode` (a link records no permissions of
|
|
117
|
+
its own).
|
|
76
118
|
|
|
77
119
|
## Restore journal
|
|
78
120
|
|
|
@@ -80,8 +122,10 @@ One JSON file per restore operation, written **before any mutation** and
|
|
|
80
122
|
updated as the pass applies:
|
|
81
123
|
|
|
82
124
|
```ts
|
|
125
|
+
type ByteRef = { blob: string } | { text: string } // session-relative byte file, or inline text
|
|
126
|
+
|
|
83
127
|
interface RestoreJournal {
|
|
84
|
-
version:
|
|
128
|
+
version: 2
|
|
85
129
|
id: string // `op-<base36 ms>-<random>`; file name suffix
|
|
86
130
|
sessionId: string
|
|
87
131
|
targetSeq: number // rewind target the restore belongs to
|
|
@@ -94,20 +138,32 @@ interface RestoreJournal {
|
|
|
94
138
|
|
|
95
139
|
interface RestoreJournalAction {
|
|
96
140
|
path: string
|
|
97
|
-
action: 'restore' | 'delete' // restore = write
|
|
98
|
-
before:
|
|
99
|
-
rescue:
|
|
141
|
+
action: 'restore' | 'delete' // restore = write the content back; delete = unlink
|
|
142
|
+
before: ByteRef | null // target content for restore; null for delete
|
|
143
|
+
rescue: ByteRef | null // pre-restore disk state; null = file was absent
|
|
144
|
+
mode?: number // recorded target permissions
|
|
145
|
+
rescueMode?: number // recorded pre-restore permissions
|
|
146
|
+
parent?: string // checkpoint-time location pin (see the entry)
|
|
100
147
|
rescueError?: string // set when the rescue capture failed (rollback skips it)
|
|
101
148
|
done: boolean // true once the action's fs op completed and was marked
|
|
102
149
|
failed?: string // per-action failure message (the pass never aborts)
|
|
103
150
|
}
|
|
104
151
|
```
|
|
105
152
|
|
|
153
|
+
A `{blob}` reference is safe and session-relative: `<anchorSeq>/<base>.before`
|
|
154
|
+
(an entry sidecar) or `rescue/<opId>/<n>.before` (a rescue copy), validated on
|
|
155
|
+
both write and read so a corrupt or hostile journal can never point a restore or
|
|
156
|
+
a rollback outside the store. `{text}` refs carry content that has no sidecar:
|
|
157
|
+
released-v1 entry content, or a legacy journal's inline strings.
|
|
158
|
+
|
|
106
159
|
States: `running` and `rollback-running` are non-terminal; `completed` /
|
|
107
|
-
`rolled-back` are terminal.
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
160
|
+
`rolled-back` are terminal. Reconciliation *reports* a still non-terminal op as
|
|
161
|
+
`interrupted` (or `recovery-required` when the journal is corrupt or a rollback
|
|
162
|
+
could not complete), while the journal itself stays `running` /
|
|
163
|
+
`rollback-running`. A journal read back from a legacy file (`restore-journal-`
|
|
164
|
+
prefix, `version: 1`, inline strings) is rewritten IN PLACE as `version: 2` once
|
|
165
|
+
a redo or rollback pass completes, so the same op never leaves two divergent
|
|
166
|
+
versions on disk.
|
|
111
167
|
|
|
112
168
|
## Write guarantees
|
|
113
169
|
|
|
@@ -116,9 +172,29 @@ complete), while the journal itself stays `running` / `rollback-running`.
|
|
|
116
172
|
file — never a readable half-written target — and readers ignore temp files
|
|
117
173
|
(they do not end in `.json`). The next write of the same target overwrites
|
|
118
174
|
a leftover temp.
|
|
175
|
+
- **Bytes before metadata**: a checkpoint commit places the sidecar first
|
|
176
|
+
(a staged capture is `rename`d out of `.pending/`; other sources are written
|
|
177
|
+
temp-then-rename) and only then publishes the entry JSON, so a crash can
|
|
178
|
+
leave an unreferenced sidecar but never an entry whose bytes are missing.
|
|
179
|
+
A sidecar that is missing or shorter than `size` is a per-file failure, never
|
|
180
|
+
a silent "the file was created".
|
|
181
|
+
- **Pinned location**: every record this build writes — entry, link and journal
|
|
182
|
+
action — carries where the tracked file's directory resolved at commit time
|
|
183
|
+
(the `parent` pin, best-effort), and a restore re-checks it before touching the
|
|
184
|
+
path — the initial pass, a post-restart `continueRestore` and a
|
|
185
|
+
`rollbackRestore` alike (the journal carries the pin). A directory that
|
|
186
|
+
resolves elsewhere is refused and reported, so a restore can never write or
|
|
187
|
+
unlink outside the recorded location; a record with no pin (released-v1 data,
|
|
188
|
+
or a commit whose parent could not be resolved) falls back to the
|
|
189
|
+
final-component link check alone. A parent chain that is GONE is still
|
|
190
|
+
recreated — the plugin restores files whose directory was deleted — but only
|
|
191
|
+
while its nearest surviving ancestor resolves inside the pin. A stable
|
|
192
|
+
symlinked ancestor is never refused: both sides of the comparison are
|
|
193
|
+
`realpath`s.
|
|
119
194
|
- **Journal before mutation**: the rescue state of every planned path is
|
|
120
|
-
captured and the intent journal
|
|
121
|
-
mutation; each action is marked
|
|
195
|
+
captured as a raw byte copy and the intent journal — references only —
|
|
196
|
+
persisted atomically BEFORE the first fs mutation; each action is marked
|
|
197
|
+
`done` as it is applied.
|
|
122
198
|
- **Disk is truth**: after a restart, reconciliation compares the real disk
|
|
123
199
|
against each action's goal (the restore target for `running` journals, the
|
|
124
200
|
rescue state for `rollback-running` / `recovery-required` ones). A path
|
|
@@ -126,32 +202,62 @@ complete), while the journal itself stays `running` / `rollback-running`.
|
|
|
126
202
|
- **Bounded storage**: `prune` keeps the newest 100 anchor groups per session
|
|
127
203
|
(`MAX_ANCHOR_GROUPS`), materializing any surviving dedup link that references
|
|
128
204
|
a group being dropped before deleting whole anchor directories; it also
|
|
129
|
-
recycles terminal journals (`completed` / `rolled-back`)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
205
|
+
recycles terminal journals (`completed` / `rolled-back`) together with their
|
|
206
|
+
`rescue/<opId>/` bytes, and collects `.pending/` captures older than 24 h that
|
|
207
|
+
no commit consumed. A group a non-terminal journal still references is pinned
|
|
208
|
+
— evicting it would make "continue finishes the interrupted op" impossible —
|
|
209
|
+
so the effective window may exceed `keep` until that op is resolved.
|
|
210
|
+
Non-terminal and corrupt journals are always kept. Across sessions,
|
|
211
|
+
`pruneStale` removes whole long-inactive session directories whose newest
|
|
212
|
+
member stamp is older than a configurable idle cutoff (default off), so the
|
|
213
|
+
store root does not grow without bound either.
|
|
134
214
|
|
|
135
215
|
## Validation and failure policy
|
|
136
216
|
|
|
137
|
-
- **Entries**: a missing or
|
|
138
|
-
skipped) — losing one backup, never the recovery path.
|
|
217
|
+
- **Entries**: a missing, malformed, or self-contradictory entry is read as
|
|
218
|
+
`undefined` (silently skipped) — losing one backup, never the recovery path.
|
|
219
|
+
Contradictions are never guessed at: a `blob: null` with `size !== 0` or with
|
|
220
|
+
`lossy`, a `blob` that is not this entry's sidecar name, and a non-string
|
|
221
|
+
`before` in a v1 record are all corruption, and guessing an entry's kind is
|
|
222
|
+
how a restore turns into a delete. An absent or malformed `parent` is not
|
|
223
|
+
corruption: it means "no pin", so the entry falls back to the released
|
|
224
|
+
final-component rule. Two cases are *not* silent skips: an entry
|
|
225
|
+
whose `store` is newer than this build fails the whole operation closed (see
|
|
226
|
+
Versioning), and a record whose sidecar is missing or too short is a per-file
|
|
227
|
+
failure that the restore reports — never a delete of the live file.
|
|
139
228
|
- **Journals**: a corrupt or schema-invalid journal **fails loud** —
|
|
140
229
|
`reconcileRestores` reports it as `recovery-required` and never drops it,
|
|
141
230
|
because dropping it would silently erase the interrupted restore's recovery
|
|
142
|
-
record.
|
|
231
|
+
record. A `ref` with a traversal, absolute, or unknown-root segment counts as
|
|
232
|
+
corrupt.
|
|
143
233
|
- **Journal IO**: best-effort by design — if a journal cannot be written, the
|
|
144
234
|
restore proceeds with pre-journal semantics (crash safety degrades,
|
|
145
235
|
behavior does not).
|
|
146
236
|
|
|
147
237
|
## Versioning policy
|
|
148
238
|
|
|
149
|
-
The
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
239
|
+
The format is versioned twice: a session-level `store` marker (the value is the
|
|
240
|
+
current `CURRENT_STORE_VERSION`, 2) and a self-describing field on every record
|
|
241
|
+
(`store` on entries, `version` on journals). A missing `store` marker (or `1`)
|
|
242
|
+
means the released v1 string format, which is still read but never migrated. A
|
|
243
|
+
`store` **above** the current version fails the whole operation closed — no file
|
|
244
|
+
restore and no new entry written into a store a newer build owns (an explicit
|
|
245
|
+
clear or the age-based sweep is not version-gated) — while the conversation
|
|
246
|
+
rewind itself keeps working (it does not depend on snapshots). A journal whose
|
|
247
|
+
`version` is present but neither 1 nor 2 is corrupt and is reported
|
|
248
|
+
`recovery-required` (see Validation).
|
|
249
|
+
|
|
250
|
+
Because compatibility is not safe in both directions, the byte format reuses
|
|
251
|
+
none of v1's path/state keys (`path`, `anchorSeq`, `before`) and does not carry
|
|
252
|
+
`anchorSeq` (it equals the parent directory). A released v1 build therefore
|
|
253
|
+
rejects each such entry instead of reading it as "the file was created", so a
|
|
254
|
+
downgrade cannot delete workspace files; that field-name contract is pinned by
|
|
255
|
+
`tests/downgrade-safety.test.ts`. Compatibility means reading old data, not
|
|
256
|
+
repairing it: bytes the v1 build had already lost cannot be recovered.
|
|
257
|
+
|
|
258
|
+
A future incompatible format should raise the marker/version (readers already
|
|
259
|
+
fail closed above their own version) or move the state root and ship an explicit
|
|
260
|
+
migration tool. Old-format data is never silently re-interpreted.
|
|
155
261
|
|
|
156
262
|
## Cleanup policy persistence
|
|
157
263
|
|
|
@@ -50,19 +50,17 @@ The auto-cleanup switch and the idle-day cutoff live in the **dsh-settings confi
|
|
|
50
50
|
|
|
51
51
|
## When automatic cleanup runs
|
|
52
52
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
what you're doing.
|
|
59
|
-
|
|
60
|
-
If you want a change to take effect right away, use `run`; automatic cleanup
|
|
61
|
-
picks up a fresh setting on the next run.
|
|
53
|
+
When it's enabled, automatic cleanup runs in the background at most **once a
|
|
54
|
+
day** — and only after 24 hours have passed since the last cleanup. The clock is
|
|
55
|
+
saved to `<dsh home>/snapshot-cleanup-last-sweep.json`, so restarting the host
|
|
56
|
+
can't make it run early. To clean up right now instead of waiting, use
|
|
57
|
+
`/snapshot-auto-cleanup run`.
|
|
62
58
|
|
|
63
59
|
## Safety
|
|
64
60
|
|
|
65
|
-
- Only rewind **snapshots**
|
|
61
|
+
- Only rewind **snapshots** are ever removed: a session's snapshot directory
|
|
62
|
+
holds the before-write file backups plus their bookkeeping (staged captures,
|
|
63
|
+
rescue copies, restore journals), and that is all the cleanup touches. Your
|
|
66
64
|
conversation is never touched, and the plugin never rewrites or deletes your
|
|
67
65
|
session history.
|
|
68
66
|
- Automatic cleanup never removes your **active** session's snapshots — only
|
|
@@ -38,13 +38,11 @@
|
|
|
38
38
|
|
|
39
39
|
## 自动清理何时运行
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
想让改动立即生效,请用 `run`;自动清理要到下次运行才会采用新设置。
|
|
41
|
+
开启后,自动清理在后台运行,**一天最多一次**——且距上次清理已满 24 小时才会执行。这个时钟保存在 `<dsh home>/snapshot-cleanup-last-sweep.json`,所以重启宿主也不会让它提前运行。想立刻清理而不等待,用 `/snapshot-auto-cleanup run`。
|
|
44
42
|
|
|
45
43
|
## 安全
|
|
46
44
|
|
|
47
|
-
-
|
|
45
|
+
- 只会移除回退用的**快照**:一个会话的快照目录里只有写前文件备份及其附属记录(暂存副本、救援副本、还原日志),清理也只动这些。你的对话绝不会被动到,插件也绝不改写或删除你的会话历史。
|
|
48
46
|
- 自动清理**永不**移除你**当前活跃**会话的快照——只清理已失活超过阈值的会话。
|
|
49
47
|
- `run --current` 会清空当前会话的快照。这对该会话的文件回退历史是**不可逆**的:你无法回退到清空之前的代码状态,但你的对话保持完整,会话会从"现在"开始重新记录快照。
|
|
50
48
|
|