diff --git a/index.js b/index.js index de2973a..d56cc61 100644 --- a/index.js +++ b/index.js @@ -388,7 +388,11 @@ import { import { buildAuthorityDiagnosticsBundle, buildAuthorityDiagnosticsBundlePath, + buildAuthorityDiagnosticsManifestPath, buildAuthorityPerformanceBaseline, + readAuthorityDiagnosticsManifest, + removeAuthorityDiagnosticsManifestEntry, + upsertAuthorityDiagnosticsManifestEntry, writeAuthorityDiagnosticsBundle as writeAuthorityDiagnosticsBundleFile, } from "./maintenance/authority-diagnostics-bundle.js"; @@ -2019,6 +2023,19 @@ function getGraphPersistenceLiveState() { authorityDiagnosticsBundleSize: Number( graphPersistenceState.authorityDiagnosticsBundleSize || 0, ), + authorityDiagnosticsManifestPath: String( + graphPersistenceState.authorityDiagnosticsManifestPath || "", + ), + authorityDiagnosticsArtifacts: cloneRuntimeDebugValue( + graphPersistenceState.authorityDiagnosticsArtifacts, + [], + ), + authorityDiagnosticsArtifactsUpdatedAt: String( + graphPersistenceState.authorityDiagnosticsArtifactsUpdatedAt || "", + ), + authorityDiagnosticsArtifactsError: String( + graphPersistenceState.authorityDiagnosticsArtifactsError || "", + ), }; return cloneRuntimeDebugValue(snapshot, snapshot); @@ -2372,6 +2389,7 @@ async function exportAuthorityDiagnosticsBundle(options = {}) { performanceBaseline: baseline, }); const path = buildAuthorityDiagnosticsBundlePath(chatId, reason); + const manifestPath = buildAuthorityDiagnosticsManifestPath(chatId); const adapter = getAuthorityBlobAdapter(); try { const result = await writeAuthorityDiagnosticsBundleFile(adapter, bundle, { @@ -2391,6 +2409,27 @@ async function exportAuthorityDiagnosticsBundle(options = {}) { return 0; } })(); + const manifestEntry = { + chatId, + path: String(result?.path || path), + reason, + size: bundleSize, + bundleVersion: Number(bundle?.bundleVersion || 1), + createdAt: String(bundle?.createdAt || updatedAt), + updatedAt, + }; + const manifestResult = await upsertAuthorityDiagnosticsManifestEntry(adapter, manifestEntry, { + chatId, + path: manifestPath, + signal: options.signal, + }).catch(() => null); + const nextArtifactEntries = manifestResult?.entries || [ + manifestEntry, + ...((Array.isArray(graphPersistenceState.authorityDiagnosticsArtifacts) + ? graphPersistenceState.authorityDiagnosticsArtifacts + : [] + ).filter((entry) => String(entry?.path || "") !== manifestEntry.path)), + ]; recordAuthorityBlobSnapshot({ action: "diagnostics-write", ok: result?.ok !== false, @@ -2406,6 +2445,12 @@ async function exportAuthorityDiagnosticsBundle(options = {}) { authorityDiagnosticsBundleReason: reason, authorityDiagnosticsBundleUpdatedAt: updatedAt, authorityDiagnosticsBundleSize: bundleSize, + authorityDiagnosticsManifestPath: String(manifestResult?.path || manifestPath), + authorityDiagnosticsArtifacts: cloneRuntimeDebugValue(nextArtifactEntries, []), + authorityDiagnosticsArtifactsUpdatedAt: String( + manifestResult?.manifest?.updatedAt || updatedAt, + ), + authorityDiagnosticsArtifactsError: "", }); if (options.refreshHost !== false) { refreshPanelLiveState(); @@ -2436,6 +2481,173 @@ async function exportAuthorityDiagnosticsBundle(options = {}) { } } +async function refreshAuthorityDiagnosticsArtifacts(options = {}) { + const chatId = normalizeChatIdCandidate( + options.chatId || getCurrentChatId() || graphPersistenceState.chatId, + ); + if (!chatId) { + return { + ok: false, + error: "missing-chat-id", + }; + } + if (!shouldUseAuthorityDiagnosticsBundle()) { + return { + ok: false, + error: "authority-diagnostics-unavailable", + }; + } + const adapter = getAuthorityBlobAdapter(); + const manifestPath = buildAuthorityDiagnosticsManifestPath(chatId); + try { + const result = await readAuthorityDiagnosticsManifest(adapter, { + chatId, + path: manifestPath, + signal: options.signal, + }); + updateGraphPersistenceState({ + authorityDiagnosticsManifestPath: String(result?.path || manifestPath), + authorityDiagnosticsArtifacts: cloneRuntimeDebugValue(result?.entries, []), + authorityDiagnosticsArtifactsUpdatedAt: String( + result?.manifest?.updatedAt || new Date().toISOString(), + ), + authorityDiagnosticsArtifactsError: "", + }); + if (options.refreshHost !== false) { + refreshPanelLiveState(); + } + return { + ok: true, + entries: result?.entries || [], + path: String(result?.path || manifestPath), + }; + } catch (error) { + const message = error?.message || String(error) || "Authority diagnostics manifest failed"; + updateGraphPersistenceState({ + authorityDiagnosticsManifestPath: manifestPath, + authorityDiagnosticsArtifactsError: message, + authorityDiagnosticsArtifactsUpdatedAt: new Date().toISOString(), + }); + if (options.refreshHost !== false) { + refreshPanelLiveState(); + } + return { + ok: false, + error: message, + }; + } +} + +async function readAuthorityDiagnosticsArtifact(path = "", options = {}) { + const normalizedPath = String(path || "").trim(); + if (!normalizedPath) { + return { + ok: false, + error: "missing-artifact-path", + }; + } + const adapter = getAuthorityBlobAdapter(); + try { + const result = await adapter.readJson(normalizedPath, { + signal: options.signal, + }); + if (!result?.exists || !result?.payload) { + return { + ok: false, + error: "artifact-not-found", + }; + } + return { + ok: true, + path: String(result.path || normalizedPath), + payload: result.payload, + result, + }; + } catch (error) { + return { + ok: false, + error: error?.message || String(error) || "Authority diagnostics artifact read failed", + }; + } +} + +async function deleteAuthorityDiagnosticsArtifact(path = "", options = {}) { + const normalizedPath = String(path || "").trim(); + const chatId = normalizeChatIdCandidate( + options.chatId || getCurrentChatId() || graphPersistenceState.chatId, + ); + if (!normalizedPath) { + return { + ok: false, + error: "missing-artifact-path", + }; + } + const adapter = getAuthorityBlobAdapter(); + const manifestPath = buildAuthorityDiagnosticsManifestPath(chatId); + try { + const deleteResult = await adapter.delete(normalizedPath, { + signal: options.signal, + }); + const manifestResult = chatId + ? await removeAuthorityDiagnosticsManifestEntry(adapter, normalizedPath, { + chatId, + path: manifestPath, + signal: options.signal, + }).catch(() => null) + : null; + const updatedAt = new Date().toISOString(); + const wasLatestArtifact = + String(graphPersistenceState.authorityDiagnosticsBundlePath || "") === normalizedPath; + const nextArtifactEntries = manifestResult?.entries || + (Array.isArray(graphPersistenceState.authorityDiagnosticsArtifacts) + ? graphPersistenceState.authorityDiagnosticsArtifacts + : [] + ).filter((entry) => String(entry?.path || "") !== normalizedPath); + updateGraphPersistenceState({ + authorityDiagnosticsManifestPath: String(manifestResult?.path || manifestPath), + authorityDiagnosticsArtifacts: cloneRuntimeDebugValue(nextArtifactEntries, []), + authorityDiagnosticsArtifactsUpdatedAt: String( + manifestResult?.manifest?.updatedAt || updatedAt, + ), + authorityDiagnosticsArtifactsError: "", + authorityDiagnosticsBundlePath: wasLatestArtifact ? "" : graphPersistenceState.authorityDiagnosticsBundlePath, + authorityDiagnosticsBundleReason: wasLatestArtifact ? "" : graphPersistenceState.authorityDiagnosticsBundleReason, + authorityDiagnosticsBundleUpdatedAt: wasLatestArtifact ? "" : graphPersistenceState.authorityDiagnosticsBundleUpdatedAt, + authorityDiagnosticsBundleSize: wasLatestArtifact ? 0 : graphPersistenceState.authorityDiagnosticsBundleSize, + }); + recordAuthorityBlobSnapshot({ + action: "diagnostics-delete", + ok: deleteResult?.ok !== false, + backend: "authority-blob", + path: normalizedPath, + reason: "manual-diagnostics-delete", + }); + if (options.refreshHost !== false) { + refreshPanelLiveState(); + } + return { + ok: deleteResult?.ok !== false, + deleted: deleteResult?.deleted === true, + missing: deleteResult?.missing === true, + path: normalizedPath, + entries: manifestResult?.entries || [], + }; + } catch (error) { + const message = error?.message || String(error) || "Authority diagnostics artifact delete failed"; + updateGraphPersistenceState({ + authorityDiagnosticsArtifactsError: message, + authorityDiagnosticsArtifactsUpdatedAt: new Date().toISOString(), + }); + if (options.refreshHost !== false) { + refreshPanelLiveState(); + } + return { + ok: false, + error: message, + }; + } +} + async function writeAuthorityLukerCheckpointBlob( checkpoint = null, { chatId = "", reason = "luker-checkpoint", signal = undefined } = {}, @@ -21512,6 +21724,24 @@ async function onCaptureAuthorityPerformanceBaseline() { }); } +async function onRefreshAuthorityDiagnosticsArtifacts() { + return await refreshAuthorityDiagnosticsArtifacts({ + refreshHost: true, + }); +} + +async function onReadAuthorityDiagnosticsArtifact(path = "") { + return await readAuthorityDiagnosticsArtifact(path, { + refreshHost: true, + }); +} + +async function onDeleteAuthorityDiagnosticsArtifact(path = "") { + return await deleteAuthorityDiagnosticsArtifact(path, { + refreshHost: true, + }); +} + async function onReembedDirect() { return await onReembedDirectController({ getEmbeddingConfig, @@ -22095,6 +22325,9 @@ async function onCompactLukerSidecar() { writeAuthorityCheckpoint: onWriteAuthorityCheckpoint, restoreAuthorityCheckpoint: onRestoreAuthorityCheckpoint, captureAuthorityPerformanceBaseline: onCaptureAuthorityPerformanceBaseline, + refreshAuthorityDiagnosticsArtifacts: onRefreshAuthorityDiagnosticsArtifacts, + readAuthorityDiagnosticsArtifact: onReadAuthorityDiagnosticsArtifact, + deleteAuthorityDiagnosticsArtifact: onDeleteAuthorityDiagnosticsArtifact, reembedDirect: onReembedDirect, reroll: onReroll, clearGraph: onClearGraph, diff --git a/maintenance/authority-diagnostics-bundle.js b/maintenance/authority-diagnostics-bundle.js index f29c1e1..ecdf0ac 100644 --- a/maintenance/authority-diagnostics-bundle.js +++ b/maintenance/authority-diagnostics-bundle.js @@ -56,6 +56,9 @@ function buildCompactTimestamp(date = new Date()) { return `${year}${month}${day}-${hour}${minute}${second}`; } +const AUTHORITY_DIAGNOSTICS_MANIFEST_VERSION = 1; +const AUTHORITY_DIAGNOSTICS_MANIFEST_LIMIT = 12; + function isSensitiveKey(key = "") { return /(api[_-]?key|token|secret|password|authorization|auth[_-]?header|cookie)/i.test( String(key || ""), @@ -302,6 +305,227 @@ export function buildAuthorityDiagnosticsBundlePath(chatId = "", reason = "diagn return `user/files/ST-BME_diagnostics_${safeChatId}-${safeReason}-${hash}-${timestamp}.json`; } +function normalizeIsoTimestamp(value = "") { + const text = String(value || "").trim(); + if (!text) return ""; + const date = new Date(text); + return Number.isNaN(date.getTime()) ? "" : date.toISOString(); +} + +function normalizeManifestLimit(value, fallback = AUTHORITY_DIAGNOSTICS_MANIFEST_LIMIT) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 1) return fallback; + return Math.max(1, Math.trunc(parsed)); +} + +export function buildAuthorityDiagnosticsManifestPath(chatId = "") { + const normalizedChatId = normalizeRecordId(chatId); + const safeChatId = buildSafeSlug(normalizedChatId || "global"); + const hash = buildHash(`${normalizedChatId}:diagnostics-manifest`); + return `user/files/ST-BME_diagnostics_manifest_${safeChatId}-${hash}.json`; +} + +export function normalizeAuthorityDiagnosticsArtifactRecord(record = null) { + if (!record || typeof record !== "object" || Array.isArray(record)) { + return null; + } + const path = normalizeRecordId(record.path); + if (!path) return null; + const createdAt = + normalizeIsoTimestamp(record.createdAt) || normalizeIsoTimestamp(record.updatedAt); + const updatedAt = + normalizeIsoTimestamp(record.updatedAt) || createdAt || new Date().toISOString(); + return { + kind: "diagnostics-bundle", + chatId: normalizeRecordId(record.chatId), + path, + reason: String(record.reason || "diagnostics-bundle"), + size: Math.max(0, Number(record.size || 0) || 0), + bundleVersion: Math.max(1, Number(record.bundleVersion || 1) || 1), + createdAt: createdAt || updatedAt, + updatedAt, + }; +} + +function buildAuthorityDiagnosticsManifestEntries(entries = [], limit = AUTHORITY_DIAGNOSTICS_MANIFEST_LIMIT) { + const normalizedEntries = []; + const seenPaths = new Set(); + for (const entry of Array.isArray(entries) ? entries : []) { + const normalized = normalizeAuthorityDiagnosticsArtifactRecord(entry); + if (!normalized || seenPaths.has(normalized.path)) continue; + seenPaths.add(normalized.path); + normalizedEntries.push(normalized); + } + normalizedEntries.sort((left, right) => { + const leftTime = Date.parse(left.updatedAt || left.createdAt || 0) || 0; + const rightTime = Date.parse(right.updatedAt || right.createdAt || 0) || 0; + if (rightTime !== leftTime) return rightTime - leftTime; + return String(left.path).localeCompare(String(right.path)); + }); + return normalizedEntries.slice(0, normalizeManifestLimit(limit)); +} + +function normalizeAuthorityDiagnosticsManifest(manifest = null, options = {}) { + const source = manifest && typeof manifest === "object" && !Array.isArray(manifest) ? manifest : {}; + const chatId = normalizeRecordId(options.chatId || source.chatId); + const entries = buildAuthorityDiagnosticsManifestEntries( + source.entries, + options.limit, + ); + const updatedAt = + normalizeIsoTimestamp(options.updatedAt || source.updatedAt) || + entries[0]?.updatedAt || + new Date().toISOString(); + return { + kind: "st-bme-authority-diagnostics-manifest", + version: AUTHORITY_DIAGNOSTICS_MANIFEST_VERSION, + chatId, + updatedAt, + entries, + }; +} + +export async function readAuthorityDiagnosticsManifest(adapter, options = {}) { + if (!adapter || typeof adapter.readJson !== "function") { + throw new Error("Authority diagnostics adapter unavailable"); + } + const chatId = normalizeRecordId(options.chatId); + const path = String(options.path || buildAuthorityDiagnosticsManifestPath(chatId)); + const result = await adapter.readJson(path, { + signal: options.signal, + }); + if (!result?.exists || !result?.payload) { + const manifest = normalizeAuthorityDiagnosticsManifest(null, { + chatId, + updatedAt: options.updatedAt, + limit: options.limit, + }); + return { + ok: true, + exists: false, + path, + entries: manifest.entries, + manifest, + result, + }; + } + const manifest = normalizeAuthorityDiagnosticsManifest(result.payload, { + chatId, + updatedAt: options.updatedAt, + limit: options.limit, + }); + return { + ok: true, + exists: true, + path, + entries: manifest.entries, + manifest, + result, + }; +} + +export async function writeAuthorityDiagnosticsManifest(adapter, manifest = null, options = {}) { + if (!adapter || typeof adapter.writeJson !== "function") { + throw new Error("Authority diagnostics adapter unavailable"); + } + const chatId = normalizeRecordId(options.chatId || manifest?.chatId); + const path = String(options.path || buildAuthorityDiagnosticsManifestPath(chatId)); + const normalizedManifest = normalizeAuthorityDiagnosticsManifest(manifest, { + chatId, + updatedAt: options.updatedAt, + limit: options.limit, + }); + const result = await adapter.writeJson(path, normalizedManifest, { + signal: options.signal, + metadata: safeClone( + { + chatId, + kind: "diagnostics-manifest", + entryCount: normalizedManifest.entries.length, + updatedAt: normalizedManifest.updatedAt, + }, + {}, + ), + }); + return { + ok: result?.ok !== false, + path: String(result?.path || path), + manifest: normalizedManifest, + entries: normalizedManifest.entries, + result, + }; +} + +export async function upsertAuthorityDiagnosticsManifestEntry(adapter, entry = null, options = {}) { + const normalizedEntry = normalizeAuthorityDiagnosticsArtifactRecord(entry); + if (!normalizedEntry) { + throw new Error("Authority diagnostics artifact entry unavailable"); + } + const current = await readAuthorityDiagnosticsManifest(adapter, { + chatId: options.chatId || normalizedEntry.chatId, + path: options.path, + signal: options.signal, + limit: options.limit, + }); + const entries = buildAuthorityDiagnosticsManifestEntries( + [normalizedEntry, ...current.entries.filter((item) => item.path !== normalizedEntry.path)], + options.limit, + ); + const writeResult = await writeAuthorityDiagnosticsManifest( + adapter, + { + ...current.manifest, + chatId: + normalizeRecordId(options.chatId || normalizedEntry.chatId || current.manifest?.chatId), + updatedAt: normalizedEntry.updatedAt || new Date().toISOString(), + entries, + }, + { + chatId: options.chatId || normalizedEntry.chatId, + path: current.path, + signal: options.signal, + limit: options.limit, + }, + ); + return { + ...writeResult, + entry: normalizedEntry, + }; +} + +export async function removeAuthorityDiagnosticsManifestEntry(adapter, artifactPath = "", options = {}) { + const normalizedPath = normalizeRecordId(artifactPath); + if (!normalizedPath) { + throw new Error("Authority diagnostics artifact path is required"); + } + const current = await readAuthorityDiagnosticsManifest(adapter, { + chatId: options.chatId, + path: options.path, + signal: options.signal, + limit: options.limit, + }); + const entries = current.entries.filter((entry) => entry.path !== normalizedPath); + const removed = entries.length !== current.entries.length; + const writeResult = await writeAuthorityDiagnosticsManifest( + adapter, + { + ...current.manifest, + updatedAt: options.updatedAt || new Date().toISOString(), + entries, + }, + { + chatId: options.chatId || current.manifest?.chatId, + path: current.path, + signal: options.signal, + limit: options.limit, + }, + ); + return { + ...writeResult, + removed, + }; +} + export function buildAuthorityDiagnosticsBundle({ chatId = "", reason = "diagnostics-bundle", diff --git a/tests/authority-diagnostics-bundle.mjs b/tests/authority-diagnostics-bundle.mjs index ec5cd6f..874819c 100644 --- a/tests/authority-diagnostics-bundle.mjs +++ b/tests/authority-diagnostics-bundle.mjs @@ -3,16 +3,23 @@ import assert from "node:assert/strict"; import { buildAuthorityDiagnosticsBundle, buildAuthorityDiagnosticsBundlePath, + buildAuthorityDiagnosticsManifestPath, buildAuthorityPerformanceBaseline, + readAuthorityDiagnosticsManifest, + removeAuthorityDiagnosticsManifestEntry, sanitizeDiagnosticsSettings, + upsertAuthorityDiagnosticsManifestEntry, writeAuthorityDiagnosticsBundle, } from "../maintenance/authority-diagnostics-bundle.js"; function createMockAdapter() { const calls = []; + const storage = new Map(); return { calls, + storage, async writeJson(path, payload, options = {}) { + storage.set(path, structuredClone(payload)); calls.push([path, payload, options]); return { ok: true, @@ -20,6 +27,28 @@ function createMockAdapter() { size: JSON.stringify(payload).length, }; }, + async readJson(path) { + if (!storage.has(path)) { + return { + exists: false, + path, + }; + } + return { + exists: true, + path, + payload: structuredClone(storage.get(path)), + }; + }, + async delete(path) { + const existed = storage.delete(path); + return { + ok: true, + path, + deleted: existed, + missing: !existed, + }; + }, }; } @@ -204,6 +233,14 @@ function createMockAdapter() { ); } +{ + const manifestPath = buildAuthorityDiagnosticsManifestPath("chat/main"); + assert.match( + manifestPath, + /^user\/files\/ST-BME_diagnostics_manifest_chat_main-[a-z0-9]+\.json$/, + ); +} + { const adapter = createMockAdapter(); const bundle = buildAuthorityDiagnosticsBundle({ @@ -225,4 +262,38 @@ function createMockAdapter() { assert.equal(adapter.calls[0][2]?.metadata?.chatId, "chat-main"); } +{ + const adapter = createMockAdapter(); + await upsertAuthorityDiagnosticsManifestEntry(adapter, { + chatId: "chat-main", + path: "user/files/diag-a.json", + reason: "manual-export", + size: 100, + updatedAt: "2026-01-01T00:00:00.000Z", + }); + await upsertAuthorityDiagnosticsManifestEntry(adapter, { + chatId: "chat-main", + path: "user/files/diag-b.json", + reason: "scheduled-export", + size: 120, + updatedAt: "2026-01-02T00:00:00.000Z", + }); + const readResult = await readAuthorityDiagnosticsManifest(adapter, { + chatId: "chat-main", + }); + assert.equal(readResult.exists, true); + assert.equal(readResult.entries.length, 2); + assert.equal(readResult.entries[0].path, "user/files/diag-b.json"); + assert.equal(readResult.entries[1].path, "user/files/diag-a.json"); + + const removeResult = await removeAuthorityDiagnosticsManifestEntry( + adapter, + "user/files/diag-a.json", + { chatId: "chat-main" }, + ); + assert.equal(removeResult.removed, true); + assert.equal(removeResult.entries.length, 1); + assert.equal(removeResult.entries[0].path, "user/files/diag-b.json"); +} + console.log("authority-diagnostics-bundle tests passed"); diff --git a/ui/panel.js b/ui/panel.js index 8278ff6..c12df2f 100644 --- a/ui/panel.js +++ b/ui/panel.js @@ -3132,6 +3132,13 @@ function _refreshTaskPersistence() { ? _formatTaskProfileTime(ps.authorityDiagnosticsBundleUpdatedAt) : "—"; const authorityBundleSizeLabel = _formatDataSizeBytes(ps.authorityDiagnosticsBundleSize); + const authorityArtifactEntries = Array.isArray(ps.authorityDiagnosticsArtifacts) + ? ps.authorityDiagnosticsArtifacts.filter((entry) => entry && typeof entry === "object") + : []; + const authorityArtifactManifestPathLabel = String(ps.authorityDiagnosticsManifestPath || "").trim() || "—"; + const authorityArtifactHistoryUpdatedLabel = ps.authorityDiagnosticsArtifactsUpdatedAt + ? _formatTaskProfileTime(ps.authorityDiagnosticsArtifactsUpdatedAt) + : "—"; const activeRegionLabel = String( historyState?.activeRegion || historyState?.lastExtractedRegion || @@ -3247,6 +3254,9 @@ function _refreshTaskPersistence() { ["诊断包大小", authorityBundleSizeLabel], ["诊断包时间", authorityBundleUpdatedLabel], ["诊断包原因", ps.authorityDiagnosticsBundleReason || "—"], + ["诊断清单", authorityArtifactManifestPathLabel], + ["工件记录", `${authorityArtifactEntries.length} 条`], + ["列表刷新", authorityArtifactHistoryUpdatedLabel], ]; const authorityAuditActions = Array.isArray(ps.authorityConsistencyAudit?.actions) ? ps.authorityConsistencyAudit.actions.map((value) => String(value || "").trim()).filter(Boolean) @@ -3275,7 +3285,43 @@ function _refreshTaskPersistence() { typeof _actionHandlers.exportDiagnosticsBundle === "function" ? `` : "", + typeof _actionHandlers.refreshAuthorityDiagnosticsArtifacts === "function" + ? `` + : "", ].filter(Boolean).join(""); + const authorityArtifactsHtml = authorityArtifactEntries.length + ? ` +