@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/src/settings.ts
CHANGED
|
@@ -3,13 +3,25 @@
|
|
|
3
3
|
* @module @anionex/dsh-turn-rewind
|
|
4
4
|
*/
|
|
5
5
|
import type { Context } from '@deepseek-ai/cordis'
|
|
6
|
-
|
|
6
|
+
// Type-only: loads the host's `Context.settings` augmentation and its provider
|
|
7
|
+
// type without binding any runtime symbol (0.1.5 deleted `settingsNamespace`
|
|
8
|
+
// and the free `installSettingsSection`, so a value import would break the whole
|
|
9
|
+
// DSH web profile at load time).
|
|
10
|
+
import type { SettingsProvider } from '@deepseek-ai/dsh-settings'
|
|
7
11
|
import z from '@deepseek-ai/schemastery'
|
|
8
12
|
import { resolveConfig, type ChangeLedgerEngine } from './engine.js'
|
|
9
13
|
import type { ChangeLedgerConfig, ResolvedChangeLedgerConfig } from './types.js'
|
|
10
14
|
|
|
11
|
-
/**
|
|
12
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Namespace join key shared by the host section and the browser settings card.
|
|
17
|
+
*
|
|
18
|
+
* DSH 0.1.5 removed the `settingsNamespace` helper and made `SettingsNamespace`
|
|
19
|
+
* a compile-time-only brand, so a plain lowercase literal is the entire runtime
|
|
20
|
+
* value — the upstream convention (`const CHAT_SETTINGS_NAMESPACE = 'ui-chat'`).
|
|
21
|
+
* The literal satisfies 0.1.5's registration grammar `/^[a-z][a-z0-9-]*$/` and is
|
|
22
|
+
* the exact key the browser card binds through `ctx.settingsScope.bind`.
|
|
23
|
+
*/
|
|
24
|
+
export const TURN_REWIND_SETTINGS_NAMESPACE = 'turn-rewind'
|
|
13
25
|
|
|
14
26
|
/** Every runtime-tunable field of {@link ChangeLedgerConfig}; `storageDir` stays config-layer only. */
|
|
15
27
|
export interface TurnRewindSettings {
|
|
@@ -72,15 +84,46 @@ function tunableSettings(resolved: ResolvedChangeLedgerConfig): TurnRewindSettin
|
|
|
72
84
|
}
|
|
73
85
|
}
|
|
74
86
|
|
|
87
|
+
/** Owner scope handed back by {@link SettingsProviderLike.register}. */
|
|
88
|
+
interface SettingsScopeLike<T> {
|
|
89
|
+
get(): T
|
|
90
|
+
watch(callback: (next: T, prev: T) => void | Promise<void>): () => void
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Source sink and change notification a settings consumer hands to the provider. */
|
|
94
|
+
interface SettingsSectionHooksLike<T> {
|
|
95
|
+
setSource(current: () => T): void
|
|
96
|
+
onChange(): void
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Version-portable view of the parts of `ctx.settings` this plugin drives.
|
|
101
|
+
*
|
|
102
|
+
* The plugin spans DSH releases whose `@deepseek-ai/dsh-settings` type surface
|
|
103
|
+
* differs — 0.1.5 branded the namespace parameter and moved the consumer wiring
|
|
104
|
+
* onto the provider — so the wiring below is written against this structural view
|
|
105
|
+
* (namespace as a plain string) and intersected with the host's own
|
|
106
|
+
* {@link SettingsProvider} type at the single boundary where it is used.
|
|
107
|
+
*/
|
|
108
|
+
interface SettingsProviderLike {
|
|
109
|
+
register<T>(ns: string, schema: z<T>, options?: { readonly base?: Partial<T> }): SettingsScopeLike<T>
|
|
110
|
+
installSection?<T>(owner: Context, ns: string, schema: z<T>, entry: T, hooks: SettingsSectionHooksLike<T>): void
|
|
111
|
+
}
|
|
112
|
+
|
|
75
113
|
/**
|
|
76
114
|
* Register the `turn-rewind` settings namespace and apply its resolved value to the
|
|
77
115
|
* running engine. The composition entry (from `cordis.patch.yml`) is the base layer;
|
|
78
116
|
* the user layer persists through the DSH settings provider. `storageDir` is never
|
|
79
117
|
* carried by the namespace: the storage root must not move while the engine runs.
|
|
118
|
+
*
|
|
119
|
+
* `settings` is an optional service, so the whole wiring stays behind
|
|
120
|
+
* `ctx.inject(['settings'], …)`: a host without a provider keeps running on the
|
|
121
|
+
* composition entry alone.
|
|
80
122
|
*/
|
|
81
123
|
export function installTurnRewindSettings(ctx: Context, config: ChangeLedgerConfig, engine: ChangeLedgerEngine): void {
|
|
82
|
-
|
|
83
|
-
|
|
124
|
+
const entry = tunableSettings(resolveConfig(config))
|
|
125
|
+
let source: () => TurnRewindSettings = () => entry
|
|
126
|
+
const hooks: SettingsSectionHooksLike<TurnRewindSettings> = {
|
|
84
127
|
setSource: (current) => { source = current },
|
|
85
128
|
onChange: () => {
|
|
86
129
|
try {
|
|
@@ -89,5 +132,30 @@ export function installTurnRewindSettings(ctx: Context, config: ChangeLedgerConf
|
|
|
89
132
|
ctx.logger.warn(`[turn-rewind] could not apply settings update: ${error instanceof Error ? error.message : String(error)}`)
|
|
90
133
|
}
|
|
91
134
|
},
|
|
135
|
+
}
|
|
136
|
+
ctx.inject(['settings'], (scope: Context) => {
|
|
137
|
+
const provider = scope.settings as SettingsProvider & SettingsProviderLike
|
|
138
|
+
// 0.1.5 owns the canonical wiring on the provider itself; it also suppresses the
|
|
139
|
+
// detach fallback while the consumer is unloading, which the deleted free
|
|
140
|
+
// function did not do.
|
|
141
|
+
if (typeof provider.installSection === 'function') {
|
|
142
|
+
provider.installSection(ctx, TURN_REWIND_SETTINGS_NAMESPACE, TurnRewindSettingsSchema, entry, hooks)
|
|
143
|
+
return
|
|
144
|
+
}
|
|
145
|
+
// Older hosts shipped that wiring only as the free `installSettingsSection`,
|
|
146
|
+
// which 0.1.5 removed, so reproduce its body against `register` + `scope.get()`
|
|
147
|
+
// + `scope.watch()` — present in every supported version.
|
|
148
|
+
const settingsScope = provider.register(
|
|
149
|
+
TURN_REWIND_SETTINGS_NAMESPACE,
|
|
150
|
+
TurnRewindSettingsSchema,
|
|
151
|
+
{ base: entry },
|
|
152
|
+
)
|
|
153
|
+
hooks.setSource(() => settingsScope.get())
|
|
154
|
+
scope.effect(() => () => {
|
|
155
|
+
hooks.setSource(() => entry)
|
|
156
|
+
hooks.onChange()
|
|
157
|
+
})
|
|
158
|
+
hooks.onChange()
|
|
159
|
+
settingsScope.watch(() => { hooks.onChange() })
|
|
92
160
|
})
|
|
93
161
|
}
|
package/src/snapshot.ts
CHANGED
|
@@ -2,9 +2,9 @@ import { createHash } from 'node:crypto'
|
|
|
2
2
|
import { constants, type BigIntStats } from 'node:fs'
|
|
3
3
|
import { lstat, open, readlink } from 'node:fs/promises'
|
|
4
4
|
import { ChangeLedgerError } from './errors.js'
|
|
5
|
-
import { discoverRepository, sameRepositoryFence, type RepositorySnapshotSource } from './git.js'
|
|
6
5
|
import { isNodeError, resolveWorkspacePath } from './path-utils.js'
|
|
7
6
|
import type { LedgerStore } from './store.js'
|
|
7
|
+
import { discoverWorkspace, sameWorkspaceFence, type WorkspaceSnapshotSource } from './workspace.js'
|
|
8
8
|
import type {
|
|
9
9
|
ResolvedChangeLedgerConfig,
|
|
10
10
|
SnapshotEntry,
|
|
@@ -15,7 +15,7 @@ const COMPARISON_READ_BUDGET_BYTES = 128 * 1024 * 1024
|
|
|
15
15
|
|
|
16
16
|
/** One captured tree, optionally persisted into the blob store. */
|
|
17
17
|
export interface CapturedTree {
|
|
18
|
-
readonly source:
|
|
18
|
+
readonly source: WorkspaceSnapshotSource
|
|
19
19
|
readonly entries: Readonly<Record<string, SnapshotEntry>>
|
|
20
20
|
readonly gitEntries?: Readonly<Record<string, SnapshotEntry>>
|
|
21
21
|
readonly treeHash: string
|
|
@@ -32,7 +32,7 @@ export async function captureTree(options: {
|
|
|
32
32
|
readonly signal?: AbortSignal
|
|
33
33
|
}): Promise<CapturedTree> {
|
|
34
34
|
throwIfAborted(options.signal)
|
|
35
|
-
const source = await
|
|
35
|
+
const source = await discoverWorkspace(options.cwd, options.config, options.signal)
|
|
36
36
|
if (source.paths.length > options.config.maxFiles) {
|
|
37
37
|
throw new ChangeLedgerError(
|
|
38
38
|
'TOO_MANY_FILES',
|
|
@@ -41,7 +41,9 @@ export async function captureTree(options: {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
const entries: Record<string, SnapshotEntry> = Object.create(null) as Record<string, SnapshotEntry>
|
|
44
|
-
|
|
44
|
+
// Git-object capture only applies to a Git worktree; an ordinary directory
|
|
45
|
+
// always stores its own content-addressed blobs.
|
|
46
|
+
const gitObjectFormat = source.state.type === 'git' ? options.gitObjectFormat : undefined
|
|
45
47
|
const gitCapture = gitObjectFormat === undefined
|
|
46
48
|
? undefined
|
|
47
49
|
: {
|
|
@@ -135,8 +137,8 @@ export async function captureStableTree(options: {
|
|
|
135
137
|
})
|
|
136
138
|
const second = await captureTree(options)
|
|
137
139
|
if (first.treeHash === second.treeHash
|
|
138
|
-
&&
|
|
139
|
-
&&
|
|
140
|
+
&& sameWorkspaceFence(first.source.state, second.source.state)
|
|
141
|
+
&& sameStagedPaths(first.source, second.source)
|
|
140
142
|
&& arraysEqual(first.source.paths, second.source.paths)) {
|
|
141
143
|
return second
|
|
142
144
|
}
|
|
@@ -144,6 +146,12 @@ export async function captureStableTree(options: {
|
|
|
144
146
|
throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', 'workspace did not remain stable across repeated full-tree captures')
|
|
145
147
|
}
|
|
146
148
|
|
|
149
|
+
/** Whether two captures observed the same index state; ordinary directories have none. */
|
|
150
|
+
function sameStagedPaths(left: WorkspaceSnapshotSource, right: WorkspaceSnapshotSource): boolean {
|
|
151
|
+
if (left.state.type !== 'git' || right.state.type !== 'git') return true
|
|
152
|
+
return arraysEqual(left.state.stagedPaths, right.state.stagedPaths)
|
|
153
|
+
}
|
|
154
|
+
|
|
147
155
|
/** Compute stable path-level differences between two captured trees. */
|
|
148
156
|
export function diffTrees(
|
|
149
157
|
before: Readonly<Record<string, SnapshotEntry>>,
|
package/src/types.ts
CHANGED
|
@@ -34,7 +34,8 @@ export interface SymlinkSnapshotEntry {
|
|
|
34
34
|
export type SnapshotEntry = FileSnapshotEntry | SymlinkSnapshotEntry
|
|
35
35
|
|
|
36
36
|
/** Git facts that fence a restore point to the repository state it observed. */
|
|
37
|
-
export interface
|
|
37
|
+
export interface GitWorkspaceState {
|
|
38
|
+
readonly type: 'git'
|
|
38
39
|
readonly root: string
|
|
39
40
|
readonly commonDir: string
|
|
40
41
|
readonly head?: string
|
|
@@ -43,6 +44,18 @@ export interface RepositoryState {
|
|
|
43
44
|
readonly stagedPaths: readonly string[]
|
|
44
45
|
}
|
|
45
46
|
|
|
47
|
+
/** Identity of an ordinary directory workspace captured without Git. */
|
|
48
|
+
export interface DirectoryWorkspaceState {
|
|
49
|
+
readonly type: 'directory'
|
|
50
|
+
readonly root: string
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Workspace facts that fence a restore point to the state it observed. */
|
|
54
|
+
export type WorkspaceState = GitWorkspaceState | DirectoryWorkspaceState
|
|
55
|
+
|
|
56
|
+
/** @deprecated Use `WorkspaceState`; the durable manifest field keeps its original name. */
|
|
57
|
+
export type RepositoryState = WorkspaceState
|
|
58
|
+
|
|
46
59
|
/** Why a restore point exists. */
|
|
47
60
|
export type RestorePointKind = 'user' | 'rescue' | 'turn'
|
|
48
61
|
|
|
@@ -89,8 +102,10 @@ export interface GitCheckpointMetadata {
|
|
|
89
102
|
}
|
|
90
103
|
|
|
91
104
|
/** Automatic turn checkpoint whose content is stored in the repository object database. */
|
|
92
|
-
export interface RestorePointManifestV2 extends Omit<RestorePointManifestV1, 'version' | 'treeHash'> {
|
|
105
|
+
export interface RestorePointManifestV2 extends Omit<RestorePointManifestV1, 'version' | 'treeHash' | 'repository'> {
|
|
93
106
|
readonly version: typeof GIT_CHECKPOINT_FORMAT_VERSION
|
|
107
|
+
/** Git-native checkpoints only exist for a Git worktree. */
|
|
108
|
+
readonly repository: GitWorkspaceState
|
|
94
109
|
/** Deterministic sidecar identity; Git tree integrity is verified separately. */
|
|
95
110
|
readonly treeHash: string
|
|
96
111
|
readonly git: GitCheckpointMetadata
|
|
@@ -126,6 +141,7 @@ export interface RestorePointSummary {
|
|
|
126
141
|
readonly id: RestorePointId
|
|
127
142
|
readonly kind: RestorePointKind
|
|
128
143
|
readonly workspace: string
|
|
144
|
+
readonly workspaceType: WorkspaceState['type']
|
|
129
145
|
readonly sessionId?: string
|
|
130
146
|
readonly label?: string
|
|
131
147
|
readonly parentRestorePoint?: RestorePointId
|
package/src/validate.ts
CHANGED
|
@@ -102,7 +102,8 @@ export function parseManifest(value: unknown): RestorePointManifest {
|
|
|
102
102
|
}
|
|
103
103
|
if (version === LEDGER_FORMAT_VERSION) return { version, ...common }
|
|
104
104
|
if (restorePointKind !== 'turn') corrupt('Git-native v2 restore points are automatic turn checkpoints')
|
|
105
|
-
|
|
105
|
+
if (repository.type !== 'git') corrupt('Git-native v2 restore points must fence a Git workspace')
|
|
106
|
+
return { version, ...common, repository, git: parseGitCheckpoint(record.git, id) }
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
/** Parse an untrusted durable restore-operation journal. */
|
|
@@ -158,9 +159,27 @@ export function validateBlobHash(value: string): string {
|
|
|
158
159
|
return value
|
|
159
160
|
}
|
|
160
161
|
|
|
162
|
+
/**
|
|
163
|
+
* Parse the durable workspace fence of one manifest.
|
|
164
|
+
*
|
|
165
|
+
* A missing `type` is the only legacy interpretation: manifests written before
|
|
166
|
+
* ordinary-directory support are always Git workspaces and are upgraded in
|
|
167
|
+
* memory. Directory fences must not carry Git-only fields.
|
|
168
|
+
* @param value - persisted `repository` field.
|
|
169
|
+
* @returns the validated workspace state.
|
|
170
|
+
*/
|
|
161
171
|
function parseRepository(value: unknown): RepositoryState {
|
|
162
172
|
const record = objectRecord(value, 'repository state')
|
|
163
173
|
const root = absoluteString(record, 'root')
|
|
174
|
+
if (record.type === 'directory') {
|
|
175
|
+
for (const key of ['commonDir', 'head', 'branch', 'operation', 'stagedPaths']) {
|
|
176
|
+
if (record[key] !== undefined) corrupt(`directory workspace state must not contain ${key}`)
|
|
177
|
+
}
|
|
178
|
+
return { type: 'directory', root }
|
|
179
|
+
}
|
|
180
|
+
if (record.type !== undefined && record.type !== 'git') {
|
|
181
|
+
corrupt('workspace state type must be "git" or "directory"')
|
|
182
|
+
}
|
|
164
183
|
const commonDir = absoluteString(record, 'commonDir')
|
|
165
184
|
const head = optionalHash(record, 'head')
|
|
166
185
|
const branch = optionalString(record, 'branch')
|
|
@@ -169,6 +188,7 @@ function parseRepository(value: unknown): RepositoryState {
|
|
|
169
188
|
if (!Array.isArray(stagedValue)) corrupt('repository stagedPaths must be an array')
|
|
170
189
|
const stagedPaths = stagedValue.map((path) => validateRelativePath(requireString(path, 'staged path')))
|
|
171
190
|
return {
|
|
191
|
+
type: 'git',
|
|
172
192
|
root,
|
|
173
193
|
commonDir,
|
|
174
194
|
...(head === undefined ? {} : { head }),
|
package/src/workspace.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { discoverDirectory, discoverDirectoryRoot, type DirectorySnapshotSource } from './directory.js'
|
|
2
|
+
import {
|
|
3
|
+
discoverRepositoryOptional,
|
|
4
|
+
discoverRepositoryRootOptional,
|
|
5
|
+
sameRepositoryFence,
|
|
6
|
+
type RepositorySnapshotSource,
|
|
7
|
+
} from './git.js'
|
|
8
|
+
import type { ResolvedChangeLedgerConfig, WorkspaceState } from './types.js'
|
|
9
|
+
|
|
10
|
+
/** Eligible snapshot source for either supported workspace mode. */
|
|
11
|
+
export type WorkspaceSnapshotSource = RepositorySnapshotSource | DirectorySnapshotSource
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Discover the owning Git worktree, or treat `cwd` itself as an ordinary
|
|
15
|
+
* directory when it is not inside any repository.
|
|
16
|
+
* @param cwd - working directory the Session runs in.
|
|
17
|
+
* @param config - capture limits applied to ordinary-directory enumeration.
|
|
18
|
+
* @param signal - optional caller cancellation.
|
|
19
|
+
* @returns the snapshot source for the owning workspace.
|
|
20
|
+
*/
|
|
21
|
+
export async function discoverWorkspace(
|
|
22
|
+
cwd: string,
|
|
23
|
+
config: Pick<ResolvedChangeLedgerConfig, 'maxFiles' | 'maxFileBytes'>,
|
|
24
|
+
signal?: AbortSignal,
|
|
25
|
+
): Promise<WorkspaceSnapshotSource> {
|
|
26
|
+
const repository = await discoverRepositoryOptional(cwd, signal)
|
|
27
|
+
return repository ?? discoverDirectory(cwd, config, signal)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Workspace mode and root resolved without inventorying any files. */
|
|
31
|
+
export interface WorkspaceIdentity {
|
|
32
|
+
readonly type: WorkspaceState['type']
|
|
33
|
+
readonly root: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Resolve the workspace mode and root without inventorying files, for callers
|
|
38
|
+
* that only need to address the workspace (locks, skip markers, storage purge).
|
|
39
|
+
* @param cwd - working directory the Session runs in.
|
|
40
|
+
* @param signal - optional caller cancellation.
|
|
41
|
+
* @returns the workspace mode and canonical root.
|
|
42
|
+
*/
|
|
43
|
+
export async function discoverWorkspaceIdentity(cwd: string, signal?: AbortSignal): Promise<WorkspaceIdentity> {
|
|
44
|
+
const root = await discoverRepositoryRootOptional(cwd, signal)
|
|
45
|
+
return root === undefined
|
|
46
|
+
? { type: 'directory', root: await discoverDirectoryRoot(cwd) }
|
|
47
|
+
: { type: 'git', root }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Resolve the workspace root without inventorying its files. */
|
|
51
|
+
export async function discoverWorkspaceRoot(cwd: string, signal?: AbortSignal): Promise<string> {
|
|
52
|
+
return (await discoverWorkspaceIdentity(cwd, signal)).root
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Whether two fences still describe the same workspace.
|
|
57
|
+
*
|
|
58
|
+
* Git workspaces keep the full repository fence (HEAD, branch, operation);
|
|
59
|
+
* ordinary directories only own their canonical root, so a moved or recreated
|
|
60
|
+
* root is the only thing that can invalidate them.
|
|
61
|
+
*/
|
|
62
|
+
export function sameWorkspaceFence(left: WorkspaceState, right: WorkspaceState): boolean {
|
|
63
|
+
if (left.type !== right.type || left.root !== right.root) return false
|
|
64
|
+
if (left.type === 'directory' || right.type === 'directory') return true
|
|
65
|
+
return sameRepositoryFence(left, right)
|
|
66
|
+
}
|