@anionex/dsh-turn-rewind 0.2.2 → 0.3.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.
@@ -0,0 +1,37 @@
1
+ import { type DirectorySnapshotSource } from './directory.js';
2
+ import { type RepositorySnapshotSource } from './git.js';
3
+ import type { ResolvedChangeLedgerConfig, WorkspaceState } from './types.js';
4
+ /** Eligible snapshot source for either supported workspace mode. */
5
+ export type WorkspaceSnapshotSource = RepositorySnapshotSource | DirectorySnapshotSource;
6
+ /**
7
+ * Discover the owning Git worktree, or treat `cwd` itself as an ordinary
8
+ * directory when it is not inside any repository.
9
+ * @param cwd - working directory the Session runs in.
10
+ * @param config - capture limits applied to ordinary-directory enumeration.
11
+ * @param signal - optional caller cancellation.
12
+ * @returns the snapshot source for the owning workspace.
13
+ */
14
+ export declare function discoverWorkspace(cwd: string, config: Pick<ResolvedChangeLedgerConfig, 'maxFiles' | 'maxFileBytes'>, signal?: AbortSignal): Promise<WorkspaceSnapshotSource>;
15
+ /** Workspace mode and root resolved without inventorying any files. */
16
+ export interface WorkspaceIdentity {
17
+ readonly type: WorkspaceState['type'];
18
+ readonly root: string;
19
+ }
20
+ /**
21
+ * Resolve the workspace mode and root without inventorying files, for callers
22
+ * that only need to address the workspace (locks, skip markers, storage purge).
23
+ * @param cwd - working directory the Session runs in.
24
+ * @param signal - optional caller cancellation.
25
+ * @returns the workspace mode and canonical root.
26
+ */
27
+ export declare function discoverWorkspaceIdentity(cwd: string, signal?: AbortSignal): Promise<WorkspaceIdentity>;
28
+ /** Resolve the workspace root without inventorying its files. */
29
+ export declare function discoverWorkspaceRoot(cwd: string, signal?: AbortSignal): Promise<string>;
30
+ /**
31
+ * Whether two fences still describe the same workspace.
32
+ *
33
+ * Git workspaces keep the full repository fence (HEAD, branch, operation);
34
+ * ordinary directories only own their canonical root, so a moved or recreated
35
+ * root is the only thing that can invalidate them.
36
+ */
37
+ export declare function sameWorkspaceFence(left: WorkspaceState, right: WorkspaceState): boolean;
package/lib/validate.js CHANGED
@@ -99,7 +99,9 @@ export function parseManifest(value) {
99
99
  return { version, ...common };
100
100
  if (restorePointKind !== 'turn')
101
101
  corrupt('Git-native v2 restore points are automatic turn checkpoints');
102
- return { version, ...common, git: parseGitCheckpoint(record.git, id) };
102
+ if (repository.type !== 'git')
103
+ corrupt('Git-native v2 restore points must fence a Git workspace');
104
+ return { version, ...common, repository, git: parseGitCheckpoint(record.git, id) };
103
105
  }
104
106
  /** Parse an untrusted durable restore-operation journal. */
105
107
  export function parseOperation(value) {
@@ -155,9 +157,28 @@ export function validateBlobHash(value) {
155
157
  }
156
158
  return value;
157
159
  }
160
+ /**
161
+ * Parse the durable workspace fence of one manifest.
162
+ *
163
+ * A missing `type` is the only legacy interpretation: manifests written before
164
+ * ordinary-directory support are always Git workspaces and are upgraded in
165
+ * memory. Directory fences must not carry Git-only fields.
166
+ * @param value - persisted `repository` field.
167
+ * @returns the validated workspace state.
168
+ */
158
169
  function parseRepository(value) {
159
170
  const record = objectRecord(value, 'repository state');
160
171
  const root = absoluteString(record, 'root');
172
+ if (record.type === 'directory') {
173
+ for (const key of ['commonDir', 'head', 'branch', 'operation', 'stagedPaths']) {
174
+ if (record[key] !== undefined)
175
+ corrupt(`directory workspace state must not contain ${key}`);
176
+ }
177
+ return { type: 'directory', root };
178
+ }
179
+ if (record.type !== undefined && record.type !== 'git') {
180
+ corrupt('workspace state type must be "git" or "directory"');
181
+ }
161
182
  const commonDir = absoluteString(record, 'commonDir');
162
183
  const head = optionalHash(record, 'head');
163
184
  const branch = optionalString(record, 'branch');
@@ -167,6 +188,7 @@ function parseRepository(value) {
167
188
  corrupt('repository stagedPaths must be an array');
168
189
  const stagedPaths = stagedValue.map((path) => validateRelativePath(requireString(path, 'staged path')));
169
190
  return {
191
+ type: 'git',
170
192
  root,
171
193
  commonDir,
172
194
  ...(head === undefined ? {} : { head }),
@@ -0,0 +1,45 @@
1
+ import { discoverDirectory, discoverDirectoryRoot } from './directory.js';
2
+ import { discoverRepositoryOptional, discoverRepositoryRootOptional, sameRepositoryFence, } from './git.js';
3
+ /**
4
+ * Discover the owning Git worktree, or treat `cwd` itself as an ordinary
5
+ * directory when it is not inside any repository.
6
+ * @param cwd - working directory the Session runs in.
7
+ * @param config - capture limits applied to ordinary-directory enumeration.
8
+ * @param signal - optional caller cancellation.
9
+ * @returns the snapshot source for the owning workspace.
10
+ */
11
+ export async function discoverWorkspace(cwd, config, signal) {
12
+ const repository = await discoverRepositoryOptional(cwd, signal);
13
+ return repository ?? discoverDirectory(cwd, config, signal);
14
+ }
15
+ /**
16
+ * Resolve the workspace mode and root without inventorying files, for callers
17
+ * that only need to address the workspace (locks, skip markers, storage purge).
18
+ * @param cwd - working directory the Session runs in.
19
+ * @param signal - optional caller cancellation.
20
+ * @returns the workspace mode and canonical root.
21
+ */
22
+ export async function discoverWorkspaceIdentity(cwd, signal) {
23
+ const root = await discoverRepositoryRootOptional(cwd, signal);
24
+ return root === undefined
25
+ ? { type: 'directory', root: await discoverDirectoryRoot(cwd) }
26
+ : { type: 'git', root };
27
+ }
28
+ /** Resolve the workspace root without inventorying its files. */
29
+ export async function discoverWorkspaceRoot(cwd, signal) {
30
+ return (await discoverWorkspaceIdentity(cwd, signal)).root;
31
+ }
32
+ /**
33
+ * Whether two fences still describe the same workspace.
34
+ *
35
+ * Git workspaces keep the full repository fence (HEAD, branch, operation);
36
+ * ordinary directories only own their canonical root, so a moved or recreated
37
+ * root is the only thing that can invalidate them.
38
+ */
39
+ export function sameWorkspaceFence(left, right) {
40
+ if (left.type !== right.type || left.root !== right.root)
41
+ return false;
42
+ if (left.type === 'directory' || right.type === 'directory')
43
+ return true;
44
+ return sameRepositoryFence(left, right);
45
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@anionex/dsh-turn-rewind",
3
- "version": "0.2.2",
3
+ "version": "0.3.0",
4
4
  "description": "Turn-level conversation and workspace rewind for DeepSeek Harness, powered by a persistent Change Ledger",
5
5
  "repository": {
6
6
  "type": "git",
@@ -109,5 +109,8 @@
109
109
  "engines": {
110
110
  "node": "^22.19.0 || >=24.0.0"
111
111
  },
112
- "packageManager": "pnpm@11.7.0"
112
+ "packageManager": "pnpm@11.7.0",
113
+ "dependencies": {
114
+ "ignore": "^7.0.9"
115
+ }
113
116
  }
@@ -1214,9 +1214,12 @@ function friendlyError(error: unknown): string {
1214
1214
  case 'REWIND_ENDPOINT_UNAVAILABLE': return '回退服务没有响应。请确认 @anionex/dsh-turn-rewind 已挂载到当前 DSH,并重启 DSH 后重试。'
1215
1215
  case 'REWIND_INVALID_RESPONSE': return '回退服务返回了无法解析的内容。请重启 DSH 后重试。'
1216
1216
  case 'PLAN_STALE': return '项目文件在检查后又发生了变化。为避免覆盖新修改,请重新检查后再恢复。'
1217
+ case 'PLAN_STALE_WORKSPACE': return '项目文件在检查后又发生了变化。为避免覆盖新修改,请重新检查后再恢复。'
1217
1218
  case 'PLAN_STALE_REPOSITORY': return 'Git 状态在检查后又发生了变化,恢复已失效。请重新检查后再试。'
1218
1219
  case 'WORKSPACE_IN_USE': return '这个项目目录还有别的对话正在运行。请等那些对话结束或停止后,再重新检查。'
1219
1220
  case 'WORKSPACE_LOCKED': return '另一个恢复操作正在处理这个项目目录。请等待它完成后重新检查。'
1221
+ case 'WORKSPACE_CHANGED': return '这个项目目录已经不在原来的位置,旧的回退点无法再使用。'
1222
+ case 'WORKSPACE_MODE_CHANGED': return '这个项目目录的工作区类型变了(例如从普通目录变成了 Git 仓库)。旧的回退点不再适用,请重新发送一条消息生成新的回退点。'
1220
1223
  case 'HEAD_CHANGED': return '项目的提交或分支已发生变化。为避免覆盖新改动,请重新检查后再恢复。'
1221
1224
  case 'REPOSITORY_CHANGED': return '这个项目目录已不属于原来的 Git 工作区,无法恢复。'
1222
1225
  case 'GIT_OPERATION_CHANGED': return 'Git 正在执行其他操作。请先完成或取消该操作,再重新检查。'
@@ -1248,6 +1251,10 @@ export function explainCheckpointFailure(message: string): string {
1248
1251
  case 'SNAPSHOT_TOO_LARGE':
1249
1252
  case 'TOO_MANY_FILES':
1250
1253
  return '本轮有文件超过检查点的大小或数量上限,没有保存文件检查点。仍可只回溯消息。'
1254
+ case 'UNSUPPORTED_FILE_TYPE':
1255
+ return '项目目录里有无法保存的特殊文件(如 socket、设备文件、FIFO),没有保存文件检查点。仍可只回溯消息。'
1256
+ case 'IGNORE_FILE_INVALID':
1257
+ return '项目目录里的 .dsh-rewindignore 内容无效,没有保存文件检查点。修正该文件后可重新发送消息。'
1251
1258
  case 'INVALID_PATH':
1252
1259
  return '项目目录里有无法安全保存的路径(例如嵌套的独立 Git 仓库),没有保存文件检查点。仍可只回溯消息。'
1253
1260
  default:
@@ -0,0 +1,194 @@
1
+ import type { BigIntStats } from 'node:fs'
2
+ import { lstat, readFile, readdir, realpath } from 'node:fs/promises'
3
+ import { join, resolve } from 'node:path'
4
+ import ignore, { type Ignore } from 'ignore'
5
+ import { ChangeLedgerError } from './errors.js'
6
+ import { canonicalDirectory, isNodeError, isWithin } from './path-utils.js'
7
+ import type { DirectoryWorkspaceState, ResolvedChangeLedgerConfig } from './types.js'
8
+
9
+ const IGNORE_FILE = '.dsh-rewindignore'
10
+ const DEFAULT_IGNORE_PATTERNS = ['.git', 'node_modules'] as const
11
+
12
+ /** Ordinary-directory discovery result plus its eligible path inventory. */
13
+ export interface DirectorySnapshotSource {
14
+ readonly state: DirectoryWorkspaceState
15
+ readonly paths: readonly string[]
16
+ }
17
+
18
+ /** Resolve the canonical ordinary directory used as a non-Git workspace root. */
19
+ export async function discoverDirectoryRoot(cwd: string): Promise<string> {
20
+ return canonicalDirectory(cwd)
21
+ }
22
+
23
+ /** Enumerate regular files and symlinks in an ordinary directory using rewind ignore rules. */
24
+ export async function discoverDirectory(
25
+ cwd: string,
26
+ config: Pick<ResolvedChangeLedgerConfig, 'maxFiles' | 'maxFileBytes'>,
27
+ signal?: AbortSignal,
28
+ ): Promise<DirectorySnapshotSource> {
29
+ const root = await discoverDirectoryRoot(cwd)
30
+ const matcher = await loadIgnoreMatcher(root, config.maxFileBytes, signal)
31
+ const paths: string[] = []
32
+ await walkDirectory(root, root, '', matcher, paths, config.maxFiles, signal)
33
+ paths.sort(comparePaths)
34
+ return { state: { type: 'directory', root }, paths }
35
+ }
36
+
37
+ /**
38
+ * Read `.dsh-rewindignore` when present and combine it with the built-in
39
+ * exclusions. The built-in patterns are added last so user negation rules can
40
+ * never re-include the plugin's own metadata or dependency trees.
41
+ */
42
+ async function loadIgnoreMatcher(root: string, maxFileBytes: number, signal?: AbortSignal): Promise<Ignore> {
43
+ const matcher = ignore()
44
+ const ignorePath = join(root, IGNORE_FILE)
45
+ throwIfAborted(signal)
46
+ let before: BigIntStats
47
+ try {
48
+ before = await lstat(ignorePath, { bigint: true })
49
+ } catch (error) {
50
+ if (isNodeError(error, 'ENOENT')) {
51
+ matcher.add(DEFAULT_IGNORE_PATTERNS)
52
+ return matcher
53
+ }
54
+ throw error
55
+ }
56
+ if (!before.isFile()) {
57
+ throw new ChangeLedgerError('IGNORE_FILE_INVALID', `${IGNORE_FILE} must be a regular file when present`)
58
+ }
59
+ if (before.size > BigInt(maxFileBytes)) {
60
+ throw new ChangeLedgerError(
61
+ 'FILE_TOO_LARGE',
62
+ `${JSON.stringify(IGNORE_FILE)} is ${before.size.toString()} bytes; configured per-file maximum is ${maxFileBytes}`,
63
+ )
64
+ }
65
+ const content = await readFile(ignorePath, 'utf8')
66
+ let after: BigIntStats
67
+ try {
68
+ after = await lstat(ignorePath, { bigint: true })
69
+ } catch (error) {
70
+ if (isNodeError(error, 'ENOENT')) {
71
+ throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', `${IGNORE_FILE} disappeared while its rules were being read`)
72
+ }
73
+ throw error
74
+ }
75
+ if (!sameStat(before, after)) {
76
+ throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', `${IGNORE_FILE} changed while its rules were being read`)
77
+ }
78
+ try {
79
+ matcher.add(content)
80
+ } catch (error) {
81
+ throw new ChangeLedgerError('IGNORE_FILE_INVALID', `${IGNORE_FILE} contains invalid ignore rules`, { cause: error })
82
+ }
83
+ matcher.add(DEFAULT_IGNORE_PATTERNS)
84
+ return matcher
85
+ }
86
+
87
+ /** Walk one ordinary directory without following symlinks, rejecting unstable trees. */
88
+ async function walkDirectory(
89
+ root: string,
90
+ directory: string,
91
+ directoryPath: string,
92
+ matcher: Ignore,
93
+ paths: string[],
94
+ maxFiles: number,
95
+ signal?: AbortSignal,
96
+ ): Promise<void> {
97
+ throwIfAborted(signal)
98
+ const before = await lstat(directory, { bigint: true })
99
+ await assertSafeDirectory(root, directory, before)
100
+ const entries = await readdir(directory, { withFileTypes: true })
101
+ const after = await lstat(directory, { bigint: true })
102
+ if (!sameStat(before, after)) {
103
+ throw new ChangeLedgerError(
104
+ 'WORKSPACE_CHANGED_DURING_CAPTURE',
105
+ `directory changed while being enumerated: ${JSON.stringify(directoryPath || '.')}`,
106
+ )
107
+ }
108
+
109
+ entries.sort((left, right) => comparePaths(left.name, right.name))
110
+ for (const entry of entries) {
111
+ throwIfAborted(signal)
112
+ await assertSafeDirectory(root, directory, after)
113
+ const path = directoryPath === '' ? entry.name : `${directoryPath}/${entry.name}`
114
+ const target = join(directory, entry.name)
115
+ let info: BigIntStats
116
+ try {
117
+ info = await lstat(target, { bigint: true })
118
+ } catch (error) {
119
+ if (isNodeError(error, 'ENOENT')) continue
120
+ throw error
121
+ }
122
+ if (info.isDirectory() && !info.isSymbolicLink()) {
123
+ if (matcher.ignores(`${path}/`)) continue
124
+ await walkDirectory(root, target, path, matcher, paths, maxFiles, signal)
125
+ continue
126
+ }
127
+ if (path !== IGNORE_FILE && matcher.ignores(path)) continue
128
+ if (!info.isFile() && !info.isSymbolicLink()) {
129
+ throw new ChangeLedgerError(
130
+ 'UNSUPPORTED_FILE_TYPE',
131
+ `ordinary-directory path is not a regular file or symlink: ${JSON.stringify(path)}`,
132
+ )
133
+ }
134
+ paths.push(path)
135
+ if (paths.length > maxFiles) {
136
+ throw new ChangeLedgerError(
137
+ 'TOO_MANY_FILES',
138
+ `workspace has more than ${maxFiles} eligible paths; latest path is ${JSON.stringify(path)}`,
139
+ )
140
+ }
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Re-prove that one traversed directory is still the same real directory inside
146
+ * the workspace root, so a swapped symlink or moved ancestor cannot redirect
147
+ * the inventory outside it.
148
+ */
149
+ async function assertSafeDirectory(root: string, directory: string, expected: BigIntStats): Promise<void> {
150
+ if (!expected.isDirectory() || expected.isSymbolicLink()) {
151
+ throw new ChangeLedgerError('SYMLINK_PARENT', `directory traversal reached a symbolic link: ${JSON.stringify(directory)}`)
152
+ }
153
+ let canonical: string
154
+ try {
155
+ canonical = await realpath(directory)
156
+ } catch (error) {
157
+ if (isNodeError(error, 'ENOENT') || isNodeError(error, 'ELOOP')) {
158
+ throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', `directory changed during traversal: ${JSON.stringify(directory)}`)
159
+ }
160
+ throw error
161
+ }
162
+ if (canonical !== resolve(directory) || !isWithin(root, canonical)) {
163
+ throw new ChangeLedgerError('UNSAFE_TARGET', `directory traversal escaped the workspace: ${JSON.stringify(directory)}`)
164
+ }
165
+ let current: BigIntStats
166
+ try {
167
+ current = await lstat(directory, { bigint: true })
168
+ } catch (error) {
169
+ if (isNodeError(error, 'ENOENT')) {
170
+ throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', `directory disappeared during traversal: ${JSON.stringify(directory)}`)
171
+ }
172
+ throw error
173
+ }
174
+ if (!sameStat(expected, current)) {
175
+ throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', `directory changed during traversal: ${JSON.stringify(directory)}`)
176
+ }
177
+ }
178
+
179
+ function sameStat(left: BigIntStats, right: BigIntStats): boolean {
180
+ return left.dev === right.dev
181
+ && left.ino === right.ino
182
+ && left.mode === right.mode
183
+ && left.size === right.size
184
+ && left.mtimeNs === right.mtimeNs
185
+ && left.ctimeNs === right.ctimeNs
186
+ }
187
+
188
+ function throwIfAborted(signal?: AbortSignal): void {
189
+ if (signal?.aborted === true) throw signal.reason
190
+ }
191
+
192
+ function comparePaths(left: string, right: string): number {
193
+ return Buffer.from(left).compare(Buffer.from(right))
194
+ }