@anionex/dsh-turn-rewind 0.1.2 → 0.2.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 +19 -6
- package/README.zh.md +19 -6
- package/SECURITY.md +3 -1
- package/docs/assets/turn-rewind-dialog.png +0 -0
- package/lib/client.js +295 -25
- package/lib/client.js.map +1 -1
- package/lib/engine.js +140 -3
- package/lib/index.js +17 -1
- package/lib/rewind-host.js +104 -9
- package/lib/settings.js +58 -0
- package/lib/store.js +90 -23
- package/lib/types/client/index.d.ts +74 -7
- package/lib/types/engine.d.ts +21 -2
- package/lib/types/index.d.ts +7 -0
- package/lib/types/rewind-host.d.ts +16 -2
- package/lib/types/settings.d.ts +44 -0
- package/lib/types/store.d.ts +20 -0
- package/lib/types/types.d.ts +19 -0
- package/package.json +19 -3
- package/src/client/index.tsx +540 -45
- package/src/engine.ts +154 -3
- package/src/index.ts +20 -1
- package/src/rewind-host.ts +122 -9
- package/src/settings.ts +93 -0
- package/src/store.ts +109 -23
- package/src/types.ts +21 -0
package/src/engine.ts
CHANGED
|
@@ -49,6 +49,8 @@ import {
|
|
|
49
49
|
type RestoreResult,
|
|
50
50
|
type SnapshotEntry,
|
|
51
51
|
type WorkspaceChange,
|
|
52
|
+
type WorkspaceOverview,
|
|
53
|
+
type WorkspacePurgeReport,
|
|
52
54
|
} from './types.js'
|
|
53
55
|
|
|
54
56
|
const DEFAULTS = {
|
|
@@ -67,7 +69,7 @@ const DEFAULTS = {
|
|
|
67
69
|
|
|
68
70
|
/** Persistent workspace change-set engine, independent of the DSH tool adapter. */
|
|
69
71
|
export class ChangeLedgerEngine {
|
|
70
|
-
readonly
|
|
72
|
+
private readonly currentConfig: ResolvedChangeLedgerConfig
|
|
71
73
|
readonly store: LedgerStore
|
|
72
74
|
private readonly plans = new Map<string, RestorePlan>()
|
|
73
75
|
private readonly activePlans = new Set<string>()
|
|
@@ -75,11 +77,28 @@ export class ChangeLedgerEngine {
|
|
|
75
77
|
|
|
76
78
|
/** Build an engine and start crash-journal reconciliation. */
|
|
77
79
|
constructor(config: ChangeLedgerConfig = {}) {
|
|
78
|
-
this.
|
|
79
|
-
this.store = new LedgerStore(this.
|
|
80
|
+
this.currentConfig = resolveConfig(config)
|
|
81
|
+
this.store = new LedgerStore(this.currentConfig)
|
|
80
82
|
this.ready = this.initializeStore()
|
|
81
83
|
}
|
|
82
84
|
|
|
85
|
+
/** Currently resolved configuration; runtime-tunable fields update in place. */
|
|
86
|
+
get config(): ResolvedChangeLedgerConfig {
|
|
87
|
+
return this.currentConfig
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Swap runtime-tunable configuration (limits, modes, timeouts, trust) in place.
|
|
92
|
+
* The storage root must never move once the engine owns locks and journals.
|
|
93
|
+
*/
|
|
94
|
+
updateConfig(config: ChangeLedgerConfig): void {
|
|
95
|
+
const resolved = resolveConfig(config)
|
|
96
|
+
if (resolved.storageDir !== this.currentConfig.storageDir) {
|
|
97
|
+
throw new ChangeLedgerError('INVALID_CONFIG', 'storageDir cannot change while the engine is running')
|
|
98
|
+
}
|
|
99
|
+
Object.assign(this.currentConfig as WritableResolvedConfig, resolved)
|
|
100
|
+
}
|
|
101
|
+
|
|
83
102
|
/** Wait for startup reconciliation and return the number of interrupted journals found. */
|
|
84
103
|
async initialize(): Promise<number> {
|
|
85
104
|
return this.ready
|
|
@@ -743,6 +762,135 @@ export class ChangeLedgerEngine {
|
|
|
743
762
|
}
|
|
744
763
|
}
|
|
745
764
|
|
|
765
|
+
/** Inventory every workspace this storage root has ever persisted state for. */
|
|
766
|
+
async listWorkspaces(options: { readonly signal?: AbortSignal } = {}): Promise<WorkspaceOverview[]> {
|
|
767
|
+
await this.ready
|
|
768
|
+
const groups = new Map<string, { restorePoints: RestorePointSummary[]; recoveryCount: number }>()
|
|
769
|
+
const groupOf = (workspace: string): { restorePoints: RestorePointSummary[]; recoveryCount: number } => {
|
|
770
|
+
const existing = groups.get(workspace)
|
|
771
|
+
if (existing !== undefined) return existing
|
|
772
|
+
const created = { restorePoints: [], recoveryCount: 0 }
|
|
773
|
+
groups.set(workspace, created)
|
|
774
|
+
return created
|
|
775
|
+
}
|
|
776
|
+
for (const workspaceDir of await this.store.listWorkspaceDirs(options.signal)) {
|
|
777
|
+
for (const manifest of await this.store.listManifestsInDir(workspaceDir, options.signal)) {
|
|
778
|
+
groupOf(manifest.workspace).restorePoints.push(summarize(manifest))
|
|
779
|
+
}
|
|
780
|
+
for (const operation of await this.store.listOperationsInDir(workspaceDir, options.signal)) {
|
|
781
|
+
if (operation.state === 'interrupted' || operation.state === 'recovery-required') {
|
|
782
|
+
groupOf(operation.workspace).recoveryCount += 1
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
return [...groups.entries()]
|
|
787
|
+
.map(([workspace, group]) => ({
|
|
788
|
+
workspace,
|
|
789
|
+
restorePoints: [...group.restorePoints].sort(
|
|
790
|
+
(left, right) => right.createdAt - left.createdAt || right.id.localeCompare(left.id),
|
|
791
|
+
),
|
|
792
|
+
totalBytes: group.restorePoints.reduce((total, point) => total + point.totalBytes, 0),
|
|
793
|
+
recoveryCount: group.recoveryCount,
|
|
794
|
+
}))
|
|
795
|
+
.sort((left, right) => left.workspace.localeCompare(right.workspace))
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/** Delete unprotected restore points recorded for one workspace and collect unused blobs. */
|
|
799
|
+
async purgeWorkspace(options: {
|
|
800
|
+
readonly workspace: string
|
|
801
|
+
/** Restrict the purge to these restore points; protection rules still apply. */
|
|
802
|
+
readonly restorePointIds?: readonly string[]
|
|
803
|
+
readonly signal?: AbortSignal
|
|
804
|
+
}): Promise<WorkspacePurgeReport> {
|
|
805
|
+
await this.ready
|
|
806
|
+
const restorePointIds = options.restorePointIds === undefined ? undefined : new Set(options.restorePointIds)
|
|
807
|
+
const targetDirs = new Set<string>()
|
|
808
|
+
for (const workspaceDir of await this.store.listWorkspaceDirs(options.signal)) {
|
|
809
|
+
for (const manifest of await this.store.listManifestsInDir(workspaceDir, options.signal)) {
|
|
810
|
+
if (manifest.workspace === options.workspace
|
|
811
|
+
&& (restorePointIds === undefined || restorePointIds.has(manifest.id))) {
|
|
812
|
+
targetDirs.add(workspaceDir)
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
for (const operation of await this.store.listOperationsInDir(workspaceDir, options.signal)) {
|
|
816
|
+
if (operation.workspace === options.workspace) targetDirs.add(workspaceDir)
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
let deletedRestorePoints = 0
|
|
820
|
+
let retainedRestorePoints = 0
|
|
821
|
+
let deletedBlobs = 0
|
|
822
|
+
let retainedBlobs = 0
|
|
823
|
+
for (const workspaceDir of targetDirs) {
|
|
824
|
+
const result = await this.purgeWorkspaceDir(workspaceDir, options.workspace, restorePointIds, options.signal)
|
|
825
|
+
deletedRestorePoints += result.deletedRestorePoints
|
|
826
|
+
retainedRestorePoints += result.retainedRestorePoints
|
|
827
|
+
deletedBlobs += result.deletedBlobs
|
|
828
|
+
retainedBlobs += result.retainedBlobs
|
|
829
|
+
}
|
|
830
|
+
return { workspace: options.workspace, deletedRestorePoints, retainedRestorePoints, deletedBlobs, retainedBlobs }
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
private async purgeWorkspaceDir(
|
|
834
|
+
workspaceDir: string,
|
|
835
|
+
workspace: string,
|
|
836
|
+
restorePointIds: ReadonlySet<string> | undefined,
|
|
837
|
+
signal?: AbortSignal,
|
|
838
|
+
): Promise<{ deletedRestorePoints: number; retainedRestorePoints: number; deletedBlobs: number; retainedBlobs: number }> {
|
|
839
|
+
// A live repository needs both its shared Git lock and durable directory lock.
|
|
840
|
+
// A missing path, or one now nested under a different repository, cannot expose
|
|
841
|
+
// its old Git namespace but still takes the durable lock before storage cleanup.
|
|
842
|
+
let liveRepository = false
|
|
843
|
+
let release: () => Promise<void>
|
|
844
|
+
try {
|
|
845
|
+
const root = await discoverRepositoryRoot(workspace, signal)
|
|
846
|
+
if (root === workspace) {
|
|
847
|
+
await this.assertStorageSeparated(root)
|
|
848
|
+
release = await this.acquireWorkspace(root, signal)
|
|
849
|
+
liveRepository = true
|
|
850
|
+
} else {
|
|
851
|
+
release = await this.store.acquireWorkspaceDir(workspaceDir, workspace, signal)
|
|
852
|
+
}
|
|
853
|
+
} catch (error) {
|
|
854
|
+
if (!(error instanceof ChangeLedgerError) || error.code !== 'WORKSPACE_NOT_FOUND') throw error
|
|
855
|
+
release = await this.store.acquireWorkspaceDir(workspaceDir, workspace, signal)
|
|
856
|
+
}
|
|
857
|
+
let deletedRestorePoints = 0
|
|
858
|
+
let retainedRestorePoints = 0
|
|
859
|
+
try {
|
|
860
|
+
const manifests = (await this.store.listManifestsInDir(workspaceDir, signal))
|
|
861
|
+
.filter((manifest) => manifest.workspace === workspace
|
|
862
|
+
&& (restorePointIds === undefined || restorePointIds.has(manifest.id)))
|
|
863
|
+
const protectedIds = new Set<string>()
|
|
864
|
+
for (const operation of await this.store.listOperationsInDir(workspaceDir, signal)) {
|
|
865
|
+
if (operation.state === 'running' || operation.state === 'rollback-running'
|
|
866
|
+
|| operation.state === 'interrupted' || operation.state === 'recovery-required') {
|
|
867
|
+
protectedIds.add(operation.restorePointId)
|
|
868
|
+
protectedIds.add(operation.rescuePointId)
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
for (const journal of await this.store.listGitCheckpointJournalsInDir(workspaceDir, signal)) {
|
|
872
|
+
protectedIds.add(journal.restorePointId)
|
|
873
|
+
}
|
|
874
|
+
for (const manifest of manifests) {
|
|
875
|
+
if (protectedIds.has(manifest.id)) {
|
|
876
|
+
retainedRestorePoints += 1
|
|
877
|
+
continue
|
|
878
|
+
}
|
|
879
|
+
if (liveRepository) {
|
|
880
|
+
await this.deleteManifestWithGit(manifest, signal)
|
|
881
|
+
} else {
|
|
882
|
+
await this.store.deleteManifestInDir(workspaceDir, manifest.id)
|
|
883
|
+
}
|
|
884
|
+
deletedRestorePoints += 1
|
|
885
|
+
}
|
|
886
|
+
if (restorePointIds === undefined) await this.store.clearTurnOutcomesInDir(workspaceDir)
|
|
887
|
+
const gc = await this.store.collectGarbageInDir(workspaceDir, signal)
|
|
888
|
+
return { deletedRestorePoints, retainedRestorePoints, ...gc }
|
|
889
|
+
} finally {
|
|
890
|
+
await release()
|
|
891
|
+
}
|
|
892
|
+
}
|
|
893
|
+
|
|
746
894
|
private async createLocked(options: {
|
|
747
895
|
readonly cwd: string
|
|
748
896
|
readonly kind: RestorePointKind
|
|
@@ -943,6 +1091,9 @@ export class ChangeLedgerEngine {
|
|
|
943
1091
|
}
|
|
944
1092
|
}
|
|
945
1093
|
|
|
1094
|
+
/** Mutable view used only by `updateConfig` to swap fields on the shared resolved object. */
|
|
1095
|
+
type WritableResolvedConfig = { -readonly [K in keyof ResolvedChangeLedgerConfig]: ResolvedChangeLedgerConfig[K] }
|
|
1096
|
+
|
|
946
1097
|
/** Resolve and validate every deployment-varying configuration value. */
|
|
947
1098
|
export function resolveConfig(config: ChangeLedgerConfig): ResolvedChangeLedgerConfig {
|
|
948
1099
|
const home = homedir()
|
package/src/index.ts
CHANGED
|
@@ -4,12 +4,14 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import type { Context } from '@deepseek-ai/cordis'
|
|
6
6
|
import { ChangeLedgerEngine } from './engine.js'
|
|
7
|
-
import { installRewindHttp, TurnCheckpointCoordinator } from './rewind-host.js'
|
|
7
|
+
import { installManageHttp, installRewindHttp, TurnCheckpointCoordinator } from './rewind-host.js'
|
|
8
|
+
import { installTurnRewindSettings } from './settings.js'
|
|
8
9
|
import type { ChangeLedgerConfig } from './types.js'
|
|
9
10
|
|
|
10
11
|
export * from './engine.js'
|
|
11
12
|
export * from './errors.js'
|
|
12
13
|
export * from './rewind-host.js'
|
|
14
|
+
export * from './settings.js'
|
|
13
15
|
export * from './types.js'
|
|
14
16
|
|
|
15
17
|
declare module '@deepseek-ai/cordis' {
|
|
@@ -30,7 +32,9 @@ export class ChangeLedgerService {
|
|
|
30
32
|
ctx.inject(['agents'], (scope: Context) => { checkpoints.install(scope) })
|
|
31
33
|
ctx.inject(['webServer', 'sessions', 'sessionQuery', 'apiProxy', 'agents'], (scope: Context) => {
|
|
32
34
|
installRewindHttp(scope, this.engine, checkpoints)
|
|
35
|
+
installManageHttp(scope, this.engine)
|
|
33
36
|
})
|
|
37
|
+
installTurnRewindSettings(ctx, config, this.engine)
|
|
34
38
|
void this.engine.initialize().then((reconciled) => {
|
|
35
39
|
if (reconciled > 0) {
|
|
36
40
|
ctx.logger.warn(`[change-ledger] reconciled ${reconciled} interrupted durable operation(s)`)
|
|
@@ -95,6 +99,21 @@ export class ChangeLedgerService {
|
|
|
95
99
|
listRecovery(options: Parameters<ChangeLedgerEngine['listRecovery']>[0]): ReturnType<ChangeLedgerEngine['listRecovery']> {
|
|
96
100
|
return this.engine.listRecovery(options)
|
|
97
101
|
}
|
|
102
|
+
|
|
103
|
+
/** Inventory every workspace this storage root has persisted state for. */
|
|
104
|
+
listWorkspaces(options?: Parameters<ChangeLedgerEngine['listWorkspaces']>[0]): ReturnType<ChangeLedgerEngine['listWorkspaces']> {
|
|
105
|
+
return this.engine.listWorkspaces(options)
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Delete unprotected restore points recorded for one workspace. */
|
|
109
|
+
purgeWorkspace(options: Parameters<ChangeLedgerEngine['purgeWorkspace']>[0]): ReturnType<ChangeLedgerEngine['purgeWorkspace']> {
|
|
110
|
+
return this.engine.purgeWorkspace(options)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Swap runtime-tunable configuration; the storage root must stay fixed. */
|
|
114
|
+
updateConfig(config: ChangeLedgerConfig): void {
|
|
115
|
+
this.engine.updateConfig(config)
|
|
116
|
+
}
|
|
98
117
|
}
|
|
99
118
|
|
|
100
119
|
export default ChangeLedgerService
|
package/src/rewind-host.ts
CHANGED
|
@@ -29,6 +29,12 @@ interface AgentLike {
|
|
|
29
29
|
readonly session: SessionLike
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
interface ToolExecutionLike {
|
|
33
|
+
readonly agent?: AgentLike
|
|
34
|
+
readonly parent?: symbol
|
|
35
|
+
readonly signal: AbortSignal
|
|
36
|
+
}
|
|
37
|
+
|
|
32
38
|
interface AgentsLike {
|
|
33
39
|
list(): AgentLike[]
|
|
34
40
|
}
|
|
@@ -102,6 +108,10 @@ declare module '@deepseek-ai/cordis' {
|
|
|
102
108
|
},
|
|
103
109
|
next: () => Promise<unknown>,
|
|
104
110
|
): Promise<unknown>
|
|
111
|
+
'tools/execute'(
|
|
112
|
+
exec: ToolExecutionLike,
|
|
113
|
+
next: () => Promise<unknown>,
|
|
114
|
+
): Promise<unknown>
|
|
105
115
|
}
|
|
106
116
|
}
|
|
107
117
|
|
|
@@ -110,7 +120,7 @@ const BODY_LIMIT = 64 * 1024
|
|
|
110
120
|
const INITIAL_CHANGE_PREVIEW_LIMIT = 8
|
|
111
121
|
const MAX_CHANGE_PAGE_SIZE = 200
|
|
112
122
|
|
|
113
|
-
/** Capture each turn
|
|
123
|
+
/** Capture each turn beside model work and gate root tool side effects on its bounded outcome. */
|
|
114
124
|
export class TurnCheckpointCoordinator {
|
|
115
125
|
private readonly captures = new Map<string, Promise<void>>()
|
|
116
126
|
private readonly pending = new Set<string>()
|
|
@@ -120,10 +130,17 @@ export class TurnCheckpointCoordinator {
|
|
|
120
130
|
|
|
121
131
|
constructor(private readonly engine: ChangeLedgerEngine) {}
|
|
122
132
|
|
|
123
|
-
/**
|
|
133
|
+
/** Keep sidecar checkpoint work out of the Agent response waterfall. */
|
|
124
134
|
install(ctx: Context): void {
|
|
125
135
|
ctx.on('agent/pre-step', async ({ agent, turn, step, signal }, next) => {
|
|
126
|
-
if (step === 1)
|
|
136
|
+
if (step === 1) this.startCapture(ctx, agent, turn, signal)
|
|
137
|
+
return next()
|
|
138
|
+
}, { prepend: true })
|
|
139
|
+
|
|
140
|
+
ctx.on('tools/execute', async (exec, next) => {
|
|
141
|
+
if (exec.agent !== undefined && exec.parent === undefined) {
|
|
142
|
+
await this.waitForOpenTurn(exec.agent, exec.signal)
|
|
143
|
+
}
|
|
127
144
|
return next()
|
|
128
145
|
}, { prepend: true })
|
|
129
146
|
}
|
|
@@ -138,6 +155,20 @@ export class TurnCheckpointCoordinator {
|
|
|
138
155
|
return error === undefined ? { status: 'missing' } : { status: 'failed', error }
|
|
139
156
|
}
|
|
140
157
|
|
|
158
|
+
private startCapture(
|
|
159
|
+
ctx: Pick<Context, 'logger'>,
|
|
160
|
+
agent: AgentLike,
|
|
161
|
+
turn: number,
|
|
162
|
+
signal: AbortSignal,
|
|
163
|
+
): void {
|
|
164
|
+
const key = checkpointKey(agent.id, turn)
|
|
165
|
+
if (this.captures.has(key)) return
|
|
166
|
+
const capture = this.capture(ctx, agent, turn, signal).catch(async (error: unknown) => {
|
|
167
|
+
await this.recordFailure(ctx, agent.id, turn, error)
|
|
168
|
+
})
|
|
169
|
+
this.captures.set(key, capture)
|
|
170
|
+
}
|
|
171
|
+
|
|
141
172
|
private async capture(
|
|
142
173
|
ctx: Pick<Context, 'logger'>,
|
|
143
174
|
agent: AgentLike,
|
|
@@ -159,11 +190,6 @@ export class TurnCheckpointCoordinator {
|
|
|
159
190
|
const captureDeadline = createDeadline(Math.max(1, timeoutMs - reserveMs))
|
|
160
191
|
const captureSignal = AbortSignal.any([signal, captureDeadline.signal])
|
|
161
192
|
try {
|
|
162
|
-
const existing = this.captures.get(key)
|
|
163
|
-
if (existing !== undefined) {
|
|
164
|
-
await waitWithSignal(existing, captureSignal).catch(() => undefined)
|
|
165
|
-
return
|
|
166
|
-
}
|
|
167
193
|
this.pending.add(key)
|
|
168
194
|
this.failures.delete(key)
|
|
169
195
|
this.skips.delete(key)
|
|
@@ -201,7 +227,6 @@ export class TurnCheckpointCoordinator {
|
|
|
201
227
|
this.pending.delete(key)
|
|
202
228
|
}
|
|
203
229
|
})()
|
|
204
|
-
this.captures.set(key, capture)
|
|
205
230
|
try {
|
|
206
231
|
await waitWithSignal(capture, outcomeSignal)
|
|
207
232
|
} catch (error) {
|
|
@@ -220,6 +245,24 @@ export class TurnCheckpointCoordinator {
|
|
|
220
245
|
}
|
|
221
246
|
}
|
|
222
247
|
|
|
248
|
+
/** Wait only for the bounded checkpoint outcome of the Agent's open turn. */
|
|
249
|
+
private async waitForOpenTurn(agent: AgentLike, signal: AbortSignal): Promise<void> {
|
|
250
|
+
if (signal.aborted) return
|
|
251
|
+
const boundary = agent.session.events.findLast(event => event.type === 'turn/start' || event.type === 'turn/end')
|
|
252
|
+
if (boundary?.type !== 'turn/start') return
|
|
253
|
+
const turn = boundary.data.turn
|
|
254
|
+
if (!Number.isSafeInteger(turn) || (turn as number) < 0) return
|
|
255
|
+
const capture = this.captures.get(checkpointKey(agent.id, turn as number))
|
|
256
|
+
if (capture === undefined) return
|
|
257
|
+
try {
|
|
258
|
+
await waitWithSignal(capture, signal)
|
|
259
|
+
} catch (error) {
|
|
260
|
+
// Delegate caller cancellation so the DSH tool runtime preserves its
|
|
261
|
+
// canonical ABORTED_BEFORE_DISPATCH result instead of a wrapper error.
|
|
262
|
+
if (!signal.aborted) throw error
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
223
266
|
private async serializeWorkspace(workspace: string, signal: AbortSignal, task: () => Promise<void>): Promise<void> {
|
|
224
267
|
const previous = this.workspaceTails.get(workspace) ?? Promise.resolve()
|
|
225
268
|
const current = (async () => {
|
|
@@ -686,3 +729,73 @@ function json(response: HttpResponseLike, status: number, value: unknown): void
|
|
|
686
729
|
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' })
|
|
687
730
|
response.end(`${JSON.stringify(value)}\n`)
|
|
688
731
|
}
|
|
732
|
+
|
|
733
|
+
export const MANAGE_HTTP_PATH = '/turn-rewind/manage'
|
|
734
|
+
|
|
735
|
+
/** Register the same-origin storage-management endpoint consumed by the settings card. */
|
|
736
|
+
export function installManageHttp(ctx: Context, engine: ChangeLedgerEngine): void {
|
|
737
|
+
ctx.effect(() => ctx.webServer.register({
|
|
738
|
+
kind: 'exact',
|
|
739
|
+
path: MANAGE_HTTP_PATH,
|
|
740
|
+
handler: createManageHttpHandler(engine),
|
|
741
|
+
}), 'change-ledger.manageHttp')
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/** Build the storage-management route as a testable unit. */
|
|
745
|
+
export function createManageHttpHandler(
|
|
746
|
+
engine: ChangeLedgerEngine,
|
|
747
|
+
): (request: HttpRequestLike, response: HttpResponseLike) => Promise<void> {
|
|
748
|
+
return async (request, response) => {
|
|
749
|
+
try {
|
|
750
|
+
if (request.method === 'GET') {
|
|
751
|
+
const workspaces = await engine.listWorkspaces()
|
|
752
|
+
json(response, 200, {
|
|
753
|
+
storageDir: engine.config.storageDir,
|
|
754
|
+
totalBytes: workspaces.reduce((total, workspace) => total + workspace.totalBytes, 0),
|
|
755
|
+
workspaces: workspaces.map((workspace) => ({
|
|
756
|
+
workspace: workspace.workspace,
|
|
757
|
+
totalBytes: workspace.totalBytes,
|
|
758
|
+
recoveryCount: workspace.recoveryCount,
|
|
759
|
+
restorePoints: workspace.restorePoints.map((point) => ({
|
|
760
|
+
id: point.id,
|
|
761
|
+
kind: point.kind,
|
|
762
|
+
format: point.format,
|
|
763
|
+
createdAt: point.createdAt,
|
|
764
|
+
totalBytes: point.totalBytes,
|
|
765
|
+
fileCount: point.fileCount,
|
|
766
|
+
...(point.sessionId === undefined ? {} : { sessionId: point.sessionId }),
|
|
767
|
+
...(point.turn === undefined ? {} : { turn: point.turn }),
|
|
768
|
+
...(point.label === undefined ? {} : { label: point.label }),
|
|
769
|
+
})),
|
|
770
|
+
})),
|
|
771
|
+
})
|
|
772
|
+
return
|
|
773
|
+
}
|
|
774
|
+
if (request.method === 'POST') {
|
|
775
|
+
const body = objectBody(await readBody(request))
|
|
776
|
+
const action = requiredText(body.action, 'action')
|
|
777
|
+
if (action === 'delete') {
|
|
778
|
+
const workspace = requiredText(body.workspace, 'workspace')
|
|
779
|
+
const restorePointId = requiredText(body.restorePointId, 'restorePointId')
|
|
780
|
+
const report = await engine.purgeWorkspace({ workspace, restorePointIds: [restorePointId] })
|
|
781
|
+
if (report.deletedRestorePoints === 0) {
|
|
782
|
+
throw new ChangeLedgerError(
|
|
783
|
+
report.retainedRestorePoints > 0 ? 'RECOVERY_REFERENCE' : 'RESTORE_POINT_NOT_FOUND',
|
|
784
|
+
report.retainedRestorePoints > 0
|
|
785
|
+
? 'restore point is required by an incomplete recovery journal'
|
|
786
|
+
: `restore point ${restorePointId} does not exist`,
|
|
787
|
+
)
|
|
788
|
+
}
|
|
789
|
+
json(response, 200, { status: 'completed', action, ...report })
|
|
790
|
+
return
|
|
791
|
+
}
|
|
792
|
+
throw new ChangeLedgerError('INVALID_ARGUMENTS', `unsupported manage action ${action}`)
|
|
793
|
+
}
|
|
794
|
+
response.writeHead(405, { allow: 'GET, POST' })
|
|
795
|
+
response.end()
|
|
796
|
+
} catch (error) {
|
|
797
|
+
const status = error instanceof ChangeLedgerError && error.code === 'RESTORE_POINT_NOT_FOUND' ? 404 : 409
|
|
798
|
+
json(response, status, { error: errorMessage(error), code: error instanceof ChangeLedgerError ? error.code : 'MANAGE_FAILED' })
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
}
|
package/src/settings.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime settings surface for Turn Rewind on the DSH web settings page.
|
|
3
|
+
* @module @anionex/dsh-turn-rewind
|
|
4
|
+
*/
|
|
5
|
+
import type { Context } from '@deepseek-ai/cordis'
|
|
6
|
+
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
7
|
+
import z from '@deepseek-ai/schemastery'
|
|
8
|
+
import { resolveConfig, type ChangeLedgerEngine } from './engine.js'
|
|
9
|
+
import type { ChangeLedgerConfig, ResolvedChangeLedgerConfig } from './types.js'
|
|
10
|
+
|
|
11
|
+
/** Namespace join key shared by the host section and the browser settings card. */
|
|
12
|
+
export const TURN_REWIND_SETTINGS_NAMESPACE = settingsNamespace('turn-rewind')
|
|
13
|
+
|
|
14
|
+
/** Every runtime-tunable field of {@link ChangeLedgerConfig}; `storageDir` stays config-layer only. */
|
|
15
|
+
export interface TurnRewindSettings {
|
|
16
|
+
/** Maximum user and rescue restore points retained per workspace. */
|
|
17
|
+
maxRestorePoints: number
|
|
18
|
+
/** Maximum automatic turn checkpoints retained per session. */
|
|
19
|
+
maxTurnCheckpointsPerSession: number
|
|
20
|
+
/** Maximum number of files in one restore point. */
|
|
21
|
+
maxFiles: number
|
|
22
|
+
/** Maximum bytes read from one regular file. */
|
|
23
|
+
maxFileBytes: number
|
|
24
|
+
/** Maximum aggregate regular-file bytes in one restore point. */
|
|
25
|
+
maxSnapshotBytes: number
|
|
26
|
+
/** Restore-plan lifetime in milliseconds. */
|
|
27
|
+
planTtlMs: number
|
|
28
|
+
/** Age after which a lock whose owner is gone may be reclaimed. */
|
|
29
|
+
staleLockMs: number
|
|
30
|
+
/** Automatic turn-checkpoint implementation; `off` records durable skips instead. */
|
|
31
|
+
turnCheckpointMode: 'off' | 'git-native' | 'legacy'
|
|
32
|
+
/** Maximum time one automatic checkpoint may block the first Agent step. */
|
|
33
|
+
turnCheckpointTimeoutMs: number
|
|
34
|
+
/** Maximum uncached worktree bytes read by one automatic Git-native checkpoint. */
|
|
35
|
+
turnCheckpointMaxNewBytes: number
|
|
36
|
+
/** Fast trusts fenced Git/stat metadata; strict rereads every eligible path. */
|
|
37
|
+
turnCheckpointTrust: 'fast' | 'strict'
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Schemastery schema for the `turn-rewind` settings namespace. */
|
|
41
|
+
export const TurnRewindSettingsSchema: z<TurnRewindSettings> = (() => {
|
|
42
|
+
const defaults = tunableSettings(resolveConfig({}))
|
|
43
|
+
return z.object({
|
|
44
|
+
maxRestorePoints: z.number().step(1).min(1).default(defaults.maxRestorePoints),
|
|
45
|
+
maxTurnCheckpointsPerSession: z.number().step(1).min(1).default(defaults.maxTurnCheckpointsPerSession),
|
|
46
|
+
maxFiles: z.number().step(1).min(1).default(defaults.maxFiles),
|
|
47
|
+
maxFileBytes: z.number().step(1).min(1).default(defaults.maxFileBytes),
|
|
48
|
+
maxSnapshotBytes: z.number().step(1).min(1).default(defaults.maxSnapshotBytes),
|
|
49
|
+
planTtlMs: z.number().step(1).min(1).default(defaults.planTtlMs),
|
|
50
|
+
staleLockMs: z.number().step(1).min(1).default(defaults.staleLockMs),
|
|
51
|
+
turnCheckpointMode: z.union(['off', 'git-native', 'legacy']).default(defaults.turnCheckpointMode),
|
|
52
|
+
turnCheckpointTimeoutMs: z.number().step(1).min(1).default(defaults.turnCheckpointTimeoutMs),
|
|
53
|
+
turnCheckpointMaxNewBytes: z.number().step(1).min(1).default(defaults.turnCheckpointMaxNewBytes),
|
|
54
|
+
turnCheckpointTrust: z.union(['fast', 'strict']).default(defaults.turnCheckpointTrust),
|
|
55
|
+
})
|
|
56
|
+
})()
|
|
57
|
+
|
|
58
|
+
/** Project one resolved configuration onto the settings namespace subset. */
|
|
59
|
+
function tunableSettings(resolved: ResolvedChangeLedgerConfig): TurnRewindSettings {
|
|
60
|
+
return {
|
|
61
|
+
maxRestorePoints: resolved.maxRestorePoints,
|
|
62
|
+
maxTurnCheckpointsPerSession: resolved.maxTurnCheckpointsPerSession,
|
|
63
|
+
maxFiles: resolved.maxFiles,
|
|
64
|
+
maxFileBytes: resolved.maxFileBytes,
|
|
65
|
+
maxSnapshotBytes: resolved.maxSnapshotBytes,
|
|
66
|
+
planTtlMs: resolved.planTtlMs,
|
|
67
|
+
staleLockMs: resolved.staleLockMs,
|
|
68
|
+
turnCheckpointMode: resolved.turnCheckpointMode,
|
|
69
|
+
turnCheckpointTimeoutMs: resolved.turnCheckpointTimeoutMs,
|
|
70
|
+
turnCheckpointMaxNewBytes: resolved.turnCheckpointMaxNewBytes,
|
|
71
|
+
turnCheckpointTrust: resolved.turnCheckpointTrust,
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Register the `turn-rewind` settings namespace and apply its resolved value to the
|
|
77
|
+
* running engine. The composition entry (from `cordis.patch.yml`) is the base layer;
|
|
78
|
+
* the user layer persists through the DSH settings provider. `storageDir` is never
|
|
79
|
+
* carried by the namespace: the storage root must not move while the engine runs.
|
|
80
|
+
*/
|
|
81
|
+
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(), {
|
|
84
|
+
setSource: (current) => { source = current },
|
|
85
|
+
onChange: () => {
|
|
86
|
+
try {
|
|
87
|
+
engine.updateConfig({ ...config, ...source() })
|
|
88
|
+
} catch (error) {
|
|
89
|
+
ctx.logger.warn(`[turn-rewind] could not apply settings update: ${error instanceof Error ? error.message : String(error)}`)
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
})
|
|
93
|
+
}
|