@anionex/dsh-turn-rewind 0.2.3 → 0.3.1

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.
@@ -1,7 +1,7 @@
1
- import type { RepositoryState } from './types.js';
1
+ import type { GitWorkspaceState } from './types.js';
2
2
  /** Repository discovery result plus the eligible path inventory. */
3
3
  export interface RepositorySnapshotSource {
4
- readonly state: RepositoryState;
4
+ readonly state: GitWorkspaceState;
5
5
  readonly paths: readonly string[];
6
6
  }
7
7
  /** Durable identity and shared lock location for one concrete Git worktree. */
@@ -14,6 +14,17 @@ export interface GitWorktreeIdentity {
14
14
  }
15
15
  /** Discover the Git worktree owning `cwd` and enumerate tracked/non-ignored paths. */
16
16
  export declare function discoverRepository(cwd: string, signal?: AbortSignal): Promise<RepositorySnapshotSource>;
17
+ /**
18
+ * Discover the owning Git worktree, or return undefined when `cwd` is not
19
+ * inside any repository. Every other discovery failure still throws, so a
20
+ * broken repository never silently degrades into an ordinary directory.
21
+ * @param cwd - candidate working directory.
22
+ * @param signal - optional caller cancellation.
23
+ * @returns the snapshot source, or undefined outside every repository.
24
+ */
25
+ export declare function discoverRepositoryOptional(cwd: string, signal?: AbortSignal): Promise<RepositorySnapshotSource | undefined>;
26
+ /** Resolve the Git worktree root, or undefined when `cwd` is outside every repository. */
27
+ export declare function discoverRepositoryRootOptional(cwd: string, signal?: AbortSignal): Promise<string | undefined>;
17
28
  /** Resolve the canonical Git worktree root owning `cwd` without inventorying its files. */
18
29
  export declare function discoverRepositoryRoot(cwd: string, signal?: AbortSignal): Promise<string>;
19
30
  /** Create or read the per-worktree identity used by refs and cross-store locking. */
@@ -21,7 +32,7 @@ export declare function ensureGitWorktreeIdentity(cwd: string, signal?: AbortSig
21
32
  /** Resolve a moved linked worktree from its common directory and durable identity. */
22
33
  export declare function resolveGitWorktreeRoot(commonDir: string, worktreeId: string, fallback: string, signal?: AbortSignal): Promise<string>;
23
34
  /** Return true when two repository fences refer to the same checkout state. */
24
- export declare function sameRepositoryFence(left: RepositoryState, right: RepositoryState): boolean;
35
+ export declare function sameRepositoryFence(left: GitWorkspaceState, right: GitWorkspaceState): boolean;
25
36
  /** Run Git with stable non-interactive defaults and return UTF-8 output. */
26
37
  export declare function runGit(cwd: string, args: readonly string[], signal?: AbortSignal, options?: {
27
38
  readonly env?: NodeJS.ProcessEnv;
@@ -42,4 +53,4 @@ export declare function runGitBuffer(cwd: string, args: readonly string[], signa
42
53
  readonly maxBuffer?: number;
43
54
  }): Promise<Buffer>;
44
55
  /** Return the Git metadata directory for diagnostics. */
45
- export declare function gitMetadataParent(state: RepositoryState): string;
56
+ export declare function gitMetadataParent(state: GitWorkspaceState): string;
@@ -8,11 +8,22 @@ interface SessionEventLike {
8
8
  interface SessionHeaderLike {
9
9
  readonly cwd?: string;
10
10
  readonly parentSession?: string;
11
+ /**
12
+ * Fork-inherited prefix length on DSH releases before 0.1.5. 0.1.5 deleted the
13
+ * field — a stored header that still carries it is rejected outright — and moved
14
+ * the same quantity to {@link SessionLike.inheritedEventCount}.
15
+ */
11
16
  readonly seedLength?: number;
12
17
  }
13
18
  interface SessionLike {
14
19
  readonly id: string;
15
20
  readonly header: SessionHeaderLike;
21
+ /**
22
+ * Exact number of leading events inherited from the fork parent, as reported by
23
+ * DSH 0.1.5+ on the live session and on the stored session-log snapshot. `0` —
24
+ * never `undefined` — for a session with no inherited prefix.
25
+ */
26
+ readonly inheritedEventCount?: number;
16
27
  readonly events?: readonly SessionEventLike[];
17
28
  snapshotEvents?(fromSeq?: number, toSeqExclusive?: number): readonly SessionEventLike[];
18
29
  }
@@ -36,6 +47,7 @@ interface SessionQueryLike {
36
47
  readSession(id: string): Promise<{
37
48
  readonly session: SessionHeaderLike;
38
49
  readonly events: readonly SessionEventLike[];
50
+ readonly inheritedEventCount?: number;
39
51
  }>;
40
52
  }
41
53
  interface HttpRequestLike {
@@ -6,8 +6,16 @@ import type { Context } from '@deepseek-ai/cordis';
6
6
  import z from '@deepseek-ai/schemastery';
7
7
  import { type ChangeLedgerEngine } from './engine.js';
8
8
  import type { ChangeLedgerConfig } from './types.js';
9
- /** Namespace join key shared by the host section and the browser settings card. */
10
- export declare const TURN_REWIND_SETTINGS_NAMESPACE: Branded<"SettingsNamespace">;
9
+ /**
10
+ * Namespace join key shared by the host section and the browser settings card.
11
+ *
12
+ * DSH 0.1.5 removed the `settingsNamespace` helper and made `SettingsNamespace`
13
+ * a compile-time-only brand, so a plain lowercase literal is the entire runtime
14
+ * value — the upstream convention (`const CHAT_SETTINGS_NAMESPACE = 'ui-chat'`).
15
+ * The literal satisfies 0.1.5's registration grammar `/^[a-z][a-z0-9-]*$/` and is
16
+ * the exact key the browser card binds through `ctx.settingsScope.bind`.
17
+ */
18
+ export declare const TURN_REWIND_SETTINGS_NAMESPACE = "turn-rewind";
11
19
  /** Every runtime-tunable field of {@link ChangeLedgerConfig}; `storageDir` stays config-layer only. */
12
20
  export interface TurnRewindSettings {
13
21
  /** Maximum user and rescue restore points retained per workspace. */
@@ -40,5 +48,9 @@ export declare const TurnRewindSettingsSchema: z<TurnRewindSettings>;
40
48
  * running engine. The composition entry (from `cordis.patch.yml`) is the base layer;
41
49
  * the user layer persists through the DSH settings provider. `storageDir` is never
42
50
  * carried by the namespace: the storage root must not move while the engine runs.
51
+ *
52
+ * `settings` is an optional service, so the whole wiring stays behind
53
+ * `ctx.inject(['settings'], …)`: a host without a provider keeps running on the
54
+ * composition entry alone.
43
55
  */
44
56
  export declare function installTurnRewindSettings(ctx: Context, config: ChangeLedgerConfig, engine: ChangeLedgerEngine): void;
@@ -1,9 +1,9 @@
1
- import { type RepositorySnapshotSource } from './git.js';
2
1
  import type { LedgerStore } from './store.js';
2
+ import { type WorkspaceSnapshotSource } from './workspace.js';
3
3
  import type { ResolvedChangeLedgerConfig, SnapshotEntry, WorkspaceChange } from './types.js';
4
4
  /** One captured tree, optionally persisted into the blob store. */
5
5
  export interface CapturedTree {
6
- readonly source: RepositorySnapshotSource;
6
+ readonly source: WorkspaceSnapshotSource;
7
7
  readonly entries: Readonly<Record<string, SnapshotEntry>>;
8
8
  readonly gitEntries?: Readonly<Record<string, SnapshotEntry>>;
9
9
  readonly treeHash: string;
@@ -26,7 +26,8 @@ export interface SymlinkSnapshotEntry {
26
26
  /** One path captured by a restore point. */
27
27
  export type SnapshotEntry = FileSnapshotEntry | SymlinkSnapshotEntry;
28
28
  /** Git facts that fence a restore point to the repository state it observed. */
29
- export interface RepositoryState {
29
+ export interface GitWorkspaceState {
30
+ readonly type: 'git';
30
31
  readonly root: string;
31
32
  readonly commonDir: string;
32
33
  readonly head?: string;
@@ -34,6 +35,15 @@ export interface RepositoryState {
34
35
  readonly operation?: string;
35
36
  readonly stagedPaths: readonly string[];
36
37
  }
38
+ /** Identity of an ordinary directory workspace captured without Git. */
39
+ export interface DirectoryWorkspaceState {
40
+ readonly type: 'directory';
41
+ readonly root: string;
42
+ }
43
+ /** Workspace facts that fence a restore point to the state it observed. */
44
+ export type WorkspaceState = GitWorkspaceState | DirectoryWorkspaceState;
45
+ /** @deprecated Use `WorkspaceState`; the durable manifest field keeps its original name. */
46
+ export type RepositoryState = WorkspaceState;
37
47
  /** Why a restore point exists. */
38
48
  export type RestorePointKind = 'user' | 'rescue' | 'turn';
39
49
  /** Durable workspace restore point. */
@@ -77,8 +87,10 @@ export interface GitCheckpointMetadata {
77
87
  readonly newlyStoredBytes: number;
78
88
  }
79
89
  /** Automatic turn checkpoint whose content is stored in the repository object database. */
80
- export interface RestorePointManifestV2 extends Omit<RestorePointManifestV1, 'version' | 'treeHash'> {
90
+ export interface RestorePointManifestV2 extends Omit<RestorePointManifestV1, 'version' | 'treeHash' | 'repository'> {
81
91
  readonly version: typeof GIT_CHECKPOINT_FORMAT_VERSION;
92
+ /** Git-native checkpoints only exist for a Git worktree. */
93
+ readonly repository: GitWorkspaceState;
82
94
  /** Deterministic sidecar identity; Git tree integrity is verified separately. */
83
95
  readonly treeHash: string;
84
96
  readonly git: GitCheckpointMetadata;
@@ -110,6 +122,7 @@ export interface RestorePointSummary {
110
122
  readonly id: RestorePointId;
111
123
  readonly kind: RestorePointKind;
112
124
  readonly workspace: string;
125
+ readonly workspaceType: WorkspaceState['type'];
113
126
  readonly sessionId?: string;
114
127
  readonly label?: string;
115
128
  readonly parentRestorePoint?: RestorePointId;
@@ -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.3",
3
+ "version": "0.3.1",
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",
@@ -54,8 +54,10 @@
54
54
  "client": {
55
55
  "platform": "web",
56
56
  "inject": [
57
- "@deepseek-ai/dsh-client-runtime",
57
+ "@deepseek-ai/dsh-client-ui-renderer",
58
+ "@deepseek-ai/dsh-api-session-controller",
58
59
  "@deepseek-ai/dsh-client-ui-conversation",
60
+ "@deepseek-ai/dsh-client-ui-settings",
59
61
  "@deepseek-ai/dsh-client-ui-settings-plugins"
60
62
  ]
61
63
  },
@@ -68,7 +70,10 @@
68
70
  "0.1.2-alpha.1": "compatible",
69
71
  "0.1.2-alpha.3": "compatible",
70
72
  "0.1.2-alpha.4": "compatible",
71
- "0.1.2-alpha.5": "compatible"
73
+ "0.1.2-alpha.5": "compatible",
74
+ "0.1.5-alpha.1": "compatible",
75
+ "0.1.5-alpha.2": "compatible",
76
+ "0.1.5-rc.1": "compatible"
72
77
  },
73
78
  "profiles": [
74
79
  "web"
@@ -94,12 +99,12 @@
94
99
  "license": "BSD-3-Clause",
95
100
  "peerDependencies": {
96
101
  "@deepseek-ai/cordis": "^4.0.1",
97
- "@deepseek-ai/dsh-settings": ">=0.1.0-rc.8 <0.2.0",
102
+ "@deepseek-ai/dsh-settings": ">=0.1.0-rc.8 <0.2.0 || ^0.1.1-rc.1 || ^0.1.2-alpha.1 || ^0.1.5-rc.1",
98
103
  "@deepseek-ai/schemastery": "^3.18.1"
99
104
  },
100
105
  "devDependencies": {
101
106
  "@deepseek-ai/cordis": "^4.0.1",
102
- "@deepseek-ai/dsh-settings": ">=0.1.0-rc.8 <0.2.0",
107
+ "@deepseek-ai/dsh-settings": ">=0.1.0-rc.8 <0.2.0 || ^0.1.1-rc.1 || ^0.1.2-alpha.1 || ^0.1.5-rc.1",
103
108
  "@deepseek-ai/schemastery": "^3.18.1",
104
109
  "@types/node": "^24.0.0",
105
110
  "@types/react": "18.3.28",
@@ -109,5 +114,8 @@
109
114
  "engines": {
110
115
  "node": "^22.19.0 || >=24.0.0"
111
116
  },
112
- "packageManager": "pnpm@11.7.0"
117
+ "packageManager": "pnpm@11.7.0",
118
+ "dependencies": {
119
+ "ignore": "^7.0.9"
120
+ }
113
121
  }
@@ -269,8 +269,15 @@ export function selectRewindMessage(node: ConversationNodeLike): RewindMatch | n
269
269
  return { messageSeq: node.seq, promptText }
270
270
  }
271
271
 
272
- /** Browser plugin entry: bridge every direct user-message action row to the rewind UI. */
273
- export const inject = ['slots', 'sessions', 'conversation']
272
+ /**
273
+ * Browser plugin entry: bridge every direct user-message action row to the rewind UI.
274
+ *
275
+ * Every service read on `ctx` must be declared here: Cordis throws while reading an
276
+ * undeclared service off the context proxy, before optional chaining can apply.
277
+ * `settingsScope` is provided by `@deepseek-ai/dsh-client-ui-settings` and may be
278
+ * absent, which is what `ctx.settingsScope?.bind(…)` below relies on.
279
+ */
280
+ export const inject = ['slots', 'sessions', 'conversation', 'settingsScope']
274
281
  export function apply(ctx: ClientContextLike): void {
275
282
  ctx.effect(() => {
276
283
  if (document.querySelector(`style[data-plugin-css="${STYLE_ID}"]`) !== null) return () => {}
@@ -1064,6 +1071,41 @@ export function selectRewindMessageTarget(value: RewindNodeLike): { readonly mat
1064
1071
  }
1065
1072
  }
1066
1073
 
1074
+ /** CSS-module class every DSH message action row matches (`<hash>_actions`). */
1075
+ const ACTIONS_SELECTOR = '[class*="actions"]'
1076
+ /**
1077
+ * Wrapper that owned the hover chrome and the action row before DSH 0.1.5. The
1078
+ * attribute is gone in 0.1.5 — the message row itself is the hover root now — so it
1079
+ * is only a fallback for older clients.
1080
+ */
1081
+ const LEGACY_HOVER_ROOT_SELECTOR = '[data-time-hover-root="true"]'
1082
+
1083
+ /** Query one descendant, tolerating a row double that implements no `querySelector`. */
1084
+ function queryInside(root: Element, selector: string): Element | null {
1085
+ const query = (root as Partial<Element>).querySelector
1086
+ return typeof query === 'function' ? query.call(root, selector) : null
1087
+ }
1088
+
1089
+ /**
1090
+ * Resolve the container one rewind button is portalled into.
1091
+ *
1092
+ * DSH 0.1.5 renders the copy/branch icon row (`MessageIconActions`, class
1093
+ * `<hash>_actions`) as a direct child of the user-message row and dropped
1094
+ * `data-time-hover-root`; older clients nested that row under the removed wrapper.
1095
+ * Both shapes are tried, current first. There is no official slot for user-message
1096
+ * actions in 0.1.5 — only `conversation.chat.assistant-actions`, for finalized
1097
+ * assistant turns — so this bridge stays DOM-based.
1098
+ * @param row - the `[data-chat-flow-kind="user"]` row owning one message.
1099
+ * @returns the actions container, or null when the row exposes none.
1100
+ */
1101
+ function findActionsContainer(row: Element): Element | null {
1102
+ const direct = queryInside(row, ACTIONS_SELECTOR)
1103
+ if (direct !== null) return direct
1104
+ const legacy = queryInside(row, LEGACY_HOVER_ROOT_SELECTOR)
1105
+ if (legacy === null) return null
1106
+ return queryInside(legacy, ACTIONS_SELECTOR) ?? legacy.lastElementChild
1107
+ }
1108
+
1067
1109
  function collectPortalTargets(nodes: readonly RewindNodeLike[]): readonly RewindPortalTarget[] {
1068
1110
  const rows = new Map<string, HTMLElement>()
1069
1111
  for (const element of Array.from(document.querySelectorAll<HTMLElement>(
@@ -1077,11 +1119,7 @@ function collectPortalTargets(nodes: readonly RewindNodeLike[]): readonly Rewind
1077
1119
  const target = selectRewindMessageTarget(value)
1078
1120
  if (target === null) continue
1079
1121
  const row = rows.get(target.rowKey)
1080
- const messageRoot = row?.querySelector?.<HTMLElement>('[data-time-hover-root="true"]')
1081
- // Match the actions container by class or last child element
1082
- const actions = (typeof messageRoot?.querySelector === 'function'
1083
- ? messageRoot.querySelector<HTMLElement>('[class*="actions"]')
1084
- : undefined) ?? messageRoot?.lastElementChild
1122
+ const actions = row === undefined ? null : findActionsContainer(row)
1085
1123
  if (!(actions instanceof HTMLElement)) continue
1086
1124
  targets.push({ container: actions, matched: target.matched })
1087
1125
  }
@@ -1214,9 +1252,12 @@ function friendlyError(error: unknown): string {
1214
1252
  case 'REWIND_ENDPOINT_UNAVAILABLE': return '回退服务没有响应。请确认 @anionex/dsh-turn-rewind 已挂载到当前 DSH,并重启 DSH 后重试。'
1215
1253
  case 'REWIND_INVALID_RESPONSE': return '回退服务返回了无法解析的内容。请重启 DSH 后重试。'
1216
1254
  case 'PLAN_STALE': return '项目文件在检查后又发生了变化。为避免覆盖新修改,请重新检查后再恢复。'
1255
+ case 'PLAN_STALE_WORKSPACE': return '项目文件在检查后又发生了变化。为避免覆盖新修改,请重新检查后再恢复。'
1217
1256
  case 'PLAN_STALE_REPOSITORY': return 'Git 状态在检查后又发生了变化,恢复已失效。请重新检查后再试。'
1218
1257
  case 'WORKSPACE_IN_USE': return '这个项目目录还有别的对话正在运行。请等那些对话结束或停止后,再重新检查。'
1219
1258
  case 'WORKSPACE_LOCKED': return '另一个恢复操作正在处理这个项目目录。请等待它完成后重新检查。'
1259
+ case 'WORKSPACE_CHANGED': return '这个项目目录已经不在原来的位置,旧的回退点无法再使用。'
1260
+ case 'WORKSPACE_MODE_CHANGED': return '这个项目目录的工作区类型变了(例如从普通目录变成了 Git 仓库)。旧的回退点不再适用,请重新发送一条消息生成新的回退点。'
1220
1261
  case 'HEAD_CHANGED': return '项目的提交或分支已发生变化。为避免覆盖新改动,请重新检查后再恢复。'
1221
1262
  case 'REPOSITORY_CHANGED': return '这个项目目录已不属于原来的 Git 工作区,无法恢复。'
1222
1263
  case 'GIT_OPERATION_CHANGED': return 'Git 正在执行其他操作。请先完成或取消该操作,再重新检查。'
@@ -1248,6 +1289,10 @@ export function explainCheckpointFailure(message: string): string {
1248
1289
  case 'SNAPSHOT_TOO_LARGE':
1249
1290
  case 'TOO_MANY_FILES':
1250
1291
  return '本轮有文件超过检查点的大小或数量上限,没有保存文件检查点。仍可只回溯消息。'
1292
+ case 'UNSUPPORTED_FILE_TYPE':
1293
+ return '项目目录里有无法保存的特殊文件(如 socket、设备文件、FIFO),没有保存文件检查点。仍可只回溯消息。'
1294
+ case 'IGNORE_FILE_INVALID':
1295
+ return '项目目录里的 .dsh-rewindignore 内容无效,没有保存文件检查点。修正该文件后可重新发送消息。'
1251
1296
  case 'INVALID_PATH':
1252
1297
  return '项目目录里有无法安全保存的路径(例如嵌套的独立 Git 仓库),没有保存文件检查点。仍可只回溯消息。'
1253
1298
  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
+ }