dsh-session-rewind 1.3.0 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +18 -4
- package/locale/en.json +2 -1
- package/locale/zh.json +2 -1
- package/package.json +1 -1
- package/src/lib/route.js +53 -0
- package/src/lib/surface.js +18 -14
- package/src/plugin.js +13 -1
- package/test/selftest.mjs +19 -0
package/lib/client.js
CHANGED
|
@@ -66,7 +66,7 @@ loader.load({
|
|
|
66
66
|
const CUTS_ROUTE = '/api/session-rewind.cuts';
|
|
67
67
|
const DIAGNOSE_ROUTE = '/api/session-rewind.diagnose';
|
|
68
68
|
const TAIL_ROUTE = '/api/session-rewind.tail';
|
|
69
|
-
const BOOT_REVISION = '1.3.
|
|
69
|
+
const BOOT_REVISION = '1.3.1';
|
|
70
70
|
const HIDDEN_ATTR = 'data-session-rewind-hidden';
|
|
71
71
|
|
|
72
72
|
/** One per-message bridge per session, shared by every TurnRewind instance. */
|
|
@@ -870,6 +870,7 @@ loader.load({
|
|
|
870
870
|
: `${tr('notice.failed')}(HTTP ${response.status})`),
|
|
871
871
|
);
|
|
872
872
|
error.status = response.status;
|
|
873
|
+
error.code = value !== null && typeof value === 'object' && typeof value.code === 'string' ? value.code : null;
|
|
873
874
|
throw error;
|
|
874
875
|
}
|
|
875
876
|
return value;
|
|
@@ -949,7 +950,7 @@ loader.load({
|
|
|
949
950
|
}
|
|
950
951
|
};
|
|
951
952
|
|
|
952
|
-
const runRewind = async (seq, restoreFiles, notice) => {
|
|
953
|
+
const runRewind = async (seq, restoreFiles, notice, fromTurn = null) => {
|
|
953
954
|
notice(tr('notice.working'));
|
|
954
955
|
try {
|
|
955
956
|
const result = await postJson(INPLACE_ROUTE, { sessionId, seq, restoreFiles });
|
|
@@ -982,11 +983,24 @@ loader.load({
|
|
|
982
983
|
}
|
|
983
984
|
notice(parts.length === 0 ? tr('notice.rewound') : `${tr('notice.rewound')} · ${parts.join(' · ')}`);
|
|
984
985
|
} catch (error) {
|
|
986
|
+
const code = error !== null && typeof error === 'object' ? error.code ?? null : null;
|
|
985
987
|
sendDiagnose(sessionId, {
|
|
986
988
|
reason: 'rewind-failed',
|
|
987
989
|
error: String(error && error.message ? error.message : error),
|
|
990
|
+
code,
|
|
988
991
|
sequence: seq,
|
|
989
992
|
});
|
|
993
|
+
if (code === 'not-on-surface') {
|
|
994
|
+
// Already withdrawn by an earlier rewind: hide the row and say so,
|
|
995
|
+
// instead of reporting a failure the user cannot act on.
|
|
996
|
+
const turn = Number.isInteger(fromTurn) ? fromTurn : null;
|
|
997
|
+
if (Number.isInteger(turn)) {
|
|
998
|
+
state.hiddenTurns.add(turn);
|
|
999
|
+
applyHiding();
|
|
1000
|
+
}
|
|
1001
|
+
notice(tr('notice.already'));
|
|
1002
|
+
return;
|
|
1003
|
+
}
|
|
990
1004
|
notice(error instanceof Error && error.message !== '' ? error.message : tr('notice.failed'));
|
|
991
1005
|
}
|
|
992
1006
|
};
|
|
@@ -1039,11 +1053,11 @@ loader.load({
|
|
|
1039
1053
|
};
|
|
1040
1054
|
popover.appendChild(makeButton(tr('popover.chat'), () => {
|
|
1041
1055
|
closePopover();
|
|
1042
|
-
void runRewind(seq, false, notice);
|
|
1056
|
+
void runRewind(seq, false, notice, turn);
|
|
1043
1057
|
}));
|
|
1044
1058
|
popover.appendChild(makeButton(tr('popover.both'), () => {
|
|
1045
1059
|
closePopover();
|
|
1046
|
-
void runRewind(seq, true, notice);
|
|
1060
|
+
void runRewind(seq, true, notice, turn);
|
|
1047
1061
|
}));
|
|
1048
1062
|
document.body.appendChild(popover);
|
|
1049
1063
|
state.popover = popover;
|
package/locale/en.json
CHANGED
|
@@ -2,5 +2,6 @@
|
|
|
2
2
|
"meta": {
|
|
3
3
|
"title": "Session Rewind",
|
|
4
4
|
"description": "Adds a rewind control to every finished Turn plus one entry in the session header. Pick a Turn to branch the conversation there into a new session, put that Turn's original prompt back in the composer so you can edit and resend it, and restore the workspace files that Turn changed to their pre-Turn content."
|
|
5
|
-
}
|
|
5
|
+
},
|
|
6
|
+
"notice.already": "That message was already rewound; its row is now hidden."
|
|
6
7
|
}
|
package/locale/zh.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-session-rewind",
|
|
3
|
-
"version": "1.3.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Rewind a finished Turn: branch the conversation at any Turn (session header or per-Turn control) and restore every workspace file that Turn changed. · 回合回退:从任意已完成的回合重新开始,并把该回合改动过的工作区文件恢复原状。",
|
|
6
6
|
"icon": "./icon.svg",
|
package/src/lib/route.js
CHANGED
|
@@ -22,6 +22,7 @@ export const INPLACE_PATH = '/api/session-rewind.inplace';
|
|
|
22
22
|
export const CUTS_PATH = '/api/session-rewind.cuts';
|
|
23
23
|
export const DIAGNOSE_PATH = '/api/session-rewind.diagnose';
|
|
24
24
|
export const TAIL_PATH = '/api/session-rewind.tail';
|
|
25
|
+
export const SURFACE_PATH = '/api/session-rewind.surface';
|
|
25
26
|
export const SELFCHECK_PATH = '/api/session-rewind.selfcheck';
|
|
26
27
|
|
|
27
28
|
/** A rewind request is a session id plus a Turn number or a seq. */
|
|
@@ -199,6 +200,58 @@ function targetOf(value) {
|
|
|
199
200
|
return { sessionId, turn, seq };
|
|
200
201
|
}
|
|
201
202
|
|
|
203
|
+
/**
|
|
204
|
+
* Build the surface route.
|
|
205
|
+
*
|
|
206
|
+
* `planInPlace` refuses a rewind when the target Seq is not a node of the live
|
|
207
|
+
* Session's surface, so when a click is refused the only useful question is what
|
|
208
|
+
* that surface actually contains. This exposes it read-only.
|
|
209
|
+
*
|
|
210
|
+
* @param {{
|
|
211
|
+
* surfaceOf: (sessionId: string) => { nodes?: readonly unknown[]; replaceGeneration?: number; contentGeneration?: number } | null,
|
|
212
|
+
* trustedHosts?: () => readonly string[],
|
|
213
|
+
* onError?: (error: unknown) => void,
|
|
214
|
+
* }} options
|
|
215
|
+
* @returns {(request: import('node:http').IncomingMessage, response: import('node:http').ServerResponse) => Promise<void>}
|
|
216
|
+
*/
|
|
217
|
+
export function createSurfaceHandler({ surfaceOf, trustedHosts = () => [], onError = () => {} }) {
|
|
218
|
+
return async (request, response) => {
|
|
219
|
+
try {
|
|
220
|
+
if (request.method !== 'POST') {
|
|
221
|
+
writeText(response, 405, 'method not allowed');
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (!isTrustedRequest(request, hostsOf(trustedHosts))) {
|
|
225
|
+
writeText(response, 403, 'forbidden');
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
const mediaType = header(request.headers, 'content-type')?.split(';', 1)[0]?.trim().toLowerCase();
|
|
229
|
+
if (mediaType !== 'application/json') {
|
|
230
|
+
writeText(response, 415, 'content type must be application/json');
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const value = await readJsonBody(request);
|
|
234
|
+
if (!isRecord(value)) throw new RequestBoundaryError(400, 'request body must be a JSON object');
|
|
235
|
+
const sessionId = typeof value.sessionId === 'string' ? value.sessionId : '';
|
|
236
|
+
if (sessionId === '') throw new RequestBoundaryError(400, 'sessionId is required');
|
|
237
|
+
const live = surfaceOf(sessionId);
|
|
238
|
+
if (live === null) throw new RequestBoundaryError(409, 'this session is not open right now');
|
|
239
|
+
const nodes = Array.isArray(live.nodes) ? live.nodes.map((node) => (typeof node === 'number' ? node : null)) : [];
|
|
240
|
+
const tail = Number.isInteger(value.tail) && value.tail > 0 && value.tail <= 200 ? value.tail : 20;
|
|
241
|
+
writeJson(response, 200, {
|
|
242
|
+
ok: true,
|
|
243
|
+
count: nodes.length,
|
|
244
|
+
replaceGeneration: live.replaceGeneration ?? null,
|
|
245
|
+
contentGeneration: live.contentGeneration ?? null,
|
|
246
|
+
head: nodes.slice(0, 10),
|
|
247
|
+
tail: nodes.slice(-tail),
|
|
248
|
+
});
|
|
249
|
+
} catch (error) {
|
|
250
|
+
fail(response, error, onError);
|
|
251
|
+
}
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
202
255
|
/**
|
|
203
256
|
* Build the event-tail route.
|
|
204
257
|
*
|
package/src/lib/surface.js
CHANGED
|
@@ -172,34 +172,38 @@ export function hiddenFromTurn(events) {
|
|
|
172
172
|
/**
|
|
173
173
|
* Turns whose rows belong to a withdrawn stretch of the log.
|
|
174
174
|
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
175
|
+
* A Session can be rewound more than once, and each rewind appends its own
|
|
176
|
+
* marker, so the withdrawn turns are the union over every marker of the turns
|
|
177
|
+
* spanned by `[surfaceOp.startSeq, markerSeq)`. Taking only the earliest marker —
|
|
178
|
+
* as the first version did — leaves the turns withdrawn later still on screen.
|
|
179
179
|
*
|
|
180
180
|
* @param {readonly unknown[]} events
|
|
181
181
|
* @returns {readonly number[]}
|
|
182
182
|
*/
|
|
183
183
|
export function withdrawnTurns(events) {
|
|
184
|
-
const
|
|
185
|
-
if (startSeq === null) return [];
|
|
186
|
-
let markerSeq = null;
|
|
184
|
+
const spans = [];
|
|
187
185
|
for (const event of events) {
|
|
188
186
|
if (!isRecord(event) || event.type !== 'user/message') continue;
|
|
189
187
|
if (event.data?.source?.kind !== MARKER_SOURCE_KIND) continue;
|
|
190
|
-
if (typeof event.seq
|
|
188
|
+
if (typeof event.seq !== 'number') continue;
|
|
189
|
+
const op = event.surfaceOp;
|
|
190
|
+
if (!isRecord(op) || op.op !== 'replace' || typeof op.startSeq !== 'number') continue;
|
|
191
|
+
spans.push({ from: op.startSeq, before: event.seq });
|
|
191
192
|
}
|
|
192
|
-
if (
|
|
193
|
+
if (spans.length === 0) return [];
|
|
193
194
|
const turns = new Set();
|
|
194
195
|
let current = null;
|
|
195
196
|
for (const event of events) {
|
|
196
197
|
if (!isRecord(event) || typeof event.seq !== 'number') continue;
|
|
197
198
|
const turn = event.data?.turn;
|
|
198
|
-
if (Number.isInteger(turn) && event.type === 'turn/start') current = turn;
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
199
|
+
if (Number.isInteger(turn) && (event.type === 'turn/start' || event.type === 'turn/end')) current = turn;
|
|
200
|
+
if (!Number.isInteger(current)) continue;
|
|
201
|
+
for (const span of spans) {
|
|
202
|
+
if (event.seq >= span.from && event.seq < span.before) {
|
|
203
|
+
turns.add(current);
|
|
204
|
+
break;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
203
207
|
}
|
|
204
208
|
return [...turns].sort((left, right) => left - right);
|
|
205
209
|
}
|
package/src/plugin.js
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
CUTS_PATH,
|
|
30
30
|
DIAGNOSE_PATH,
|
|
31
31
|
INPLACE_PATH,
|
|
32
|
+
SURFACE_PATH,
|
|
32
33
|
TAIL_PATH,
|
|
33
34
|
PLAN_PATH,
|
|
34
35
|
RESTORE_PATH,
|
|
@@ -37,6 +38,7 @@ import {
|
|
|
37
38
|
createCutsHandler,
|
|
38
39
|
createDiagnoseHandler,
|
|
39
40
|
createInplaceHandler,
|
|
41
|
+
createSurfaceHandler,
|
|
40
42
|
createTailHandler,
|
|
41
43
|
createPlanHandler,
|
|
42
44
|
createRestoreHandler,
|
|
@@ -595,7 +597,7 @@ export function apply(ctx) {
|
|
|
595
597
|
plugin: 'dsh-session-rewind',
|
|
596
598
|
archive: { directory: archive.directory, ...archiveStats },
|
|
597
599
|
client: { discovered: clientPath !== null, path: clientPath },
|
|
598
|
-
routes: [SELFCHECK_PATH, DIAGNOSE_PATH, TAIL_PATH, CANDIDATES_PATH, INPLACE_PATH, CUTS_PATH, PLAN_PATH, RESTORE_PATH],
|
|
600
|
+
routes: [SELFCHECK_PATH, DIAGNOSE_PATH, TAIL_PATH, SURFACE_PATH, CANDIDATES_PATH, INPLACE_PATH, CUTS_PATH, PLAN_PATH, RESTORE_PATH],
|
|
599
601
|
};
|
|
600
602
|
},
|
|
601
603
|
trustedHosts,
|
|
@@ -629,6 +631,12 @@ export function apply(ctx) {
|
|
|
629
631
|
onError,
|
|
630
632
|
});
|
|
631
633
|
|
|
634
|
+
const surfaceHandler = createSurfaceHandler({
|
|
635
|
+
surfaceOf: (sessionId) => liveAgentOf(ctx, sessionId)?.session?.surface ?? null,
|
|
636
|
+
trustedHosts,
|
|
637
|
+
onError,
|
|
638
|
+
});
|
|
639
|
+
|
|
632
640
|
const tailHandler = createTailHandler({
|
|
633
641
|
events: async (sessionId) => readEvents(ctx, sessionId),
|
|
634
642
|
trustedHosts,
|
|
@@ -650,6 +658,10 @@ export function apply(ctx) {
|
|
|
650
658
|
() => scope.webServer.register({ kind: 'exact', path: SELFCHECK_PATH, handler: statusHandler }),
|
|
651
659
|
'session-rewind: selfcheck route',
|
|
652
660
|
);
|
|
661
|
+
scope.effect(
|
|
662
|
+
() => scope.webServer.register({ kind: 'exact', path: SURFACE_PATH, handler: surfaceHandler }),
|
|
663
|
+
'session-rewind: surface route',
|
|
664
|
+
);
|
|
653
665
|
scope.effect(
|
|
654
666
|
() => scope.webServer.register({ kind: 'exact', path: TAIL_PATH, handler: tailHandler }),
|
|
655
667
|
'session-rewind: tail route',
|
package/test/selftest.mjs
CHANGED
|
@@ -116,6 +116,25 @@ assert.deepEqual(payload.source, { kind: MARKER_SOURCE_KIND });
|
|
|
116
116
|
assert.deepEqual(withdrawnTurns(events), []);
|
|
117
117
|
}
|
|
118
118
|
|
|
119
|
+
// --- two rewinds in one Session -------------------------------------------
|
|
120
|
+
// Each marker withdraws its own span; the union is what must disappear. Missing
|
|
121
|
+
// the second marker is what left the later turns on screen.
|
|
122
|
+
{
|
|
123
|
+
const twice = [
|
|
124
|
+
{ seq: 1, type: 'turn/start', data: { turn: 1 } },
|
|
125
|
+
{ seq: 2, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'one' }] } },
|
|
126
|
+
{ seq: 5, type: 'turn/end', data: { turn: 1 } },
|
|
127
|
+
{ seq: 6, type: 'user/message', data: { source: { kind: MARKER_SOURCE_KIND } }, surfaceOp: { op: 'replace', startSeq: 2, endSeq: 5 } },
|
|
128
|
+
{ seq: 7, type: 'turn/start', data: { turn: 2 } },
|
|
129
|
+
{ seq: 8, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'two' }] } },
|
|
130
|
+
{ seq: 11, type: 'turn/end', data: { turn: 2 } },
|
|
131
|
+
{ seq: 12, type: 'user/message', data: { source: { kind: MARKER_SOURCE_KIND } }, surfaceOp: { op: 'replace', startSeq: 8, endSeq: 11 } },
|
|
132
|
+
{ seq: 13, type: 'turn/start', data: { turn: 3 } },
|
|
133
|
+
{ seq: 14, type: 'user/message', data: { source: { kind: 'user' }, content: [{ type: 'text', text: 'three' }] } },
|
|
134
|
+
];
|
|
135
|
+
assert.deepEqual(withdrawnTurns(twice), [1, 2]);
|
|
136
|
+
}
|
|
137
|
+
|
|
119
138
|
// --- archive -------------------------------------------------------------
|
|
120
139
|
const root = await mkdtemp(join(tmpdir(), 'rewind-test-'));
|
|
121
140
|
const archiveDir = join(root, 'archive');
|