@anionex/dsh-turn-rewind 0.1.2 → 0.2.0
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 +289 -22
- package/lib/client.js.map +1 -1
- package/lib/engine.js +140 -3
- package/lib/index.js +17 -1
- package/lib/rewind-host.js +131 -19
- 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 +5 -0
- 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 +534 -42
- package/src/engine.ts +154 -3
- package/src/index.ts +20 -1
- package/src/rewind-host.ts +144 -20
- 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
|
@@ -4,6 +4,7 @@ import { createDeadline } from './deadline.js'
|
|
|
4
4
|
import { ChangeLedgerError, errorMessage } from './errors.js'
|
|
5
5
|
import type { ChangeLedgerEngine } from './engine.js'
|
|
6
6
|
import { discoverRepositoryRoot } from './git.js'
|
|
7
|
+
import type { WorkspacePurgeReport } from './types.js'
|
|
7
8
|
|
|
8
9
|
interface SessionEventLike {
|
|
9
10
|
readonly type: string
|
|
@@ -312,6 +313,101 @@ export function installRewindHttp(
|
|
|
312
313
|
}), 'change-ledger.rewindHttp')
|
|
313
314
|
}
|
|
314
315
|
|
|
316
|
+
export const MANAGE_HTTP_PATH = '/turn-rewind/manage'
|
|
317
|
+
|
|
318
|
+
/** Register the same-origin storage-management endpoint consumed by the settings card. */
|
|
319
|
+
export function installManageHttp(ctx: Context, engine: ChangeLedgerEngine): void {
|
|
320
|
+
ctx.effect(() => ctx.webServer.register({
|
|
321
|
+
kind: 'exact',
|
|
322
|
+
path: MANAGE_HTTP_PATH,
|
|
323
|
+
handler: createManageHttpHandler(engine),
|
|
324
|
+
}), 'change-ledger.manageHttp')
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
/** Build the storage-management route as a testable unit. */
|
|
328
|
+
export function createManageHttpHandler(
|
|
329
|
+
engine: ChangeLedgerEngine,
|
|
330
|
+
): (request: HttpRequestLike, response: HttpResponseLike) => Promise<void> {
|
|
331
|
+
return async (request, response) => {
|
|
332
|
+
try {
|
|
333
|
+
if (request.method === 'GET') {
|
|
334
|
+
const workspaces = await engine.listWorkspaces()
|
|
335
|
+
json(response, 200, {
|
|
336
|
+
storageDir: engine.config.storageDir,
|
|
337
|
+
totalBytes: workspaces.reduce((total, workspace) => total + workspace.totalBytes, 0),
|
|
338
|
+
workspaces: workspaces.map((workspace) => ({
|
|
339
|
+
workspace: workspace.workspace,
|
|
340
|
+
totalBytes: workspace.totalBytes,
|
|
341
|
+
recoveryCount: workspace.recoveryCount,
|
|
342
|
+
restorePoints: workspace.restorePoints.map((point) => ({
|
|
343
|
+
id: point.id,
|
|
344
|
+
kind: point.kind,
|
|
345
|
+
format: point.format,
|
|
346
|
+
createdAt: point.createdAt,
|
|
347
|
+
totalBytes: point.totalBytes,
|
|
348
|
+
fileCount: point.fileCount,
|
|
349
|
+
...(point.sessionId === undefined ? {} : { sessionId: point.sessionId }),
|
|
350
|
+
...(point.turn === undefined ? {} : { turn: point.turn }),
|
|
351
|
+
...(point.label === undefined ? {} : { label: point.label }),
|
|
352
|
+
})),
|
|
353
|
+
})),
|
|
354
|
+
})
|
|
355
|
+
return
|
|
356
|
+
}
|
|
357
|
+
if (request.method === 'POST') {
|
|
358
|
+
const body = objectBody(await readBody(request))
|
|
359
|
+
const action = requiredText(body.action, 'action')
|
|
360
|
+
if (action === 'delete') {
|
|
361
|
+
const workspace = requiredText(body.workspace, 'workspace')
|
|
362
|
+
const restorePointId = requiredText(body.restorePointId, 'restorePointId')
|
|
363
|
+
const report = await engine.purgeWorkspace({ workspace, restorePointIds: [restorePointId] })
|
|
364
|
+
if (report.deletedRestorePoints === 0) {
|
|
365
|
+
throw new ChangeLedgerError(
|
|
366
|
+
report.retainedRestorePoints > 0 ? 'RECOVERY_REFERENCE' : 'RESTORE_POINT_NOT_FOUND',
|
|
367
|
+
report.retainedRestorePoints > 0
|
|
368
|
+
? 'restore point is required by an incomplete recovery journal'
|
|
369
|
+
: `restore point ${restorePointId} does not exist`,
|
|
370
|
+
)
|
|
371
|
+
}
|
|
372
|
+
json(response, 200, { status: 'completed', action, ...report })
|
|
373
|
+
return
|
|
374
|
+
}
|
|
375
|
+
if (action === 'clear-workspace') {
|
|
376
|
+
const workspace = requiredText(body.workspace, 'workspace')
|
|
377
|
+
json(response, 200, { status: 'completed', action, ...await engine.purgeWorkspace({ workspace }) })
|
|
378
|
+
return
|
|
379
|
+
}
|
|
380
|
+
if (action === 'clear-all') {
|
|
381
|
+
const reports: WorkspacePurgeReport[] = []
|
|
382
|
+
const failures: { readonly workspace: string; readonly error: string }[] = []
|
|
383
|
+
for (const overview of await engine.listWorkspaces()) {
|
|
384
|
+
try {
|
|
385
|
+
reports.push(await engine.purgeWorkspace({ workspace: overview.workspace }))
|
|
386
|
+
} catch (error) {
|
|
387
|
+
failures.push({ workspace: overview.workspace, error: errorMessage(error) })
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
json(response, 200, {
|
|
391
|
+
status: failures.length > 0 ? 'partial' : 'completed',
|
|
392
|
+
action,
|
|
393
|
+
reports,
|
|
394
|
+
...(failures.length > 0 ? { failures } : {}),
|
|
395
|
+
})
|
|
396
|
+
return
|
|
397
|
+
}
|
|
398
|
+
throw new ChangeLedgerError('INVALID_ARGUMENTS', 'action must be "delete", "clear-workspace", or "clear-all"')
|
|
399
|
+
}
|
|
400
|
+
json(response, 405, { error: 'method not allowed' })
|
|
401
|
+
} catch (error) {
|
|
402
|
+
const status = error instanceof ChangeLedgerError && error.code === 'RESTORE_POINT_NOT_FOUND' ? 404 : 409
|
|
403
|
+
json(response, status, {
|
|
404
|
+
error: errorMessage(error),
|
|
405
|
+
code: error instanceof ChangeLedgerError ? error.code : 'MANAGE_FAILED',
|
|
406
|
+
})
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
315
411
|
/** Build the exact-route handler as a testable unit. */
|
|
316
412
|
export function createRewindHttpHandler(
|
|
317
413
|
ctx: Pick<Context, 'sessions' | 'sessionQuery' | 'apiProxy'> & { readonly agents?: AgentsLike },
|
|
@@ -327,7 +423,16 @@ export function createRewindHttpHandler(
|
|
|
327
423
|
const detailsOnly = url.searchParams.get('details') === '1'
|
|
328
424
|
const offset = nonNegativeInteger(url.searchParams.get('offset') ?? '0', 'offset')
|
|
329
425
|
const limit = pageSize(url.searchParams.get('limit'), detailsOnly ? MAX_CHANGE_PAGE_SIZE : INITIAL_CHANGE_PREVIEW_LIMIT)
|
|
330
|
-
const {
|
|
426
|
+
const { session, target } = await resolveMessageTarget(ctx, sessionId, messageSeq)
|
|
427
|
+
let checkpoint: MessageCheckpoint | undefined
|
|
428
|
+
try {
|
|
429
|
+
checkpoint = await resolveMessageCheckpoint(ctx, engine, sessionId, session, target)
|
|
430
|
+
} catch (error) {
|
|
431
|
+
// File-checkpoint discovery is allowed to fail independently: the dialog
|
|
432
|
+
// must still expose message-only rewind for a valid Session boundary.
|
|
433
|
+
json(response, 200, { status: 'failed', error: errorMessage(error) })
|
|
434
|
+
return
|
|
435
|
+
}
|
|
331
436
|
if (checkpoint === undefined) {
|
|
332
437
|
const durableSkip = await engine.findTurnCheckpointSkip({
|
|
333
438
|
cwd: target.cwd,
|
|
@@ -383,11 +488,20 @@ export function createRewindHttpHandler(
|
|
|
383
488
|
if (request.method === 'POST') {
|
|
384
489
|
const body = objectBody(await readBody(request))
|
|
385
490
|
const mode = body.mode
|
|
386
|
-
if (mode !== 'code' && mode !== 'both') {
|
|
387
|
-
throw new ChangeLedgerError('INVALID_ARGUMENTS', 'mode must be "code" or "
|
|
491
|
+
if (mode !== 'code' && mode !== 'both' && mode !== 'messages') {
|
|
492
|
+
throw new ChangeLedgerError('INVALID_ARGUMENTS', 'mode must be "code", "both", or "messages"')
|
|
388
493
|
}
|
|
389
494
|
const sessionId = requiredText(body.sessionId, 'sessionId')
|
|
390
495
|
const messageSeq = nonNegativeInteger(body.messageSeq, 'messageSeq')
|
|
496
|
+
if (mode === 'messages') {
|
|
497
|
+
// Message-only rewind never mutates project files, so no checkpoint, no
|
|
498
|
+
// restore plan, and no shared-workspace gate apply; the conversation
|
|
499
|
+
// restart revalidates the message boundary itself.
|
|
500
|
+
const { target } = await resolveMessageTarget(ctx, sessionId, messageSeq)
|
|
501
|
+
const fork = await createConversationRestart(ctx, sessionId, target)
|
|
502
|
+
json(response, 200, { status: 'completed', mode, sessionId: fork.sessionId })
|
|
503
|
+
return
|
|
504
|
+
}
|
|
391
505
|
const checkpointId = requiredText(body.checkpointId, 'checkpointId')
|
|
392
506
|
const checkpoint = await checkpointForRequest(ctx, engine, sessionId, messageSeq, checkpointId)
|
|
393
507
|
const activeSessionIds = await sharedWorkspaceSessions(ctx.agents, checkpoint.cwd)
|
|
@@ -465,20 +579,29 @@ interface MessageCheckpoint extends MessageTarget {
|
|
|
465
579
|
readonly id: string
|
|
466
580
|
}
|
|
467
581
|
|
|
582
|
+
async function resolveMessageTarget(
|
|
583
|
+
ctx: Pick<Context, 'sessions' | 'sessionQuery'>,
|
|
584
|
+
sessionId: string,
|
|
585
|
+
messageSeq: number,
|
|
586
|
+
): Promise<{ readonly session: SessionLike; readonly target: MessageTarget }> {
|
|
587
|
+
const session = await readSession(ctx, sessionId)
|
|
588
|
+
return { session, target: messageTarget(session, messageSeq) }
|
|
589
|
+
}
|
|
590
|
+
|
|
468
591
|
async function resolveMessageCheckpoint(
|
|
469
592
|
ctx: Pick<Context, 'sessions' | 'sessionQuery'>,
|
|
470
593
|
engine: ChangeLedgerEngine,
|
|
471
594
|
sessionId: string,
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
595
|
+
source: SessionLike,
|
|
596
|
+
target: MessageTarget,
|
|
597
|
+
): Promise<MessageCheckpoint | undefined> {
|
|
598
|
+
let current = source
|
|
476
599
|
const direct = await engine.findTurnCheckpoint({ cwd: target.cwd, sessionId, turn: target.turn })
|
|
477
600
|
if (direct !== undefined) {
|
|
478
601
|
if (direct.turnStartSeq !== target.turnStartSeq) {
|
|
479
602
|
throw new ChangeLedgerError('PLAN_STALE', 'the message checkpoint no longer matches its turn start')
|
|
480
603
|
}
|
|
481
|
-
return {
|
|
604
|
+
return { ...target, id: direct.id }
|
|
482
605
|
}
|
|
483
606
|
|
|
484
607
|
const seen = new Set<string>([sessionId])
|
|
@@ -490,7 +613,7 @@ async function resolveMessageCheckpoint(
|
|
|
490
613
|
}
|
|
491
614
|
if (parentId === undefined || seedLength === undefined
|
|
492
615
|
|| target.messageSeq >= seedLength || target.turnStartSeq >= seedLength) {
|
|
493
|
-
return
|
|
616
|
+
return undefined
|
|
494
617
|
}
|
|
495
618
|
if (seen.has(parentId)) {
|
|
496
619
|
throw new ChangeLedgerError('PLAN_STALE', 'session fork lineage contains a cycle')
|
|
@@ -501,7 +624,7 @@ async function resolveMessageCheckpoint(
|
|
|
501
624
|
} catch (error) {
|
|
502
625
|
throw new ChangeLedgerError('PLAN_STALE', `parent session ${parentId} is unavailable`, { cause: error })
|
|
503
626
|
}
|
|
504
|
-
const parentTarget = messageTarget(current, messageSeq)
|
|
627
|
+
const parentTarget = messageTarget(current, target.messageSeq)
|
|
505
628
|
if (parentTarget.turn !== target.turn
|
|
506
629
|
|| parentTarget.turnStartSeq !== target.turnStartSeq
|
|
507
630
|
|| parentTarget.previousTurnEndSeq !== target.previousTurnEndSeq) {
|
|
@@ -512,7 +635,7 @@ async function resolveMessageCheckpoint(
|
|
|
512
635
|
if (inherited.turnStartSeq !== target.turnStartSeq) {
|
|
513
636
|
throw new ChangeLedgerError('PLAN_STALE', 'the inherited message checkpoint no longer matches the fork boundary')
|
|
514
637
|
}
|
|
515
|
-
return {
|
|
638
|
+
return { ...target, id: inherited.id }
|
|
516
639
|
}
|
|
517
640
|
}
|
|
518
641
|
|
|
@@ -523,7 +646,8 @@ async function checkpointForRequest(
|
|
|
523
646
|
messageSeq: number,
|
|
524
647
|
requestedId: string,
|
|
525
648
|
): Promise<MessageCheckpoint> {
|
|
526
|
-
const {
|
|
649
|
+
const { session, target } = await resolveMessageTarget(ctx, sessionId, messageSeq)
|
|
650
|
+
const checkpoint = await resolveMessageCheckpoint(ctx, engine, sessionId, session, target)
|
|
527
651
|
if (checkpoint === undefined) {
|
|
528
652
|
throw new ChangeLedgerError('RESTORE_POINT_NOT_FOUND', `message ${String(target.messageSeq)} has no rewind checkpoint`)
|
|
529
653
|
}
|
|
@@ -536,23 +660,23 @@ async function checkpointForRequest(
|
|
|
536
660
|
async function createConversationRestart(
|
|
537
661
|
ctx: Pick<Context, 'sessions' | 'sessionQuery' | 'apiProxy'>,
|
|
538
662
|
sourceId: string,
|
|
539
|
-
|
|
663
|
+
target: MessageTarget,
|
|
540
664
|
): Promise<{ readonly sessionId: string }> {
|
|
541
665
|
const source = await readSession(ctx, sourceId)
|
|
542
|
-
const current = messageTarget(source,
|
|
543
|
-
if (current.turn !==
|
|
544
|
-
|| current.turnStartSeq !==
|
|
545
|
-
|| current.previousTurnEndSeq !==
|
|
666
|
+
const current = messageTarget(source, target.messageSeq)
|
|
667
|
+
if (current.turn !== target.turn
|
|
668
|
+
|| current.turnStartSeq !== target.turnStartSeq
|
|
669
|
+
|| current.previousTurnEndSeq !== target.previousTurnEndSeq) {
|
|
546
670
|
throw new ChangeLedgerError('PLAN_STALE', 'the session no longer contains the selected message boundary')
|
|
547
671
|
}
|
|
548
|
-
const response =
|
|
672
|
+
const response = target.previousTurnEndSeq === undefined
|
|
549
673
|
? await ctx.apiProxy.sessions.create({
|
|
550
674
|
rpcId: randomUUID(),
|
|
551
|
-
payload: { cwd:
|
|
675
|
+
payload: { cwd: target.cwd },
|
|
552
676
|
})
|
|
553
677
|
: await ctx.apiProxy.sessions.fork({
|
|
554
678
|
rpcId: randomUUID(),
|
|
555
|
-
payload: { sessionId: sourceId, atSeq:
|
|
679
|
+
payload: { sessionId: sourceId, atSeq: target.previousTurnEndSeq },
|
|
556
680
|
})
|
|
557
681
|
if (!response.result.ok) {
|
|
558
682
|
throw new ChangeLedgerError('CONVERSATION_REWIND_FAILED', response.result.error.message)
|
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
|
+
}
|