dsh-session-rewind 0.0.0-stage → 1.2.7

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/src/plugin.js ADDED
@@ -0,0 +1,667 @@
1
+ /**
2
+ * Native DSH Host half: the file half of "rewind a finished Turn".
3
+ *
4
+ * What it does
5
+ * - watches every filesystem mutation that happens inside a Turn and stores
6
+ * the pre-mutation bytes of the target (the same `fs/write-intent` /
7
+ * `fs/edit-intent` waterfall `workspace-changes` uses, with the tool
8
+ * execution mapped back to its Session through `tools/pre-execute`);
9
+ * - serves two same-origin routes: one reports what rewinding to a Turn would
10
+ * restore, one performs the restore.
11
+ *
12
+ * The conversation half is deliberately not here: the browser half forks the
13
+ * Session through the Client `sessions` service, because the session log is
14
+ * append-only and forking is the only supported way to branch it.
15
+ *
16
+ * Defensive by construction, following the same shape as `dsh-session-copy`:
17
+ * - no required `inject` entries, so a renamed Host service can never stop the
18
+ * plugin from activating;
19
+ * - `fs` and `webServer` are awaited through `ctx.inject`, and `sessionQuery`
20
+ * is resolved lazily per request;
21
+ * - every step is wrapped, so a throw here is logged and swallowed instead of
22
+ * reaching the loader's activation audit.
23
+ */
24
+
25
+ import { archiveDirectory, createArchive } from './lib/archive.js';
26
+ import { restoreFile as restoreOne } from './lib/restore.js';
27
+ import {
28
+ CANDIDATES_PATH,
29
+ CUTS_PATH,
30
+ DIAGNOSE_PATH,
31
+ INPLACE_PATH,
32
+ PLAN_PATH,
33
+ RESTORE_PATH,
34
+ SELFCHECK_PATH,
35
+ createCandidatesHandler,
36
+ createCutsHandler,
37
+ createDiagnoseHandler,
38
+ createInplaceHandler,
39
+ createPlanHandler,
40
+ createRestoreHandler,
41
+ createSelfcheckHandler,
42
+ } from './lib/route.js';
43
+ import { describeInPlace, executeInPlace, liveAgentOf } from './lib/inplace.js';
44
+ import { hiddenFromSeq, hiddenFromTurn } from './lib/surface.js';
45
+
46
+ export const name = 'session-rewind';
47
+
48
+ /** Nothing is required at activation time; every service is resolved lazily. */
49
+ export const inject = [];
50
+
51
+ /** Cap on the prompt handed back to the composer. */
52
+ const MAX_PROMPT_CHARACTERS = 20_000;
53
+
54
+ function report(ctx, level, error) {
55
+ try {
56
+ const logger = ctx?.logger;
57
+ const write = logger?.[level];
58
+ if (typeof write === 'function') {
59
+ write.call(logger, error instanceof Error ? error : new Error(String(error)));
60
+ return;
61
+ }
62
+ // eslint-disable-next-line no-console
63
+ console[level === 'error' ? 'error' : 'warn']('[dsh-session-rewind]', error);
64
+ } catch {
65
+ /* diagnostics must never escalate */
66
+ }
67
+ }
68
+
69
+ function serviceOf(scope, ctx, serviceName) {
70
+ try {
71
+ const fromScope = scope?.get?.(serviceName);
72
+ if (fromScope !== undefined && fromScope !== null) return fromScope;
73
+ } catch {
74
+ /* fall through to the root context */
75
+ }
76
+ try {
77
+ const fromRoot = ctx?.get?.(serviceName);
78
+ if (fromRoot !== undefined && fromRoot !== null) return fromRoot;
79
+ } catch {
80
+ /* treat as unavailable */
81
+ }
82
+ return undefined;
83
+ }
84
+
85
+ function isRecord(value) {
86
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
87
+ }
88
+
89
+ /** Join the text blocks of one message content array. */
90
+ function textOfContent(content) {
91
+ if (!Array.isArray(content)) return '';
92
+ const parts = [];
93
+ for (const block of content) {
94
+ if (!isRecord(block)) continue;
95
+ if (block.type !== undefined && block.type !== 'text') continue;
96
+ if (typeof block.text === 'string' && block.text !== '') parts.push(block.text);
97
+ }
98
+ return parts.join('\n').trim();
99
+ }
100
+
101
+ /**
102
+ * Read the complete committed event log of one Session.
103
+ *
104
+ * `sessionQuery.readSession` replays and validates the durable log, which is
105
+ * what a rewind must reason about. A live Session object is the fallback for
106
+ * releases where the query service is unavailable.
107
+ *
108
+ * @param {object} ctx
109
+ * @param {string} sessionId
110
+ * @returns {Promise<readonly object[]>}
111
+ */
112
+ async function readEvents(ctx, sessionId) {
113
+ const query = serviceOf(undefined, ctx, 'sessionQuery');
114
+ if (query !== undefined && typeof query.readSession === 'function') {
115
+ const snapshot = await query.readSession(sessionId);
116
+ if (Array.isArray(snapshot?.events)) return snapshot.events;
117
+ }
118
+ const sessions = serviceOf(undefined, ctx, 'sessions');
119
+ const live = typeof sessions?.get === 'function' ? sessions.get(sessionId) : undefined;
120
+ if (Array.isArray(live?.events)) return live.events;
121
+ if (typeof live?.snapshotEvents === 'function') {
122
+ const events = live.snapshotEvents();
123
+ if (Array.isArray(events)) return events;
124
+ }
125
+ const error = new Error('the session log is unavailable in this DSH version');
126
+ error.status = 503;
127
+ throw error;
128
+ }
129
+
130
+ /**
131
+ * Locate one Turn in a log and its fork boundary.
132
+ *
133
+ * @param {readonly object[]} events
134
+ * @param {{ turn?: number, seq?: number }} options
135
+ * @returns {{ turn: number, endSeq: number | null } | null}
136
+ */
137
+ export function locateTurn(events, options) {
138
+ const starts = [];
139
+ const ends = new Map();
140
+ for (const event of events) {
141
+ if (!isRecord(event)) continue;
142
+ const turn = event.data?.turn;
143
+ if (!Number.isInteger(turn)) continue;
144
+ const seq = typeof event.seq === 'number' ? event.seq : -1;
145
+ if (event.type === 'turn/start') starts.push({ turn, seq });
146
+ else if (event.type === 'turn/end') ends.set(turn, typeof event.seq === 'number' ? event.seq : null);
147
+ }
148
+
149
+ let turn = Number.isInteger(options.turn) ? options.turn : undefined;
150
+ if (turn === undefined && Number.isInteger(options.seq)) {
151
+ let best;
152
+ for (const start of starts) {
153
+ if (start.seq > options.seq) continue;
154
+ if (best === undefined || start.seq > best.seq) best = start;
155
+ }
156
+ turn = best?.turn;
157
+ }
158
+ if (turn === undefined) return null;
159
+ // A requested Turn number is only honoured when the log actually opened it;
160
+ // otherwise the caller would be handing `sessions.fork` a boundary of its own
161
+ // invention.
162
+ if (!starts.some((start) => start.turn === turn)) return null;
163
+ return { turn, endSeq: ends.get(turn) ?? null };
164
+ }
165
+
166
+ /**
167
+ * The prompt the user sent for one Turn, when the Turn has one.
168
+ *
169
+ * Only genuinely user-authored messages count: runtime context, instruction
170
+ * files, projected notes and steering notices are all `user/message` events
171
+ * with a different `source.kind`, and none of them belong in a composer.
172
+ *
173
+ * @param {readonly object[]} events
174
+ * @param {number} turn
175
+ * @returns {string | null}
176
+ */
177
+ export function userPromptOf(events, turn) {
178
+ let inside = false;
179
+ for (const event of events) {
180
+ if (!isRecord(event)) continue;
181
+ if (event.type === 'turn/start' && event.data?.turn === turn) {
182
+ inside = true;
183
+ continue;
184
+ }
185
+ if (event.type === 'turn/end' && event.data?.turn === turn) break;
186
+ if (!inside || event.type !== 'user/message') continue;
187
+ const source = event.data?.source;
188
+ if (isRecord(source) && source.kind !== 'user') continue;
189
+ const text = textOfContent(event.data?.content);
190
+ if (text !== '') return text.slice(0, MAX_PROMPT_CHARACTERS);
191
+ }
192
+ return null;
193
+ }
194
+
195
+ /**
196
+ * The Turn still open in a live Session, or undefined when the last committed
197
+ * event closed one.
198
+ *
199
+ * The `session/event` feed owns this in steady state, but a plugin can also be
200
+ * activated while a Turn is already running (install, reload, profile restart
201
+ * mid-Turn), and then no `turn/start` is ever delivered for that Turn. Reading
202
+ * the tail of the live log recovers it without guessing.
203
+ *
204
+ * @param {object} session
205
+ * @returns {number | undefined}
206
+ */
207
+ function openTurnOf(session) {
208
+ let events;
209
+ try {
210
+ if (typeof session?.snapshotEvents === 'function') {
211
+ const snapshot = session.snapshotEvents();
212
+ if (Array.isArray(snapshot)) events = snapshot;
213
+ }
214
+ } catch {
215
+ /* fall through to the plain accessor */
216
+ }
217
+ if (events === undefined) {
218
+ try {
219
+ const raw = session?.events;
220
+ if (Array.isArray(raw)) events = raw;
221
+ } catch {
222
+ /* an unreadable log only costs the capture */
223
+ }
224
+ }
225
+ if (events === undefined) return undefined;
226
+
227
+ // Only the tail can hold the open Turn; a long Session must not be walked in
228
+ // full on every miss.
229
+ const floor = Math.max(0, events.length - 5000);
230
+ for (let index = events.length - 1; index >= floor; index -= 1) {
231
+ const event = events[index];
232
+ if (!isRecord(event)) continue;
233
+ if (event.type === 'turn/end') return undefined;
234
+ if (event.type === 'turn/start') {
235
+ const turn = event.data?.turn;
236
+ return Number.isInteger(turn) ? turn : undefined;
237
+ }
238
+ }
239
+ return undefined;
240
+ }
241
+
242
+ /**
243
+ * @param {object} ctx DSH Cordis context.
244
+ */
245
+ export function apply(ctx) {
246
+ /** @type {ReturnType<typeof createArchive> | undefined} */
247
+ let archive;
248
+ try {
249
+ archive = createArchive({
250
+ directory: archiveDirectory(),
251
+ warn: (error) => report(ctx, 'warn', error),
252
+ });
253
+ ctx.effect(() => () => {
254
+ try {
255
+ const closing = archive?.close();
256
+ if (closing !== undefined) closing.catch(() => {});
257
+ } catch {
258
+ /* teardown must never throw */
259
+ }
260
+ }, 'session-rewind: archive');
261
+ } catch (error) {
262
+ report(ctx, 'error', error);
263
+ return;
264
+ }
265
+
266
+ /** Session id → the Turn currently open for that Session. */
267
+ const openTurns = new Map();
268
+ /** Tool execution → the Session that issued it (a WeakMap, so it never leaks). */
269
+ const actors = new WeakMap();
270
+
271
+ try {
272
+ ctx.on('session/event', (session, event) => {
273
+ try {
274
+ const sessionId = session?.id;
275
+ if (typeof sessionId !== 'string' || sessionId === '') return;
276
+ if (event?.type === 'turn/start') {
277
+ const turn = event.data?.turn;
278
+ if (Number.isInteger(turn)) openTurns.set(sessionId, turn);
279
+ return;
280
+ }
281
+ if (event?.type === 'turn/end') openTurns.delete(sessionId);
282
+ } catch {
283
+ /* an event we cannot read must not break the Turn */
284
+ }
285
+ });
286
+
287
+ ctx.on('session/disposed', (session) => {
288
+ try {
289
+ const sessionId = session?.id;
290
+ if (typeof sessionId === 'string') openTurns.delete(sessionId);
291
+ } catch {
292
+ /* nothing to clean */
293
+ }
294
+ });
295
+
296
+ ctx.on('tools/pre-execute', async (exec, next) => {
297
+ try {
298
+ const session = exec?.agent?.session;
299
+ const sessionId = session?.id;
300
+ if (typeof sessionId === 'string' && sessionId !== '') {
301
+ const cwd = session?.header?.cwd;
302
+ actors.set(exec, {
303
+ sessionId,
304
+ session,
305
+ cwd: typeof cwd === 'string' ? cwd : undefined,
306
+ });
307
+ }
308
+ } catch {
309
+ /* mapping is best-effort */
310
+ }
311
+ return next();
312
+ });
313
+ } catch (error) {
314
+ report(ctx, 'warn', error);
315
+ }
316
+
317
+ // Capture pre-images while `fs` is available. The listener runs before the
318
+ // mutation is applied, so reading the target now yields the pre-Turn bytes.
319
+ try {
320
+ ctx.inject(['fs'], (scope) => {
321
+ const capture = async (target, actor) => {
322
+ const owner = isRecord(actor) || typeof actor === 'function' ? actors.get(actor) : undefined;
323
+ if (owner === undefined) return;
324
+ let turn = openTurns.get(owner.sessionId);
325
+ if (!Number.isInteger(turn)) {
326
+ // Recovery path for a plugin mounted mid-Turn: derive it once from the
327
+ // live log, then cache it until the next `turn/start`/`turn/end`.
328
+ turn = openTurnOf(owner.session);
329
+ if (!Number.isInteger(turn)) return;
330
+ openTurns.set(owner.sessionId, turn);
331
+ }
332
+ let absolute;
333
+ try {
334
+ absolute = scope.fs.processPath(target);
335
+ } catch {
336
+ return;
337
+ }
338
+ if (typeof absolute !== 'string' || absolute === '') return;
339
+ await archive?.record({
340
+ sessionId: owner.sessionId,
341
+ turn,
342
+ cwd: owner.cwd,
343
+ absolute,
344
+ displayPath: typeof target?.displayPath === 'string' && target.displayPath !== ''
345
+ ? target.displayPath
346
+ : absolute,
347
+ });
348
+ };
349
+
350
+ const listen = (eventName) => {
351
+ scope.on(eventName, async (target, actor, next) => {
352
+ const intent = await next();
353
+ try {
354
+ await capture(target, actor);
355
+ } catch (error) {
356
+ report(ctx, 'warn', error);
357
+ }
358
+ return intent;
359
+ }, { prepend: true });
360
+ };
361
+
362
+ try {
363
+ listen('fs/write-intent');
364
+ listen('fs/edit-intent');
365
+ } catch (error) {
366
+ report(ctx, 'warn', error);
367
+ }
368
+ });
369
+ } catch (error) {
370
+ report(ctx, 'warn', error);
371
+ }
372
+
373
+ try {
374
+ ctx.inject(['webServer'], (scope) => {
375
+ const describeTurn = async (sessionId, options) => {
376
+ const events = await readEvents(ctx, sessionId);
377
+ return locateTurn(events, options);
378
+ };
379
+
380
+ const userPrompt = async (sessionId, turn) => {
381
+ const events = await readEvents(ctx, sessionId);
382
+ return userPromptOf(events, turn);
383
+ };
384
+
385
+ /**
386
+ * The user-authored message that opened one Turn, if the Turn has one.
387
+ * This is the in-place rewind target: the marker replaces it and every
388
+ * surface node after it.
389
+ */
390
+ const userMessageOf = (events, turn) => {
391
+ let inside = false;
392
+ for (const event of events) {
393
+ if (!isRecord(event)) continue;
394
+ if (event.type === 'turn/start' && event.data?.turn === turn) {
395
+ inside = true;
396
+ continue;
397
+ }
398
+ if (event.type === 'turn/end' && event.data?.turn === turn) return null;
399
+ if (!inside || event.type !== 'user/message') continue;
400
+ if (event.data?.source?.kind !== 'user') continue;
401
+ if (typeof event.seq !== 'number') continue;
402
+ return event;
403
+ }
404
+ return null;
405
+ };
406
+
407
+ const liveSession = (sessionId) => {
408
+ const agent = liveAgentOf(ctx, sessionId);
409
+ const session = agent?.session;
410
+ if (session === undefined || session === null) {
411
+ const error = new Error('this session is not live, so it cannot be rewound in place');
412
+ error.status = 409;
413
+ throw error;
414
+ }
415
+ return session;
416
+ };
417
+
418
+ /** Restore every archived Turn at or after `fromTurn` (newest first). */
419
+ const restoreTurns = async (sessionId, fromTurn) => {
420
+ const fsService = serviceOf(scope, ctx, 'fs');
421
+ if (fsService === undefined) return undefined;
422
+ const syncObservation = async (absolute, observation) => {
423
+ try {
424
+ const target = await fsService.resolve(absolute);
425
+ const emit = typeof scope.emit === 'function' ? scope.emit.bind(scope) : undefined;
426
+ if (emit !== undefined) emit('fs/observed', target, observation, undefined);
427
+ } catch {
428
+ /* best-effort */
429
+ }
430
+ };
431
+ const summary = { restored: 0, deleted: 0, failed: 0, skipped: 0, turns: [] };
432
+ let turn = Number.isInteger(fromTurn) ? fromTurn : undefined;
433
+ if (turn === undefined) return summary;
434
+ // The archive is keyed by turn; scan forward until a turn has no record.
435
+ for (let current = turn; current < turn + 400; current += 1) {
436
+ let entry = null;
437
+ try {
438
+ entry = await archive.get(sessionId, current);
439
+ } catch {
440
+ entry = null;
441
+ }
442
+ if (entry === null) continue;
443
+ const context = {
444
+ workspaceRoot: typeof entry.cwd === 'string' && entry.cwd !== '' ? entry.cwd : undefined,
445
+ sessionId,
446
+ };
447
+ for (const file of Array.isArray(entry.files) ? entry.files : []) {
448
+ if (file.existed === true && typeof file.blob !== 'string') {
449
+ summary.skipped += 1;
450
+ continue;
451
+ }
452
+ try {
453
+ const outcome = await restoreOne({ fsService, archive, file, ...context, syncObservation });
454
+ if (outcome.status === 'restored') summary.restored += 1;
455
+ else if (outcome.status === 'deleted') summary.deleted += 1;
456
+ else if (outcome.status === 'skipped') summary.skipped += 1;
457
+ else summary.failed += 1;
458
+ } catch {
459
+ summary.failed += 1;
460
+ }
461
+ }
462
+ summary.turns.push(current);
463
+ }
464
+ return summary;
465
+ };
466
+
467
+ const describeInPlaceFor = (sessionId, seq) => describeInPlace(liveSession(sessionId), seq);
468
+
469
+ const executeInPlaceFor = (sessionId, seq, restoreFiles) => executeInPlace({
470
+ ctx,
471
+ sessionId,
472
+ seq,
473
+ restoreTurns: restoreFiles === true
474
+ ? async (fromTurn) => restoreTurns(sessionId, fromTurn)
475
+ : undefined,
476
+ });
477
+
478
+ const cutsOf = async (sessionId) => {
479
+ // Read the durable log: the client needs the cut after a page reload too.
480
+ const events = await readEvents(ctx, sessionId);
481
+ return { hiddenFromSeq: hiddenFromSeq(events), hiddenFromTurn: hiddenFromTurn(events) };
482
+ };
483
+
484
+ /**
485
+ * Every finished Turn of one Session, newest first, as a rewind target.
486
+ * A candidate needs a committed `turn/end` seq because that is the boundary
487
+ * `sessions.fork` accepts.
488
+ */
489
+ const listTurns = async (sessionId, limit) => {
490
+ const events = await readEvents(ctx, sessionId);
491
+ const ends = [];
492
+ for (const event of events) {
493
+ if (!isRecord(event)) continue;
494
+ if (event.type !== 'turn/end') continue;
495
+ const turn = event.data?.turn;
496
+ if (!Number.isInteger(turn)) continue;
497
+ ends.push({ turn, seq: typeof event.seq === 'number' ? event.seq : null });
498
+ }
499
+ const candidates = [];
500
+ for (const end of ends.reverse()) {
501
+ if (candidates.length >= limit) break;
502
+ let entry = null;
503
+ try {
504
+ entry = await archive.get(sessionId, end.turn);
505
+ } catch {
506
+ /* the archive is optional here */
507
+ }
508
+ const files = Array.isArray(entry?.files) ? entry.files : [];
509
+ const userMessage = userMessageOf(events, end.turn);
510
+ candidates.push({
511
+ turn: end.turn,
512
+ seq: end.seq,
513
+ userSeq: userMessage === null ? null : userMessage.seq,
514
+ prompt: userPromptOf(events, end.turn),
515
+ files: {
516
+ count: files.length,
517
+ restorable: files.filter((file) => file.existed !== true || typeof file.blob === 'string').length,
518
+ },
519
+ });
520
+ }
521
+ return candidates;
522
+ };
523
+
524
+ const trustedHosts = () => {
525
+ const runtime = serviceOf(scope, ctx, 'webRuntime');
526
+ const hosts = runtime?.trustedHosts;
527
+ return Array.isArray(hosts) ? hosts : [];
528
+ };
529
+
530
+ const onError = (error) => report(ctx, 'warn', error);
531
+
532
+ const planHandler = createPlanHandler({
533
+ archive,
534
+ describeTurn,
535
+ userPromptOf: userPrompt,
536
+ trustedHosts,
537
+ onError,
538
+ });
539
+
540
+ const restoreHandler = createRestoreHandler({
541
+ archive,
542
+ describeTurn,
543
+ restoreFile: async (file, context) => {
544
+ const fsService = serviceOf(scope, ctx, 'fs');
545
+ if (fsService === undefined) {
546
+ const error = new Error('the fs service is unavailable in this DSH version');
547
+ error.status = 503;
548
+ throw error;
549
+ }
550
+ // A raw-Node restore is invisible to the fs observation policy; publish
551
+ // the true state so the Session's next write of the same path is not
552
+ // judged against the stale pre-restore observation.
553
+ const syncObservation = async (absolute, observation) => {
554
+ try {
555
+ const target = await fsService.resolve(absolute);
556
+ const emit = typeof scope.emit === 'function' ? scope.emit.bind(scope) : undefined;
557
+ if (emit === undefined) return;
558
+ emit('fs/observed', target, observation, undefined);
559
+ } catch {
560
+ /* best-effort */
561
+ }
562
+ };
563
+ return restoreOne({ fsService, archive, file, ...context, syncObservation });
564
+ },
565
+ trustedHosts,
566
+ onError,
567
+ });
568
+
569
+ const statusHandler = createSelfcheckHandler({
570
+ describe: async () => {
571
+ let clientPath = null;
572
+ try {
573
+ const clientModules = serviceOf(scope, ctx, 'clientModules');
574
+ const path = typeof clientModules?.clientPath === 'function'
575
+ ? clientModules.clientPath('dsh-session-rewind')
576
+ : undefined;
577
+ if (typeof path === 'string' && path !== '') clientPath = path;
578
+ } catch {
579
+ /* the client table is optional */
580
+ }
581
+ let archiveStats = { turns: 0, files: 0 };
582
+ try {
583
+ archiveStats = await archive.stats();
584
+ } catch {
585
+ /* totals are diagnostics only */
586
+ }
587
+ return {
588
+ plugin: 'dsh-session-rewind',
589
+ archive: { directory: archive.directory, ...archiveStats },
590
+ client: { discovered: clientPath !== null, path: clientPath },
591
+ routes: [SELFCHECK_PATH, DIAGNOSE_PATH, CANDIDATES_PATH, INPLACE_PATH, CUTS_PATH, PLAN_PATH, RESTORE_PATH],
592
+ };
593
+ },
594
+ trustedHosts,
595
+ onError,
596
+ });
597
+
598
+ const candidatesHandler = createCandidatesHandler({
599
+ listTurns,
600
+ trustedHosts,
601
+ onError,
602
+ });
603
+
604
+ const inplaceHandler = createInplaceHandler({
605
+ describe: describeInPlaceFor,
606
+ execute: executeInPlaceFor,
607
+ trustedHosts,
608
+ onError,
609
+ });
610
+
611
+ /** Last few browser reports per Session, in memory only. */
612
+ const diagnostics = new Map();
613
+ const diagnoseHandler = createDiagnoseHandler({
614
+ remember: (sessionId, report) => {
615
+ const list = diagnostics.get(sessionId) ?? [];
616
+ list.push({ at: Date.now(), report });
617
+ while (list.length > 5) list.shift();
618
+ diagnostics.set(sessionId, list);
619
+ },
620
+ recall: (sessionId) => diagnostics.get(sessionId) ?? [],
621
+ trustedHosts,
622
+ onError,
623
+ });
624
+
625
+ const cutsHandler = createCutsHandler({
626
+ cutsOf,
627
+ trustedHosts,
628
+ onError,
629
+ });
630
+
631
+ try {
632
+ scope.effect(
633
+ () => scope.webServer.register({ kind: 'exact', path: DIAGNOSE_PATH, handler: diagnoseHandler }),
634
+ 'session-rewind: diagnose route',
635
+ );
636
+ scope.effect(
637
+ () => scope.webServer.register({ kind: 'exact', path: SELFCHECK_PATH, handler: statusHandler }),
638
+ 'session-rewind: selfcheck route',
639
+ );
640
+ scope.effect(
641
+ () => scope.webServer.register({ kind: 'exact', path: CANDIDATES_PATH, handler: candidatesHandler }),
642
+ 'session-rewind: candidates route',
643
+ );
644
+ scope.effect(
645
+ () => scope.webServer.register({ kind: 'exact', path: INPLACE_PATH, handler: inplaceHandler }),
646
+ 'session-rewind: in-place route',
647
+ );
648
+ scope.effect(
649
+ () => scope.webServer.register({ kind: 'exact', path: CUTS_PATH, handler: cutsHandler }),
650
+ 'session-rewind: cuts route',
651
+ );
652
+ scope.effect(
653
+ () => scope.webServer.register({ kind: 'exact', path: PLAN_PATH, handler: planHandler }),
654
+ 'session-rewind: plan route',
655
+ );
656
+ scope.effect(
657
+ () => scope.webServer.register({ kind: 'exact', path: RESTORE_PATH, handler: restoreHandler }),
658
+ 'session-rewind: restore route',
659
+ );
660
+ } catch (error) {
661
+ report(ctx, 'error', error);
662
+ }
663
+ });
664
+ } catch (error) {
665
+ report(ctx, 'error', error);
666
+ }
667
+ }