diff --git a/index.js b/index.js index 8e80f10..93dc79a 100644 --- a/index.js +++ b/index.js @@ -249,6 +249,16 @@ import { getPersistedSettingsSnapshot, mergePersistedSettings, } from "./runtime/settings-defaults.js"; +import { + createDefaultAuthorityCapabilityState, + normalizeAuthorityCapabilityState, + probeAuthorityCapabilities, +} from "./runtime/authority-capabilities.js"; +import { + createAuthorityBrowserState, + getAuthorityBrowserStateSnapshot, + normalizeAuthorityBrowserState, +} from "./sync/authority-browser-state.js"; import { retrieve } from "./retrieval/retriever.js"; import { applyProcessedHistorySnapshotToGraph, @@ -1234,6 +1244,9 @@ let lastExtractionStatus = createUiStatus("待命", "尚未执行提取", "idle" let lastVectorStatus = createUiStatus("待命", "尚未执行向量任务", "idle"); let lastRecallStatus = createUiStatus("待命", "尚未执行召回", "idle"); let graphPersistenceState = createGraphPersistenceState(); +let authorityCapabilityState = createDefaultAuthorityCapabilityState(); +let authorityBrowserState = createAuthorityBrowserState(); +let authorityProbePromise = null; const lastStatusToastAt = {}; let pendingRecallSendIntent = createRecallInputRecord(); let lastRecallSentUserMessage = createRecallInputRecord(); @@ -1437,6 +1450,115 @@ function isLukerPrimaryPersistenceHost(context = getContext()) { return resolvePersistenceHostProfile(context) === "luker"; } +function getAuthorityRuntimeSnapshot(settings = getSettings()) { + authorityCapabilityState = normalizeAuthorityCapabilityState( + authorityCapabilityState, + settings, + ); + authorityBrowserState = normalizeAuthorityBrowserState( + authorityBrowserState, + settings, + ); + return { + capability: authorityCapabilityState, + browserState: getAuthorityBrowserStateSnapshot(authorityBrowserState, settings), + }; +} + +function buildAuthorityPersistenceStatePatch(settings = getSettings()) { + const { capability, browserState } = getAuthorityRuntimeSnapshot(settings); + return { + authority: cloneRuntimeDebugValue(capability, null), + authorityBrowserState: cloneRuntimeDebugValue(browserState, null), + authorityInstalled: Boolean(capability.installed), + authorityHealthy: Boolean(capability.healthy), + authorityServerPrimaryReady: Boolean(capability.serverPrimaryReady), + authorityStoragePrimaryReady: Boolean(capability.storagePrimaryReady), + authorityTriviumPrimaryReady: Boolean(capability.triviumPrimaryReady), + authorityBrowserCacheMode: String(browserState.mode || "minimal"), + authorityOfflineQueueBytes: Number(browserState.offlineQueueBytes || 0), + authorityOfflineQueueItems: Number(browserState.offlineQueueItems || 0), + authorityDegradedReason: capability.serverPrimaryReady + ? "" + : String(capability.reason || capability.lastError || ""), + }; +} + +async function refreshAuthorityRuntimeState({ + force = false, + source = "authority-refresh", +} = {}) { + if (authorityProbePromise && !force) { + return await authorityProbePromise; + } + const settings = getSettings(); + authorityBrowserState = normalizeAuthorityBrowserState( + authorityBrowserState, + settings, + ); + updateGraphPersistenceState({ + ...buildAuthorityPersistenceStatePatch(settings), + authorityLastRefreshSource: String(source || "authority-refresh"), + }); + + const allowRelativeUrl = + typeof window === "object" && + Boolean(window?.location) && + typeof window.location.href === "string"; + authorityProbePromise = probeAuthorityCapabilities({ + settings, + fetchImpl: + typeof globalThis.fetch === "function" + ? globalThis.fetch.bind(globalThis) + : null, + headerProvider: + typeof getRequestHeaders === "function" ? getRequestHeaders : null, + allowRelativeUrl, + nowMs: Date.now(), + }) + .then((snapshot) => { + authorityCapabilityState = normalizeAuthorityCapabilityState( + snapshot, + settings, + ); + authorityBrowserState = normalizeAuthorityBrowserState( + { + ...authorityBrowserState, + lastProbeAt: authorityCapabilityState.lastProbeAt, + lastError: authorityCapabilityState.lastError, + }, + settings, + ); + updateGraphPersistenceState({ + ...buildAuthorityPersistenceStatePatch(settings), + authorityLastRefreshSource: String(source || "authority-refresh"), + }); + return authorityCapabilityState; + }) + .catch((error) => { + authorityCapabilityState = normalizeAuthorityCapabilityState( + { + installed: false, + healthy: false, + reason: "probe-failed", + lastError: error?.message || String(error), + lastProbeAt: Date.now(), + updatedAt: new Date().toISOString(), + }, + settings, + ); + updateGraphPersistenceState({ + ...buildAuthorityPersistenceStatePatch(settings), + authorityLastRefreshSource: String(source || "authority-refresh"), + }); + return authorityCapabilityState; + }) + .finally(() => { + authorityProbePromise = null; + }); + return await authorityProbePromise; +} + function getGraphPersistenceLiveState() { const liveCommitMarker = cloneRuntimeDebugValue(graphPersistenceState.commitMarker, null) || @@ -1452,6 +1574,7 @@ function getGraphPersistenceLiveState() { adapterRuntime.adapter.hostProfile || persistenceEnvironment.hostProfile, ); + const authorityRuntime = getAuthorityRuntimeSnapshot(); const primaryStorageTier = normalizePersistenceStorageTier( graphPersistenceState.primaryStorageTier || persistenceEnvironment.primaryStorageTier, @@ -1515,6 +1638,38 @@ function getGraphPersistenceLiveState() { updatedAt: graphPersistenceState.updatedAt, storagePrimary: graphPersistenceState.storagePrimary || "indexeddb", storageMode: graphPersistenceState.storageMode || "indexeddb", + authority: cloneRuntimeDebugValue(authorityRuntime.capability, null), + authorityBrowserState: cloneRuntimeDebugValue( + authorityRuntime.browserState, + null, + ), + authorityInstalled: Boolean(authorityRuntime.capability.installed), + authorityHealthy: Boolean(authorityRuntime.capability.healthy), + authorityServerPrimaryReady: Boolean( + authorityRuntime.capability.serverPrimaryReady, + ), + authorityStoragePrimaryReady: Boolean( + authorityRuntime.capability.storagePrimaryReady, + ), + authorityTriviumPrimaryReady: Boolean( + authorityRuntime.capability.triviumPrimaryReady, + ), + authorityBrowserCacheMode: String( + authorityRuntime.browserState.mode || "minimal", + ), + authorityOfflineQueueBytes: Number( + authorityRuntime.browserState.offlineQueueBytes || 0, + ), + authorityOfflineQueueItems: Number( + authorityRuntime.browserState.offlineQueueItems || 0, + ), + authorityDegradedReason: authorityRuntime.capability.serverPrimaryReady + ? "" + : String( + authorityRuntime.capability.reason || + authorityRuntime.capability.lastError || + "", + ), resolvedLocalStore: String( graphPersistenceState.resolvedLocalStore || buildGraphLocalStoreSelectorKey(getPreferredGraphLocalStorePresentationSync()), @@ -13299,6 +13454,27 @@ function updateModuleSettings(patch = {}) { ]); const recallUiKeys = new Set(["recallCardUserInputDisplayMode"]); const noticeUiKeys = new Set(["noticeDisplayMode"]); + const authorityKeys = new Set([ + "authorityEnabled", + "authorityBaseUrl", + "authorityPrimaryWhenAvailable", + "authorityStorageMode", + "authorityVectorMode", + "authoritySqlPrimary", + "authorityTriviumPrimary", + "authorityGraphQueryEnabled", + "authorityJobsEnabled", + "authorityBlobCheckpointEnabled", + "authorityBrowserCacheMode", + "authorityOfflineWritePolicy", + "authorityOfflineQueueMaxBytes", + "authorityOfflineQueueMaxItems", + "authorityOfflineQueueMaxAgeMs", + "authorityVectorSyncChunkSize", + "authorityVectorFailOpen", + "authorityDiagnosticsEnabled", + "authorityProbeIntervalMs", + ]); const settings = getSettings(); const previousCloudStorageMode = String( settings.cloudStorageMode || "automatic", @@ -13380,6 +13556,13 @@ function updateModuleSettings(patch = {}) { refreshVisibleStageNotices(); } + if (Object.keys(patch).some((key) => authorityKeys.has(key))) { + void refreshAuthorityRuntimeState({ + force: true, + source: "settings-updated", + }); + } + const currentGraphLocalStorageMode = getRequestedGraphLocalStorageMode( settings, ); @@ -20007,6 +20190,10 @@ async function onCompactLukerSidecar() { (async function init() { await loadServerSettings(); + void refreshAuthorityRuntimeState({ + force: true, + source: "init", + }); const { target, lightweightHostMode, adapter } = syncBmeHostRuntimeFlags(getContext()); updateGraphPersistenceState({ hostProfile: adapter.hostProfile, diff --git a/runtime/authority-capabilities.js b/runtime/authority-capabilities.js new file mode 100644 index 0000000..bdeee38 --- /dev/null +++ b/runtime/authority-capabilities.js @@ -0,0 +1,367 @@ +const DEFAULT_AUTHORITY_BASE_URL = "/api/plugins/authority"; +const DEFAULT_AUTHORITY_PROBE_INTERVAL_MS = 60000; + +const SQL_FEATURES = ["sql", "sql.query", "sql.page", "sql.pageall", "querysql"]; +const SQL_MUTATION_FEATURES = ["sql", "sql.mutation", "sql.execute", "sql.write", "sql.transaction"]; +const TRIVIUM_FEATURES = ["trivium", "trivium.search", "trivium.query", "trivium.filterwhere", "trivium.bulkupsert"]; +const JOB_FEATURES = ["jobs", "jobs.list", "jobs.wait", "events", "sse"]; +const BLOB_FEATURES = ["blob", "blob.write", "privatefiles", "private.files", "files.private"]; + +function toBoolean(value, fallback = false) { + if (typeof value === "boolean") return value; + if (typeof value === "number") return value !== 0; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (["true", "1", "yes", "on"].includes(normalized)) return true; + if (["false", "0", "no", "off"].includes(normalized)) return false; + } + return fallback; +} + +function clampInteger(value, fallback, min, max) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return fallback; + return Math.min(max, Math.max(min, Math.trunc(numeric))); +} + +function normalizeMode(value, fallback, allowed) { + const normalized = String(value ?? fallback).trim().toLowerCase(); + return allowed.includes(normalized) ? normalized : fallback; +} + +function normalizeFeatureName(value) { + return String(value ?? "").trim().toLowerCase(); +} + +function addFeature(features, value) { + const normalized = normalizeFeatureName(value); + if (normalized) features.add(normalized); +} + +function addFeatureObject(features, value, prefix = "") { + if (!value || typeof value !== "object" || Array.isArray(value)) return; + for (const [key, enabled] of Object.entries(value)) { + if (!enabled) continue; + addFeature(features, key); + if (prefix) addFeature(features, `${prefix}.${key}`); + if (enabled && typeof enabled === "object" && !Array.isArray(enabled)) { + addFeatureObject(features, enabled, prefix ? `${prefix}.${key}` : key); + } + } +} + +function hasAnyFeature(features, aliases) { + return aliases.some((alias) => features.has(normalizeFeatureName(alias))); +} + +function createFeatureReadiness(features) { + return { + sql: hasAnyFeature(features, SQL_FEATURES), + sqlMutation: hasAnyFeature(features, SQL_MUTATION_FEATURES), + trivium: hasAnyFeature(features, TRIVIUM_FEATURES), + jobs: hasAnyFeature(features, JOB_FEATURES), + blob: hasAnyFeature(features, BLOB_FEATURES), + }; +} + +function collectMissingFeatures(readiness) { + const missing = []; + if (!readiness.sql) missing.push("sql.query"); + if (!readiness.sqlMutation) missing.push("sql.mutation"); + if (!readiness.trivium) missing.push("trivium.search"); + if (!readiness.jobs) missing.push("jobs"); + if (!readiness.blob) missing.push("blob-or-private-files"); + return missing; +} + +function isRelativeAuthorityUrl(baseUrl) { + return /^\//.test(String(baseUrl || "")); +} + +function normalizeLatencyMs(startedAt, finishedAt) { + return Math.max(0, Math.round((Number(finishedAt) - Number(startedAt)) * 10) / 10); +} + +function readNowMs() { + if (typeof performance === "object" && typeof performance.now === "function") { + return performance.now(); + } + return Date.now(); +} + +export function normalizeAuthorityBaseUrl(baseUrl = DEFAULT_AUTHORITY_BASE_URL) { + const normalized = String(baseUrl || DEFAULT_AUTHORITY_BASE_URL).trim() || DEFAULT_AUTHORITY_BASE_URL; + return normalized.replace(/\/+$/, ""); +} + +export function normalizeAuthoritySettings(settings = {}) { + const source = settings && typeof settings === "object" && !Array.isArray(settings) ? settings : {}; + const enabledMode = normalizeMode(source.authorityEnabled ?? source.enabledMode, "auto", ["auto", "on", "off", "true", "false"]); + return { + enabledMode: enabledMode === "true" ? "on" : enabledMode === "false" ? "off" : enabledMode, + enabled: enabledMode !== "off" && enabledMode !== "false", + baseUrl: normalizeAuthorityBaseUrl(source.authorityBaseUrl ?? source.baseUrl), + primaryWhenAvailable: toBoolean(source.authorityPrimaryWhenAvailable ?? source.primaryWhenAvailable, true), + storageMode: normalizeMode(source.authorityStorageMode ?? source.storageMode, "auto-server-primary", ["auto-server-primary", "server-primary", "local-primary", "off"]), + vectorMode: normalizeMode(source.authorityVectorMode ?? source.vectorMode, "auto-primary", ["auto-primary", "primary", "local-fallback", "off"]), + sqlPrimary: toBoolean(source.authoritySqlPrimary ?? source.sqlPrimary, true), + triviumPrimary: toBoolean(source.authorityTriviumPrimary ?? source.triviumPrimary, true), + jobsEnabled: toBoolean(source.authorityJobsEnabled ?? source.jobsEnabled, true), + blobCheckpointEnabled: toBoolean(source.authorityBlobCheckpointEnabled ?? source.blobCheckpointEnabled, true), + diagnosticsEnabled: toBoolean(source.authorityDiagnosticsEnabled ?? source.diagnosticsEnabled, true), + failOpen: toBoolean(source.authorityFailOpen ?? source.failOpen, true), + probeIntervalMs: clampInteger(source.authorityProbeIntervalMs ?? source.probeIntervalMs, DEFAULT_AUTHORITY_PROBE_INTERVAL_MS, 1000, 3600000), + }; +} + +export function buildAuthorityProbeUrls(baseUrl = DEFAULT_AUTHORITY_BASE_URL) { + const normalizedBaseUrl = normalizeAuthorityBaseUrl(baseUrl); + return [ + `${normalizedBaseUrl}/v1/diagnostics/probe`, + `${normalizedBaseUrl}/v1/probe`, + `${normalizedBaseUrl}/probe`, + normalizedBaseUrl, + ]; +} + +export function collectAuthorityFeatures(payload = {}) { + const features = new Set(); + const source = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : {}; + for (const value of Array.isArray(source.features) ? source.features : []) { + addFeature(features, value); + } + for (const value of Array.isArray(source.capabilities) ? source.capabilities : []) { + addFeature(features, value); + } + addFeatureObject(features, source.features); + addFeatureObject(features, source.capabilities); + addFeatureObject(features, source.services); + addFeatureObject(features, source.featureFlags); + addFeatureObject(features, source.flags); + return features; +} + +export function createDefaultAuthorityCapabilityState(overrides = {}) { + return { + enabledMode: "auto", + baseUrl: DEFAULT_AUTHORITY_BASE_URL, + installed: false, + healthy: false, + sessionReady: false, + permissionReady: false, + minimumFeatureSetReady: false, + serverPrimaryReady: false, + storagePrimaryReady: false, + triviumPrimaryReady: false, + jobsReady: false, + blobReady: false, + features: [], + missingFeatures: ["sql.query", "sql.mutation", "trivium.search", "jobs", "blob-or-private-files"], + reason: "not-probed", + lastError: "", + endpoint: "", + status: 0, + latencyMs: 0, + lastProbeAt: 0, + updatedAt: "", + ...overrides, + }; +} + +export function normalizeAuthorityCapabilityState(input = {}, settings = {}) { + const normalizedSettings = normalizeAuthoritySettings(settings); + const source = input && typeof input === "object" && !Array.isArray(input) ? input : {}; + const features = new Set((Array.isArray(source.features) ? source.features : []).map(normalizeFeatureName).filter(Boolean)); + const readiness = createFeatureReadiness(features); + const missingFeatures = Array.isArray(source.missingFeatures) && source.missingFeatures.length + ? source.missingFeatures.map(String) + : collectMissingFeatures(readiness); + const healthy = Boolean(source.healthy); + const sessionReady = source.sessionReady == null ? healthy : Boolean(source.sessionReady); + const permissionReady = source.permissionReady == null ? sessionReady : Boolean(source.permissionReady); + const storagePrimaryReady = healthy && sessionReady && permissionReady && readiness.sql && readiness.sqlMutation; + const triviumPrimaryReady = healthy && sessionReady && permissionReady && readiness.trivium; + const jobsReady = healthy && readiness.jobs; + const blobReady = healthy && readiness.blob; + const minimumFeatureSetReady = storagePrimaryReady && triviumPrimaryReady && jobsReady && blobReady; + const serverPrimaryRequested = + normalizedSettings.enabled && + normalizedSettings.primaryWhenAvailable && + normalizedSettings.storageMode !== "local-primary" && + normalizedSettings.storageMode !== "off"; + return createDefaultAuthorityCapabilityState({ + ...source, + enabledMode: normalizedSettings.enabledMode, + baseUrl: normalizedSettings.baseUrl, + installed: Boolean(source.installed), + healthy, + sessionReady, + permissionReady, + minimumFeatureSetReady, + serverPrimaryReady: serverPrimaryRequested && minimumFeatureSetReady, + storagePrimaryReady, + triviumPrimaryReady, + jobsReady, + blobReady, + features: Array.from(features).sort(), + missingFeatures, + reason: String(source.reason || (healthy ? "ok" : "not-ready")), + lastError: String(source.lastError || ""), + endpoint: String(source.endpoint || ""), + status: clampInteger(source.status, 0, 0, 999), + latencyMs: Math.max(0, Number(source.latencyMs) || 0), + lastProbeAt: Math.max(0, Number(source.lastProbeAt) || 0), + updatedAt: String(source.updatedAt || ""), + }); +} + +export function normalizeAuthorityProbeResponse(payload = {}, context = {}) { + const settings = normalizeAuthoritySettings(context.settings || {}); + const features = collectAuthorityFeatures(payload); + const readiness = createFeatureReadiness(features); + const missingFeatures = collectMissingFeatures(readiness); + const sessionReady = payload?.sessionReady ?? payload?.session?.ready ?? payload?.session?.active ?? true; + const permissionReady = payload?.permissionReady ?? payload?.permissions?.ready ?? payload?.authorized ?? sessionReady; + const healthy = payload?.healthy ?? payload?.ok ?? true; + return normalizeAuthorityCapabilityState( + { + installed: true, + healthy: Boolean(healthy), + sessionReady: Boolean(sessionReady), + permissionReady: Boolean(permissionReady), + features: Array.from(features), + missingFeatures, + reason: missingFeatures.length ? "missing-required-features" : "ok", + endpoint: context.endpoint || "", + status: context.status || 200, + latencyMs: context.latencyMs || 0, + lastProbeAt: context.nowMs || Date.now(), + updatedAt: new Date(context.nowMs || Date.now()).toISOString(), + }, + settings, + ); +} + +export async function probeAuthorityCapabilities(options = {}) { + const settings = normalizeAuthoritySettings(options.settings || {}); + const nowMs = Number(options.nowMs) || Date.now(); + if (!settings.enabled || settings.storageMode === "off") { + return normalizeAuthorityCapabilityState( + { + reason: "disabled", + lastProbeAt: nowMs, + updatedAt: new Date(nowMs).toISOString(), + }, + settings, + ); + } + + const fetchImpl = options.fetchImpl || (typeof fetch === "function" ? fetch : null); + if (typeof fetchImpl !== "function") { + return normalizeAuthorityCapabilityState( + { + reason: "fetch-unavailable", + lastError: "fetch unavailable", + lastProbeAt: nowMs, + updatedAt: new Date(nowMs).toISOString(), + }, + settings, + ); + } + + if (options.allowRelativeUrl === false && isRelativeAuthorityUrl(settings.baseUrl)) { + return normalizeAuthorityCapabilityState( + { + reason: "relative-url-unavailable", + lastError: "relative Authority URL cannot be probed in this runtime", + lastProbeAt: nowMs, + updatedAt: new Date(nowMs).toISOString(), + }, + settings, + ); + } + + let headers = { Accept: "application/json" }; + if (typeof options.headerProvider === "function") { + try { + headers = { ...headers, ...(options.headerProvider() || {}) }; + } catch { + headers = { ...headers }; + } + } + + let lastError = ""; + let lastStatus = 0; + for (const endpoint of buildAuthorityProbeUrls(settings.baseUrl)) { + const startedAt = readNowMs(); + try { + const response = await fetchImpl(endpoint, { method: "GET", headers }); + const finishedAt = readNowMs(); + const status = Number(response?.status || 0); + lastStatus = status; + if (status === 404) continue; + if (status === 401 || status === 403) { + return normalizeAuthorityCapabilityState( + { + installed: true, + healthy: false, + sessionReady: false, + permissionReady: false, + reason: "permission-denied", + lastError: `HTTP ${status}`, + endpoint, + status, + latencyMs: normalizeLatencyMs(startedAt, finishedAt), + lastProbeAt: nowMs, + updatedAt: new Date(nowMs).toISOString(), + }, + settings, + ); + } + if (!response?.ok) { + return normalizeAuthorityCapabilityState( + { + installed: status > 0, + healthy: false, + reason: "http-error", + lastError: `HTTP ${status || "unknown"}`, + endpoint, + status, + latencyMs: normalizeLatencyMs(startedAt, finishedAt), + lastProbeAt: nowMs, + updatedAt: new Date(nowMs).toISOString(), + }, + settings, + ); + } + let payload = {}; + try { + payload = typeof response.json === "function" ? await response.json() : {}; + } catch { + payload = {}; + } + return normalizeAuthorityProbeResponse(payload, { + settings, + endpoint, + status, + latencyMs: normalizeLatencyMs(startedAt, finishedAt), + nowMs, + }); + } catch (error) { + lastError = error?.message || String(error); + } + } + + return normalizeAuthorityCapabilityState( + { + installed: false, + healthy: false, + reason: lastStatus === 404 ? "not-installed" : "probe-failed", + lastError, + status: lastStatus, + lastProbeAt: nowMs, + updatedAt: new Date(nowMs).toISOString(), + }, + settings, + ); +} diff --git a/runtime/settings-defaults.js b/runtime/settings-defaults.js index a2866b0..3255e39 100644 --- a/runtime/settings-defaults.js +++ b/runtime/settings-defaults.js @@ -117,6 +117,26 @@ export const defaultSettings = { embeddingBackendApiUrl: "", embeddingAutoSuffix: true, + authorityEnabled: "auto", + authorityBaseUrl: "/api/plugins/authority", + authorityPrimaryWhenAvailable: true, + authorityStorageMode: "auto-server-primary", + authorityVectorMode: "auto-primary", + authoritySqlPrimary: true, + authorityTriviumPrimary: true, + authorityGraphQueryEnabled: true, + authorityJobsEnabled: true, + authorityBlobCheckpointEnabled: true, + authorityBrowserCacheMode: "minimal", + authorityOfflineWritePolicy: "queue-local-dirty", + authorityOfflineQueueMaxBytes: 1048576, + authorityOfflineQueueMaxItems: 128, + authorityOfflineQueueMaxAgeMs: 3600000, + authorityVectorSyncChunkSize: 1000, + authorityVectorFailOpen: true, + authorityDiagnosticsEnabled: true, + authorityProbeIntervalMs: 60000, + // Native 性能加速(灰度) graphUseNativeLayout: true, graphNativeLayoutThresholdNodes: 280, diff --git a/sync/authority-browser-state.js b/sync/authority-browser-state.js new file mode 100644 index 0000000..443b932 --- /dev/null +++ b/sync/authority-browser-state.js @@ -0,0 +1,205 @@ +const DEFAULT_MAX_BYTES = 1024 * 1024; +const DEFAULT_MAX_ITEMS = 128; +const DEFAULT_MAX_AGE_MS = 60 * 60 * 1000; + +function clampInteger(value, fallback, min, max) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return fallback; + return Math.min(max, Math.max(min, Math.trunc(numeric))); +} + +function normalizeMode(value = "minimal") { + const normalized = String(value || "minimal").trim().toLowerCase(); + return ["minimal", "off", "emergency-snapshot"].includes(normalized) + ? normalized + : "minimal"; +} + +function estimateJsonBytes(value) { + let serialized = ""; + try { + serialized = JSON.stringify(value ?? null) || "null"; + } catch { + serialized = String(value ?? ""); + } + if (typeof TextEncoder === "function") { + return new TextEncoder().encode(serialized).byteLength; + } + return serialized.length * 2; +} + +function normalizeQueueItem(item = {}, nowMs = Date.now()) { + const source = item && typeof item === "object" && !Array.isArray(item) ? item : {}; + const payload = source.payload && typeof source.payload === "object" ? source.payload : source; + const rawCreatedAt = Number(source.createdAt ?? nowMs); + const createdAt = Number.isFinite(rawCreatedAt) + ? Math.max(0, rawCreatedAt) + : nowMs; + const rawBytes = Number(source.bytes ?? estimateJsonBytes(payload)); + const bytes = Number.isFinite(rawBytes) ? Math.max(0, rawBytes) : 0; + return { + id: String(source.id || `offline-${createdAt}-${Math.random().toString(36).slice(2, 8)}`), + kind: String(source.kind || "mutation"), + reason: String(source.reason || "authority-offline"), + createdAt, + bytes, + payload, + }; +} + +function pruneQueue(queue = [], policy = {}, nowMs = Date.now()) { + const maxAgeMs = Math.max(0, Number(policy.maxAgeMs || DEFAULT_MAX_AGE_MS) || 0); + if (!Array.isArray(queue)) return []; + return queue + .map((item) => normalizeQueueItem(item, nowMs)) + .filter((item) => maxAgeMs <= 0 || nowMs - item.createdAt <= maxAgeMs) + .sort((left, right) => left.createdAt - right.createdAt); +} + +function summarizeQueue(queue = []) { + const items = Array.isArray(queue) ? queue : []; + const bytes = items.reduce((sum, item) => sum + Math.max(0, Number(item?.bytes || 0) || 0), 0); + return { + items: items.length, + bytes, + }; +} + +export function getAuthorityBrowserStoragePolicy(settings = {}) { + const source = settings && typeof settings === "object" && !Array.isArray(settings) ? settings : {}; + return { + mode: normalizeMode(source.authorityBrowserCacheMode), + offlineWritePolicy: String(source.authorityOfflineWritePolicy || "queue-local-dirty"), + maxBytes: clampInteger(source.authorityOfflineQueueMaxBytes, DEFAULT_MAX_BYTES, 0, 64 * 1024 * 1024), + maxItems: clampInteger(source.authorityOfflineQueueMaxItems, DEFAULT_MAX_ITEMS, 0, 100000), + maxAgeMs: clampInteger(source.authorityOfflineQueueMaxAgeMs, DEFAULT_MAX_AGE_MS, 0, 30 * 24 * 60 * 60 * 1000), + }; +} + +export function createAuthorityBrowserState(overrides = {}) { + return { + mode: "minimal", + serverRevision: 0, + serverIntegrity: "", + lastProbeAt: 0, + lastCommitAt: 0, + lastError: "", + offlineQueue: [], + offlineQueueBytes: 0, + offlineQueueItems: 0, + offlineQueueOverflow: false, + offlineQueueOverflowReason: "", + updatedAt: "", + ...overrides, + }; +} + +export function normalizeAuthorityBrowserState(input = {}, settings = {}, nowMs = Date.now()) { + const policy = getAuthorityBrowserStoragePolicy(settings); + const source = input && typeof input === "object" && !Array.isArray(input) ? input : {}; + const queue = pruneQueue(source.offlineQueue, policy, nowMs); + const summary = summarizeQueue(queue); + return createAuthorityBrowserState({ + mode: policy.mode, + serverRevision: Math.max(0, Number(source.serverRevision || 0) || 0), + serverIntegrity: String(source.serverIntegrity || ""), + lastProbeAt: Math.max(0, Number(source.lastProbeAt || 0) || 0), + lastCommitAt: Math.max(0, Number(source.lastCommitAt || 0) || 0), + lastError: String(source.lastError || ""), + offlineQueue: queue, + offlineQueueBytes: summary.bytes, + offlineQueueItems: summary.items, + offlineQueueOverflow: Boolean(source.offlineQueueOverflow), + offlineQueueOverflowReason: String(source.offlineQueueOverflowReason || ""), + updatedAt: String(source.updatedAt || ""), + }); +} + +export function recordAuthorityAcceptedRevision(state = {}, accepted = {}, settings = {}, nowMs = Date.now()) { + const current = normalizeAuthorityBrowserState(state, settings, nowMs); + return createAuthorityBrowserState({ + ...current, + serverRevision: Math.max(current.serverRevision, Number(accepted.revision || 0) || 0), + serverIntegrity: String(accepted.integrity || current.serverIntegrity || ""), + lastCommitAt: Math.max(0, Number(accepted.committedAt || nowMs) || nowMs), + lastError: "", + updatedAt: new Date(nowMs).toISOString(), + }); +} + +export function enqueueAuthorityOfflineMutation(state = {}, mutation = {}, settings = {}, nowMs = Date.now()) { + const policy = getAuthorityBrowserStoragePolicy(settings); + const current = normalizeAuthorityBrowserState(state, settings, nowMs); + if (policy.mode === "off" || policy.offlineWritePolicy === "off") { + const nextState = createAuthorityBrowserState({ + ...current, + offlineQueueOverflow: true, + offlineQueueOverflowReason: "offline-queue-disabled", + updatedAt: new Date(nowMs).toISOString(), + }); + return { accepted: false, reason: "offline-queue-disabled", state: nextState }; + } + + const item = normalizeQueueItem(mutation, nowMs); + const nextItems = [...current.offlineQueue, item]; + const nextSummary = summarizeQueue(nextItems); + if (policy.maxItems > 0 && nextSummary.items > policy.maxItems) { + const nextState = createAuthorityBrowserState({ + ...current, + offlineQueueOverflow: true, + offlineQueueOverflowReason: "max-items-exceeded", + updatedAt: new Date(nowMs).toISOString(), + }); + return { accepted: false, reason: "max-items-exceeded", state: nextState }; + } + if (policy.maxBytes > 0 && nextSummary.bytes > policy.maxBytes) { + const nextState = createAuthorityBrowserState({ + ...current, + offlineQueueOverflow: true, + offlineQueueOverflowReason: "max-bytes-exceeded", + updatedAt: new Date(nowMs).toISOString(), + }); + return { accepted: false, reason: "max-bytes-exceeded", state: nextState }; + } + + const nextState = createAuthorityBrowserState({ + ...current, + offlineQueue: nextItems, + offlineQueueBytes: nextSummary.bytes, + offlineQueueItems: nextSummary.items, + offlineQueueOverflow: false, + offlineQueueOverflowReason: "", + updatedAt: new Date(nowMs).toISOString(), + }); + return { accepted: true, reason: "queued", item, state: nextState }; +} + +export function clearAuthorityOfflineQueue(state = {}, settings = {}, nowMs = Date.now()) { + const current = normalizeAuthorityBrowserState(state, settings, nowMs); + return createAuthorityBrowserState({ + ...current, + offlineQueue: [], + offlineQueueBytes: 0, + offlineQueueItems: 0, + offlineQueueOverflow: false, + offlineQueueOverflowReason: "", + updatedAt: new Date(nowMs).toISOString(), + }); +} + +export function getAuthorityBrowserStateSnapshot(state = {}, settings = {}, nowMs = Date.now()) { + const normalized = normalizeAuthorityBrowserState(state, settings, nowMs); + return { + mode: normalized.mode, + serverRevision: normalized.serverRevision, + serverIntegrity: normalized.serverIntegrity, + lastProbeAt: normalized.lastProbeAt, + lastCommitAt: normalized.lastCommitAt, + lastError: normalized.lastError, + offlineQueueBytes: normalized.offlineQueueBytes, + offlineQueueItems: normalized.offlineQueueItems, + offlineQueueOverflow: normalized.offlineQueueOverflow, + offlineQueueOverflowReason: normalized.offlineQueueOverflowReason, + updatedAt: normalized.updatedAt, + }; +} diff --git a/tests/authority-browser-state.mjs b/tests/authority-browser-state.mjs new file mode 100644 index 0000000..e021d07 --- /dev/null +++ b/tests/authority-browser-state.mjs @@ -0,0 +1,142 @@ +import assert from "node:assert/strict"; + +import { + clearAuthorityOfflineQueue, + enqueueAuthorityOfflineMutation, + getAuthorityBrowserStateSnapshot, + getAuthorityBrowserStoragePolicy, + normalizeAuthorityBrowserState, + recordAuthorityAcceptedRevision, +} from "../sync/authority-browser-state.js"; +import { defaultSettings } from "../runtime/settings-defaults.js"; + +const policy = getAuthorityBrowserStoragePolicy(defaultSettings); +assert.equal(policy.mode, "minimal"); +assert.equal(policy.offlineWritePolicy, "queue-local-dirty"); +assert.equal(policy.maxBytes, 1048576); +assert.equal(policy.maxItems, 128); +assert.equal(policy.maxAgeMs, 3600000); + +const normalized = normalizeAuthorityBrowserState( + { + serverRevision: 7, + serverIntegrity: "abc", + offlineQueue: [ + { + id: "old", + createdAt: 0, + bytes: 10, + payload: { a: 1 }, + }, + { + id: "fresh", + createdAt: 9000, + bytes: 20, + payload: { b: 2 }, + }, + ], + }, + { + ...defaultSettings, + authorityOfflineQueueMaxAgeMs: 1000, + }, + 10000, +); +assert.equal(normalized.serverRevision, 7); +assert.equal(normalized.serverIntegrity, "abc"); +assert.equal(normalized.offlineQueueItems, 1); +assert.equal(normalized.offlineQueueBytes, 20); +assert.equal(normalized.offlineQueue[0].id, "fresh"); + +const acceptedRevision = recordAuthorityAcceptedRevision( + normalized, + { + revision: 11, + integrity: "server-integrity", + }, + defaultSettings, + 12000, +); +assert.equal(acceptedRevision.serverRevision, 11); +assert.equal(acceptedRevision.serverIntegrity, "server-integrity"); +assert.equal(acceptedRevision.lastCommitAt, 12000); +assert.equal(acceptedRevision.offlineQueueItems, 1); + +const enqueueResult = enqueueAuthorityOfflineMutation( + acceptedRevision, + { + id: "mutation-1", + kind: "commitDelta", + payload: { upsertNodes: [{ id: "n1" }] }, + }, + { + ...defaultSettings, + authorityOfflineQueueMaxItems: 3, + }, + 13000, +); +assert.equal(enqueueResult.accepted, true); +assert.equal(enqueueResult.state.offlineQueueItems, 2); +assert.equal(enqueueResult.state.offlineQueueOverflow, false); + +const itemOverflow = enqueueAuthorityOfflineMutation( + enqueueResult.state, + { + id: "mutation-overflow", + payload: { upsertNodes: [{ id: "n2" }] }, + }, + { + ...defaultSettings, + authorityOfflineQueueMaxItems: 1, + }, + 14000, +); +assert.equal(itemOverflow.accepted, false); +assert.equal(itemOverflow.reason, "max-items-exceeded"); +assert.equal(itemOverflow.state.offlineQueueItems, 2); +assert.equal(itemOverflow.state.offlineQueueOverflow, true); + +const byteOverflow = enqueueAuthorityOfflineMutation( + {}, + { + id: "large-mutation", + payload: { text: "x".repeat(64) }, + }, + { + ...defaultSettings, + authorityOfflineQueueMaxBytes: 8, + }, + 15000, +); +assert.equal(byteOverflow.accepted, false); +assert.equal(byteOverflow.reason, "max-bytes-exceeded"); +assert.equal(byteOverflow.state.offlineQueueItems, 0); +assert.equal(byteOverflow.state.offlineQueueOverflow, true); + +const disabled = enqueueAuthorityOfflineMutation( + {}, + { + id: "disabled-mutation", + payload: { a: 1 }, + }, + { + ...defaultSettings, + authorityBrowserCacheMode: "off", + }, + 16000, +); +assert.equal(disabled.accepted, false); +assert.equal(disabled.reason, "offline-queue-disabled"); + +const cleared = clearAuthorityOfflineQueue(enqueueResult.state, defaultSettings, 17000); +assert.equal(cleared.offlineQueueItems, 0); +assert.equal(cleared.offlineQueueBytes, 0); +assert.equal(cleared.offlineQueueOverflow, false); + +const snapshot = getAuthorityBrowserStateSnapshot(acceptedRevision, defaultSettings, 18000); +assert.equal(snapshot.serverRevision, 11); +assert.equal(snapshot.serverIntegrity, "server-integrity"); +assert.equal(snapshot.offlineQueueItems, 1); +assert.equal("offlineQueue" in snapshot, false); + +console.log("authority-browser-state tests passed"); diff --git a/tests/authority-capabilities.mjs b/tests/authority-capabilities.mjs new file mode 100644 index 0000000..658a691 --- /dev/null +++ b/tests/authority-capabilities.mjs @@ -0,0 +1,116 @@ +import assert from "node:assert/strict"; + +import { + buildAuthorityProbeUrls, + collectAuthorityFeatures, + normalizeAuthorityCapabilityState, + normalizeAuthoritySettings, + probeAuthorityCapabilities, +} from "../runtime/authority-capabilities.js"; +import { defaultSettings } from "../runtime/settings-defaults.js"; + +const normalizedSettings = normalizeAuthoritySettings(defaultSettings); +assert.equal(normalizedSettings.enabled, true); +assert.equal(normalizedSettings.enabledMode, "auto"); +assert.equal(normalizedSettings.baseUrl, "/api/plugins/authority"); +assert.equal(normalizedSettings.storageMode, "auto-server-primary"); +assert.equal(normalizedSettings.vectorMode, "auto-primary"); +assert.equal(normalizedSettings.primaryWhenAvailable, true); + +assert.deepEqual(buildAuthorityProbeUrls("/api/plugins/authority/"), [ + "/api/plugins/authority/v1/diagnostics/probe", + "/api/plugins/authority/v1/probe", + "/api/plugins/authority/probe", + "/api/plugins/authority", +]); + +const collected = collectAuthorityFeatures({ + features: ["sql.query", "trivium.search"], + services: { + sql: true, + jobs: true, + blob: true, + }, +}); +assert.equal(collected.has("sql.query"), true); +assert.equal(collected.has("trivium.search"), true); +assert.equal(collected.has("sql"), true); +assert.equal(collected.has("jobs"), true); +assert.equal(collected.has("blob"), true); + +const readyState = normalizeAuthorityCapabilityState( + { + installed: true, + healthy: true, + features: ["sql", "trivium", "jobs", "blob"], + }, + defaultSettings, +); +assert.equal(readyState.serverPrimaryReady, true); +assert.equal(readyState.storagePrimaryReady, true); +assert.equal(readyState.triviumPrimaryReady, true); +assert.equal(readyState.minimumFeatureSetReady, true); + +const missingState = normalizeAuthorityCapabilityState( + { + installed: true, + healthy: true, + features: ["sql"], + }, + defaultSettings, +); +assert.equal(missingState.serverPrimaryReady, false); +assert.equal(missingState.triviumPrimaryReady, false); +assert.ok(missingState.missingFeatures.includes("trivium.search")); + +const disabledState = await probeAuthorityCapabilities({ + settings: { + ...defaultSettings, + authorityEnabled: "off", + }, + fetchImpl: async () => { + throw new Error("should-not-fetch"); + }, + nowMs: 1000, +}); +assert.equal(disabledState.reason, "disabled"); +assert.equal(disabledState.serverPrimaryReady, false); +assert.equal(disabledState.lastProbeAt, 1000); + +let requestedUrl = ""; +const probedState = await probeAuthorityCapabilities({ + settings: defaultSettings, + allowRelativeUrl: true, + nowMs: 2000, + fetchImpl: async (url) => { + requestedUrl = url; + return { + ok: true, + status: 200, + async json() { + return { + healthy: true, + sessionReady: true, + permissionReady: true, + features: ["sql", "trivium", "jobs", "blob"], + }; + }, + }; + }, +}); +assert.equal(requestedUrl, "/api/plugins/authority/v1/diagnostics/probe"); +assert.equal(probedState.installed, true); +assert.equal(probedState.healthy, true); +assert.equal(probedState.serverPrimaryReady, true); +assert.equal(probedState.lastProbeAt, 2000); + +const relativeUnavailable = await probeAuthorityCapabilities({ + settings: defaultSettings, + allowRelativeUrl: false, + fetchImpl: async () => ({ ok: true, status: 200, json: async () => ({}) }), + nowMs: 3000, +}); +assert.equal(relativeUnavailable.reason, "relative-url-unavailable"); +assert.equal(relativeUnavailable.serverPrimaryReady, false); + +console.log("authority-capabilities tests passed"); diff --git a/tests/default-settings.mjs b/tests/default-settings.mjs index 1737197..43f9ec0 100644 --- a/tests/default-settings.mjs +++ b/tests/default-settings.mjs @@ -68,6 +68,25 @@ assert.equal(defaultSettings.worldInfoFilterMode, "default"); assert.equal(defaultSettings.worldInfoFilterCustomKeywords, ""); assert.equal("maintenanceAutoMinNewNodes" in defaultSettings, false); assert.equal(defaultSettings.embeddingTransportMode, "direct"); +assert.equal(defaultSettings.authorityEnabled, "auto"); +assert.equal(defaultSettings.authorityBaseUrl, "/api/plugins/authority"); +assert.equal(defaultSettings.authorityPrimaryWhenAvailable, true); +assert.equal(defaultSettings.authorityStorageMode, "auto-server-primary"); +assert.equal(defaultSettings.authorityVectorMode, "auto-primary"); +assert.equal(defaultSettings.authoritySqlPrimary, true); +assert.equal(defaultSettings.authorityTriviumPrimary, true); +assert.equal(defaultSettings.authorityGraphQueryEnabled, true); +assert.equal(defaultSettings.authorityJobsEnabled, true); +assert.equal(defaultSettings.authorityBlobCheckpointEnabled, true); +assert.equal(defaultSettings.authorityBrowserCacheMode, "minimal"); +assert.equal(defaultSettings.authorityOfflineWritePolicy, "queue-local-dirty"); +assert.equal(defaultSettings.authorityOfflineQueueMaxBytes, 1048576); +assert.equal(defaultSettings.authorityOfflineQueueMaxItems, 128); +assert.equal(defaultSettings.authorityOfflineQueueMaxAgeMs, 3600000); +assert.equal(defaultSettings.authorityVectorSyncChunkSize, 1000); +assert.equal(defaultSettings.authorityVectorFailOpen, true); +assert.equal(defaultSettings.authorityDiagnosticsEnabled, true); +assert.equal(defaultSettings.authorityProbeIntervalMs, 60000); assert.equal(defaultSettings.graphUseNativeLayout, true); assert.equal(defaultSettings.graphNativeLayoutThresholdNodes, 280); assert.equal(defaultSettings.graphNativeLayoutThresholdEdges, 1600); diff --git a/ui/ui-status.js b/ui/ui-status.js index ccf295a..bfa325f 100644 --- a/ui/ui-status.js +++ b/ui/ui-status.js @@ -96,6 +96,51 @@ export function createGraphPersistenceState() { storagePrimary: "indexeddb", storageMode: "indexeddb", resolvedLocalStore: "indexeddb:indexeddb", + authority: { + enabledMode: "auto", + baseUrl: "/api/plugins/authority", + installed: false, + healthy: false, + sessionReady: false, + permissionReady: false, + minimumFeatureSetReady: false, + serverPrimaryReady: false, + storagePrimaryReady: false, + triviumPrimaryReady: false, + jobsReady: false, + blobReady: false, + features: [], + missingFeatures: [], + reason: "not-probed", + lastError: "", + endpoint: "", + status: 0, + latencyMs: 0, + lastProbeAt: 0, + updatedAt: "", + }, + authorityBrowserState: { + mode: "minimal", + serverRevision: 0, + serverIntegrity: "", + lastProbeAt: 0, + lastCommitAt: 0, + lastError: "", + offlineQueueBytes: 0, + offlineQueueItems: 0, + offlineQueueOverflow: false, + offlineQueueOverflowReason: "", + updatedAt: "", + }, + authorityInstalled: false, + authorityHealthy: false, + authorityServerPrimaryReady: false, + authorityStoragePrimaryReady: false, + authorityTriviumPrimaryReady: false, + authorityBrowserCacheMode: "minimal", + authorityOfflineQueueBytes: 0, + authorityOfflineQueueItems: 0, + authorityDegradedReason: "", localStoreFormatVersion: 1, localStoreMigrationState: "idle", opfsWriteLockState: {