@oar-sh/oar 0.9.10 → 0.9.11
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/CHANGELOG.md +314 -1
- package/README.md +156 -972
- package/package.json +13 -13
- package/server/README.md +95 -22
- package/server/claude-worker/claude-session-process.mjs +89 -11
- package/server/claude-worker/claude-session-worker.mjs +9 -1
- package/server/copilot-worker/copilot-sdk-session-process.mjs +76 -11
- package/server/copilot-worker/copilot-sdk-session-worker.mjs +6 -0
- package/server/db-schema.mjs +59 -2
- package/server/grok-worker/acp-client.mjs +2 -1
- package/server/grok-worker/acp-host-services.mjs +4 -3
- package/server/migrations/0013-previews.mjs +130 -0
- package/server/migrations/0014-conversation-groups.mjs +89 -0
- package/server/migrations/0015-conversation-group-position.mjs +76 -0
- package/server/migrations/0016-share-access-log.mjs +110 -0
- package/server/public/app/about-ui.js +73 -0
- package/server/public/app/about-view.mjs +234 -0
- package/server/public/app/action-confirmations.js +31 -14
- package/server/public/app/agent-exchange-fold-view.mjs +40 -101
- package/server/public/app/api-client.js +251 -16
- package/server/public/app/ask-user-view.js +17 -15
- package/server/public/app/bootstrap.js +158 -32
- package/server/public/app/chat-actions-menu-state.mjs +20 -10
- package/server/public/app/context-menu.mjs +90 -6
- package/server/public/app/conversation-group-drag.mjs +191 -0
- package/server/public/app/conversation-group-order.mjs +265 -0
- package/server/public/app/conversation-group-state.mjs +100 -0
- package/server/public/app/conversation-list-drag.mjs +449 -0
- package/server/public/app/conversation-turn-state.mjs +81 -0
- package/server/public/app/conversation-view.js +136 -93
- package/server/public/app/cwd-picker.js +1 -1
- package/server/public/app/hidden-messages-view.mjs +312 -0
- package/server/public/app/host-suspend-ui.js +7 -1
- package/server/public/app/journal-view.js +787 -22
- package/server/public/app/preview-cards.mjs +117 -31
- package/server/public/app/preview-command.mjs +10 -1
- package/server/public/app/relay-board-view.js +19 -6
- package/server/public/app/relay-restart-error.mjs +16 -0
- package/server/public/app/settings-modal.js +40 -8
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/share-format.mjs +49 -0
- package/server/public/app/share-preview-marks.mjs +167 -0
- package/server/public/app/share-state.js +109 -0
- package/server/public/app/shared-conversations-settings.js +480 -0
- package/server/public/app/socket-handlers.js +80 -14
- package/server/public/app/store.js +96 -30
- package/server/public/app/subagent-row.mjs +3 -3
- package/server/public/app/subagent-viewer.mjs +30 -6
- package/server/public/app/thinking-follow.js +0 -4
- package/server/public/app/transcript-jump-bottom-view.mjs +194 -20
- package/server/public/app/transcript-jump-bottom.mjs +132 -21
- package/server/public/app/transcript-viewport-anchor.mjs +140 -0
- package/server/public/index.html +386 -15
- package/server/relay-tools.md +2 -1
- package/server/relay.mjs +2 -1
- package/server/repositories/conversation-group-repository.mjs +444 -0
- package/server/repositories/message-repository.mjs +1 -0
- package/server/repositories/session-repository.mjs +12 -37
- package/server/repositories/share-repository.mjs +322 -0
- package/server/repositories/share-visibility-statements.mjs +10 -0
- package/server/routes/about-routes.mjs +15 -0
- package/server/routes/cache-routes.mjs +4 -0
- package/server/routes/conversation-group-routes.mjs +171 -0
- package/server/routes/messages-routes.mjs +184 -65
- package/server/routes/preview-routes.mjs +9 -1
- package/server/routes/sessions-routes.mjs +771 -193
- package/server/server-runtime.mjs +316 -100
- package/server/services/about-service.mjs +461 -0
- package/server/services/background-task-store.mjs +128 -0
- package/server/services/background-work-view.mjs +228 -0
- package/server/services/cache-rebuild-service.mjs +30 -0
- package/server/services/delete-archive-service.mjs +4 -1
- package/server/services/detached-worker-watch-service.mjs +150 -0
- package/server/services/hidden-message-list.mjs +21 -0
- package/server/services/host-suspend-command.mjs +2 -1
- package/server/services/message-pin-list.mjs +1 -1
- package/server/services/preview-health-probe.mjs +8 -1
- package/server/services/preview-proxy-server.mjs +8 -4
- package/server/services/preview-registry-service.mjs +676 -26
- package/server/services/preview-static-handler.mjs +40 -7
- package/server/services/preview-store.mjs +274 -0
- package/server/services/rate-limit-service.mjs +12 -1
- package/server/services/relay-auth.mjs +183 -0
- package/server/services/relay-cli-launcher-service.mjs +3 -1
- package/server/services/relay-event-service.mjs +17 -0
- package/server/services/remote-relay-dispatcher.mjs +57 -5
- package/server/services/request-boundary.mjs +183 -0
- package/server/services/session-activity-tracker.mjs +227 -19
- package/server/services/session-worker-launch-service.mjs +190 -6
- package/server/services/session-worker-process-service.mjs +10 -6
- package/server/services/share-access-log-service.mjs +123 -0
- package/server/services/share-path-redaction.mjs +424 -0
- package/server/services/status-event-service.mjs +7 -3
- package/server/services/tunnel-worker-path-guard.mjs +24 -41
- package/server/services/windows-autostart-service.mjs +6 -1
- package/server/services/windows-boot-autostart-service.mjs +207 -23
- package/shared/preview-tool-core.mjs +15 -5
- package/shared/remote-relay-contract.mjs +3 -1
- package/shared/share-path-privacy.mjs +30 -0
- package/shared/windows-system-tools.mjs +106 -0
- package/shared/worker-runtime/background-task-digest.mjs +30 -0
- package/shared/worker-runtime/heartbeat.mjs +24 -0
- package/shared/worker-runtime/kept-posts.mjs +83 -0
- package/shared/worker-runtime/worker-websocket-link.mjs +47 -15
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
import crypto from 'crypto';
|
|
4
4
|
|
|
5
|
-
// Preview lane registry: the
|
|
6
|
-
// dedicated preview listener
|
|
5
|
+
// Preview lane registry: the set of dev servers and folders published through
|
|
6
|
+
// the dedicated preview listener (persisted through an injected store, so it
|
|
7
|
+
// survives a restart), plus the config normalization and the startup
|
|
7
8
|
// interlocks that keep that listener a separate origin from the relay.
|
|
8
9
|
//
|
|
9
10
|
// This module is deliberately http-free. It owns *what is allowed*; the listener
|
|
@@ -255,18 +256,155 @@ export function validatePreviewTarget({ host, port } = {}, {
|
|
|
255
256
|
return { ok: true, host: normalizedHost, port: normalizedPort };
|
|
256
257
|
}
|
|
257
258
|
|
|
259
|
+
|
|
260
|
+
export const PREVIEW_TOKEN_REDACTION = '[preview link removed]';
|
|
261
|
+
// The path prefix as typed, percent-encoded, or encoded more than once.
|
|
262
|
+
const PATH_PREFIX_SRC = 'test(?:_|%(?:25)*5f)';
|
|
263
|
+
// A preview path is recognised from this many hex characters on, whatever
|
|
264
|
+
// follows and however long the run is: a link that was cut short (a reply
|
|
265
|
+
// still streaming, a turn that failed inside it, a capped line) is then
|
|
266
|
+
// removed like a whole one, and at most 7 of a token's 32 characters can
|
|
267
|
+
// remain, which leaves 100 bits to guess. Shorter runs stay, so names such as
|
|
268
|
+
// `test_add` or `test_1` read as written.
|
|
269
|
+
export const PREVIEW_REDACT_MIN_HEX = 8;
|
|
270
|
+
const PREVIEW_PATH_TOKEN_GLOBAL_RE = new RegExp(`${PATH_PREFIX_SRC}[0-9a-f]{${PREVIEW_REDACT_MIN_HEX},}`, 'gi');
|
|
271
|
+
|
|
272
|
+
// `abcdefgh(?:i(?:j(?:k)?)?)?`: the first `min` characters, then any more.
|
|
273
|
+
function prefixSource(token, min) {
|
|
274
|
+
let tail = '';
|
|
275
|
+
for (let i = token.length - 1; i >= min; i -= 1) tail = `(?:${token[i]}${tail})?`;
|
|
276
|
+
return `${token.slice(0, min)}${tail}`;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// `(?:(?:(?:a)?b)?c)?defghijk`: the last `min` characters, with any more before.
|
|
280
|
+
function suffixSource(token, min) {
|
|
281
|
+
let head = '';
|
|
282
|
+
for (let i = 0; i < token.length - min; i += 1) head = `(?:${head}${token[i]})?`;
|
|
283
|
+
return `${head}${token.slice(token.length - min)}`;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Removes preview capabilities from every string of a JSON-like value. Returns
|
|
288
|
+
* a copy; the input is untouched. Redacted are:
|
|
289
|
+
* - each `test_<hex>` path segment with at least PREVIEW_REDACT_MIN_HEX hex
|
|
290
|
+
* characters, also with the `_` percent-encoded, wherever it stands: a
|
|
291
|
+
* whole link, one cut short, one glued to more hex;
|
|
292
|
+
* - each occurrence of one of `tokens` (the relay's known previews: the
|
|
293
|
+
* preview tool's list output quotes them bare), or of its first
|
|
294
|
+
* PREVIEW_REDACT_MIN_HEX characters or more, wherever it stands;
|
|
295
|
+
* - the end of one of `tokens`, of that length or more, at the start of a
|
|
296
|
+
* string (a link that runs across two strings).
|
|
297
|
+
*
|
|
298
|
+
* Ordinary text is hit only where `test_` is followed by eight or more hex
|
|
299
|
+
* digits (`test_deadbeef`), or where it holds eight characters of a known
|
|
300
|
+
* token by chance.
|
|
301
|
+
*/
|
|
302
|
+
export function redactPreviewTokens(value, { tokens = [] } = {}) {
|
|
303
|
+
const bare = [...new Set((tokens || []).map(toText).filter((token) => isPreviewToken(token)))];
|
|
304
|
+
// Every pattern here does a bounded amount of work per character, so the
|
|
305
|
+
// pass is linear in the length of the text: it runs on a public route, on
|
|
306
|
+
// whatever a transcript holds. In particular nothing looks back over a run
|
|
307
|
+
// of whitespace; the start of a string is found by plain code.
|
|
308
|
+
const startRe = bare.length > 0
|
|
309
|
+
? new RegExp(`(?:${bare.map((token) => suffixSource(token, PREVIEW_REDACT_MIN_HEX)).join('|')})`, 'iy')
|
|
310
|
+
: null;
|
|
311
|
+
const bareRe = bare.length > 0
|
|
312
|
+
? new RegExp(`(?:${bare.map((token) => prefixSource(token, PREVIEW_REDACT_MIN_HEX)).join('|')})`, 'gi')
|
|
313
|
+
: null;
|
|
314
|
+
const redactText = (text) => {
|
|
315
|
+
let out = text.replace(PREVIEW_PATH_TOKEN_GLOBAL_RE, PREVIEW_TOKEN_REDACTION);
|
|
316
|
+
if (!bareRe) return out;
|
|
317
|
+
// The start of the string first: what is left of a link begun in another one.
|
|
318
|
+
const start = out.search(/\S/);
|
|
319
|
+
if (start !== -1) {
|
|
320
|
+
startRe.lastIndex = start;
|
|
321
|
+
const match = startRe.exec(out);
|
|
322
|
+
if (match) out = `${out.slice(0, start)}${PREVIEW_TOKEN_REDACTION}${out.slice(start + match[0].length)}`;
|
|
323
|
+
}
|
|
324
|
+
return out.replace(bareRe, PREVIEW_TOKEN_REDACTION);
|
|
325
|
+
};
|
|
326
|
+
const walk = (node, depth) => {
|
|
327
|
+
if (typeof node === 'string') return redactText(node);
|
|
328
|
+
if (!node || typeof node !== 'object' || depth > 64) return node;
|
|
329
|
+
if (Array.isArray(node)) return node.map((item) => walk(item, depth + 1));
|
|
330
|
+
if (typeof node.toJSON === 'function') return walk(node.toJSON(), depth + 1);
|
|
331
|
+
const out = {};
|
|
332
|
+
for (const [key, item] of Object.entries(node)) out[key] = walk(item, depth + 1);
|
|
333
|
+
return out;
|
|
334
|
+
};
|
|
335
|
+
return walk(value, 0);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* A short, stable name for a preview in logs: the token itself is the
|
|
340
|
+
* capability, so it never goes into a log line.
|
|
341
|
+
*/
|
|
342
|
+
export function previewLogId(token) {
|
|
343
|
+
return crypto.createHash('sha256').update(toText(token)).digest('hex').slice(0, 8);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// A row a restore could not judge is checked again on the health probe's beat
|
|
347
|
+
// for this long, then dropped: ten minutes outlast a busy database or a mount
|
|
348
|
+
// that comes up late, and a link nobody can check does not stay for ever.
|
|
349
|
+
const DEFAULT_KEPT_ROW_MAX_AGE_MS = 10 * 60_000;
|
|
350
|
+
// The shutdown flush waits this long on a locked database, not the driver's
|
|
351
|
+
// default: the runtime's force-exit timer is armed only after it.
|
|
352
|
+
const FLUSH_BUSY_TIMEOUT_MS = 250;
|
|
353
|
+
|
|
354
|
+
// Writes of `last_hit_at` are coalesced to one per preview per minute: a page
|
|
355
|
+
// load is dozens of requests, and the column only feeds the "last opened" line.
|
|
356
|
+
const DEFAULT_HIT_PERSIST_INTERVAL_MS = 60_000;
|
|
357
|
+
|
|
358
|
+
function sameTargetKey({ conversationId, mode, targetHost, targetPort, rootDir }) {
|
|
359
|
+
const conv = toText(conversationId);
|
|
360
|
+
return mode === 'static'
|
|
361
|
+
? JSON.stringify([conv, 'static', toText(rootDir)])
|
|
362
|
+
: JSON.stringify([conv, 'port', normalizeHost(targetHost), Number(targetPort)]);
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function formatAge(ms) {
|
|
366
|
+
const minutes = Math.max(0, Math.round(ms / 60_000));
|
|
367
|
+
if (minutes < 1) return 'just now';
|
|
368
|
+
if (minutes < 60) return `${minutes} min ago`;
|
|
369
|
+
const hours = Math.round(minutes / 60);
|
|
370
|
+
if (hours < 48) return `${hours} h ago`;
|
|
371
|
+
return `${Math.round(hours / 24)} d ago`;
|
|
372
|
+
}
|
|
373
|
+
|
|
258
374
|
/**
|
|
259
|
-
* The live preview set.
|
|
260
|
-
*
|
|
261
|
-
*
|
|
375
|
+
* The live preview set. Kept in memory for the hot path and written through to
|
|
376
|
+
* an injected store (the `previews` table), so a restarted runtime restores
|
|
377
|
+
* every link it handed out. Nothing expires on its own: a preview ends when it
|
|
378
|
+
* is closed, when its conversation is deleted, or when a restore finds it no
|
|
379
|
+
* longer valid (folder gone or outside its workspace root, port preview from
|
|
380
|
+
* an earlier host boot, conversation gone).
|
|
381
|
+
*
|
|
382
|
+
* A row a restore could not judge (a check threw, or answered with an fs
|
|
383
|
+
* error rather than a "no") is kept apart: not served, but listed as
|
|
384
|
+
* `unverified`, counted towards the limit, closable, and checked again by
|
|
385
|
+
* recheckKeptRows until it is restored, refused, or too old.
|
|
262
386
|
*/
|
|
263
387
|
export function createPreviewRegistry({
|
|
264
388
|
config,
|
|
265
389
|
now = () => Date.now(),
|
|
266
390
|
randomBytes = crypto.randomBytes,
|
|
267
391
|
onChange = null,
|
|
392
|
+
store = null,
|
|
393
|
+
hostBootId = '',
|
|
394
|
+
isSameHostBoot = (a, b) => !!toText(a) && toText(a) === toText(b),
|
|
395
|
+
// (conversationId) => boolean: does the conversation still exist (not deleted)?
|
|
396
|
+
checkConversation = null,
|
|
397
|
+
// (rootDir, conversationId) => { ok, rootDir } | { ok:false, error, transient? }:
|
|
398
|
+
// the registration-time jail, run again against the conversation's workspace
|
|
399
|
+
// root. `transient` (an fs error, not a "no") keeps the row to be checked again.
|
|
400
|
+
checkStaticRoot = null,
|
|
401
|
+
hitPersistIntervalMs = DEFAULT_HIT_PERSIST_INTERVAL_MS,
|
|
402
|
+
keptRowMaxAgeMs = DEFAULT_KEPT_ROW_MAX_AGE_MS,
|
|
403
|
+
logger = console,
|
|
268
404
|
} = {}) {
|
|
269
405
|
const entries = new Map();
|
|
406
|
+
// token -> { row, since, reason }: stored rows no check could judge yet.
|
|
407
|
+
const kept = new Map();
|
|
270
408
|
const settings = config || normalizePreviewsConfig({});
|
|
271
409
|
|
|
272
410
|
function toPublicRecord(entry) {
|
|
@@ -281,23 +419,158 @@ export function createPreviewRegistry({
|
|
|
281
419
|
url: entry.url,
|
|
282
420
|
basePath: entry.basePath,
|
|
283
421
|
createdAt: entry.createdAt,
|
|
422
|
+
lastHitAt: entry.lastHitAt ?? null,
|
|
284
423
|
online: entry.online,
|
|
285
424
|
lastSeenOnline: entry.lastSeenOnline,
|
|
286
425
|
hits: entry.hits,
|
|
287
426
|
};
|
|
288
427
|
}
|
|
289
428
|
|
|
290
|
-
function
|
|
429
|
+
function keptRecord({ row }) {
|
|
430
|
+
const basePath = previewBasePath(row.token);
|
|
431
|
+
return {
|
|
432
|
+
token: row.token,
|
|
433
|
+
conversationId: toText(row.conversationId) || null,
|
|
434
|
+
label: toText(row.label).slice(0, 120) || 'preview',
|
|
435
|
+
mode: row.mode,
|
|
436
|
+
targetHost: row.targetHost ?? null,
|
|
437
|
+
targetPort: row.targetPort ?? null,
|
|
438
|
+
rootDir: row.rootDir ?? null,
|
|
439
|
+
url: `${settings.publicBaseUrl}${basePath}`,
|
|
440
|
+
basePath,
|
|
441
|
+
createdAt: Number.isFinite(row.createdAt) ? row.createdAt : 0,
|
|
442
|
+
lastHitAt: Number.isFinite(row.lastHitAt) ? row.lastHitAt : null,
|
|
443
|
+
online: false,
|
|
444
|
+
lastSeenOnline: null,
|
|
445
|
+
hits: 0,
|
|
446
|
+
// Not served until a check passes.
|
|
447
|
+
unverified: true,
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
function storeCall(name, ...args) {
|
|
452
|
+
if (!store || typeof store[name] !== 'function') return undefined;
|
|
453
|
+
try {
|
|
454
|
+
return store[name](...args);
|
|
455
|
+
} catch (error) {
|
|
456
|
+
logger?.warn?.(`[preview] store ${name} failed: ${error?.message || error}`);
|
|
457
|
+
return undefined;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Writes that threw (a locked or busy database) are kept here and retried by
|
|
462
|
+
// retryPendingWrites — on the health probe's beat and at shutdown — so the
|
|
463
|
+
// table converges with memory instead of bringing a closed link back, or
|
|
464
|
+
// losing a live one, at the next start. Keyed by the stored token:
|
|
465
|
+
// 'upsert' writes the live entry again, 'remove' deletes the row.
|
|
466
|
+
//
|
|
467
|
+
// One entry per token, and only for a token that is live or has a row: a
|
|
468
|
+
// preview whose insert never landed leaves nothing behind when it is closed,
|
|
469
|
+
// so cycles of create and close on a failing store do not grow the map.
|
|
470
|
+
const pendingWrites = new Map();
|
|
471
|
+
// Conversation ids whose rows are still to be deleted, each with the tokens
|
|
472
|
+
// that were closed with it: those rows are still stored.
|
|
473
|
+
const pendingConversationRemovals = new Map();
|
|
474
|
+
|
|
475
|
+
// Counts the writes that threw, for passes that must stop at the first one.
|
|
476
|
+
let storeWriteFailures = 0;
|
|
477
|
+
|
|
478
|
+
// true when the write went through, or there is nothing to write to; false
|
|
479
|
+
// when the store threw. Logs the first failure of a key only.
|
|
480
|
+
function storeWrite(key, name, ...args) {
|
|
481
|
+
if (!store || typeof store[name] !== 'function') return true;
|
|
482
|
+
try {
|
|
483
|
+
store[name](...args);
|
|
484
|
+
return true;
|
|
485
|
+
} catch (error) {
|
|
486
|
+
storeWriteFailures += 1;
|
|
487
|
+
if (!pendingWrites.has(key) && !pendingConversationRemovals.has(key)) {
|
|
488
|
+
logger?.warn?.(`[preview] store ${name} failed, retried later: ${error?.message || error}`);
|
|
489
|
+
}
|
|
490
|
+
return false;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// A failed write goes to the end of the line, so one that keeps failing
|
|
495
|
+
// does not hold the others back.
|
|
496
|
+
function setPending(token, op) {
|
|
497
|
+
pendingWrites.delete(token);
|
|
498
|
+
pendingWrites.set(token, op);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function upsertRow(entry) {
|
|
502
|
+
const ok = storeWrite(entry.token, 'insert', { ...toPublicRecord(entry), hostBootId });
|
|
503
|
+
if (ok) {
|
|
504
|
+
entry.stored = true;
|
|
505
|
+
pendingWrites.delete(entry.token);
|
|
506
|
+
} else {
|
|
507
|
+
setPending(entry.token, 'upsert');
|
|
508
|
+
}
|
|
509
|
+
return ok;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function removeRow(token) {
|
|
513
|
+
const ok = storeWrite(token, 'remove', token);
|
|
514
|
+
if (ok) pendingWrites.delete(token);
|
|
515
|
+
else setPending(token, 'remove');
|
|
516
|
+
return ok;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function liveTokensOfConversation(conversationId) {
|
|
520
|
+
const tokens = [];
|
|
521
|
+
for (const entry of entries.values()) {
|
|
522
|
+
if (entry.conversationId === conversationId) tokens.push(entry.token);
|
|
523
|
+
}
|
|
524
|
+
for (const [token, held] of kept) {
|
|
525
|
+
if (toText(held.row.conversationId) === conversationId) tokens.push(token);
|
|
526
|
+
}
|
|
527
|
+
return tokens;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
// One pass over the failed writes, stopped at the first that fails again:
|
|
531
|
+
// on a locked database every attempt blocks for the driver's timeout, and
|
|
532
|
+
// the next one would only block again. -> how many are still pending.
|
|
533
|
+
function retryPendingWrites() {
|
|
534
|
+
const pendingCount = () => pendingWrites.size + pendingConversationRemovals.size;
|
|
535
|
+
for (const [token, op] of [...pendingWrites]) {
|
|
536
|
+
if (op === 'upsert') {
|
|
537
|
+
const entry = entries.get(token);
|
|
538
|
+
// Gone since without a close of its own: nothing is left to write.
|
|
539
|
+
if (!entry) pendingWrites.delete(token);
|
|
540
|
+
else if (!upsertRow(entry)) return pendingCount();
|
|
541
|
+
} else if (!removeRow(token)) {
|
|
542
|
+
return pendingCount();
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
for (const [conversationId, closedTokens] of [...pendingConversationRemovals]) {
|
|
546
|
+
// Only what was there when the delete was asked for: a preview
|
|
547
|
+
// registered under the same conversation id since then is in memory, and
|
|
548
|
+
// its row stays.
|
|
549
|
+
const ok = storeWrite(conversationId, 'removeForConversation', conversationId, {
|
|
550
|
+
exceptTokens: liveTokensOfConversation(conversationId),
|
|
551
|
+
});
|
|
552
|
+
if (!ok) {
|
|
553
|
+
pendingConversationRemovals.delete(conversationId);
|
|
554
|
+
pendingConversationRemovals.set(conversationId, closedTokens);
|
|
555
|
+
return pendingCount();
|
|
556
|
+
}
|
|
557
|
+
pendingConversationRemovals.delete(conversationId);
|
|
558
|
+
}
|
|
559
|
+
return pendingCount();
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function emitChange(reason, entry, record = entry ? toPublicRecord(entry) : null) {
|
|
291
563
|
if (typeof onChange !== 'function') return;
|
|
292
564
|
try {
|
|
293
|
-
onChange({ reason, preview:
|
|
565
|
+
onChange({ reason, preview: record, previews: list() });
|
|
294
566
|
} catch {}
|
|
295
567
|
}
|
|
296
568
|
|
|
297
569
|
function list() {
|
|
298
|
-
return [
|
|
299
|
-
.
|
|
300
|
-
.map(
|
|
570
|
+
return [
|
|
571
|
+
...[...entries.values()].map(toPublicRecord),
|
|
572
|
+
...[...kept.values()].map(keptRecord),
|
|
573
|
+
].sort((a, b) => a.createdAt - b.createdAt);
|
|
301
574
|
}
|
|
302
575
|
|
|
303
576
|
function listForConversation(conversationId) {
|
|
@@ -306,40 +579,142 @@ export function createPreviewRegistry({
|
|
|
306
579
|
return list().filter((entry) => entry.conversationId === id);
|
|
307
580
|
}
|
|
308
581
|
|
|
309
|
-
function
|
|
310
|
-
const token = createPreviewToken({ randomBytes });
|
|
582
|
+
function buildEntry(token, fields) {
|
|
311
583
|
const basePath = previewBasePath(token);
|
|
312
|
-
|
|
584
|
+
return {
|
|
313
585
|
token,
|
|
314
586
|
basePath,
|
|
587
|
+
// Rebuilt from the current settings on every boot, so a changed public
|
|
588
|
+
// address moves every restored link with it.
|
|
315
589
|
url: `${settings.publicBaseUrl}${basePath}`,
|
|
316
590
|
createdAt: now(),
|
|
591
|
+
lastHitAt: null,
|
|
592
|
+
persistedHitAt: null,
|
|
593
|
+
hitWriteAt: null,
|
|
317
594
|
lastSeenOnline: null,
|
|
318
595
|
hits: 0,
|
|
319
596
|
...fields,
|
|
320
597
|
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function insertEntry(fields) {
|
|
601
|
+
const token = createPreviewToken({ randomBytes });
|
|
602
|
+
const entry = buildEntry(token, fields);
|
|
321
603
|
entries.set(token, entry);
|
|
604
|
+
upsertRow(entry);
|
|
322
605
|
emitChange('created', entry);
|
|
323
606
|
return { ok: true, preview: toPublicRecord(entry) };
|
|
324
607
|
}
|
|
325
608
|
|
|
326
|
-
function
|
|
609
|
+
function findSameTarget(fields) {
|
|
610
|
+
const key = sameTargetKey(fields);
|
|
611
|
+
for (const entry of entries.values()) {
|
|
612
|
+
if (sameTargetKey(entry) === key) return entry;
|
|
613
|
+
}
|
|
614
|
+
return null;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function findKeptSameTarget(fields) {
|
|
618
|
+
const key = sameTargetKey(fields);
|
|
619
|
+
for (const held of kept.values()) {
|
|
620
|
+
if (sameTargetKey(held.row) === key) return held;
|
|
621
|
+
}
|
|
622
|
+
return null;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// A registration of a kept row's target has just passed the target's own
|
|
626
|
+
// checks (the folder jail, the port rules), so the row comes back under its
|
|
627
|
+
// token, if its conversation still exists. Two rows do not come back and
|
|
628
|
+
// are dropped: one whose conversation is gone, and a port row from an
|
|
629
|
+
// earlier host boot, whose old link must not reach whatever listens on the
|
|
630
|
+
// port now. -> the result of create, or null (go on as without the row).
|
|
631
|
+
function reviveKept(held, fields) {
|
|
632
|
+
const { row } = held;
|
|
633
|
+
const conversationId = toText(row.conversationId);
|
|
634
|
+
if (conversationId && typeof checkConversation === 'function') {
|
|
635
|
+
let exists;
|
|
636
|
+
try {
|
|
637
|
+
exists = checkConversation(conversationId);
|
|
638
|
+
} catch (error) {
|
|
639
|
+
held.reason = `conversation check failed: ${errorText(error)}`;
|
|
640
|
+
return {
|
|
641
|
+
ok: false,
|
|
642
|
+
status: 503,
|
|
643
|
+
error: 'This preview is stored but could not be checked yet; try again in a moment.',
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
if (!exists) {
|
|
647
|
+
kept.delete(row.token);
|
|
648
|
+
dropRow(row, 'its conversation no longer exists');
|
|
649
|
+
return null;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
kept.delete(row.token);
|
|
653
|
+
if (fields.mode === 'port' && !isSameHostBoot(row.hostBootId, hostBootId)) {
|
|
654
|
+
dropRow(row, 'port preview from an earlier host boot');
|
|
655
|
+
return null;
|
|
656
|
+
}
|
|
657
|
+
const entry = buildEntry(row.token, {
|
|
658
|
+
...fields,
|
|
659
|
+
label: toText(row.label).slice(0, 120) || fields.label,
|
|
660
|
+
createdAt: Number.isFinite(row.createdAt) ? row.createdAt : now(),
|
|
661
|
+
lastHitAt: Number.isFinite(row.lastHitAt) ? row.lastHitAt : null,
|
|
662
|
+
persistedHitAt: Number.isFinite(row.lastHitAt) ? row.lastHitAt : null,
|
|
663
|
+
stored: true,
|
|
664
|
+
});
|
|
665
|
+
entries.set(entry.token, entry);
|
|
666
|
+
upsertRow(entry);
|
|
667
|
+
emitChange('restored', entry);
|
|
668
|
+
return { ok: true, existing: true, preview: toPublicRecord(entry) };
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function describeTarget(entry) {
|
|
672
|
+
if (entry.mode === 'static') {
|
|
673
|
+
return `folder ${toText(entry.rootDir).split(/[\\/]/).filter(Boolean).pop() || entry.rootDir}`;
|
|
674
|
+
}
|
|
675
|
+
return `port ${entry.targetPort}`;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function tooManyError() {
|
|
679
|
+
const at = now();
|
|
680
|
+
const oldest = list()
|
|
681
|
+
.slice(0, 3)
|
|
682
|
+
.map((entry) => `"${entry.label}" (${describeTarget(entry)}, published ${formatAge(at - entry.createdAt)}, `
|
|
683
|
+
+ `${entry.lastHitAt ? `last opened ${formatAge(at - entry.lastHitAt)}` : 'never opened'})`);
|
|
684
|
+
// More can be live than the limit (rows restored after it was lowered).
|
|
685
|
+
const toClose = Math.max(1, entries.size + kept.size - settings.maxLive + 1);
|
|
686
|
+
return {
|
|
687
|
+
ok: false,
|
|
688
|
+
status: 429,
|
|
689
|
+
error: `Too many live previews (max ${settings.maxLive}); previews stay until closed. `
|
|
690
|
+
+ `Oldest: ${oldest.join('; ')}. Close ${toClose === 1 ? 'one' : toClose} in Settings → Previews, with the preview tool's `
|
|
691
|
+
+ `close action (list shows the tokens), or with DELETE /api/previews/:token.`,
|
|
692
|
+
};
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
// A second create for the same conversation and the same target returns the
|
|
696
|
+
// live preview instead of minting another token, so every link handed out
|
|
697
|
+
// for that target stays one link.
|
|
698
|
+
function createOrReuse(fields) {
|
|
327
699
|
if (!settings.enabled) {
|
|
328
700
|
return { ok: false, status: 503, error: 'Preview lane is disabled' };
|
|
329
701
|
}
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
702
|
+
const existing = findSameTarget(fields);
|
|
703
|
+
if (existing) return { ok: true, existing: true, preview: toPublicRecord(existing) };
|
|
704
|
+
const held = findKeptSameTarget(fields);
|
|
705
|
+
const revived = held ? reviveKept(held, fields) : null;
|
|
706
|
+
if (revived) return revived;
|
|
707
|
+
// Kept rows count: each of them may be a live link again at any moment.
|
|
708
|
+
if (entries.size + kept.size >= settings.maxLive) return tooManyError();
|
|
709
|
+
return insertEntry(fields);
|
|
334
710
|
}
|
|
335
711
|
|
|
336
712
|
function create({ conversationId, port, host, label } = {}) {
|
|
337
|
-
|
|
338
|
-
if (!creatable.ok) return creatable;
|
|
713
|
+
if (!settings.enabled) return { ok: false, status: 503, error: 'Preview lane is disabled' };
|
|
339
714
|
const target = validatePreviewTarget({ host, port }, settings);
|
|
340
715
|
if (!target.ok) return { ok: false, status: 400, error: target.error };
|
|
341
716
|
|
|
342
|
-
return
|
|
717
|
+
return createOrReuse({
|
|
343
718
|
mode: 'port',
|
|
344
719
|
conversationId: toText(conversationId) || null,
|
|
345
720
|
label: toText(label).slice(0, 120) || `localhost:${target.port}`,
|
|
@@ -355,12 +730,11 @@ export function createPreviewRegistry({
|
|
|
355
730
|
// workspace root) — the route owns that check because it has fs access and
|
|
356
731
|
// the workspace resolver; the registry stays fs-free.
|
|
357
732
|
function createStatic({ conversationId, rootDir, label } = {}) {
|
|
358
|
-
|
|
359
|
-
if (!creatable.ok) return creatable;
|
|
733
|
+
if (!settings.enabled) return { ok: false, status: 503, error: 'Preview lane is disabled' };
|
|
360
734
|
const root = toText(rootDir);
|
|
361
735
|
if (!root) return { ok: false, status: 400, error: 'Missing rootDir' };
|
|
362
736
|
|
|
363
|
-
return
|
|
737
|
+
return createOrReuse({
|
|
364
738
|
mode: 'static',
|
|
365
739
|
conversationId: toText(conversationId) || null,
|
|
366
740
|
label: toText(label).slice(0, 120) || root.split(/[\\/]/).filter(Boolean).pop() || 'static preview',
|
|
@@ -391,11 +765,41 @@ export function createPreviewRegistry({
|
|
|
391
765
|
return entry;
|
|
392
766
|
}
|
|
393
767
|
|
|
768
|
+
// The throttle runs on the time of the last attempt, so a failing store is
|
|
769
|
+
// not hammered once per request; a hit that did not get written is written
|
|
770
|
+
// by the next attempt or by flushHits.
|
|
771
|
+
// -> false when the write failed.
|
|
772
|
+
function persistHit(entry, at = now()) {
|
|
773
|
+
if (!entry.lastHitAt || entry.persistedHitAt === entry.lastHitAt) return true;
|
|
774
|
+
entry.hitWriteAt = at;
|
|
775
|
+
const hitAt = entry.lastHitAt;
|
|
776
|
+
if (!storeWrite(`hit:${entry.token}`, 'touch', entry.token, hitAt)) return false;
|
|
777
|
+
entry.persistedHitAt = hitAt;
|
|
778
|
+
return true;
|
|
779
|
+
}
|
|
780
|
+
|
|
394
781
|
function recordHit(token, at = now()) {
|
|
395
782
|
const entry = entries.get(toText(token));
|
|
396
783
|
if (!entry) return;
|
|
397
784
|
entry.hits += 1;
|
|
398
785
|
entry.lastHitAt = at;
|
|
786
|
+
if (entry.hitWriteAt === null || at - entry.hitWriteAt >= hitPersistIntervalMs) {
|
|
787
|
+
persistHit(entry, at);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Writes the hits the throttle held back and retries failed writes; called
|
|
792
|
+
// on shutdown, which it must not hold up: it gives up at the first write
|
|
793
|
+
// that fails and waits only briefly on a locked database.
|
|
794
|
+
function flushHits() {
|
|
795
|
+
const flush = () => {
|
|
796
|
+
if (retryPendingWrites() > 0) return;
|
|
797
|
+
for (const entry of entries.values()) {
|
|
798
|
+
if (!persistHit(entry)) return;
|
|
799
|
+
}
|
|
800
|
+
};
|
|
801
|
+
if (typeof store?.withBusyTimeout === 'function') store.withBusyTimeout(FLUSH_BUSY_TIMEOUT_MS, flush);
|
|
802
|
+
else flush();
|
|
399
803
|
}
|
|
400
804
|
|
|
401
805
|
function markHealth(token, online, at = now()) {
|
|
@@ -411,8 +815,19 @@ export function createPreviewRegistry({
|
|
|
411
815
|
|
|
412
816
|
function close(token) {
|
|
413
817
|
const entry = entries.get(toText(token));
|
|
414
|
-
if (!entry)
|
|
818
|
+
if (!entry) {
|
|
819
|
+
const held = kept.get(toText(token));
|
|
820
|
+
if (!held) return { ok: false, status: 404, error: 'Unknown preview' };
|
|
821
|
+
const record = keptRecord(held);
|
|
822
|
+
kept.delete(record.token);
|
|
823
|
+
removeRow(record.token);
|
|
824
|
+
emitChange('closed', null, record);
|
|
825
|
+
return { ok: true, preview: record };
|
|
826
|
+
}
|
|
415
827
|
entries.delete(entry.token);
|
|
828
|
+
// A row that never reached the store needs no delete.
|
|
829
|
+
if (entry.stored) removeRow(entry.token);
|
|
830
|
+
else pendingWrites.delete(entry.token);
|
|
416
831
|
emitChange('closed', entry);
|
|
417
832
|
return { ok: true, preview: toPublicRecord(entry) };
|
|
418
833
|
}
|
|
@@ -421,28 +836,263 @@ export function createPreviewRegistry({
|
|
|
421
836
|
const id = toText(conversationId);
|
|
422
837
|
if (!id) return 0;
|
|
423
838
|
let closed = 0;
|
|
839
|
+
const closedTokens = new Set(pendingConversationRemovals.get(id) || []);
|
|
424
840
|
for (const entry of [...entries.values()]) {
|
|
425
841
|
if (entry.conversationId !== id) continue;
|
|
426
842
|
entries.delete(entry.token);
|
|
843
|
+
// Covered by the conversation-wide delete below (or its retry).
|
|
844
|
+
pendingWrites.delete(entry.token);
|
|
845
|
+
closedTokens.add(entry.token);
|
|
846
|
+
closed += 1;
|
|
847
|
+
}
|
|
848
|
+
for (const [token, held] of [...kept]) {
|
|
849
|
+
if (toText(held.row.conversationId) !== id) continue;
|
|
850
|
+
kept.delete(token);
|
|
851
|
+
pendingWrites.delete(token);
|
|
852
|
+
closedTokens.add(token);
|
|
427
853
|
closed += 1;
|
|
428
854
|
}
|
|
855
|
+
// Also rows this runtime never loaded (lane off at boot).
|
|
856
|
+
if (storeWrite(id, 'removeForConversation', id)) pendingConversationRemovals.delete(id);
|
|
857
|
+
else pendingConversationRemovals.set(id, closedTokens);
|
|
429
858
|
if (closed > 0) emitChange('closed', null);
|
|
430
859
|
return closed;
|
|
431
860
|
}
|
|
432
861
|
|
|
862
|
+
// Drops the in-memory set only; the stored rows stay. Not part of shutdown
|
|
863
|
+
// any more: a restart restores the set from the store.
|
|
433
864
|
function clear() {
|
|
434
|
-
const had = entries.size > 0;
|
|
865
|
+
const had = entries.size + kept.size > 0;
|
|
435
866
|
entries.clear();
|
|
867
|
+
kept.clear();
|
|
436
868
|
if (had) emitChange('cleared', null);
|
|
437
869
|
}
|
|
438
870
|
|
|
871
|
+
function dropRow(row, reason) {
|
|
872
|
+
removeRow(row.token);
|
|
873
|
+
logger?.warn?.(`[preview] not restored ${previewLogId(row.token)} "${toText(row.label)}": ${reason}`);
|
|
874
|
+
return { outcome: 'dropped' };
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// A check that threw, or answered with an fs error rather than a "no",
|
|
878
|
+
// proves nothing about the row: it stays, unserved, and is checked again.
|
|
879
|
+
// Logged once, not on every check.
|
|
880
|
+
function skipRow(row, reason) {
|
|
881
|
+
const held = kept.get(row?.token);
|
|
882
|
+
if (held) {
|
|
883
|
+
held.reason = reason;
|
|
884
|
+
return { outcome: 'skipped' };
|
|
885
|
+
}
|
|
886
|
+
logger?.warn?.(`[preview] not restored ${previewLogId(row?.token)} "${toText(row?.label)}", `
|
|
887
|
+
+ `kept and checked again: ${reason}`);
|
|
888
|
+
if (typeof row?.token === 'string' && row.token === toText(row.token) && isPreviewToken(row.token)) {
|
|
889
|
+
kept.set(row.token, { row, since: now(), reason });
|
|
890
|
+
}
|
|
891
|
+
return { outcome: 'skipped' };
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
function errorText(error) {
|
|
895
|
+
return error?.message || String(error);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
// -> { outcome: 'restored' | 'dropped' | 'skipped' | 'present' }
|
|
899
|
+
function restoreRow(row) {
|
|
900
|
+
// Only the canonical form is ever written; a row in another form (hand
|
|
901
|
+
// written, upper case) is deleted by the value it is stored under.
|
|
902
|
+
if (typeof row.token !== 'string' || row.token !== toText(row.token) || !isPreviewToken(row.token)) {
|
|
903
|
+
return dropRow(row, 'invalid token');
|
|
904
|
+
}
|
|
905
|
+
if (entries.has(row.token)) return { outcome: 'present' };
|
|
906
|
+
const conversationId = toText(row.conversationId) || null;
|
|
907
|
+
if (conversationId && typeof checkConversation === 'function') {
|
|
908
|
+
let exists;
|
|
909
|
+
try {
|
|
910
|
+
exists = checkConversation(conversationId);
|
|
911
|
+
} catch (error) {
|
|
912
|
+
return skipRow(row, `conversation check failed: ${errorText(error)}`);
|
|
913
|
+
}
|
|
914
|
+
if (!exists) return dropRow(row, 'its conversation no longer exists');
|
|
915
|
+
}
|
|
916
|
+
let fields;
|
|
917
|
+
let rewrite = false;
|
|
918
|
+
if (row.mode === 'static') {
|
|
919
|
+
let rootDir = toText(row.rootDir);
|
|
920
|
+
if (!rootDir) return dropRow(row, 'no folder');
|
|
921
|
+
if (typeof checkStaticRoot === 'function') {
|
|
922
|
+
let checked;
|
|
923
|
+
try {
|
|
924
|
+
checked = checkStaticRoot(rootDir, conversationId);
|
|
925
|
+
} catch (error) {
|
|
926
|
+
return skipRow(row, `folder check failed: ${errorText(error)}`);
|
|
927
|
+
}
|
|
928
|
+
if (!checked?.ok && checked?.transient) return skipRow(row, checked.error || 'folder check failed');
|
|
929
|
+
if (!checked?.ok) return dropRow(row, checked?.error || 'folder refused');
|
|
930
|
+
if (checked.rootDir && checked.rootDir !== rootDir) {
|
|
931
|
+
rootDir = checked.rootDir;
|
|
932
|
+
rewrite = true;
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
fields = { mode: 'static', targetHost: null, targetPort: null, rootDir, online: true };
|
|
936
|
+
} else if (row.mode === 'port') {
|
|
937
|
+
if (!isSameHostBoot(row.hostBootId, hostBootId)) {
|
|
938
|
+
return dropRow(row, 'port preview from an earlier host boot');
|
|
939
|
+
}
|
|
940
|
+
const target = validatePreviewTarget({ host: row.targetHost, port: row.targetPort }, settings);
|
|
941
|
+
if (!target.ok) return dropRow(row, target.error);
|
|
942
|
+
fields = { mode: 'port', targetHost: target.host, targetPort: target.port, rootDir: null, online: null };
|
|
943
|
+
// An uptime-based id matches within a tolerance; storing the current one
|
|
944
|
+
// keeps clock drift from adding up across restarts.
|
|
945
|
+
rewrite = toText(row.hostBootId) !== toText(hostBootId);
|
|
946
|
+
} else {
|
|
947
|
+
return dropRow(row, 'unknown mode');
|
|
948
|
+
}
|
|
949
|
+
const entry = buildEntry(row.token, {
|
|
950
|
+
...fields,
|
|
951
|
+
conversationId,
|
|
952
|
+
label: toText(row.label).slice(0, 120) || (fields.mode === 'static' ? 'static preview' : `localhost:${fields.targetPort}`),
|
|
953
|
+
createdAt: Number.isFinite(row.createdAt) ? row.createdAt : now(),
|
|
954
|
+
lastHitAt: Number.isFinite(row.lastHitAt) ? row.lastHitAt : null,
|
|
955
|
+
persistedHitAt: Number.isFinite(row.lastHitAt) ? row.lastHitAt : null,
|
|
956
|
+
stored: true,
|
|
957
|
+
});
|
|
958
|
+
entries.set(entry.token, entry);
|
|
959
|
+
if (rewrite) upsertRow(entry);
|
|
960
|
+
return { outcome: 'restored' };
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
// Checks the kept rows again; called on the health probe's beat, so a
|
|
964
|
+
// failure that passes heals without a restart. A row is restored, dropped
|
|
965
|
+
// by the answer of its check, or, when it has stayed unjudgeable for
|
|
966
|
+
// keptRowMaxAgeMs, dropped and logged. Anything that may have blocked for
|
|
967
|
+
// a database timeout ends the pass: a row that still cannot be judged (it
|
|
968
|
+
// moves to the end of the line), or a write that failed. A row past the
|
|
969
|
+
// bound is not deleted here but left to the next retry of pending writes,
|
|
970
|
+
// so a pass waits once at most.
|
|
971
|
+
// -> how many rows are still kept.
|
|
972
|
+
function recheckKeptRows() {
|
|
973
|
+
if (!settings.enabled || kept.size === 0) return kept.size;
|
|
974
|
+
let changed = false;
|
|
975
|
+
const failuresBefore = storeWriteFailures;
|
|
976
|
+
for (const [token, held] of [...kept]) {
|
|
977
|
+
if (storeWriteFailures !== failuresBefore) break;
|
|
978
|
+
let result;
|
|
979
|
+
try {
|
|
980
|
+
result = restoreRow(held.row);
|
|
981
|
+
} catch (error) {
|
|
982
|
+
result = skipRow(held.row, `restore failed: ${errorText(error)}`);
|
|
983
|
+
}
|
|
984
|
+
if (result.outcome !== 'skipped') {
|
|
985
|
+
kept.delete(token);
|
|
986
|
+
changed = true;
|
|
987
|
+
if (result.outcome === 'restored') {
|
|
988
|
+
logger?.log?.(`[preview] restored ${previewLogId(token)} "${toText(held.row.label)}" on a later check`);
|
|
989
|
+
}
|
|
990
|
+
continue;
|
|
991
|
+
}
|
|
992
|
+
kept.delete(token);
|
|
993
|
+
if (now() - held.since >= keptRowMaxAgeMs) {
|
|
994
|
+
setPending(token, 'remove');
|
|
995
|
+
changed = true;
|
|
996
|
+
logger?.warn?.(`[preview] dropped ${previewLogId(token)} "${toText(held.row.label)}": `
|
|
997
|
+
+ `could not be checked for ${Math.round(keptRowMaxAgeMs / 60_000)} min (${held.reason})`);
|
|
998
|
+
} else {
|
|
999
|
+
kept.set(token, held);
|
|
1000
|
+
}
|
|
1001
|
+
break;
|
|
1002
|
+
}
|
|
1003
|
+
if (changed) emitChange('rechecked', null);
|
|
1004
|
+
return kept.size;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// The store's upkeep on the health probe's beat: failed writes first, and
|
|
1008
|
+
// the kept rows only when none of them failed again, so that a sweep on a
|
|
1009
|
+
// locked database waits for one timeout and not for one per step.
|
|
1010
|
+
function maintain() {
|
|
1011
|
+
const failuresBefore = storeWriteFailures;
|
|
1012
|
+
retryPendingWrites();
|
|
1013
|
+
if (storeWriteFailures === failuresBefore) recheckKeptRows();
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// The stored tokens as last read while the lane is off; null until a read
|
|
1017
|
+
// went through.
|
|
1018
|
+
let storedTokens = null;
|
|
1019
|
+
let storedTokensReadFailed = false;
|
|
1020
|
+
|
|
1021
|
+
// Every token that is or may again become a working link, for the redaction
|
|
1022
|
+
// of shared transcripts:
|
|
1023
|
+
// - live and kept previews;
|
|
1024
|
+
// - rows that are still stored because their delete has not landed yet (a
|
|
1025
|
+
// closed preview, a deleted conversation, a kept row past its bound): a
|
|
1026
|
+
// crash before the delete lands brings them back;
|
|
1027
|
+
// - with the lane off (nothing loaded), the stored rows. A read that fails
|
|
1028
|
+
// there is never taken for "no tokens": the last list that was read
|
|
1029
|
+
// stays in use, and without one this throws, so the caller shares nothing
|
|
1030
|
+
// rather than a transcript it could not redact.
|
|
1031
|
+
function knownTokens() {
|
|
1032
|
+
const tokens = new Set([...entries.keys(), ...kept.keys()]);
|
|
1033
|
+
for (const [token, op] of pendingWrites) {
|
|
1034
|
+
if (op === 'remove') tokens.add(token);
|
|
1035
|
+
}
|
|
1036
|
+
for (const closedTokens of pendingConversationRemovals.values()) {
|
|
1037
|
+
for (const token of closedTokens) tokens.add(token);
|
|
1038
|
+
}
|
|
1039
|
+
if (!settings.enabled && store && typeof store.list === 'function') {
|
|
1040
|
+
try {
|
|
1041
|
+
const rows = store.list() || [];
|
|
1042
|
+
storedTokens = rows.map((row) => toText(row?.token)).filter((token) => isPreviewToken(token));
|
|
1043
|
+
storedTokensReadFailed = false;
|
|
1044
|
+
} catch (error) {
|
|
1045
|
+
if (!storedTokensReadFailed) {
|
|
1046
|
+
logger?.warn?.(`[preview] stored previews could not be read for redaction, `
|
|
1047
|
+
+ `${storedTokens ? 'the last list stays in use' : 'none was read yet'}: ${errorText(error)}`);
|
|
1048
|
+
}
|
|
1049
|
+
storedTokensReadFailed = true;
|
|
1050
|
+
if (!storedTokens) throw new Error('The stored previews could not be read');
|
|
1051
|
+
}
|
|
1052
|
+
for (const token of storedTokens) tokens.add(token);
|
|
1053
|
+
}
|
|
1054
|
+
return [...tokens];
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
// Restores the stored set. With the lane off nothing is loaded or served and
|
|
1058
|
+
// the rows stay untouched for the next boot that has it on.
|
|
1059
|
+
function restore() {
|
|
1060
|
+
if (!settings.enabled || !store) return { restored: 0, dropped: 0, skipped: 0 };
|
|
1061
|
+
const rows = storeCall('list') || [];
|
|
1062
|
+
const counts = { restored: 0, dropped: 0, skipped: 0 };
|
|
1063
|
+
for (const row of rows) {
|
|
1064
|
+
let result;
|
|
1065
|
+
try {
|
|
1066
|
+
result = restoreRow(row);
|
|
1067
|
+
} catch (error) {
|
|
1068
|
+
result = skipRow(row, `restore failed: ${errorText(error)}`);
|
|
1069
|
+
}
|
|
1070
|
+
if (counts[result.outcome] !== undefined) counts[result.outcome] += 1;
|
|
1071
|
+
}
|
|
1072
|
+
if (rows.length > 0) {
|
|
1073
|
+
logger?.log?.(`[preview] restored ${counts.restored} preview(s)`
|
|
1074
|
+
+ `${counts.dropped ? `, dropped ${counts.dropped}` : ''}`
|
|
1075
|
+
+ `${counts.skipped ? `, kept ${counts.skipped} to check again` : ''}`);
|
|
1076
|
+
}
|
|
1077
|
+
return counts;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
restore();
|
|
1081
|
+
|
|
439
1082
|
return {
|
|
440
1083
|
settings,
|
|
441
1084
|
get size() { return entries.size; },
|
|
1085
|
+
get keptCount() { return kept.size; },
|
|
442
1086
|
create,
|
|
443
1087
|
createStatic,
|
|
444
1088
|
resolve,
|
|
445
1089
|
recordHit,
|
|
1090
|
+
flushHits,
|
|
1091
|
+
retryPendingWrites,
|
|
1092
|
+
recheckKeptRows,
|
|
1093
|
+
maintain,
|
|
1094
|
+
knownTokens,
|
|
1095
|
+
get pendingWriteCount() { return pendingWrites.size + pendingConversationRemovals.size; },
|
|
446
1096
|
markHealth,
|
|
447
1097
|
close,
|
|
448
1098
|
closeForConversation,
|