dsh-rewind-plugin 0.1.9 → 0.2.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/README.md +46 -46
- package/lib/client.js +99 -41
- package/lib/index.js +200 -157
- package/lib/types/client/index.d.ts +5 -0
- package/lib/types/client/locales.d.ts +2 -0
- package/lib/types/client/styles.d.ts +2 -1
- package/lib/types/index.d.ts +39 -16
- package/lib/types/session-cwd.d.ts +1 -1
- package/lib/types/snapshot.d.ts +102 -0
- package/package.json +1 -1
- package/scripts/verify-host.mjs +115 -80
- package/lib/types/ledger.d.ts +0 -88
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checkpoint store — the Claude Code style file-rewind backing for dsh-rewind.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code's checkpointing (see README) works like this: it creates a
|
|
5
|
+
* BACKUP of a file BEFORE every tracked modification, groups those backups by
|
|
6
|
+
* the user message they belong to (a "checkpoint"), and rewinding to a
|
|
7
|
+
* checkpoint restores every backup recorded at or after it — modified files
|
|
8
|
+
* are written back to their pre-edit content, files created after the target
|
|
9
|
+
* are deleted. This module is the same design, persisted on disk:
|
|
10
|
+
*
|
|
11
|
+
* - `tools/execute` captures the BEFORE state of each tracked write/edit call
|
|
12
|
+
* (or "created" when the file did not exist) — the capture happens at the
|
|
13
|
+
* around-dispatch stage, so an approval `ask` short-circuit cannot skip it
|
|
14
|
+
* and a denied call never records.
|
|
15
|
+
* - The entry is committed to disk at `tools/post-execute` under the turn's
|
|
16
|
+
* anchor seq: `<root>/<sessionId>/<anchorSeq>/<callId>.json`, carrying the
|
|
17
|
+
* path and the before content (`before: null` = the file was created).
|
|
18
|
+
* - Because entries live on disk under the dsh data directory, they survive a
|
|
19
|
+
* host restart, are bounded (the newest 100 anchor groups per session are
|
|
20
|
+
* kept), and restores read/write the real file system with plain `node:fs`
|
|
21
|
+
* — independent of the fs service.
|
|
22
|
+
*
|
|
23
|
+
* Restore semantics (identical to Claude Code): for every path with entries
|
|
24
|
+
* anchored at or after the target message, apply the EARLIEST entry — write
|
|
25
|
+
* the before content back, or delete the file when that entry recorded a
|
|
26
|
+
* creation. Symbolic links are skipped and reported, never written through.
|
|
27
|
+
*
|
|
28
|
+
* @module dsh-rewind/snapshot
|
|
29
|
+
*/
|
|
30
|
+
/** Default store root: the dsh data directory. */
|
|
31
|
+
export declare const DEFAULT_SNAPSHOT_ROOT: string;
|
|
32
|
+
/** Environment variable overriding the store root (tests, exotic homes). */
|
|
33
|
+
export declare const SNAPSHOT_ROOT_ENV = "DSH_REWIND_SNAPSHOT_DIR";
|
|
34
|
+
/** Number of newest anchor groups (user messages) kept per session. */
|
|
35
|
+
export declare const MAX_ANCHOR_GROUPS = 100;
|
|
36
|
+
/** One committed before-backup, keyed by tool call. */
|
|
37
|
+
export interface CheckpointEntry {
|
|
38
|
+
readonly callId: string;
|
|
39
|
+
/** Seq of the user message anchoring the turn in which the change happened. */
|
|
40
|
+
readonly anchorSeq: number;
|
|
41
|
+
/** Resolved display path (absolute) of the tracked file. */
|
|
42
|
+
readonly path: string;
|
|
43
|
+
/** Full content before the change; null when the file was created. */
|
|
44
|
+
readonly before: string | null;
|
|
45
|
+
/** Epoch ms the entry was committed (stable ordering within a group). */
|
|
46
|
+
readonly time: number;
|
|
47
|
+
}
|
|
48
|
+
/** Per-file restore impact preview (`/rewind preview @seq both`). */
|
|
49
|
+
export interface FileImpact {
|
|
50
|
+
readonly path: string;
|
|
51
|
+
/** `restore` = write the before content back; `delete` = remove the file. */
|
|
52
|
+
readonly action: 'restore' | 'delete';
|
|
53
|
+
}
|
|
54
|
+
/** Outcome of one restore pass. */
|
|
55
|
+
export interface RestoreOutcome {
|
|
56
|
+
readonly restored: readonly string[];
|
|
57
|
+
readonly deleted: readonly string[];
|
|
58
|
+
readonly skipped: readonly string[];
|
|
59
|
+
readonly failed: readonly {
|
|
60
|
+
path: string;
|
|
61
|
+
message: string;
|
|
62
|
+
}[];
|
|
63
|
+
}
|
|
64
|
+
/** Deletes one file by its real path (node:fs, bypassing the fs service). */
|
|
65
|
+
export type DeleteFile = (path: string) => Promise<void>;
|
|
66
|
+
/**
|
|
67
|
+
* On-disk checkpoint store. Every write goes straight through `node:fs`, so a
|
|
68
|
+
* restore reliably lands on the real file system.
|
|
69
|
+
*/
|
|
70
|
+
export declare class SnapshotStore {
|
|
71
|
+
readonly root: string;
|
|
72
|
+
constructor(root?: string);
|
|
73
|
+
/** Absolute path of one anchor group directory. */
|
|
74
|
+
anchorDir(sessionId: string, anchorSeq: number): string;
|
|
75
|
+
/** Commit one before-backup under its turn's anchor group. */
|
|
76
|
+
recordEntry(sessionId: string, entry: Omit<CheckpointEntry, 'time'>): Promise<void>;
|
|
77
|
+
/**
|
|
78
|
+
* All committed entries anchored at or after `targetSeq`, newest first (for
|
|
79
|
+
* preview ordering). The boundary is inclusive: rewinding to a message also
|
|
80
|
+
* reverts the changes its own turn caused (the rewind cut removes that
|
|
81
|
+
* turn's assistant response and tool calls), so only entries anchored at
|
|
82
|
+
* earlier messages survive.
|
|
83
|
+
*/
|
|
84
|
+
entriesAfter(sessionId: string, targetSeq: number): Promise<CheckpointEntry[]>;
|
|
85
|
+
/** Per-file restore impact for the earliest entry at/after the target. */
|
|
86
|
+
impactsAfter(sessionId: string, targetSeq: number): Promise<FileImpact[]>;
|
|
87
|
+
/**
|
|
88
|
+
* Restore the workspace to the target message's checkpoint: for every path
|
|
89
|
+
* with entries anchored at or after it, apply the EARLIEST entry — write the
|
|
90
|
+
* before content back, or delete the file when it was created after the
|
|
91
|
+
* target. Symbolic links are skipped (reported, never written through).
|
|
92
|
+
* Failures are per-file and never abort the pass.
|
|
93
|
+
*/
|
|
94
|
+
restoreAfter(sessionId: string, targetSeq: number, deleteFile: DeleteFile): Promise<RestoreOutcome>;
|
|
95
|
+
/**
|
|
96
|
+
* Drop the session's oldest anchor groups beyond `keep` (default
|
|
97
|
+
* {@link MAX_ANCHOR_GROUPS}), deleting their whole directories.
|
|
98
|
+
*/
|
|
99
|
+
prune(sessionId: string, keep?: number): Promise<void>;
|
|
100
|
+
/** True when a path exists on disk (used by tests and diagnostics). */
|
|
101
|
+
exists(path: string): Promise<boolean>;
|
|
102
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-rewind-plugin",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "DeepSeek Harness plugin: in-place conversation rewind in the same session window (Claude Code /rewind semantics) with optional workspace file restore",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|
package/scripts/verify-host.mjs
CHANGED
|
@@ -2,49 +2,58 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Host-half verification: boots the built plugin (`lib/index.js`) on a real
|
|
4
4
|
* cordis context with a real dsh-session, then drives the `/rewind` command
|
|
5
|
-
* handler and the
|
|
5
|
+
* handler and the checkpoint pipeline end to end — no model, no UI. Files are
|
|
6
|
+
* real files under a temporary directory, so a restore is verified against
|
|
7
|
+
* actual on-disk content, and the checkpoint store root is overridden to that
|
|
8
|
+
* temporary directory.
|
|
6
9
|
*
|
|
7
10
|
* Run: `npm run build && node scripts/verify-host.mjs`
|
|
8
11
|
*
|
|
9
12
|
* What it proves:
|
|
10
13
|
* 1. the plugin registers a `rewind` command on the ctx;
|
|
11
|
-
* 2. `/rewind` (no args)
|
|
14
|
+
* 2. `/rewind` (no args) withdraws the most recent user message;
|
|
12
15
|
* 3. `/rewind @<seq> chat` cuts the surface in-place (log untouched);
|
|
13
|
-
* 4.
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* 4. a successful write through the tools pipeline commits a before-backup
|
|
17
|
+
* under the turn's anchor seq;
|
|
18
|
+
* 5. a denied call never commits (no phantom entry in the store);
|
|
19
|
+
* 6. relative file paths resolve against the session cwd (fs-tools rule);
|
|
20
|
+
* 7. `/rewind preview @<seq> both` reports the checkpoint impact;
|
|
21
|
+
* 8. `/rewind @<seq> both` restores the real file to its pre-edit content
|
|
22
|
+
* and deletes files created after the target;
|
|
23
|
+
* 9. a running agent is force-stopped before the rewind (not refused);
|
|
24
|
+
* 10. a cancel that never quiesces aborts the rewind (timeout path).
|
|
19
25
|
*/
|
|
20
26
|
import { Context } from '@deepseek-ai/cordis'
|
|
21
27
|
import { FileSystem, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
|
|
22
28
|
import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
23
29
|
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
|
30
|
+
import { mkdtemp, mkdir, rm, writeFile, readFile } from 'node:fs/promises'
|
|
31
|
+
import { tmpdir } from 'node:os'
|
|
24
32
|
import { join } from 'node:path'
|
|
25
33
|
import { apply as applyRewind } from '../lib/index.js'
|
|
26
34
|
|
|
27
35
|
const aborted = () => new AbortController().signal
|
|
28
36
|
|
|
29
|
-
|
|
37
|
+
const tmpRoot = await mkdtemp(join(tmpdir(), 'dsh-rewind-verify-'))
|
|
38
|
+
const wsDir = join(tmpRoot, 'ws')
|
|
39
|
+
const snapRoot = join(tmpRoot, 'snapshots')
|
|
40
|
+
await mkdir(wsDir, { recursive: true })
|
|
41
|
+
|
|
42
|
+
/** Real-filesystem fs double: resolve returns the real display path. */
|
|
30
43
|
class FakeFs extends FileSystem {
|
|
31
|
-
files = new Map()
|
|
32
44
|
async resolve(path, opts = {}) {
|
|
33
45
|
const displayPath = opts?.cwd !== undefined && !path.startsWith('/') ? join(opts.cwd, path) : path
|
|
34
46
|
return { targetKey: FsTargetKey(displayPath), displayPath }
|
|
35
47
|
}
|
|
36
48
|
processPath(target) { return target.displayPath }
|
|
37
|
-
async readText(target) {
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
return
|
|
49
|
+
async readText(target) { return readFile(target.displayPath, 'utf8') }
|
|
50
|
+
async writeText(target, content) { await writeFile(target.displayPath, content, 'utf8'); return { operation: 'update', version: FsVersion('v'), before: null, after: content } }
|
|
51
|
+
async stat(target) {
|
|
52
|
+
try { await readFile(target.displayPath); return { version: FsVersion('v'), type: 'file' } } catch { return undefined }
|
|
41
53
|
}
|
|
42
|
-
async writeText(target, content) { this.files.set(target.displayPath, content); return { operation: 'update', version: FsVersion('v'), before: null, after: content } }
|
|
43
|
-
async stat(target) { return this.files.has(target.displayPath) ? { version: FsVersion('v'), type: 'file' } : undefined }
|
|
44
54
|
}
|
|
45
55
|
|
|
46
56
|
const fs = new FakeFs(new Context())
|
|
47
|
-
fs.files.set('/workspace/a.txt', 'original content')
|
|
48
57
|
|
|
49
58
|
const user = text => createUserMessage({ content: [{ type: 'text', text }], source: { kind: 'user' } })
|
|
50
59
|
const assistant = text => createAssistantMessage({ content: [{ type: 'text', text }], source: { provider: 'test', model: 'test' } })
|
|
@@ -73,10 +82,20 @@ ctx.provide('commands', {
|
|
|
73
82
|
},
|
|
74
83
|
})
|
|
75
84
|
ctx.provide('fs', fs)
|
|
76
|
-
applyRewind(ctx)
|
|
85
|
+
applyRewind(ctx, { snapshotDir: snapRoot })
|
|
77
86
|
|
|
78
87
|
const call = (agentOf, rawInput) => registered.handler({ commandId: Symbol('cid'), agent: agentOf, rawInput, signal: aborted() })
|
|
79
88
|
|
|
89
|
+
/** Simulate one tracked tool call: before-capture, dispatch writes the file, post-execute commits. */
|
|
90
|
+
async function runWrite(agentOf, callId, filePath, content) {
|
|
91
|
+
const exec = { callId, name: 'write', arguments: { file_path: filePath, content }, agent: agentOf, signal: aborted() }
|
|
92
|
+
await ctx.waterfall('tools/execute', exec, async () => {
|
|
93
|
+
await fs.writeText({ targetKey: FsTargetKey(filePath), displayPath: filePath }, content)
|
|
94
|
+
return { isError: false, content: [] }
|
|
95
|
+
})
|
|
96
|
+
await ctx.waterfall('tools/post-execute', exec, { isError: false, content: [] }, async () => ({ kind: 'accept' }))
|
|
97
|
+
}
|
|
98
|
+
|
|
80
99
|
let failures = 0
|
|
81
100
|
const check = (name, ok, detail) => {
|
|
82
101
|
console.log(`${ok ? 'ok ' : 'FAIL'} ${name}${ok ? '' : ` — ${detail}`}`)
|
|
@@ -86,86 +105,102 @@ const check = (name, ok, detail) => {
|
|
|
86
105
|
// 1. command registered
|
|
87
106
|
check('command registered', typeof registered?.handler === 'function' && registered.name === 'rewind', JSON.stringify(registered))
|
|
88
107
|
|
|
89
|
-
// 2. bare /rewind
|
|
90
|
-
|
|
91
|
-
|
|
108
|
+
// 2. bare /rewind (manual, no parameters) withdraws the most recent user
|
|
109
|
+
// message (seq 2 "second question") and everything after it
|
|
110
|
+
const bareBefore = [...session.surface.nodes]
|
|
111
|
+
const bareResult = await call(agent, '')
|
|
112
|
+
const bareAfter = [...session.surface.nodes]
|
|
113
|
+
check('bare /rewind succeeds', bareResult.kind === 'success', bareResult.text)
|
|
114
|
+
check('bare /rewind withdraws the latest message', bareAfter.length === 3 && bareAfter[0] === 0 && bareAfter[1] === 1 && bareAfter[2] > 3, `before ${JSON.stringify(bareBefore)} -> after ${JSON.stringify(bareAfter)}`)
|
|
115
|
+
check('log stays append-only (5 events)', session.events.length === 5, `events=${session.events.length}`)
|
|
92
116
|
|
|
93
|
-
// 3. /rewind
|
|
94
|
-
|
|
95
|
-
const
|
|
96
|
-
const
|
|
117
|
+
// 3. /rewind @<seq> chat (the button's exact call form) cuts the surface on a
|
|
118
|
+
// fresh session
|
|
119
|
+
const paramSession = buildSession('verify-param')
|
|
120
|
+
const paramAgent = { id: paramSession.id, session: paramSession, status: 'idle' }
|
|
121
|
+
const before = [...paramSession.surface.nodes]
|
|
122
|
+
const chatResult = await call(paramAgent, '@2 chat')
|
|
123
|
+
const after = [...paramSession.surface.nodes]
|
|
97
124
|
check('rewind chat succeeds', chatResult.kind === 'success', chatResult.text)
|
|
98
125
|
check('surface cut to [0,1,marker] (target withdrawn)', after.length === 3 && after[0] === 0 && after[1] === 1 && after[2] > 3, `before ${JSON.stringify(before)} -> after ${JSON.stringify(after)}`)
|
|
99
|
-
check('log stays append-only (5 events)',
|
|
100
|
-
|
|
101
|
-
const writeExec = (callId, filePath, content) => ({
|
|
102
|
-
callId, name: 'write', arguments: { file_path: filePath, content }, agent, signal: aborted(),
|
|
103
|
-
})
|
|
126
|
+
check('log stays append-only (5 events)', paramSession.events.length === 5, `events=${paramSession.events.length}`)
|
|
104
127
|
|
|
105
|
-
// 4. a
|
|
106
|
-
//
|
|
128
|
+
// 4. a tracked write commits a before-backup; rewinding both restores the
|
|
129
|
+
// real file and deletes files created after the target
|
|
107
130
|
{
|
|
108
|
-
const
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
await
|
|
114
|
-
|
|
115
|
-
await
|
|
116
|
-
|
|
131
|
+
const aPath = join(wsDir, 'a.txt')
|
|
132
|
+
await writeFile(aPath, 'original content', 'utf8')
|
|
133
|
+
// The next user message anchors the turn that will edit a.txt (seq 5).
|
|
134
|
+
session.append('user/message', user('third question'), { surfaceOp: 'append' })
|
|
135
|
+
const anchorSeq = 5
|
|
136
|
+
await runWrite(agent, 'c1', aPath, 'rewritten') // before-capture: 'original content'
|
|
137
|
+
const createdPath = join(wsDir, 'created.txt')
|
|
138
|
+
await runWrite(agent, 'c2', createdPath, 'new') // file did not exist: before-capture = created
|
|
139
|
+
await writeFile(aPath, 'v3', 'utf8') // later edit lands after the backups
|
|
140
|
+
|
|
141
|
+
const preview = await call(agent, `preview @${anchorSeq} both`)
|
|
142
|
+
check('preview reports the file impact', preview.kind === 'success' && preview.text.includes(aPath) && preview.text.includes('还原'), preview.text)
|
|
143
|
+
|
|
144
|
+
const both = await call(agent, `@${anchorSeq} both`)
|
|
145
|
+
check('rewind both succeeds', both.kind === 'success' && both.text.includes('还原 1 个文件') && both.text.includes('删除 1 个文件'), both.text)
|
|
146
|
+
check('modified file restored to pre-edit content', await readFile(aPath, 'utf8') === 'original content', await readFile(aPath, 'utf8'))
|
|
147
|
+
let createdGone = false
|
|
148
|
+
try { await readFile(createdPath, 'utf8') } catch { createdGone = true }
|
|
149
|
+
check('created file deleted', createdGone, `exists=${!createdGone}`)
|
|
117
150
|
}
|
|
118
151
|
|
|
119
|
-
// 5. a denied call never
|
|
152
|
+
// 5. a denied call never commits (no phantom entry)
|
|
120
153
|
{
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
154
|
+
const deniedPath = join(wsDir, 'denied.txt')
|
|
155
|
+
await writeFile(deniedPath, 'x', 'utf8')
|
|
156
|
+
const exec = { callId: 'c3', name: 'write', arguments: { file_path: deniedPath, content: 'denied write' }, agent, signal: aborted() }
|
|
157
|
+
await ctx.waterfall('tools/pre-execute', exec, async () => ({ kind: 'deny', reason: 'no' }))
|
|
125
158
|
await ctx.waterfall('tools/post-execute', exec, { isError: true, error: { message: 'denied', info: { name: 'x', code: 'y' } }, content: [] }, async () => ({ kind: 'accept' }))
|
|
159
|
+
const preview = await call(agent, 'preview @5 both')
|
|
160
|
+
check('denied call is not in the impact list', !preview.text.includes(deniedPath), preview.text)
|
|
126
161
|
}
|
|
127
162
|
|
|
128
163
|
// 6. relative paths resolve against the session cwd (fs-tools rule)
|
|
129
164
|
{
|
|
130
|
-
const cwdSession = buildSession('verify-cwd',
|
|
165
|
+
const cwdSession = buildSession('verify-cwd', wsDir)
|
|
131
166
|
const cwdAgent = { id: cwdSession.id, session: cwdSession, status: 'idle' }
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
await
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
167
|
+
const relPath = join(wsDir, 'rel.txt')
|
|
168
|
+
await writeFile(relPath, 'relative original', 'utf8')
|
|
169
|
+
cwdSession.append('user/message', user('relative question'), { surfaceOp: 'append' })
|
|
170
|
+
await runWrite(cwdAgent, 'c4', 'rel.txt', 'relative new') // relative path
|
|
171
|
+
|
|
172
|
+
const preview = await call(cwdAgent, 'preview @4 both')
|
|
173
|
+
check('relative path resolved via session cwd', preview.kind === 'success' && preview.text.includes(relPath), preview.text)
|
|
174
|
+
const both = await call(cwdAgent, '@4 both')
|
|
175
|
+
check('both restores cwd-resolved file', both.kind === 'success' && await readFile(relPath, 'utf8') === 'relative original', both.text)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// 7. preview on a message with no recorded changes reports none
|
|
179
|
+
{
|
|
180
|
+
const cleanSession = buildSession('verify-norec')
|
|
181
|
+
const cleanAgent = { id: cleanSession.id, session: cleanSession, status: 'idle' }
|
|
182
|
+
const previewResult = await call(cleanAgent, 'preview @2 both')
|
|
183
|
+
check('preview with no entries reports no changes', previewResult.kind === 'success' && previewResult.text.includes('无需还原文件'), previewResult.text)
|
|
142
184
|
}
|
|
143
185
|
|
|
144
|
-
//
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
check('rewind
|
|
152
|
-
check('file content restored', fs.files.get('/workspace/a.txt') === 'original content', fs.files.get('/workspace/a.txt'))
|
|
153
|
-
|
|
154
|
-
// 9. a running agent is force-stopped before the rewind (not refused)
|
|
155
|
-
const runningSession = buildSession('verify-running')
|
|
156
|
-
const running = {
|
|
157
|
-
...{ id: runningSession.id, session: runningSession, status: 'idle' }, status: 'running',
|
|
158
|
-
cancel: () => { cancelled = true; running.status = 'idle' },
|
|
186
|
+
// 8. a running agent is force-stopped before the rewind (not refused)
|
|
187
|
+
{
|
|
188
|
+
const runningSession = buildSession('verify-running')
|
|
189
|
+
let cancelled = false
|
|
190
|
+
const running = { id: runningSession.id, session: runningSession, status: 'running', cancel: () => { cancelled = true; running.status = 'idle' } }
|
|
191
|
+
const runningResult = await call(running, '@2 chat')
|
|
192
|
+
check('running agent is cancelled first', cancelled === true, `cancelled=${cancelled}`)
|
|
193
|
+
check('rewind succeeds after stop', runningResult.kind === 'success', runningResult.text)
|
|
159
194
|
}
|
|
160
|
-
let cancelled = false
|
|
161
|
-
const runningResult = await registered.handler({ commandId: Symbol('cid'), agent: running, rawInput: '@2 chat', signal: aborted() })
|
|
162
|
-
check('running agent is cancelled first', cancelled === true, `cancelled=${cancelled}`)
|
|
163
|
-
check('rewind succeeds after stop', runningResult.kind === 'success', runningResult.text)
|
|
164
195
|
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
const
|
|
168
|
-
|
|
196
|
+
// 9. a cancel that never quiesces aborts the rewind (timeout path)
|
|
197
|
+
{
|
|
198
|
+
const stuckSession = buildSession('verify-stuck')
|
|
199
|
+
const stuck = { id: stuckSession.id, session: stuckSession, status: 'running', cancel: () => {} }
|
|
200
|
+
const stuckResult = await call(stuck, '@2 chat')
|
|
201
|
+
check('stuck agent aborts rewind', stuckResult.kind === 'error', stuckResult.text)
|
|
202
|
+
}
|
|
169
203
|
|
|
204
|
+
await rm(tmpRoot, { recursive: true, force: true })
|
|
170
205
|
console.log(failures === 0 ? '\nverify-host: all checks passed' : `\nverify-host: ${failures} check(s) FAILED`)
|
|
171
206
|
process.exit(failures === 0 ? 0 : 1)
|
package/lib/types/ledger.d.ts
DELETED
|
@@ -1,88 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* In-memory change ledger: records every write-class tool mutation that
|
|
3
|
-
* happened while the plugin was running, so a "rewind conversation and code"
|
|
4
|
-
* can reverse the changes that followed a target message.
|
|
5
|
-
*
|
|
6
|
-
* Scope (v0.1): the ledger covers only `write` / `edit` / `str_replace_editor`
|
|
7
|
-
* mutations observed through the tools pipeline while the plugin is loaded.
|
|
8
|
-
* Changes made by bash or external programs are not recorded and cannot be
|
|
9
|
-
* restored; a git-first snapshot layer is a v2 option.
|
|
10
|
-
*
|
|
11
|
-
* @module dsh-rewind/ledger
|
|
12
|
-
*/
|
|
13
|
-
import type { FileSystem } from '@deepseek-ai/dsh-fs';
|
|
14
|
-
/** One recorded write-class mutation. */
|
|
15
|
-
export interface LedgerEntry {
|
|
16
|
-
/** Tool that made the change: `write` | `edit` | `str_replace_editor`. */
|
|
17
|
-
readonly toolName: string;
|
|
18
|
-
/** Seq of the user message anchoring the turn in which the change happened. */
|
|
19
|
-
readonly anchorSeq: number;
|
|
20
|
-
/** Display path (model/UI-facing), as resolved at record time. */
|
|
21
|
-
readonly path: string;
|
|
22
|
-
/** Full file content before the change; undefined when the file was created. */
|
|
23
|
-
readonly before: string | undefined;
|
|
24
|
-
/** Full file content after the change. */
|
|
25
|
-
readonly after: string;
|
|
26
|
-
}
|
|
27
|
-
/** Unique per-file impact of rewinding past a target message. */
|
|
28
|
-
export interface FileImpact {
|
|
29
|
-
readonly path: string;
|
|
30
|
-
/** `restore` = the file existed before the target; `delete` = created after it. */
|
|
31
|
-
readonly action: 'restore' | 'delete';
|
|
32
|
-
}
|
|
33
|
-
/** Result of one reverse restore pass. */
|
|
34
|
-
export interface RestoreOutcome {
|
|
35
|
-
readonly restored: readonly string[];
|
|
36
|
-
readonly deleted: readonly string[];
|
|
37
|
-
readonly failed: readonly {
|
|
38
|
-
path: string;
|
|
39
|
-
message: string;
|
|
40
|
-
}[];
|
|
41
|
-
}
|
|
42
|
-
/** Deletes one file by its process path (the host supplies the backend-appropriate delete). */
|
|
43
|
-
export type DeleteFile = (processPath: string) => Promise<void>;
|
|
44
|
-
/**
|
|
45
|
-
* Per-session cap on recorded entries. The ledger is intentionally bounded so
|
|
46
|
-
* an extremely long session cannot grow one entry list without limit; the
|
|
47
|
-
* oldest entries are dropped first, so rewinds to very early messages in a
|
|
48
|
-
* pathological session may lose the earliest file history (a declared
|
|
49
|
-
* tradeoff, see README).
|
|
50
|
-
*/
|
|
51
|
-
export declare const MAX_LEDGER_ENTRIES = 2000;
|
|
52
|
-
/**
|
|
53
|
-
* Append-only change ledger. Entries are recorded in commit order; a rewind
|
|
54
|
-
* replays them in reverse for the affected range. Bounded per session to
|
|
55
|
-
* {@link MAX_LEDGER_ENTRIES} (oldest dropped first).
|
|
56
|
-
*/
|
|
57
|
-
export declare class RewindLedger {
|
|
58
|
-
private readonly entries;
|
|
59
|
-
/** Record one committed mutation, dropping the oldest entry when over the cap. */
|
|
60
|
-
record(entry: LedgerEntry): void;
|
|
61
|
-
/**
|
|
62
|
-
* All entries anchored at or after `targetSeq`, newest first. The boundary
|
|
63
|
-
* is inclusive: rewinding to a message also reverts the changes its own
|
|
64
|
-
* turn caused (the rewind cut removes that turn's assistant response and
|
|
65
|
-
* tool calls), so only changes anchored at earlier messages survive.
|
|
66
|
-
*/
|
|
67
|
-
changesAfter(targetSeq: number): readonly LedgerEntry[];
|
|
68
|
-
/**
|
|
69
|
-
* Unique per-file impact for preview. A file whose earliest affected change
|
|
70
|
-
* created it (`before === undefined`) is deleted on restore; any other file
|
|
71
|
-
* is written back to its pre-target content.
|
|
72
|
-
*/
|
|
73
|
-
impactsAfter(targetSeq: number): readonly FileImpact[];
|
|
74
|
-
/**
|
|
75
|
-
* Reverse every change anchored at or after `targetSeq`. Each entry writes
|
|
76
|
-
* its pre-change content back; a file that did not exist before the target
|
|
77
|
-
* is deleted instead. Failures are collected per file and never abort the pass.
|
|
78
|
-
* @param fs - the filesystem service (resolve/readText/writeText/processPath).
|
|
79
|
-
* @param deleteFile - backend-appropriate file deletion by process path.
|
|
80
|
-
* @param targetSeq - the rewind target; only later changes are reverted.
|
|
81
|
-
* @param options - session workspace cwd (relative ledger paths resolve
|
|
82
|
-
* against it, mirroring the fs tools) and an optional abort signal.
|
|
83
|
-
*/
|
|
84
|
-
restoreAfter(fs: FileSystem, deleteFile: DeleteFile, targetSeq: number, options?: {
|
|
85
|
-
cwd?: string;
|
|
86
|
-
signal?: AbortSignal;
|
|
87
|
-
}): Promise<RestoreOutcome>;
|
|
88
|
-
}
|