@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/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
- import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
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
- /** Namespace join key shared by the host section and the browser settings card. */
12
- export const TURN_REWIND_SETTINGS_NAMESPACE = settingsNamespace('turn-rewind')
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
- let source: () => TurnRewindSettings = () => tunableSettings(resolveConfig(config))
83
- installSettingsSection(ctx, TURN_REWIND_SETTINGS_NAMESPACE, TurnRewindSettingsSchema, source(), {
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: RepositorySnapshotSource
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 discoverRepository(options.cwd, options.signal)
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
- const gitObjectFormat = options.gitObjectFormat
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
- && sameRepositoryFence(first.source.state, second.source.state)
139
- && arraysEqual(first.source.state.stagedPaths, second.source.state.stagedPaths)
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 RepositoryState {
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
- return { version, ...common, git: parseGitCheckpoint(record.git, id) }
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 }),
@@ -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
+ }