@antst/roller 0.1.2
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/LICENSE +21 -0
- package/README.md +16 -0
- package/cordis.patch.yml +4 -0
- package/lib/capture.d.ts +12 -0
- package/lib/capture.js +156 -0
- package/lib/format.d.ts +14 -0
- package/lib/format.js +17 -0
- package/lib/index.d.ts +8 -0
- package/lib/index.js +26 -0
- package/lib/restore-files.d.ts +7 -0
- package/lib/restore-files.js +31 -0
- package/lib/restore.d.ts +6 -0
- package/lib/restore.js +119 -0
- package/lib/spec.d.ts +42 -0
- package/lib/spec.js +27 -0
- package/lib/version.d.ts +1 -0
- package/lib/version.js +19 -0
- package/package.json +77 -0
- package/validated-dsh-versions.json +3 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Anton Starikov
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# @antst/roller
|
|
2
|
+
|
|
3
|
+
roller is file rewind for DeepSeek Harness (DSH). It records the original
|
|
4
|
+
contents of files changed by DSH's `write`, `edit`, and mutating
|
|
5
|
+
`str_replace_editor` operations, then restores those files to a completed
|
|
6
|
+
turn boundary.
|
|
7
|
+
|
|
8
|
+
Install as a DSH plugin bundle, not as a standalone dependency:
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
npm install --global @deepseek-ai/dsh
|
|
12
|
+
dsh plugin --profile <profile> add @antst/roller
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Full documentation, source, and issue tracker:
|
|
16
|
+
https://forgejo.antst.net/ai/roller
|
package/cordis.patch.yml
ADDED
package/lib/capture.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
import type { FsTarget } from '@deepseek-ai/dsh-fs';
|
|
3
|
+
import type { KvTable } from '@deepseek-ai/dsh-storage-domain';
|
|
4
|
+
import { type CheckpointKey, type CheckpointRecord } from './spec.js';
|
|
5
|
+
export declare class CaptureJournal {
|
|
6
|
+
private readonly ctx;
|
|
7
|
+
private readonly checkpoints;
|
|
8
|
+
private readonly evictedAtTurn;
|
|
9
|
+
constructor(ctx: Context, checkpoints: KvTable<CheckpointKey, CheckpointRecord>);
|
|
10
|
+
capture(target: FsTarget, value: object | undefined): Promise<void>;
|
|
11
|
+
private evict;
|
|
12
|
+
}
|
package/lib/capture.js
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { relative, sep } from 'node:path';
|
|
3
|
+
import { FsError } from '@deepseek-ai/dsh-fs';
|
|
4
|
+
import { hasMultipleHardLinks } from './restore-files.js';
|
|
5
|
+
import { MAX_CHECKPOINT_BYTES, RETAINED_TURNS, } from './spec.js';
|
|
6
|
+
function isRecord(value) {
|
|
7
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
8
|
+
}
|
|
9
|
+
function mutationActor(value) {
|
|
10
|
+
if (!isRecord(value) || !isRecord(value.arguments) || !isRecord(value.agent))
|
|
11
|
+
return undefined;
|
|
12
|
+
const session = value.agent.session;
|
|
13
|
+
if (!isRecord(session) || !isRecord(session.header) || typeof session.snapshotEvents !== 'function')
|
|
14
|
+
return undefined;
|
|
15
|
+
if (typeof session.header.id !== 'string')
|
|
16
|
+
return undefined;
|
|
17
|
+
if (value.name !== 'write' && value.name !== 'edit' && value.name !== 'str_replace_editor')
|
|
18
|
+
return undefined;
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
function requestedPath(actor) {
|
|
22
|
+
if (actor.name === 'write' || actor.name === 'edit') {
|
|
23
|
+
return typeof actor.arguments.file_path === 'string' ? actor.arguments.file_path : undefined;
|
|
24
|
+
}
|
|
25
|
+
const command = actor.arguments.command;
|
|
26
|
+
if (command !== 'create' && command !== 'str_replace' && command !== 'insert')
|
|
27
|
+
return undefined;
|
|
28
|
+
return typeof actor.arguments.path === 'string' ? actor.arguments.path : undefined;
|
|
29
|
+
}
|
|
30
|
+
function openTurn(session) {
|
|
31
|
+
let boundary;
|
|
32
|
+
const events = session.snapshotEvents();
|
|
33
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
34
|
+
const event = events[index];
|
|
35
|
+
if (event.type === 'turn/start' || event.type === 'turn/end') {
|
|
36
|
+
boundary = event;
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (boundary?.type !== 'turn/start' || !isRecord(boundary.data))
|
|
41
|
+
return undefined;
|
|
42
|
+
return Number.isSafeInteger(boundary.data.turn) && boundary.data.turn >= 0
|
|
43
|
+
? boundary.data.turn
|
|
44
|
+
: undefined;
|
|
45
|
+
}
|
|
46
|
+
function keyOf(sessionId, turn, targetKey) {
|
|
47
|
+
return createHash('sha256')
|
|
48
|
+
.update(sessionId).update('\0').update(String(turn)).update('\0').update(targetKey)
|
|
49
|
+
.digest('hex');
|
|
50
|
+
}
|
|
51
|
+
function normalizedRelative(cwdPath, targetPath) {
|
|
52
|
+
return relative(cwdPath, targetPath).split(sep).join('/');
|
|
53
|
+
}
|
|
54
|
+
function losslessUtf8(bytes) {
|
|
55
|
+
let decoded;
|
|
56
|
+
try {
|
|
57
|
+
decoded = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return false;
|
|
61
|
+
}
|
|
62
|
+
return Buffer.from(decoded, 'utf8').equals(Buffer.from(bytes));
|
|
63
|
+
}
|
|
64
|
+
export class CaptureJournal {
|
|
65
|
+
ctx;
|
|
66
|
+
checkpoints;
|
|
67
|
+
evictedAtTurn = new Map();
|
|
68
|
+
constructor(ctx, checkpoints) {
|
|
69
|
+
this.ctx = ctx;
|
|
70
|
+
this.checkpoints = checkpoints;
|
|
71
|
+
}
|
|
72
|
+
async capture(target, value) {
|
|
73
|
+
const actor = mutationActor(value);
|
|
74
|
+
if (actor === undefined)
|
|
75
|
+
return;
|
|
76
|
+
const path = requestedPath(actor);
|
|
77
|
+
const cwd = actor.agent.session.header.cwd;
|
|
78
|
+
const turn = openTurn(actor.agent.session);
|
|
79
|
+
if (path === undefined || cwd === undefined || turn === undefined)
|
|
80
|
+
return;
|
|
81
|
+
const sessionId = actor.agent.session.header.id;
|
|
82
|
+
const targetKey = String(target.targetKey);
|
|
83
|
+
const key = keyOf(sessionId, turn, targetKey);
|
|
84
|
+
const existing = this.checkpoints.get(key);
|
|
85
|
+
if (existing !== undefined) {
|
|
86
|
+
if (existing.sessionId !== sessionId || existing.turn !== turn || existing.targetKey !== targetKey) {
|
|
87
|
+
throw new Error(`roller: checkpoint key collision for ${target.displayPath}`);
|
|
88
|
+
}
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const cwdTarget = actor.signal === undefined
|
|
92
|
+
? await this.ctx.fs.resolve(cwd)
|
|
93
|
+
: await this.ctx.fs.resolve(cwd, { signal: actor.signal });
|
|
94
|
+
if (!this.ctx.fs.contains(cwdTarget, target))
|
|
95
|
+
return;
|
|
96
|
+
const pathInfo = await this.ctx.fs.lstat(path, { cwd }, actor.signal);
|
|
97
|
+
if (pathInfo?.type === 'symlink')
|
|
98
|
+
return;
|
|
99
|
+
let before;
|
|
100
|
+
if (pathInfo === undefined) {
|
|
101
|
+
before = { kind: 'absent' };
|
|
102
|
+
}
|
|
103
|
+
else {
|
|
104
|
+
if (pathInfo.type !== 'file')
|
|
105
|
+
return;
|
|
106
|
+
if (await hasMultipleHardLinks(this.ctx.fs.processPath(target)))
|
|
107
|
+
return;
|
|
108
|
+
let bytes;
|
|
109
|
+
try {
|
|
110
|
+
bytes = await this.ctx.fs.readBytes(target, actor.signal, MAX_CHECKPOINT_BYTES);
|
|
111
|
+
}
|
|
112
|
+
catch (error) {
|
|
113
|
+
if (error instanceof FsError && error.code === 'FS_TOO_LARGE')
|
|
114
|
+
return;
|
|
115
|
+
throw error;
|
|
116
|
+
}
|
|
117
|
+
if (!losslessUtf8(bytes))
|
|
118
|
+
return;
|
|
119
|
+
before = {
|
|
120
|
+
kind: 'text',
|
|
121
|
+
contentBase64: Buffer.from(bytes).toString('base64'),
|
|
122
|
+
byteLength: bytes.byteLength,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
await this.evict(sessionId, turn);
|
|
126
|
+
const cwdPath = this.ctx.fs.processPath(cwdTarget);
|
|
127
|
+
const targetPath = this.ctx.fs.processPath(target);
|
|
128
|
+
try {
|
|
129
|
+
await this.checkpoints.put(key, {
|
|
130
|
+
sessionId,
|
|
131
|
+
turn,
|
|
132
|
+
targetKey,
|
|
133
|
+
relativePath: normalizedRelative(cwdPath, targetPath),
|
|
134
|
+
displayPath: target.displayPath,
|
|
135
|
+
before,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
const cause = error instanceof Error ? error.message : String(error);
|
|
140
|
+
throw new Error(`roller: checkpoint write failed for ${target.displayPath}: ${cause}`, { cause: error });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
async evict(sessionId, turn) {
|
|
144
|
+
if (this.evictedAtTurn.get(sessionId) === turn)
|
|
145
|
+
return;
|
|
146
|
+
const cutoff = turn - (RETAINED_TURNS - 1);
|
|
147
|
+
for (const [key, record] of this.checkpoints.entries()) {
|
|
148
|
+
if (record.sessionId === sessionId && record.turn < cutoff) {
|
|
149
|
+
await this.checkpoints.delete(key);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
// This is only a scan watermark. Journal correctness never depends on
|
|
153
|
+
// retaining or reconstructing it, so a restart merely repeats one scan.
|
|
154
|
+
this.evictedAtTurn.set(sessionId, turn);
|
|
155
|
+
}
|
|
156
|
+
}
|
package/lib/format.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
export interface RestoreFailure {
|
|
2
|
+
path: string;
|
|
3
|
+
operation: 'write' | 'delete';
|
|
4
|
+
message: string;
|
|
5
|
+
}
|
|
6
|
+
export interface RestoreReport {
|
|
7
|
+
seq: number;
|
|
8
|
+
turn: number;
|
|
9
|
+
written: readonly string[];
|
|
10
|
+
deleted: readonly string[];
|
|
11
|
+
failed: readonly RestoreFailure[];
|
|
12
|
+
}
|
|
13
|
+
/** Stable command/done text rendered by every DSH command surface. */
|
|
14
|
+
export declare function formatRestoreReport(report: RestoreReport): string;
|
package/lib/format.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
function paths(heading, values) {
|
|
2
|
+
return [heading, ...(values.length === 0 ? ['- none'] : values.map(value => `- ${value}`))];
|
|
3
|
+
}
|
|
4
|
+
/** Stable command/done text rendered by every DSH command surface. */
|
|
5
|
+
export function formatRestoreReport(report) {
|
|
6
|
+
return [
|
|
7
|
+
report.seq === 0
|
|
8
|
+
? 'Restored files to session start.'
|
|
9
|
+
: `Restored files to turn/end ${report.seq} (turn ${report.turn}).`,
|
|
10
|
+
...paths(`Written (${report.written.length}):`, report.written),
|
|
11
|
+
...paths(`Deleted (${report.deleted.length}):`, report.deleted),
|
|
12
|
+
`Failed (${report.failed.length}):`,
|
|
13
|
+
...(report.failed.length === 0
|
|
14
|
+
? ['- none']
|
|
15
|
+
: report.failed.map(failure => `- ${failure.path}: ${failure.operation}: ${failure.message}`)),
|
|
16
|
+
].join('\n');
|
|
17
|
+
}
|
package/lib/index.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
export { MAX_CHECKPOINT_BYTES, RETAINED_TURNS, checkpointRecordSchema, rollerDomainSpec, } from './spec.js';
|
|
3
|
+
export type { CheckpointKey, CheckpointRecord } from './spec.js';
|
|
4
|
+
export { formatRestoreReport } from './format.js';
|
|
5
|
+
export type { RestoreFailure, RestoreReport } from './format.js';
|
|
6
|
+
export declare const name = "roller";
|
|
7
|
+
export declare const inject: string[];
|
|
8
|
+
export declare function apply(ctx: Context): Promise<void>;
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { CaptureJournal } from './capture.js';
|
|
2
|
+
import { registerRestoreCommand } from './restore.js';
|
|
3
|
+
import { rollerDomainSpec } from './spec.js';
|
|
4
|
+
import { warnIfUnsupportedDsh } from './version.js';
|
|
5
|
+
export { MAX_CHECKPOINT_BYTES, RETAINED_TURNS, checkpointRecordSchema, rollerDomainSpec, } from './spec.js';
|
|
6
|
+
export { formatRestoreReport } from './format.js';
|
|
7
|
+
export const name = 'roller';
|
|
8
|
+
export const inject = ['commands', 'fs', 'sandboxPolicy', 'sessions', 'storageDomain'];
|
|
9
|
+
export async function apply(ctx) {
|
|
10
|
+
// A real profile boot installs Loader before mounting config entries
|
|
11
|
+
// (DSH packages/boot/app-boot/src/index.ts:779-789); direct test hosts do not.
|
|
12
|
+
if (ctx.get('loader') !== undefined)
|
|
13
|
+
warnIfUnsupportedDsh();
|
|
14
|
+
const domain = await ctx.storageDomain.open(rollerDomainSpec);
|
|
15
|
+
ctx.effect(() => () => domain.close());
|
|
16
|
+
const journal = new CaptureJournal(ctx, domain.table('checkpoints'));
|
|
17
|
+
ctx.effect(() => registerRestoreCommand(ctx, domain.table('checkpoints')));
|
|
18
|
+
ctx.on('fs/write-intent', async (target, actor, next) => {
|
|
19
|
+
await journal.capture(target, actor);
|
|
20
|
+
return next();
|
|
21
|
+
}, { prepend: true });
|
|
22
|
+
ctx.on('fs/edit-intent', async (target, actor, next) => {
|
|
23
|
+
await journal.capture(target, actor);
|
|
24
|
+
return next();
|
|
25
|
+
}, { prepend: true });
|
|
26
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
import type { FsTarget } from '@deepseek-ai/dsh-fs';
|
|
3
|
+
import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox';
|
|
4
|
+
/** Host-only hard-link check for capture eligibility. */
|
|
5
|
+
export declare function hasMultipleHardLinks(path: string): Promise<boolean>;
|
|
6
|
+
/** The one host-local seam used until DSH exposes a filesystem delete primitive. */
|
|
7
|
+
export declare function deleteRestoredFile(ctx: Context, target: FsTarget, policy: SandboxExecutionPolicy, exists: boolean, signal: AbortSignal): Promise<void>;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { lstat, unlink } from 'node:fs/promises';
|
|
2
|
+
/** Host-only hard-link check for capture eligibility. */
|
|
3
|
+
export async function hasMultipleHardLinks(path) {
|
|
4
|
+
return (await lstat(path)).nlink > 1;
|
|
5
|
+
}
|
|
6
|
+
/** The one host-local seam used until DSH exposes a filesystem delete primitive. */
|
|
7
|
+
export async function deleteRestoredFile(ctx, target, policy, exists, signal) {
|
|
8
|
+
if (!exists)
|
|
9
|
+
return;
|
|
10
|
+
if (policy.mode === 'read-only') {
|
|
11
|
+
throw new Error(`cannot delete ${target.displayPath}: file access denied under read-only mode`);
|
|
12
|
+
}
|
|
13
|
+
if (policy.mode === 'workspace-write') {
|
|
14
|
+
const root = await ctx.fs.resolve(policy.workspaceRoot, { signal });
|
|
15
|
+
if (!ctx.fs.contains(root, target)) {
|
|
16
|
+
throw new Error(`cannot delete ${target.displayPath}: outside workspace-write root`);
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
const hostPath = ctx.fs.processPath(target);
|
|
20
|
+
if (ctx.fs.processPathFromHostPath(hostPath) !== hostPath) {
|
|
21
|
+
throw new Error(`cannot delete ${target.displayPath}: filesystem is not host-local`);
|
|
22
|
+
}
|
|
23
|
+
signal.throwIfAborted();
|
|
24
|
+
const info = await lstat(hostPath);
|
|
25
|
+
if (!info.isFile())
|
|
26
|
+
throw new Error(`cannot delete ${target.displayPath}: not a regular file`);
|
|
27
|
+
if (info.nlink > 1)
|
|
28
|
+
throw new Error(`cannot delete ${target.displayPath}: hard-linked file`);
|
|
29
|
+
signal.throwIfAborted();
|
|
30
|
+
await unlink(hostPath);
|
|
31
|
+
}
|
package/lib/restore.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
2
|
+
import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands';
|
|
3
|
+
import type { KvTable } from '@deepseek-ai/dsh-storage-domain';
|
|
4
|
+
import type { CheckpointKey, CheckpointRecord } from './spec.js';
|
|
5
|
+
export declare function restoreCommand(ctx: Context, checkpoints: KvTable<CheckpointKey, CheckpointRecord>, invocation: CommandInvocation): Promise<CommandResult>;
|
|
6
|
+
export declare function registerRestoreCommand(ctx: Context, checkpoints: KvTable<CheckpointKey, CheckpointRecord>): () => void;
|
package/lib/restore.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { SessionSeq } from '@deepseek-ai/dsh-session';
|
|
2
|
+
import { deleteRestoredFile } from './restore-files.js';
|
|
3
|
+
import { formatRestoreReport } from './format.js';
|
|
4
|
+
function parseSeq(rawInput) {
|
|
5
|
+
const value = rawInput.trim();
|
|
6
|
+
if (!/^\d+$/u.test(value))
|
|
7
|
+
return undefined;
|
|
8
|
+
const parsed = Number(value);
|
|
9
|
+
return Number.isSafeInteger(parsed) ? parsed : undefined;
|
|
10
|
+
}
|
|
11
|
+
function latestCompletedTurn(session) {
|
|
12
|
+
const events = session.snapshotEvents();
|
|
13
|
+
for (let index = events.length - 1; index >= 0; index -= 1) {
|
|
14
|
+
const event = events[index];
|
|
15
|
+
if (event.type === 'turn/end')
|
|
16
|
+
return event.data.turn;
|
|
17
|
+
}
|
|
18
|
+
return 0;
|
|
19
|
+
}
|
|
20
|
+
function selectedRecords(checkpoints, sessionId, targetTurn) {
|
|
21
|
+
const newestFirst = [...checkpoints.entries()]
|
|
22
|
+
.map(([, record]) => record)
|
|
23
|
+
.filter(record => record.sessionId === sessionId && record.turn > targetTurn)
|
|
24
|
+
.sort((left, right) => right.turn - left.turn);
|
|
25
|
+
const selected = new Map();
|
|
26
|
+
for (const record of newestFirst)
|
|
27
|
+
selected.set(record.targetKey, record);
|
|
28
|
+
return [...selected.values()].sort((left, right) => left.relativePath < right.relativePath ? -1 : left.relativePath > right.relativePath ? 1 : 0);
|
|
29
|
+
}
|
|
30
|
+
function decodedContent(record) {
|
|
31
|
+
if (record.before.kind !== 'text')
|
|
32
|
+
throw new Error('checkpoint does not contain text');
|
|
33
|
+
const bytes = Buffer.from(record.before.contentBase64, 'base64');
|
|
34
|
+
if (bytes.toString('base64') !== record.before.contentBase64
|
|
35
|
+
|| bytes.byteLength !== record.before.byteLength) {
|
|
36
|
+
throw new Error('checkpoint content is malformed');
|
|
37
|
+
}
|
|
38
|
+
const content = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes);
|
|
39
|
+
if (!Buffer.from(content, 'utf8').equals(bytes))
|
|
40
|
+
throw new Error('checkpoint content is not lossless UTF-8');
|
|
41
|
+
return content;
|
|
42
|
+
}
|
|
43
|
+
function failureMessage(error) {
|
|
44
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
45
|
+
return message.replaceAll(/\s+/gu, ' ').trim() || 'unknown failure';
|
|
46
|
+
}
|
|
47
|
+
function error(text) {
|
|
48
|
+
return { kind: 'error', text };
|
|
49
|
+
}
|
|
50
|
+
export async function restoreCommand(ctx, checkpoints, invocation) {
|
|
51
|
+
const value = invocation.rawInput.trim();
|
|
52
|
+
const start = value === 'start' || value === '0';
|
|
53
|
+
const seq = start ? 0 : parseSeq(value);
|
|
54
|
+
if (seq === undefined)
|
|
55
|
+
return error('Usage: /roller-restore <turn-end-seq|start|0>');
|
|
56
|
+
const current = invocation.agent.session;
|
|
57
|
+
const sourceId = current.header.parentSession ?? current.id;
|
|
58
|
+
const source = ctx.sessions.get(sourceId);
|
|
59
|
+
if (source === undefined)
|
|
60
|
+
return error(`Source session ${sourceId} is not live.`);
|
|
61
|
+
const boundary = start
|
|
62
|
+
? { type: 'turn/end', data: { turn: -1 } }
|
|
63
|
+
: source.eventAt(SessionSeq(seq));
|
|
64
|
+
if (boundary?.type !== 'turn/end')
|
|
65
|
+
return error(`No turn/end event exists at sequence ${seq}.`);
|
|
66
|
+
const latestTurn = latestCompletedTurn(source);
|
|
67
|
+
if (!start && latestTurn - boundary.data.turn > 100) {
|
|
68
|
+
return error(`Turn ${boundary.data.turn} is outside roller's 100-turn retention window.`);
|
|
69
|
+
}
|
|
70
|
+
const cwd = current.header.cwd;
|
|
71
|
+
if (cwd === undefined)
|
|
72
|
+
return error('The current session has no workspace cwd.');
|
|
73
|
+
const records = selectedRecords(checkpoints, String(sourceId), boundary.data.turn);
|
|
74
|
+
const policy = ctx.sandboxPolicy.resolve({ session: current });
|
|
75
|
+
const written = [];
|
|
76
|
+
const deleted = [];
|
|
77
|
+
const failed = [];
|
|
78
|
+
const cwdTarget = await ctx.fs.resolve(cwd, { signal: invocation.signal });
|
|
79
|
+
for (const record of records) {
|
|
80
|
+
const operation = record.before.kind === 'text' ? 'write' : 'delete';
|
|
81
|
+
try {
|
|
82
|
+
const target = await ctx.fs.resolve(record.relativePath, { cwd, signal: invocation.signal });
|
|
83
|
+
if (!ctx.fs.contains(cwdTarget, target))
|
|
84
|
+
throw new Error('path is outside the session cwd');
|
|
85
|
+
const pathInfo = await ctx.fs.lstat(record.relativePath, { cwd }, invocation.signal);
|
|
86
|
+
if (pathInfo?.type === 'symlink')
|
|
87
|
+
throw new Error('refusing to restore a symbolic link');
|
|
88
|
+
if (pathInfo !== undefined && pathInfo.type !== 'file')
|
|
89
|
+
throw new Error('path is not a regular file');
|
|
90
|
+
if (record.before.kind === 'text') {
|
|
91
|
+
await ctx.fs.writeText(target, decodedContent(record), undefined, invocation.signal, policy);
|
|
92
|
+
written.push(record.relativePath);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
await deleteRestoredFile(ctx, target, policy, pathInfo !== undefined, invocation.signal);
|
|
96
|
+
deleted.push(record.relativePath);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
catch (pathError) {
|
|
100
|
+
failed.push({
|
|
101
|
+
path: record.relativePath,
|
|
102
|
+
operation,
|
|
103
|
+
message: failureMessage(pathError),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return {
|
|
108
|
+
kind: 'success',
|
|
109
|
+
text: formatRestoreReport({ seq, turn: start ? 0 : boundary.data.turn, written, deleted, failed }),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
export function registerRestoreCommand(ctx, checkpoints) {
|
|
113
|
+
return ctx.commands.register({
|
|
114
|
+
name: 'roller-restore',
|
|
115
|
+
description: 'Restore files to a completed turn boundary or session start',
|
|
116
|
+
input: { hint: '<turn-end-seq|start|0>' },
|
|
117
|
+
handler: invocation => restoreCommand(ctx, checkpoints, invocation),
|
|
118
|
+
});
|
|
119
|
+
}
|
package/lib/spec.d.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
export declare const MAX_CHECKPOINT_BYTES = 1048576;
|
|
3
|
+
export declare const RETAINED_TURNS = 100;
|
|
4
|
+
export type CheckpointKey = string & {
|
|
5
|
+
readonly __checkpointKey: unique symbol;
|
|
6
|
+
};
|
|
7
|
+
export declare const checkpointRecordSchema: z.ZodObject<{
|
|
8
|
+
sessionId: z.ZodString;
|
|
9
|
+
turn: z.ZodNumber;
|
|
10
|
+
targetKey: z.ZodString;
|
|
11
|
+
relativePath: z.ZodString;
|
|
12
|
+
displayPath: z.ZodString;
|
|
13
|
+
before: z.ZodDiscriminatedUnion<[z.ZodObject<{
|
|
14
|
+
kind: z.ZodLiteral<"absent">;
|
|
15
|
+
}, z.core.$strip>, z.ZodObject<{
|
|
16
|
+
kind: z.ZodLiteral<"text">;
|
|
17
|
+
contentBase64: z.ZodString;
|
|
18
|
+
byteLength: z.ZodNumber;
|
|
19
|
+
}, z.core.$strip>], "kind">;
|
|
20
|
+
}, z.core.$strip>;
|
|
21
|
+
export type CheckpointRecord = z.infer<typeof checkpointRecordSchema>;
|
|
22
|
+
export declare const rollerDomainSpec: {
|
|
23
|
+
name: string;
|
|
24
|
+
version: number;
|
|
25
|
+
layout: "per-record";
|
|
26
|
+
tables: {
|
|
27
|
+
checkpoints: import("@deepseek-ai/dsh-storage-domain").DomainTableSpec<CheckpointKey, {
|
|
28
|
+
sessionId: string;
|
|
29
|
+
turn: number;
|
|
30
|
+
targetKey: string;
|
|
31
|
+
relativePath: string;
|
|
32
|
+
displayPath: string;
|
|
33
|
+
before: {
|
|
34
|
+
kind: "absent";
|
|
35
|
+
} | {
|
|
36
|
+
kind: "text";
|
|
37
|
+
contentBase64: string;
|
|
38
|
+
byteLength: number;
|
|
39
|
+
};
|
|
40
|
+
}>;
|
|
41
|
+
};
|
|
42
|
+
};
|
package/lib/spec.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain';
|
|
3
|
+
export const MAX_CHECKPOINT_BYTES = 1_048_576;
|
|
4
|
+
export const RETAINED_TURNS = 100;
|
|
5
|
+
export const checkpointRecordSchema = z.object({
|
|
6
|
+
sessionId: z.string(),
|
|
7
|
+
turn: z.number().int().nonnegative(),
|
|
8
|
+
targetKey: z.string(),
|
|
9
|
+
relativePath: z.string(),
|
|
10
|
+
displayPath: z.string(),
|
|
11
|
+
before: z.discriminatedUnion('kind', [
|
|
12
|
+
z.object({ kind: z.literal('absent') }),
|
|
13
|
+
z.object({
|
|
14
|
+
kind: z.literal('text'),
|
|
15
|
+
contentBase64: z.string(),
|
|
16
|
+
byteLength: z.number().int().nonnegative().max(MAX_CHECKPOINT_BYTES),
|
|
17
|
+
}),
|
|
18
|
+
]),
|
|
19
|
+
});
|
|
20
|
+
export const rollerDomainSpec = defineDomain({
|
|
21
|
+
name: 'roller',
|
|
22
|
+
version: 1,
|
|
23
|
+
layout: 'per-record',
|
|
24
|
+
tables: {
|
|
25
|
+
checkpoints: domainTable(checkpointRecordSchema),
|
|
26
|
+
},
|
|
27
|
+
});
|
package/lib/version.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function warnIfUnsupportedDsh(): void;
|
package/lib/version.js
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { dirname, resolve } from 'node:path';
|
|
3
|
+
const validatedVersions = JSON.parse(readFileSync(new URL('../validated-dsh-versions.json', import.meta.url), 'utf8'));
|
|
4
|
+
function runningDshVersion() {
|
|
5
|
+
try {
|
|
6
|
+
const entry = realpathSync(process.argv[1] ?? '');
|
|
7
|
+
const manifest = JSON.parse(readFileSync(resolve(dirname(entry), '../package.json'), 'utf8'));
|
|
8
|
+
return typeof manifest.version === 'string' ? manifest.version : 'unknown';
|
|
9
|
+
}
|
|
10
|
+
catch {
|
|
11
|
+
return 'unknown';
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
export function warnIfUnsupportedDsh() {
|
|
15
|
+
const version = runningDshVersion();
|
|
16
|
+
if (!validatedVersions.includes(version)) {
|
|
17
|
+
process.stderr.write(`roller: warning: DSH ${version} is not validated; validated: ${validatedVersions.join(', ')}\n`);
|
|
18
|
+
}
|
|
19
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@antst/roller",
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "File rewind for DeepSeek Harness",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "https://github.com/antst/roller",
|
|
11
|
+
"directory": "packages/roller"
|
|
12
|
+
},
|
|
13
|
+
"homepage": "https://github.com/antst/roller#readme",
|
|
14
|
+
"bugs": {
|
|
15
|
+
"url": "https://github.com/antst/roller/issues"
|
|
16
|
+
},
|
|
17
|
+
"type": "module",
|
|
18
|
+
"main": "lib/index.js",
|
|
19
|
+
"types": "lib/index.d.ts",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./lib/index.d.ts",
|
|
23
|
+
"default": "./lib/index.js"
|
|
24
|
+
},
|
|
25
|
+
"./cordis.patch.yml": "./cordis.patch.yml"
|
|
26
|
+
},
|
|
27
|
+
"files": [
|
|
28
|
+
"lib/**/*.js",
|
|
29
|
+
"lib/**/*.d.ts",
|
|
30
|
+
"cordis.patch.yml",
|
|
31
|
+
"validated-dsh-versions.json"
|
|
32
|
+
],
|
|
33
|
+
"license": "MIT",
|
|
34
|
+
"dsh": {
|
|
35
|
+
"bundle": {
|
|
36
|
+
"patch": "./cordis.patch.yml"
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
41
|
+
},
|
|
42
|
+
"dependencies": {
|
|
43
|
+
"zod": "4.4.3"
|
|
44
|
+
},
|
|
45
|
+
"peerDependencies": {
|
|
46
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
47
|
+
"@deepseek-ai/dsh-commands": "0.1.2-rc.1",
|
|
48
|
+
"@deepseek-ai/dsh-fs": "0.1.2-rc.1",
|
|
49
|
+
"@deepseek-ai/dsh-sandbox": "0.1.2-rc.1",
|
|
50
|
+
"@deepseek-ai/dsh-sandbox-policy": "0.1.2-rc.1",
|
|
51
|
+
"@deepseek-ai/dsh-session": "0.1.2-rc.1",
|
|
52
|
+
"@deepseek-ai/dsh-storage-domain": "0.1.2-rc.1"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@deepseek-ai/cordis": "4.0.2",
|
|
56
|
+
"@deepseek-ai/dsh-agent": "0.1.2-rc.1",
|
|
57
|
+
"@deepseek-ai/dsh-agent-loop": "0.1.2-rc.1",
|
|
58
|
+
"@deepseek-ai/dsh-agent-loop-testkit": "0.1.2-rc.1",
|
|
59
|
+
"@deepseek-ai/dsh-commands": "0.1.2-rc.1",
|
|
60
|
+
"@deepseek-ai/dsh-fs": "0.1.2-rc.1",
|
|
61
|
+
"@deepseek-ai/dsh-fs-local": "0.1.2-rc.1",
|
|
62
|
+
"@deepseek-ai/dsh-fs-observation-policy": "0.1.2-rc.1",
|
|
63
|
+
"@deepseek-ai/dsh-fs-sandbox": "0.1.2-rc.1",
|
|
64
|
+
"@deepseek-ai/dsh-llm": "0.1.2-rc.1",
|
|
65
|
+
"@deepseek-ai/dsh-sandbox": "0.1.2-rc.1",
|
|
66
|
+
"@deepseek-ai/dsh-sandbox-policy": "0.1.2-rc.1",
|
|
67
|
+
"@deepseek-ai/dsh-session": "0.1.2-rc.1",
|
|
68
|
+
"@deepseek-ai/dsh-session-projection": "0.1.2-rc.1",
|
|
69
|
+
"@deepseek-ai/dsh-storage": "0.1.2-rc.1",
|
|
70
|
+
"@deepseek-ai/dsh-storage-domain": "0.1.2-rc.1",
|
|
71
|
+
"@deepseek-ai/dsh-storage-json": "0.1.2-rc.1",
|
|
72
|
+
"@deepseek-ai/dsh-system-prompt": "0.1.2-rc.1",
|
|
73
|
+
"@deepseek-ai/dsh-tool-fs": "0.1.2-rc.1",
|
|
74
|
+
"@deepseek-ai/dsh-tool-str-replace-editor": "0.1.2-rc.1",
|
|
75
|
+
"@deepseek-ai/dsh-tools": "0.1.2-rc.1"
|
|
76
|
+
}
|
|
77
|
+
}
|