mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
feat(authority): add server-primary capability probe
This commit is contained in:
187
index.js
187
index.js
@@ -249,6 +249,16 @@ import {
|
|||||||
getPersistedSettingsSnapshot,
|
getPersistedSettingsSnapshot,
|
||||||
mergePersistedSettings,
|
mergePersistedSettings,
|
||||||
} from "./runtime/settings-defaults.js";
|
} 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 { retrieve } from "./retrieval/retriever.js";
|
||||||
import {
|
import {
|
||||||
applyProcessedHistorySnapshotToGraph,
|
applyProcessedHistorySnapshotToGraph,
|
||||||
@@ -1234,6 +1244,9 @@ let lastExtractionStatus = createUiStatus("待命", "尚未执行提取", "idle"
|
|||||||
let lastVectorStatus = createUiStatus("待命", "尚未执行向量任务", "idle");
|
let lastVectorStatus = createUiStatus("待命", "尚未执行向量任务", "idle");
|
||||||
let lastRecallStatus = createUiStatus("待命", "尚未执行召回", "idle");
|
let lastRecallStatus = createUiStatus("待命", "尚未执行召回", "idle");
|
||||||
let graphPersistenceState = createGraphPersistenceState();
|
let graphPersistenceState = createGraphPersistenceState();
|
||||||
|
let authorityCapabilityState = createDefaultAuthorityCapabilityState();
|
||||||
|
let authorityBrowserState = createAuthorityBrowserState();
|
||||||
|
let authorityProbePromise = null;
|
||||||
const lastStatusToastAt = {};
|
const lastStatusToastAt = {};
|
||||||
let pendingRecallSendIntent = createRecallInputRecord();
|
let pendingRecallSendIntent = createRecallInputRecord();
|
||||||
let lastRecallSentUserMessage = createRecallInputRecord();
|
let lastRecallSentUserMessage = createRecallInputRecord();
|
||||||
@@ -1437,6 +1450,115 @@ function isLukerPrimaryPersistenceHost(context = getContext()) {
|
|||||||
return resolvePersistenceHostProfile(context) === "luker";
|
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() {
|
function getGraphPersistenceLiveState() {
|
||||||
const liveCommitMarker =
|
const liveCommitMarker =
|
||||||
cloneRuntimeDebugValue(graphPersistenceState.commitMarker, null) ||
|
cloneRuntimeDebugValue(graphPersistenceState.commitMarker, null) ||
|
||||||
@@ -1452,6 +1574,7 @@ function getGraphPersistenceLiveState() {
|
|||||||
adapterRuntime.adapter.hostProfile ||
|
adapterRuntime.adapter.hostProfile ||
|
||||||
persistenceEnvironment.hostProfile,
|
persistenceEnvironment.hostProfile,
|
||||||
);
|
);
|
||||||
|
const authorityRuntime = getAuthorityRuntimeSnapshot();
|
||||||
const primaryStorageTier = normalizePersistenceStorageTier(
|
const primaryStorageTier = normalizePersistenceStorageTier(
|
||||||
graphPersistenceState.primaryStorageTier ||
|
graphPersistenceState.primaryStorageTier ||
|
||||||
persistenceEnvironment.primaryStorageTier,
|
persistenceEnvironment.primaryStorageTier,
|
||||||
@@ -1515,6 +1638,38 @@ function getGraphPersistenceLiveState() {
|
|||||||
updatedAt: graphPersistenceState.updatedAt,
|
updatedAt: graphPersistenceState.updatedAt,
|
||||||
storagePrimary: graphPersistenceState.storagePrimary || "indexeddb",
|
storagePrimary: graphPersistenceState.storagePrimary || "indexeddb",
|
||||||
storageMode: graphPersistenceState.storageMode || "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(
|
resolvedLocalStore: String(
|
||||||
graphPersistenceState.resolvedLocalStore ||
|
graphPersistenceState.resolvedLocalStore ||
|
||||||
buildGraphLocalStoreSelectorKey(getPreferredGraphLocalStorePresentationSync()),
|
buildGraphLocalStoreSelectorKey(getPreferredGraphLocalStorePresentationSync()),
|
||||||
@@ -13299,6 +13454,27 @@ function updateModuleSettings(patch = {}) {
|
|||||||
]);
|
]);
|
||||||
const recallUiKeys = new Set(["recallCardUserInputDisplayMode"]);
|
const recallUiKeys = new Set(["recallCardUserInputDisplayMode"]);
|
||||||
const noticeUiKeys = new Set(["noticeDisplayMode"]);
|
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 settings = getSettings();
|
||||||
const previousCloudStorageMode = String(
|
const previousCloudStorageMode = String(
|
||||||
settings.cloudStorageMode || "automatic",
|
settings.cloudStorageMode || "automatic",
|
||||||
@@ -13380,6 +13556,13 @@ function updateModuleSettings(patch = {}) {
|
|||||||
refreshVisibleStageNotices();
|
refreshVisibleStageNotices();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (Object.keys(patch).some((key) => authorityKeys.has(key))) {
|
||||||
|
void refreshAuthorityRuntimeState({
|
||||||
|
force: true,
|
||||||
|
source: "settings-updated",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const currentGraphLocalStorageMode = getRequestedGraphLocalStorageMode(
|
const currentGraphLocalStorageMode = getRequestedGraphLocalStorageMode(
|
||||||
settings,
|
settings,
|
||||||
);
|
);
|
||||||
@@ -20007,6 +20190,10 @@ async function onCompactLukerSidecar() {
|
|||||||
|
|
||||||
(async function init() {
|
(async function init() {
|
||||||
await loadServerSettings();
|
await loadServerSettings();
|
||||||
|
void refreshAuthorityRuntimeState({
|
||||||
|
force: true,
|
||||||
|
source: "init",
|
||||||
|
});
|
||||||
const { target, lightweightHostMode, adapter } = syncBmeHostRuntimeFlags(getContext());
|
const { target, lightweightHostMode, adapter } = syncBmeHostRuntimeFlags(getContext());
|
||||||
updateGraphPersistenceState({
|
updateGraphPersistenceState({
|
||||||
hostProfile: adapter.hostProfile,
|
hostProfile: adapter.hostProfile,
|
||||||
|
|||||||
367
runtime/authority-capabilities.js
Normal file
367
runtime/authority-capabilities.js
Normal file
@@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -117,6 +117,26 @@ export const defaultSettings = {
|
|||||||
embeddingBackendApiUrl: "",
|
embeddingBackendApiUrl: "",
|
||||||
embeddingAutoSuffix: true,
|
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 性能加速(灰度)
|
// Native 性能加速(灰度)
|
||||||
graphUseNativeLayout: true,
|
graphUseNativeLayout: true,
|
||||||
graphNativeLayoutThresholdNodes: 280,
|
graphNativeLayoutThresholdNodes: 280,
|
||||||
|
|||||||
205
sync/authority-browser-state.js
Normal file
205
sync/authority-browser-state.js
Normal file
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
142
tests/authority-browser-state.mjs
Normal file
142
tests/authority-browser-state.mjs
Normal file
@@ -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");
|
||||||
116
tests/authority-capabilities.mjs
Normal file
116
tests/authority-capabilities.mjs
Normal file
@@ -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");
|
||||||
@@ -68,6 +68,25 @@ assert.equal(defaultSettings.worldInfoFilterMode, "default");
|
|||||||
assert.equal(defaultSettings.worldInfoFilterCustomKeywords, "");
|
assert.equal(defaultSettings.worldInfoFilterCustomKeywords, "");
|
||||||
assert.equal("maintenanceAutoMinNewNodes" in defaultSettings, false);
|
assert.equal("maintenanceAutoMinNewNodes" in defaultSettings, false);
|
||||||
assert.equal(defaultSettings.embeddingTransportMode, "direct");
|
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.graphUseNativeLayout, true);
|
||||||
assert.equal(defaultSettings.graphNativeLayoutThresholdNodes, 280);
|
assert.equal(defaultSettings.graphNativeLayoutThresholdNodes, 280);
|
||||||
assert.equal(defaultSettings.graphNativeLayoutThresholdEdges, 1600);
|
assert.equal(defaultSettings.graphNativeLayoutThresholdEdges, 1600);
|
||||||
|
|||||||
@@ -96,6 +96,51 @@ export function createGraphPersistenceState() {
|
|||||||
storagePrimary: "indexeddb",
|
storagePrimary: "indexeddb",
|
||||||
storageMode: "indexeddb",
|
storageMode: "indexeddb",
|
||||||
resolvedLocalStore: "indexeddb: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,
|
localStoreFormatVersion: 1,
|
||||||
localStoreMigrationState: "idle",
|
localStoreMigrationState: "idle",
|
||||||
opfsWriteLockState: {
|
opfsWriteLockState: {
|
||||||
|
|||||||
Reference in New Issue
Block a user