dsh-session-rewind 1.3.0 → 1.3.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/lib/client.js +33 -9
- 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/client-smoke.mjs +12 -0
- 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.2';
|
|
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,12 +950,11 @@ loader.load({
|
|
|
949
950
|
}
|
|
950
951
|
};
|
|
951
952
|
|
|
952
|
-
const runRewind = async (seq, restoreFiles, notice) => {
|
|
953
|
-
notice(tr('notice.working'));
|
|
953
|
+
const runRewind = async (seq, restoreFiles, notice, fromTurn = null) => {
|
|
954
954
|
try {
|
|
955
|
+
notice(tr('notice.working'));
|
|
955
956
|
const result = await postJson(INPLACE_ROUTE, { sessionId, seq, restoreFiles });
|
|
956
957
|
if (state.disposed) return;
|
|
957
|
-
if (Number.isInteger(result.hiddenFromTurn)) state.cutTurn = result.hiddenFromTurn;
|
|
958
958
|
if (Array.isArray(result.hiddenTurns) && result.hiddenTurns.length > 0) {
|
|
959
959
|
for (const turn of result.hiddenTurns) {
|
|
960
960
|
if (Number.isInteger(turn)) state.hiddenTurns.add(turn);
|
|
@@ -982,16 +982,29 @@ loader.load({
|
|
|
982
982
|
}
|
|
983
983
|
notice(parts.length === 0 ? tr('notice.rewound') : `${tr('notice.rewound')} · ${parts.join(' · ')}`);
|
|
984
984
|
} catch (error) {
|
|
985
|
+
const code = error !== null && typeof error === 'object' ? error.code ?? null : null;
|
|
985
986
|
sendDiagnose(sessionId, {
|
|
986
987
|
reason: 'rewind-failed',
|
|
987
988
|
error: String(error && error.message ? error.message : error),
|
|
989
|
+
code,
|
|
988
990
|
sequence: seq,
|
|
989
991
|
});
|
|
992
|
+
if (code === 'not-on-surface') {
|
|
993
|
+
// Already withdrawn by an earlier rewind: hide the row and say so,
|
|
994
|
+
// instead of reporting a failure the user cannot act on.
|
|
995
|
+
const turn = Number.isInteger(fromTurn) ? fromTurn : null;
|
|
996
|
+
if (Number.isInteger(turn)) {
|
|
997
|
+
state.hiddenTurns.add(turn);
|
|
998
|
+
applyHiding();
|
|
999
|
+
}
|
|
1000
|
+
notice(tr('notice.already'));
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
990
1003
|
notice(error instanceof Error && error.message !== '' ? error.message : tr('notice.failed'));
|
|
991
1004
|
}
|
|
992
1005
|
};
|
|
993
1006
|
|
|
994
|
-
const openPopover = (anchor, seq, notice) => {
|
|
1007
|
+
const openPopover = (anchor, seq, notice, turn = null) => {
|
|
995
1008
|
closePopover();
|
|
996
1009
|
const popover = document.createElement('div');
|
|
997
1010
|
popover.setAttribute('data-session-rewind-popover', '');
|
|
@@ -1034,16 +1047,27 @@ loader.load({
|
|
|
1034
1047
|
button.addEventListener('mouseleave', () => {
|
|
1035
1048
|
button.style.background = 'transparent';
|
|
1036
1049
|
});
|
|
1037
|
-
button.addEventListener('click',
|
|
1050
|
+
button.addEventListener('click', () => {
|
|
1051
|
+
try {
|
|
1052
|
+
onClick();
|
|
1053
|
+
} catch (error) {
|
|
1054
|
+
sendDiagnose(sessionId, {
|
|
1055
|
+
reason: 'option-failed',
|
|
1056
|
+
error: String(error && error.message ? error.message : error),
|
|
1057
|
+
boot: BOOT_REVISION,
|
|
1058
|
+
sequence: seq,
|
|
1059
|
+
});
|
|
1060
|
+
}
|
|
1061
|
+
});
|
|
1038
1062
|
return button;
|
|
1039
1063
|
};
|
|
1040
1064
|
popover.appendChild(makeButton(tr('popover.chat'), () => {
|
|
1041
1065
|
closePopover();
|
|
1042
|
-
void runRewind(seq, false, notice);
|
|
1066
|
+
void runRewind(seq, false, notice, turn);
|
|
1043
1067
|
}));
|
|
1044
1068
|
popover.appendChild(makeButton(tr('popover.both'), () => {
|
|
1045
1069
|
closePopover();
|
|
1046
|
-
void runRewind(seq, true, notice);
|
|
1070
|
+
void runRewind(seq, true, notice, turn);
|
|
1047
1071
|
}));
|
|
1048
1072
|
document.body.appendChild(popover);
|
|
1049
1073
|
state.popover = popover;
|
|
@@ -1172,7 +1196,7 @@ loader.load({
|
|
|
1172
1196
|
event.preventDefault();
|
|
1173
1197
|
event.stopPropagation();
|
|
1174
1198
|
sendDiagnose(sessionId, { reason: 'click', turn, seq, buttons: state.buttons.size, mapped: state.turnToSeq.size });
|
|
1175
|
-
openPopover(button, seq, notice);
|
|
1199
|
+
openPopover(button, seq, notice, turn);
|
|
1176
1200
|
});
|
|
1177
1201
|
anchor.appendChild(button);
|
|
1178
1202
|
state.buttons.set(row, button);
|
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.2",
|
|
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/client-smoke.mjs
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import assert from 'node:assert/strict';
|
|
10
10
|
import { readFile } from 'node:fs/promises';
|
|
11
|
+
import { readFileSync } from 'node:fs';
|
|
11
12
|
|
|
12
13
|
//#region stubs
|
|
13
14
|
const listeners = new Map();
|
|
@@ -437,4 +438,15 @@ assert.match(textOf(headerTree), /session-rewind\.notice\.unavailable/u);
|
|
|
437
438
|
assert.equal(forkCalls.length, 0);
|
|
438
439
|
//#endregion
|
|
439
440
|
|
|
441
|
+
// --- popover wiring --------------------------------------------------------
|
|
442
|
+
// v1.3.1 shipped a popover whose option callbacks referenced a `turn` the
|
|
443
|
+
// enclosing function never declared. That throws inside a DOM listener, which the
|
|
444
|
+
// browser swallows: clicking a control did nothing at all. Pin the wiring.
|
|
445
|
+
{
|
|
446
|
+
const source = readFileSync(new URL('../lib/client.js', import.meta.url), 'utf8');
|
|
447
|
+
assert.match(source, /const openPopover = \(anchor, seq, notice, turn/u);
|
|
448
|
+
assert.match(source, /runRewind\(seq, false, notice, turn\)/u);
|
|
449
|
+
assert.match(source, /runRewind\(seq, true, notice, turn\)/u);
|
|
450
|
+
}
|
|
451
|
+
|
|
440
452
|
console.log('client smoke: all assertions passed');
|
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');
|