@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.
- package/README.md +13 -4
- package/README.zh.md +13 -4
- package/docs/FORMAT.md +5 -1
- package/lib/client.js +51 -7
- package/lib/client.js.map +1 -1
- package/lib/directory.js +162 -0
- package/lib/engine.js +94 -56
- package/lib/git.js +46 -0
- package/lib/rewind-host.js +36 -10
- package/lib/settings.js +38 -5
- package/lib/snapshot.js +13 -5
- package/lib/types/client/index.d.ts +8 -1
- package/lib/types/directory.d.ts +10 -0
- package/lib/types/engine.d.ts +6 -0
- package/lib/types/git.d.ts +15 -4
- package/lib/types/rewind-host.d.ts +12 -0
- package/lib/types/settings.d.ts +14 -2
- package/lib/types/snapshot.d.ts +2 -2
- package/lib/types/types.d.ts +15 -2
- package/lib/types/workspace.d.ts +37 -0
- package/lib/validate.js +23 -1
- package/lib/workspace.js +45 -0
- package/package.json +14 -6
- package/src/client/index.tsx +52 -7
- package/src/directory.ts +194 -0
- package/src/engine.ts +104 -68
- package/src/git.ts +49 -4
- package/src/rewind-host.ts +52 -11
- package/src/settings.ts +73 -5
- package/src/snapshot.ts +14 -6
- package/src/types.ts +18 -2
- package/src/validate.ts +21 -1
- package/src/workspace.ts +66 -0
package/lib/types/git.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import type {
|
|
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:
|
|
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:
|
|
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:
|
|
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 {
|
package/lib/types/settings.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
10
|
-
|
|
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;
|
package/lib/types/snapshot.d.ts
CHANGED
|
@@ -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:
|
|
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;
|
package/lib/types/types.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 }),
|
package/lib/workspace.js
ADDED
|
@@ -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.
|
|
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-
|
|
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
|
}
|
package/src/client/index.tsx
CHANGED
|
@@ -269,8 +269,15 @@ export function selectRewindMessage(node: ConversationNodeLike): RewindMatch | n
|
|
|
269
269
|
return { messageSeq: node.seq, promptText }
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
-
/**
|
|
273
|
-
|
|
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
|
|
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:
|
package/src/directory.ts
ADDED
|
@@ -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
|
+
}
|