Compare commits

...

3 Commits

Author SHA1 Message Date
Alexander Whitestone
5aca3bef4a fix: normalize legacy portal registry metadata
Some checks failed
CI / test (pull_request) Failing after 39s
CI / validate (pull_request) Failing after 31s
Review Approval Gate / verify-review (pull_request) Failing after 5s
2026-04-16 22:18:14 -04:00
Alexander Whitestone
eccba8e573 feat: hot-reload portal registry from portals.json (#1536) 2026-04-16 22:16:42 -04:00
Alexander Whitestone
7baa37279b test: add portal hot-reload regression coverage (#1536) 2026-04-16 22:12:31 -04:00
5 changed files with 366 additions and 15 deletions

106
app.js
View File

@@ -10,6 +10,10 @@ import { MemoryOptimizer } from './nexus/components/memory-optimizer.js';
import { MemoryInspect } from './nexus/components/memory-inspect.js';
import { MemoryPulse } from './nexus/components/memory-pulse.js';
import { ReasoningTrace } from './nexus/components/reasoning-trace.js';
import {
createPortalRegistryWatcher,
fetchPortalRegistry,
} from './portal-registry.mjs';
// ═══════════════════════════════════════════
// NEXUS v1.1 — Portal System Update
@@ -83,6 +87,7 @@ let workshopPanelCanvas = null;
let workshopScanMat = null;
let workshopPanelRefreshTimer = 0;
let lastFocusedPortal = null;
let portalRegistryWatcher = null;
// ═══ VISITOR / OPERATOR MODE ═══
let uiMode = 'visitor'; // 'visitor' | 'operator'
@@ -731,9 +736,25 @@ async function init() {
// Load Portals from Registry
try {
const response = await fetch('./portals.json');
const portalData = await response.json();
createPortals(portalData);
const portalData = await fetchPortalRegistry({
registryUrl: './portals.json',
cacheBustParam: '_registry_ts',
});
applyPortalRegistry(portalData);
portalRegistryWatcher?.stop?.();
portalRegistryWatcher = createPortalRegistryWatcher({
loadRegistry: () => fetchPortalRegistry({
registryUrl: './portals.json',
cacheBustParam: '_registry_ts',
}),
applyRegistry: (nextRegistry) => applyPortalRegistry(nextRegistry, { announce: true }),
onError: (error) => {
console.error('Failed to hot-reload portals.json:', error);
},
});
await portalRegistryWatcher.prime(portalData);
portalRegistryWatcher.start();
} catch (e) {
console.error('Failed to load portals.json:', e);
addChatMessage('error', 'Portal registry offline. Check logs.');
@@ -1084,7 +1105,7 @@ function refreshWorkshopPanel() {
ctx.fillText(`HERMES STATUS: ${wsConnected ? 'ONLINE' : 'OFFLINE'}`, 40, 120);
ctx.fillStyle = '#7b5cff';
const contextName = activePortal ? activePortal.name.toUpperCase() : 'NEXUS CORE';
const contextName = activePortal ? activePortal.config.name.toUpperCase() : 'NEXUS CORE';
ctx.fillText(`CONTEXT: ${contextName}`, 40, 160);
ctx.fillStyle = '#a0b8d0';
@@ -1531,6 +1552,83 @@ function createVisionPoint(config) {
}
// ═══ PORTAL SYSTEM ═══
function disposeMaterial(material) {
if (!material) return;
if (Array.isArray(material)) {
material.forEach(disposeMaterial);
return;
}
[
'map',
'alphaMap',
'aoMap',
'bumpMap',
'displacementMap',
'emissiveMap',
'envMap',
'lightMap',
'metalnessMap',
'normalMap',
'roughnessMap',
'specularMap',
].forEach((key) => {
material[key]?.dispose?.();
});
if (material.uniforms) {
Object.values(material.uniforms).forEach((uniform) => {
uniform?.value?.dispose?.();
});
}
material.dispose?.();
}
function disposeObject3D(root) {
if (!root?.traverse) return;
root.traverse((node) => {
node.geometry?.dispose?.();
disposeMaterial(node.material);
});
}
function clearPortals() {
portals.forEach((portal) => {
scene?.remove?.(portal.group);
disposeObject3D(portal.group);
portal.group?.clear?.();
});
portals = [];
activePortal = null;
lastFocusedPortal = null;
}
function applyPortalRegistry(data, { announce = false } = {}) {
const previousActivePortalId = activePortal?.config?.id || null;
clearPortals();
createPortals(data);
if (previousActivePortalId) {
activePortal = portals.find((portal) => portal.config.id === previousActivePortalId) || null;
if (!activePortal && portalOverlayActive) {
closePortalOverlay();
}
}
if (atlasOverlayActive) populateAtlas();
checkPortalProximity();
refreshWorkshopPanel();
if (announce) {
addChatMessage('system', `Portal registry hot-reloaded (${data.length} portals).`, false);
}
}
function createPortals(data) {
data.forEach(config => {
const portal = createPortal(config);

123
portal-registry.mjs Normal file
View File

@@ -0,0 +1,123 @@
export const DEFAULT_PORTAL_REGISTRY_POLL_MS = 5000;
export const DEFAULT_PORTAL_REGISTRY_CACHE_BUST_PARAM = '_registry_ts';
export function getPortalRegistrySignature(registry) {
return JSON.stringify(registry);
}
export function buildPortalRegistryRequestUrl(
registryUrl = './portals.json',
cacheBustValue = Date.now(),
baseHref = typeof window !== 'undefined' && window.location ? window.location.href : 'http://localhost/',
cacheBustParam = DEFAULT_PORTAL_REGISTRY_CACHE_BUST_PARAM,
) {
const url = new URL(registryUrl, baseHref);
if (cacheBustValue !== null && cacheBustValue !== undefined) {
url.searchParams.set(cacheBustParam, String(cacheBustValue));
}
return url.toString();
}
export async function fetchPortalRegistry({
fetchImpl = fetch,
registryUrl = './portals.json',
baseHref = typeof window !== 'undefined' && window.location ? window.location.href : 'http://localhost/',
cacheBustValue = Date.now(),
cacheBustParam = DEFAULT_PORTAL_REGISTRY_CACHE_BUST_PARAM,
} = {}) {
const requestUrl = buildPortalRegistryRequestUrl(
registryUrl,
cacheBustValue,
baseHref,
cacheBustParam,
);
const response = await fetchImpl(requestUrl, {
cache: 'no-store',
headers: { 'Cache-Control': 'no-cache' },
});
if (!response.ok) {
throw new Error(`Failed to load ${registryUrl}: ${response.status}`);
}
const registry = await response.json();
if (!Array.isArray(registry)) {
throw new Error(`${registryUrl} must be a JSON array`);
}
return registry;
}
export function createPortalRegistryWatcher({
loadRegistry,
applyRegistry,
onError = (error) => console.error('Portal registry watch failed:', error),
intervalMs = DEFAULT_PORTAL_REGISTRY_POLL_MS,
setIntervalImpl = setInterval,
clearIntervalImpl = clearInterval,
signatureFn = getPortalRegistrySignature,
} = {}) {
if (typeof loadRegistry !== 'function') {
throw new TypeError('loadRegistry must be a function');
}
if (typeof applyRegistry !== 'function') {
throw new TypeError('applyRegistry must be a function');
}
let intervalId = null;
let lastSignature = null;
async function refresh() {
try {
const registry = await loadRegistry();
const nextSignature = signatureFn(registry);
if (nextSignature === lastSignature) {
return {
changed: false,
registry,
previousSignature: lastSignature,
nextSignature,
};
}
const previousSignature = lastSignature;
lastSignature = nextSignature;
applyRegistry(registry, { previousSignature, nextSignature });
return {
changed: true,
registry,
previousSignature,
nextSignature,
};
} catch (error) {
onError(error);
return { changed: false, error };
}
}
return {
async prime(registry) {
lastSignature = signatureFn(registry);
return lastSignature;
},
getLastSignature() {
return lastSignature;
},
async refresh() {
return refresh();
},
start() {
if (intervalId !== null) {
return intervalId;
}
intervalId = setIntervalImpl(() => {
void refresh();
}, intervalMs);
return intervalId;
},
stop() {
if (intervalId !== null) {
clearIntervalImpl(intervalId);
intervalId = null;
}
},
};
}

View File

@@ -129,13 +129,22 @@
"type": "harness",
"params": {
"mode": "creative"
}
},
"action_label": "Enter Workshop"
},
"agents_present": [
"timmy",
"kimi"
],
"interaction_ready": true
"interaction_ready": true,
"portal_type": "harness",
"world_category": "creative",
"environment": "production",
"access_mode": "visitor",
"readiness_state": "online",
"telemetry_source": "workshop.timmy.foundation",
"owner": "Timmy",
"blocked_reason": null
},
{
"id": "archive",
@@ -157,12 +166,21 @@
"type": "harness",
"params": {
"mode": "read"
}
},
"action_label": "Enter Archive"
},
"agents_present": [
"claude"
],
"interaction_ready": true
"interaction_ready": true,
"portal_type": "harness",
"world_category": "knowledge",
"environment": "production",
"access_mode": "visitor",
"readiness_state": "online",
"telemetry_source": "archive.timmy.foundation",
"owner": "Timmy",
"blocked_reason": null
},
{
"id": "chapel",
@@ -184,10 +202,19 @@
"type": "harness",
"params": {
"mode": "meditation"
}
},
"action_label": "Enter Chapel"
},
"agents_present": [],
"interaction_ready": true
"interaction_ready": true,
"portal_type": "harness",
"world_category": "reflection",
"environment": "production",
"access_mode": "visitor",
"readiness_state": "online",
"telemetry_source": "chapel.timmy.foundation",
"owner": "Timmy",
"blocked_reason": null
},
{
"id": "courtyard",
@@ -209,13 +236,22 @@
"type": "harness",
"params": {
"mode": "social"
}
},
"action_label": "Enter Courtyard"
},
"agents_present": [
"timmy",
"perplexity"
],
"interaction_ready": true
"interaction_ready": true,
"portal_type": "harness",
"world_category": "social",
"environment": "production",
"access_mode": "visitor",
"readiness_state": "online",
"telemetry_source": "courtyard.timmy.foundation",
"owner": "Timmy",
"blocked_reason": null
},
{
"id": "gate",
@@ -237,10 +273,19 @@
"type": "harness",
"params": {
"mode": "transit"
}
},
"action_label": "Open Gate"
},
"agents_present": [],
"interaction_ready": false
"interaction_ready": false,
"portal_type": "harness",
"world_category": "transit",
"environment": "production",
"access_mode": "visitor",
"readiness_state": "standby",
"telemetry_source": "gate.timmy.foundation",
"owner": "Timmy",
"blocked_reason": "Transit gate staged but not interaction ready."
},
{
"id": "playground",

View File

@@ -0,0 +1,71 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
buildPortalRegistryRequestUrl,
createPortalRegistryWatcher,
getPortalRegistrySignature,
} from '../portal-registry.mjs';
test('buildPortalRegistryRequestUrl appends a cache-busting query without dropping existing params', () => {
const url = buildPortalRegistryRequestUrl('./portals.json?mode=atlas', 42, 'https://nexus.test/world/index.html');
assert.equal(
url,
'https://nexus.test/world/portals.json?mode=atlas&_registry_ts=42'
);
});
test('portal registry watcher only reapplies the world when portals.json actually changes', async () => {
const applied = [];
const snapshots = [
[{ id: 'forge', status: 'online' }],
[{ id: 'forge', status: 'online' }, { id: 'archive', status: 'online' }],
];
let tick;
const started = [];
const cleared = [];
const watcher = createPortalRegistryWatcher({
intervalMs: 2500,
loadRegistry: async () => {
const next = snapshots.shift();
if (!next) throw new Error('no more snapshots');
return next;
},
applyRegistry: (registry, meta) => {
applied.push({ ids: registry.map((portal) => portal.id), meta });
},
onError: (error) => {
throw error;
},
setIntervalImpl: (fn, ms) => {
tick = fn;
started.push(ms);
return 99;
},
clearIntervalImpl: (id) => {
cleared.push(id);
},
});
await watcher.prime([{ id: 'forge', status: 'online' }]);
assert.equal(
watcher.getLastSignature(),
getPortalRegistrySignature([{ id: 'forge', status: 'online' }])
);
watcher.start();
assert.deepEqual(started, [2500]);
await tick();
assert.equal(applied.length, 0, 'same registry should not trigger a rebuild');
await tick();
assert.equal(applied.length, 1, 'changed registry should trigger one rebuild');
assert.deepEqual(applied[0].ids, ['forge', 'archive']);
assert.match(applied[0].meta.previousSignature, /forge/);
assert.match(applied[0].meta.nextSignature, /archive/);
watcher.stop();
assert.deepEqual(cleared, [99]);
});

View File

@@ -0,0 +1,14 @@
from pathlib import Path
APP_JS = Path("app.js")
def test_app_wires_portal_registry_hot_reload_loop() -> None:
source = APP_JS.read_text()
assert "createPortalRegistryWatcher" in source
assert "fetchPortalRegistry" in source
assert "applyPortalRegistry(" in source
assert "portalRegistryWatcher.start()" in source
assert "_registry_ts" in source