@anionex/dsh-turn-rewind 0.1.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/LICENSE +28 -0
- package/README.md +182 -0
- package/README.zh.md +180 -0
- package/SECURITY.md +53 -0
- package/cordis.patch.yml +4 -0
- package/docs/FORMAT.md +101 -0
- package/docs/IMPLEMENTATION_PLAN.md +56 -0
- package/docs/assets/turn-rewind-action.png +0 -0
- package/docs/assets/turn-rewind-dialog.png +0 -0
- package/lib/client.js +473 -0
- package/lib/client.js.map +1 -0
- package/lib/engine.js +616 -0
- package/lib/errors.js +20 -0
- package/lib/git.js +155 -0
- package/lib/index.js +71 -0
- package/lib/path-utils.js +262 -0
- package/lib/rewind-host.js +421 -0
- package/lib/snapshot.js +183 -0
- package/lib/store.js +391 -0
- package/lib/types/client/index.d.ts +81 -0
- package/lib/types/engine.d.ts +90 -0
- package/lib/types/errors.d.ts +15 -0
- package/lib/types/git.d.ts +14 -0
- package/lib/types/index.d.ts +43 -0
- package/lib/types/path-utils.d.ts +34 -0
- package/lib/types/rewind-host.d.ts +137 -0
- package/lib/types/snapshot.d.ts +35 -0
- package/lib/types/store.d.ts +39 -0
- package/lib/types/types.d.ts +177 -0
- package/lib/types/validate.d.ts +11 -0
- package/lib/types.js +2 -0
- package/lib/validate.js +242 -0
- package/package.json +91 -0
- package/src/client/index.tsx +648 -0
- package/src/client/platform.d.ts +27 -0
- package/src/engine.ts +770 -0
- package/src/errors.ts +23 -0
- package/src/git.ts +178 -0
- package/src/index.ts +100 -0
- package/src/path-utils.ts +255 -0
- package/src/rewind-host.ts +584 -0
- package/src/snapshot.ts +229 -0
- package/src/store.ts +383 -0
- package/src/types.ts +195 -0
- package/src/validate.ts +245 -0
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { ChangeLedgerError, errorMessage } from './errors.js';
|
|
3
|
+
import { discoverRepositoryRoot } from './git.js';
|
|
4
|
+
export const REWIND_HTTP_PATH = '/turn-rewind';
|
|
5
|
+
const BODY_LIMIT = 64 * 1024;
|
|
6
|
+
const INITIAL_CHANGE_PREVIEW_LIMIT = 8;
|
|
7
|
+
const MAX_CHANGE_PAGE_SIZE = 200;
|
|
8
|
+
/** Capture each turn before its opening user message can trigger model or tool work. */
|
|
9
|
+
export class TurnCheckpointCoordinator {
|
|
10
|
+
engine;
|
|
11
|
+
captures = new Map();
|
|
12
|
+
pending = new Set();
|
|
13
|
+
failures = new Map();
|
|
14
|
+
workspaceTails = new Map();
|
|
15
|
+
constructor(engine) {
|
|
16
|
+
this.engine = engine;
|
|
17
|
+
}
|
|
18
|
+
/** Install the first-step gate; checkpoint failures are recorded but never reject the user turn. */
|
|
19
|
+
install(ctx) {
|
|
20
|
+
ctx.on('agent/pre-step', async ({ agent, turn, step, signal }, next) => {
|
|
21
|
+
if (step === 1)
|
|
22
|
+
await this.capture(ctx, agent, turn, signal);
|
|
23
|
+
return next();
|
|
24
|
+
}, { prepend: true });
|
|
25
|
+
}
|
|
26
|
+
/** Current capture state for a session turn when no durable checkpoint exists yet. */
|
|
27
|
+
state(sessionId, turn) {
|
|
28
|
+
const key = checkpointKey(sessionId, turn);
|
|
29
|
+
if (this.pending.has(key))
|
|
30
|
+
return { status: 'pending' };
|
|
31
|
+
const error = this.failures.get(key);
|
|
32
|
+
return error === undefined ? { status: 'missing' } : { status: 'failed', error };
|
|
33
|
+
}
|
|
34
|
+
async capture(ctx, agent, turn, signal) {
|
|
35
|
+
const key = checkpointKey(agent.id, turn);
|
|
36
|
+
const existing = this.captures.get(key);
|
|
37
|
+
if (existing !== undefined) {
|
|
38
|
+
await existing;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const cwd = agent.session.header.cwd;
|
|
42
|
+
if (cwd === undefined)
|
|
43
|
+
return;
|
|
44
|
+
const start = agent.session.events.findLast(event => event.type === 'turn/start' && event.data.turn === turn);
|
|
45
|
+
if (start === undefined) {
|
|
46
|
+
this.recordFailure(ctx, agent.id, turn, new Error('turn/start is unavailable before the first step'));
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
this.pending.add(key);
|
|
50
|
+
this.failures.delete(key);
|
|
51
|
+
const capture = (async () => {
|
|
52
|
+
try {
|
|
53
|
+
const workspace = await discoverRepositoryRoot(cwd, signal);
|
|
54
|
+
await this.serializeWorkspace(workspace, signal, async () => {
|
|
55
|
+
await this.engine.createTurnCheckpoint({
|
|
56
|
+
cwd: workspace,
|
|
57
|
+
sessionId: agent.id,
|
|
58
|
+
turn,
|
|
59
|
+
turnStartSeq: start.seq,
|
|
60
|
+
signal,
|
|
61
|
+
});
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
this.recordFailure(ctx, agent.id, turn, error);
|
|
66
|
+
}
|
|
67
|
+
finally {
|
|
68
|
+
this.pending.delete(key);
|
|
69
|
+
}
|
|
70
|
+
})();
|
|
71
|
+
this.captures.set(key, capture);
|
|
72
|
+
await capture;
|
|
73
|
+
}
|
|
74
|
+
async serializeWorkspace(workspace, signal, task) {
|
|
75
|
+
const previous = this.workspaceTails.get(workspace) ?? Promise.resolve();
|
|
76
|
+
const current = previous.catch(() => undefined).then(async () => {
|
|
77
|
+
signal.throwIfAborted();
|
|
78
|
+
await task();
|
|
79
|
+
});
|
|
80
|
+
this.workspaceTails.set(workspace, current);
|
|
81
|
+
try {
|
|
82
|
+
await current;
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
if (this.workspaceTails.get(workspace) === current)
|
|
86
|
+
this.workspaceTails.delete(workspace);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
recordFailure(ctx, sessionId, turn, error) {
|
|
90
|
+
const message = errorMessage(error);
|
|
91
|
+
this.failures.set(checkpointKey(sessionId, turn), message);
|
|
92
|
+
ctx.logger.warn(`[turn-rewind] checkpoint failed for ${sessionId} turn ${String(turn)}: ${message}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
/** Register the same-origin preview/apply endpoint consumed by the browser half. */
|
|
96
|
+
export function installRewindHttp(ctx, engine, coordinator) {
|
|
97
|
+
ctx.effect(() => ctx.webServer.register({
|
|
98
|
+
kind: 'exact',
|
|
99
|
+
path: REWIND_HTTP_PATH,
|
|
100
|
+
handler: createRewindHttpHandler(ctx, engine, coordinator),
|
|
101
|
+
}), 'change-ledger.rewindHttp');
|
|
102
|
+
}
|
|
103
|
+
/** Build the exact-route handler as a testable unit. */
|
|
104
|
+
export function createRewindHttpHandler(ctx, engine, coordinator) {
|
|
105
|
+
return async (request, response) => {
|
|
106
|
+
try {
|
|
107
|
+
if (request.method === 'GET') {
|
|
108
|
+
const url = new URL(request.url ?? REWIND_HTTP_PATH, 'http://dsh.local');
|
|
109
|
+
const sessionId = requiredText(url.searchParams.get('sessionId'), 'sessionId');
|
|
110
|
+
const messageSeq = nonNegativeInteger(url.searchParams.get('messageSeq'), 'messageSeq');
|
|
111
|
+
const detailsOnly = url.searchParams.get('details') === '1';
|
|
112
|
+
const offset = nonNegativeInteger(url.searchParams.get('offset') ?? '0', 'offset');
|
|
113
|
+
const limit = pageSize(url.searchParams.get('limit'), detailsOnly ? MAX_CHANGE_PAGE_SIZE : INITIAL_CHANGE_PREVIEW_LIMIT);
|
|
114
|
+
const { target, checkpoint } = await resolveMessageCheckpoint(ctx, engine, sessionId, messageSeq);
|
|
115
|
+
if (checkpoint === undefined) {
|
|
116
|
+
json(response, 200, coordinator.state(sessionId, target.turn));
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const inspection = await engine.inspect({ cwd: checkpoint.cwd, restorePointId: checkpoint.id });
|
|
120
|
+
const activeSessionIds = await sharedWorkspaceSessions(ctx.agents, checkpoint.cwd);
|
|
121
|
+
const changes = inspection.changes.slice(offset, offset + limit);
|
|
122
|
+
const common = {
|
|
123
|
+
status: 'ready', sessionId, messageSeq, turn: checkpoint.turn, checkpointId: checkpoint.id,
|
|
124
|
+
turnStartSeq: checkpoint.turnStartSeq,
|
|
125
|
+
totalChanges: inspection.changes.length,
|
|
126
|
+
changes: changes.map(change => ({ path: change.path, kind: change.kind })),
|
|
127
|
+
offset,
|
|
128
|
+
truncated: offset + changes.length < inspection.changes.length,
|
|
129
|
+
headChanged: inspection.headChanged,
|
|
130
|
+
operationChanged: inspection.operationChanged,
|
|
131
|
+
checkpointHead: inspection.restorePoint.head,
|
|
132
|
+
checkpointBranch: inspection.restorePoint.branch,
|
|
133
|
+
checkpointOperation: inspection.restorePoint.operation,
|
|
134
|
+
currentHead: inspection.currentHead,
|
|
135
|
+
currentBranch: inspection.currentBranch,
|
|
136
|
+
currentOperation: inspection.currentOperation,
|
|
137
|
+
activeSessionIds,
|
|
138
|
+
restoreBlocked: activeSessionIds.length > 0 || inspection.operationChanged,
|
|
139
|
+
};
|
|
140
|
+
if (inspection.changes.length === 0) {
|
|
141
|
+
json(response, 200, common);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (detailsOnly || activeSessionIds.length > 0 || inspection.operationChanged) {
|
|
145
|
+
json(response, 200, common);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const plan = await engine.planRestore({
|
|
149
|
+
cwd: checkpoint.cwd,
|
|
150
|
+
restorePointId: checkpoint.id,
|
|
151
|
+
sessionId,
|
|
152
|
+
allowHeadChange: inspection.headChanged,
|
|
153
|
+
expectedCurrentTreeHash: inspection.currentTreeHash,
|
|
154
|
+
expectedRepository: inspection.currentRepository,
|
|
155
|
+
});
|
|
156
|
+
json(response, 200, { ...common, planId: plan.id, confirmation: plan.confirmation });
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
if (request.method === 'POST') {
|
|
160
|
+
const body = objectBody(await readBody(request));
|
|
161
|
+
const mode = body.mode;
|
|
162
|
+
if (mode !== 'code' && mode !== 'both') {
|
|
163
|
+
throw new ChangeLedgerError('INVALID_ARGUMENTS', 'mode must be "code" or "both"');
|
|
164
|
+
}
|
|
165
|
+
const sessionId = requiredText(body.sessionId, 'sessionId');
|
|
166
|
+
const messageSeq = nonNegativeInteger(body.messageSeq, 'messageSeq');
|
|
167
|
+
const checkpointId = requiredText(body.checkpointId, 'checkpointId');
|
|
168
|
+
const checkpoint = await checkpointForRequest(ctx, engine, sessionId, messageSeq, checkpointId);
|
|
169
|
+
const activeSessionIds = await sharedWorkspaceSessions(ctx.agents, checkpoint.cwd);
|
|
170
|
+
if (activeSessionIds.length > 0) {
|
|
171
|
+
throw new ChangeLedgerError('WORKSPACE_IN_USE', `project files are also used by active sessions: ${activeSessionIds.slice(0, 5).join(', ')}`);
|
|
172
|
+
}
|
|
173
|
+
const planId = optionalText(body.planId, 'planId');
|
|
174
|
+
const confirmation = optionalText(body.confirmation, 'confirmation');
|
|
175
|
+
if (planId === undefined || confirmation === undefined) {
|
|
176
|
+
throw new ChangeLedgerError('NO_CHANGES', 'the selected turn has no project files to restore');
|
|
177
|
+
}
|
|
178
|
+
const restoreResult = await engine.applyRestore({ planId, confirmation, sessionId });
|
|
179
|
+
if (mode === 'code') {
|
|
180
|
+
json(response, 200, { status: 'completed', mode, ...restoreResult });
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
try {
|
|
184
|
+
const fork = await createConversationRestart(ctx, sessionId, checkpoint);
|
|
185
|
+
json(response, 200, { status: 'completed', mode, sessionId: fork.sessionId, ...restoreResult });
|
|
186
|
+
}
|
|
187
|
+
catch (forkError) {
|
|
188
|
+
if (restoreResult === undefined)
|
|
189
|
+
throw forkError;
|
|
190
|
+
try {
|
|
191
|
+
const rollbackPlan = await engine.planRestore({
|
|
192
|
+
cwd: checkpoint.cwd,
|
|
193
|
+
restorePointId: restoreResult.rescuePointId,
|
|
194
|
+
sessionId,
|
|
195
|
+
});
|
|
196
|
+
await engine.applyRestore({
|
|
197
|
+
planId: rollbackPlan.id,
|
|
198
|
+
confirmation: rollbackPlan.confirmation,
|
|
199
|
+
sessionId,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
catch (rollbackError) {
|
|
203
|
+
throw new AggregateError([forkError, rollbackError], 'conversation fork failed and code compensation also failed');
|
|
204
|
+
}
|
|
205
|
+
throw new ChangeLedgerError('RESTORE_FAILED_ROLLED_BACK', `conversation fork failed; code was recovered from ${restoreResult.rescuePointId}: ${errorMessage(forkError)}`, { cause: forkError });
|
|
206
|
+
}
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
json(response, 405, { error: 'method not allowed' });
|
|
210
|
+
}
|
|
211
|
+
catch (error) {
|
|
212
|
+
const status = error instanceof ChangeLedgerError && error.code === 'RESTORE_POINT_NOT_FOUND' ? 404 : 409;
|
|
213
|
+
json(response, status, { error: errorMessage(error), code: error instanceof ChangeLedgerError ? error.code : 'REWIND_FAILED' });
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
async function readSession(ctx, sessionId) {
|
|
218
|
+
const live = ctx.sessions.get(sessionId);
|
|
219
|
+
if (live !== undefined)
|
|
220
|
+
return live;
|
|
221
|
+
const stored = await ctx.sessionQuery.readSession(sessionId);
|
|
222
|
+
return { id: sessionId, header: stored.session, events: stored.events };
|
|
223
|
+
}
|
|
224
|
+
async function resolveMessageCheckpoint(ctx, engine, sessionId, messageSeq) {
|
|
225
|
+
let current = await readSession(ctx, sessionId);
|
|
226
|
+
const target = messageTarget(current, messageSeq);
|
|
227
|
+
const direct = await engine.findTurnCheckpoint({ cwd: target.cwd, sessionId, turn: target.turn });
|
|
228
|
+
if (direct !== undefined) {
|
|
229
|
+
if (direct.turnStartSeq !== target.turnStartSeq) {
|
|
230
|
+
throw new ChangeLedgerError('PLAN_STALE', 'the message checkpoint no longer matches its turn start');
|
|
231
|
+
}
|
|
232
|
+
return { target, checkpoint: { ...target, id: direct.id } };
|
|
233
|
+
}
|
|
234
|
+
const seen = new Set([sessionId]);
|
|
235
|
+
while (true) {
|
|
236
|
+
const parentId = current.header.parentSession;
|
|
237
|
+
const seedLength = current.header.seedLength;
|
|
238
|
+
if ((parentId === undefined) !== (seedLength === undefined)) {
|
|
239
|
+
throw new ChangeLedgerError('PLAN_STALE', 'session fork lineage has incomplete parent metadata');
|
|
240
|
+
}
|
|
241
|
+
if (parentId === undefined || seedLength === undefined
|
|
242
|
+
|| target.messageSeq >= seedLength || target.turnStartSeq >= seedLength) {
|
|
243
|
+
return { target };
|
|
244
|
+
}
|
|
245
|
+
if (seen.has(parentId)) {
|
|
246
|
+
throw new ChangeLedgerError('PLAN_STALE', 'session fork lineage contains a cycle');
|
|
247
|
+
}
|
|
248
|
+
seen.add(parentId);
|
|
249
|
+
try {
|
|
250
|
+
current = await readSession(ctx, parentId);
|
|
251
|
+
}
|
|
252
|
+
catch (error) {
|
|
253
|
+
throw new ChangeLedgerError('PLAN_STALE', `parent session ${parentId} is unavailable`, { cause: error });
|
|
254
|
+
}
|
|
255
|
+
const parentTarget = messageTarget(current, messageSeq);
|
|
256
|
+
if (parentTarget.turn !== target.turn
|
|
257
|
+
|| parentTarget.turnStartSeq !== target.turnStartSeq
|
|
258
|
+
|| parentTarget.previousTurnEndSeq !== target.previousTurnEndSeq) {
|
|
259
|
+
throw new ChangeLedgerError('PLAN_STALE', 'session fork lineage no longer matches the inherited message boundary');
|
|
260
|
+
}
|
|
261
|
+
const inherited = await engine.findTurnCheckpoint({ cwd: target.cwd, sessionId: parentId, turn: target.turn });
|
|
262
|
+
if (inherited === undefined)
|
|
263
|
+
continue;
|
|
264
|
+
if (inherited.turnStartSeq !== target.turnStartSeq) {
|
|
265
|
+
throw new ChangeLedgerError('PLAN_STALE', 'the inherited message checkpoint no longer matches the fork boundary');
|
|
266
|
+
}
|
|
267
|
+
return { target, checkpoint: { ...target, id: inherited.id } };
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
async function checkpointForRequest(ctx, engine, sessionId, messageSeq, requestedId) {
|
|
271
|
+
const { target, checkpoint } = await resolveMessageCheckpoint(ctx, engine, sessionId, messageSeq);
|
|
272
|
+
if (checkpoint === undefined) {
|
|
273
|
+
throw new ChangeLedgerError('RESTORE_POINT_NOT_FOUND', `message ${String(target.messageSeq)} has no rewind checkpoint`);
|
|
274
|
+
}
|
|
275
|
+
if (requestedId !== checkpoint.id) {
|
|
276
|
+
throw new ChangeLedgerError('PLAN_STALE', 'the selected message checkpoint changed; reopen the rewind dialog');
|
|
277
|
+
}
|
|
278
|
+
return checkpoint;
|
|
279
|
+
}
|
|
280
|
+
async function createConversationRestart(ctx, sourceId, checkpoint) {
|
|
281
|
+
const source = await readSession(ctx, sourceId);
|
|
282
|
+
const current = messageTarget(source, checkpoint.messageSeq);
|
|
283
|
+
if (current.turn !== checkpoint.turn
|
|
284
|
+
|| current.turnStartSeq !== checkpoint.turnStartSeq
|
|
285
|
+
|| current.previousTurnEndSeq !== checkpoint.previousTurnEndSeq) {
|
|
286
|
+
throw new ChangeLedgerError('PLAN_STALE', 'the session no longer contains the selected message boundary');
|
|
287
|
+
}
|
|
288
|
+
const response = checkpoint.previousTurnEndSeq === undefined
|
|
289
|
+
? await ctx.apiProxy.sessions.create({
|
|
290
|
+
rpcId: randomUUID(),
|
|
291
|
+
payload: { cwd: checkpoint.cwd },
|
|
292
|
+
})
|
|
293
|
+
: await ctx.apiProxy.sessions.fork({
|
|
294
|
+
rpcId: randomUUID(),
|
|
295
|
+
payload: { sessionId: sourceId, atSeq: checkpoint.previousTurnEndSeq },
|
|
296
|
+
});
|
|
297
|
+
if (!response.result.ok) {
|
|
298
|
+
throw new ChangeLedgerError('CONVERSATION_REWIND_FAILED', response.result.error.message);
|
|
299
|
+
}
|
|
300
|
+
return { sessionId: requiredText(response.result.value.sessionId, 'fork sessionId') };
|
|
301
|
+
}
|
|
302
|
+
function messageTarget(session, messageSeq) {
|
|
303
|
+
const cwd = session.header.cwd;
|
|
304
|
+
if (cwd === undefined)
|
|
305
|
+
throw new ChangeLedgerError('WORKSPACE_REQUIRED', `session ${session.id} has no workspace`);
|
|
306
|
+
const message = session.events.find(event => event.type === 'user/message' && event.seq === messageSeq && isDirectUserMessage(event));
|
|
307
|
+
if (message === undefined) {
|
|
308
|
+
throw new ChangeLedgerError('RESTORE_POINT_NOT_FOUND', `session ${session.id} has no user message at ${String(messageSeq)}`);
|
|
309
|
+
}
|
|
310
|
+
const start = session.events.findLast(event => event.type === 'turn/start' && event.seq < messageSeq);
|
|
311
|
+
const turn = start?.data.turn;
|
|
312
|
+
if (start === undefined || !Number.isSafeInteger(turn) || turn < 0) {
|
|
313
|
+
throw new ChangeLedgerError('PLAN_STALE', 'the selected user message has no valid turn start');
|
|
314
|
+
}
|
|
315
|
+
const openingMessage = session.events.find(event => (event.type === 'user/message'
|
|
316
|
+
&& event.seq > start.seq
|
|
317
|
+
&& event.seq <= messageSeq
|
|
318
|
+
&& isDirectUserMessage(event)));
|
|
319
|
+
if (openingMessage?.seq !== messageSeq) {
|
|
320
|
+
throw new ChangeLedgerError('RESTORE_POINT_NOT_FOUND', 'rewind is available only for the opening user message of a turn');
|
|
321
|
+
}
|
|
322
|
+
const interveningEnd = session.events.find(event => event.type === 'turn/end' && event.seq > start.seq && event.seq < messageSeq);
|
|
323
|
+
if (interveningEnd !== undefined) {
|
|
324
|
+
throw new ChangeLedgerError('PLAN_STALE', 'the selected user message is outside its recorded turn');
|
|
325
|
+
}
|
|
326
|
+
const previousEnd = session.events.findLast(event => event.type === 'turn/end' && event.seq < start.seq);
|
|
327
|
+
return {
|
|
328
|
+
messageSeq,
|
|
329
|
+
turn: turn,
|
|
330
|
+
turnStartSeq: start.seq,
|
|
331
|
+
...(previousEnd === undefined ? {} : { previousTurnEndSeq: previousEnd.seq }),
|
|
332
|
+
cwd,
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
function isDirectUserMessage(event) {
|
|
336
|
+
const source = event.data.source;
|
|
337
|
+
return source !== null && typeof source === 'object' && !Array.isArray(source)
|
|
338
|
+
&& source.kind === 'user';
|
|
339
|
+
}
|
|
340
|
+
function checkpointKey(sessionId, turn) {
|
|
341
|
+
return `${sessionId}\0${String(turn)}`;
|
|
342
|
+
}
|
|
343
|
+
function requiredText(value, name) {
|
|
344
|
+
if (typeof value !== 'string' || value === '')
|
|
345
|
+
throw new ChangeLedgerError('INVALID_ARGUMENTS', `${name} must be a non-empty string`);
|
|
346
|
+
return value;
|
|
347
|
+
}
|
|
348
|
+
function optionalText(value, name) {
|
|
349
|
+
return value === undefined ? undefined : requiredText(value, name);
|
|
350
|
+
}
|
|
351
|
+
function nonNegativeInteger(value, name) {
|
|
352
|
+
const parsed = typeof value === 'string' && /^\d+$/.test(value) ? Number(value) : value;
|
|
353
|
+
if (!Number.isSafeInteger(parsed) || parsed < 0) {
|
|
354
|
+
throw new ChangeLedgerError('INVALID_ARGUMENTS', `${name} must be a non-negative safe integer`);
|
|
355
|
+
}
|
|
356
|
+
return parsed;
|
|
357
|
+
}
|
|
358
|
+
function pageSize(value, fallback) {
|
|
359
|
+
if (value === null || value === undefined)
|
|
360
|
+
return fallback;
|
|
361
|
+
const parsed = nonNegativeInteger(value, 'limit');
|
|
362
|
+
if (parsed < 1 || parsed > MAX_CHANGE_PAGE_SIZE) {
|
|
363
|
+
throw new ChangeLedgerError('INVALID_ARGUMENTS', `limit must be between 1 and ${String(MAX_CHANGE_PAGE_SIZE)}`);
|
|
364
|
+
}
|
|
365
|
+
return parsed;
|
|
366
|
+
}
|
|
367
|
+
async function sharedWorkspaceSessions(agents, cwd) {
|
|
368
|
+
const listed = agents?.list() ?? [];
|
|
369
|
+
if (listed.length === 0)
|
|
370
|
+
return [];
|
|
371
|
+
const root = await discoverRepositoryRoot(cwd);
|
|
372
|
+
const shared = [];
|
|
373
|
+
for (const agent of listed) {
|
|
374
|
+
if (agent.status !== 'running')
|
|
375
|
+
continue;
|
|
376
|
+
const session = agent.session;
|
|
377
|
+
if (session.header.cwd === undefined)
|
|
378
|
+
continue;
|
|
379
|
+
try {
|
|
380
|
+
if (await discoverRepositoryRoot(session.header.cwd) === root)
|
|
381
|
+
shared.push(session.id);
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
// A live Session whose cwd is not a valid Git worktree cannot share this worktree.
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return shared.sort();
|
|
388
|
+
}
|
|
389
|
+
function objectBody(value) {
|
|
390
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
|
|
391
|
+
throw new ChangeLedgerError('INVALID_ARGUMENTS', 'request body must be an object');
|
|
392
|
+
}
|
|
393
|
+
return value;
|
|
394
|
+
}
|
|
395
|
+
async function readBody(request) {
|
|
396
|
+
const chunks = [];
|
|
397
|
+
let size = 0;
|
|
398
|
+
await new Promise((resolve, reject) => {
|
|
399
|
+
request.on('data', (chunk) => {
|
|
400
|
+
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
401
|
+
size += bytes.length;
|
|
402
|
+
if (size > BODY_LIMIT) {
|
|
403
|
+
reject(new ChangeLedgerError('INVALID_ARGUMENTS', 'request body is too large'));
|
|
404
|
+
return;
|
|
405
|
+
}
|
|
406
|
+
chunks.push(bytes);
|
|
407
|
+
});
|
|
408
|
+
request.on('end', resolve);
|
|
409
|
+
request.on('error', reject);
|
|
410
|
+
});
|
|
411
|
+
try {
|
|
412
|
+
return JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
413
|
+
}
|
|
414
|
+
catch (error) {
|
|
415
|
+
throw new ChangeLedgerError('INVALID_ARGUMENTS', 'request body must be valid JSON', { cause: error });
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
function json(response, status, value) {
|
|
419
|
+
response.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' });
|
|
420
|
+
response.end(`${JSON.stringify(value)}\n`);
|
|
421
|
+
}
|
package/lib/snapshot.js
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { lstat, readFile, readlink } from 'node:fs/promises';
|
|
3
|
+
import { ChangeLedgerError } from './errors.js';
|
|
4
|
+
import { discoverRepository, sameRepositoryFence } from './git.js';
|
|
5
|
+
import { isNodeError, resolveWorkspacePath } from './path-utils.js';
|
|
6
|
+
/** Capture the current tracked and non-ignored Git working tree. */
|
|
7
|
+
export async function captureTree(options) {
|
|
8
|
+
throwIfAborted(options.signal);
|
|
9
|
+
const source = await discoverRepository(options.cwd, options.signal);
|
|
10
|
+
if (source.paths.length > options.config.maxFiles) {
|
|
11
|
+
throw new ChangeLedgerError('TOO_MANY_FILES', `workspace has ${source.paths.length} eligible paths; configured maximum is ${options.config.maxFiles}`);
|
|
12
|
+
}
|
|
13
|
+
const entries = Object.create(null);
|
|
14
|
+
let totalBytes = 0;
|
|
15
|
+
for (const path of source.paths) {
|
|
16
|
+
throwIfAborted(options.signal);
|
|
17
|
+
const entry = await captureEntry(source.state.root, path, options.config.maxFileBytes, options.signal);
|
|
18
|
+
if (entry === undefined)
|
|
19
|
+
continue;
|
|
20
|
+
if (entry.kind === 'file') {
|
|
21
|
+
totalBytes += entry.content.length;
|
|
22
|
+
if (totalBytes > options.config.maxSnapshotBytes) {
|
|
23
|
+
throw new ChangeLedgerError('SNAPSHOT_TOO_LARGE', `eligible files exceed configured aggregate limit of ${options.config.maxSnapshotBytes} bytes`);
|
|
24
|
+
}
|
|
25
|
+
if (options.store !== undefined) {
|
|
26
|
+
await options.store.putBlob(source.state.root, entry.snapshot.blob, entry.content);
|
|
27
|
+
}
|
|
28
|
+
entries[path] = entry.snapshot;
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
entries[path] = entry.snapshot;
|
|
32
|
+
}
|
|
33
|
+
return {
|
|
34
|
+
source,
|
|
35
|
+
entries,
|
|
36
|
+
treeHash: hashTree(entries),
|
|
37
|
+
fileCount: Object.keys(entries).length,
|
|
38
|
+
totalBytes,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Capture the complete tree twice and accept it only when both path/content and
|
|
43
|
+
* repository fences agree. This prevents a point from silently mixing files
|
|
44
|
+
* observed at incompatible moments while another process is editing the tree.
|
|
45
|
+
*/
|
|
46
|
+
export async function captureStableTree(options) {
|
|
47
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
48
|
+
const first = await captureTree({
|
|
49
|
+
cwd: options.cwd,
|
|
50
|
+
config: options.config,
|
|
51
|
+
...(options.signal === undefined ? {} : { signal: options.signal }),
|
|
52
|
+
});
|
|
53
|
+
const second = await captureTree(options);
|
|
54
|
+
if (first.treeHash === second.treeHash
|
|
55
|
+
&& sameRepositoryFence(first.source.state, second.source.state)
|
|
56
|
+
&& arraysEqual(first.source.state.stagedPaths, second.source.state.stagedPaths)
|
|
57
|
+
&& arraysEqual(first.source.paths, second.source.paths)) {
|
|
58
|
+
return second;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', 'workspace did not remain stable across repeated full-tree captures');
|
|
62
|
+
}
|
|
63
|
+
/** Compute stable path-level differences between two captured trees. */
|
|
64
|
+
export function diffTrees(before, after) {
|
|
65
|
+
const paths = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort(comparePaths);
|
|
66
|
+
const changes = [];
|
|
67
|
+
for (const path of paths) {
|
|
68
|
+
const left = before[path];
|
|
69
|
+
const right = after[path];
|
|
70
|
+
if (left === undefined && right !== undefined) {
|
|
71
|
+
changes.push({ path, kind: 'added', after: right });
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (left !== undefined && right === undefined) {
|
|
75
|
+
changes.push({ path, kind: 'deleted', before: left });
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
if (left === undefined || right === undefined || entriesEqual(left, right))
|
|
79
|
+
continue;
|
|
80
|
+
if (left.kind !== right.kind) {
|
|
81
|
+
changes.push({ path, kind: 'type-changed', before: left, after: right });
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (left.kind === 'file' && right.kind === 'file' && left.blob === right.blob && left.mode !== right.mode) {
|
|
85
|
+
changes.push({ path, kind: 'mode-changed', before: left, after: right });
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (left.kind === 'symlink' && right.kind === 'symlink' && left.target === right.target && left.mode !== right.mode) {
|
|
89
|
+
changes.push({ path, kind: 'mode-changed', before: left, after: right });
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
changes.push({ path, kind: 'modified', before: left, after: right });
|
|
93
|
+
}
|
|
94
|
+
return changes;
|
|
95
|
+
}
|
|
96
|
+
/** Return whether two snapshot entries are byte/type/mode equivalent. */
|
|
97
|
+
export function entriesEqual(left, right) {
|
|
98
|
+
if (left === undefined || right === undefined)
|
|
99
|
+
return left === right;
|
|
100
|
+
if (left.kind !== right.kind || left.mode !== right.mode)
|
|
101
|
+
return false;
|
|
102
|
+
if (left.kind === 'file' && right.kind === 'file') {
|
|
103
|
+
return left.blob === right.blob && left.size === right.size;
|
|
104
|
+
}
|
|
105
|
+
return left.kind === 'symlink' && right.kind === 'symlink' && left.target === right.target;
|
|
106
|
+
}
|
|
107
|
+
/** Hash a complete path map into a deterministic tree identity. */
|
|
108
|
+
export function hashTree(entries) {
|
|
109
|
+
const hash = createHash('sha256');
|
|
110
|
+
for (const path of Object.keys(entries).sort(comparePaths)) {
|
|
111
|
+
const entry = entries[path];
|
|
112
|
+
if (entry === undefined)
|
|
113
|
+
continue;
|
|
114
|
+
hash.update(path);
|
|
115
|
+
hash.update('\0');
|
|
116
|
+
if (entry.kind === 'file') {
|
|
117
|
+
hash.update(`file\0${entry.blob}\0${entry.size}\0${entry.mode}\0`);
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
hash.update(`symlink\0${entry.target}\0${entry.mode}\0`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return hash.digest('hex');
|
|
124
|
+
}
|
|
125
|
+
async function captureEntry(root, path, maxFileBytes, signal) {
|
|
126
|
+
const target = resolveWorkspacePath(root, path);
|
|
127
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
128
|
+
throwIfAborted(signal);
|
|
129
|
+
let before;
|
|
130
|
+
try {
|
|
131
|
+
before = await lstat(target, { bigint: true });
|
|
132
|
+
}
|
|
133
|
+
catch (error) {
|
|
134
|
+
if (isNodeError(error, 'ENOENT'))
|
|
135
|
+
return undefined;
|
|
136
|
+
throw error;
|
|
137
|
+
}
|
|
138
|
+
const mode = Number(before.mode & 511n);
|
|
139
|
+
if (before.isSymbolicLink()) {
|
|
140
|
+
const linkTarget = await readlink(target);
|
|
141
|
+
const after = await lstat(target, { bigint: true });
|
|
142
|
+
if (!sameStat(before, after))
|
|
143
|
+
continue;
|
|
144
|
+
return { kind: 'symlink', snapshot: { kind: 'symlink', target: linkTarget, mode } };
|
|
145
|
+
}
|
|
146
|
+
if (!before.isFile()) {
|
|
147
|
+
throw new ChangeLedgerError('UNSUPPORTED_FILE_TYPE', `eligible path is not a regular file or symlink: ${JSON.stringify(path)}`);
|
|
148
|
+
}
|
|
149
|
+
if (before.size > BigInt(maxFileBytes)) {
|
|
150
|
+
throw new ChangeLedgerError('FILE_TOO_LARGE', `${JSON.stringify(path)} is ${before.size.toString()} bytes; configured per-file maximum is ${maxFileBytes}`);
|
|
151
|
+
}
|
|
152
|
+
const content = await readFile(target);
|
|
153
|
+
throwIfAborted(signal);
|
|
154
|
+
const after = await lstat(target, { bigint: true });
|
|
155
|
+
if (!sameStat(before, after) || BigInt(content.length) !== after.size)
|
|
156
|
+
continue;
|
|
157
|
+
const blob = createHash('sha256').update(content).digest('hex');
|
|
158
|
+
return {
|
|
159
|
+
kind: 'file',
|
|
160
|
+
snapshot: { kind: 'file', blob, size: content.length, mode },
|
|
161
|
+
content,
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
throw new ChangeLedgerError('WORKSPACE_CHANGED_DURING_CAPTURE', `path changed repeatedly while being captured: ${JSON.stringify(path)}`);
|
|
165
|
+
}
|
|
166
|
+
function sameStat(left, right) {
|
|
167
|
+
return left.dev === right.dev
|
|
168
|
+
&& left.ino === right.ino
|
|
169
|
+
&& left.mode === right.mode
|
|
170
|
+
&& left.size === right.size
|
|
171
|
+
&& left.mtimeNs === right.mtimeNs
|
|
172
|
+
&& left.ctimeNs === right.ctimeNs;
|
|
173
|
+
}
|
|
174
|
+
function throwIfAborted(signal) {
|
|
175
|
+
if (signal?.aborted === true)
|
|
176
|
+
throw signal.reason;
|
|
177
|
+
}
|
|
178
|
+
function comparePaths(left, right) {
|
|
179
|
+
return Buffer.from(left).compare(Buffer.from(right));
|
|
180
|
+
}
|
|
181
|
+
function arraysEqual(left, right) {
|
|
182
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
183
|
+
}
|