fix: recover expired task leases and persist diagnostic logs
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
/* Bounded, local-only diagnostics. Never stores question or answer contents. */
|
||||
(function (root) {
|
||||
'use strict';
|
||||
const KEY = 'diagnosticLog.v1';
|
||||
const ENABLED_KEY = 'diagnosticLog.enabled';
|
||||
const LIMIT = 2000;
|
||||
const DETAIL_KEYS = new Set(('action attempt attemptId attempts autoAnswer autoContinuePlayback available bankTotal bankVersion bankRevision blocked canAnswer canExport completed confirmed count currentTime deadline delay destination details dropped duration elapsed elapsedSeconds enabled epoch error errorName event execution expected expires expiresAt failed fresh generation hasTask heartbeatAt holder id interval kind lastActivityAt lastProgressAt lease leaseKey leaseOwner leases leaseWaiting loaded manualConfirmIncomplete maxAttempts maxRefresh message minSubmitMinutes mode name nextTask now owner ownerId ownOwner page pageId pageKind pageUrl path paused pending predictedScore previous previousOwner previousStatus previousReason progress rate reason remaining remainingAttempts remainingSeconds requested result retryCount role route running scope seconds selected sequence session sessionId signature since singleAttempt skipSingleAttemptQuiz skipped source stack stage stale startedAt status stopReason submitted task taskCount taskId taskKey taskTitle taskType tasks time title total totalAttempts totalQuestions type updatedAt url verified verifiedCount version video visible visibility waitedMs waiting waitReason worker workerId workers').split(' '));
|
||||
const PRIVATE_KEY = /password|passwd|token|cookie|authorization|credential|secret|^(?:answers?|correctTexts?|questions?|options?|题干|题目|答案|选项)$/i;
|
||||
function safeURL(value) {
|
||||
try { const parsed = new URL(String(value)); return /^(https?|file):$/.test(parsed.protocol) ? parsed.origin + parsed.pathname : '[已省略地址]'; }
|
||||
catch (_) { return String(value).split(/[?#]/)[0].slice(0, 400); }
|
||||
}
|
||||
function text(value, maximum) {
|
||||
let string;
|
||||
try { string = String(value == null ? '' : value); } catch (_) { return '[无法读取]'; }
|
||||
return string
|
||||
.replace(/https?:\/\/[^\s<>"']+/gi, safeURL)
|
||||
.replace(/\b(Bearer|Basic)\s+[A-Za-z0-9._~+/=-]+/gi, '$1 [已隐藏]')
|
||||
.replace(/\b(password|passwd|token|cookie|authorization|secret)\s*[:=]\s*[^\s,;]+/gi, '$1=[已隐藏]')
|
||||
.slice(0, maximum || 700);
|
||||
}
|
||||
function sanitize(value, depth, seen) {
|
||||
if (value == null || typeof value === 'boolean') return value;
|
||||
if (typeof value === 'number') return Number.isFinite(value) ? value : null;
|
||||
if (typeof value === 'string') return text(value);
|
||||
if (typeof value !== 'object' || depth > 4 || seen.has(value)) return null;
|
||||
seen.add(value);
|
||||
let result;
|
||||
if (Array.isArray(value)) result = value.slice(0, 30).map(function (entry) { return sanitize(entry, depth + 1, seen); });
|
||||
else {
|
||||
result = {};
|
||||
Object.keys(value).slice(0, 100).forEach(function (key) {
|
||||
if (PRIVATE_KEY.test(key) || !DETAIL_KEYS.has(key)) return;
|
||||
if (typeof value[key] === 'function' || typeof value[key] === 'undefined') return;
|
||||
result[key] = /^(url|pageUrl|path|route|destination)$/.test(key) && typeof value[key] === 'string' ? safeURL(value[key]) : sanitize(value[key], depth + 1, seen);
|
||||
});
|
||||
}
|
||||
seen.delete(value);
|
||||
return result;
|
||||
}
|
||||
function clean(value) { try { return sanitize(value, 0, new Set()); } catch (_) { return {}; } }
|
||||
function create(options) {
|
||||
options = options || {};
|
||||
const store = options.storage || { name: 'memory', get: async function (_, fallback) { return fallback; }, set: async function () {} };
|
||||
const now = typeof options.now === 'function' ? options.now : Date.now;
|
||||
const session = Math.random().toString(36).slice(2, 10) + '-' + Date.now().toString(36);
|
||||
let sequence = 0, records = [], pending = [], enabled = true, initialized = false, destroyed = false;
|
||||
let dropped = 0, clearedAt = -1, timer = null, errorCount = 0, storageError = '', queue = Promise.resolve();
|
||||
let settingRevision = 0;
|
||||
function timestamp() { const value = Number(now()); return Number.isFinite(value) ? value : Date.now(); }
|
||||
function emit() { if (typeof options.onState === 'function') { try { options.onState(getState()); } catch (_) { /* UI failures must not interrupt tasks. */ } } }
|
||||
function report(error) {
|
||||
storageError = text(error && error.message || error || '日志存储失败', 240);
|
||||
if (errorCount++ < 1 && typeof options.onError === 'function') { try { options.onError(new Error(storageError)); } catch (_) { /* Do not recurse through diagnostics. */ } }
|
||||
}
|
||||
function validate(data) {
|
||||
if (!data || data.schema !== 1 || !Array.isArray(data.records)) return { records: [], dropped: 0, clearedAt: -1 };
|
||||
const cutoff = Number.isFinite(data.clearedAt) ? data.clearedAt : -1;
|
||||
return { records: data.records.filter(function (item) { return item && typeof item.id === 'string' && Number.isFinite(item.time) && item.time > cutoff; }).slice(-LIMIT).map(function (item) {
|
||||
return { id: text(item.id, 100), time: item.time, lastTime: Number.isFinite(item.lastTime) ? item.lastTime : item.time,
|
||||
level: ['info', 'warning', 'error'].includes(item.level) ? item.level : 'info', event: text(item.event, 100), message: text(item.message), details: clean(item.details), context: clean(item.context), count: Math.max(1, Math.min(1000000, Math.floor(Number(item.count) || 1))) };
|
||||
}), dropped: Math.max(0, Number(data.dropped) || 0), clearedAt: cutoff };
|
||||
}
|
||||
function merge(left, right, cutoff) {
|
||||
const map = new Map();
|
||||
left.concat(right).forEach(function (item) {
|
||||
if (item.time <= cutoff) return;
|
||||
const old = map.get(item.id);
|
||||
if (!old || item.count > old.count || item.lastTime > old.lastTime) map.set(item.id, item);
|
||||
});
|
||||
const result = Array.from(map.values()).sort(function (a, b) { return a.time - b.time || a.id.localeCompare(b.id); });
|
||||
const removed = Math.max(0, result.length - LIMIT);
|
||||
return { records: result.slice(-LIMIT), removed: removed };
|
||||
}
|
||||
const ready = Promise.resolve().then(async function () {
|
||||
const revision = settingRevision;
|
||||
try {
|
||||
const values = await Promise.all([store.get(KEY, null), store.get(ENABLED_KEY, true)]);
|
||||
const saved = validate(values[0]);
|
||||
if (revision === 0 && settingRevision === 0) enabled = values[1] !== false;
|
||||
clearedAt = saved.clearedAt; dropped = saved.dropped;
|
||||
records = merge(saved.records, enabled ? records : [], clearedAt).records;
|
||||
if (!enabled) pending = [];
|
||||
} catch (error) { report(error); }
|
||||
initialized = true;
|
||||
if (pending.length) schedule();
|
||||
emit();
|
||||
return getState();
|
||||
});
|
||||
function getState() {
|
||||
return { enabled: enabled, total: records.length, dropped: dropped, limit: LIMIT, storage: store.name || 'local', storageError: storageError, initialized: initialized, pending: pending.length, sessionId: session };
|
||||
}
|
||||
function schedule() {
|
||||
if (timer || destroyed || !initialized) return;
|
||||
timer = root.setTimeout(function () { timer = null; flush().catch(function () {}); }, 450);
|
||||
if (timer && typeof timer.unref === 'function') timer.unref();
|
||||
}
|
||||
function record(level, event, message, details) {
|
||||
if (!enabled || destroyed) return false;
|
||||
let context = {};
|
||||
try { context = typeof options.getContext === 'function' ? clean(options.getContext()) : {}; } catch (_) { /* Logging may never interrupt a task. */ }
|
||||
const time = Math.max(timestamp(), clearedAt + 1);
|
||||
const entry = { id: session + '.' + (++sequence), time: time, lastTime: time,
|
||||
level: level === 'warn' ? 'warning' : ['info', 'warning', 'error'].includes(level) ? level : 'info',
|
||||
event: /^[\w.:-]{1,100}$/.test(String(event || '')) ? String(event) : 'diagnostic', message: text(message), details: clean(details || {}), context: context, count: 1 };
|
||||
const previous = records[records.length - 1];
|
||||
if (previous && previous.id.startsWith(session + '.') && time - previous.lastTime >= 0 && time - previous.lastTime <= 2000 && JSON.stringify([previous.level, previous.event, previous.message, previous.details, previous.context]) === JSON.stringify([entry.level, entry.event, entry.message, entry.details, entry.context])) {
|
||||
previous.count = Math.min(1000000, previous.count + 1); previous.lastTime = time;
|
||||
pending.push(Object.assign({}, previous));
|
||||
} else { records.push(entry); pending.push(entry); }
|
||||
// Both the visible buffer and unsaved batch are bounded, even if storage is unavailable.
|
||||
if (records.length > LIMIT) { dropped += records.length - LIMIT; records = records.slice(-LIMIT); }
|
||||
const batch = new Map(); pending.forEach(function (item) { batch.set(item.id, item); }); pending = Array.from(batch.values()).slice(-LIMIT);
|
||||
schedule(); return true;
|
||||
}
|
||||
function lock(action) {
|
||||
const locks = root.navigator && root.navigator.locks;
|
||||
return locks && typeof locks.request === 'function' ? locks.request('yianxue-diagnostic-log-v1', action) : action();
|
||||
}
|
||||
function flush() {
|
||||
if (timer) { root.clearTimeout(timer); timer = null; }
|
||||
queue = queue.then(async function () {
|
||||
await ready;
|
||||
try {
|
||||
await lock(async function () {
|
||||
const saved = validate(await store.get(KEY, null));
|
||||
const cutoff = Math.max(clearedAt, saved.clearedAt);
|
||||
const merged = merge(saved.records, records, cutoff);
|
||||
const nextDropped = Math.max(dropped, saved.dropped + merged.removed);
|
||||
const payload = JSON.parse(JSON.stringify({ schema: 1, records: merged.records, dropped: nextDropped, clearedAt: cutoff }));
|
||||
if (pending.length) await store.set(KEY, payload);
|
||||
const savedIds = new Map(payload.records.map(function (item) { return [item.id, [item.count, item.lastTime]]; }));
|
||||
pending = pending.filter(function (item) { const value = savedIds.get(item.id); return !value || item.count > value[0] || item.lastTime > value[1]; });
|
||||
const local = merge(payload.records, pending, cutoff); records = local.records; dropped = Math.max(dropped, nextDropped + local.removed); clearedAt = cutoff;
|
||||
storageError = '';
|
||||
});
|
||||
} catch (error) { report(error); }
|
||||
emit();
|
||||
return getState();
|
||||
});
|
||||
return queue;
|
||||
}
|
||||
async function setEnabled(value) {
|
||||
settingRevision++; enabled = value !== false;
|
||||
await ready;
|
||||
if (!enabled) await flush();
|
||||
try { await store.set(ENABLED_KEY, enabled); } catch (error) { report(error); }
|
||||
emit();
|
||||
return getState();
|
||||
}
|
||||
function getRecords() { return JSON.parse(JSON.stringify(records)); }
|
||||
async function clear() {
|
||||
await ready; await flush();
|
||||
queue = queue.then(async function () { try { return await lock(async function () {
|
||||
clearedAt = Math.max(timestamp(), records.reduce(function (latest, item) { return Math.max(latest, item.time); }, -1));
|
||||
records = []; pending = []; dropped = 0;
|
||||
try { await store.set(KEY, { schema: 1, records: [], dropped: 0, clearedAt: clearedAt }); storageError = ''; } catch (error) { report(error); }
|
||||
emit();
|
||||
return getState();
|
||||
}); } catch (error) { report(error); return getState(); } });
|
||||
return queue;
|
||||
}
|
||||
async function exportJSON() {
|
||||
await flush();
|
||||
return JSON.stringify({ schema: 1, exportedAt: new Date(timestamp()).toISOString(), state: getState(), records: getRecords() }, null, 2);
|
||||
}
|
||||
async function exportText() {
|
||||
await flush();
|
||||
const state = getState();
|
||||
return '谊安学助手诊断日志\n导出时间:' + new Date(timestamp()).toISOString() + '\n已保存:' + state.total + ' 条;超出容量移除:' + state.dropped + ' 条\n\n' + records.map(function (item) {
|
||||
return new Date(item.time).toISOString() + ' [' + item.level + '] ' + item.event + ' ' + item.message + (item.count > 1 ? '(重复 ' + item.count + ' 次,最后 ' + new Date(item.lastTime).toISOString() + ')' : '') + '\n context=' + JSON.stringify(item.context) + '\n details=' + JSON.stringify(item.details);
|
||||
}).join('\n');
|
||||
}
|
||||
async function destroy() { destroyed = true; if (timer) { root.clearTimeout(timer); timer = null; } return flush(); }
|
||||
return { ready: ready, record: record, setEnabled: setEnabled, getState: getState, getRecords: getRecords, clear: clear, exportJSON: exportJSON, exportText: exportText, flush: flush, destroy: destroy };
|
||||
}
|
||||
const api = Object.freeze({ create: create, key: KEY, enabledKey: ENABLED_KEY, limit: LIMIT });
|
||||
root.YianDiagnosticLogger = api;
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
Reference in New Issue
Block a user