dsh-session-rewind 0.0.0-stage → 1.2.8

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.
@@ -0,0 +1,594 @@
1
+ /**
2
+ * The two plugin-owned Host HTTP routes behind the browser's rewind control.
3
+ *
4
+ * They are same-origin, loopback-only control routes: the plan route reports
5
+ * what a rewind would do, the restore route performs the file half of it. The
6
+ * conversation half is a Client-side `sessions.fork`, so no Host route mutates
7
+ * the conversation at all.
8
+ *
9
+ * Both handlers answer with a status and a short body on every failure path and
10
+ * never throw, so a missing or renamed Host service can only cost the rewind
11
+ * control, never the application.
12
+ *
13
+ * The request fence mirrors DSH Web's own `/api` Host/Origin trust check: the
14
+ * request must come from a loopback (or trusted) authority, must not be a
15
+ * cross-site fetch, and its Origin must match the Host header.
16
+ */
17
+
18
+ export const PLAN_PATH = '/api/session-rewind.plan';
19
+ export const RESTORE_PATH = '/api/session-rewind.restore';
20
+ export const CANDIDATES_PATH = '/api/session-rewind.candidates';
21
+ export const INPLACE_PATH = '/api/session-rewind.inplace';
22
+ export const CUTS_PATH = '/api/session-rewind.cuts';
23
+ export const DIAGNOSE_PATH = '/api/session-rewind.diagnose';
24
+ export const SELFCHECK_PATH = '/api/session-rewind.selfcheck';
25
+
26
+ /** A rewind request is a session id plus a Turn number or a seq. */
27
+ export const MAX_REQUEST_BYTES = 16 * 1024;
28
+
29
+ export class RequestBoundaryError extends Error {
30
+ constructor(status, message) {
31
+ super(message);
32
+ this.name = 'RequestBoundaryError';
33
+ this.status = status;
34
+ }
35
+ }
36
+
37
+ function header(headers, name) {
38
+ const value = headers?.[name];
39
+ return Array.isArray(value) ? value[0] : typeof value === 'string' ? value : undefined;
40
+ }
41
+
42
+ function isRecord(value) {
43
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
44
+ }
45
+
46
+ function parseAuthority(authority) {
47
+ try {
48
+ return new URL(`http://${authority}`);
49
+ } catch {
50
+ return undefined;
51
+ }
52
+ }
53
+
54
+ function isLoopbackHostname(hostname) {
55
+ if (hostname === 'localhost' || hostname === '[::1]') return true;
56
+ const parts = hostname.split('.');
57
+ return parts.length === 4
58
+ && parts[0] === '127'
59
+ && parts.every((part) => /^\d{1,3}$/u.test(part) && Number(part) <= 255);
60
+ }
61
+
62
+ function isTrustedAuthority(hostUrl, trustedHosts) {
63
+ return trustedHosts.some((entry) => {
64
+ if (typeof entry !== 'string') return false;
65
+ const entryUrl = parseAuthority(entry);
66
+ if (entryUrl === undefined) return false;
67
+ return entryUrl.port === '' || entryUrl.port === hostUrl.port
68
+ ? entryUrl.hostname === hostUrl.hostname
69
+ : entryUrl.host === hostUrl.host;
70
+ });
71
+ }
72
+
73
+ /**
74
+ * Mirror DSH Web's own `/api` Host/Origin trust fence for a plugin route.
75
+ * @param {import('node:http').IncomingMessage} request
76
+ * @param {readonly string[]} trustedHosts
77
+ * @returns {boolean}
78
+ */
79
+ export function isTrustedRequest(request, trustedHosts) {
80
+ const host = header(request.headers, 'host');
81
+ if (host === undefined) return false;
82
+ const hostUrl = parseAuthority(host);
83
+ if (hostUrl === undefined) return false;
84
+ if (!isLoopbackHostname(hostUrl.hostname) && !isTrustedAuthority(hostUrl, trustedHosts)) return false;
85
+ if (header(request.headers, 'sec-fetch-site') === 'cross-site') return false;
86
+ const origin = header(request.headers, 'origin');
87
+ if (origin === undefined) return true;
88
+ try {
89
+ return new URL(origin).host === hostUrl.host;
90
+ } catch {
91
+ return false;
92
+ }
93
+ }
94
+
95
+ async function readJsonBody(request) {
96
+ const chunks = [];
97
+ let bytes = 0;
98
+ for await (const chunk of request) {
99
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
100
+ bytes += buffer.byteLength;
101
+ if (bytes > MAX_REQUEST_BYTES) throw new RequestBoundaryError(413, 'request body is too large');
102
+ chunks.push(buffer);
103
+ }
104
+ const text = Buffer.concat(chunks).toString('utf8').trim();
105
+ if (text === '') throw new RequestBoundaryError(400, 'request body must be valid JSON');
106
+ try {
107
+ return JSON.parse(text);
108
+ } catch {
109
+ throw new RequestBoundaryError(400, 'request body must be valid JSON');
110
+ }
111
+ }
112
+
113
+ function writeText(response, status, text) {
114
+ response.writeHead(status, {
115
+ 'Cache-Control': 'no-store',
116
+ 'Content-Type': 'text/plain; charset=utf-8',
117
+ 'X-Content-Type-Options': 'nosniff',
118
+ });
119
+ response.end(text);
120
+ }
121
+
122
+ function writeJson(response, status, value) {
123
+ response.writeHead(status, {
124
+ 'Cache-Control': 'no-store',
125
+ 'Content-Type': 'application/json; charset=utf-8',
126
+ 'X-Content-Type-Options': 'nosniff',
127
+ });
128
+ response.end(JSON.stringify(value));
129
+ }
130
+
131
+ function hostsOf(resolve) {
132
+ try {
133
+ const hosts = resolve();
134
+ return Array.isArray(hosts) ? hosts.filter((entry) => typeof entry === 'string') : [];
135
+ } catch {
136
+ return [];
137
+ }
138
+ }
139
+
140
+ function messageOf(error) {
141
+ if (error instanceof Error && error.message !== '') return error.message;
142
+ return String(error);
143
+ }
144
+
145
+ function fail(response, error, onError) {
146
+ const status = Number.isInteger(error?.status) && error.status >= 400 && error.status <= 599
147
+ ? error.status
148
+ : 500;
149
+ if (!(error instanceof RequestBoundaryError)) {
150
+ try {
151
+ onError(error);
152
+ } catch {
153
+ /* logging must not mask the response */
154
+ }
155
+ }
156
+ const text = status === 500 ? 'the rewind request failed' : messageOf(error);
157
+ try {
158
+ writeText(response, status, text);
159
+ } catch {
160
+ /* the response may already be gone */
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Shape one archived file for the browser.
166
+ * @param {object} file
167
+ * @returns {{ path: string, displayPath: string, action: 'restore' | 'delete' | 'skip', size: number, reason?: string }}
168
+ */
169
+ function planFile(file) {
170
+ const action = file.existed !== true
171
+ ? 'delete'
172
+ : typeof file.blob === 'string'
173
+ ? 'restore'
174
+ : 'skip';
175
+ return {
176
+ path: file.path,
177
+ displayPath: file.displayPath,
178
+ action,
179
+ size: Number.isFinite(file.size) ? file.size : 0,
180
+ ...(typeof file.reason === 'string' ? { reason: file.reason } : {}),
181
+ };
182
+ }
183
+
184
+ /**
185
+ * Read the `{ sessionId, turn?, seq? }` request target.
186
+ * @param {unknown} value
187
+ * @returns {{ sessionId: string, turn: number | undefined, seq: number | undefined }}
188
+ */
189
+ function targetOf(value) {
190
+ if (!isRecord(value)) throw new RequestBoundaryError(400, 'request body must be a JSON object');
191
+ const sessionId = typeof value.sessionId === 'string' ? value.sessionId : '';
192
+ if (sessionId === '') throw new RequestBoundaryError(400, 'sessionId is required');
193
+ const turn = Number.isInteger(value.turn) ? value.turn : undefined;
194
+ const seq = Number.isInteger(value.seq) ? value.seq : undefined;
195
+ if (turn === undefined && seq === undefined) {
196
+ throw new RequestBoundaryError(400, 'turn or seq is required');
197
+ }
198
+ return { sessionId, turn, seq };
199
+ }
200
+
201
+ /**
202
+ * Build the browser-diagnostics route.
203
+ *
204
+ * The Web half cannot be inspected from the Host, so the page reports what it
205
+ * actually sees (how many chat rows exist, which attributes they carry, and any
206
+ * error the per-message bridge hit). It keeps the last few reports per Session in
207
+ * memory — never on disk — and they contain no message text.
208
+ *
209
+ * @param {{
210
+ * remember: (sessionId: string, report: unknown) => void,
211
+ * recall: (sessionId: string) => readonly unknown[],
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 createDiagnoseHandler({ remember, recall, 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 body = await readJsonBody(request);
234
+ if (!isRecord(body)) throw new RequestBoundaryError(400, 'request body must be a JSON object');
235
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId : '';
236
+ if (sessionId === '') throw new RequestBoundaryError(400, 'sessionId is required');
237
+ if (body.read === true) {
238
+ writeJson(response, 200, { ok: true, reports: recall(sessionId) });
239
+ return;
240
+ }
241
+ remember(sessionId, body.report);
242
+ writeJson(response, 200, { ok: true });
243
+ } catch (error) {
244
+ fail(response, error, onError);
245
+ }
246
+ };
247
+ }
248
+
249
+ /**
250
+ * Build the in-place rewind route.
251
+ *
252
+ * `dryRun` returns the plan (what would be cut, and the prompt that would come
253
+ * back) without touching the log, which is what lets the browser preview the
254
+ * action and what makes the whole path testable against a live session.
255
+ *
256
+ * @param {{
257
+ * describe: (sessionId: string, seq: number) => object,
258
+ * execute: (sessionId: string, seq: number, restoreFiles: boolean) => Promise<object>,
259
+ * trustedHosts?: () => readonly string[],
260
+ * onError?: (error: unknown) => void,
261
+ * }} options
262
+ * @returns {(request: import('node:http').IncomingMessage, response: import('node:http').ServerResponse) => Promise<void>}
263
+ */
264
+ export function createInplaceHandler({
265
+ describe,
266
+ execute,
267
+ trustedHosts = () => [],
268
+ onError = () => {},
269
+ }) {
270
+ return async (request, response) => {
271
+ try {
272
+ if (request.method !== 'POST') {
273
+ writeText(response, 405, 'method not allowed');
274
+ return;
275
+ }
276
+ if (!isTrustedRequest(request, hostsOf(trustedHosts))) {
277
+ writeText(response, 403, 'forbidden');
278
+ return;
279
+ }
280
+ const mediaType = header(request.headers, 'content-type')?.split(';', 1)[0]?.trim().toLowerCase();
281
+ if (mediaType !== 'application/json') {
282
+ writeText(response, 415, 'content type must be application/json');
283
+ return;
284
+ }
285
+ const body = await readJsonBody(request);
286
+ if (!isRecord(body)) throw new RequestBoundaryError(400, 'request body must be a JSON object');
287
+ const sessionId = typeof body.sessionId === 'string' ? body.sessionId : '';
288
+ if (sessionId === '') throw new RequestBoundaryError(400, 'sessionId is required');
289
+ if (!Number.isInteger(body.seq) || body.seq < 0) {
290
+ throw new RequestBoundaryError(400, 'seq must be a non-negative integer');
291
+ }
292
+ if (body.dryRun === true) {
293
+ const plan = await describe(sessionId, body.seq);
294
+ writeJson(response, 200, { ok: true, dryRun: true, plan });
295
+ return;
296
+ }
297
+ const result = await execute(sessionId, body.seq, body.restoreFiles !== false);
298
+ writeJson(response, 200, { ok: true, ...result });
299
+ } catch (error) {
300
+ if (typeof error?.code === 'string' && error?.status === undefined) {
301
+ try {
302
+ // A planning refusal is the caller's problem, not a server fault.
303
+ writeJson(response, 409, { ok: false, code: error.code, message: messageOf(error) });
304
+ return;
305
+ } catch {
306
+ /* fall through to the generic failure path */
307
+ }
308
+ }
309
+ fail(response, error, onError);
310
+ }
311
+ };
312
+ }
313
+
314
+ /**
315
+ * Build the cuts route: the earliest surface cut already executed in a Session,
316
+ * which the Chat view uses to hide the withdrawn rows.
317
+ *
318
+ * @param {{
319
+ * cutsOf: (sessionId: string) => Promise<number | null>,
320
+ * trustedHosts?: () => readonly string[],
321
+ * onError?: (error: unknown) => void,
322
+ * }} options
323
+ * @returns {(request: import('node:http').IncomingMessage, response: import('node:http').ServerResponse) => Promise<void>}
324
+ */
325
+ export function createCutsHandler({ cutsOf, trustedHosts = () => [], onError = () => {} }) {
326
+ return async (request, response) => {
327
+ try {
328
+ if (request.method !== 'POST') {
329
+ writeText(response, 405, 'method not allowed');
330
+ return;
331
+ }
332
+ if (!isTrustedRequest(request, hostsOf(trustedHosts))) {
333
+ writeText(response, 403, 'forbidden');
334
+ return;
335
+ }
336
+ const mediaType = header(request.headers, 'content-type')?.split(';', 1)[0]?.trim().toLowerCase();
337
+ if (mediaType !== 'application/json') {
338
+ writeText(response, 415, 'content type must be application/json');
339
+ return;
340
+ }
341
+ const value = await readJsonBody(request);
342
+ if (!isRecord(value)) throw new RequestBoundaryError(400, 'request body must be a JSON object');
343
+ const sessionId = typeof value.sessionId === 'string' ? value.sessionId : '';
344
+ if (sessionId === '') throw new RequestBoundaryError(400, 'sessionId is required');
345
+ const cuts = await cutsOf(sessionId);
346
+ writeJson(response, 200, {
347
+ ok: true,
348
+ ...(isRecord(cuts) ? cuts : { hiddenFromSeq: null, hiddenFromTurn: null }),
349
+ });
350
+ } catch (error) {
351
+ fail(response, error, onError);
352
+ }
353
+ };
354
+ }
355
+
356
+ /**
357
+ * Build the candidate route: every finished Turn this Session can be rewound to,
358
+ * newest first, each with the prompt it was sent with and what the file archive
359
+ * holds for it.
360
+ *
361
+ * The chat header offers this list so a rewind does not require scrolling back to
362
+ * the Turn itself.
363
+ *
364
+ * @param {{
365
+ * listTurns: (sessionId: string, limit: number) => Promise<readonly object[]>,
366
+ * trustedHosts?: () => readonly string[],
367
+ * onError?: (error: unknown) => void,
368
+ * }} options
369
+ * @returns {(request: import('node:http').IncomingMessage, response: import('node:http').ServerResponse) => Promise<void>}
370
+ */
371
+ export function createCandidatesHandler({
372
+ listTurns,
373
+ trustedHosts = () => [],
374
+ onError = () => {},
375
+ }) {
376
+ return async (request, response) => {
377
+ try {
378
+ if (request.method !== 'POST') {
379
+ writeText(response, 405, 'method not allowed');
380
+ return;
381
+ }
382
+ if (!isTrustedRequest(request, hostsOf(trustedHosts))) {
383
+ writeText(response, 403, 'forbidden');
384
+ return;
385
+ }
386
+ const mediaType = header(request.headers, 'content-type')?.split(';', 1)[0]?.trim().toLowerCase();
387
+ if (mediaType !== 'application/json') {
388
+ writeText(response, 415, 'content type must be application/json');
389
+ return;
390
+ }
391
+ const value = await readJsonBody(request);
392
+ if (!isRecord(value)) throw new RequestBoundaryError(400, 'request body must be a JSON object');
393
+ const sessionId = typeof value.sessionId === 'string' ? value.sessionId : '';
394
+ if (sessionId === '') throw new RequestBoundaryError(400, 'sessionId is required');
395
+ const limit = Number.isInteger(value.limit) && value.limit > 0 && value.limit <= 200 ? value.limit : 50;
396
+ const turns = await listTurns(sessionId, limit);
397
+ writeJson(response, 200, { ok: true, turns });
398
+ } catch (error) {
399
+ fail(response, error, onError);
400
+ }
401
+ };
402
+ }
403
+
404
+ /**
405
+ * Build the read-only self-check route: what this plugin is actually wired to in
406
+ * the running Host. It performs no mutation and exposes no session content, so it
407
+ * is safe to leave behind as a support diagnostic.
408
+ *
409
+ * @param {{
410
+ * describe: () => Promise<object>,
411
+ * trustedHosts?: () => readonly string[],
412
+ * onError?: (error: unknown) => void,
413
+ * }} options
414
+ * @returns {(request: import('node:http').IncomingMessage, response: import('node:http').ServerResponse) => Promise<void>}
415
+ */
416
+ export function createSelfcheckHandler({ describe, trustedHosts = () => [], onError = () => {} }) {
417
+ return async (request, response) => {
418
+ try {
419
+ if (request.method !== 'GET' && request.method !== 'POST') {
420
+ writeText(response, 405, 'method not allowed');
421
+ return;
422
+ }
423
+ if (!isTrustedRequest(request, hostsOf(trustedHosts))) {
424
+ writeText(response, 403, 'forbidden');
425
+ return;
426
+ }
427
+ writeJson(response, 200, { ok: true, ...(await describe()) });
428
+ } catch (error) {
429
+ fail(response, error, onError);
430
+ }
431
+ };
432
+ }
433
+
434
+ /**
435
+ * Build the plan route: "what would rewinding to this Turn do?".
436
+ *
437
+ * @param {{
438
+ * archive: { get: (sessionId: string, turn: number) => Promise<object | null> },
439
+ * describeTurn: (sessionId: string, options: { turn?: number, seq?: number }) => Promise<{ turn: number, endSeq: number | null } | null>,
440
+ * userPromptOf: (sessionId: string, turn: number) => Promise<string | null>,
441
+ * trustedHosts?: () => readonly string[],
442
+ * onError?: (error: unknown) => void,
443
+ * }} options
444
+ * @returns {(request: import('node:http').IncomingMessage, response: import('node:http').ServerResponse) => Promise<void>}
445
+ */
446
+ export function createPlanHandler({
447
+ archive,
448
+ describeTurn,
449
+ userPromptOf,
450
+ trustedHosts = () => [],
451
+ onError = () => {},
452
+ }) {
453
+ return async (request, response) => {
454
+ try {
455
+ if (request.method !== 'POST') {
456
+ writeText(response, 405, 'method not allowed');
457
+ return;
458
+ }
459
+ if (!isTrustedRequest(request, hostsOf(trustedHosts))) {
460
+ writeText(response, 403, 'forbidden');
461
+ return;
462
+ }
463
+ const mediaType = header(request.headers, 'content-type')?.split(';', 1)[0]?.trim().toLowerCase();
464
+ if (mediaType !== 'application/json') {
465
+ writeText(response, 415, 'content type must be application/json');
466
+ return;
467
+ }
468
+
469
+ const target = targetOf(await readJsonBody(request));
470
+ const described = await describeTurn(target.sessionId, { turn: target.turn, seq: target.seq });
471
+ if (described === null) {
472
+ throw new RequestBoundaryError(409, 'that Turn is not part of this session');
473
+ }
474
+
475
+ const entry = await archive.get(target.sessionId, described.turn);
476
+ const files = Array.isArray(entry?.files) ? entry.files.map(planFile) : [];
477
+
478
+ let prompt = null;
479
+ try {
480
+ prompt = await userPromptOf(target.sessionId, described.turn);
481
+ } catch (error) {
482
+ onError(error);
483
+ }
484
+
485
+ writeJson(response, 200, {
486
+ ok: true,
487
+ turn: described.turn,
488
+ seq: described.endSeq,
489
+ prompt: typeof prompt === 'string' && prompt !== '' ? prompt : null,
490
+ files,
491
+ });
492
+ } catch (error) {
493
+ fail(response, error, onError);
494
+ }
495
+ };
496
+ }
497
+
498
+ /**
499
+ * Build the restore route: put the selected files back to their pre-Turn state.
500
+ *
501
+ * The browser may only select paths that the archive already recorded for this
502
+ * Session and Turn; the request never names a path the Host resolves on its own.
503
+ *
504
+ * @param {{
505
+ * archive: {
506
+ * get: (sessionId: string, turn: number) => Promise<object | null>,
507
+ * markRestored: (sessionId: string, turn: number) => Promise<void>,
508
+ * },
509
+ * describeTurn: (sessionId: string, options: { turn?: number, seq?: number }) => Promise<{ turn: number, endSeq: number | null } | null>,
510
+ * restoreFile: (file: object, context: { workspaceRoot?: string, sessionId: string }) => Promise<{ path: string, status: string, message?: string }>,
511
+ * trustedHosts?: () => readonly string[],
512
+ * onError?: (error: unknown) => void,
513
+ * }} options
514
+ * @returns {(request: import('node:http').IncomingMessage, response: import('node:http').ServerResponse) => Promise<void>}
515
+ */
516
+ export function createRestoreHandler({
517
+ archive,
518
+ describeTurn,
519
+ restoreFile,
520
+ trustedHosts = () => [],
521
+ onError = () => {},
522
+ }) {
523
+ return async (request, response) => {
524
+ try {
525
+ if (request.method !== 'POST') {
526
+ writeText(response, 405, 'method not allowed');
527
+ return;
528
+ }
529
+ if (!isTrustedRequest(request, hostsOf(trustedHosts))) {
530
+ writeText(response, 403, 'forbidden');
531
+ return;
532
+ }
533
+ const mediaType = header(request.headers, 'content-type')?.split(';', 1)[0]?.trim().toLowerCase();
534
+ if (mediaType !== 'application/json') {
535
+ writeText(response, 415, 'content type must be application/json');
536
+ return;
537
+ }
538
+
539
+ const body = await readJsonBody(request);
540
+ const target = targetOf(body);
541
+ const described = await describeTurn(target.sessionId, { turn: target.turn, seq: target.seq });
542
+ if (described === null) {
543
+ throw new RequestBoundaryError(409, 'that Turn is not part of this session');
544
+ }
545
+
546
+ const entry = await archive.get(target.sessionId, described.turn);
547
+ if (entry === null) {
548
+ writeJson(response, 200, { ok: true, turn: described.turn, results: [] });
549
+ return;
550
+ }
551
+
552
+ const requested = isRecord(body) && Array.isArray(body.paths)
553
+ ? new Set(body.paths.filter((path) => typeof path === 'string'))
554
+ : null;
555
+ const selected = Array.isArray(entry.files)
556
+ ? entry.files.filter((file) => requested === null || requested.has(file.path))
557
+ : [];
558
+ const context = {
559
+ workspaceRoot: typeof entry.cwd === 'string' && entry.cwd !== '' ? entry.cwd : undefined,
560
+ sessionId: target.sessionId,
561
+ };
562
+
563
+ const results = [];
564
+ for (const file of selected) {
565
+ if (file.existed === true && typeof file.blob !== 'string') {
566
+ results.push({
567
+ path: file.displayPath,
568
+ status: 'skipped',
569
+ message: 'the pre-Turn content of this file was not recorded',
570
+ });
571
+ continue;
572
+ }
573
+ try {
574
+ results.push(await restoreFile(file, context));
575
+ } catch (error) {
576
+ results.push({ path: file.displayPath, status: 'failed', message: messageOf(error) });
577
+ }
578
+ }
579
+
580
+ const changed = results.some((result) => result.status === 'restored' || result.status === 'deleted');
581
+ if (changed) {
582
+ try {
583
+ await archive.markRestored(target.sessionId, described.turn);
584
+ } catch (error) {
585
+ onError(error);
586
+ }
587
+ }
588
+
589
+ writeJson(response, 200, { ok: true, turn: described.turn, results });
590
+ } catch (error) {
591
+ fail(response, error, onError);
592
+ }
593
+ };
594
+ }