diff --git a/client/src/components/eidoverse/EidoverseWorldDrawer.jsx b/client/src/components/eidoverse/EidoverseWorldDrawer.jsx
new file mode 100644
index 000000000..ed0827617
--- /dev/null
+++ b/client/src/components/eidoverse/EidoverseWorldDrawer.jsx
@@ -0,0 +1,520 @@
+import { useState } from 'react';
+import { Compass, Database, Palette, RefreshCw } from 'lucide-react';
+import { Link } from 'react-router';
+import Drawer from '../Drawer';
+import useDrawerTab from '../../hooks/useDrawerTab';
+import { formatBytes } from '../../utils/formatters';
+
+const TABS = [
+ { id: 'experience', label: 'Experience', icon: Compass },
+ { id: 'districts', label: 'Districts & Data', icon: Database },
+ { id: 'appearance', label: 'Appearance & Assets', icon: Palette },
+ { id: 'updates', label: 'Updates & Advanced', icon: RefreshCw },
+];
+const TAB_IDS = TABS.map(({ id }) => id);
+const SOURCE_ROUTES = {
+ apps: '/apps', agents: '/cos/agents', tasks: '/cos/tasks', features: '/settings/features',
+ peers: '/instances', health: '/cos/health', productivity: '/cos/productivity',
+ activity: '/cos/productivity', goals: '/goals/list', memory: '/brain/memory',
+ storage: '/settings/database', jira: '/goals/list', operations: '/cos/health',
+};
+
+const fieldClass = 'mt-1 min-h-[42px] w-full rounded-lg border border-port-border bg-port-bg px-3 text-sm text-white focus:border-port-accent focus:outline-none';
+const secondaryButton = 'inline-flex min-h-[40px] items-center justify-center rounded-lg border border-port-border px-3 py-2 text-sm text-gray-200 transition-colors hover:border-port-accent hover:text-white disabled:cursor-wait disabled:opacity-50';
+const primaryButton = 'inline-flex min-h-[40px] items-center justify-center rounded-lg bg-port-accent px-4 py-2 text-sm font-semibold text-black transition-opacity hover:opacity-90 disabled:cursor-wait disabled:opacity-50';
+
+const titleCase = (value) => String(value || '')
+ .replace(/[-_]/g, ' ')
+ .replace(/\b\w/g, (letter) => letter.toUpperCase());
+
+const statusTone = (status) => {
+ if (status === 'complete') return 'border-port-success/40 bg-port-success/10 text-port-success';
+ if (status === 'failed') return 'border-port-error/40 bg-port-error/10 text-port-error';
+ return 'border-port-accent/40 bg-port-accent/10 text-port-accent';
+};
+
+export default function EidoverseWorldDrawer({
+ open,
+ onClose,
+ worldState,
+ worldName,
+ setWorldName,
+ humanName,
+ setHumanName,
+ recipeDraft,
+ assetOverridesDraft,
+ mutateRecipe,
+ mutateAssetOverride,
+ markDirty,
+ configStatus,
+ projectionStatus,
+ dirty,
+ onSave,
+ onProject,
+ onReset,
+ onRefreshAssets,
+}) {
+ const [activeTab, setActiveTab] = useDrawerTab('eidoverseTab', 'experience', TAB_IDS);
+ const [resetArmed, setResetArmed] = useState(false);
+ const [numericDrafts, setNumericDrafts] = useState({});
+ const design = worldState?.design || {};
+ const reconciliation = design.reconciliation || {};
+ const projectionSummary = worldState?.projection?.lastSummary || {};
+ const busy = configStatus === 'saving' || projectionStatus === 'running';
+ const projectionActionBlocked = busy || dirty;
+ const assetRecipes = recipeDraft?.assetRecipe?.slots || {};
+ const assetRows = [
+ ...Object.entries(assetRecipes).map(([slot, recipe]) => ({ slot, recipe, legacy: false })),
+ ...Object.keys(assetOverridesDraft || {})
+ .filter((slot) => !Object.hasOwn(assetRecipes, slot))
+ .sort()
+ .map((slot) => ({ slot, recipe: null, legacy: true })),
+ ];
+ const numericValue = (key, fallback) => (
+ Object.hasOwn(numericDrafts, key) ? numericDrafts[key] : fallback
+ );
+ const updateNumericDraft = (key, raw, commit) => {
+ setNumericDrafts((current) => ({ ...current, [key]: raw }));
+ if (raw === '') return;
+ const number = Number(raw);
+ if (Number.isFinite(number)) commit(number);
+ };
+ const finishNumericDraft = (key) => {
+ setNumericDrafts((current) => {
+ const next = { ...current };
+ delete next[key];
+ return next;
+ });
+ };
+
+ const identityFields = (
+
+
+
World Design V{design.selectedVersion || recipeDraft?.version}
+
{design.name || recipeDraft?.name}
+
+ A luminous systems garden where PortOS apps, agents, goals, memory, data, peers, and activity each have a legible home.
+
+
+
+ World name
+ { markDirty(); setWorldName(event.target.value); }}
+ maxLength={64}
+ pattern="[A-Za-z0-9_-]+"
+ required
+ />
+
+
+ My Eidoverse name
+ { markDirty(); setHumanName(event.target.value); }}
+ maxLength={64}
+ placeholder="Leave blank for a private generated name"
+ />
+
+
+
+
World
+
{worldState?.world}
+
+
+
CoS presence
+
{worldState?.presence?.connected ? 'Connected' : 'Ready to reconnect'}
+
+
+
Live signal budget
+
{design.maxEntities ?? recipeDraft?.maxEntities ?? 48} maximum
+
+
+
+ );
+
+ const districtFields = (
+
+
+ PortOS projects bounded summaries, never raw records. Stable IDs keep each signal in its district across refreshes.
+
+ {projectionSummary.truncated && (
+
+ The shared world cap omitted {' '}
+ {Object.entries(projectionSummary.droppedBySource || {})
+ .filter(([, count]) => count > 0)
+ .map(([source, count]) => `${count} ${titleCase(source)}`)
+ .join(', ')} signal(s). PortOS distributes available space across sources before adding extra signals.
+
+ )}
+ {(recipeDraft?.districts || []).map((district) => (
+
+
+
+
+
+
{district.label}
+
+
{district.direction} · {district.landmark}
+
+
onReset('district', district.id)}
+ >
+ Reset district
+
+
+
+ {district.sources.map((source) => (
+
+
mutateRecipe((current) => ({
+ ...current,
+ includes: { ...current.includes, [source]: event.target.checked },
+ }))}
+ />
+
+
+ {titleCase(source)}
+
+
+ {projectionSummary.sourceAvailability?.[source] === false
+ ? 'Stale · last good held'
+ : (projectionSummary.sourceCounts?.[source] ?? 'Not projected')}
+ {projectionSummary.droppedBySource?.[source] > 0
+ ? ` · ${projectionSummary.droppedBySource[source]} omitted by cap`
+ : ''}
+ {SOURCE_ROUTES[source] && Open in PortOS}
+
+
+
+ Cap
+ {
+ updateNumericDraft(`limit.${source}`, event.target.value, (number) => mutateRecipe((current) => ({
+ ...current,
+ limits: { ...current.limits, [source]: number },
+ })));
+ }}
+ onBlur={() => finishNumericDraft(`limit.${source}`)}
+ />
+
+
+ ))}
+
+
+ ))}
+
+ );
+
+ const appearanceFields = (
+
+
+ Dawn atmosphere
+ Lightweight skymesh, three authored lights, restrained fog, and sparse wind grass.
+
+ {[
+ ['hours', 'Sun hour', 0, 24, 0.1],
+ ['exposure', 'Exposure', 0.3, 1.8, 0.01],
+ ['fog', 'Fog', 0, 3, 0.01],
+ ].map(([key, label, min, max, step]) => (
+
+ {label}
+ {
+ updateNumericDraft(`sky.${key}`, event.target.value, (number) => mutateRecipe((current) => ({
+ ...current,
+ environment: {
+ ...current.environment,
+ sky: { ...current.environment.sky, [key]: number },
+ },
+ })));
+ }}
+ onBlur={() => finishNumericDraft(`sky.${key}`)}
+ />
+
+ ))}
+
+ Grass density
+ {
+ updateNumericDraft('grass.density', event.target.value, (number) => mutateRecipe((current) => ({
+ ...current,
+ environment: {
+ ...current.environment,
+ grass: { ...current.environment.grass, density: number },
+ },
+ })));
+ }}
+ onBlur={() => finishNumericDraft('grass.density')}
+ />
+
+
+
+
+
+
+
+
Portable asset recipe
+
Paths and search terms ship; model bytes stay in Eidoverse.
+
+
+ Refresh asset matches
+
+
+ {dirty && Save changes before refreshing asset matches.
}
+
+ {assetRows.map(({ slot, recipe, legacy }) => {
+ const resolution = design.assetResolutions?.[slot];
+ return (
+
+
+
{titleCase(slot)}
+
+ {legacy ? 'legacy V1 override' : (resolution?.source || 'pending')}
+
+
+ {legacy ? (
+ <>
+ {assetOverridesDraft[slot]}
+
+ Preserved from World Design V1. Clear it to let the semantic V2 asset recipe choose this model.
+
+ mutateAssetOverride(slot, '')}
+ >
+ Clear legacy {titleCase(slot)} override
+
+ >
+ ) : (
+ <>
+ {resolution?.path || 'Will resolve on the next projection'}
+ Search: {recipe.fallbackQueries.join(' · ')}
+
+ {resolution?.bytes != null ? `${formatBytes(resolution.bytes)} selected · ` : ''}
+ Budget: {Math.round(recipe.maxBytes / 1_000_000)} MB · {recipe.sourcePolicy}
+
+
+ Local override (optional)
+ mutateAssetOverride(slot, event.target.value)}
+ placeholder="eidoverse/... or store/..."
+ pattern="(?:eidoverse|store)[\\/](?!.*\.\.).*"
+ />
+
+ >
+ )}
+
+ );
+ })}
+
+
+
+ );
+
+ const updateFields = (
+
+
+
+
+
Reconciliation {reconciliation.status || 'pending'}
+
{reconciliation.checkpoint || 'Waiting for first projection'}
+
+
+ V{design.lastAppliedVersion ?? '—'} → V{design.pendingVersion ?? design.selectedVersion}
+
+
+ {reconciliation.error && {reconciliation.error}
}
+ {reconciliation.errorContext?.missing?.length > 0 && (
+ Unresolved slots: {reconciliation.errorContext.missing.join(', ')}
+ )}
+ {reconciliation.errorContext?.remediation === '/apps' && (
+ Update Eidoverse from Managed Apps
+ )}
+ {reconciliation.retiredOwnerCleanup?.failedCount > 0 && (
+
+ PortOS could not retire {reconciliation.retiredOwnerCleanup.failedCount} previous owner role(s), so this world continued.
+ {reconciliation.retiredOwnerCleanup.retryingCount > 0
+ ? ` ${reconciliation.retiredOwnerCleanup.retryingCount} will retry on the next projection.`
+ : ''}
+ {reconciliation.retiredOwnerCleanup.droppedCount > 0
+ ? ` ${reconciliation.retiredOwnerCleanup.droppedCount} reached the retry limit; review prior worlds manually if those roles matter.`
+ : ''}
+
+ )}
+ {reconciliation.operationCount > 0 && (
+
+
+ {titleCase(reconciliation.checkpoint)}
+ {Math.min(reconciliation.appliedOperations || 0, reconciliation.operationCount)}/{reconciliation.operationCount}
+
+
+
+ )}
+
+
+ {design.migrationReport && (
+
+ Migration report
+
+
Status {design.migrationReport.status}
+
Preserved overrides {design.migrationReport.preservedOverrides?.length || 0}
+
From design V{design.migrationReport.fromDesignVersion || 1}
+
To design V{design.migrationReport.toDesignVersion || 2}
+
+ {design.migrationReport.preservedOverrides?.length > 0 && (
+
+ {design.migrationReport.preservedOverrides.map((path) => {path} )}
+
+ )}
+ {design.migrationReport.adoptedDefaultChanges?.length > 0 && (
+
+ {design.migrationReport.adoptedDefaultChanges.map((change) => (
+
+ {change.area}
+ {change.from} → {change.to}
+
+ ))}
+
+ )}
+ {Object.keys(design.migrationReport.unsupportedOverrides || {}).length > 0 && (
+
+ Retained for manual review: {Object.keys(design.migrationReport.unsupportedOverrides).join(', ')}
+
+ )}
+ {design.migrationReport.removedMachineDerivedIdentity && (
+
+ The old automatic machine-name identity was retired in favor of a private generated name. Explicitly configured names are never changed.
+
+ )}
+
+ )}
+
+ {reconciliation.runtimeVersion && (
+
+ Runtime compatibility
+ Protocol preflight passed on build {reconciliation.runtimeVersion.sha}.
+ {reconciliation.runtimeVersion.commitTime}
+
+ )}
+
+
+ Apply and recover
+ Preflight every asset, build V2 under PortOS-managed IDs, then retire only stale managed entities. A failure remains pending and retryable.
+ {dirty && (
+
+ Save your world changes before applying an update or refreshing asset matches.
+
+ )}
+
+
+ {projectionStatus === 'running' ? 'Applying update…' : 'Apply world update'}
+
+
+ Refresh asset matches
+
+
+
+
+
+ Reset PortOS world design
+ Clears install-local overrides and the asset lock. Eidoverse model bytes and non-PortOS world entities are untouched.
+ {!resetArmed ? (
+ setResetArmed(true)}>Reset all settings…
+ ) : (
+
+ { setResetArmed(false); onReset('all'); }}>
+ Confirm reset
+
+ setResetArmed(false)}>Cancel
+
+ )}
+
+
+ );
+
+ const panel = activeTab === 'experience'
+ ? identityFields
+ : activeTab === 'districts'
+ ? districtFields
+ : activeTab === 'appearance'
+ ? appearanceFields
+ : updateFields;
+
+ return (
+
+
+
+ );
+}
diff --git a/client/src/lib/README.md b/client/src/lib/README.md
index 943793ab6..df38a029d 100644
--- a/client/src/lib/README.md
+++ b/client/src/lib/README.md
@@ -18,6 +18,7 @@ grep -i "what you want to do" client/src/lib/README.md
```
| `navFeatures.js` | `filterNavByFeatures(navEntries, isFeatureEnabled)` — drops nav-manifest entries whose optional instance feature (`post`, `datadog`, `jira`, `gsd`) is off. The single gate for BROWSE surfaces (sidebar, ⌘K); routes stay reachable by URL, bookmark, and voice. Pair with `useInstanceFeatures`. |
+| `eidoverseWorldReset.js` | Client reset-reconciliation maps for Eidoverse source kinds and district asset slots; parity-tested against the authoritative server world-design contracts. |
| `postQuickSession.js` | Pure Quick POST duration presets, local-observation estimator, deterministic budget composer, and preview metadata. |
| `postRotation.js` | Pure deterministic day-based rotation for POST practice selection — `orderByRecencyRotation` sorts candidates fresh-before-recently-practiced, then by priority, and rotates equivalent ones by local day. Mirrored from `server/lib/postRotation.js`. |
diff --git a/client/src/lib/eidoverseWorldReset.js b/client/src/lib/eidoverseWorldReset.js
new file mode 100644
index 000000000..095bdfa24
--- /dev/null
+++ b/client/src/lib/eidoverseWorldReset.js
@@ -0,0 +1,35 @@
+export const EIDOVERSE_SOURCE_KIND = Object.freeze({
+ apps: 'app',
+ agents: 'agent',
+ tasks: 'task',
+ features: 'feature',
+ peers: 'peer',
+ health: 'health',
+ productivity: 'productivity',
+ activity: 'activity',
+ goals: 'goal',
+ memory: 'memory',
+ storage: 'storage',
+ jira: 'jira',
+ operations: 'operations',
+});
+
+export const EIDOVERSE_RESET_ASSET_SLOTS = Object.freeze({
+ nexus: Object.freeze(['nexus', 'health', 'operations', 'feature', 'district']),
+ apps: Object.freeze(['app']),
+ agents: Object.freeze(['agent', 'task']),
+ goals: Object.freeze(['goal', 'jira']),
+ memory: Object.freeze(['memory']),
+ data: Object.freeze(['storage']),
+ federation: Object.freeze(['peer']),
+ activity: Object.freeze(['activity', 'productivity']),
+});
+
+const CUSTOM_DISTRICT_ASSET_SLOTS = Object.freeze(['district']);
+
+export function eidoverseResetAssetSlotsForDistrict(districtId, sources = []) {
+ return [...new Set([
+ ...(EIDOVERSE_RESET_ASSET_SLOTS[districtId] || CUSTOM_DISTRICT_ASSET_SLOTS),
+ ...sources.map((source) => EIDOVERSE_SOURCE_KIND[source]).filter(Boolean),
+ ])];
+}
diff --git a/client/src/lib/index.js b/client/src/lib/index.js
index 96ada92f8..f24f19c5a 100644
--- a/client/src/lib/index.js
+++ b/client/src/lib/index.js
@@ -28,6 +28,7 @@ export * from './catalogTypes.js';
export * from './creativeDirectorPlan.js';
export * from './creativeDirectorPreview.js';
export * from './editorialRoadmap.js';
+export * from './eidoverseWorldReset.js';
export * from './federatedMediaReadiness.js';
export * from './fableLoomReadiness.js';
export * from './glbFailure.js';
diff --git a/client/src/pages/Eidoverse.jsx b/client/src/pages/Eidoverse.jsx
index d00718d64..bf96308a5 100644
--- a/client/src/pages/Eidoverse.jsx
+++ b/client/src/pages/Eidoverse.jsx
@@ -1,10 +1,24 @@
import { useCallback, useEffect, useRef, useState } from 'react';
-import { ExternalLink, Orbit, RotateCcw, Settings } from 'lucide-react';
+import {
+ AlertTriangle,
+ ExternalLink,
+ Orbit,
+ RotateCcw,
+ Settings,
+ SlidersHorizontal,
+ Sparkles,
+} from 'lucide-react';
import { Link } from 'react-router';
import PageHeader from '../components/PageHeader';
import BrailleSpinner from '../components/BrailleSpinner';
+import EidoverseWorldDrawer from '../components/eidoverse/EidoverseWorldDrawer';
+import {
+ EIDOVERSE_SOURCE_KIND as SOURCE_KIND,
+ eidoverseResetAssetSlotsForDistrict,
+} from '../lib/eidoverseWorldReset';
import {
getApp,
+ getEidoverseWorldProjectionStatus,
getEidoverseWorldStatus,
getInstanceFeatures,
projectEidoverseWorld,
@@ -15,6 +29,18 @@ import {
const silent = { silent: true };
const RUNNING_STATUSES = new Set(['online', 'launching', 'unknown']);
+const FRESH_WORLD_VISIBLE_CHECKPOINTS = new Set([
+ 'environment-complete',
+ 'applying-infrastructure',
+ 'infrastructure-complete',
+ 'applying-live',
+ 'live-complete',
+ 'applying-ambient',
+ 'ambient-complete',
+ 'applying-reconciliation',
+ 'reconciliation-complete',
+ 'projection-committed',
+]);
const failedStart = (result) => Object.values(result?.results || {})
.find((entry) => entry?.success === false);
@@ -38,54 +64,101 @@ export const hostUrlFor = (host, setup, location = window.location, identity = n
return url.toString();
};
-const RECIPE_INCLUDE_KEYS = [
- 'apps',
- 'agents',
- 'tasks',
- 'features',
- 'peers',
- 'health',
- 'productivity',
- 'activity',
- 'goals',
- 'memory',
- 'storage',
- 'jira',
- 'operations',
-];
-
-const RECIPE_KIND_BY_SOURCE = {
- apps: 'app',
- agents: 'agent',
- tasks: 'task',
- features: 'feature',
- peers: 'peer',
- health: 'health',
- productivity: 'productivity',
- activity: 'activity',
- goals: 'goal',
- memory: 'memory',
- storage: 'storage',
- jira: 'jira',
- operations: 'operations',
-};
-
-const RECIPE_LAYOUT_KEYS = ['spacing', 'laneGap', 'columns'];
-const RECIPE_TERRAIN_KEYS = ['size', 'segments', 'amplitude', 'flatRadius'];
-// Mirror the server's case-insensitive, slash-normalized path contract. HTML
-// patterns have no flag syntax, so spell out the case-insensitive prefixes and
-// accept either path separator explicitly.
-const EIDOVERSE_ASSET_PATTERN = '(?:[Ee][Ii][Dd][Oo][Vv][Ee][Rr][Ss][Ee]|[Ss][Tt][Oo][Rr][Ee])[\\\\/](?!.*\\.\\.).*';
-
const worldIdentityFor = (world) => ({
world: world?.world,
name: world?.identity?.name || world?.human?.name,
avatar: world?.identity?.avatar || world?.human?.avatar,
});
+const statusTone = (status) => {
+ if (status === 'complete') return 'border-port-success/45 bg-port-success/10 text-port-success';
+ if (status === 'failed') return 'border-port-error/45 bg-port-error/10 text-port-error';
+ return 'border-port-accent/45 bg-port-accent/10 text-port-accent';
+};
+
+const DELETE_DRAFT_VALUE = Symbol('delete-draft-value');
+const isDraftRecord = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
+const draftValuesEqual = (left, right) => Object.is(left, right)
+ || JSON.stringify(left) === JSON.stringify(right);
+function mergeServerDraftChanges(current, submitted, before, after) {
+ if (draftValuesEqual(before, after)) return current;
+ if (isDraftRecord(current) && isDraftRecord(submitted)
+ && isDraftRecord(before) && isDraftRecord(after)) {
+ const merged = { ...current };
+ const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
+ for (const key of keys) {
+ if (draftValuesEqual(before[key], after[key])) continue;
+ const value = mergeServerDraftChanges(current[key], submitted[key], before[key], after[key]);
+ if (value === DELETE_DRAFT_VALUE) delete merged[key];
+ else merged[key] = value;
+ }
+ return merged;
+ }
+ if (!draftValuesEqual(current, submitted)) return current;
+ return after === undefined ? DELETE_DRAFT_VALUE : structuredClone(after);
+}
+
+const reconcileActionDraft = (current, submitted, before, after) => {
+ const merged = mergeServerDraftChanges(current, submitted, before, after);
+ return merged === DELETE_DRAFT_VALUE ? {} : merged;
+};
+
+function mergeSubmittedKeys(current = {}, submitted = {}, after = {}, keys = []) {
+ const merged = { ...current };
+ for (const key of keys) {
+ if (!draftValuesEqual(current?.[key], submitted?.[key])) continue;
+ if (Object.hasOwn(after || {}, key)) merged[key] = structuredClone(after[key]);
+ else delete merged[key];
+ }
+ return merged;
+}
+
+function reconcileResetRecipe(current, submitted, after, reset) {
+ if (reset.scope === 'all') {
+ return reconcileActionDraft(current, submitted, submitted, after);
+ }
+ if (reset.scope === 'assets') {
+ const keys = new Set([
+ ...Object.keys(current?.assets || {}),
+ ...Object.keys(submitted?.assets || {}),
+ ...Object.keys(after?.assets || {}),
+ ]);
+ return {
+ ...current,
+ assets: mergeSubmittedKeys(current?.assets, submitted?.assets, after?.assets, keys),
+ };
+ }
+ const district = after?.districts?.find(({ id }) => id === reset.districtId);
+ const sources = district?.sources || [];
+ const kinds = sources.map((source) => SOURCE_KIND[source]).filter(Boolean);
+ const slots = eidoverseResetAssetSlotsForDistrict(reset.districtId, sources);
+ return {
+ ...current,
+ includes: mergeSubmittedKeys(current?.includes, submitted?.includes, after?.includes, sources),
+ limits: mergeSubmittedKeys(current?.limits, submitted?.limits, after?.limits, sources),
+ scale: mergeSubmittedKeys(current?.scale, submitted?.scale, after?.scale, kinds),
+ assets: mergeSubmittedKeys(current?.assets, submitted?.assets, after?.assets, slots),
+ };
+}
+
+function reconcileResetAssetOverrides(current, submitted, after, reset, sources = []) {
+ if (reset.scope === 'all' || reset.scope === 'assets') {
+ return reconcileActionDraft(current, submitted, submitted, after);
+ }
+ return mergeSubmittedKeys(
+ current,
+ submitted,
+ after,
+ eidoverseResetAssetSlotsForDistrict(reset.districtId, sources),
+ );
+}
+
export default function Eidoverse() {
const requestGeneration = useRef(0);
const configDraftRevision = useRef(0);
+ const savedDraftRevision = useRef(0);
+ const projectionPollGeneration = useRef(0);
+ const projectionPollTimer = useRef(null);
const [phase, setPhase] = useState('loading');
const [error, setError] = useState('');
const [hostUrl, setHostUrl] = useState('');
@@ -96,9 +169,27 @@ export default function Eidoverse() {
const [worldName, setWorldName] = useState('');
const [humanName, setHumanName] = useState('');
const [recipeDraft, setRecipeDraft] = useState(null);
+ const [assetOverridesDraft, setAssetOverridesDraft] = useState({});
const [projectionStatus, setProjectionStatus] = useState('idle');
const [projectionError, setProjectionError] = useState('');
const [configStatus, setConfigStatus] = useState('');
+ const [draftDirty, setDraftDirty] = useState(false);
+ const [settingsOpen, setSettingsOpen] = useState(false);
+ const [iframeReady, setIframeReady] = useState(false);
+
+ const applyWorldResponse = useCallback((updated, { replaceDraft = true } = {}) => {
+ setWorldState((current) => current
+ ? { ...current, ...updated, identity: updated.identity || updated.human || current.identity }
+ : updated);
+ if (replaceDraft) {
+ if (updated?.recipe) setRecipeDraft(updated.recipe);
+ setAssetOverridesDraft(updated?.design?.userOverrides?.assets || {});
+ if (updated?.world) setWorldName(updated.world);
+ if (updated?.identity?.name || updated?.human?.name) setHumanName(updated.identity?.name || updated.human.name);
+ savedDraftRevision.current = configDraftRevision.current;
+ setDraftDirty(false);
+ }
+ }, []);
const prepare = useCallback(() => {
const generation = ++requestGeneration.current;
@@ -109,13 +200,17 @@ export default function Eidoverse() {
setError('');
setHostUrl('');
setHostInfo(null);
+ setIframeReady(false);
setSetupState(null);
setWorldState(null);
setRecipeDraft(null);
+ setAssetOverridesDraft({});
setProjectionStatus('idle');
setProjectionError('');
setConfigStatus('');
+ setDraftDirty(false);
configDraftRevision.current = 0;
+ savedDraftRevision.current = 0;
const load = async () => {
const featureState = await getInstanceFeatures(silent);
@@ -156,6 +251,7 @@ export default function Eidoverse() {
setWorldName(result.world?.world || '');
setHumanName(result.world?.identity?.name || result.world?.human?.name || '');
setRecipeDraft(result.world?.recipe || null);
+ setAssetOverridesDraft(result.world?.design?.userOverrides?.assets || {});
setHostUrl(result.hostUrl || '');
}, (reason) => {
if (!isCurrent()) return;
@@ -167,23 +263,55 @@ export default function Eidoverse() {
const runProjection = useCallback(async () => {
setProjectionStatus('running');
setProjectionError('');
- try {
- const result = await projectEidoverseWorld(silent);
- setWorldState((current) => current
- ? {
+ const submittedRevision = configDraftRevision.current;
+ const submittedDraftWasClean = submittedRevision === savedDraftRevision.current;
+ const pollGeneration = ++projectionPollGeneration.current;
+ const poll = () => {
+ if (projectionPollGeneration.current !== pollGeneration) return;
+ getEidoverseWorldProjectionStatus(silent).then((status) => {
+ if (projectionPollGeneration.current !== pollGeneration) return;
+ setWorldState((current) => current ? {
...current,
- projection: result.projection || current.projection,
- presence: result.presence || current.presence,
+ projection: status.projection || current.projection,
+ design: status.design ? { ...current.design, ...status.design } : current.design,
+ } : current);
+ }).catch(() => {}).finally(() => {
+ if (projectionPollGeneration.current === pollGeneration) {
+ projectionPollTimer.current = setTimeout(poll, 750);
}
- : current);
+ });
+ };
+ projectionPollTimer.current = setTimeout(poll, 750);
+ return projectEidoverseWorld(silent).then((result) => {
+ const replaceDraft = submittedDraftWasClean
+ && configDraftRevision.current === submittedRevision;
+ setWorldState((current) => current ? {
+ ...current,
+ projection: result.projection || current.projection,
+ presence: result.presence || current.presence,
+ design: result.design || current.design,
+ recipe: result.recipe || current.recipe,
+ } : current);
+ if (replaceDraft && result.recipe) {
+ setRecipeDraft(result.recipe);
+ setAssetOverridesDraft(result.design?.userOverrides?.assets || {});
+ }
setProjectionStatus('complete');
return result;
- } catch (reason) {
+ }, async (reason) => {
setProjectionStatus('error');
setProjectionError(reason?.message || 'PortOS could not project its current state into Eidoverse.');
+ const failedStatus = await getEidoverseWorldStatus(silent).catch(() => null);
+ if (failedStatus) applyWorldResponse(failedStatus, { replaceDraft: false });
throw reason;
- }
- }, []);
+ }).finally(() => {
+ if (projectionPollGeneration.current === pollGeneration) {
+ projectionPollGeneration.current += 1;
+ clearTimeout(projectionPollTimer.current);
+ projectionPollTimer.current = null;
+ }
+ });
+ }, [applyWorldResponse]);
useEffect(() => {
if (phase !== 'ready' || !hostUrl) return undefined;
@@ -191,125 +319,125 @@ export default function Eidoverse() {
return undefined;
}, [phase, hostUrl, runProjection]);
+ useEffect(() => {
+ prepare();
+ return () => {
+ requestGeneration.current += 1;
+ projectionPollGeneration.current += 1;
+ clearTimeout(projectionPollTimer.current);
+ };
+ }, [prepare]);
+
+ const markConfigDirty = useCallback(() => {
+ configDraftRevision.current += 1;
+ setDraftDirty(true);
+ setConfigStatus((current) => current === 'saving' ? current : '');
+ }, []);
+
+ const mutateRecipe = useCallback((mutator) => {
+ markConfigDirty();
+ setRecipeDraft((current) => current ? mutator(current) : current);
+ }, [markConfigDirty]);
+
+ const mutateAssetOverride = useCallback((slot, path) => {
+ markConfigDirty();
+ setAssetOverridesDraft((current) => {
+ const next = { ...current };
+ if (path.trim()) next[slot] = path;
+ else delete next[slot];
+ return next;
+ });
+ }, [markConfigDirty]);
+
const saveWorldConfig = useCallback(async () => {
if (!recipeDraft) return;
const submittedRevision = configDraftRevision.current;
setConfigStatus('saving');
- let updated;
- try {
- updated = await updateEidoverseWorldConfig({
- world: worldName.trim(),
- humanName: humanName.trim() || null,
- recipe: recipeDraft,
- }, silent);
- setWorldState((current) => current
- ? { ...current, ...updated, identity: updated.human }
- : current);
- if (configDraftRevision.current === submittedRevision) {
- setWorldName(updated.world || '');
- setHumanName(updated.human?.name || '');
- setRecipeDraft(updated.recipe || recipeDraft);
- setConfigStatus('saved');
- } else {
- setConfigStatus('');
- }
- } catch (reason) {
+ const updated = await updateEidoverseWorldConfig({
+ world: worldName.trim(),
+ humanName: humanName.trim() || null,
+ recipe: recipeDraft,
+ assetOverrides: assetOverridesDraft,
+ }, silent).catch((reason) => {
setConfigStatus(reason?.message || 'Could not save the Eidoverse world configuration.');
- return;
- }
+ return null;
+ });
+ if (!updated) return;
+ const draftIsCurrent = configDraftRevision.current === submittedRevision;
+ applyWorldResponse(updated, { replaceDraft: draftIsCurrent });
+ setConfigStatus(draftIsCurrent ? 'saved' : '');
const nextHostUrl = hostInfo && setupState
? hostUrlFor(hostInfo, setupState, window.location, worldIdentityFor(updated))
: hostUrl;
if (nextHostUrl !== hostUrl) setHostUrl(nextHostUrl);
else void runProjection().catch(() => {});
- }, [humanName, hostInfo, hostUrl, recipeDraft, runProjection, setupState, worldName]);
-
- const markConfigDirty = () => {
- configDraftRevision.current += 1;
- setConfigStatus((current) => current === 'saving' ? current : '');
- };
-
- const toggleRecipeInclude = (key) => {
- markConfigDirty();
- setRecipeDraft((current) => current
- ? { ...current, includes: { ...current.includes, [key]: !current.includes[key] } }
- : current);
- };
+ }, [applyWorldResponse, assetOverridesDraft, hostInfo, hostUrl, humanName, recipeDraft, runProjection, setupState, worldName]);
- const updateRecipeLimit = (key, value) => {
- markConfigDirty();
- setRecipeDraft((current) => current
- ? { ...current, limits: { ...current.limits, [key]: value === '' ? 0 : Number(value) } }
- : current);
- };
-
- const updateRecipeNumber = (section, key, value) => {
- markConfigDirty();
- setRecipeDraft((current) => current
- ? {
- ...current,
- [section]: {
- ...current[section],
- [key]: value === '' ? '' : Number(value),
- },
+ const runConfigAction = useCallback(async (payload) => {
+ const submittedRevision = configDraftRevision.current;
+ const submittedDraftWasClean = submittedRevision === savedDraftRevision.current;
+ const submittedRecipeDraft = recipeDraft;
+ const submittedAssetOverrides = assetOverridesDraft;
+ const serverRecipeBeforeAction = worldState?.recipe;
+ const serverAssetOverridesBefore = worldState?.design?.userOverrides?.assets || {};
+ setConfigStatus('saving');
+ const updated = await updateEidoverseWorldConfig(payload, silent).catch((reason) => {
+ setConfigStatus(reason?.message || 'Could not update the Eidoverse world configuration.');
+ return null;
+ });
+ if (!updated) return;
+ const draftIsCurrent = configDraftRevision.current === submittedRevision;
+ const replaceDraft = draftIsCurrent
+ && (submittedDraftWasClean || payload.reset?.scope === 'all');
+ if (replaceDraft) configDraftRevision.current += 1;
+ applyWorldResponse(updated, { replaceDraft });
+ if (!replaceDraft && payload.reset) {
+ if (updated.recipe) {
+ setRecipeDraft((current) => reconcileResetRecipe(
+ current,
+ submittedRecipeDraft,
+ updated.recipe,
+ payload.reset,
+ ));
}
- : current);
- };
-
- const updateRecipeText = (section, key, value) => {
- markConfigDirty();
- setRecipeDraft((current) => current
- ? { ...current, [section]: { ...current[section], [key]: value } }
- : current);
- };
-
- const updateRecipeOrigin = (index, value) => {
- markConfigDirty();
- setRecipeDraft((current) => current
- ? {
- ...current,
- layout: {
- ...current.layout,
- origin: current.layout?.origin?.map((part, partIndex) => partIndex === index
- ? (value === '' ? '' : Number(value))
- : part) || [0, 0, 0],
- },
+ setAssetOverridesDraft((current) => reconcileResetAssetOverrides(
+ current,
+ submittedAssetOverrides,
+ updated.design?.userOverrides?.assets || {},
+ payload.reset,
+ updated.recipe?.districts?.find(({ id }) => id === payload.reset.districtId)?.sources,
+ ));
+ } else if (!replaceDraft && payload.refreshAssets) {
+ if (updated.recipe) {
+ setRecipeDraft((current) => reconcileActionDraft(
+ current,
+ submittedRecipeDraft,
+ serverRecipeBeforeAction,
+ updated.recipe,
+ ));
}
- : current);
- };
-
- const updateRecipeAsset = (sourceKey, value) => {
- const kind = RECIPE_KIND_BY_SOURCE[sourceKey];
- markConfigDirty();
- setRecipeDraft((current) => kind && current
- ? { ...current, assets: { ...current.assets, [kind]: value } }
- : current);
- };
-
- useEffect(() => {
- prepare();
- return () => { requestGeneration.current += 1; };
- }, [prepare]);
+ setAssetOverridesDraft((current) => reconcileActionDraft(
+ current,
+ submittedAssetOverrides,
+ serverAssetOverridesBefore,
+ updated.design?.userOverrides?.assets || {},
+ ));
+ }
+ setConfigStatus(replaceDraft ? 'saved' : '');
+ void runProjection().catch(() => {});
+ }, [applyWorldResponse, assetOverridesDraft, recipeDraft, runProjection, worldState]);
const actions = (
<>
{hostUrl && (
-
+
Open full screen
)}
{appId && (
-
+
Manage app
@@ -317,263 +445,126 @@ export default function Eidoverse() {
>
);
- const worldControls = phase === 'ready' && worldState && recipeDraft ? (
-
-
-
- Private PortOS world
- World: {worldState.world}
- User: {worldState.identity?.name || 'not configured'}
- CoS: {worldState.presence?.connected ? 'connected' : 'ready to reconnect'} {worldState.presence?.role ? ` · ${worldState.presence.role}` : ''}
-
-
- { void runProjection().catch(() => {}); }}
- disabled={projectionStatus === 'running'}
- className="inline-flex min-h-[36px] items-center gap-1.5 rounded-lg border border-port-border px-2.5 py-1.5 text-gray-200 transition-colors hover:border-port-accent hover:text-white disabled:cursor-wait disabled:opacity-50"
- >
-
- {projectionStatus === 'running' ? 'Projecting…' : 'Project PortOS now'}
-
-
- CoS tasks
-
-
-
- {projectionError && (
-
{projectionError}
- )}
-
- World identity and projection recipe
-
-
-
- ) : null;
+ const design = worldState?.design || {};
+ const reconciliation = design.reconciliation || {};
+ const summary = worldState?.projection?.lastSummary || {};
+ const projectionProgress = reconciliation.operationCount > 0
+ ? `${Math.min(reconciliation.appliedOperations || 0, reconciliation.operationCount)}/${reconciliation.operationCount}`
+ : null;
+ const freshWorldLighting = projectionStatus === 'running'
+ && design.lastAppliedVersion == null
+ && !FRESH_WORLD_VISIBLE_CHECKPOINTS.has(reconciliation.checkpoint);
+ const showLoadingCurtain = !iframeReady || freshWorldLighting;
return (
-
+
- {worldControls}
-
{phase === 'ready' && (
-
+
+
)}
{['loading', 'starting', 'connecting'].includes(phase) && (
-
+
)}
@@ -582,13 +573,8 @@ export default function Eidoverse() {
Install Eidoverse Worlds
-
- Install and enable the managed app from PortOS Features before opening this world.
-
-
+ Install and enable the managed app from PortOS Features before opening this world.
+
Open Features
@@ -601,17 +587,35 @@ export default function Eidoverse() {
Eidoverse Worlds did not load
{error}
-
+
Retry
)}
+
+
setSettingsOpen(false)}
+ worldState={worldState}
+ worldName={worldName}
+ setWorldName={setWorldName}
+ humanName={humanName}
+ setHumanName={setHumanName}
+ recipeDraft={recipeDraft}
+ assetOverridesDraft={assetOverridesDraft}
+ mutateRecipe={mutateRecipe}
+ mutateAssetOverride={mutateAssetOverride}
+ markDirty={markConfigDirty}
+ configStatus={configStatus}
+ projectionStatus={projectionStatus}
+ dirty={draftDirty}
+ onSave={saveWorldConfig}
+ onProject={() => { if (!draftDirty) void runProjection().catch(() => {}); }}
+ onReset={(scope, districtId) => { void runConfigAction({ reset: { scope, ...(districtId ? { districtId } : {}) } }); }}
+ onRefreshAssets={() => { if (!draftDirty) void runConfigAction({ refreshAssets: true }); }}
+ />
);
}
diff --git a/client/src/pages/Eidoverse.test.jsx b/client/src/pages/Eidoverse.test.jsx
index 27b3625b4..362e4b503 100644
--- a/client/src/pages/Eidoverse.test.jsx
+++ b/client/src/pages/Eidoverse.test.jsx
@@ -1,10 +1,11 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { render, screen, waitFor } from '@testing-library/react';
+import { act, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router';
vi.mock('../services/api', () => ({
getApp: vi.fn(),
+ getEidoverseWorldProjectionStatus: vi.fn(),
getEidoverseWorldStatus: vi.fn(),
getInstanceFeatures: vi.fn(),
projectEidoverseWorld: vi.fn(),
@@ -13,58 +14,101 @@ vi.mock('../services/api', () => ({
updateEidoverseWorldConfig: vi.fn(),
}));
+vi.mock('../components/BrailleSpinner', () => ({
+ default: ({ text }) => {text} ,
+}));
+
import * as api from '../services/api';
import Eidoverse, { hostUrlFor } from './Eidoverse';
-const setup = {
- installed: true,
- appId: 'app-eidoverse',
- uiPort: 8940,
- runtimeStatus: 'online',
-};
-
+const setup = { installed: true, appId: 'app-eidoverse', uiPort: 8940, runtimeStatus: 'online' };
const featureResponse = (overrides = {}) => ({
- features: [{
- id: 'eidoverse',
- enabled: false,
- setup: { ...setup, ...overrides },
- }],
+ features: [{ id: 'eidoverse', enabled: false, setup: { ...setup, ...overrides } }],
});
+const includes = {
+ apps: true, agents: true, tasks: true, features: true, peers: true, health: true,
+ productivity: true, activity: true, goals: true, memory: true, storage: true, jira: true, operations: true,
+};
+const limits = {
+ apps: 8, agents: 6, tasks: 6, features: 4, peers: 4, health: 1,
+ productivity: 1, activity: 3, goals: 4, memory: 3, storage: 4, jira: 3, operations: 1,
+};
+const scale = Object.fromEntries([
+ 'app', 'agent', 'task', 'feature', 'peer', 'health', 'productivity',
+ 'activity', 'goal', 'memory', 'storage', 'jira', 'operations',
+].map((kind) => [kind, 1]));
+const slot = (name) => ({
+ preferredPaths: [`eidoverse/assets/models/${name}.glb`],
+ fallbackQueries: [`example ${name}`],
+ requiredTokens: [name],
+ excludedTokens: ['car'],
+ maxBytes: 20_000_000,
+ format: 'glb',
+ animation: 'optional',
+ sourcePolicy: 'library-only',
+ fallback: 'eidoverse/assets/models/orb.glb',
+});
+const assetSlots = Object.fromEntries([
+ 'nexus', 'app', 'agent', 'task', 'goal', 'memory', 'storage', 'peer', 'activity', 'district',
+].map((name) => [name, slot(name)]));
+
+const recipe = {
+ version: 2,
+ name: 'Luminous Systems Garden',
+ maxEntities: 48,
+ includes,
+ limits,
+ scale,
+ districts: [
+ { id: 'nexus', label: 'PortOS Nexus', anchor: [0, 0, 0], sources: ['health', 'operations', 'features'], accent: '#ffb86b' },
+ { id: 'apps', label: 'App Terraces', anchor: [-30, 0, -18], sources: ['apps'], accent: '#65d9ff' },
+ { id: 'agents', label: 'Agent Foundry', anchor: [0, 0, -34], sources: ['agents', 'tasks'], accent: '#a78bfa' },
+ ],
+ environment: {
+ terrain: { seed: 'example', size: 180, segments: 96, amplitude: 1.4, flatRadius: 48, layers: [{ color: '#0d1629', repeat: 22 }] },
+ sky: { system: 'skymesh', hours: 7.2, azimuth: 145, sun: 1.35, ambient: 1.2, fill: 1.1, exposure: 1.08, fog: 0.42, clouds: 'cirrus', weather: 'clear' },
+ grass: { species: 'grass', width: 154, depth: 144, center: [0, 0], height: 0.22, color: 'gray-green', density: 0.45 },
+ lights: [],
+ },
+ assetRecipe: { version: 2, slots: assetSlots },
+ assets: { app: 'eidoverse/assets/models/app.glb' },
+};
+
+const design = {
+ name: recipe.name,
+ selectedVersion: 2,
+ lastAppliedVersion: 1,
+ pendingVersion: 2,
+ assetRecipeVersion: 2,
+ maxEntities: 48,
+ districts: recipe.districts,
+ assetResolutions: {
+ app: { path: 'eidoverse/assets/models/app.glb', source: 'preferred', bytes: 4_000_000, catalogFingerprint: 'example' },
+ },
+ migrationReport: { status: 'ready', fromDesignVersion: 1, toDesignVersion: 2, preservedOverrides: ['limits.apps'] },
+ reconciliation: { status: 'pending', checkpoint: 'migration-complete', error: null },
+};
+
const worldResponse = {
world: 'portos',
identity: { name: 'example-portos-user' },
human: { name: 'example-portos-user' },
cos: { id: 'portos-cos', enabled: true },
- recipe: {
- version: 1,
- includes: {
- apps: true, agents: true, tasks: true, features: true, peers: true, health: true,
- productivity: true, activity: true, goals: true, memory: true, storage: true, jira: true, operations: true,
- },
- limits: {
- apps: 10, agents: 10, tasks: 10, features: 10, peers: 10, health: 1,
- productivity: 1, activity: 10, goals: 10, memory: 10, storage: 10, jira: 10, operations: 1,
- },
- layout: { origin: [0, 0, 0], spacing: 7, laneGap: 6, columns: 8 },
- scale: {
- app: 1, agent: 1, task: 1, feature: 1, peer: 1, health: 1,
- productivity: 1, activity: 1, goal: 1, memory: 1, storage: 1, jira: 1, operations: 1,
- },
- assets: Object.fromEntries([
- 'app', 'agent', 'task', 'feature', 'peer', 'health', 'productivity', 'activity',
- 'goal', 'memory', 'storage', 'jira', 'operations',
- ].map((kind) => [kind, `eidoverse/assets/models/${kind}.glb`])),
- terrain: {
- seed: 'example', size: 128, segments: 64, amplitude: 1.8, flatRadius: 28,
- layers: [{ color: '#142338', repeat: 18 }],
+ recipe,
+ design,
+ projection: {
+ lastSummary: {
+ liveEntityCount: 12,
+ sourceAvailability: { apps: true, agents: false },
+ sourceCounts: { apps: 3, agents: null },
},
},
presence: { connected: false },
};
-const renderPage = () => render(
-
+const renderPage = (entry = '/eidoverse') => render(
+
,
);
@@ -77,28 +121,47 @@ describe('Eidoverse hosted page', () => {
api.startApp.mockResolvedValue({ success: true, results: {} });
api.startEidoverseHost.mockResolvedValue({ running: true, protocol: 'http', port: 5563 });
api.getEidoverseWorldStatus.mockResolvedValue(worldResponse);
+ api.getEidoverseWorldProjectionStatus.mockResolvedValue({
+ design: {
+ lastAppliedVersion: design.lastAppliedVersion,
+ pendingVersion: design.pendingVersion,
+ reconciliation: design.reconciliation,
+ },
+ projection: worldResponse.projection,
+ });
api.projectEidoverseWorld.mockResolvedValue({
success: true,
- projection: { lastSuccessAt: '2026-01-01T00:00:00.000Z' },
+ projection: { lastSuccessAt: '2026-01-01T00:00:00.000Z', lastSummary: worldResponse.projection.lastSummary },
presence: { connected: true, role: 'owner' },
+ design: { ...design, lastAppliedVersion: 2, pendingVersion: null, reconciliation: { status: 'complete', checkpoint: 'projection-committed' } },
+ recipe,
});
api.updateEidoverseWorldConfig.mockResolvedValue({ ...worldResponse, human: worldResponse.identity });
});
- it('loads the installed managed app even when the optional nav entry is disabled', async () => {
+ it('loads the installed managed app and renders the PortOS spatial overlay', async () => {
renderPage();
const frame = await screen.findByTitle('Eidoverse Worlds');
expect(frame).toHaveAttribute('src', `http://${window.location.hostname}:8940/?world=portos&name=example-portos-user`);
- expect(api.getApp).toHaveBeenCalledWith('app-eidoverse', { silent: true });
- expect(api.startApp).not.toHaveBeenCalled();
- expect(api.startEidoverseHost).toHaveBeenCalledWith({ silent: true });
- expect(api.getEidoverseWorldStatus).toHaveBeenCalledWith({ silent: true });
+ const overlayHeading = await screen.findByText('Your PortOS, made spatial');
+ expect(overlayHeading.closest('section')).toHaveClass('port-media-overlay');
+ expect(screen.getByText(/The Nexus is system health/)).toHaveClass('text-port-text-muted');
+ expect(screen.getByText('Design V2').parentElement).toHaveClass('text-port-text-muted');
+ expect(screen.getByText(/Steady = current/)).toHaveClass('text-port-text-muted');
+ expect(screen.getByRole('button', { name: 'Refresh world' }))
+ .toHaveClass('port-media-overlay-strong', 'port-media-overlay-item');
+ expect(screen.getByRole('button', { name: 'Refresh world' }))
+ .toHaveAttribute('aria-label', 'Refresh world');
+ expect(screen.getByRole('region', { name: 'PortOS district legend' }).querySelector('.port-media-overlay'))
+ .toBeInTheDocument();
+ expect(screen.getByText('App Terraces')).toBeInTheDocument();
+ expect(screen.getByText('12/48 live signals')).toBeInTheDocument();
await waitFor(() => expect(api.projectEidoverseWorld).toHaveBeenCalledWith({ silent: true }));
expect(screen.getByRole('link', { name: 'Manage app' })).toHaveAttribute('href', '/apps/app-eidoverse/overview');
});
- it('starts a stopped managed app before connecting the hosted page', async () => {
+ it('starts a stopped managed app before connecting', async () => {
api.getApp.mockResolvedValue({ id: setup.appId, overallStatus: 'stopped' });
renderPage();
@@ -107,17 +170,15 @@ describe('Eidoverse hosted page', () => {
expect(api.startEidoverseHost).toHaveBeenCalledAfter(api.startApp);
});
- it('sends an uninstalled user to the existing Features setup', async () => {
+ it('sends an uninstalled user to Features', async () => {
api.getInstanceFeatures.mockResolvedValue(featureResponse({ installed: false, appId: null }));
renderPage();
- const setupLink = await screen.findByRole('link', { name: 'Open Features' });
- expect(setupLink).toHaveAttribute('href', '/settings/features');
+ expect(await screen.findByRole('link', { name: 'Open Features' })).toHaveAttribute('href', '/settings/features');
expect(api.getApp).not.toHaveBeenCalled();
- expect(api.startEidoverseHost).not.toHaveBeenCalled();
});
- it('surfaces a managed-app start failure and retries on demand', async () => {
+ it('surfaces a managed-app start failure and retries', async () => {
api.getApp.mockResolvedValue({ id: setup.appId, overallStatus: 'stopped' });
api.startApp
.mockResolvedValueOnce({ success: true, results: { eidoverse: { success: false, error: 'Example startup failure' } } })
@@ -127,7 +188,6 @@ describe('Eidoverse hosted page', () => {
expect(await screen.findByRole('alert')).toHaveTextContent('Example startup failure');
await user.click(screen.getByRole('button', { name: 'Retry' }));
-
await screen.findByTitle('Eidoverse Worlds');
expect(api.startApp).toHaveBeenCalledTimes(2);
});
@@ -145,112 +205,359 @@ describe('Eidoverse hosted page', () => {
)).toThrow(/shared certificate/);
});
- it('shows bridge readiness errors instead of mounting a dead iframe', async () => {
- api.startEidoverseHost.mockRejectedValue(new Error('Eidoverse Worlds did not become ready in time.'));
- renderPage();
-
- await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('did not become ready'));
- expect(screen.queryByTitle('Eidoverse Worlds')).toBeNull();
- });
-
- it('keeps a successful local save visible when the follow-up projection fails', async () => {
+ it('keeps a successful local save visible when projection fails', async () => {
const user = userEvent.setup();
renderPage();
await screen.findByTitle('Eidoverse Worlds');
await waitFor(() => expect(api.projectEidoverseWorld).toHaveBeenCalledTimes(1));
api.projectEidoverseWorld.mockRejectedValueOnce(new Error('Example projection failure'));
- await user.click(screen.getByText('World identity and projection recipe'));
+ await user.click(screen.getByRole('button', { name: 'World controls' }));
await user.click(screen.getByRole('button', { name: 'Save and project' }));
- expect(await screen.findByText('Saved locally.')).toBeInTheDocument();
+ expect(await screen.findByText('Saved locally and queued for projection.')).toBeInTheDocument();
expect(await screen.findByText('Example projection failure')).toBeInTheDocument();
- expect(api.updateEidoverseWorldConfig).toHaveBeenCalledOnce();
- });
-
- it('clears the saved marker as soon as the local recipe draft changes again', async () => {
- const user = userEvent.setup();
- renderPage();
- await screen.findByTitle('Eidoverse Worlds');
- await user.click(screen.getByText('World identity and projection recipe'));
- await user.click(screen.getByRole('button', { name: 'Save and project' }));
- expect(await screen.findByText('Saved locally.')).toBeInTheDocument();
-
- await user.type(screen.getByLabelText('My Eidoverse name'), '-edited');
- expect(screen.queryByText('Saved locally.')).not.toBeInTheDocument();
+ expect(screen.getByRole('link', { name: 'Check the Eidoverse runtime' })).toHaveAttribute('href', '/apps/app-eidoverse/overview');
});
- it('keeps newer draft edits intact while an earlier save is in flight', async () => {
+ it('keeps newer edits intact while an earlier save is in flight', async () => {
let resolveSave;
- api.updateEidoverseWorldConfig.mockReturnValueOnce(new Promise((resolve) => {
- resolveSave = resolve;
- }));
+ api.updateEidoverseWorldConfig.mockReturnValueOnce(new Promise((resolve) => { resolveSave = resolve; }));
const user = userEvent.setup();
renderPage();
await screen.findByTitle('Eidoverse Worlds');
- await user.click(screen.getByText('World identity and projection recipe'));
+ await user.click(screen.getByRole('button', { name: 'World controls' }));
const nameInput = screen.getByLabelText('My Eidoverse name');
- const saveButton = screen.getByRole('button', { name: 'Save and project' });
- await user.click(saveButton);
+ await user.click(screen.getByRole('button', { name: 'Save and project' }));
expect(screen.getByRole('button', { name: 'Saving…' })).toBeDisabled();
-
await user.type(nameInput, '-edited');
- expect(screen.getByRole('button', { name: 'Saving…' })).toBeDisabled();
resolveSave({ ...worldResponse, human: worldResponse.identity });
await waitFor(() => expect(screen.getByRole('button', { name: 'Save and project' })).toBeEnabled());
expect(nameInput).toHaveValue('example-portos-user-edited');
- expect(screen.queryByText('Saved locally.')).not.toBeInTheDocument();
- expect(api.updateEidoverseWorldConfig).toHaveBeenCalledOnce();
+ expect(screen.queryByText('Saved locally and queued for projection.')).not.toBeInTheDocument();
});
- it('reloads the durable browser identity and reports projection failure separately after a world rename', async () => {
+ it('reloads the durable browser identity after a world rename', async () => {
const user = userEvent.setup();
const renamed = { ...worldResponse, world: 'portos-two', human: worldResponse.identity };
renderPage();
await screen.findByTitle('Eidoverse Worlds');
- await waitFor(() => expect(api.projectEidoverseWorld).toHaveBeenCalledTimes(1));
api.updateEidoverseWorldConfig.mockResolvedValueOnce(renamed);
- api.projectEidoverseWorld.mockRejectedValueOnce(new Error('Example renamed-world failure'));
- await user.click(screen.getByText('World identity and projection recipe'));
+ await user.click(screen.getByRole('button', { name: 'World controls' }));
const worldInput = screen.getByLabelText('World name');
await user.clear(worldInput);
await user.type(worldInput, 'portos-two');
await user.click(screen.getByRole('button', { name: 'Save and project' }));
- expect(await screen.findByText('Saved locally.')).toBeInTheDocument();
- expect(await screen.findByText('Example renamed-world failure')).toBeInTheDocument();
- expect(screen.getByTitle('Eidoverse Worlds')).toHaveAttribute(
+ await waitFor(() => expect(screen.getByTitle('Eidoverse Worlds')).toHaveAttribute(
'src',
`http://${window.location.hostname}:8940/?world=portos-two&name=example-portos-user`,
- );
+ ));
});
- it('mirrors the strict world recipe constraints in the browser form', async () => {
+ it('groups data, assets, and upgrade state in a deep-linkable tabbed drawer', async () => {
const user = userEvent.setup();
renderPage();
await screen.findByTitle('Eidoverse Worlds');
- await user.click(screen.getByText('World identity and projection recipe'));
+ await user.click(screen.getByRole('button', { name: 'World controls' }));
expect(screen.getByLabelText('World name')).toHaveAttribute('pattern', '[A-Za-z0-9_-]+');
- const assetInput = screen.getByLabelText('Asset path', { selector: '#eidoverse-asset-app' });
- expect(assetInput).toBeRequired();
- expect(assetInput).toHaveAttribute(
- 'pattern',
- '(?:[Ee][Ii][Dd][Oo][Vv][Ee][Rr][Ss][Ee]|[Ss][Tt][Oo][Rr][Ee])[\\\\/](?!.*\\.\\.).*',
+ await user.click(screen.getByRole('tab', { name: 'Districts & Data' }));
+ expect(screen.getByText(/bounded summaries, never raw records/i)).toBeInTheDocument();
+ expect(screen.getByLabelText('Apps')).toBeChecked();
+ expect(screen.getByText('3')).toBeInTheDocument();
+ expect(screen.getAllByRole('link', { name: 'Open in PortOS' })
+ .some((link) => link.getAttribute('href') === '/apps')).toBe(true);
+
+ await user.click(screen.getByRole('tab', { name: 'Appearance & Assets' }));
+ expect(screen.getByText('Portable asset recipe')).toBeInTheDocument();
+ expect(screen.getByText('eidoverse/assets/models/app.glb')).toBeInTheDocument();
+ expect(screen.getByLabelText('Sun hour')).toHaveValue(7.2);
+
+ await user.click(screen.getByRole('tab', { name: 'Updates & Advanced' }));
+ expect(screen.getByText('projection-committed')).toBeInTheDocument();
+ expect(screen.getByText('1', { selector: 'dd' })).toBeInTheDocument();
+ expect(screen.getByText('limits.apps')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Apply world update' })).toBeInTheDocument();
+ });
+
+ it('surfaces per-source omissions when the shared live-signal cap is saturated', async () => {
+ const user = userEvent.setup();
+ const truncatedSummary = {
+ ...worldResponse.projection.lastSummary,
+ liveEntityCount: 48,
+ maxLiveEntities: 48,
+ truncated: true,
+ droppedBySource: { apps: 2, agents: 1 },
+ };
+ api.getEidoverseWorldStatus.mockResolvedValueOnce({
+ ...worldResponse,
+ projection: { lastSummary: truncatedSummary },
+ });
+ api.projectEidoverseWorld.mockResolvedValueOnce({
+ success: true,
+ projection: { lastSummary: truncatedSummary },
+ presence: { connected: true, role: 'owner' },
+ design: { ...design, lastAppliedVersion: 2, pendingVersion: null },
+ recipe,
+ });
+ renderPage();
+ await screen.findByTitle('Eidoverse Worlds');
+ await user.click(screen.getByRole('button', { name: 'World controls' }));
+ await user.click(screen.getByRole('tab', { name: 'Districts & Data' }));
+
+ expect(screen.getByText(/shared world cap omitted 2 Apps, 1 Agents signal/i)).toBeInTheDocument();
+ expect(screen.getByText(/2 omitted by cap/)).toBeInTheDocument();
+ });
+
+ it('shows exact staged reconciliation progress while a projection is running', async () => {
+ const user = userEvent.setup();
+ let resolveProjection;
+ api.getEidoverseWorldStatus.mockResolvedValueOnce({
+ ...worldResponse,
+ design: {
+ ...design,
+ reconciliation: {
+ status: 'applying', checkpoint: 'applying-infrastructure', operationCount: 20, appliedOperations: 5,
+ },
+ },
+ });
+ api.projectEidoverseWorld.mockReturnValueOnce(new Promise((resolve) => { resolveProjection = resolve; }));
+ renderPage();
+
+ expect(await screen.findByText('Projecting 5/20')).toBeInTheDocument();
+ await user.click(screen.getByRole('button', { name: 'World controls' }));
+ await user.click(screen.getByRole('tab', { name: 'Updates & Advanced' }));
+ expect(screen.getByRole('progressbar', { name: 'World reconciliation progress' })).toHaveAttribute('aria-valuenow', '5');
+ resolveProjection({
+ success: true,
+ projection: worldResponse.projection,
+ presence: { connected: true },
+ design: { ...design, reconciliation: { status: 'complete', checkpoint: 'projection-committed' } },
+ recipe,
+ });
+ await waitFor(() => expect(screen.queryByText('Projecting 5/20')).not.toBeInTheDocument());
+ });
+
+ it('keeps a fresh-world curtain up until the dawn environment is applied', async () => {
+ let resolveProjection;
+ api.getEidoverseWorldStatus.mockResolvedValueOnce({
+ ...worldResponse,
+ design: {
+ ...design,
+ lastAppliedVersion: null,
+ reconciliation: { status: 'applying', checkpoint: 'asset-preflight-complete' },
+ },
+ });
+ api.projectEidoverseWorld.mockReturnValueOnce(new Promise((resolve) => { resolveProjection = resolve; }));
+ renderPage();
+ const frame = await screen.findByTitle('Eidoverse Worlds');
+ await waitFor(() => expect(api.projectEidoverseWorld).toHaveBeenCalledOnce());
+ fireEvent.load(frame);
+
+ expect(screen.getByText(/Preparing the PortOS systems garden/)).toBeInTheDocument();
+ api.getEidoverseWorldProjectionStatus.mockResolvedValue({
+ projection: worldResponse.projection,
+ design: {
+ lastAppliedVersion: null,
+ reconciliation: { status: 'applying', checkpoint: 'environment-complete' },
+ },
+ });
+ await waitFor(
+ () => expect(screen.queryByText(/Preparing the PortOS systems garden/)).not.toBeInTheDocument(),
+ { timeout: 2500 },
);
- await user.clear(assetInput);
- await user.type(assetInput, 'EIDOVERSE\\assets\\models\\example.glb');
- expect(assetInput).toBeValid();
-
- const scaleInput = screen.getByLabelText('Scale', { selector: '#eidoverse-scale-app' });
- await user.clear(scaleInput);
- await user.type(scaleInput, '0.001');
- expect(scaleInput).toBeValid();
- expect(screen.getByLabelText('Seed')).toBeRequired();
- expect(screen.getByLabelText('size')).toHaveAttribute('min', '0.01');
- expect(screen.getByLabelText('amplitude')).toHaveAttribute('max', '100');
+ expect(api.getEidoverseWorldStatus).toHaveBeenCalledOnce();
+ expect(api.getEidoverseWorldProjectionStatus).toHaveBeenCalled();
+
+ await act(async () => {
+ resolveProjection({
+ success: true,
+ projection: worldResponse.projection,
+ presence: { connected: true },
+ design,
+ recipe,
+ });
+ });
+ });
+
+ it('resets one semantic district without clearing the full world design', async () => {
+ const user = userEvent.setup();
+ renderPage();
+ await screen.findByTitle('Eidoverse Worlds');
+ await user.click(screen.getByRole('button', { name: 'World controls' }));
+ await user.click(screen.getByRole('tab', { name: 'Districts & Data' }));
+ await user.click(screen.getByRole('button', { name: 'Reset App Terraces' }));
+
+ await waitFor(() => expect(api.updateEidoverseWorldConfig).toHaveBeenCalledWith(
+ { reset: { scope: 'district', districtId: 'apps' } },
+ { silent: true },
+ ));
+ });
+
+ it('keeps newer edits intact while a scoped reset is in flight', async () => {
+ let resolveReset;
+ api.updateEidoverseWorldConfig.mockReturnValueOnce(new Promise((resolve) => { resolveReset = resolve; }));
+ const user = userEvent.setup();
+ renderPage();
+ await screen.findByTitle('Eidoverse Worlds');
+ await user.click(screen.getByRole('button', { name: 'World controls' }));
+ await user.click(screen.getByRole('tab', { name: 'Districts & Data' }));
+ await user.click(screen.getByRole('button', { name: 'Reset App Terraces' }));
+ await user.click(screen.getByRole('tab', { name: 'Experience' }));
+
+ const nameInput = screen.getByLabelText('My Eidoverse name');
+ await user.type(nameInput, '-edited');
+ resolveReset({ ...worldResponse, human: worldResponse.identity });
+
+ await waitFor(() => expect(api.projectEidoverseWorld).toHaveBeenCalledTimes(2));
+ expect(nameInput).toHaveValue('example-portos-user-edited');
+ });
+
+ it('merges a scoped reset into the draft without discarding unrelated unsaved edits', async () => {
+ const user = userEvent.setup();
+ renderPage();
+ await screen.findByTitle('Eidoverse Worlds');
+ await waitFor(() => expect(api.projectEidoverseWorld).toHaveBeenCalledOnce());
+ await user.click(screen.getByRole('button', { name: 'World controls' }));
+ await user.click(screen.getByRole('tab', { name: 'Appearance & Assets' }));
+
+ const sunHour = screen.getByLabelText('Sun hour');
+ await user.clear(sunHour);
+ await user.type(sunHour, '8.4');
+ await user.click(screen.getByRole('tab', { name: 'Districts & Data' }));
+ const appsSection = screen.getByRole('heading', { name: 'App Terraces' }).closest('section');
+ const appsLimit = within(appsSection).getByRole('spinbutton', { name: 'Cap' });
+ await user.clear(appsLimit);
+ await user.type(appsLimit, '5');
+ await user.click(within(appsSection).getByRole('button', { name: 'Reset App Terraces' }));
+
+ await waitFor(() => expect(api.updateEidoverseWorldConfig).toHaveBeenCalledWith(
+ { reset: { scope: 'district', districtId: 'apps' } },
+ { silent: true },
+ ));
+ await waitFor(() => expect(appsLimit).toHaveValue(8));
+ await user.click(screen.getByRole('tab', { name: 'Appearance & Assets' }));
+ expect(screen.getByLabelText('Sun hour')).toHaveValue(8.4);
+
+ const save = screen.getByRole('button', { name: 'Save and project' });
+ await waitFor(() => expect(save).toBeEnabled());
+ expect([...save.closest('form').elements]
+ .filter((element) => typeof element.checkValidity === 'function' && !element.checkValidity())
+ .map((element) => ({ id: element.id, value: element.value, validationMessage: element.validationMessage })))
+ .toEqual([]);
+ await user.click(save);
+ await waitFor(() => expect(api.updateEidoverseWorldConfig).toHaveBeenCalledTimes(2));
+ const saved = api.updateEidoverseWorldConfig.mock.calls.at(-1)[0];
+ expect(saved.recipe.environment.sky.hours).toBe(8.4);
+ expect(saved.recipe.limits.apps).toBe(8);
+ });
+
+ it('gates projection and asset actions until the visible draft is saved', async () => {
+ const user = userEvent.setup();
+ renderPage();
+ await screen.findByTitle('Eidoverse Worlds');
+ await waitFor(() => expect(api.projectEidoverseWorld).toHaveBeenCalledOnce());
+ await user.click(screen.getByRole('button', { name: 'World controls' }));
+ await user.click(screen.getByRole('tab', { name: 'Appearance & Assets' }));
+
+ const sunHour = screen.getByLabelText('Sun hour');
+ await user.clear(sunHour);
+ await user.type(sunHour, '8.4');
+ const appearanceRefresh = screen.getByRole('button', { name: 'Refresh asset matches' });
+ expect(appearanceRefresh).toBeDisabled();
+ expect(screen.getByText('Save changes before refreshing asset matches.')).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Refresh world' })).toBeDisabled();
+
+ await user.click(screen.getByRole('tab', { name: 'Updates & Advanced' }));
+ const apply = screen.getByRole('button', { name: 'Apply world update' });
+ const refresh = screen.getByRole('button', { name: 'Refresh asset matches' });
+ expect(apply).toBeDisabled();
+ expect(refresh).toBeDisabled();
+ expect(screen.getByText(/Save your world changes before applying an update/)).toBeInTheDocument();
+ await user.click(apply);
+ await user.click(refresh);
+ expect(api.updateEidoverseWorldConfig).not.toHaveBeenCalled();
+ expect(api.projectEidoverseWorld).toHaveBeenCalledOnce();
+
+ await user.click(screen.getByRole('button', { name: 'Save and project' }));
+ await waitFor(() => expect(api.updateEidoverseWorldConfig).toHaveBeenCalledOnce());
+ await waitFor(() => expect(api.projectEidoverseWorld).toHaveBeenCalledTimes(2));
+ await waitFor(() => expect(apply).toBeEnabled());
+ expect(refresh).toBeEnabled();
+ expect(screen.getByRole('button', { name: 'Refresh world' })).toBeEnabled();
+ });
+
+ it('does not coerce temporarily cleared appearance numbers to invalid zeroes', async () => {
+ const user = userEvent.setup();
+ renderPage();
+ await screen.findByTitle('Eidoverse Worlds');
+ await user.click(screen.getByRole('button', { name: 'World controls' }));
+ await user.click(screen.getByRole('tab', { name: 'Appearance & Assets' }));
+
+ await user.clear(screen.getByLabelText('Exposure'));
+ await user.clear(screen.getByLabelText('Grass density'));
+ const save = screen.getByRole('button', { name: 'Save and project' });
+ await waitFor(() => expect(save).toBeEnabled());
+ fireEvent.submit(save.closest('form'));
+
+ await waitFor(() => expect(api.updateEidoverseWorldConfig).toHaveBeenCalledOnce());
+ const saved = api.updateEidoverseWorldConfig.mock.calls[0][0];
+ expect(saved.recipe.environment.sky.exposure).toBe(1.08);
+ expect(saved.recipe.environment.grass.density).toBe(0.45);
+ });
+
+ it('does not coerce a temporarily cleared source cap to zero', async () => {
+ const user = userEvent.setup();
+ renderPage();
+ await screen.findByTitle('Eidoverse Worlds');
+ await user.click(screen.getByRole('button', { name: 'World controls' }));
+ await user.click(screen.getByRole('tab', { name: 'Districts & Data' }));
+
+ const appsSection = screen.getByRole('heading', { name: 'App Terraces' }).closest('section');
+ const appsLimit = within(appsSection).getByRole('spinbutton', { name: 'Cap' });
+ await user.clear(appsLimit);
+ expect(appsLimit).toHaveValue(null);
+ const save = screen.getByRole('button', { name: 'Save and project' });
+ fireEvent.submit(save.closest('form'));
+
+ await waitFor(() => expect(api.updateEidoverseWorldConfig).toHaveBeenCalledOnce());
+ expect(api.updateEidoverseWorldConfig.mock.calls[0][0].recipe.limits.apps).toBe(8);
+ });
+
+ it('surfaces a preserved legacy asset override and lets the user clear it', async () => {
+ const legacyPath = 'store/example-legacy-feature';
+ const legacyDesign = {
+ ...design,
+ userOverrides: { assets: { feature: legacyPath } },
+ };
+ api.getEidoverseWorldStatus.mockResolvedValue({ ...worldResponse, design: legacyDesign });
+ api.projectEidoverseWorld.mockResolvedValue({
+ success: true,
+ projection: worldResponse.projection,
+ presence: { connected: true },
+ design: legacyDesign,
+ recipe,
+ });
+ const user = userEvent.setup();
+ renderPage();
+ await screen.findByTitle('Eidoverse Worlds');
+ await user.click(screen.getByRole('button', { name: 'World controls' }));
+ await user.click(screen.getByRole('tab', { name: 'Appearance & Assets' }));
+
+ expect(screen.getByText(legacyPath)).toBeInTheDocument();
+ const clear = screen.getByRole('button', { name: 'Clear legacy Feature override' });
+ await waitFor(() => expect(clear).toBeEnabled());
+ await user.click(clear);
+ expect(screen.queryByRole('button', { name: 'Clear legacy Feature override' })).not.toBeInTheDocument();
+
+ const save = screen.getByRole('button', { name: 'Save and project' });
+ expect(save).toBeEnabled();
+ fireEvent.submit(save.closest('form'));
+ await waitFor(() => expect(api.updateEidoverseWorldConfig).toHaveBeenCalledOnce());
+ expect(api.updateEidoverseWorldConfig.mock.calls[0][0].assetOverrides).toEqual({});
});
});
diff --git a/client/src/services/apiSystem.js b/client/src/services/apiSystem.js
index e65f1e2d9..7bac2bf51 100644
--- a/client/src/services/apiSystem.js
+++ b/client/src/services/apiSystem.js
@@ -88,6 +88,7 @@ export const startEidoverseHost = (options = {}) => request('/settings/features/
...options,
});
export const getEidoverseWorldStatus = (options) => request('/eidoverse/world/status', options);
+export const getEidoverseWorldProjectionStatus = (options) => request('/eidoverse/world/projection/status', options);
export const updateEidoverseWorldConfig = (payload, options = {}) => request('/eidoverse/world/config', {
method: 'PUT',
body: JSON.stringify(payload),
diff --git a/docs/SELF_UPDATE.md b/docs/SELF_UPDATE.md
index a248c4c8f..4ea67094a 100644
--- a/docs/SELF_UPDATE.md
+++ b/docs/SELF_UPDATE.md
@@ -46,6 +46,16 @@ The sync step refreshes each checkout's local submodule metadata from the newly
All restart-triggering UI actions (`Update Now`, `Sync Fork & Update`, both “from Fork As-Is” variants, and the reconcile variants) launch `update.sh` or `update.ps1`, so they inherit this exact sequence. `Sync Fork Only` remains intentionally different: it only fast-forwards the GitHub fork and does not touch the local checkout.
+After source update and restart, the normal boot migration pass upgrades
+versioned PortOS-owned data before route initialization. Eidoverse World Design
+updates use this path: the offline migration changes only
+`data/eidoverse/portos-world.json`, preserving V1 custom leaves as explicit V2
+overrides and recording a pending checkpoint. A post-boot, non-AI reconciler
+applies that checkpoint only when the separately managed Eidoverse runtime is
+already online; otherwise the Eidoverse page keeps the update pending with a
+direct managed-app remediation link. Update scripts never mutate the external
+Eidoverse checkouts to apply a PortOS world design.
+
`GET /api/update/status` also compares recursive submodule checkouts with their pinned revisions. An uninitialized, conflicted, behind, or divergent module marks the install out of sync, making the existing Reconcile control available even when no newer release is waiting. A checkout deliberately advanced through the Submodules tab is not treated as stale, and CoS worktrees report submodule state as unknown because they intentionally leave submodules uninitialized and cannot run the primary-checkout update flow.
To prevent that confusion, `POST /api/update/execute` rejects fork runs with **412 `FORK_SYNC_REQUIRED`** unless either:
diff --git a/docs/STORAGE.md b/docs/STORAGE.md
index ce3a5435e..06026cdd3 100644
--- a/docs/STORAGE.md
+++ b/docs/STORAGE.md
@@ -59,7 +59,7 @@ PostgreSQL is a **required** install/runtime dependency (see [Backup & Restore](
- Writers Room draft bodies — `data/writers-room/works/{workId}/drafts/{draftId}.md`. Keep `.md` file-backed; store metadata/index rows in DB.
- MortalLoom / Health / Meatspace health data — `data/health`, `data/meatspace`, MortalLoom iCloud store. Kept file-backed to preserve iCloud/file sync and avoid routing sensitive health records through the app DB before that boundary is designed.
- App scaffolds / cloned repos / browser profiles — `data/repos`, `data/browser-profile` — inherently filesystem-oriented.
-- Eidoverse PortOS integration and world logs — `data/eidoverse/portos-world.json` stores the PortOS-owned private-world identity, projection recipe, and last-good projection checkpoint; `data/eidoverse/worlds` stores the external runtime's append-only world files. Both are `file-primary`, included in filesystem backups, and intentionally machine-local — never federated by PortOS. PortOS selects the runtime's world-store location through `.env.portos` but does not edit the external checkout to build content. The separately licensed git checkouts live under the existing re-cloneable `data/repos/` backup class.
+- Eidoverse PortOS integration and world logs — `data/eidoverse/portos-world.json` stores the PortOS-owned private-world identity, versioned design selection, user overrides, deterministic asset-resolution lock (paths, fingerprints, size, and provenance only; never model bytes), migration report, and last-good reconciliation checkpoint; `data/eidoverse/worlds` stores the external runtime's append-only world files. Both are `file-primary`, included in filesystem backups, and intentionally machine-local — never federated by PortOS. PortOS selects the runtime's world-store location through `.env.portos` but does not edit the external checkout to build content. The separately licensed git checkouts and Eidoverse-owned asset library/cache live under the existing re-cloneable `data/repos/` backup class.
- Sprite animation-track definitions — `data/sprites/animation-tracks.json` (#3152). A small hand-editable authoring config: which animation types exist beyond the compiled-in `walk` (label, directionality, frame/fps bounds, prompt template, and the on-disk `setKind` strings). `file-primary` rather than `db-primary` because it is **machine-local and inseparable from the on-disk sprite tree it describes** — a row names the `setKind` an approved set under `data/sprites/{id}/` already carries, so the two travel together or neither means anything — and because it has no cross-record queries, no relationships beyond `kinds` strings, and no sync cursor. Read synchronously and cached per process (`server/services/sprites/animationTrackStore.js`): `server/lib/validation.js` builds sprite Zod ranges from it at module load, so it must resolve without `await`. Seeded from `data.reference/sprites/animation-tracks.json` (migration 211), which is also the fallback read when no user copy exists yet. Backed up in full by the rsync snapshot.
- Quota-burn plan — `data/cos/quota-burn.json` (#3390). The install's burn plan: master switch, poll interval, and per-provider-family windows + ordered job list. `file-primary` and **intentionally machine-local — never federated**: quota belongs to a particular machine and provider account, so a synced plan would have each peer spending against the other's window budget, and the plan's `agent-prompt` jobs name managed apps that only exist on this machine. No sync cursor, no tombstone. Its four companions are `ephemeral-file` — regenerable telemetry, all safe to delete: `data/cos/quota-burn-dispatches.json` (per-window dispatch counts, self-pruning at 30 days), `data/cos/quota-burn-runs.json` (capped run log), `data/cos/quota-burn-inflight.json` (entries a burn job has enqueued but whose renders have not completed, self-pruning at 6 hours — deleting it only risks re-queueing a render already in flight), and `data/cos/quota-burn-denials.json` (per-family blocks from an observed provider refusal, cleared by the next successful burn or a 5-hour TTL — deleting it only risks one dispatch into a still-exhausted window). Backed up with the rest of `data/cos/`; a restored plan simply re-applies on this machine. See [Quota Burn](./QUOTA-BURN.md).
- YouTube brain ingests — `data/brain/youtube/{videoId}.md` (transcript), `{videoId}.mp3` (optional audio), plus `data/brain/youtube/index.json` (the ingest index) and `data/brain/youtube-ingest-settings.json`. The transcript IS an external file: it is mirrored into the user's Obsidian vault, edited there, and syncs through iCloud — the same boundary that keeps the Daily Log file-backed. The index is **intentionally machine-local — never federated**: every field in it is a local filesystem path, an Obsidian vault id, or a local video-history id, so a peer's copy would be meaningless and would poison brain reconcile exactly the way the daily log's `journal-obsidian-locations.json` sidecar would. The playlist/video reference shelf at `data/youtube/playlists.json` follows the same local-only rule: it is a bounded cache of browser-scraped YouTube metadata and links, not a federated Brain record. The durable, federated record of "I consumed and kept this" is the brain `links` entry (`db-primary` via the brain store) plus a `media.watch` row in `human_activity_events`. No tombstone. Adapters: `server/services/youtubeIngest.js` and `server/services/youtubePlaylists.js`.
diff --git a/docs/features/eidoverse.md b/docs/features/eidoverse.md
index 92d4613f0..9f47d792f 100644
--- a/docs/features/eidoverse.md
+++ b/docs/features/eidoverse.md
@@ -70,8 +70,12 @@ PortOS-owned integration state is stored in
display name, the stable Persistent Mind/CoS identity and local role
observations, the projection recipe, and the last projection checkpoint.
The human name can be configured explicitly. If it is cleared, PortOS derives a
-stable fallback from the persisted PortOS instance identity (using the instance
-name when available and a non-identifying instance-id-derived label otherwise).
+stable pseudonymous label from the persisted instance id without placing the
+raw id or the machine's display name in the Eidoverse log.
+The V1-to-V2 migration recognizes the old `instance-name` source marker and
+retires only that automatic value; an explicitly configured name is preserved.
+Because Eidoverse history is append-only, PortOS does not rewrite prior log
+entries, but it no longer joins or projects with the retired machine name.
The browser receives that same identity in the Eidoverse launch URL, so a
reload or a second trusted machine does not create a new anonymous user.
The default installation uses Eidoverse's name-based join protocol with the
@@ -96,52 +100,159 @@ local Agent Tools (MCP) controls; generic PortOS write access does not imply it.
This makes both the human and CoS roles durable in the Eidoverse world rather
than browser-session conveniences.
-## PortOS projection recipe
-
-The PortOS projection is deterministic and local. It turns current PortOS
-resources into Eidoverse entities without an LLM call. The default lanes are:
-
-- managed apps, active agents, open CoS tasks, enabled features, and federated
- peer summaries become individual model-backed entities;
-- goals and current-sprint Jira tickets become bounded individual entities;
-- productivity and activity become compact summary/history entities;
-- memory becomes category/graph summaries only (raw memory and journal bodies
- are not copied into the world component payload);
-- storage becomes bounded PostgreSQL table and `data/` domain summaries;
-- health becomes one aggregate, while operations becomes one compact hub for
- CoS/AI state, review, backup, inbox, notifications, voice, character,
- chronotype, health metrics, and disk state;
-- the recipe controls which families are included, per-family caps, layout
- spacing/scales, model assets, and procedural terrain parameters;
-- stable entity ids make repeated projections update existing buildings,
- vehicles, crates, drones, and landmarks instead of accumulating duplicates;
-- an unavailable source is preserved as unknown and does not delete its last
- good entities; a confirmed empty source may remove the generated entities for
- that family.
-
-The Eidoverse renderer receives the resource label, source id, status, and
-bounded fields as generic `portos` component metadata alongside each model.
-That keeps the world useful even when a source is sparse and leaves the
-Eidoverse scene graph/inspector as the authoritative detail view; the PortOS
-adapter does not pretend that arbitrary metadata is a persistent 3D text label.
-
-The Eidoverse page exposes **Project PortOS now** and a recipe editor. The
-Persistent Mind and explicitly granted local CoS agents can use the governed
-`eidoverse.status`, `eidoverse.project`, `eidoverse.augment`, and
+## World Design V2: Luminous Systems Garden
+
+PortOS ships a versioned world-design recipe, not a one-instance scene. World
+Design V2 replaces V1's numbered resource lanes with eight stable semantic
+districts:
+
+| District | PortOS meaning |
+|---|---|
+| PortOS Nexus | Aggregate health, operations, and feature affordances |
+| App Terraces | Managed apps and their coarse runtime state |
+| Agent Foundry | Active agents and bounded CoS task summaries |
+| Goal Observatory | Goals and current-sprint Jira summaries |
+| Memory Grove | Category and graph aggregates, never memory bodies |
+| Data Vault | Bounded PostgreSQL and file-domain summaries |
+| Federation Harbor | Coarse peer availability summaries |
+| Activity River | Productivity and recent activity aggregates |
+
+The authored environment is part of the design contract: deep slate/indigo
+terrain, a warm dawn `skymesh`, three deliberate lights, restrained fog, and a
+sparse wind-reactive field. District anchors have distinct silhouettes and
+accent colors. Abstract metrics no longer use generic car meshes. Entity
+position is derived from stable source id plus district anchor, so source array
+reordering does not move the world around. Goal progress lifts its constellation
+beacons, while activity summaries follow a shallow, stable river bend.
+
+The central Nexus light follows aggregate health: cyan is healthy, amber needs
+attention, and red is an error. The corresponding health beacon also changes
+shape, elevation, and motion, so the status remains legible without color.
+
+Every projected record is normalized to a bounded `WorldSignal` component. It
+contains a one-way stable resource key, generic safe label, controlled
+status/severity/freshness values, district, PortOS route, disclosure class, and
+a shallow set of numeric/boolean metrics. Nested collections become counts and
+arbitrary strings are discarded. Apps become a few status-count pylons rather
+than one anonymous rack per app; storage becomes two aggregate landmarks plus
+bounded anomaly markers; Jira becomes current-work status counts. PortOS never
+places raw journal/memory bodies, database or file names, task prompts, ticket
+titles, machine/peer identities, or federation records in the Eidoverse log.
+The default per-family caps sum below the hard global ceiling, and a user-edited
+recipe still cannot materialize more than 48 live PortOS signals.
+When authored caps would exceed that ceiling, PortOS allocates one signal per
+included current-or-stale source before filling additional slots in
+deterministic rounds. The drawer reports the exact per-source omissions instead
+of showing a populated source count beside an unexplained empty district.
+
+Only ids under `portos-design-v2-*` and the retired V1
+`portos-projection-*` namespace are reconciled. Unrelated Eidoverse entities are
+never moved or removed. A temporarily unavailable PortOS source preserves its
+last-good entities; a confirmed empty source retires that source's managed
+entities. The eight district landmarks, luminous connector nodes, and three
+authored lights are infrastructure rather than live records.
+
+## Assets are a recipe, not a payload
+
+PortOS does not bundle model packs. `server/lib/eidoverseWorldDesign.js` stores
+small semantic slots such as `nexus`, `agent`, `memory`, and `peer`. Each slot
+declares:
+
+- preferred Eidoverse library paths;
+- fallback search queries;
+- semantic filename tokens used as ranking hints and whole-token exclusions;
+- maximum bytes, GLB/animation expectations, and `library-only` source policy;
+- a final known-library fallback.
+
+On first projection after an install or recipe update, PortOS reads
+`/library-list`, uses `/library-models` only for unresolved slots, rejects
+content-addressed `store/` paths for portable defaults, verifies each
+chosen `/library/...` asset, and ranks candidates deterministically. It then
+persists the exact path, byte-size metadata, design/recipe/slot versions, per-slot
+recipe fingerprint, strategy, catalog fingerprint, resolution time, and
+default-versus-user source in `assetResolutions`. Normal projections reuse that
+lock and do not repeat model searches. A later design release invalidates only
+slots whose recipe fingerprint changed; unchanged paths and local overrides
+remain pinned. Eidoverse owns and caches the bytes. A user may retain an
+explicit install-local `store/...` model override, but that path is recorded as
+an override and never becomes a shipped PortOS default.
+Search results remain eligible when a library rename removes an old filename
+token, and a safe catalog GLB is the final deterministic fallback after searches
+are exhausted. Catalog entries without byte metadata remain usable with
+`bytes: null`; a known over-budget size is still rejected.
+
+## Versioning, updates, and recovery
+
+`data/eidoverse/portos-world.json` schema V2 records
+`selectedDesignVersion`, `lastAppliedDesignVersion`, `pendingDesignVersion`,
+`userOverrides`, `assetRecipeVersion`, `assetResolutions`, `migrationReport`,
+and the reconciliation checkpoint/error. The immutable V1 and V2 registries are
+both retained in source.
+
+Migration `323-eidoverse-world-design-v2.js` runs in the normal PortOS migration
+pass used by `update.sh`, `update.ps1`, and ordinary server boot. It compares
+every stored V1 leaf against the immutable V1 default: a default-matching leaf
+inherits V2, while a genuinely customized leaf becomes a V2 user override.
+Customized V1 source caps above the corresponding V2 cap are reset to the V2
+default and their original values stay visible under unsupported overrides,
+preventing an old lane-scale cap from starving the semantic districts.
+V1's lane coordinates have no safe district translation, so a custom layout is
+reported rather than silently applied. Missing state is a fresh V2 install;
+invalid or newer schema state fails closed and remains pending for repair or a
+PortOS update.
+
+The offline migration never touches an external checkout or calls an AI
+provider. It leaves V2 pending. After restart, a deterministic reconciler runs
+only when the separately managed Eidoverse process is already online. It
+preflights runtime build identity, the catalog, and every selected asset before
+sending world operations. Reconciliation is staged: infrastructure and live
+signals are created first, the managed environment follows, and only then are
+obsolete V1 ids retired. Every acknowledged operation has an inverse derived
+from the pre-update snapshot. A failed stage reconnects, applies those inverses
+in reverse order, retains the old `lastAppliedDesignVersion`, and leaves a
+resumable checkpoint. If Eidoverse is stopped or incompatible, PortOS leaves
+the update pending and the page links directly to the managed app instead of
+claiming the world is current.
+A process restart during an applying or compensating stage marks that persisted
+run as interrupted before boot reconciliation; the drawer then offers the same
+explicit retry path as other failures. Retired owner-role cleanup is
+best-effort and never blocks the current world: failed demotions retry up to
+three projections, then age out with a generic manual-review warning. A later
+clean run clears that warning, and a full reset clears all recovery state.
+
+On a genuinely fresh world there is no previous atmosphere to protect, so the
+dawn environment is applied first and the user does not wait in darkness while
+the initial landmarks and signals stream in. The stricter order above remains
+mandatory for every upgrade with a previously applied design.
+
+The hosted page retains a loading curtain until the embedded renderer arrives,
+then presents the scene as the primary surface with a district/status legend,
+live-signal budget, and real reconciliation checkpoint progress over the world.
+**World controls** opens the shared tabbed drawer:
+
+- **Experience** — durable identity and high-level design status;
+- **Districts & Data** — source visibility, counts, current/stale state, direct
+ PortOS routes, caps, and district resets;
+- **Appearance & Assets** — dawn environment, selected size/source, local
+ overrides, and the persisted asset recipe;
+- **Updates & Advanced** — migration diff, checkpoints, apply/retry, asset
+ re-resolution, and a two-step full reset.
+
+The Persistent Mind and explicitly granted local CoS agents can use the
+governed `eidoverse.status`, `eidoverse.project`, `eidoverse.augment`, and
`eidoverse.say` tools. `eidoverse.augment` accepts only bounded world verbs (for
example `spawn`, `place`, `comp`, `light`, `terrain`, `sky`, and `grant`); it
cannot execute arbitrary runtime behavior or modify the installed Eidoverse
-source. Status requires bounded PortOS read access; projection additionally
-requires the dedicated Eidoverse-management grant, while augmentation and
-world chat require that dedicated grant without widening generic PortOS
-record-write authority.
+source. Status requires bounded PortOS read access. Projection, augmentation,
+and world chat retain the dedicated Eidoverse-management grant without widening
+generic PortOS record-write authority.
## Growth and automation
-The projection recipe is intentionally separate from the current world log.
-As PortOS gains resources, the deterministic projection runs when the hosted
-page opens and can also be run manually, from a CoS task, or through the
-disabled-by-default autonomous job
+The world-design recipe and per-install asset lock are intentionally separate
+from the current world log. As PortOS gains resources, the deterministic
+projection runs when the hosted page opens and can also be run manually, from a
+CoS task, or through the disabled-by-default autonomous job
`job-eidoverse-projection`. Enabling that job is an explicit install-local
choice; it performs no provider calls and only reflects resources that already
exist. The hosted page starts the managed runtime when it is opened; an
@@ -171,6 +282,12 @@ external repositories unchanged. The bridge starts only when the page is
opened, waits for the managed app to answer before mounting the iframe, and
returns an explicit unavailable state when the runtime does not become ready.
+Projection protocol and asset preflight target the same runtime. The default
+HTTP library origin is derived from `EIDOVERSE_WS_URL` by mapping `ws`/`wss` to
+`http`/`https` on the same host and port. Deployments whose one Eidoverse runtime
+publishes separate WebSocket and HTTP endpoints may set `EIDOVERSE_HTTP_URL`
+explicitly; both values must identify that same runtime.
+
## PortOS bridge boundary
The hosted page, identity bridge, projection service, and CoS tools are explicit
diff --git a/scripts/migrations/323-eidoverse-world-design-v2.js b/scripts/migrations/323-eidoverse-world-design-v2.js
new file mode 100644
index 000000000..1047eede3
--- /dev/null
+++ b/scripts/migrations/323-eidoverse-world-design-v2.js
@@ -0,0 +1,43 @@
+/**
+ * Upgrade the install-local Eidoverse projection state to World Design V2.
+ *
+ * The migration is deliberately offline: it only rewrites PortOS's small
+ * recipe/config file. Asset resolution and world reconciliation happen after
+ * the Eidoverse runtime is online, without an AI call or external checkout
+ * mutation.
+ */
+
+import { readFile } from 'fs/promises';
+import { join } from 'path';
+import { atomicWrite, safeJSONParse } from '../../server/lib/fileUtils.js';
+import { migrateEidoverseWorldState } from '../../server/lib/eidoverseWorldDesign.js';
+
+export default {
+ async up({ rootDir }) {
+ const statePath = join(rootDir, 'data', 'eidoverse', 'portos-world.json');
+ const raw = await readFile(statePath, 'utf8').catch((error) => {
+ if (error.code === 'ENOENT') return null;
+ throw error;
+ });
+ if (raw == null) return { updated: 0, reason: 'no-state' };
+
+ const state = safeJSONParse(raw, null, { logError: false });
+ if (!state || typeof state !== 'object' || Array.isArray(state)) {
+ console.warn('⚠️ migration 323: skipped invalid Eidoverse world JSON; the feature will remain unavailable until its state file is repaired or restored');
+ return { updated: 0, reason: 'invalid-json' };
+ }
+ const migration = migrateEidoverseWorldState(state);
+ if (!migration.compatible) {
+ const reason = migration.report?.reason || 'unknown';
+ console.warn(`⚠️ migration 323: skipped incompatible Eidoverse world state (${reason}); the feature will surface repair or update guidance when opened`);
+ return { updated: 0, reason };
+ }
+ if (state.schemaVersion === 2 && state.selectedDesignVersion === 2
+ && JSON.stringify(migration.state) === JSON.stringify(state)) {
+ return { updated: 0, reason: 'already-applied' };
+ }
+ await atomicWrite(statePath, migration.state);
+ console.log(`🌐 migration 323: prepared Eidoverse World Design V2 with ${migration.report?.preservedOverrides?.length || 0} preserved override(s)`);
+ return { updated: 1, preservedOverrides: migration.report?.preservedOverrides || [] };
+ },
+};
diff --git a/scripts/migrations/323-eidoverse-world-design-v2.test.js b/scripts/migrations/323-eidoverse-world-design-v2.test.js
new file mode 100644
index 000000000..8506b7288
--- /dev/null
+++ b/scripts/migrations/323-eidoverse-world-design-v2.test.js
@@ -0,0 +1,83 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { mkdtemp, mkdir, readFile, rm, writeFile } from 'fs/promises';
+import { tmpdir } from 'os';
+import { join } from 'path';
+import migration from './323-eidoverse-world-design-v2.js';
+import { EIDOVERSE_WORLD_DESIGN_V1 } from '../../server/lib/eidoverseWorldDesign.js';
+
+let rootDir;
+const statePath = () => join(rootDir, 'data', 'eidoverse', 'portos-world.json');
+const readState = async () => JSON.parse(await readFile(statePath(), 'utf8'));
+
+afterEach(async () => {
+ vi.restoreAllMocks();
+ if (rootDir) await rm(rootDir, { recursive: true, force: true });
+ rootDir = null;
+});
+
+describe('migration 323 — Eidoverse World Design V2', () => {
+ beforeEach(async () => {
+ vi.spyOn(console, 'warn').mockImplementation(() => {});
+ rootDir = await mkdtemp(join(tmpdir(), 'portos-eidoverse-design-'));
+ await mkdir(join(rootDir, 'data', 'eidoverse'), { recursive: true });
+ });
+
+ it('upgrades the V1 default and leaves V2 pending for online reconciliation', async () => {
+ await writeFile(statePath(), JSON.stringify({ schemaVersion: 1, world: 'example-world', recipe: EIDOVERSE_WORLD_DESIGN_V1 }));
+
+ await expect(migration.up({ rootDir })).resolves.toEqual({ updated: 1, preservedOverrides: [] });
+ expect(await readState()).toMatchObject({
+ schemaVersion: 2,
+ world: 'example-world',
+ selectedDesignVersion: 2,
+ lastAppliedDesignVersion: 1,
+ pendingDesignVersion: 2,
+ reconciliation: { status: 'pending', checkpoint: 'migration-complete' },
+ });
+ });
+
+ it('preserves customized leaves and unrelated install-local fields', async () => {
+ await writeFile(statePath(), JSON.stringify({
+ schemaVersion: 1,
+ world: 'example-world',
+ extraLocalField: { preserve: true },
+ recipe: {
+ ...EIDOVERSE_WORLD_DESIGN_V1,
+ includes: { ...EIDOVERSE_WORLD_DESIGN_V1.includes, jira: false },
+ limits: { ...EIDOVERSE_WORLD_DESIGN_V1.limits, apps: 2 },
+ },
+ }));
+
+ await migration.up({ rootDir });
+ expect(await readState()).toMatchObject({
+ extraLocalField: { preserve: true },
+ userOverrides: { includes: { jira: false }, limits: { apps: 2 } },
+ recipe: { version: 2, includes: { jira: false }, limits: { apps: 2 } },
+ });
+ });
+
+ it('is idempotent and fails soft without rewriting invalid or newer state', async () => {
+ await writeFile(statePath(), JSON.stringify({ schemaVersion: 1, recipe: EIDOVERSE_WORLD_DESIGN_V1 }));
+ await migration.up({ rootDir });
+ await expect(migration.up({ rootDir })).resolves.toEqual({ updated: 0, reason: 'already-applied' });
+
+ await writeFile(statePath(), '{broken');
+ await expect(migration.up({ rootDir })).resolves.toEqual({ updated: 0, reason: 'invalid-json' });
+ expect(await readFile(statePath(), 'utf8')).toBe('{broken');
+
+ await writeFile(statePath(), JSON.stringify({ schemaVersion: 99 }));
+ await expect(migration.up({ rootDir })).resolves.toEqual({ updated: 0, reason: 'newer-state-schema' });
+
+ await writeFile(statePath(), JSON.stringify({ schemaVersion: 2, selectedDesignVersion: 99 }));
+ await expect(migration.up({ rootDir })).resolves.toEqual({ updated: 0, reason: 'newer-design-version' });
+
+ await writeFile(statePath(), JSON.stringify({ schemaVersion: 0 }));
+ await expect(migration.up({ rootDir })).resolves.toEqual({ updated: 0, reason: 'invalid-state-schema' });
+ expect(console.warn).toHaveBeenCalledTimes(4);
+ });
+
+ it('does nothing on a fresh install without state', async () => {
+ await rm(statePath(), { force: true });
+ await expect(migration.up({ rootDir })).resolves.toEqual({ updated: 0, reason: 'no-state' });
+ });
+});
diff --git a/server/lib/README.md b/server/lib/README.md
index c2dd8f1ca..36980ae00 100644
--- a/server/lib/README.md
+++ b/server/lib/README.md
@@ -430,6 +430,7 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub
| `dispatchLabels.js` | slashdo dispatch-hint contract: `model:light/medium/heavy` + `effort:low/medium/high/xhigh/max` vocabulary, prescribed forge colors, validation (`normalizeDispatchModel` / `normalizeDispatchEffort`), GitHub/GitLab vs Jira label formatting, optional contributor labels (`good first issue` / `help wanted`, never implied by `model:light`, and released at claim time by `formatContributorLabelReleaseCommands` — one best-effort command per label, since a forge fails the whole edit when a named label is absent), the `decomposed` epic workflow marker (`EPIC_DECOMPOSED_LABEL`, shared with perpetualWork.js#isActionableIssue and the claim prompts), lazy-create command text, and shared dispatch plus issue-quality guidance (`ISSUE_QUALITY_GUIDANCE`, `DISPATCH_HINT_GUIDANCE`, `JIRA_DISPATCH_HINT_GUIDANCE`). Omit an unjustified axis; never invent `medium`; reject future-only/speculative work while keeping useful current refactors claimable. Consumed by work-tracker instructions, quota-burn audits, Layered Intelligence filing, and claim follow-up prompts. |
| `domainAutonomy.js` | Per-domain autonomy guardrails (pure). `AUTONOMY_DOMAINS`/`DOMAIN_IDS`/`DOMAIN_MODES` (`off`/`dry-run`/`execute`), `getDomainMode(config, id)`, and `normalizeDomainAutonomy(raw)` to coerce a hand-edited/partial map. Default per domain is `execute` (reproduces pre-#711 behavior, so no migration needed). Also `CREATIVE_DOMAIN`/`getCreativeAutonomyMode(config)` (#2183) — the Creative Director orchestrator domain, kept out of `DOMAIN_IDS` and defaulting to mirror the `cos` mode. |
| `domainBudgets.js` | Per-domain daily autonomy budgets (pure). `BUDGET_LIMIT_FIELDS` (`maxActionsPerDay`/`maxMinutesPerDay`), `getDomainBudget(config, id)`, `normalizeDomainBudgets(raw)`, `hasBudget(budget)`, and `evaluateBudget(budget, usage)` → `{ withinBudget, exceeded }`. A `null`/non-positive cap means unlimited (default per domain, so no migration needed). Token/$ caps are intentionally absent — CLI subscription providers expose no per-run metering. Usage ledger + gate wiring live in `services/domainUsage.js`. |
+| `eidoverseWorldDesign.js` | Immutable Eidoverse World Design V1/V2 registry, legacy override migration, semantic district contract, 48-signal ceiling, and deterministic library-only asset-recipe resolution/locking for the PortOS Luminous Systems Garden. |
| `errorHandler.js` | `ServerError` + `asyncHandler` middleware, plus `sendErrorResponse`/`buildErrorEnvelope` for the standard `{ error, code, timestamp }` body outside a handler's catch. |
| `extensionErrors.js` | `isExtensionError(payload)` — true when a client error report came from a browser extension's injected content script (extension URL scheme in `source`/`stack`/`message`, or a short list of vendor/runtime message signatures) rather than from PortOS. Consumed by `services/clientErrors.js` to keep un-actionable extension noise out of the Review Hub *and* out of the 1/sec throttle slot, where it would displace real errors. **Authoritative copy** — mirrored at `client/src/lib/extensionErrors.js`, parity enforced by `extensionErrors.mirror.test.js`. |
| `fetchErrorChain.js` | `describeFetchError(err)` flattens a fetch rejection's whole `cause` chain (depth-bounded, cycle-guarded) into one searchable `code: message` string — undici reports every network failure as the same opaque `TypeError: fetch failed` with the real reason nested inside, so a classifier reading only `err.message` misjudges every one of them. `isReplayableConnectionError(err)` is the narrow predicate over that string for connection-REUSE artifacts (HTTP/2 GOAWAY, reset, hang-up) that are safe to replay once via `fetchWithTimeout`'s `shouldRetry`; it deliberately EXCLUDES timeouts, which broader classifiers like `ollamaManager`'s `isTransientPullError` include. |
diff --git a/server/lib/apiRouteCatalog.generated.json b/server/lib/apiRouteCatalog.generated.json
index 5cceb8cc6..759f820b9 100644
--- a/server/lib/apiRouteCatalog.generated.json
+++ b/server/lib/apiRouteCatalog.generated.json
@@ -8725,7 +8725,7 @@
"sources": [
{
"source": "server/routes/eidoverseWorldRoutes.js",
- "line": 56
+ "line": 64
}
]
},
@@ -8736,7 +8736,7 @@
"sources": [
{
"source": "server/routes/eidoverseWorldRoutes.js",
- "line": 36
+ "line": 44
}
]
},
@@ -8747,7 +8747,7 @@
"sources": [
{
"source": "server/routes/eidoverseWorldRoutes.js",
- "line": 43
+ "line": 51
}
]
},
@@ -8758,7 +8758,18 @@
"sources": [
{
"source": "server/routes/eidoverseWorldRoutes.js",
- "line": 50
+ "line": 58
+ }
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/eidoverse/world/projection/status",
+ "mountPath": "/api/eidoverse/world",
+ "sources": [
+ {
+ "source": "server/routes/eidoverseWorldRoutes.js",
+ "line": 38
}
]
},
@@ -8769,7 +8780,7 @@
"sources": [
{
"source": "server/routes/eidoverseWorldRoutes.js",
- "line": 63
+ "line": 71
}
]
},
@@ -8780,7 +8791,7 @@
"sources": [
{
"source": "server/routes/eidoverseWorldRoutes.js",
- "line": 30
+ "line": 31
}
]
},
@@ -23698,8 +23709,8 @@
],
"stats": {
"mounts": 146,
- "operations": 2138,
- "declarations": 2141,
+ "operations": 2139,
+ "declarations": 2142,
"sourceFiles": 226
}
}
diff --git a/server/lib/eidoverseWorldDesign.js b/server/lib/eidoverseWorldDesign.js
new file mode 100644
index 000000000..800272be6
--- /dev/null
+++ b/server/lib/eidoverseWorldDesign.js
@@ -0,0 +1,751 @@
+/**
+ * Versioned, install-portable PortOS world design contract for Eidoverse.
+ *
+ * PortOS ships only this small recipe. Eidoverse keeps the model library and
+ * cached bytes; each PortOS install resolves the recipe once and persists the
+ * resulting paths in its machine-local world state.
+ */
+
+import { createHash } from 'node:crypto';
+import { canonicalStringify, deepMerge } from './objects.js';
+
+export const EIDOVERSE_WORLD_STATE_SCHEMA_VERSION = 2;
+export const EIDOVERSE_WORLD_DESIGN_VERSION = 2;
+export const EIDOVERSE_ASSET_RECIPE_VERSION = 2;
+export const EIDOVERSE_MAX_LIVE_ENTITIES = 48;
+export const EIDOVERSE_LIBRARY_MODEL_ROOT = 'eidoverse/assets/models/';
+export const EIDOVERSE_MANAGED_PREFIX = 'portos-design-v2-';
+export const EIDOVERSE_PROJECTION_PREFIX = `${EIDOVERSE_MANAGED_PREFIX}signal-`;
+
+export const EIDOVERSE_SOURCE_KEYS = Object.freeze([
+ 'apps',
+ 'agents',
+ 'tasks',
+ 'features',
+ 'peers',
+ 'health',
+ 'productivity',
+ 'activity',
+ 'goals',
+ 'memory',
+ 'storage',
+ 'jira',
+ 'operations',
+]);
+
+const MODEL_ROOT = EIDOVERSE_LIBRARY_MODEL_ROOT;
+
+// Frozen forever: migration must compare legacy leaves against the exact V1
+// values that shipped, never against a moving approximation of them.
+export const EIDOVERSE_WORLD_DESIGN_V1 = Object.freeze({
+ version: 1,
+ includes: {
+ apps: true, agents: true, tasks: true, features: true, peers: true,
+ health: true, productivity: true, activity: true, goals: true,
+ memory: true, storage: true, jira: true, operations: true,
+ },
+ limits: {
+ apps: 48, agents: 24, tasks: 48, features: 32, peers: 16,
+ health: 1, productivity: 1, activity: 24, goals: 32,
+ memory: 16, storage: 48, jira: 48, operations: 1,
+ },
+ layout: { origin: [-24, 0, -24], spacing: 7, laneGap: 6, columns: 8 },
+ scale: {
+ app: 1, agent: 1, task: 0.8, feature: 1, peer: 1.2, health: 1.2,
+ productivity: 1.2, activity: 0.55, goal: 1.1, memory: 0.9,
+ storage: 0.8, jira: 0.75, operations: 1.3,
+ },
+ assets: {
+ app: `${MODEL_ROOT}computer_servers_rack_with_fans_on_back_row_of_four_4_columns.glb`,
+ agent: `${MODEL_ROOT}scifi_quad_small_drone_blue.glb`,
+ task: `${MODEL_ROOT}scifi_cyberpunk_intermodal_shipping_container_crate_blue.glb`,
+ feature: `${MODEL_ROOT}inanna_tech_cyber_scifi_sphere_orb.glb`,
+ peer: `${MODEL_ROOT}modern_sedan_car_blue_vehicle_generic.glb`,
+ health: `${MODEL_ROOT}inanna_tech_cyber_scifi_sumerian_retrofuturist_vehicle_car_light.glb`,
+ productivity: `${MODEL_ROOT}inanna_tech_cyber_scifi_sumerian_retrofuturist_vehicle_car_light.glb`,
+ activity: `${MODEL_ROOT}inanna_tech_cyber_scifi_sphere_orb.glb`,
+ goal: `${MODEL_ROOT}inanna_tech_cyber_scifi_sphere_orb.glb`,
+ memory: `${MODEL_ROOT}inanna_tech_cyber_scifi_sphere_orb.glb`,
+ storage: `${MODEL_ROOT}computer_servers_rack_with_fans_on_back_row_of_four_4_columns.glb`,
+ jira: `${MODEL_ROOT}scifi_cyberpunk_intermodal_shipping_container_crate_blue.glb`,
+ operations: `${MODEL_ROOT}inanna_tech_cyber_scifi_sumerian_retrofuturist_vehicle_car_light.glb`,
+ },
+ terrain: {
+ seed: 'portos', size: 128, segments: 64, amplitude: 1.8, flatRadius: 28,
+ layers: [{ color: '#142338', repeat: 18 }, { color: '#1c3b43', repeat: 10 }],
+ },
+});
+
+const assetSlot = ({ preferredPaths, fallbackQueries, requiredTokens, excludedTokens = [], maxBytes, fallback }) => ({
+ preferredPaths,
+ fallbackQueries,
+ requiredTokens,
+ excludedTokens,
+ maxBytes,
+ format: 'glb',
+ animation: 'optional',
+ sourcePolicy: 'library-only',
+ fallback,
+});
+
+// Paths are preferences, not bundled payloads. Queries allow a future library
+// to satisfy the same semantic role even when its filenames evolve.
+export const EIDOVERSE_ASSET_RECIPE_V2 = Object.freeze({
+ version: EIDOVERSE_ASSET_RECIPE_VERSION,
+ slots: {
+ nexus: assetSlot({
+ preferredPaths: [`${MODEL_ROOT}scifi_perimeter_watchtower_standalone_or_with_wall_middle_four_way.glb`],
+ fallbackQueries: ['scifi tower', 'technology tower'],
+ requiredTokens: ['tower'], excludedTokens: ['car', 'vehicle', 'rubble'], maxBytes: 24_000_000,
+ fallback: `${MODEL_ROOT}inanna_tech_cyber_scifi_sphere_orb.glb`,
+ }),
+ app: assetSlot({
+ preferredPaths: [`${MODEL_ROOT}single_loose_computer_server.glb`],
+ fallbackQueries: ['computer server', 'technology console'],
+ requiredTokens: ['server'], excludedTokens: ['car', 'vehicle'], maxBytes: 32_000_000,
+ fallback: `${MODEL_ROOT}inanna_tech_cyber_scifi_sphere_orb.glb`,
+ }),
+ agent: assetSlot({
+ preferredPaths: [`${MODEL_ROOT}scifi_quad_small_drone_blue.glb`],
+ fallbackQueries: ['scifi drone', 'robot drone'],
+ requiredTokens: ['drone'], excludedTokens: ['car', 'vehicle'], maxBytes: 24_000_000,
+ fallback: `${MODEL_ROOT}inanna_tech_cyber_scifi_sphere_orb.glb`,
+ }),
+ task: assetSlot({
+ preferredPaths: [`${MODEL_ROOT}scifi_cyberpunk_intermodal_shipping_container_crate_blue.glb`],
+ fallbackQueries: ['scifi crate', 'data container'],
+ requiredTokens: ['crate'], excludedTokens: ['car', 'vehicle'], maxBytes: 16_000_000,
+ fallback: `${MODEL_ROOT}inanna_tech_cyber_scifi_sphere_orb.glb`,
+ }),
+ goal: assetSlot({
+ preferredPaths: [`${MODEL_ROOT}inanna_tech_cyber_scifi_sphere_orb.glb`],
+ fallbackQueries: ['scifi orb', 'beacon'],
+ requiredTokens: ['orb'], excludedTokens: ['car', 'vehicle'], maxBytes: 12_000_000,
+ fallback: `${MODEL_ROOT}inanna_tech_cyber_scifi_sphere_orb.glb`,
+ }),
+ memory: assetSlot({
+ preferredPaths: [`${MODEL_ROOT}cyborg_brain_cybernetic_implant_neuralink_cyberbrain_organ.glb`],
+ fallbackQueries: ['cyber brain', 'memory tree'],
+ requiredTokens: ['brain'], excludedTokens: ['car', 'vehicle'], maxBytes: 56_000_000,
+ fallback: `${MODEL_ROOT}stylized_yucca_joshua_tree_desert_cactus_plant.glb`,
+ }),
+ storage: assetSlot({
+ preferredPaths: [`${MODEL_ROOT}scif_cyberpunk_crt_retro_computer_monitor_screen_keyboard_tower.glb`],
+ fallbackQueries: ['computer mainframe', 'data terminal'],
+ requiredTokens: ['computer'], excludedTokens: ['car', 'vehicle'], maxBytes: 44_000_000,
+ fallback: `${MODEL_ROOT}single_loose_computer_server.glb`,
+ }),
+ peer: assetSlot({
+ preferredPaths: [`${MODEL_ROOT}scifi_perimeter_wall_gate.glb`],
+ fallbackQueries: ['scifi gate', 'portal arch'],
+ requiredTokens: ['gate'], excludedTokens: ['car', 'vehicle'], maxBytes: 24_000_000,
+ fallback: `${MODEL_ROOT}scifi_perimeter_watchtower_standalone_or_with_wall_middle_four_way.glb`,
+ }),
+ activity: assetSlot({
+ preferredPaths: [`${MODEL_ROOT}streetlight_lamp_light_street_blade_runner_cyberpunk.glb`],
+ fallbackQueries: ['cyberpunk streetlight', 'light marker'],
+ requiredTokens: ['light'], excludedTokens: ['car', 'vehicle', 'rubble'], maxBytes: 16_000_000,
+ fallback: `${MODEL_ROOT}inanna_tech_cyber_scifi_sphere_orb.glb`,
+ }),
+ district: assetSlot({
+ preferredPaths: [`${MODEL_ROOT}scifi_perimeter_wall_pillar.glb`],
+ fallbackQueries: ['scifi pillar', 'technology marker'],
+ requiredTokens: ['pillar'], excludedTokens: ['car', 'vehicle'], maxBytes: 20_000_000,
+ fallback: `${MODEL_ROOT}inanna_tech_cyber_scifi_sphere_orb.glb`,
+ }),
+ },
+});
+
+export const EIDOVERSE_DISTRICTS_V2 = Object.freeze([
+ { id: 'nexus', label: 'PortOS Nexus', direction: 'Center', landmark: 'status spire', anchor: [0, 0, 0], sources: ['health', 'operations', 'features'], accent: '#ffb86b' },
+ { id: 'apps', label: 'App Terraces', direction: 'Northwest', landmark: 'service pylons', anchor: [-30, 0, -18], sources: ['apps'], accent: '#65d9ff' },
+ { id: 'agents', label: 'Agent Foundry', direction: 'North', landmark: 'drone foundry', anchor: [0, 0, -34], sources: ['agents', 'tasks'], accent: '#a78bfa' },
+ { id: 'goals', label: 'Goal Observatory', direction: 'Northeast', landmark: 'orbital beacon', anchor: [30, 0, -18], sources: ['goals', 'jira'], accent: '#f7d774' },
+ { id: 'memory', label: 'Memory Grove', direction: 'Southeast', landmark: 'neural lanterns', anchor: [32, 0, 17], sources: ['memory'], accent: '#72e6a6' },
+ { id: 'data', label: 'Data Vault', direction: 'South', landmark: 'vault servers', anchor: [0, 0, 34], sources: ['storage'], accent: '#55c2ff' },
+ { id: 'federation', label: 'Federation Harbor', direction: 'Southwest', landmark: 'portal gate', anchor: [-32, 0, 17], sources: ['peers'], accent: '#66f0d0' },
+ { id: 'activity', label: 'Activity River', direction: 'Inner south', landmark: 'light river', anchor: [0, 0, 14], sources: ['activity', 'productivity'], accent: '#ff78b7' },
+]);
+
+export const EIDOVERSE_ASSET_SLOTS_BY_DISTRICT = Object.freeze({
+ // Include retired V1 kind keys as well as V2 semantic slots. A scoped reset
+ // must clear hidden preserved overrides that can still win assetPathFor().
+ nexus: ['nexus', 'health', 'operations', 'feature', 'district'],
+ apps: ['app'],
+ agents: ['agent', 'task'],
+ goals: ['goal', 'jira'],
+ memory: ['memory'],
+ data: ['storage'],
+ federation: ['peer'],
+ activity: ['activity', 'productivity'],
+});
+
+export const EIDOVERSE_PATHS_V2 = Object.freeze(EIDOVERSE_DISTRICTS_V2
+ .filter(({ id }) => id !== 'nexus')
+ .map((district) => ({
+ id: `nexus-${district.id}`,
+ label: `Nexus to ${district.label}`,
+ toDistrictId: district.id,
+ nodes: [0.27, 0.52, 0.77].map((amount) => [
+ Number((district.anchor[0] * amount).toFixed(2)),
+ 0.08,
+ Number((district.anchor[2] * amount).toFixed(2)),
+ ]),
+ })));
+
+export const EIDOVERSE_WORLD_DESIGN_V2 = Object.freeze({
+ version: EIDOVERSE_WORLD_DESIGN_VERSION,
+ name: 'Luminous Systems Garden',
+ maxEntities: EIDOVERSE_MAX_LIVE_ENTITIES,
+ includes: Object.fromEntries(EIDOVERSE_SOURCE_KEYS.map((key) => [key, true])),
+ limits: {
+ apps: 8, agents: 6, tasks: 6, features: 4, peers: 4,
+ health: 1, productivity: 1, activity: 3, goals: 4,
+ memory: 3, storage: 4, jira: 3, operations: 1,
+ },
+ scale: {
+ app: 0.55, agent: 0.55, task: 0.48, feature: 0.5, peer: 0.7,
+ health: 0.8, productivity: 0.75, activity: 0.52, goal: 0.6,
+ memory: 0.42, storage: 0.48, jira: 0.5, operations: 0.9,
+ },
+ districts: EIDOVERSE_DISTRICTS_V2,
+ paths: EIDOVERSE_PATHS_V2,
+ environment: {
+ terrain: {
+ seed: 'portos-systems-garden-v2', size: 180, segments: 96,
+ amplitude: 1.4, flatRadius: 48,
+ layers: [{ color: '#0d1629', repeat: 22 }, { color: '#152942', repeat: 13 }],
+ },
+ sky: {
+ system: 'skymesh', hours: 7.2, azimuth: 145, sun: 1.35,
+ ambient: 1.2, fill: 1.1, exposure: 1.08, fog: 0.42,
+ clouds: 'cirrus', weather: 'clear',
+ },
+ grass: {
+ species: 'grass', width: 154, depth: 144, center: [0, 0],
+ height: 0.22, color: 'gray-green', density: 0.45,
+ },
+ lights: [
+ { id: `${EIDOVERSE_MANAGED_PREFIX}light-nexus`, pos: [0, 8, 1], color: 0x65d9ff, intensity: 22, range: 28, keep: true, day: true },
+ { id: `${EIDOVERSE_MANAGED_PREFIX}light-east`, pos: [25, 7, 4], color: 0x72e6a6, intensity: 15, range: 23, keep: true, day: true },
+ { id: `${EIDOVERSE_MANAGED_PREFIX}light-west`, pos: [-25, 7, 4], color: 0x65d9ff, intensity: 15, range: 23, keep: true, day: true },
+ ],
+ },
+ assetRecipe: EIDOVERSE_ASSET_RECIPE_V2,
+ assets: {},
+});
+
+const EIDOVERSE_V2_DEFAULT_CHANGES = Object.freeze([
+ { area: 'Composition', from: 'uniform resource lanes', to: 'eight semantic districts and circulation paths' },
+ { area: 'Atmosphere', from: 'terrain only', to: 'warm dawn sky, authored light, restrained fog, and sparse grass' },
+ { area: 'Assets', from: 'resource-kind model paths', to: 'portable semantic slots with install-local locks' },
+ { area: 'Signals', from: 'independent high source caps', to: 'bounded aggregate signals under one 48-entity budget' },
+]);
+
+export const EIDOVERSE_WORLD_DESIGNS = Object.freeze({
+ 1: EIDOVERSE_WORLD_DESIGN_V1,
+ 2: EIDOVERSE_WORLD_DESIGN_V2,
+});
+
+function freezeWorldContract(value) {
+ if (value && typeof value === 'object') {
+ for (const child of Object.values(value)) freezeWorldContract(child);
+ Object.freeze(value);
+ }
+ return value;
+}
+
+// Object.freeze is shallow. Recursively lock every registry leaf so a route or
+// test cannot accidentally mutate the migration baseline for later installs.
+freezeWorldContract(EIDOVERSE_WORLD_DESIGN_V1);
+freezeWorldContract(EIDOVERSE_ASSET_RECIPE_V2);
+freezeWorldContract(EIDOVERSE_DISTRICTS_V2);
+freezeWorldContract(EIDOVERSE_ASSET_SLOTS_BY_DISTRICT);
+freezeWorldContract(EIDOVERSE_PATHS_V2);
+freezeWorldContract(EIDOVERSE_WORLD_DESIGN_V2);
+freezeWorldContract(EIDOVERSE_WORLD_DESIGNS);
+
+const isObject = (value) => Boolean(value) && typeof value === 'object' && !Array.isArray(value);
+const clone = (value) => structuredClone(value);
+const equal = (left, right) => canonicalStringify(left) === canonicalStringify(right);
+
+const mergeDesign = (base, patch) => clone(deepMerge(clone(base), clone(patch)));
+
+function diffLeaves(value, baseline) {
+ if (equal(value, baseline)) return undefined;
+ if (!isObject(value) || !isObject(baseline)) return clone(value);
+ const output = {};
+ for (const [key, child] of Object.entries(value)) {
+ const diff = diffLeaves(child, baseline[key]);
+ if (diff !== undefined) output[key] = diff;
+ }
+ return Object.keys(output).length ? output : undefined;
+}
+
+export function resolveEidoverseDesign(userOverrides = {}, assetResolutions = {}) {
+ const resolved = mergeDesign(EIDOVERSE_WORLD_DESIGN_V2, userOverrides);
+ resolved.version = EIDOVERSE_WORLD_DESIGN_VERSION;
+ resolved.assetRecipe = clone(EIDOVERSE_ASSET_RECIPE_V2);
+ resolved.assets = { ...(resolved.assets || {}) };
+ for (const [slot, resolution] of Object.entries(assetResolutions || {})) {
+ const path = typeof resolution === 'string' ? resolution : resolution?.path;
+ if (path) resolved.assets[slot] = path;
+ }
+ return resolved;
+}
+
+export function extractEidoverseDesignOverrides(recipe) {
+ if (!isObject(recipe)) return {};
+ const candidate = {};
+ // `recipe.assets` contains the materialized per-install resolution lock in
+ // status responses. It is not a user override merely because the client
+ // round-tripped the effective recipe; explicit overrides use the dedicated
+ // `assetOverrides` config field.
+ for (const section of ['name', 'maxEntities', 'includes', 'limits', 'scale', 'districts', 'paths', 'environment']) {
+ if (recipe[section] !== undefined) candidate[section] = clone(recipe[section]);
+ }
+ return diffLeaves(mergeDesign(EIDOVERSE_WORLD_DESIGN_V2, candidate), EIDOVERSE_WORLD_DESIGN_V2) || {};
+}
+
+const validLibraryPath = (path) => typeof path === 'string'
+ && path.startsWith(EIDOVERSE_LIBRARY_MODEL_ROOT)
+ && !path.includes('..')
+ && path.toLowerCase().endsWith('.glb');
+
+export const isValidEidoverseAssetOverridePath = (path) => validLibraryPath(path) || (
+ typeof path === 'string'
+ && /^store\/[A-Za-z0-9._/-]+$/.test(path)
+ && !path.includes('..')
+);
+
+function v1OverridesForV2(legacyRecipe) {
+ const legacy = mergeDesign(EIDOVERSE_WORLD_DESIGN_V1, legacyRecipe || {});
+ const v1Diff = diffLeaves(legacy, EIDOVERSE_WORLD_DESIGN_V1) || {};
+ const overrides = {};
+ for (const section of ['includes', 'scale']) {
+ if (v1Diff[section]) overrides[section] = clone(v1Diff[section]);
+ }
+ const unsupportedLimits = {};
+ if (isObject(v1Diff.limits)) {
+ const preservedLimits = {};
+ for (const [source, value] of Object.entries(v1Diff.limits)) {
+ const v2Limit = EIDOVERSE_WORLD_DESIGN_V2.limits[source];
+ if (!Number.isInteger(value) || value < 0 || !Number.isInteger(v2Limit) || value > v2Limit) {
+ unsupportedLimits[source] = clone(value);
+ continue;
+ }
+ preservedLimits[source] = value;
+ }
+ if (Object.keys(preservedLimits).length) overrides.limits = preservedLimits;
+ } else if (v1Diff.limits !== undefined) {
+ unsupportedLimits.value = clone(v1Diff.limits);
+ }
+ if (v1Diff.terrain) overrides.environment = { terrain: clone(v1Diff.terrain) };
+ let unportableAssets;
+ if (isObject(v1Diff.assets)) {
+ const entries = Object.entries(v1Diff.assets);
+ const portable = entries.filter(([, path]) => isValidEidoverseAssetOverridePath(path));
+ if (portable.length) {
+ overrides.assets = Object.fromEntries(portable.map(([key, path]) => [key, clone(path)]));
+ }
+ const rejected = entries.filter(([, path]) => !isValidEidoverseAssetOverridePath(path));
+ if (rejected.length) {
+ unportableAssets = Object.fromEntries(rejected.map(([key, path]) => [key, clone(path)]));
+ }
+ } else if (v1Diff.assets !== undefined) {
+ unportableAssets = clone(v1Diff.assets);
+ }
+ // V1's lane geometry and any unknown legacy extension have no safe semantic
+ // translation to districts. Preserve their exact values in the report
+ // instead of silently distorting the V2 garden or losing customization.
+ const unsupportedOverrides = Object.fromEntries(Object.entries(v1Diff)
+ .filter(([section]) => !['includes', 'limits', 'scale', 'terrain', 'assets'].includes(section))
+ .map(([section, value]) => [section, clone(value)]));
+ if (Object.keys(unsupportedLimits).length) unsupportedOverrides.limits = unsupportedLimits;
+ if (unportableAssets !== undefined) unsupportedOverrides.assets = unportableAssets;
+ return { overrides, unsupportedOverrides };
+}
+
+export function migrateEidoverseWorldState(raw, { now = new Date().toISOString() } = {}) {
+ if (raw !== null && raw !== undefined && !isObject(raw)) {
+ return {
+ compatible: false,
+ state: raw,
+ report: {
+ status: 'blocked', fromSchemaVersion: null,
+ toSchemaVersion: EIDOVERSE_WORLD_STATE_SCHEMA_VERSION,
+ reason: 'invalid-state-shape', at: now,
+ },
+ };
+ }
+ const input = isObject(raw) ? clone(raw) : {};
+ const inputSchema = Number(input.schemaVersion ?? 1);
+ if (!Number.isInteger(inputSchema) || inputSchema < 1) {
+ return {
+ compatible: false,
+ state: input,
+ report: {
+ status: 'blocked', fromSchemaVersion: input.schemaVersion ?? null,
+ toSchemaVersion: EIDOVERSE_WORLD_STATE_SCHEMA_VERSION,
+ reason: 'invalid-state-schema', at: now,
+ },
+ };
+ }
+ if (inputSchema > EIDOVERSE_WORLD_STATE_SCHEMA_VERSION) {
+ return {
+ compatible: false,
+ state: input,
+ report: {
+ status: 'blocked', fromSchemaVersion: inputSchema,
+ toSchemaVersion: EIDOVERSE_WORLD_STATE_SCHEMA_VERSION,
+ reason: 'newer-state-schema', at: now,
+ },
+ };
+ }
+ const inputDesignVersions = [
+ input.selectedDesignVersion,
+ input.lastAppliedDesignVersion,
+ input.pendingDesignVersion,
+ input.recipe?.version,
+ ].filter((value) => value !== null && value !== undefined).map(Number);
+ if (inputDesignVersions.some((version) => !Number.isInteger(version) || version < 1)) {
+ return {
+ compatible: false,
+ state: input,
+ report: {
+ status: 'blocked', fromSchemaVersion: inputSchema,
+ toSchemaVersion: EIDOVERSE_WORLD_STATE_SCHEMA_VERSION,
+ reason: 'invalid-design-version', at: now,
+ },
+ };
+ }
+ if (inputDesignVersions.some((version) => version > EIDOVERSE_WORLD_DESIGN_VERSION)) {
+ return {
+ compatible: false,
+ state: input,
+ report: {
+ status: 'blocked', fromSchemaVersion: inputSchema,
+ toSchemaVersion: EIDOVERSE_WORLD_STATE_SCHEMA_VERSION,
+ reason: 'newer-design-version', at: now,
+ },
+ };
+ }
+ const inputAssetRecipeVersion = input.assetRecipeVersion === null || input.assetRecipeVersion === undefined
+ ? null
+ : Number(input.assetRecipeVersion);
+ if (inputAssetRecipeVersion !== null
+ && (!Number.isInteger(inputAssetRecipeVersion) || inputAssetRecipeVersion < 1)) {
+ return {
+ compatible: false,
+ state: input,
+ report: {
+ status: 'blocked', fromSchemaVersion: inputSchema,
+ toSchemaVersion: EIDOVERSE_WORLD_STATE_SCHEMA_VERSION,
+ reason: 'invalid-asset-recipe-version', at: now,
+ },
+ };
+ }
+ if (inputAssetRecipeVersion > EIDOVERSE_ASSET_RECIPE_VERSION) {
+ return {
+ compatible: false,
+ state: input,
+ report: {
+ status: 'blocked', fromSchemaVersion: inputSchema,
+ toSchemaVersion: EIDOVERSE_WORLD_STATE_SCHEMA_VERSION,
+ reason: 'newer-asset-recipe-version', at: now,
+ },
+ };
+ }
+
+ const removedMachineDerivedIdentity = input.human?.source === 'instance-name';
+ if (removedMachineDerivedIdentity) {
+ const retiredWorld = typeof input.world === 'string' && input.world.trim()
+ ? input.world.trim()
+ : 'portos';
+ const retiredIdentity = typeof input.human?.name === 'string'
+ ? input.human.name.trim()
+ : '';
+ if (retiredIdentity) {
+ const ownership = isObject(input.ownership) ? input.ownership : {};
+ const existingRetired = Array.isArray(ownership.retired) ? ownership.retired : [];
+ input.ownership = {
+ ...ownership,
+ retired: [
+ ...existingRetired.filter((entry) => !(
+ entry?.world === retiredWorld && entry?.id === retiredIdentity
+ )),
+ {
+ world: retiredWorld,
+ id: retiredIdentity,
+ ...(typeof input.cos?.id === 'string' && input.cos.id.trim() ? {
+ actorId: input.cos.id.trim(),
+ ...(typeof input.cos?.avatar === 'string' ? { actorAvatar: input.cos.avatar } : {}),
+ } : {}),
+ },
+ ],
+ };
+ }
+ input.human = {
+ ...input.human,
+ name: null,
+ source: null,
+ role: null,
+ };
+ }
+
+ if (inputSchema === EIDOVERSE_WORLD_STATE_SCHEMA_VERSION) {
+ const userOverrides = isObject(input.userOverrides)
+ ? input.userOverrides
+ : extractEidoverseDesignOverrides(input.recipe);
+ const assetResolutions = isObject(input.assetResolutions) ? input.assetResolutions : {};
+ const report = removedMachineDerivedIdentity
+ ? {
+ ...(input.migrationReport || {}),
+ status: input.migrationReport?.status || 'applied',
+ removedMachineDerivedIdentity: true,
+ at: input.migrationReport?.at || now,
+ }
+ : (input.migrationReport || null);
+ return {
+ compatible: true,
+ state: {
+ ...input,
+ schemaVersion: EIDOVERSE_WORLD_STATE_SCHEMA_VERSION,
+ selectedDesignVersion: EIDOVERSE_WORLD_DESIGN_VERSION,
+ assetRecipeVersion: EIDOVERSE_ASSET_RECIPE_VERSION,
+ userOverrides,
+ assetResolutions,
+ migrationReport: report,
+ recipe: resolveEidoverseDesign(userOverrides, assetResolutions),
+ },
+ report,
+ };
+ }
+
+ const { overrides, unsupportedOverrides } = v1OverridesForV2(input.recipe);
+ const customizedLeaves = Object.keys(overrides).flatMap((section) =>
+ Object.keys(overrides[section] || {}).map((key) => `${section}.${key}`));
+ const report = {
+ status: 'ready', fromSchemaVersion: inputSchema,
+ fromDesignVersion: 1, toSchemaVersion: EIDOVERSE_WORLD_STATE_SCHEMA_VERSION,
+ toDesignVersion: EIDOVERSE_WORLD_DESIGN_VERSION,
+ inheritedDefaultLeaves: true,
+ adoptedDefaultChanges: clone(EIDOVERSE_V2_DEFAULT_CHANGES),
+ preservedOverrides: customizedLeaves,
+ unsupportedOverrides,
+ removedMachineDerivedIdentity,
+ // Retain the named field used by the V2 UI and early migration previews.
+ ignoredLegacyLayout: unsupportedOverrides.layout || null,
+ at: now,
+ };
+ return {
+ compatible: true,
+ state: {
+ ...input,
+ schemaVersion: EIDOVERSE_WORLD_STATE_SCHEMA_VERSION,
+ selectedDesignVersion: EIDOVERSE_WORLD_DESIGN_VERSION,
+ lastAppliedDesignVersion: 1,
+ pendingDesignVersion: EIDOVERSE_WORLD_DESIGN_VERSION,
+ userOverrides: overrides,
+ assetRecipeVersion: EIDOVERSE_ASSET_RECIPE_VERSION,
+ assetResolutions: {},
+ migrationReport: report,
+ reconciliation: {
+ status: 'pending', checkpoint: 'migration-complete', error: null,
+ startedAt: null, completedAt: null,
+ },
+ recipe: resolveEidoverseDesign(overrides),
+ },
+ report,
+ };
+}
+
+const tokenized = (value) => String(value || '')
+ .toLowerCase()
+ .replace(/\.[a-z0-9]+$/i, '')
+ .split(/[^a-z0-9]+/)
+ .filter(Boolean);
+
+const contractFingerprint = (value) => createHash('sha256')
+ .update(canonicalStringify(value))
+ .digest('hex')
+ .slice(0, 16);
+
+function candidateScore(candidate, slot, preferredIndex, searchRank, allowCatalogFallback) {
+ const tokens = tokenized(candidate.path);
+ const hasSemanticToken = (needle) => tokens.some((token) => token === needle || token.includes(needle));
+ if (slot.excludedTokens.some((needle) => tokens.includes(needle))) return null;
+ if (candidate.size !== null && candidate.size > slot.maxBytes) return null;
+ const preferred = preferredIndex.get(candidate.path);
+ const searched = searchRank.get(candidate.path);
+ const fallback = candidate.path === slot.fallback;
+ if (preferred === undefined && searched === undefined && !allowCatalogFallback) return null;
+ const queryTokens = slot.fallbackQueries.flatMap(tokenized);
+ const queryMatches = queryTokens.filter((token) => tokens.some((candidateToken) => (
+ candidateToken === token || candidateToken.includes(token)
+ ))).length;
+ const requiredMatches = slot.requiredTokens.filter(hasSemanticToken).length;
+ return (preferred === undefined ? 0 : 1_000_000 - preferred * 10_000)
+ + (searched === undefined ? 0 : 200_000 - searched * 100)
+ + (fallback ? 100_000 : 0)
+ + requiredMatches * 1_000
+ + queryMatches * 10
+ - (candidate.size === null ? 10_000 : 0)
+ - Number(candidate.size || 0) / 1_000_000;
+}
+
+function addCatalogCandidate(catalog, candidate) {
+ const path = typeof candidate === 'string' ? candidate : candidate?.path;
+ if (!validLibraryPath(path)) return;
+ const rawSize = typeof candidate === 'string' ? null : candidate?.size;
+ const numericSize = Number(rawSize);
+ const size = rawSize !== null && rawSize !== undefined && rawSize !== ''
+ && Number.isFinite(numericSize) && numericSize >= 0
+ ? numericSize
+ : null;
+ const prior = catalog.get(path);
+ if (!prior
+ || (prior.size === null && size !== null)
+ || (prior.size !== null && size !== null && size < prior.size)) {
+ catalog.set(path, { path, size });
+ }
+}
+
+/**
+ * Inspect a persisted V2 lock without touching the Eidoverse catalog.
+ *
+ * A projection can reuse a complete lock after verifying its selected paths;
+ * only changed recipe slots, changed overrides, or incomplete metadata need a
+ * catalog/search pass.
+ */
+export function inspectEidoverseAssetResolutionLocks({ existing = {}, overrides = {} } = {}) {
+ const resolutions = {};
+ const invalidated = [];
+ for (const [slotName, slot] of Object.entries(EIDOVERSE_ASSET_RECIPE_V2.slots)) {
+ const lock = isObject(existing[slotName]) ? existing[slotName] : null;
+ const overridePath = overrides[slotName];
+ const hasOverride = isValidEidoverseAssetOverridePath(overridePath);
+ const expectedPathIsValid = hasOverride
+ ? lock?.path === overridePath
+ : validLibraryPath(lock?.path);
+ const current = expectedPathIsValid
+ && lock.slot === slotName
+ && lock.recipeFingerprint === contractFingerprint(slot)
+ && lock.userOverride === hasOverride;
+ if (!current) {
+ invalidated.push(slotName);
+ continue;
+ }
+ resolutions[slotName] = {
+ ...clone(lock),
+ designVersion: EIDOVERSE_WORLD_DESIGN_VERSION,
+ assetRecipeVersion: EIDOVERSE_ASSET_RECIPE_VERSION,
+ };
+ }
+ return { current: invalidated.length === 0, invalidated, resolutions };
+}
+
+/** Resolve every semantic slot deterministically from an Eidoverse catalog. */
+export function resolveEidoverseAssetRecipe({
+ files = [],
+ searchResults = {},
+ existing = {},
+ overrides = {},
+ resolvedAt = null,
+} = {}) {
+ const catalog = new Map();
+ for (const candidate of files) addCatalogCandidate(catalog, candidate);
+ for (const values of Object.values(searchResults || {})) {
+ for (const candidate of Array.isArray(values) ? values : []) addCatalogCandidate(catalog, candidate);
+ }
+
+ const fingerprint = createHash('sha256')
+ .update([...catalog.values()].sort((a, b) => a.path.localeCompare(b.path)).map(({ path, size }) => `${path}:${size ?? 'unknown'}`).join('\n'))
+ .digest('hex').slice(0, 16);
+ const resolutions = {};
+ const missing = [];
+ for (const [slotName, slot] of Object.entries(EIDOVERSE_ASSET_RECIPE_V2.slots)) {
+ const recipeFingerprint = contractFingerprint(slot);
+ const overridePath = overrides[slotName];
+ const locked = isObject(existing[slotName]) ? existing[slotName] : { path: existing[slotName] };
+ const lockedPath = locked.path;
+ const baseResolution = (path, strategy, userOverride, prior = null, bytes = null) => ({
+ designVersion: EIDOVERSE_WORLD_DESIGN_VERSION,
+ assetRecipeVersion: EIDOVERSE_ASSET_RECIPE_VERSION,
+ slot: slotName,
+ path,
+ catalogFingerprint: prior?.catalogFingerprint || fingerprint,
+ recipeFingerprint,
+ bytes: prior?.bytes ?? bytes,
+ strategy,
+ // `source` is retained for the V2 UI and pre-metadata lock readers.
+ source: strategy,
+ resolvedAt: prior?.resolvedAt || resolvedAt,
+ shippedDefault: !userOverride,
+ userOverride,
+ });
+ if (isValidEidoverseAssetOverridePath(overridePath)) {
+ if (lockedPath === overridePath
+ && locked.userOverride === true
+ && locked.recipeFingerprint === recipeFingerprint) {
+ resolutions[slotName] = baseResolution(overridePath, 'user-override', true, locked, catalog.get(overridePath)?.size ?? null);
+ } else {
+ resolutions[slotName] = baseResolution(overridePath, 'user-override', true, null, catalog.get(overridePath)?.size ?? null);
+ }
+ continue;
+ }
+ const lockMatchesRecipe = locked.recipeFingerprint === undefined
+ || locked.recipeFingerprint === recipeFingerprint;
+ if (validLibraryPath(lockedPath)
+ && catalog.has(lockedPath)
+ && locked.userOverride !== true
+ && lockMatchesRecipe) {
+ const strategy = locked.strategy || locked.source || 'lock';
+ resolutions[slotName] = baseResolution(lockedPath, strategy, false, locked, catalog.get(lockedPath)?.size ?? null);
+ continue;
+ }
+ const preferredIndex = new Map(slot.preferredPaths.map((path, index) => [path, index]));
+ const searchRank = new Map();
+ slot.fallbackQueries.forEach((query, queryIndex) => {
+ const candidates = Array.isArray(searchResults[query]) ? searchResults[query] : [];
+ candidates.forEach((candidate, resultIndex) => {
+ const path = typeof candidate === 'string' ? candidate : candidate?.path;
+ if (!validLibraryPath(path)) return;
+ const rank = queryIndex * 1000 + resultIndex;
+ if (!searchRank.has(path) || rank < searchRank.get(path)) {
+ searchRank.set(path, rank);
+ }
+ });
+ });
+ const searchWasAttempted = slot.fallbackQueries.some((query) => Object.hasOwn(searchResults, query));
+ const ranked = [...catalog.values()]
+ .map((candidate) => ({
+ candidate,
+ score: candidateScore(candidate, slot, preferredIndex, searchRank, searchWasAttempted),
+ }))
+ .filter(({ score }) => score !== null)
+ .sort((a, b) => b.score - a.score || a.candidate.path.localeCompare(b.candidate.path));
+ const fallbackCandidate = catalog.get(slot.fallback);
+ const fallbackTokens = fallbackCandidate ? tokenized(fallbackCandidate.path) : [];
+ const fallbackAllowed = fallbackCandidate
+ && (fallbackCandidate.size === null || fallbackCandidate.size <= slot.maxBytes)
+ && !slot.excludedTokens.some((needle) => fallbackTokens.includes(needle));
+ const chosen = ranked[0]?.candidate || (searchWasAttempted && fallbackAllowed ? fallbackCandidate : null);
+ if (!chosen) {
+ missing.push(slotName);
+ continue;
+ }
+ const strategy = preferredIndex.has(chosen.path)
+ ? 'preferred'
+ : (searchRank.has(chosen.path)
+ ? 'query'
+ : (chosen.path === slot.fallback ? 'fallback' : 'catalog-fallback'));
+ resolutions[slotName] = baseResolution(chosen.path, strategy, false, null, chosen.size);
+ }
+ return { resolutions, missing, catalogFingerprint: fingerprint };
+}
+
+export function stableEidoverseUnit(value) {
+ const hash = createHash('sha256').update(String(value)).digest();
+ return hash.readUInt32BE(0) / 0xffffffff;
+}
diff --git a/server/lib/eidoverseWorldDesign.test.js b/server/lib/eidoverseWorldDesign.test.js
new file mode 100644
index 000000000..1f0521ac6
--- /dev/null
+++ b/server/lib/eidoverseWorldDesign.test.js
@@ -0,0 +1,391 @@
+import { describe, expect, it } from 'vitest';
+import {
+ EIDOVERSE_ASSET_RECIPE_V2,
+ EIDOVERSE_WORLD_DESIGN_V1,
+ EIDOVERSE_WORLD_DESIGN_V2,
+ EIDOVERSE_WORLD_DESIGNS,
+ extractEidoverseDesignOverrides,
+ inspectEidoverseAssetResolutionLocks,
+ migrateEidoverseWorldState,
+ resolveEidoverseAssetRecipe,
+} from './eidoverseWorldDesign.js';
+
+const catalog = () => Object.values(EIDOVERSE_ASSET_RECIPE_V2.slots).flatMap((slot) => [
+ { path: slot.preferredPaths[0], size: Math.min(slot.maxBytes, 10_000_000) },
+ { path: slot.fallback, size: 4_000_000 },
+]);
+
+describe('Eidoverse World Design V2', () => {
+ it('keeps the shipped migration baselines deeply immutable', () => {
+ expect(Object.isFrozen(EIDOVERSE_WORLD_DESIGN_V1.assets)).toBe(true);
+ expect(Object.isFrozen(EIDOVERSE_ASSET_RECIPE_V2.slots.app.preferredPaths)).toBe(true);
+ expect(Object.isFrozen(EIDOVERSE_WORLD_DESIGN_V2.environment.lights[0])).toBe(true);
+ expect(EIDOVERSE_WORLD_DESIGNS).toEqual({ 1: EIDOVERSE_WORLD_DESIGN_V1, 2: EIDOVERSE_WORLD_DESIGN_V2 });
+ });
+
+ it('upgrades untouched V1 leaves to V2 without manufacturing overrides', () => {
+ const migrated = migrateEidoverseWorldState({ schemaVersion: 1, recipe: EIDOVERSE_WORLD_DESIGN_V1 }, {
+ now: '2026-01-01T00:00:00.000Z',
+ });
+
+ expect(migrated.compatible).toBe(true);
+ expect(migrated.state).toMatchObject({
+ schemaVersion: 2,
+ selectedDesignVersion: 2,
+ lastAppliedDesignVersion: 1,
+ pendingDesignVersion: 2,
+ userOverrides: {},
+ recipe: { version: 2, name: 'Luminous Systems Garden' },
+ });
+ expect(migrated.report).toMatchObject({ status: 'ready', preservedOverrides: [] });
+ });
+
+ it('preserves customized V1 leaves while inheriting new V2 defaults', () => {
+ const migrated = migrateEidoverseWorldState({
+ schemaVersion: 1,
+ recipe: {
+ ...EIDOVERSE_WORLD_DESIGN_V1,
+ limits: { ...EIDOVERSE_WORLD_DESIGN_V1.limits, apps: 3 },
+ assets: { ...EIDOVERSE_WORLD_DESIGN_V1.assets, app: 'store/example-local-model' },
+ },
+ });
+
+ expect(migrated.state.userOverrides).toMatchObject({
+ limits: { apps: 3 },
+ assets: { app: 'store/example-local-model' },
+ });
+ expect(migrated.state.recipe).toMatchObject({
+ version: 2,
+ limits: { apps: 3, agents: EIDOVERSE_WORLD_DESIGN_V2.limits.agents },
+ assets: { app: 'store/example-local-model' },
+ });
+ expect(migrated.report.preservedOverrides).toEqual(expect.arrayContaining(['limits.apps', 'assets.app']));
+ });
+
+ it('clamps oversized V1 source caps to the V2 design budget and reports the original values', () => {
+ const migrated = migrateEidoverseWorldState({
+ schemaVersion: 1,
+ recipe: {
+ ...EIDOVERSE_WORLD_DESIGN_V1,
+ limits: {
+ ...EIDOVERSE_WORLD_DESIGN_V1.limits,
+ apps: 20,
+ agents: 2,
+ },
+ },
+ });
+
+ expect(migrated.state.userOverrides.limits).toEqual({ agents: 2 });
+ expect(migrated.state.recipe.limits).toMatchObject({
+ apps: EIDOVERSE_WORLD_DESIGN_V2.limits.apps,
+ agents: 2,
+ });
+ expect(migrated.report.unsupportedOverrides.limits).toEqual({ apps: 20 });
+ });
+
+ it('preserves only a customized V1 terrain leaf while adopting V2 terrain defaults', () => {
+ const migrated = migrateEidoverseWorldState({
+ schemaVersion: 1,
+ recipe: {
+ ...EIDOVERSE_WORLD_DESIGN_V1,
+ terrain: { ...EIDOVERSE_WORLD_DESIGN_V1.terrain, seed: 'example-custom' },
+ },
+ });
+
+ expect(migrated.state.userOverrides).toMatchObject({
+ environment: { terrain: { seed: 'example-custom' } },
+ });
+ expect(migrated.state.recipe.environment.terrain).toMatchObject({
+ seed: 'example-custom',
+ size: EIDOVERSE_WORLD_DESIGN_V2.environment.terrain.size,
+ segments: EIDOVERSE_WORLD_DESIGN_V2.environment.terrain.segments,
+ layers: EIDOVERSE_WORLD_DESIGN_V2.environment.terrain.layers,
+ });
+ });
+
+ it('reports unportable V1 asset paths without activating a blocking V2 override', () => {
+ const portable = 'eidoverse/assets/models/example-custom.glb';
+ const unportable = 'eidoverse/assets/legacy/example-custom.obj';
+ const migrated = migrateEidoverseWorldState({
+ schemaVersion: 1,
+ recipe: {
+ ...EIDOVERSE_WORLD_DESIGN_V1,
+ assets: {
+ ...EIDOVERSE_WORLD_DESIGN_V1.assets,
+ app: portable,
+ feature: unportable,
+ },
+ },
+ });
+
+ expect(migrated.state.userOverrides.assets).toEqual({ app: portable });
+ expect(migrated.state.recipe.assets).toEqual({ app: portable });
+ expect(migrated.report.unsupportedOverrides.assets).toEqual({ feature: unportable });
+ expect(migrated.report.preservedOverrides).toContain('assets.app');
+ });
+
+ it('retires only an automatic machine-derived V1 identity during migration', () => {
+ const migrated = migrateEidoverseWorldState({
+ schemaVersion: 1,
+ world: 'portos',
+ human: { name: 'Example Machine', source: 'instance-name', role: 'owner', avatar: 'eidoverse/assets/vrms/example.vrm' },
+ cos: { id: 'portos-cos', avatar: 'eidoverse/assets/vrms/example-cos.vrm' },
+ recipe: EIDOVERSE_WORLD_DESIGN_V1,
+ });
+ const configured = migrateEidoverseWorldState({
+ schemaVersion: 1,
+ human: { name: 'Example User', source: 'configured', role: 'owner' },
+ recipe: EIDOVERSE_WORLD_DESIGN_V1,
+ });
+
+ expect(migrated.state.human).toEqual({
+ name: null,
+ source: null,
+ role: null,
+ avatar: 'eidoverse/assets/vrms/example.vrm',
+ });
+ expect(migrated.state.ownership.retired).toEqual([{
+ world: 'portos',
+ id: 'Example Machine',
+ actorId: 'portos-cos',
+ actorAvatar: 'eidoverse/assets/vrms/example-cos.vrm',
+ }]);
+ expect(migrated.report.removedMachineDerivedIdentity).toBe(true);
+ expect(configured.state.human).toMatchObject({ name: 'Example User', source: 'configured', role: 'owner' });
+ expect(configured.report.removedMachineDerivedIdentity).toBe(false);
+ });
+
+ it('retains unsupported V1 customization values in the migration report', () => {
+ const migrated = migrateEidoverseWorldState({
+ schemaVersion: 1,
+ recipe: {
+ ...EIDOVERSE_WORLD_DESIGN_V1,
+ layout: { ...EIDOVERSE_WORLD_DESIGN_V1.layout, spacing: 11 },
+ retiredExtension: { mode: 'example-custom-mode' },
+ },
+ });
+
+ expect(migrated.report).toMatchObject({
+ unsupportedOverrides: {
+ layout: { spacing: 11 },
+ retiredExtension: { mode: 'example-custom-mode' },
+ },
+ ignoredLegacyLayout: { spacing: 11 },
+ });
+ });
+
+ it('fails closed on state written by a newer schema', () => {
+ const state = { schemaVersion: 99, future: true };
+ expect(migrateEidoverseWorldState(state)).toMatchObject({
+ compatible: false,
+ state,
+ report: { status: 'blocked', reason: 'newer-state-schema' },
+ });
+ });
+
+ it('fails closed on malformed state and version markers', () => {
+ expect(migrateEidoverseWorldState([])).toMatchObject({
+ compatible: false,
+ report: { reason: 'invalid-state-shape' },
+ });
+ expect(migrateEidoverseWorldState({ schemaVersion: 0 })).toMatchObject({
+ compatible: false,
+ report: { reason: 'invalid-state-schema' },
+ });
+ expect(migrateEidoverseWorldState({ schemaVersion: 2, selectedDesignVersion: 'not-a-version' })).toMatchObject({
+ compatible: false,
+ report: { reason: 'invalid-design-version' },
+ });
+ expect(migrateEidoverseWorldState({ schemaVersion: 2, assetRecipeVersion: -1 })).toMatchObject({
+ compatible: false,
+ report: { reason: 'invalid-asset-recipe-version' },
+ });
+ });
+
+ it('fails closed on newer design and asset-recipe versions within schema V2', () => {
+ expect(migrateEidoverseWorldState({ schemaVersion: 2, selectedDesignVersion: 99 })).toMatchObject({
+ compatible: false,
+ report: { reason: 'newer-design-version' },
+ });
+ expect(migrateEidoverseWorldState({
+ schemaVersion: 2,
+ selectedDesignVersion: 2,
+ assetRecipeVersion: 99,
+ })).toMatchObject({
+ compatible: false,
+ report: { reason: 'newer-asset-recipe-version' },
+ });
+ });
+
+ it('resolves a portable recipe deterministically and reuses a valid lock', () => {
+ const first = resolveEidoverseAssetRecipe({ files: catalog() });
+ const second = resolveEidoverseAssetRecipe({
+ files: [...catalog()].reverse(),
+ existing: first.resolutions,
+ });
+
+ expect(first.missing).toEqual([]);
+ expect(Object.keys(first.resolutions)).toHaveLength(10);
+ expect(second).toEqual(first);
+ expect(Object.values(first.resolutions).every(({ path }) => path.startsWith('eidoverse/assets/models/'))).toBe(true);
+ expect(first.resolutions.storage.path).not.toBe(first.resolutions.app.path);
+ expect(first.resolutions.app).toMatchObject({
+ designVersion: 2,
+ assetRecipeVersion: 2,
+ slot: 'app',
+ strategy: 'preferred',
+ shippedDefault: true,
+ userOverride: false,
+ });
+ expect(JSON.stringify(EIDOVERSE_ASSET_RECIPE_V2)).not.toMatch(/\.glb\s*data:|base64/i);
+ });
+
+ it('invalidates only a slot whose recipe fingerprint changed', () => {
+ const first = resolveEidoverseAssetRecipe({ files: catalog(), resolvedAt: 'old' });
+ const existing = structuredClone(first.resolutions);
+ existing.app.recipeFingerprint = 'retired-slot-contract';
+ const next = resolveEidoverseAssetRecipe({ files: catalog(), existing, resolvedAt: 'new' });
+
+ expect(next.resolutions.app.resolvedAt).toBe('new');
+ expect(next.resolutions.app.recipeFingerprint).not.toBe('retired-slot-contract');
+ expect(next.resolutions.agent).toEqual(first.resolutions.agent);
+ });
+
+ it('can validate a complete lock without consulting a catalog', () => {
+ const first = resolveEidoverseAssetRecipe({ files: catalog(), resolvedAt: 'old' });
+ expect(inspectEidoverseAssetResolutionLocks({ existing: first.resolutions })).toEqual({
+ current: true,
+ invalidated: [],
+ resolutions: first.resolutions,
+ });
+
+ const changedOverride = inspectEidoverseAssetResolutionLocks({
+ existing: first.resolutions,
+ overrides: { app: 'store/example-local-asset' },
+ });
+ expect(changedOverride.current).toBe(false);
+ expect(changedOverride.invalidated).toEqual(['app']);
+ expect(changedOverride.resolutions.agent).toEqual(first.resolutions.agent);
+ });
+
+ it('records an explicit store override as local without making store assets defaults', () => {
+ const result = resolveEidoverseAssetRecipe({
+ files: catalog(),
+ overrides: { app: 'store/example-local-asset' },
+ resolvedAt: 'now',
+ });
+
+ expect(result.resolutions.app).toMatchObject({
+ path: 'store/example-local-asset',
+ strategy: 'user-override',
+ shippedDefault: false,
+ userOverride: true,
+ resolvedAt: 'now',
+ });
+ expect(Object.entries(result.resolutions)
+ .filter(([slot]) => slot !== 'app')
+ .every(([, resolution]) => resolution.path.startsWith('eidoverse/assets/models/'))).toBe(true);
+ });
+
+ it('never selects a content-addressed store asset for a portable default', () => {
+ const result = resolveEidoverseAssetRecipe({
+ files: [
+ ...catalog(),
+ { path: 'store/0123456789abcdef', size: 1 },
+ ],
+ });
+
+ expect(Object.values(result.resolutions).some(({ path }) => path.startsWith('store/'))).toBe(false);
+ });
+
+ it('uses deterministic search results when a preferred library path is absent', () => {
+ const appSlot = EIDOVERSE_ASSET_RECIPE_V2.slots.app;
+ const searchedPath = 'eidoverse/assets/models/example_server_console.glb';
+ const withoutApp = catalog().filter(({ path }) => (
+ path !== appSlot.preferredPaths[0] && path !== appSlot.fallback
+ ));
+ const result = resolveEidoverseAssetRecipe({
+ files: withoutApp,
+ searchResults: { 'computer server': [{ path: searchedPath, size: 2_000_000 }] },
+ });
+
+ expect(result.resolutions.app).toMatchObject({ path: searchedPath, source: 'query' });
+ });
+
+ it('accepts renamed search hits without substring-matching excluded whole tokens', () => {
+ const taskSlot = EIDOVERSE_ASSET_RECIPE_V2.slots.task;
+ const renamedPath = 'eidoverse/assets/models/example_cargo_pod_blue.glb';
+ const withoutTask = catalog().filter(({ path }) => (
+ path !== taskSlot.preferredPaths[0] && path !== taskSlot.fallback
+ ));
+ const result = resolveEidoverseAssetRecipe({
+ files: withoutTask,
+ searchResults: { 'scifi crate': [{ path: renamedPath, size: 2_000_000 }] },
+ });
+
+ expect(result.resolutions.task).toMatchObject({ path: renamedPath, strategy: 'query' });
+ });
+
+ it('keeps catalog entries with unknown sizes and records nullable lock bytes', () => {
+ const appPath = EIDOVERSE_ASSET_RECIPE_V2.slots.app.preferredPaths[0];
+ const files = catalog().map((candidate) => (
+ candidate.path === appPath ? { path: candidate.path } : candidate
+ ));
+ const result = resolveEidoverseAssetRecipe({ files });
+
+ expect(result.resolutions.app).toMatchObject({
+ path: appPath,
+ strategy: 'preferred',
+ bytes: null,
+ });
+ });
+
+ it('uses a safe catalog GLB as a last resort after semantic searches are exhausted', () => {
+ const taskSlot = EIDOVERSE_ASSET_RECIPE_V2.slots.task;
+ const files = catalog().filter(({ path }) => (
+ path !== taskSlot.preferredPaths[0] && path !== taskSlot.fallback
+ ));
+ const result = resolveEidoverseAssetRecipe({
+ files,
+ searchResults: Object.fromEntries(taskSlot.fallbackQueries.map((query) => [query, []])),
+ });
+
+ expect(result.missing).not.toContain('task');
+ expect(result.resolutions.task).toMatchObject({ strategy: 'catalog-fallback' });
+ });
+
+ it('searches before accepting an explicit fallback asset', () => {
+ const appSlot = EIDOVERSE_ASSET_RECIPE_V2.slots.app;
+ const files = catalog().filter(({ path }) => path !== appSlot.preferredPaths[0]);
+ const beforeSearch = resolveEidoverseAssetRecipe({ files });
+ const afterSearch = resolveEidoverseAssetRecipe({
+ files,
+ searchResults: Object.fromEntries(appSlot.fallbackQueries.map((query) => [query, []])),
+ });
+
+ expect(beforeSearch.missing).toContain('app');
+ expect(afterSearch.resolutions.app).toMatchObject({ path: appSlot.fallback, strategy: 'fallback' });
+ });
+
+ it('does not mistake a materialized asset lock for a user override', () => {
+ expect(extractEidoverseDesignOverrides({
+ ...EIDOVERSE_WORLD_DESIGN_V2,
+ assets: { app: 'eidoverse/assets/models/example_app.glb' },
+ })).toEqual({});
+ });
+
+ it('does not pin key-reordered recipe arrays as user overrides', () => {
+ const recipe = structuredClone(EIDOVERSE_WORLD_DESIGN_V2);
+ recipe.districts = recipe.districts.map(({
+ accent, sources, anchor, landmark, direction, label, id,
+ }) => ({ accent, sources, anchor, landmark, direction, label, id }));
+ recipe.paths = recipe.paths.map(({ nodes, toDistrictId, label, id }) => ({
+ nodes, toDistrictId, label, id,
+ }));
+ recipe.environment.lights = recipe.environment.lights.map(({
+ day, keep, range, intensity, color, pos, id,
+ }) => ({ day, keep, range, intensity, color, pos, id }));
+
+ expect(extractEidoverseDesignOverrides(recipe)).toEqual({});
+ });
+});
diff --git a/server/lib/eidoverseWorldReset.parity.test.js b/server/lib/eidoverseWorldReset.parity.test.js
new file mode 100644
index 000000000..9d879fecc
--- /dev/null
+++ b/server/lib/eidoverseWorldReset.parity.test.js
@@ -0,0 +1,25 @@
+import { describe, expect, it } from 'vitest';
+import {
+ EIDOVERSE_RESET_ASSET_SLOTS,
+ EIDOVERSE_SOURCE_KIND,
+ eidoverseResetAssetSlotsForDistrict,
+} from '../../client/src/lib/eidoverseWorldReset.js';
+import { EIDOVERSE_ASSET_SLOTS_BY_DISTRICT } from './eidoverseWorldDesign.js';
+import { EIDOVERSE_PROJECTION_KINDS } from '../services/eidoverseWorldProjection.js';
+
+describe('Eidoverse reset client/server parity', () => {
+ it('keeps district asset reset slots aligned with the server contract', () => {
+ expect(EIDOVERSE_RESET_ASSET_SLOTS).toEqual(EIDOVERSE_ASSET_SLOTS_BY_DISTRICT);
+ });
+
+ it('keeps projection source kinds aligned with the server contract', () => {
+ expect(EIDOVERSE_SOURCE_KIND).toEqual(Object.fromEntries(
+ EIDOVERSE_PROJECTION_KINDS.map(({ source, kind }) => [source, kind]),
+ ));
+ });
+
+ it('resets the generic landmark and source assets for a custom district', () => {
+ expect(eidoverseResetAssetSlotsForDistrict('example-yard', ['apps', 'goals']))
+ .toEqual(['district', 'app', 'goal']);
+ });
+});
diff --git a/server/lib/index.js b/server/lib/index.js
index d7ada2901..934b8cda3 100644
--- a/server/lib/index.js
+++ b/server/lib/index.js
@@ -407,6 +407,7 @@ export * from './concurrencyGate.js';
export * from './dispatchLabels.js';
export * from './domainAutonomy.js';
export * from './domainBudgets.js';
+export * from './eidoverseWorldDesign.js';
export * from './errorHandler.js';
export * from './extensionErrors.js';
export * from './fetchErrorChain.js';
diff --git a/server/lib/validation.js b/server/lib/validation.js
index 3163f7309..c20c2ded9 100644
--- a/server/lib/validation.js
+++ b/server/lib/validation.js
@@ -823,6 +823,10 @@ const eidoverseAssetPathSchema = z.string().trim().min(1).max(512).refine((value
&& !normalized.includes('..')
&& (/^eidoverse\//i.test(normalized) || /^store\//i.test(normalized));
}, 'must be a relative Eidoverse library or store asset path');
+const eidoverseModelAssetOverrideSchema = eidoverseAssetPathSchema.refine((value) => (
+ (value.startsWith('eidoverse/assets/models/') && value.toLowerCase().endsWith('.glb'))
+ || /^store\/[A-Za-z0-9._/-]+$/.test(value)
+), 'must be a model-library GLB or an explicit local store asset');
// These are the resource lanes that the deterministic PortOS projection may
// materialize. Keep the list explicit: a recipe must opt into known data
@@ -890,7 +894,7 @@ const eidoverseProjectionTerrainSchema = z.object({
layers: z.array(eidoverseProjectionTerrainLayerSchema).max(8),
}).strict();
-export const eidoverseProjectionRecipeSchema = z.object({
+const eidoverseProjectionRecipeV1Schema = z.object({
version: z.literal(1),
includes: eidoverseProjectionIncludesSchema,
limits: z.object({
@@ -933,6 +937,122 @@ export const eidoverseProjectionRecipeSchema = z.object({
terrain: eidoverseProjectionTerrainSchema,
}).strict();
+const eidoverseProjectionLimitsSchema = z.object(Object.fromEntries(
+ EIDOVERSE_PROJECTION_SOURCE_KEYS.map((key) => [key, z.number().int().min(0).max(100)]),
+)).strict();
+
+const eidoverseProjectionScaleSchema = z.object({
+ app: z.number().finite().positive().max(20),
+ agent: z.number().finite().positive().max(20),
+ task: z.number().finite().positive().max(20),
+ feature: z.number().finite().positive().max(20),
+ peer: z.number().finite().positive().max(20),
+ health: z.number().finite().positive().max(20),
+ productivity: z.number().finite().positive().max(20),
+ activity: z.number().finite().positive().max(20),
+ goal: z.number().finite().positive().max(20),
+ memory: z.number().finite().positive().max(20),
+ storage: z.number().finite().positive().max(20),
+ jira: z.number().finite().positive().max(20),
+ operations: z.number().finite().positive().max(20),
+}).strict();
+
+const eidoverseDistrictIdSchema = z.string().regex(/^[a-z0-9_-]{1,32}$/);
+
+const eidoverseAssetSlotSchema = z.object({
+ preferredPaths: z.array(eidoverseAssetPathSchema).max(8),
+ fallbackQueries: z.array(z.string().trim().min(1).max(80)).min(1).max(8),
+ requiredTokens: z.array(z.string().trim().min(1).max(40)).max(12),
+ excludedTokens: z.array(z.string().trim().min(1).max(40)).max(12),
+ maxBytes: z.number().int().positive().max(250_000_000),
+ format: z.literal('glb'),
+ animation: z.enum(['none', 'optional', 'required']),
+ sourcePolicy: z.literal('library-only'),
+ fallback: eidoverseAssetPathSchema,
+}).strict();
+
+const eidoverseAssetSlotsSchema = z.object({
+ nexus: eidoverseAssetSlotSchema,
+ app: eidoverseAssetSlotSchema,
+ agent: eidoverseAssetSlotSchema,
+ task: eidoverseAssetSlotSchema,
+ goal: eidoverseAssetSlotSchema,
+ memory: eidoverseAssetSlotSchema,
+ storage: eidoverseAssetSlotSchema,
+ peer: eidoverseAssetSlotSchema,
+ activity: eidoverseAssetSlotSchema,
+ district: eidoverseAssetSlotSchema,
+}).strict();
+
+const eidoverseResolvedAssetsSchema = z.record(z.string().trim().min(1).max(40), eidoverseAssetPathSchema)
+ .refine((assets) => Object.keys(assets).length <= 32, 'at most 32 asset slots may be configured');
+
+const eidoverseProjectionEnvironmentSchema = z.object({
+ terrain: eidoverseProjectionTerrainSchema,
+ sky: z.object({
+ system: z.literal('skymesh'),
+ hours: z.number().finite().min(0).max(24),
+ azimuth: z.number().finite().min(0).max(360),
+ sun: z.number().finite().min(0).max(2.5),
+ ambient: z.number().finite().min(0).max(2.5),
+ fill: z.number().finite().min(0).max(2.5),
+ exposure: z.number().finite().min(0.3).max(1.8),
+ fog: z.number().finite().min(0).max(3),
+ clouds: z.enum(['clear', 'cirrus', 'cumulus', 'stratus']),
+ weather: z.string().trim().min(1).max(40),
+ }).strict(),
+ grass: z.object({
+ species: z.string().trim().min(1).max(40),
+ width: z.number().finite().positive().max(256),
+ depth: z.number().finite().positive().max(256),
+ center: z.tuple([z.number().finite(), z.number().finite()]),
+ height: z.number().finite().positive().max(4),
+ color: z.string().trim().min(1).max(40),
+ density: z.number().finite().positive().max(2),
+ }).strict(),
+ lights: z.array(z.object({
+ id: z.string().regex(/^portos-design-v2-[A-Za-z0-9_-]{1,47}$/),
+ pos: eidoverseVector3Schema,
+ color: z.number().int().min(0).max(0xffffff),
+ intensity: z.number().finite().positive().max(100),
+ range: z.number().finite().positive().max(256),
+ keep: z.boolean(),
+ day: z.boolean(),
+ }).strict()).max(4),
+}).strict();
+
+const eidoverseProjectionRecipeV2Schema = z.object({
+ version: z.literal(2),
+ name: z.string().trim().min(1).max(80),
+ maxEntities: z.number().int().min(1).max(48),
+ includes: eidoverseProjectionIncludesSchema,
+ limits: eidoverseProjectionLimitsSchema,
+ scale: eidoverseProjectionScaleSchema,
+ districts: z.array(z.object({
+ id: eidoverseDistrictIdSchema,
+ label: z.string().trim().min(1).max(80),
+ direction: z.string().trim().min(1).max(40),
+ landmark: z.string().trim().min(1).max(80),
+ anchor: eidoverseVector3Schema,
+ sources: z.array(z.enum(EIDOVERSE_PROJECTION_SOURCE_KEYS)).min(1).max(8),
+ accent: z.string().regex(/^#[0-9a-f]{6}$/i),
+ }).strict()).min(1).max(12),
+ paths: z.array(z.object({
+ id: z.string().regex(/^[a-z0-9_-]{1,64}$/),
+ label: z.string().trim().min(1).max(100),
+ toDistrictId: eidoverseDistrictIdSchema,
+ nodes: z.array(eidoverseVector3Schema).min(1).max(8),
+ }).strict()).max(16),
+ environment: eidoverseProjectionEnvironmentSchema,
+ assetRecipe: z.object({ version: z.literal(2), slots: eidoverseAssetSlotsSchema }).strict(),
+ assets: eidoverseResolvedAssetsSchema,
+}).strict();
+
+export const eidoverseProjectionRecipeSchema = z.union([
+ eidoverseProjectionRecipeV1Schema,
+ eidoverseProjectionRecipeV2Schema,
+]);
+
// This is intentionally an opaque, bounded argument bag at the HTTP boundary.
// The PortOS service applies the narrower verb-specific checks immediately
// before sending it to Eidoverse, which keeps this public schema forward-
@@ -965,6 +1085,25 @@ export const eidoverseWorldConfigPatchSchema = z.object({
cosAvatar: eidoverseAssetPathSchema.nullable().optional(),
cosEnabled: z.boolean().optional(),
recipe: eidoverseProjectionRecipeSchema.optional(),
+ assetOverrides: z.partialRecord(
+ z.enum([
+ 'nexus', 'app', 'agent', 'task', 'goal', 'memory', 'storage', 'peer', 'activity', 'district',
+ // V1 used resource-kind keys. Keep accepting them so an upgraded install
+ // can round-trip its preserved custom paths while the V2 semantic slots
+ // become the preferred editing surface.
+ 'feature', 'health', 'productivity', 'jira', 'operations',
+ ]),
+ eidoverseModelAssetOverrideSchema,
+ ).optional(),
+ refreshAssets: z.boolean().optional(),
+ reset: z.object({
+ scope: z.enum(['all', 'assets', 'district']),
+ districtId: eidoverseDistrictIdSchema.optional(),
+ }).strict().superRefine((value, ctx) => {
+ if (value.scope === 'district' && !value.districtId) {
+ ctx.addIssue({ code: z.ZodIssueCode.custom, path: ['districtId'], message: 'districtId is required for a district reset' });
+ }
+ }).optional(),
}).strict();
export const subdirFilterSchema = z.string()
diff --git a/server/routes/eidoverseWorldRoutes.js b/server/routes/eidoverseWorldRoutes.js
index 400e10f60..68f69bd04 100644
--- a/server/routes/eidoverseWorldRoutes.js
+++ b/server/routes/eidoverseWorldRoutes.js
@@ -17,6 +17,7 @@ import {
import {
augmentEidoverseWorld,
ensureEidoverseWorldPresence,
+ getEidoverseWorldProjectionStatus,
getEidoverseWorldStatus,
projectEidoverseWorld,
sayInEidoverseWorld,
@@ -31,6 +32,13 @@ router.get('/status', asyncHandler(async (_req, res) => {
res.json(await getEidoverseWorldStatus());
}));
+// GET /api/eidoverse/world/projection/status — lightweight persisted
+// reconciliation progress for the in-flight projection poller. This avoids
+// runtime, app-registry, filesystem, and PM2 probes on every progress tick.
+router.get('/projection/status', asyncHandler(async (_req, res) => {
+ res.json(await getEidoverseWorldProjectionStatus());
+}));
+
// PUT /api/eidoverse/world/config — persist the human/CoS identity and
// deterministic PortOS projection recipe for this install.
router.put('/config', asyncHandler(async (req, res) => {
diff --git a/server/routes/eidoverseWorldRoutes.test.js b/server/routes/eidoverseWorldRoutes.test.js
index 8bc4051a2..75f128a08 100644
--- a/server/routes/eidoverseWorldRoutes.test.js
+++ b/server/routes/eidoverseWorldRoutes.test.js
@@ -6,6 +6,7 @@ import { errorMiddleware } from '../lib/errorHandler.js';
const mocks = vi.hoisted(() => ({
augment: vi.fn(),
ensurePresence: vi.fn(),
+ getProjectionStatus: vi.fn(),
getStatus: vi.fn(),
project: vi.fn(),
say: vi.fn(),
@@ -15,6 +16,7 @@ const mocks = vi.hoisted(() => ({
vi.mock('../services/eidoverseWorld.js', () => ({
augmentEidoverseWorld: mocks.augment,
ensureEidoverseWorldPresence: mocks.ensurePresence,
+ getEidoverseWorldProjectionStatus: mocks.getProjectionStatus,
getEidoverseWorldStatus: mocks.getStatus,
projectEidoverseWorld: mocks.project,
sayInEidoverseWorld: mocks.say,
@@ -35,6 +37,10 @@ describe('Eidoverse world routes', () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getStatus.mockResolvedValue({ world: 'portos', identity: { name: 'example-user' } });
+ mocks.getProjectionStatus.mockResolvedValue({
+ design: { reconciliation: { status: 'applying', checkpoint: 'applying-live' } },
+ projection: { lastRunAt: '2026-01-01T00:00:00.000Z' },
+ });
mocks.updateConfig.mockResolvedValue({ world: 'portos', human: { name: 'example-user' } });
mocks.ensurePresence.mockResolvedValue({ connected: true, role: 'owner' });
mocks.project.mockResolvedValue({ success: true, summary: { operationCount: 0 } });
@@ -49,12 +55,59 @@ describe('Eidoverse world routes', () => {
expect(mocks.getStatus).toHaveBeenCalledOnce();
});
+ it('returns lightweight persisted projection progress', async () => {
+ const res = await request(makeApp()).get('/api/eidoverse/world/projection/status');
+
+ expect(res.status).toBe(200);
+ expect(res.body).toMatchObject({
+ design: { reconciliation: { checkpoint: 'applying-live' } },
+ });
+ expect(mocks.getProjectionStatus).toHaveBeenCalledOnce();
+ expect(mocks.getStatus).not.toHaveBeenCalled();
+ });
+
it('validates and persists a configuration patch', async () => {
const res = await request(makeApp()).put('/api/eidoverse/world/config').send({ humanName: 'Example User' });
expect(res.status).toBe(200);
expect(mocks.updateConfig).toHaveBeenCalledWith({ humanName: 'Example User' });
});
+ it('validates scoped reset and asset-refresh actions', async () => {
+ const district = await request(makeApp()).put('/api/eidoverse/world/config').send({
+ reset: { scope: 'district', districtId: 'apps' },
+ });
+ const assets = await request(makeApp()).put('/api/eidoverse/world/config').send({ refreshAssets: true });
+ const invalid = await request(makeApp()).put('/api/eidoverse/world/config').send({ reset: { scope: 'district' } });
+ const custom = await request(makeApp()).put('/api/eidoverse/world/config').send({
+ reset: { scope: 'district', districtId: 'example-unknown-district' },
+ });
+ const malformed = await request(makeApp()).put('/api/eidoverse/world/config').send({
+ reset: { scope: 'district', districtId: 'Example Unknown District' },
+ });
+
+ expect(district.status).toBe(200);
+ expect(assets.status).toBe(200);
+ expect(mocks.updateConfig).toHaveBeenCalledWith({ reset: { scope: 'district', districtId: 'apps' } });
+ expect(mocks.updateConfig).toHaveBeenCalledWith({ reset: { scope: 'district', districtId: 'example-unknown-district' } });
+ expect(mocks.updateConfig).toHaveBeenCalledWith({ refreshAssets: true });
+ expect(invalid.status).toBe(400);
+ expect(custom.status).toBe(200);
+ expect(malformed.status).toBe(400);
+ });
+
+ it('accepts explicit install-local asset overrides without making them portable defaults', async () => {
+ const payload = {
+ assetOverrides: {
+ app: 'store/example-local-asset',
+ operations: 'eidoverse/assets/models/example-legacy-operations.glb',
+ },
+ };
+ const response = await request(makeApp()).put('/api/eidoverse/world/config').send(payload);
+
+ expect(response.status).toBe(200);
+ expect(mocks.updateConfig).toHaveBeenCalledWith(payload);
+ });
+
it('rejects verbs outside the bounded augmentation contract', async () => {
const res = await request(makeApp()).post('/api/eidoverse/world/augment').send({
operations: [{ verb: 'behavior', args: {} }],
diff --git a/server/services/bootstrap.js b/server/services/bootstrap.js
index c0943e983..ab5614fa4 100644
--- a/server/services/bootstrap.js
+++ b/server/services/bootstrap.js
@@ -319,6 +319,17 @@ const startBackgroundServices = ({ spawnerReady }) => {
// in bootstrapSequence.js.
initCosAfterSpawner({ spawnerReady, initCos: () => cos.init() });
+ // World Design migrations are offline and leave a pending checkpoint. If the
+ // separately-managed Eidoverse process is already online, reconcile it now;
+ // otherwise leave the checkpoint for direct remediation in the Eidoverse UI.
+ // This is deterministic local projection only — never an AI provider call.
+ import('./eidoverseWorld.js')
+ .then(({ reconcilePendingEidoverseWorld }) => reconcilePendingEidoverseWorld())
+ .then((result) => {
+ if (result.reconciled) console.log('🌐 Reconciled pending Eidoverse World Design update');
+ })
+ .catch(err => console.error(`⚠️ Eidoverse World Design reconciliation deferred: ${err.message}`));
+
// Initialize agent automation scheduler and action executor
automationScheduler.init().catch(err => console.error(`❌ Agent scheduler init failed: ${err.message}`));
// agentActionExecutor.init() is synchronous — guard with try/catch so a thrown
diff --git a/server/services/eidoverseWorld.js b/server/services/eidoverseWorld.js
index 4b54fb96e..deeca86e8 100644
--- a/server/services/eidoverseWorld.js
+++ b/server/services/eidoverseWorld.js
@@ -9,30 +9,39 @@
*/
import { createHash } from 'node:crypto';
-import { statfs } from 'node:fs/promises';
import { join } from 'node:path';
import { WebSocket } from 'ws';
import { atomicWrite, dataPath, ensureDir, readJSONFile } from '../lib/fileUtils.js';
import { createMutex } from '../lib/asyncMutex.js';
import { ServerError } from '../lib/errorHandler.js';
-import { getAllApps, getAppStatuses } from './apps.js';
-import { getStatus as getCosStatus, getAgents, getCosTasks, getTodayActivity } from './cos.js';
-import { getPendingCounts } from './review.js';
-import { getSelf, getPeers, ensureSelf } from './instances.js';
+import { canonicalStringify } from '../lib/objects.js';
+import { getSelf, ensureSelf } from './instances.js';
import { getInstanceFeatures } from './instanceFeatures.js';
-import * as backup from './backup.js';
-import { getCountsByType } from './notifications.js';
-import { getCharacter } from './character.js';
-import { getLatestMetricValues } from './appleHealthQuery.js';
-import { getVoiceConfig } from './voice/config.js';
-import { getMemoryStats } from '../lib/memoryStats.js';
-import { getGoals, getChronotype } from './identity.js';
-import { getActivityCalendar, getVelocityMetrics } from './productivity.js';
-import { getBrainGraphOverview } from './brainGraph.js';
-import { getInboxLogCounts } from './brainStorage.js';
-import { getOpenWorldIntrospection } from './openWorldIntrospection.js';
-import { fetchMyCurrentSprintTickets } from './jira.js';
import { getEidoverseStatus, EIDOVERSE_PORT } from './eidoverse.js';
+import {
+ EIDOVERSE_ASSET_SLOTS_BY_DISTRICT,
+ EIDOVERSE_ASSET_RECIPE_VERSION,
+ EIDOVERSE_WORLD_DESIGN_VERSION,
+ EIDOVERSE_WORLD_STATE_SCHEMA_VERSION,
+ extractEidoverseDesignOverrides,
+ inspectEidoverseAssetResolutionLocks,
+ isValidEidoverseAssetOverridePath,
+ migrateEidoverseWorldState,
+ resolveEidoverseAssetRecipe,
+ resolveEidoverseDesign,
+} from '../lib/eidoverseWorldDesign.js';
+import {
+ buildProjectionPlan,
+ DEFAULT_EIDOVERSE_PROJECTION_RECIPE,
+ EIDOVERSE_PROJECTION_KINDS,
+} from './eidoverseWorldProjection.js';
+import {
+ collectEidoverseWorldSources,
+ projectedJiraTickets,
+ projectedStorage,
+} from './eidoverseWorldSources.js';
+
+export { buildProjectionPlan, DEFAULT_EIDOVERSE_PROJECTION_RECIPE, projectedJiraTickets, projectedStorage };
const DATA_DIR = dataPath('eidoverse');
const STATE_FILE = join(DATA_DIR, 'portos-world.json');
@@ -40,118 +49,19 @@ const WORLD_NAME_RE = /^[a-z0-9_-]{1,64}$/i;
const ENTITY_ID_RE = /^[A-Za-z0-9_-]{1,64}$/;
const COMPONENT_TYPE_RE = /^[A-Za-z0-9._:-]{1,32}$/;
const ASSET_PATH_RE = /^(?:eidoverse|store)\//i;
-const PROJECTION_ID_PREFIX = 'portos-projection-';
const COMPONENT_TYPE = 'portos';
-const COMPONENT_RESOURCE_BY_KIND = Object.freeze({
- app: 'apps',
- agent: 'agents',
- task: 'tasks',
- feature: 'features',
- peer: 'peers',
- health: 'health',
- productivity: 'productivity',
- activity: 'activity',
- goal: 'goals',
- memory: 'memory',
- storage: 'storage',
- jira: 'jira',
- operations: 'operations',
-});
const DEFAULT_WORLD = 'portos';
const DEFAULT_HUMAN_AVATAR = 'eidoverse/assets/vrms/claude.vrm';
const DEFAULT_COS_ID = 'portos-cos';
const DEFAULT_COS_AVATAR = 'eidoverse/assets/vrms/claude_suit.vrm';
-const PROJECTION_VERB_INTERVAL_MS = 350;
+// Eidoverse admits 12 authored verbs per four seconds. Stay just below that
+// public protocol limit so large first-run reconciliations remain reliable.
+const PROJECTION_VERB_INTERVAL_MS = process.env.NODE_ENV === 'test' ? 5 : 350;
+const RETIRED_OWNER_MAX_ATTEMPTS = 3;
const WORLD_ROLES = new Set(['owner', 'builder', 'visitor']);
-const MODEL_ROOT = 'eidoverse/assets/models/';
-const HEALTH_METRIC_KEYS = ['heart_rate', 'step_count', 'active_energy', 'sleep_analysis'];
-
-export const DEFAULT_EIDOVERSE_PROJECTION_RECIPE = Object.freeze({
- version: 1,
- includes: {
- apps: true,
- agents: true,
- tasks: true,
- features: true,
- peers: true,
- health: true,
- productivity: true,
- activity: true,
- goals: true,
- memory: true,
- storage: true,
- jira: true,
- operations: true,
- },
- limits: {
- apps: 48,
- agents: 24,
- tasks: 48,
- features: 32,
- peers: 16,
- health: 1,
- productivity: 1,
- activity: 24,
- goals: 32,
- memory: 16,
- storage: 48,
- jira: 48,
- operations: 1,
- },
- layout: {
- origin: [-24, 0, -24],
- spacing: 7,
- // Keep the expanded resource lanes compact enough to stay near the
- // authored terrain while still leaving a visible gap between families.
- laneGap: 6,
- columns: 8,
- },
- scale: {
- app: 1,
- agent: 1,
- task: 0.8,
- feature: 1,
- peer: 1.2,
- health: 1.2,
- productivity: 1.2,
- activity: 0.55,
- goal: 1.1,
- memory: 0.9,
- storage: 0.8,
- jira: 0.75,
- operations: 1.3,
- },
- assets: {
- app: `${MODEL_ROOT}computer_servers_rack_with_fans_on_back_row_of_four_4_columns.glb`,
- agent: `${MODEL_ROOT}scifi_quad_small_drone_blue.glb`,
- task: `${MODEL_ROOT}scifi_cyberpunk_intermodal_shipping_container_crate_blue.glb`,
- feature: `${MODEL_ROOT}inanna_tech_cyber_scifi_sphere_orb.glb`,
- peer: `${MODEL_ROOT}modern_sedan_car_blue_vehicle_generic.glb`,
- health: `${MODEL_ROOT}inanna_tech_cyber_scifi_sumerian_retrofuturist_vehicle_car_light.glb`,
- productivity: `${MODEL_ROOT}inanna_tech_cyber_scifi_sumerian_retrofuturist_vehicle_car_light.glb`,
- activity: `${MODEL_ROOT}inanna_tech_cyber_scifi_sphere_orb.glb`,
- goal: `${MODEL_ROOT}inanna_tech_cyber_scifi_sphere_orb.glb`,
- memory: `${MODEL_ROOT}inanna_tech_cyber_scifi_sphere_orb.glb`,
- storage: `${MODEL_ROOT}computer_servers_rack_with_fans_on_back_row_of_four_4_columns.glb`,
- jira: `${MODEL_ROOT}scifi_cyberpunk_intermodal_shipping_container_crate_blue.glb`,
- operations: `${MODEL_ROOT}inanna_tech_cyber_scifi_sumerian_retrofuturist_vehicle_car_light.glb`,
- },
- terrain: {
- seed: 'portos',
- size: 128,
- segments: 64,
- amplitude: 1.8,
- flatRadius: 28,
- layers: [
- { color: '#142338', repeat: 18 },
- { color: '#1c3b43', repeat: 10 },
- ],
- },
-});
-
const DEFAULT_STATE = {
- schemaVersion: 1,
+ schemaVersion: EIDOVERSE_WORLD_STATE_SCHEMA_VERSION,
world: DEFAULT_WORLD,
human: {
name: null,
@@ -168,6 +78,26 @@ const DEFAULT_STATE = {
ownership: {
retired: [],
},
+ selectedDesignVersion: EIDOVERSE_WORLD_DESIGN_VERSION,
+ lastAppliedDesignVersion: null,
+ pendingDesignVersion: EIDOVERSE_WORLD_DESIGN_VERSION,
+ userOverrides: {},
+ assetRecipeVersion: EIDOVERSE_ASSET_RECIPE_VERSION,
+ assetResolutions: {},
+ migrationReport: null,
+ reconciliation: {
+ status: 'pending',
+ checkpoint: 'new-install',
+ error: null,
+ errorCode: null,
+ errorContext: null,
+ startedAt: null,
+ completedAt: null,
+ planFingerprint: null,
+ operationCount: 0,
+ appliedOperations: 0,
+ compensationStatus: null,
+ },
recipe: DEFAULT_EIDOVERSE_PROJECTION_RECIPE,
projection: {
lastRunAt: null,
@@ -229,14 +159,6 @@ function abortableDelay(ms, signal) {
});
}
-function stableStringify(value) {
- if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
- if (value && typeof value === 'object') {
- return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;
- }
- return JSON.stringify(value);
-}
-
function shortHash(value) {
return createHash('sha256').update(String(value)).digest('hex').slice(0, 12);
}
@@ -313,26 +235,6 @@ function safeNumber(value, field, { min = -Infinity, max = Infinity, fallback =
return number;
}
-function mergeRecipe(recipe) {
- const input = recipe && typeof recipe === 'object' && !Array.isArray(recipe) ? recipe : {};
- const defaults = DEFAULT_EIDOVERSE_PROJECTION_RECIPE;
- return {
- version: 1,
- includes: { ...defaults.includes, ...(input.includes || {}) },
- limits: { ...defaults.limits, ...(input.limits || {}) },
- layout: { ...defaults.layout, ...(input.layout || {}) },
- scale: { ...defaults.scale, ...(input.scale || {}) },
- assets: { ...defaults.assets, ...(input.assets || {}) },
- terrain: {
- ...defaults.terrain,
- ...(input.terrain || {}),
- layers: Array.isArray(input.terrain?.layers)
- ? input.terrain.layers.map((layer) => ({ ...layer }))
- : defaults.terrain.layers.map((layer) => ({ ...layer })),
- },
- };
-}
-
function normalizeRetiredOwners(value) {
if (!Array.isArray(value)) return [];
const entries = new Map();
@@ -343,6 +245,9 @@ function normalizeRetiredOwners(value) {
const key = `${world}\0${id}`;
const actorId = validIdentity(candidate?.actorId, '');
const normalizedAvatar = safeText(candidate?.actorAvatar, '').replaceAll('\\', '/');
+ const attempts = Number.isInteger(candidate?.attempts)
+ ? Math.max(0, Math.min(RETIRED_OWNER_MAX_ATTEMPTS - 1, candidate.attempts))
+ : 0;
entries.set(key, {
world,
id,
@@ -350,6 +255,7 @@ function normalizeRetiredOwners(value) {
actorId,
actorAvatar: isSafeAssetPath(normalizedAvatar) ? normalizedAvatar : DEFAULT_COS_AVATAR,
} : {}),
+ ...(attempts > 0 ? { attempts } : {}),
});
}
return [...entries.values()];
@@ -364,9 +270,30 @@ function rememberRetiredOwner(state, world, id, actor) {
}
function normalizeState(raw) {
- const input = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {};
+ const migration = migrateEidoverseWorldState(raw);
+ if (!migration.compatible) {
+ const invalid = String(migration.report?.reason || '').startsWith('invalid-');
+ throw new ServerError(invalid
+ ? 'This Eidoverse world state is invalid. Repair or restore data/eidoverse/portos-world.json before changing it.'
+ : 'This Eidoverse world state was written by a newer PortOS design. Update PortOS before changing it.', {
+ status: 409,
+ code: 'EIDOVERSE_STATE_VERSION_UNSUPPORTED',
+ context: {
+ reason: migration.report?.reason,
+ fromSchemaVersion: migration.report?.fromSchemaVersion,
+ supportedSchemaVersion: EIDOVERSE_WORLD_STATE_SCHEMA_VERSION,
+ },
+ });
+ }
+ const input = migration.state;
+ const userOverrides = input.userOverrides && typeof input.userOverrides === 'object'
+ ? input.userOverrides
+ : {};
+ const assetResolutions = input.assetResolutions && typeof input.assetResolutions === 'object'
+ ? input.assetResolutions
+ : {};
return {
- schemaVersion: 1,
+ schemaVersion: EIDOVERSE_WORLD_STATE_SCHEMA_VERSION,
world: validWorldName(input.world),
human: {
...DEFAULT_STATE.human,
@@ -381,7 +308,24 @@ function normalizeState(raw) {
ownership: {
retired: normalizeRetiredOwners(input.ownership?.retired),
},
- recipe: mergeRecipe(input.recipe),
+ selectedDesignVersion: EIDOVERSE_WORLD_DESIGN_VERSION,
+ lastAppliedDesignVersion: Number.isInteger(input.lastAppliedDesignVersion)
+ ? input.lastAppliedDesignVersion
+ : null,
+ pendingDesignVersion: input.pendingDesignVersion === null
+ ? null
+ : (Number.isInteger(input.pendingDesignVersion)
+ ? input.pendingDesignVersion
+ : EIDOVERSE_WORLD_DESIGN_VERSION),
+ userOverrides: clone(userOverrides),
+ assetRecipeVersion: EIDOVERSE_ASSET_RECIPE_VERSION,
+ assetResolutions: clone(assetResolutions),
+ migrationReport: input.migrationReport || migration.report || null,
+ reconciliation: {
+ ...DEFAULT_STATE.reconciliation,
+ ...(input.reconciliation && typeof input.reconciliation === 'object' ? input.reconciliation : {}),
+ },
+ recipe: resolveEidoverseDesign(userOverrides, assetResolutions),
projection: {
...DEFAULT_STATE.projection,
...(input.projection && typeof input.projection === 'object' ? input.projection : {}),
@@ -405,8 +349,10 @@ async function mutateState(mutator) {
function fallbackIdentity(self) {
const instanceId = self?.instanceId || 'uninitialized-instance';
- const instanceName = validIdentity(self?.name, '');
- if (instanceName) return { name: instanceName, source: 'instance-name' };
+ // An instance display name is machine identity and must never become part of
+ // Eidoverse's append-only world log by default. Keep the embodied fallback
+ // stable without disclosing the raw instance id; users can still opt into an
+ // explicit human name from the World Design drawer.
return { name: `portos-${shortHash(instanceId)}`, source: 'instance-id' };
}
@@ -436,6 +382,19 @@ function configFromState(state, presence = cosPresence) {
: validRole(state.cos.role),
gen: activePresence?.snapshot?.yourRights?.gen === true,
},
+ design: {
+ name: state.recipe.name,
+ selectedVersion: state.selectedDesignVersion,
+ lastAppliedVersion: state.lastAppliedDesignVersion,
+ pendingVersion: state.pendingDesignVersion,
+ assetRecipeVersion: state.assetRecipeVersion,
+ assetResolutions: clone(state.assetResolutions),
+ userOverrides: clone(state.userOverrides),
+ migrationReport: clone(state.migrationReport),
+ reconciliation: clone(state.reconciliation),
+ districts: clone(state.recipe.districts),
+ maxEntities: state.recipe.maxEntities,
+ },
recipe: state.recipe,
projection: state.projection,
};
@@ -485,6 +444,7 @@ export async function updateEidoverseWorldConfig(patch) {
return worldLock(async () => {
const self = await ensureSelf();
const fallback = fallbackIdentity(self);
+ const fullReset = patch.reset?.scope === 'all';
const updated = await mutateState((state) => {
const previousPresenceConfig = {
world: state.world,
@@ -494,6 +454,7 @@ export async function updateEidoverseWorldConfig(patch) {
cosAvatar: state.cos.avatar,
cosEnabled: state.cos.enabled,
};
+ let designChanged = false;
if (patch.world !== undefined) state.world = validWorldName(patch.world);
if (Object.hasOwn(patch, 'humanName')) {
state.human.name = patch.humanName
@@ -505,7 +466,76 @@ export async function updateEidoverseWorldConfig(patch) {
if (patch.cosId !== undefined) state.cos.id = validIdentity(patch.cosId, DEFAULT_COS_ID);
if (Object.hasOwn(patch, 'cosAvatar')) state.cos.avatar = patch.cosAvatar || DEFAULT_COS_AVATAR;
if (patch.cosEnabled !== undefined) state.cos.enabled = patch.cosEnabled;
- if (patch.recipe !== undefined) state.recipe = mergeRecipe(patch.recipe);
+ if (fullReset) {
+ state.userOverrides = {};
+ state.assetResolutions = {};
+ state.ownership.retired = [];
+ state.human.role = null;
+ state.cos.role = null;
+ delete state.reconciliation.retiredOwnerCleanup;
+ if (state.migrationReport) delete state.migrationReport.retiredOwnerCleanup;
+ designChanged = true;
+ } else if (patch.reset?.scope === 'assets') {
+ state.assetResolutions = {};
+ delete state.userOverrides.assets;
+ designChanged = true;
+ } else if (patch.reset?.scope === 'district') {
+ const district = state.recipe.districts.find(({ id }) => id === patch.reset.districtId);
+ if (!district) {
+ throw new ServerError('The selected Eidoverse district no longer exists in this world design.', {
+ status: 400,
+ code: 'EIDOVERSE_DISTRICT_NOT_FOUND',
+ });
+ }
+ const districtKinds = district.sources
+ .map((sourceKey) => EIDOVERSE_PROJECTION_KINDS.find((entry) => entry.source === sourceKey)?.kind)
+ .filter(Boolean);
+ for (const sourceKey of district.sources) {
+ delete state.userOverrides.includes?.[sourceKey];
+ delete state.userOverrides.limits?.[sourceKey];
+ }
+ for (const kind of districtKinds) delete state.userOverrides.scale?.[kind];
+ const districtAssetSlots = new Set([
+ ...(EIDOVERSE_ASSET_SLOTS_BY_DISTRICT[district.id] || ['district']),
+ ...districtKinds,
+ ]);
+ for (const slot of districtAssetSlots) {
+ delete state.userOverrides.assets?.[slot];
+ delete state.assetResolutions[slot];
+ }
+ designChanged = true;
+ }
+ if (patch.refreshAssets) {
+ state.assetResolutions = {};
+ designChanged = true;
+ }
+ if (patch.assetOverrides !== undefined) {
+ state.userOverrides.assets = clone(patch.assetOverrides);
+ designChanged = true;
+ }
+ if (patch.recipe !== undefined) {
+ const preservedAssetOverrides = state.userOverrides.assets;
+ state.userOverrides = patch.recipe.version === 1
+ ? migrateEidoverseWorldState({ schemaVersion: 1, recipe: patch.recipe }).state.userOverrides
+ : extractEidoverseDesignOverrides(patch.recipe);
+ if (preservedAssetOverrides && Object.keys(preservedAssetOverrides).length) {
+ state.userOverrides.assets = clone(preservedAssetOverrides);
+ }
+ designChanged = true;
+ }
+ if (designChanged) {
+ state.pendingDesignVersion = EIDOVERSE_WORLD_DESIGN_VERSION;
+ state.reconciliation.status = 'pending';
+ state.reconciliation.checkpoint = 'configuration-saved';
+ state.reconciliation.error = null;
+ state.reconciliation.errorCode = null;
+ state.reconciliation.errorContext = null;
+ state.reconciliation.planFingerprint = null;
+ state.reconciliation.operationCount = 0;
+ state.reconciliation.appliedOperations = 0;
+ state.reconciliation.compensationStatus = null;
+ }
+ state.recipe = resolveEidoverseDesign(state.userOverrides, state.assetResolutions);
const worldChanged = previousPresenceConfig.world !== state.world;
const humanChanged = previousPresenceConfig.humanName !== state.human.name;
const cosChanged = previousPresenceConfig.cosId !== state.cos.id;
@@ -513,7 +543,7 @@ export async function updateEidoverseWorldConfig(patch) {
id: previousPresenceConfig.cosId,
avatar: previousPresenceConfig.cosAvatar,
};
- if (humanChanged) {
+ if (!fullReset && humanChanged) {
rememberRetiredOwner(
state,
previousPresenceConfig.world,
@@ -521,7 +551,7 @@ export async function updateEidoverseWorldConfig(patch) {
previousOwner,
);
}
- if (cosChanged) {
+ if (!fullReset && cosChanged) {
rememberRetiredOwner(
state,
previousPresenceConfig.world,
@@ -545,7 +575,7 @@ export async function updateEidoverseWorldConfig(patch) {
cosAvatar: updated.config.cos.avatar,
cosEnabled: updated.config.cos.enabled,
};
- if (Object.keys(currentPresenceConfig).some((key) => (
+ if (fullReset || Object.keys(currentPresenceConfig).some((key) => (
updated.previousPresenceConfig[key] !== currentPresenceConfig[key]
))) {
await closeCosPresenceInternal();
@@ -559,6 +589,17 @@ function resolveWorldWsUrl() {
return configured || `ws://127.0.0.1:${EIDOVERSE_PORT}/ws`;
}
+function resolveWorldHttpUrl() {
+ const configured = typeof process.env.EIDOVERSE_HTTP_URL === 'string' ? process.env.EIDOVERSE_HTTP_URL.trim() : '';
+ if (configured) return configured;
+ const url = new URL(resolveWorldWsUrl());
+ url.protocol = url.protocol === 'wss:' ? 'https:' : 'http:';
+ url.pathname = '/';
+ url.search = '';
+ url.hash = '';
+ return url.toString();
+}
+
function asConnectionError(error, fallback = 'Eidoverse Worlds is unavailable.') {
if (error instanceof ServerError) return error;
return new ServerError(`${fallback} ${error?.message || ''}`.trim(), {
@@ -852,28 +893,93 @@ async function forgetRetiredOwners(targets) {
});
}
-async function demoteRetiredOwners(connection, targets, { signal } = {}) {
+async function recordRetiredOwnerCleanupFailures(failures) {
+ if (!failures.length) {
+ const state = await loadState();
+ if (!state.reconciliation.retiredOwnerCleanup && !state.migrationReport?.retiredOwnerCleanup) return;
+ await mutateState((current) => {
+ delete current.reconciliation.retiredOwnerCleanup;
+ if (current.migrationReport) delete current.migrationReport.retiredOwnerCleanup;
+ return current;
+ });
+ return;
+ }
+ const failuresByOwner = new Map(failures.map(({ target, error }) => [
+ `${target.world}\0${target.id}`,
+ { target, error },
+ ]));
+ const report = await mutateState((state) => {
+ let retryingCount = 0;
+ let droppedCount = 0;
+ state.ownership.retired = state.ownership.retired.flatMap((entry) => {
+ const failure = failuresByOwner.get(`${entry.world}\0${entry.id}`);
+ if (!failure) return [entry];
+ const attempts = (entry.attempts || failure.target.attempts || 0) + 1;
+ if (attempts >= RETIRED_OWNER_MAX_ATTEMPTS) {
+ droppedCount += 1;
+ return [];
+ }
+ retryingCount += 1;
+ return [{ ...entry, attempts }];
+ });
+ const nextReport = {
+ status: 'partial',
+ failedCount: failuresByOwner.size,
+ retryingCount,
+ droppedCount,
+ maxAttempts: RETIRED_OWNER_MAX_ATTEMPTS,
+ codes: [...new Set([...failuresByOwner.values()].map(({ error }) => (
+ safeText(error?.code, 'EIDOVERSE_OWNER_HANDOFF_FAILED', 80)
+ )))].slice(0, 8),
+ at: new Date().toISOString(),
+ };
+ state.reconciliation.retiredOwnerCleanup = nextReport;
+ if (state.migrationReport) state.migrationReport.retiredOwnerCleanup = clone(nextReport);
+ return nextReport;
+ });
+ console.warn(`⚠️ Eidoverse skipped ${report.failedCount} retired-owner cleanup operation(s); projection will continue`);
+}
+
+async function demoteRetiredOwners(connection, targets, {
+ signal,
+ pacing = createVerbPacing(),
+} = {}) {
const ordered = [...targets].sort((left, right) => (
Number(left.id === connection.id) - Number(right.id === connection.id)
));
+ const failures = [];
+ const succeeded = [];
for (const target of ordered) {
- throwIfAborted(signal);
- await connection.sendVerb('grant', { id: target.id, role: 'visitor' }, { signal });
+ await sendPacedVerb(connection, 'grant', { id: target.id, role: 'visitor' }, { signal, pacing }).then(
+ () => succeeded.push(target),
+ (error) => {
+ if (signal?.aborted) throw error;
+ failures.push({ target, error });
+ },
+ );
}
- await forgetRetiredOwners(targets);
+ await forgetRetiredOwners(succeeded);
+ return failures;
}
-async function revokeRetiredOwners(connection, config, { signal } = {}) {
+async function revokeRetiredOwners(connection, config, {
+ signal,
+ pacing = createVerbPacing(),
+} = {}) {
const state = await loadState();
const targets = state.ownership.retired.filter((entry) => (
entry.world !== config.world
|| (entry.id !== config.human.id && entry.id !== config.cos.id)
));
- if (!targets.length) return [];
+ if (!targets.length) {
+ await recordRetiredOwnerCleanupFailures([]);
+ return [];
+ }
+ const failures = [];
const currentWorldTargets = targets.filter((entry) => entry.world === config.world);
if (currentWorldTargets.length) {
- await demoteRetiredOwners(connection, currentWorldTargets, { signal });
+ failures.push(...await demoteRetiredOwners(connection, currentWorldTargets, { signal, pacing }));
}
const priorWorldGroups = new Map();
@@ -902,15 +1008,22 @@ async function revokeRetiredOwners(connection, config, { signal } = {}) {
await priorConnection.waitForSnapshot({ signal }).then(async (snapshot) => {
const actorRole = snapshot?.yourRights?.role || roleFromSnapshot(snapshot, group.actorId);
if (actorRole !== 'owner') {
- throw new ServerError('PortOS could not retire a previous Eidoverse owner identity.', {
+ const error = new ServerError('PortOS could not retire a previous Eidoverse owner identity.', {
status: 409,
code: 'EIDOVERSE_OWNER_HANDOFF_FAILED',
});
+ failures.push(...group.targets.map((target) => ({ target, error })));
+ return;
}
- await demoteRetiredOwners(priorConnection, group.targets, { signal });
+ failures.push(...await demoteRetiredOwners(priorConnection, group.targets, { signal }));
+ }, async (error) => {
+ if (signal?.aborted) throw error;
+ const connectionError = asConnectionError(error, 'PortOS could not inspect a previous Eidoverse world.');
+ failures.push(...group.targets.map((target) => ({ target, error: connectionError })));
}).finally(() => priorConnection.close());
}
+ await recordRetiredOwnerCleanupFailures(failures);
return targets;
}
@@ -927,6 +1040,7 @@ async function revokeRetiredOwners(connection, config, { signal } = {}) {
*/
async function seedHumanRoleAndCosGrant(config, { signal } = {}) {
throwIfAborted(signal);
+ const pacing = createVerbPacing();
let connection;
connection = createWorldConnection({
world: config.world,
@@ -940,7 +1054,7 @@ async function seedHumanRoleAndCosGrant(config, { signal } = {}) {
if (config.human.id !== config.cos.id
&& humanRole === 'owner'
&& cosRole !== 'owner') {
- await connection.sendVerb('grant', { id: config.cos.id, role: 'owner' }, { signal });
+ await sendPacedVerb(connection, 'grant', { id: config.cos.id, role: 'owner' }, { signal, pacing });
cosRole = 'owner';
}
return { humanRole, cosRole };
@@ -966,6 +1080,7 @@ async function ensureCosPresenceInternal({ fresh = false, signal } = {}) {
await seedHumanRoleAndCosGrant(config, { signal });
let connection;
+ const pacing = createVerbPacing();
connection = createWorldConnection({
world: config.world,
id: config.cos.id,
@@ -979,15 +1094,16 @@ async function ensureCosPresenceInternal({ fresh = false, signal } = {}) {
const cosRole = snapshot?.yourRights?.role || roleFromSnapshot(snapshot, config.cos.id);
let humanRole = roleFromSnapshot(snapshot, config.human.id);
if (config.human.id !== config.cos.id && cosRole === 'owner' && humanRole !== 'owner') {
- await connection.sendVerb('grant', { id: config.human.id, role: 'owner' }, { signal });
+ await sendPacedVerb(connection, 'grant', { id: config.human.id, role: 'owner' }, { signal, pacing });
humanRole = 'owner';
}
- if (cosRole === 'owner') await revokeRetiredOwners(connection, config, { signal });
+ if (cosRole === 'owner') await revokeRetiredOwners(connection, config, { signal, pacing });
return rememberObservedRoles({ humanRole, cosRole }).then(() => {
cosPresence = {
connection,
snapshot,
joinedAt: new Date().toISOString(),
+ pacing,
};
return cosPresence;
});
@@ -1002,606 +1118,439 @@ export async function ensureEidoverseWorldPresence() {
});
}
-async function getDiskUsagePercent() {
- const stats = await statfs('/').catch(() => null);
- if (!stats) return null;
- const total = stats.blocks * stats.bsize;
- if (!(total > 0)) return null;
- return Math.round(((total - stats.bavail * stats.bsize) / total) * 100);
+const libraryUrl = (path, query = null) => {
+ const url = new URL(path, resolveWorldHttpUrl());
+ for (const [key, value] of Object.entries(query || {})) url.searchParams.set(key, value);
+ return url;
+};
+
+async function fetchLibraryJson(path, query, { signal } = {}) {
+ const response = await waitWithSignal(fetch(libraryUrl(path, query), { signal }), signal).catch((error) => {
+ throw new ServerError(`Eidoverse asset library is unavailable: ${error.message}`, {
+ status: 503,
+ code: 'EIDOVERSE_ASSET_LIBRARY_UNAVAILABLE',
+ });
+ });
+ if (!response.ok) {
+ throw new ServerError(`Eidoverse asset library returned HTTP ${response.status}.`, {
+ status: 503,
+ code: 'EIDOVERSE_ASSET_LIBRARY_UNAVAILABLE',
+ });
+ }
+ return response.json();
}
-function appSummary(apps) {
- if (!Array.isArray(apps)) return null;
+async function preflightEidoverseProtocol({ signal } = {}) {
+ const response = await waitWithSignal(fetch(libraryUrl('/version'), { signal }), signal).catch((error) => {
+ throw new ServerError(`Eidoverse Worlds is not reachable yet: ${error.message}`, {
+ status: 503,
+ code: 'EIDOVERSE_WORLD_UNAVAILABLE',
+ });
+ });
+ if (response.status >= 500) {
+ throw new ServerError(`Eidoverse Worlds is temporarily unavailable (HTTP ${response.status}). Retry after its managed runtime finishes starting.`, {
+ status: 503,
+ code: 'EIDOVERSE_WORLD_UNAVAILABLE',
+ });
+ }
+ if (!response.ok) {
+ throw new ServerError('This Eidoverse Worlds runtime is too old for PortOS World Design V2. Update its managed app from Apps, then retry.', {
+ status: 409,
+ code: 'EIDOVERSE_PROTOCOL_INCOMPATIBLE',
+ context: { remediation: '/apps', required: 'world-design-v2' },
+ });
+ }
+ const version = await response.json().catch(() => null);
+ if (!version || typeof version.sha !== 'string' || typeof version.commitTime !== 'string') {
+ throw new ServerError('Eidoverse Worlds did not report the protocol build identity required by PortOS World Design V2. Update its managed app from Apps, then retry.', {
+ status: 409,
+ code: 'EIDOVERSE_PROTOCOL_INCOMPATIBLE',
+ context: { remediation: '/apps', required: 'world-design-v2' },
+ });
+ }
return {
- total: apps.length,
- online: apps.filter((app) => app.overallStatus === 'online').length,
- stopped: apps.filter((app) => app.overallStatus === 'stopped').length,
- notStarted: apps.filter((app) => app.overallStatus === 'not_started').length,
- unknown: apps.filter((app) => app.overallStatus === 'unknown').length,
+ sha: safeText(version.sha, 'unknown', 80),
+ commitTime: safeText(version.commitTime, 'unknown', 80),
};
}
-function finiteOrNull(value) {
- return typeof value === 'number' && Number.isFinite(value) ? value : null;
-}
-
-function nonNegativeOrNull(value) {
- const number = finiteOrNull(value);
- return number === null ? null : Math.max(0, number);
-}
-
-function percentageOrNull(value) {
- const number = finiteOrNull(value);
- return number === null ? null : Math.max(0, Math.min(100, number));
+async function verifyLibraryAsset(path, { signal } = {}) {
+ const encoded = path.split('/').map(encodeURIComponent).join('/');
+ const response = await waitWithSignal(fetch(libraryUrl(`/library/${encoded}`), {
+ method: 'HEAD',
+ signal,
+ }), signal).catch((error) => {
+ throw new ServerError(`Eidoverse could not verify ${path}: ${error.message}`, {
+ status: 409,
+ code: 'EIDOVERSE_ASSET_PREFLIGHT_FAILED',
+ });
+ });
+ if (!response.ok) {
+ throw new ServerError(`Eidoverse could not load the resolved library asset ${path}.`, {
+ status: 409,
+ code: 'EIDOVERSE_ASSET_PREFLIGHT_FAILED',
+ });
+ }
}
-function projectedProductivity(todayActivity, velocity, taskState) {
- if (!todayActivity && !velocity) return null;
- const stats = todayActivity?.stats || {};
- const queue = {
- pendingApprovals: Array.isArray(taskState?.awaitingApproval) ? taskState.awaitingApproval.length : null,
- pendingTasks: Array.isArray(taskState?.tasks)
- ? taskState.tasks.filter((task) => !['completed', 'done', 'archived'].includes(String(task?.status || '').toLowerCase())).length
- : null,
- };
- queue.total = [queue.pendingApprovals, queue.pendingTasks].every((value) => value !== null)
- ? queue.pendingApprovals + queue.pendingTasks
- : null;
- return [{
- id: 'summary',
- label: 'Productivity',
- completedToday: nonNegativeOrNull(stats.completed ?? velocity?.today),
- succeededToday: nonNegativeOrNull(stats.succeeded ?? velocity?.todaySuccesses),
- failedToday: nonNegativeOrNull(stats.failed ?? velocity?.todayFailures),
- successRate: percentageOrNull(stats.successRate),
- velocity: finiteOrNull(velocity?.velocity),
- velocityLabel: safeText(velocity?.velocityLabel, ''),
- averagePerDay: nonNegativeOrNull(velocity?.avgPerDay),
- historicalDays: nonNegativeOrNull(velocity?.historicalDays),
- queue,
- running: todayActivity?.isRunning === true,
- paused: todayActivity?.isPaused === true,
- }];
-}
-
-function projectedActivity(calendar) {
- if (!calendar || !Array.isArray(calendar.weeks)) return null;
- const days = calendar.weeks
- .flatMap((week) => Array.isArray(week) ? week : [])
- .filter((day) => day && typeof day === 'object' && day.isFuture !== true);
- const today = days.find((day) => day.isToday === true);
- const activeDays = days.filter((day) => (nonNegativeOrNull(day.tasks) || 0) > 0).slice(-99);
- const summary = calendar.summary || {};
- return [
- {
- id: 'summary',
- label: 'Activity calendar',
- weeks: calendar.weeks.length,
- activeDays: nonNegativeOrNull(summary.activeDays),
- totalTasks: nonNegativeOrNull(summary.totalTasks),
- totalSuccesses: nonNegativeOrNull(summary.totalSuccesses),
- successRate: percentageOrNull(summary.successRate),
- maxTasks: nonNegativeOrNull(calendar.maxTasks),
- todayTasks: nonNegativeOrNull(today?.tasks),
- },
- ...activeDays.map((day, index) => ({
- id: safeText(day.date, `day-${index}`),
- label: safeText(day.date, 'Activity day'),
- tasks: nonNegativeOrNull(day.tasks) ?? 0,
- successes: nonNegativeOrNull(day.successes) ?? 0,
- failures: nonNegativeOrNull(day.failures) ?? 0,
- successRate: percentageOrNull(day.successRate),
- isToday: day.isToday === true,
- })),
- ];
-}
-
-function projectedGoals(goalsData) {
- if (!Array.isArray(goalsData?.goals)) return null;
- const goals = goalsData.goals;
- const children = new Map(goals.map((goal) => [goal?.id, 0]));
- goals.forEach((goal) => {
- if (goal?.parentId && children.has(goal.parentId)) children.set(goal.parentId, children.get(goal.parentId) + 1);
- });
- return goals.map((goal, index) => {
- const milestones = Array.isArray(goal?.milestones) ? goal.milestones : [];
- const todos = Array.isArray(goal?.todos) ? goal.todos : [];
- return {
- id: safeText(goal?.id, `goal-${index}`),
- label: safeText(goal?.title, 'Goal'),
- status: safeText(goal?.status, 'active'),
- progress: percentageOrNull(goal?.progress) ?? 0,
- goalType: safeText(goal?.goalType, ''),
- targetDate: safeText(goal?.targetDate, ''),
- milestoneTotal: milestones.length,
- milestoneDone: milestones.filter((milestone) => milestone?.completed === true || Boolean(safeText(milestone?.completedAt, ''))).length,
- todoTotal: todos.length,
- todoPending: todos.filter((todo) => !['completed', 'done'].includes(String(todo?.status || '').toLowerCase()) && todo?.completed !== true).length,
- childCount: children.get(goal?.id) || 0,
+async function unavailableLibraryAssets(paths, { signal, verifiedPaths = new Set() } = {}) {
+ const candidates = [...new Set(paths)].filter((path) => path && !verifiedPaths.has(path));
+ const checks = await Promise.all(candidates.map((path) => (
+ verifyLibraryAsset(path, { signal }).then(
+ () => {
+ verifiedPaths.add(path);
+ return null;
+ },
+ () => {
+ throwIfAborted(signal);
+ return path;
+ },
+ )
+ )));
+ return new Set(checks.filter(Boolean));
+}
+
+async function persistAssetLock(resolutions, runtimeVersion) {
+ return mutateState((state) => {
+ state.assetResolutions = resolutions;
+ state.assetRecipeVersion = EIDOVERSE_ASSET_RECIPE_VERSION;
+ state.recipe = resolveEidoverseDesign(state.userOverrides, state.assetResolutions);
+ state.reconciliation = {
+ ...state.reconciliation,
+ status: 'applying',
+ checkpoint: 'asset-preflight-complete',
+ error: null,
+ errorCode: null,
+ errorContext: null,
+ startedAt: new Date().toISOString(),
+ completedAt: null,
+ planFingerprint: null,
+ operationCount: 0,
+ appliedOperations: 0,
+ compensationStatus: null,
+ runtimeVersion,
};
+ return configFromState(state);
});
}
-function projectedMemory(graph) {
- if (!graph || !Array.isArray(graph.nodes)) return null;
- const buckets = new Map();
- const categoryById = new Map();
- for (const node of graph.nodes) {
- const category = safeText(node?.category || node?.brainType, 'other').toLowerCase() || 'other';
- categoryById.set(node?.id, category);
- const bucket = buckets.get(category) || { count: 0, importance: 0 };
- bucket.count += 1;
- bucket.importance += Math.max(0, finiteOrNull(node?.importance) ?? 1);
- buckets.set(category, bucket);
- }
- const bridgeCounts = new Map();
- for (const edge of Array.isArray(graph.edges) ? graph.edges : []) {
- const from = categoryById.get(edge?.source);
- const to = categoryById.get(edge?.target);
- if (!from || !to || from === to) continue;
- bridgeCounts.set(from, (bridgeCounts.get(from) || 0) + 1);
- bridgeCounts.set(to, (bridgeCounts.get(to) || 0) + 1);
+async function resolveAndLockAssets(config, { signal } = {}) {
+ const runtimeVersion = await preflightEidoverseProtocol({ signal });
+ const existing = config.design?.assetResolutions || {};
+ const overrides = config.design?.userOverrides?.assets || {};
+ const explicitOverrides = Object.values(overrides);
+ if (explicitOverrides.some((path) => !isValidEidoverseAssetOverridePath(path))) {
+ throw new ServerError('A preserved Eidoverse asset override is invalid. Reset the affected asset slot, then retry.', {
+ status: 409,
+ code: 'EIDOVERSE_ASSET_OVERRIDE_INVALID',
+ });
}
- return [...buckets.entries()]
- .sort(([a, left], [b, right]) => right.count - left.count || a.localeCompare(b))
- .map(([category, bucket]) => ({
- id: category,
- label: `Memory ${category}`,
- category,
- count: bucket.count,
- importance: bucket.importance,
- bridgeCount: bridgeCounts.get(category) || 0,
- totalMemories: graph.nodes.length,
- totalEdges: Array.isArray(graph.edges) ? graph.edges.length : 0,
- hasEmbeddings: graph.hasEmbeddings === true,
- }));
-}
-
-function projectedStorage(introspection) {
- if (!introspection || typeof introspection !== 'object') return null;
- const items = [];
- const db = introspection.db;
- const fsSection = introspection.fs;
- const dbOnline = Array.isArray(db?.tables);
- items.push({
- id: 'database',
- label: 'PostgreSQL',
- area: 'database',
- status: db === null ? 'offline' : (dbOnline ? 'online' : 'unknown'),
- tableCount: dbOnline ? db.tables.length : null,
- sizeBytes: finiteOrNull(db?.sizeBytes),
- migrations: db?.migrations?.applied === undefined ? null : nonNegativeOrNull(db.migrations.applied),
- });
- if (dbOnline) {
- db.tables.forEach((table, index) => items.push({
- id: `db-table-${shortHash(table?.name || index)}`,
- label: safeText(table?.name, 'Database table'),
- area: 'database-table',
- status: 'online',
- rowEstimate: nonNegativeOrNull(table?.rowEstimate),
- sizeBytes: nonNegativeOrNull(table?.totalBytes),
- hasEmbedding: table?.hasEmbedding === true,
- }));
+ const lockInspection = inspectEidoverseAssetResolutionLocks({ existing, overrides });
+ const verifiedPaths = new Set();
+ const unavailablePaths = await unavailableLibraryAssets([
+ ...Object.values(lockInspection.resolutions).map(({ path }) => path),
+ ...explicitOverrides,
+ ], { signal, verifiedPaths });
+ const unavailableOverrideSlots = Object.entries(overrides)
+ .filter(([, path]) => unavailablePaths.has(path))
+ .map(([slot]) => slot);
+ if (unavailableOverrideSlots.length) {
+ throw new ServerError(`Eidoverse could not load the local asset override for: ${unavailableOverrideSlots.join(', ')}. Clear or replace the override, then retry.`, {
+ status: 409,
+ code: 'EIDOVERSE_ASSET_OVERRIDE_UNAVAILABLE',
+ context: { missing: unavailableOverrideSlots },
+ });
}
- const fsOnline = Array.isArray(fsSection?.domains);
- items.push({
- id: 'filesystem',
- label: 'PortOS data files',
- area: 'filesystem',
- status: fsSection === null ? 'offline' : (fsOnline ? 'online' : 'unknown'),
- domainCount: fsOnline ? fsSection.domains.length : null,
- sizeBytes: finiteOrNull(fsSection?.totalBytes),
- fileCount: nonNegativeOrNull(fsSection?.totalFiles),
- });
- if (fsOnline) {
- fsSection.domains.forEach((domain, index) => items.push({
- id: `data-domain-${shortHash(domain?.name || index)}`,
- label: safeText(domain?.name, 'Data domain'),
- area: 'data-domain',
- status: 'online',
- sizeBytes: nonNegativeOrNull(domain?.bytes),
- fileCount: nonNegativeOrNull(domain?.files),
- }));
+ if (lockInspection.current && unavailablePaths.size === 0) {
+ return persistAssetLock(lockInspection.resolutions, runtimeVersion);
}
- return items;
-}
-
-function projectedOperations({ cosStatus, review, backupState, notifications, character, healthMetrics, voiceConfig, memory, diskPercent, chronotype, inboxCounts }) {
- const values = [cosStatus, review, backupState, notifications, character, healthMetrics, voiceConfig, memory, diskPercent, chronotype, inboxCounts];
- if (!values.some((value) => value !== null && value !== undefined)) return null;
- return [{
- id: 'overview',
- label: 'PortOS operations',
- cos: cosStatus ? {
- running: cosStatus.running === true,
- paused: cosStatus.paused === true,
- activeAgents: nonNegativeOrNull(cosStatus.activeAgents),
- pausedAgents: nonNegativeOrNull(cosStatus.pausedAgents),
- provider: safeText(cosStatus.provider?.name, ''),
- } : null,
- ai: cosStatus ? {
- running: cosStatus.running === true,
- activeAgents: nonNegativeOrNull(cosStatus.activeAgents),
- provider: safeText(cosStatus.provider?.name, ''),
- } : null,
- review: review ? {
- total: nonNegativeOrNull(review.total),
- cos: nonNegativeOrNull(review.cos),
- alerts: nonNegativeOrNull(review.alert),
- } : null,
- backup: backupState ? {
- status: safeText(backupState.status, 'unknown'),
- lastRun: safeText(backupState.lastRun, ''),
- filesChanged: nonNegativeOrNull(backupState.filesChanged),
- } : null,
- notifications: notifications ? {
- total: nonNegativeOrNull(notifications.total),
- unread: nonNegativeOrNull(notifications.unread),
- } : null,
- inbox: inboxCounts ? {
- total: nonNegativeOrNull(inboxCounts.total),
- needsReview: nonNegativeOrNull(inboxCounts.needs_review),
- classifying: nonNegativeOrNull(inboxCounts.classifying),
- } : null,
- character: character ? {
- level: nonNegativeOrNull(character.level),
- } : null,
- chronotype: chronotype ? {
- type: safeText(chronotype.type, ''),
- confidence: percentageOrNull(typeof chronotype.confidence === 'number' ? chronotype.confidence * 100 : chronotype.confidence),
- peakFocusStart: safeText(chronotype.recommendations?.peakFocusStart, ''),
- peakFocusEnd: safeText(chronotype.recommendations?.peakFocusEnd, ''),
- sleepTime: safeText(chronotype.recommendations?.sleepTime, ''),
- } : null,
- voice: voiceConfig ? {
- enabled: voiceConfig.enabled === true,
- sttEngine: safeText(voiceConfig.stt?.engine, ''),
- ttsEngine: safeText(voiceConfig.tts?.engine, ''),
- } : null,
- memory: memory ? {
- usedPercent: memory.total > 0 ? Math.round((memory.used / memory.total) * 100) : null,
- source: safeText(memory.source, 'unknown'),
- } : null,
- healthMetrics: healthMetrics ?? null,
- diskPercent: percentageOrNull(diskPercent),
- }];
-}
-
-async function projectedJira(appConfig, featuresState) {
- if (!Array.isArray(featuresState?.features)) return null;
- const jiraFeature = featuresState.features.find((feature) => feature?.id === 'jira');
- if (!jiraFeature) return null;
- if (jiraFeature.enabled !== true) return [];
- if (!Array.isArray(appConfig)) return null;
- const specs = [...new Map(appConfig
- .filter((app) => app?.jira?.enabled && app.jira.instanceId && app.jira.projectKey)
- .map((app) => [`${app.jira.instanceId}/${app.jira.projectKey}`, {
- instanceId: app.jira.instanceId,
- projectKey: app.jira.projectKey,
- }]))
- .values()];
- if (specs.length === 0) return [];
- const batches = await Promise.all(specs.map((spec) => fetchMyCurrentSprintTickets(spec.instanceId, spec.projectKey)
- .then((tickets) => Array.isArray(tickets) ? { tickets, failed: false } : { tickets: [], failed: true })
- .catch(() => ({ tickets: [], failed: true }))));
- if (batches.some((batch) => batch.failed)) return null;
- const byKey = new Map();
- batches.flatMap((batch) => batch.tickets).forEach((ticket, index) => {
- if (!ticket?.key || byKey.has(ticket.key)) return;
- byKey.set(ticket.key, {
- id: safeText(ticket.key, `ticket-${index}`),
- label: safeText(ticket.summary, ticket.key),
- status: safeText(ticket.statusCategory || ticket.status, 'todo'),
- statusCategory: safeText(ticket.statusCategory, ''),
- priority: safeText(ticket.priority, ''),
- issueType: safeText(ticket.issueType, ''),
- storyPoints: nonNegativeOrNull(ticket.storyPoints),
+
+ const files = await fetchLibraryJson('/library-list', { dir: 'eidoverse/assets/models' }, { signal });
+ if (!Array.isArray(files)) {
+ throw new ServerError('Eidoverse returned an invalid model-library catalog.', {
+ status: 503,
+ code: 'EIDOVERSE_ASSET_LIBRARY_INVALID',
});
+ }
+
+ const recipe = config.recipe.assetRecipe;
+ const resolvedAt = new Date().toISOString();
+ const searchResults = {};
+ const resolveAvailable = () => resolveEidoverseAssetRecipe({
+ files: files.filter((candidate) => !unavailablePaths.has(candidate?.path || candidate)),
+ searchResults: Object.fromEntries(Object.entries(searchResults).map(([query, candidates]) => [
+ query,
+ candidates.filter((candidate) => !unavailablePaths.has(candidate?.path)),
+ ])),
+ existing: Object.fromEntries(Object.entries(existing).filter(([, lock]) => (
+ !unavailablePaths.has(typeof lock === 'string' ? lock : lock?.path)
+ ))),
+ overrides,
+ resolvedAt,
});
- return [...byKey.values()].sort((a, b) => a.id.localeCompare(b.id));
+ const unresolvedError = (missing) => new ServerError(`Eidoverse is missing required PortOS world assets: ${missing.join(', ')}.`, {
+ status: 409,
+ code: 'EIDOVERSE_ASSET_RECIPE_UNRESOLVED',
+ context: { missing, assetRecipeVersion: EIDOVERSE_ASSET_RECIPE_VERSION },
+ });
+ const resolveVerified = async (attempt = 0) => {
+ const result = resolveAvailable();
+ if (result.missing.length) {
+ const unresolvedSlots = result.missing.map((slotName) => recipe.slots[slotName]).filter(Boolean);
+ const queries = [...new Set(unresolvedSlots
+ .flatMap((slot) => slot.fallbackQueries)
+ .filter((query) => query && !Object.hasOwn(searchResults, query)))];
+ if (queries.length === 0) throw unresolvedError(result.missing);
+ const searchPairs = await Promise.all(queries.map(async (query) => {
+ const candidates = await fetchLibraryJson('/library-models', { q: query }, { signal });
+ if (!Array.isArray(candidates)) {
+ throw new ServerError('Eidoverse returned invalid model search results.', {
+ status: 503,
+ code: 'EIDOVERSE_ASSET_LIBRARY_INVALID',
+ });
+ }
+ return [query, candidates];
+ }));
+ Object.assign(searchResults, Object.fromEntries(searchPairs));
+ return resolveVerified(attempt + 1);
+ }
+ const failedPaths = await unavailableLibraryAssets(
+ Object.values(result.resolutions).map(({ path }) => path),
+ { signal, verifiedPaths },
+ );
+ if (failedPaths.size === 0) return result;
+ for (const path of failedPaths) unavailablePaths.add(path);
+ if (attempt >= files.length + Object.keys(recipe.slots).length) {
+ throw unresolvedError(Object.keys(recipe.slots));
+ }
+ return resolveVerified(attempt + 1);
+ };
+ const result = await resolveVerified();
+
+ return persistAssetLock(result.resolutions, runtimeVersion);
}
-function healthSnapshot({ apps, cosStatus, review, backupState, notifications, character, healthMetrics, voiceConfig, memory, diskPercent }) {
- const health = {
- apps: appSummary(apps),
- cos: cosStatus
- ? {
- running: cosStatus.running === true,
- activeAgents: nonNegativeOrNull(cosStatus.activeAgents),
- pausedAgents: nonNegativeOrNull(cosStatus.pausedAgents),
- }
- : null,
- review: review ? {
- total: nonNegativeOrNull(review.total),
- cos: nonNegativeOrNull(review.cos),
- alerts: nonNegativeOrNull(review.alert),
- } : null,
- backup: backupState ? {
- status: safeText(backupState.status, 'unknown'),
- lastRun: safeText(backupState.lastRun, ''),
- filesChanged: nonNegativeOrNull(backupState.filesChanged),
- } : null,
- memory: memory ? {
- usedPercent: memory.total > 0 ? Math.round((memory.used / memory.total) * 100) : null,
- source: safeText(memory.source, 'unknown'),
- } : null,
- notifications: notifications ? {
- total: nonNegativeOrNull(notifications.total),
- unread: nonNegativeOrNull(notifications.unread),
- } : null,
- character: character ? {
- level: nonNegativeOrNull(character.level),
- } : null,
- metrics: healthMetrics ?? null,
- voice: voiceConfig ? {
- enabled: voiceConfig.enabled === true,
- sttEngine: safeText(voiceConfig.stt?.engine, ''),
- ttsEngine: safeText(voiceConfig.tts?.engine, ''),
- } : null,
- diskPercent: percentageOrNull(diskPercent),
- };
- const available = [apps, cosStatus, review, backupState, notifications, character, healthMetrics, voiceConfig, memory, diskPercent]
- .some((value) => value !== null && value !== undefined);
- return available ? health : null;
+function createVerbPacing() {
+ return { lastVerbSentAt: null };
}
-async function collectProjectionSources({ signal } = {}) {
+async function sendPacedVerb(connection, verb, args, {
+ signal,
+ pacing = createVerbPacing(),
+} = {}) {
throwIfAborted(signal);
- const [apps, appConfig, agents, taskState, cosStatus, review, featuresState, peers, backupState, notifications, character, healthMetrics, voiceConfig, memory, diskPercent, todayActivity, velocity, activityCalendar, goalsData, chronotype, memoryGraph, inboxCounts, introspection] = await waitWithSignal(Promise.all([
- getAppStatuses().catch(() => null),
- getAllApps({ includeArchived: false }).catch(() => null),
- getAgents().catch(() => null),
- getCosTasks().catch(() => null),
- getCosStatus().catch(() => null),
- getPendingCounts().catch(() => null),
- getInstanceFeatures().catch(() => null),
- getPeers().catch(() => null),
- backup.getState().catch(() => null),
- getCountsByType().catch(() => null),
- getCharacter({ withSkills: false, withMetrics: false }).catch(() => null),
- getLatestMetricValues(HEALTH_METRIC_KEYS).catch(() => null),
- getVoiceConfig().catch(() => null),
- getMemoryStats().catch(() => null),
- getDiskUsagePercent(),
- getTodayActivity().catch(() => null),
- getVelocityMetrics().catch(() => null),
- getActivityCalendar(12).catch(() => null),
- getGoals().catch(() => null),
- getChronotype().catch(() => null),
- getBrainGraphOverview({ limit: 100 }).catch(() => null),
- getInboxLogCounts().catch(() => null),
- getOpenWorldIntrospection().catch(() => null),
- ]), signal);
-
- const projectedAgents = Array.isArray(agents)
- ? agents
- .filter((agent) => ['running', 'paused'].includes(agent?.status))
- .map((agent) => ({
- id: safeText(agent.id, 'agent'),
- status: safeText(agent.status, 'unknown'),
- taskId: safeText(agent.taskId, ''),
- phase: safeText(agent.metadata?.phase, ''),
- appName: safeText(agent.metadata?.taskAppName, ''),
- }))
- : null;
- const projectedTasks = Array.isArray(taskState?.tasks)
- ? taskState.tasks
- .filter((task) => !['completed', 'done', 'archived'].includes(String(task?.status || '').toLowerCase()))
- .map((task) => ({
- id: safeText(task.id, 'task'),
- label: safeText(task.title || task.name || task.description, 'CoS task').split('\n')[0].slice(0, 140),
- status: safeText(task.status, 'pending'),
- priority: safeText(task.priority, ''),
- type: safeText(task.type, ''),
- }))
- : null;
- const projectedFeatures = Array.isArray(featuresState?.features)
- ? featuresState.features.map((feature) => ({
- id: safeText(feature.id, 'feature'),
- label: safeText(feature.label || feature.id, 'Feature'),
- enabled: feature.enabled === true,
- }))
- : null;
- const projectedPeers = Array.isArray(peers)
- ? peers.map((peer) => ({
- id: safeText(peer.instanceId || peer.id, 'peer'),
- label: safeText(peer.name, 'Federated peer'),
- enabled: peer.enabled !== false,
- fullSync: peer.fullSync === true,
- status: safeText(peer.status, ''),
- }))
- : null;
-
- const health = healthSnapshot({ apps, cosStatus, review, backupState, notifications, character, healthMetrics, voiceConfig, memory, diskPercent });
- const jira = await waitWithSignal(projectedJira(appConfig, featuresState), signal);
+ if (pacing.lastVerbSentAt !== null) {
+ const wait = PROJECTION_VERB_INTERVAL_MS - (Date.now() - pacing.lastVerbSentAt);
+ if (wait > 0) await abortableDelay(wait, signal);
+ }
+ await connection.sendVerb(verb, args, { signal });
+ pacing.lastVerbSentAt = Date.now();
+}
+
+async function sendOperations(connection, operations, {
+ signal,
+ onApplied,
+ pacing = createVerbPacing(),
+} = {}) {
+ for (const operation of operations) {
+ await sendPacedVerb(connection, operation.verb, operation.args, { signal, pacing });
+ onApplied?.(operation);
+ }
+}
- return {
- apps: Array.isArray(apps)
- ? apps.map((app) => ({
- id: safeText(app.id, 'app'),
- label: safeText(app.name, 'Managed app'),
- status: safeText(app.overallStatus, 'unknown'),
- type: safeText(app.type, ''),
- managed: app.managed === true,
- }))
- : null,
- agents: projectedAgents,
- tasks: projectedTasks,
- features: projectedFeatures,
- peers: projectedPeers,
- health,
- productivity: projectedProductivity(todayActivity, velocity, taskState),
- activity: projectedActivity(activityCalendar),
- goals: projectedGoals(goalsData),
- memory: projectedMemory(memoryGraph),
- storage: projectedStorage(introspection),
- jira,
- operations: projectedOperations({ cosStatus, review, backupState, notifications, character, healthMetrics, voiceConfig, memory, diskPercent, chronotype, inboxCounts }),
- };
+async function recordReconciliationCheckpoint(patch) {
+ return mutateState((state) => {
+ state.reconciliation = { ...state.reconciliation, ...patch };
+ return clone(state.reconciliation);
+ });
}
-const PROJECTION_KINDS = [
- { kind: 'app', source: 'apps' },
- { kind: 'agent', source: 'agents' },
- { kind: 'task', source: 'tasks' },
- { kind: 'feature', source: 'features' },
- { kind: 'peer', source: 'peers' },
- { kind: 'health', source: 'health' },
- { kind: 'productivity', source: 'productivity' },
- { kind: 'activity', source: 'activity' },
- { kind: 'goal', source: 'goals' },
- { kind: 'memory', source: 'memory' },
- { kind: 'storage', source: 'storage' },
- { kind: 'jira', source: 'jira' },
- { kind: 'operations', source: 'operations' },
-];
-
-function sourceAvailable(source, key) {
- if (key === 'health') return source.health !== null && source.health !== undefined;
- return Array.isArray(source[key]);
-}
-
-function projectionEntityId(kind, sourceId, index) {
- return `${PROJECTION_ID_PREFIX}${kind}-${shortHash(`${kind}:${sourceId || index}`)}`;
-}
-
-function componentFor(kind, item) {
- if (kind === 'health') return { resource: 'health', label: 'PortOS health', ...item };
- const component = {
- resource: COMPONENT_RESOURCE_BY_KIND[kind] || kind,
- sourceId: safeText(item.id, 'unknown'),
- label: safeText(item.label, kind),
- };
- for (const [key, value] of Object.entries(item)) {
- if (key === 'id' || key === 'label' || value === undefined) continue;
- if (typeof value === 'string') component[key] = safeText(value, '', 300);
- else if (typeof value === 'number' && Number.isFinite(value)) component[key] = value;
- else if (typeof value === 'boolean') component[key] = value;
- else if (value !== null && typeof value === 'object') component[key] = clone(value);
+function restoreEntityOperations(id, entity) {
+ if (!entity) return [];
+ if (entity.kind === 'light') {
+ return [{ layer: 'compensation', verb: 'light', args: {
+ id,
+ pos: entity.pos,
+ color: entity.color,
+ intensity: entity.intensity,
+ range: entity.range,
+ ...(entity.keep ? { keep: true } : {}),
+ ...(entity.day === false ? { day: false } : {}),
+ } }];
}
- return component;
+ if (!entity.lib) return [];
+ const operations = [{ layer: 'compensation', verb: 'spawn', args: {
+ id,
+ lib: entity.lib,
+ pos: entity.pos || [0, 0, 0],
+ yaw: entity.yaw || 0,
+ ...(entity.scale !== undefined ? { scale: entity.scale } : {}),
+ ...(entity.collide ? { collide: entity.collide } : {}),
+ } }];
+ for (const [type, data] of Object.entries(entity.comp || {})) {
+ // Legacy PortOS components may predate the privacy-safe WorldSignal shape.
+ // They are visually inert, so do not duplicate their old contents into a
+ // new append-only log during rollback.
+ if (type === COMPONENT_TYPE && !(
+ data?.managedBy === 'portos'
+ && data?.designVersion === EIDOVERSE_WORLD_DESIGN_VERSION
+ && data?.disclosure === 'aggregate'
+ )) continue;
+ operations.push({ layer: 'compensation', verb: 'comp', args: { id, type, data } });
+ }
+ return operations;
}
-function entityPosition(index, kind, recipe) {
- const { origin, spacing, laneGap, columns } = recipe.layout;
- const lane = PROJECTION_KINDS.findIndex((entry) => entry.kind === kind);
- const column = index % columns;
- const row = Math.floor(index / columns);
- return [
- origin[0] + column * spacing,
- origin[1],
- origin[2] + lane * laneGap + row * spacing,
- ];
+function inverseOperationsFor(operation, currentState) {
+ const id = operation.args?.id;
+ const existing = id ? currentState?.entities?.[id] : null;
+ switch (operation.verb) {
+ case 'spawn':
+ return [{ layer: 'compensation', verb: 'remove', args: { id } }];
+ case 'place':
+ return existing ? [{ layer: 'compensation', verb: 'place', args: {
+ id,
+ pos: existing.pos || [0, 0, 0],
+ yaw: existing.yaw || 0,
+ ...(existing.scale !== undefined ? { scale: existing.scale } : {}),
+ } }] : [];
+ case 'comp':
+ return [{ layer: 'compensation', verb: 'comp', args: {
+ id,
+ type: operation.args.type,
+ data: existing?.comp?.[operation.args.type] ?? null,
+ } }];
+ case 'remove':
+ return restoreEntityOperations(id, existing);
+ case 'light':
+ return existing
+ ? restoreEntityOperations(id, existing)
+ : [{ layer: 'compensation', verb: 'remove', args: { id } }];
+ case 'terrain':
+ return [{
+ layer: 'compensation',
+ verb: 'terrain',
+ args: currentState?.terrain || {
+ seed: 'portos-legacy-neutral', size: 128, segments: 2, amplitude: 0, flatRadius: 128,
+ layers: [{ color: '#142338', repeat: 1 }],
+ },
+ }];
+ case 'sky':
+ return [{
+ layer: 'compensation',
+ verb: 'sky',
+ args: currentState?.sky || {
+ system: 'skymesh', hours: 0, azimuth: 0, sun: 0, ambient: 0.15,
+ fill: 0, exposure: 0.35, fog: 1, clouds: 'none', weather: 'clear',
+ },
+ }];
+ case 'grass':
+ return [{
+ layer: 'compensation',
+ verb: 'grass',
+ args: currentState?.grass || { clear: true },
+ }];
+ default:
+ return [];
+ }
}
-function equal(valueA, valueB) {
- return stableStringify(valueA) === stableStringify(valueB);
+function compensationOperations(applied, currentState) {
+ return [...applied].reverse().flatMap((operation) => inverseOperationsFor(operation, currentState));
}
-/**
- * Build the deterministic world operations without opening a socket. This is
- * intentionally exported so recipe changes can be tested without a live
- * Eidoverse process and so future renderers can reuse the same projection.
- */
-export function buildProjectionPlan({ source = {}, recipe = DEFAULT_EIDOVERSE_PROJECTION_RECIPE, currentState = {} }) {
- const effectiveRecipe = mergeRecipe(recipe);
- const stateEntities = currentState?.entities && typeof currentState.entities === 'object'
- ? currentState.entities
- : {};
- const operations = [];
- const desiredIds = new Set();
- const removableKinds = new Set();
- const sourceAvailability = {};
- let created = 0;
- let updated = 0;
- let removed = 0;
-
- sourceAvailability.terrain = currentState?.terrain !== undefined;
- if (!equal(currentState?.terrain || null, effectiveRecipe.terrain)) {
- operations.push({ verb: 'terrain', args: effectiveRecipe.terrain });
- }
-
- for (const { kind, source: sourceKey } of PROJECTION_KINDS) {
- const available = sourceAvailable(source, sourceKey);
- sourceAvailability[sourceKey] = available;
- if (!effectiveRecipe.includes[`${sourceKey}`]) {
- removableKinds.add(kind);
- continue;
- }
- if (!available) continue;
- removableKinds.add(kind);
- const values = kind === 'health' ? [source.health] : source[sourceKey];
- const limited = values.slice(0, effectiveRecipe.limits[sourceKey] ?? values.length);
- limited.forEach((item, index) => {
- const sourceId = kind === 'health' ? 'health' : item.id || `${kind}-${index}`;
- const id = projectionEntityId(kind, sourceId, index);
- const pos = entityPosition(index, kind, effectiveRecipe);
- const spawn = {
- id,
- lib: effectiveRecipe.assets[kind],
- pos,
- yaw: 0,
- scale: effectiveRecipe.scale[kind],
- collide: 'box',
- };
- const existing = stateEntities[id];
- desiredIds.add(id);
- if (!existing || existing.lib !== spawn.lib) {
- if (existing) {
- operations.push({ verb: 'remove', args: { id } });
- removed += 1;
- }
- operations.push({ verb: 'spawn', args: spawn });
- created += 1;
- } else if (!equal({ pos: existing.pos, yaw: existing.yaw, scale: existing.scale }, {
- pos: spawn.pos,
- yaw: spawn.yaw,
- scale: spawn.scale,
- })) {
- operations.push({ verb: 'place', args: { id, pos, yaw: 0, scale: spawn.scale } });
- updated += 1;
- }
- const component = componentFor(kind, item);
- if (!equal(existing?.comp?.[COMPONENT_TYPE], component)) {
- operations.push({ verb: 'comp', args: { id, type: COMPONENT_TYPE, data: component } });
- if (existing) updated += 1;
- }
- });
- }
+// Infrastructure and live signals are proven first so a V2 update cannot make
+// its new atmosphere authoritative without the semantic world beneath it. The
+// ambient layer follows the environment, then reconciliation retires only old
+// PortOS-managed ids.
+const PROJECTION_UPDATE_STAGES = ['infrastructure', 'live', 'environment', 'ambient', 'reconciliation'];
+const PROJECTION_FRESH_STAGES = ['environment', 'infrastructure', 'live', 'ambient', 'reconciliation'];
- for (const id of Object.keys(stateEntities)) {
- if (!id.startsWith(PROJECTION_ID_PREFIX) || desiredIds.has(id)) continue;
- const kind = id.slice(PROJECTION_ID_PREFIX.length).split('-')[0];
- if (!removableKinds.has(kind)) continue;
- operations.push({ verb: 'remove', args: { id } });
- removed += 1;
- }
+async function applyProjectionPlan(presence, plan, { signal, freshInstall = false } = {}) {
+ const applied = [];
+ const planFingerprint = shortHash(canonicalStringify({
+ designVersion: EIDOVERSE_WORLD_DESIGN_VERSION,
+ operations: plan.operations,
+ }));
+ await recordReconciliationCheckpoint({
+ status: 'applying',
+ checkpoint: 'plan-ready',
+ planFingerprint,
+ operationCount: plan.operations.length,
+ appliedOperations: 0,
+ compensationStatus: null,
+ });
- return {
- operations,
- summary: {
- created,
- updated,
- removed,
- operationCount: operations.length,
- sourceAvailability,
- sourceCounts: Object.fromEntries(PROJECTION_KINDS.map(({ kind, source: sourceKey }) => [
- sourceKey,
- sourceAvailable(source, sourceKey)
- ? (kind === 'health' ? 1 : source[sourceKey].length)
- : null,
- ])),
- },
- };
+ const stageOrder = freshInstall ? PROJECTION_FRESH_STAGES : PROJECTION_UPDATE_STAGES;
+ const stages = stageOrder.map((stage) => [
+ stage,
+ plan.operations.filter((operation) => operation.layer === stage),
+ ]);
+ const pacing = presence.pacing || createVerbPacing();
+ const execution = stages.reduce((promise, [stage, operations]) => promise.then(async () => {
+ if (operations.length === 0) return;
+ await recordReconciliationCheckpoint({ checkpoint: `applying-${stage}` });
+ await sendOperations(presence.connection, operations, {
+ signal,
+ pacing,
+ onApplied: (operation) => applied.push(operation),
+ });
+ await recordReconciliationCheckpoint({
+ checkpoint: `${stage}-complete`,
+ appliedOperations: applied.length,
+ });
+ }), Promise.resolve());
+
+ return execution.catch(async (error) => {
+ const rollback = compensationOperations(applied, presence.snapshot?.state || {});
+ await recordReconciliationCheckpoint({
+ status: 'compensating',
+ checkpoint: 'compensation-started',
+ appliedOperations: applied.length,
+ compensationStatus: 'running',
+ });
+ const compensation = ensureCosPresenceInternal({ fresh: true })
+ .then((recoveryPresence) => sendOperations(recoveryPresence.connection, rollback, {
+ pacing: recoveryPresence.pacing,
+ }));
+ return compensation.then(
+ async () => {
+ await recordReconciliationCheckpoint({
+ status: 'pending',
+ checkpoint: 'compensation-complete',
+ compensationStatus: 'complete',
+ });
+ error.compensationStatus = 'complete';
+ throw error;
+ },
+ async (compensationError) => {
+ await recordReconciliationCheckpoint({
+ status: 'pending',
+ checkpoint: 'compensation-failed',
+ compensationStatus: 'failed',
+ compensationError: safeText(compensationError?.message, 'Rollback failed', 500),
+ });
+ error.compensationStatus = 'failed';
+ error.compensationError = compensationError;
+ throw error;
+ },
+ );
+ });
}
-async function sendOperations(connection, operations, { signal } = {}) {
- for (let index = 0; index < operations.length; index += 1) {
- throwIfAborted(signal);
- if (index > 0) await abortableDelay(PROJECTION_VERB_INTERVAL_MS, signal);
- const operation = operations[index];
- await connection.sendVerb(operation.verb, operation.args, { signal });
+function reconciliationErrorContext(error) {
+ if (['EIDOVERSE_ASSET_RECIPE_UNRESOLVED', 'EIDOVERSE_ASSET_OVERRIDE_UNAVAILABLE'].includes(error?.code)) {
+ return {
+ missing: Array.isArray(error.context?.missing)
+ ? error.context.missing.filter((slot) => typeof slot === 'string').slice(0, 16)
+ : [],
+ assetRecipeVersion: EIDOVERSE_ASSET_RECIPE_VERSION,
+ };
+ }
+ if (error?.code === 'EIDOVERSE_PROTOCOL_INCOMPATIBLE') {
+ return { remediation: '/apps', required: 'world-design-v2' };
}
+ return null;
}
async function recordProjection({ success, summary = null, error = null }) {
@@ -1614,6 +1563,34 @@ async function recordProjection({ success, summary = null, error = null }) {
lastError: safeText(error?.message || error, 'Projection failed', 500),
}),
};
+ if (success) {
+ state.lastAppliedDesignVersion = EIDOVERSE_WORLD_DESIGN_VERSION;
+ state.pendingDesignVersion = null;
+ state.reconciliation = {
+ ...state.reconciliation,
+ status: 'complete',
+ checkpoint: 'projection-committed',
+ error: null,
+ errorCode: null,
+ errorContext: null,
+ completedAt: now,
+ appliedOperations: summary?.operationCount ?? state.reconciliation.operationCount,
+ compensationStatus: null,
+ compensationError: null,
+ };
+ if (state.migrationReport?.status === 'ready') state.migrationReport.status = 'applied';
+ } else {
+ state.pendingDesignVersion = EIDOVERSE_WORLD_DESIGN_VERSION;
+ state.reconciliation = {
+ ...state.reconciliation,
+ status: 'failed',
+ checkpoint: state.reconciliation?.checkpoint || 'projection-started',
+ error: safeText(error?.message || error, 'Projection failed', 500),
+ errorCode: safeText(error?.code, '', 80) || null,
+ errorContext: reconciliationErrorContext(error),
+ completedAt: now,
+ };
+ }
return state.projection;
});
}
@@ -1623,26 +1600,35 @@ export async function projectEidoverseWorld({ signal } = {}) {
throwIfAborted(signal);
await assertInstalled();
const config = await ensureEidoverseWorldConfig();
+ const lockedConfig = await resolveAndLockAssets(config, { signal });
const presence = await ensureCosPresenceInternal({ fresh: true, signal });
- const source = await collectProjectionSources({ signal });
+ const source = await collectEidoverseWorldSources({ signal });
throwIfAborted(signal);
const plan = buildProjectionPlan({
source,
- recipe: config.recipe,
+ recipe: lockedConfig.recipe,
currentState: presence.snapshot?.state || {},
});
- await sendOperations(presence.connection, plan.operations, { signal });
+ await applyProjectionPlan(presence, plan, {
+ signal,
+ freshInstall: lockedConfig.design.lastAppliedVersion === null,
+ });
const summary = {
- world: config.world,
- cosId: config.cos.id,
+ world: lockedConfig.world,
+ cosId: lockedConfig.cos.id,
+ assetRecipeVersion: lockedConfig.design.assetRecipeVersion,
+ assetCatalogFingerprint: Object.values(lockedConfig.design.assetResolutions)[0]?.catalogFingerprint || null,
...plan.summary,
};
const projection = await recordProjection({ success: true, summary });
+ const appliedConfig = configFromState(await loadState());
return {
success: true,
summary,
projection,
presence: presenceSummary(presence),
+ design: appliedConfig.design,
+ recipe: appliedConfig.recipe,
};
};
@@ -1652,6 +1638,53 @@ export async function projectEidoverseWorld({ signal } = {}) {
}));
}
+/**
+ * Boot-time, non-AI reconciliation for an update-prepared design. It never
+ * starts or installs the external runtime: if Eidoverse is not already online,
+ * the pending checkpoint remains visible for the page to remediate later.
+ */
+async function recoverInterruptedProjection() {
+ const state = await loadState();
+ if (!['applying', 'compensating'].includes(state.reconciliation.status)) {
+ return { recovered: false, state };
+ }
+ const interruptedStatus = state.reconciliation.status;
+ const recoveredState = await mutateState((current) => {
+ current.pendingDesignVersion = EIDOVERSE_WORLD_DESIGN_VERSION;
+ current.reconciliation = {
+ ...current.reconciliation,
+ status: 'failed',
+ checkpoint: 'interrupted',
+ error: 'The previous Eidoverse projection stopped before it completed. Retry the world update.',
+ errorCode: 'EIDOVERSE_PROJECTION_INTERRUPTED',
+ errorContext: null,
+ completedAt: new Date().toISOString(),
+ ...(interruptedStatus === 'compensating' ? { compensationStatus: 'interrupted' } : {}),
+ };
+ return clone(current);
+ });
+ return { recovered: true, state: recoveredState };
+}
+
+export async function reconcilePendingEidoverseWorld() {
+ const recovery = await recoverInterruptedProjection();
+ if (recovery.recovered) return { reconciled: false, reason: 'interrupted' };
+ const { features } = await getInstanceFeatures();
+ if (features.find(({ id }) => id === 'eidoverse')?.enabled !== true) {
+ return { reconciled: false, reason: 'feature-disabled' };
+ }
+ const setup = await getEidoverseStatus();
+ const state = recovery.state;
+ if (state.pendingDesignVersion !== EIDOVERSE_WORLD_DESIGN_VERSION
+ || (state.lastAppliedDesignVersion === null && !state.migrationReport)) {
+ return { reconciled: false, reason: 'current' };
+ }
+ if (!setup.installed) return { reconciled: false, reason: 'not-installed' };
+ if (setup.runtimeStatus !== 'online') return { reconciled: false, reason: 'runtime-offline' };
+ const result = await projectEidoverseWorld();
+ return { reconciled: true, result };
+}
+
function objectArgs(args) {
if (!args || typeof args !== 'object' || Array.isArray(args)) {
throw new ServerError('Eidoverse operation args must be an object.', {
@@ -1760,7 +1793,7 @@ export async function augmentEidoverseWorld(operations, { signal } = {}) {
await assertInstalled();
const presence = await ensureCosPresenceInternal({ signal });
const normalized = operations.map(normalizeAugmentOperation);
- await sendOperations(presence.connection, normalized, { signal });
+ await sendOperations(presence.connection, normalized, { signal, pacing: presence.pacing });
return {
success: true,
world: presence.connection.world,
@@ -1782,7 +1815,10 @@ export async function sayInEidoverseWorld(text, { signal } = {}) {
code: 'EIDOVERSE_ARGUMENT_INVALID',
});
}
- await presence.connection.sendVerb('say', { text: message }, { signal });
+ await sendPacedVerb(presence.connection, 'say', { text: message }, {
+ signal,
+ pacing: presence.pacing,
+ });
return { success: true, world: presence.connection.world, id: presence.connection.id };
});
}
@@ -1814,6 +1850,19 @@ export async function getEidoverseWorldStatus() {
};
}
+export async function getEidoverseWorldProjectionStatus() {
+ const state = await loadState();
+ return {
+ design: {
+ selectedVersion: state.selectedDesignVersion,
+ lastAppliedVersion: state.lastAppliedDesignVersion,
+ pendingVersion: state.pendingDesignVersion,
+ reconciliation: clone(state.reconciliation),
+ },
+ projection: clone(state.projection),
+ };
+}
+
export async function closeEidoverseWorldConnections() {
return worldLock(() => closeCosPresenceInternal());
}
diff --git a/server/services/eidoverseWorld.runtime.test.js b/server/services/eidoverseWorld.runtime.test.js
index a16d25e3c..4640bd17f 100644
--- a/server/services/eidoverseWorld.runtime.test.js
+++ b/server/services/eidoverseWorld.runtime.test.js
@@ -4,22 +4,39 @@ const mocks = vi.hoisted(() => ({
persistedState: null,
writes: 0,
worlds: new Map(),
+ socketUrls: [],
sent: [],
deferredVerbAcks: [],
deferVerbAcks: 0,
+ rejectVerb: null,
+ rejectedVerb: false,
appStatuses: [],
appStatusReads: 0,
+ featureEnabled: true,
+ eidoverseStatus: {
+ installed: true,
+ runtimeStatus: 'online',
+ appId: 'app-eidoverse',
+ worldDataReady: true,
+ },
self: { instanceId: 'instance-example', name: 'Example PortOS' },
+ worldStatePath: '/mock/data/eidoverse/portos-world.json',
}));
+const DEFAULT_HUMAN_NAME = 'portos-8e9b660b05fb';
+
vi.mock('../lib/fileUtils.js', async (importActual) => {
const actual = await importActual();
+ const isWorldStatePath = (path) => String(path).replaceAll('\\', '/') === mocks.worldStatePath;
return {
...actual,
dataPath: (...segments) => `/mock/data/${segments.join('/')}`,
ensureDir: vi.fn(async () => {}),
- readJSONFile: vi.fn(async (_path, fallback) => structuredClone(mocks.persistedState ?? fallback)),
- atomicWrite: vi.fn(async (_path, value) => {
+ readJSONFile: vi.fn(async (path, fallback) => structuredClone(
+ isWorldStatePath(path) ? (mocks.persistedState ?? fallback) : fallback,
+ )),
+ atomicWrite: vi.fn(async (path, value) => {
+ if (!isWorldStatePath(path)) return;
mocks.persistedState = structuredClone(value);
mocks.writes += 1;
}),
@@ -42,11 +59,12 @@ vi.mock('./apps.js', () => ({
vi.mock('./eidoverse.js', () => ({
EIDOVERSE_PORT: 8940,
- getEidoverseStatus: vi.fn(async () => ({
- installed: true,
- runtimeStatus: 'online',
- appId: 'app-eidoverse',
- worldDataReady: true,
+ getEidoverseStatus: vi.fn(async () => structuredClone(mocks.eidoverseStatus)),
+}));
+
+vi.mock('./instanceFeatures.js', () => ({
+ getInstanceFeatures: vi.fn(async () => ({
+ features: [{ id: 'eidoverse', enabled: mocks.featureEnabled }],
})),
}));
@@ -55,11 +73,12 @@ vi.mock('ws', () => {
static OPEN = 1;
static CLOSED = 3;
- constructor() {
+ constructor(url) {
this.readyState = 0;
this.listeners = new Map();
this.identity = null;
this.world = null;
+ mocks.socketUrls.push(String(url));
queueMicrotask(() => {
this.readyState = FakeWebSocket.OPEN;
this.emit('open');
@@ -112,8 +131,17 @@ vi.mock('ws', () => {
actor: this.identity,
verb: message.verb,
args: structuredClone(message.args),
+ sentAt: Date.now(),
});
queueMicrotask(() => callback?.(null));
+ if (message.verb === mocks.rejectVerb && !mocks.rejectedVerb) {
+ mocks.rejectedVerb = true;
+ queueMicrotask(() => this.emit('message', JSON.stringify({
+ type: 'error',
+ error: 'synthetic projection rejection',
+ })));
+ return;
+ }
const acknowledge = () => {
if (this.readyState !== FakeWebSocket.OPEN) return;
this.emit('message', JSON.stringify({
@@ -145,17 +173,42 @@ vi.mock('ws', () => {
});
const world = await import('./eidoverseWorld.js');
+const { EIDOVERSE_WORLD_DESIGN_V1 } = await import('../lib/eidoverseWorldDesign.js');
beforeEach(async () => {
+ vi.unstubAllEnvs();
await world.__resetEidoverseWorldForTests();
mocks.persistedState = null;
mocks.writes = 0;
mocks.worlds.clear();
+ mocks.socketUrls.length = 0;
mocks.sent.length = 0;
mocks.deferredVerbAcks.length = 0;
mocks.deferVerbAcks = 0;
+ mocks.rejectVerb = null;
+ mocks.rejectedVerb = false;
mocks.appStatuses = [];
mocks.appStatusReads = 0;
+ mocks.featureEnabled = true;
+ mocks.eidoverseStatus = {
+ installed: true,
+ runtimeStatus: 'online',
+ appId: 'app-eidoverse',
+ worldDataReady: true,
+ };
+ const libraryFiles = Object.values(world.DEFAULT_EIDOVERSE_PROJECTION_RECIPE.assetRecipe.slots)
+ .flatMap((slot) => [
+ { path: slot.preferredPaths[0], size: Math.min(slot.maxBytes, 1_000_000) },
+ { path: slot.fallback, size: 1_000_000 },
+ ]);
+ vi.stubGlobal('fetch', vi.fn(async (input, init) => {
+ const url = new URL(String(input));
+ if (url.pathname === '/version') return Response.json({ sha: 'example-build', commitTime: '2026-01-01T00:00:00.000Z' });
+ if (url.pathname === '/library-list') return Response.json(libraryFiles);
+ if (url.pathname === '/library-models') return Response.json([]);
+ if (url.pathname.startsWith('/library/')) return new Response(init?.method === 'HEAD' ? null : '', { status: 200 });
+ return new Response(null, { status: 404 });
+ }));
});
describe('Eidoverse private-world lifecycle', () => {
@@ -164,21 +217,140 @@ describe('Eidoverse private-world lifecycle', () => {
expect(status).toMatchObject({
world: 'portos',
- identity: { name: 'Example PortOS', source: 'instance-name' },
+ identity: { name: DEFAULT_HUMAN_NAME, source: 'instance-id' },
presence: { connected: false },
});
expect(mocks.writes).toBe(0);
expect(mocks.persistedState).toBeNull();
});
+ it('does not cold-project a fresh install at boot even when the runtime is online', async () => {
+ await world.ensureEidoverseWorldConfig();
+
+ await expect(world.reconcilePendingEidoverseWorld()).resolves.toEqual({
+ reconciled: false,
+ reason: 'current',
+ });
+ expect(fetch).not.toHaveBeenCalled();
+ expect(mocks.sent).toEqual([]);
+ expect(mocks.persistedState.pendingDesignVersion).toBe(2);
+ });
+
+ it('reconciles a migrated V1 world through the normal online boot path', async () => {
+ mocks.persistedState = {
+ schemaVersion: 1,
+ world: 'portos',
+ recipe: EIDOVERSE_WORLD_DESIGN_V1,
+ };
+
+ const result = await world.reconcilePendingEidoverseWorld();
+
+ expect(result.reconciled).toBe(true);
+ expect(mocks.sent).toEqual(expect.arrayContaining([
+ expect.objectContaining({ verb: 'terrain' }),
+ expect.objectContaining({
+ verb: 'spawn',
+ args: expect.objectContaining({ id: expect.stringMatching(/^portos-design-v2-/) }),
+ }),
+ ]));
+ expect(mocks.persistedState).toMatchObject({
+ schemaVersion: 2,
+ lastAppliedDesignVersion: 2,
+ pendingDesignVersion: null,
+ migrationReport: { status: 'applied' },
+ reconciliation: { status: 'complete', checkpoint: 'projection-committed' },
+ });
+ });
+
+ it('reads projection progress without runtime, app-registry, or library probes', async () => {
+ await world.ensureEidoverseWorldConfig();
+ fetch.mockClear();
+ mocks.appStatusReads = 0;
+
+ const progress = await world.getEidoverseWorldProjectionStatus();
+
+ expect(progress).toMatchObject({
+ design: { selectedVersion: 2, pendingVersion: 2 },
+ projection: { lastRunAt: null },
+ });
+ expect(mocks.appStatusReads).toBe(0);
+ expect(fetch).not.toHaveBeenCalled();
+ });
+
+ it('leaves a pending upgrade untouched when the Eidoverse feature is disabled', async () => {
+ await world.ensureEidoverseWorldConfig();
+ mocks.persistedState.lastAppliedDesignVersion = 1;
+ mocks.persistedState.migrationReport = {
+ status: 'ready',
+ fromDesignVersion: 1,
+ toDesignVersion: 2,
+ };
+ mocks.featureEnabled = false;
+
+ await expect(world.reconcilePendingEidoverseWorld()).resolves.toEqual({
+ reconciled: false,
+ reason: 'feature-disabled',
+ });
+ expect(fetch).not.toHaveBeenCalled();
+ expect(mocks.sent).toEqual([]);
+ expect(mocks.persistedState.pendingDesignVersion).toBe(2);
+ });
+
+ it('leaves a prepared boot-time design update pending without starting an offline runtime', async () => {
+ await world.ensureEidoverseWorldConfig();
+ mocks.persistedState.lastAppliedDesignVersion = 1;
+ mocks.persistedState.migrationReport = {
+ status: 'ready',
+ fromDesignVersion: 1,
+ toDesignVersion: 2,
+ };
+ mocks.eidoverseStatus.runtimeStatus = 'stopped';
+
+ await expect(world.reconcilePendingEidoverseWorld()).resolves.toEqual({
+ reconciled: false,
+ reason: 'runtime-offline',
+ });
+ expect(fetch).not.toHaveBeenCalled();
+ expect(mocks.sent).toEqual([]);
+ expect(mocks.persistedState.pendingDesignVersion).toBe(2);
+ });
+
+ it('marks a persisted in-flight projection as interrupted before boot reconciliation', async () => {
+ await world.ensureEidoverseWorldConfig();
+ mocks.persistedState.reconciliation = {
+ ...mocks.persistedState.reconciliation,
+ status: 'applying',
+ checkpoint: 'applying-live',
+ operationCount: 20,
+ appliedOperations: 5,
+ };
+ fetch.mockClear();
+
+ await expect(world.reconcilePendingEidoverseWorld()).resolves.toEqual({
+ reconciled: false,
+ reason: 'interrupted',
+ });
+ expect(mocks.persistedState.reconciliation).toMatchObject({
+ status: 'failed',
+ checkpoint: 'interrupted',
+ errorCode: 'EIDOVERSE_PROJECTION_INTERRUPTED',
+ operationCount: 20,
+ appliedOperations: 5,
+ });
+ expect(mocks.persistedState.pendingDesignVersion).toBe(2);
+ expect(fetch).not.toHaveBeenCalled();
+ expect(mocks.sent).toEqual([]);
+ });
+
it('gives the human and persistent CoS owner roles and hands ownership to a renamed human', async () => {
const first = await world.ensureEidoverseWorldPresence();
expect(first).toMatchObject({ connected: true, id: 'portos-cos', role: 'owner' });
expect(mocks.worlds.get('portos').roles).toMatchObject({
- 'Example PortOS': { role: 'owner' },
+ [DEFAULT_HUMAN_NAME]: { role: 'owner' },
'portos-cos': { role: 'owner' },
});
+ expect(JSON.stringify(mocks.sent)).not.toContain(mocks.self.name);
await world.updateEidoverseWorldConfig({ humanName: 'Second Example User' });
expect(await world.getEidoverseWorldStatus()).toMatchObject({
@@ -189,7 +361,7 @@ describe('Eidoverse private-world lifecycle', () => {
expect(reconnected.role).toBe('owner');
expect(mocks.worlds.get('portos').roles['Second Example User']).toMatchObject({ role: 'owner' });
- expect(mocks.worlds.get('portos').roles['Example PortOS']).toMatchObject({ role: 'visitor' });
+ expect(mocks.worlds.get('portos').roles[DEFAULT_HUMAN_NAME]).toMatchObject({ role: 'visitor' });
expect(mocks.sent).toContainEqual(expect.objectContaining({
actor: 'portos-cos',
verb: 'grant',
@@ -198,7 +370,7 @@ describe('Eidoverse private-world lifecycle', () => {
expect(mocks.sent).toContainEqual(expect.objectContaining({
actor: 'portos-cos',
verb: 'grant',
- args: { id: 'Example PortOS', role: 'visitor' },
+ args: { id: DEFAULT_HUMAN_NAME, role: 'visitor' },
}));
expect(mocks.persistedState.ownership.retired).toEqual([]);
});
@@ -215,6 +387,48 @@ describe('Eidoverse private-world lifecycle', () => {
});
});
+ it('migrates a stale V1 recipe submitted after the update instead of pinning V1 defaults', async () => {
+ const updated = await world.updateEidoverseWorldConfig({ recipe: EIDOVERSE_WORLD_DESIGN_V1 });
+
+ expect(updated.design.userOverrides).toEqual({});
+ expect(updated.recipe).toMatchObject({
+ version: 2,
+ limits: {
+ apps: world.DEFAULT_EIDOVERSE_PROJECTION_RECIPE.limits.apps,
+ tasks: world.DEFAULT_EIDOVERSE_PROJECTION_RECIPE.limits.tasks,
+ },
+ environment: {
+ terrain: {
+ size: world.DEFAULT_EIDOVERSE_PROJECTION_RECIPE.environment.terrain.size,
+ segments: world.DEFAULT_EIDOVERSE_PROJECTION_RECIPE.environment.terrain.segments,
+ },
+ },
+ });
+ });
+
+ it('persists every writable field accepted in a V2 recipe submission', async () => {
+ const recipe = structuredClone(world.DEFAULT_EIDOVERSE_PROJECTION_RECIPE);
+ recipe.name = 'Example Systems Garden';
+ recipe.maxEntities = 12;
+ recipe.districts[0].label = 'Example Nexus';
+ recipe.paths[0].label = 'Example Route';
+
+ const updated = await world.updateEidoverseWorldConfig({ recipe });
+
+ expect(updated.design.userOverrides).toMatchObject({
+ name: 'Example Systems Garden',
+ maxEntities: 12,
+ });
+ expect(updated.design.userOverrides.districts[0].label).toBe('Example Nexus');
+ expect(updated.design.userOverrides.paths[0].label).toBe('Example Route');
+ expect(updated.recipe).toMatchObject({
+ name: 'Example Systems Garden',
+ maxEntities: 12,
+ });
+ expect(updated.recipe.districts[0].label).toBe('Example Nexus');
+ expect(updated.recipe.paths[0].label).toBe('Example Route');
+ });
+
it('retires the prior human owner when the world and human identity change together', async () => {
await world.ensureEidoverseWorldPresence();
await world.updateEidoverseWorldConfig({
@@ -223,7 +437,7 @@ describe('Eidoverse private-world lifecycle', () => {
});
await world.ensureEidoverseWorldPresence();
- expect(mocks.worlds.get('portos').roles['Example PortOS']).toMatchObject({ role: 'visitor' });
+ expect(mocks.worlds.get('portos').roles[DEFAULT_HUMAN_NAME]).toMatchObject({ role: 'visitor' });
expect(mocks.worlds.get('second-world').roles).toMatchObject({
'Second Example User': { role: 'owner' },
'portos-cos': { role: 'owner' },
@@ -232,11 +446,131 @@ describe('Eidoverse private-world lifecycle', () => {
world: 'portos',
actor: 'portos-cos',
verb: 'grant',
- args: { id: 'Example PortOS', role: 'visitor' },
+ args: { id: DEFAULT_HUMAN_NAME, role: 'visitor' },
}));
expect(mocks.persistedState.ownership.retired).toEqual([]);
});
+ it('continues projection when a prior-world actor cannot retire an old owner', async () => {
+ await world.ensureEidoverseWorldConfig();
+ mocks.persistedState.ownership.retired = [{
+ world: 'example-prior-world',
+ id: 'Example Retired Owner',
+ actorId: 'example-retired-actor',
+ actorAvatar: 'eidoverse/assets/vrms/example-retired-actor.vrm',
+ }];
+ mocks.worlds.set('example-prior-world', {
+ roles: {
+ 'example-existing-owner': { role: 'owner' },
+ 'example-retired-actor': { role: 'visitor' },
+ },
+ });
+
+ const result = await world.projectEidoverseWorld();
+
+ expect(result.success).toBe(true);
+ expect(mocks.persistedState.ownership.retired).toEqual([expect.objectContaining({
+ world: 'example-prior-world',
+ id: 'Example Retired Owner',
+ attempts: 1,
+ })]);
+ expect(mocks.persistedState.reconciliation).toMatchObject({
+ status: 'complete',
+ retiredOwnerCleanup: {
+ status: 'partial',
+ failedCount: 1,
+ retryingCount: 1,
+ droppedCount: 0,
+ codes: ['EIDOVERSE_OWNER_HANDOFF_FAILED'],
+ },
+ });
+ expect(mocks.sent).toContainEqual(expect.objectContaining({ verb: 'terrain' }));
+
+ await world.projectEidoverseWorld();
+ expect(mocks.persistedState.ownership.retired[0]).toMatchObject({ attempts: 2 });
+
+ await world.projectEidoverseWorld();
+ expect(mocks.persistedState.ownership.retired).toEqual([]);
+ expect(mocks.persistedState.reconciliation.retiredOwnerCleanup).toMatchObject({
+ failedCount: 1,
+ retryingCount: 0,
+ droppedCount: 1,
+ });
+
+ await world.projectEidoverseWorld();
+ expect(mocks.persistedState.reconciliation.retiredOwnerCleanup).toBeUndefined();
+ });
+
+ it('retries only a retired owner whose demotion verb failed', async () => {
+ await world.ensureEidoverseWorldConfig();
+ mocks.persistedState.ownership.retired = [
+ { world: 'portos', id: 'Example Failed Owner' },
+ { world: 'portos', id: 'Example Successful Owner' },
+ ];
+ mocks.worlds.set('portos', {
+ roles: {
+ [DEFAULT_HUMAN_NAME]: { role: 'owner' },
+ 'portos-cos': { role: 'owner' },
+ 'Example Failed Owner': { role: 'owner' },
+ 'Example Successful Owner': { role: 'owner' },
+ },
+ });
+ mocks.rejectVerb = 'grant';
+
+ await expect(world.projectEidoverseWorld()).resolves.toMatchObject({ success: true });
+
+ expect(mocks.persistedState.ownership.retired).toEqual([expect.objectContaining({
+ world: 'portos',
+ id: 'Example Failed Owner',
+ attempts: 1,
+ })]);
+ expect(mocks.persistedState.reconciliation.retiredOwnerCleanup).toMatchObject({
+ failedCount: 1,
+ retryingCount: 1,
+ droppedCount: 0,
+ codes: ['EIDOVERSE_WORLD_VERB_REJECTED'],
+ });
+
+ await expect(world.projectEidoverseWorld()).resolves.toMatchObject({ success: true });
+ expect(mocks.persistedState.ownership.retired).toEqual([]);
+ expect(mocks.persistedState.reconciliation.retiredOwnerCleanup).toBeUndefined();
+ });
+
+ it('makes the full reset a recovery for retired ownership and cached roles', async () => {
+ await world.ensureEidoverseWorldPresence();
+ mocks.persistedState.ownership.retired = [{
+ world: 'example-prior-world',
+ id: 'Example Retired Owner',
+ }];
+ mocks.persistedState.human.role = 'owner';
+ mocks.persistedState.cos.role = 'owner';
+ mocks.persistedState.reconciliation.retiredOwnerCleanup = { status: 'partial', failedCount: 1 };
+ mocks.persistedState.migrationReport = {
+ status: 'ready',
+ retiredOwnerCleanup: { status: 'partial', failedCount: 1 },
+ };
+
+ const reset = await world.updateEidoverseWorldConfig({
+ world: 'example-reset-world',
+ humanName: 'Example Reset Human',
+ reset: { scope: 'all' },
+ });
+ const status = await world.getEidoverseWorldStatus();
+
+ expect(reset).toMatchObject({
+ human: { role: null },
+ cos: { role: null },
+ design: {
+ migrationReport: { status: 'ready' },
+ reconciliation: { status: 'pending', checkpoint: 'configuration-saved' },
+ },
+ });
+ expect(status.presence).toMatchObject({ connected: false, role: null });
+ expect(reset.design.migrationReport.retiredOwnerCleanup).toBeUndefined();
+ expect(reset.design.reconciliation.retiredOwnerCleanup).toBeUndefined();
+ expect(mocks.persistedState.ownership.retired).toEqual([]);
+ });
+
it('closes the persistent connection when the CoS presence is disabled', async () => {
await world.ensureEidoverseWorldPresence();
await world.updateEidoverseWorldConfig({ cosEnabled: false });
@@ -248,6 +582,7 @@ describe('Eidoverse private-world lifecycle', () => {
it('stops a multi-operation augmentation before sending the next verb when canceled', async () => {
await world.ensureEidoverseWorldPresence();
mocks.sent.length = 0;
+ mocks.deferVerbAcks = 1;
const controller = new AbortController();
const pending = world.augmentEidoverseWorld([
{ verb: 'spawn', args: { id: 'example-one', lib: 'eidoverse/assets/models/example.glb' } },
@@ -300,4 +635,299 @@ describe('Eidoverse private-world lifecycle', () => {
await expect(pending).rejects.toMatchObject({ name: 'AbortError' });
resolveAppStatuses([]);
});
+
+ it('preflights and locks recipe assets before completing a V2 reconciliation', async () => {
+ const result = await world.projectEidoverseWorld();
+
+ const expectedSlots = ['nexus', 'app', 'agent', 'task', 'goal', 'memory', 'storage', 'peer', 'activity', 'district'];
+ expect(Object.keys(result.design.assetResolutions)).toEqual(expectedSlots);
+ expect(Object.keys(mocks.persistedState.assetResolutions)).toEqual(expectedSlots);
+ expect(fetch.mock.calls.filter(([input]) => String(input).includes('/library/eidoverse/'))).toHaveLength(10);
+ expect(result.summary.assetCatalogFingerprint).toEqual(expect.any(String));
+
+ expect(result).toMatchObject({
+ success: true,
+ summary: { designVersion: 2, maxLiveEntities: 48, assetRecipeVersion: 2 },
+ design: {
+ selectedVersion: 2,
+ lastAppliedVersion: 2,
+ pendingVersion: null,
+ reconciliation: { status: 'complete', checkpoint: 'projection-committed' },
+ },
+ });
+ expect(Object.keys(result.design.assetResolutions)).toHaveLength(10);
+ expect(mocks.persistedState).toMatchObject({
+ schemaVersion: 2,
+ lastAppliedDesignVersion: 2,
+ pendingDesignVersion: null,
+ reconciliation: { status: 'complete' },
+ });
+ expect(mocks.sent).toEqual(expect.arrayContaining([
+ expect.objectContaining({ verb: 'sky', args: expect.objectContaining({ system: 'skymesh' }) }),
+ expect.objectContaining({ verb: 'light', args: expect.objectContaining({ id: 'portos-design-v2-light-nexus' }) }),
+ ]));
+ const authoredVerbs = mocks.sent.filter(({ type, verb }) => type === 'verb' && verb !== 'grant');
+ expect(authoredVerbs[0].verb).toBe('terrain');
+ expect(fetch.mock.calls.filter(([input]) => String(input).includes('/library-models')).length).toBe(0);
+
+ fetch.mockClear();
+ await world.projectEidoverseWorld();
+ expect(fetch.mock.calls.filter(([input]) => String(input).includes('/library-list'))).toHaveLength(0);
+ expect(fetch.mock.calls.filter(([input]) => String(input).includes('/library-models'))).toHaveLength(0);
+ expect(fetch.mock.calls.filter(([input]) => String(input).includes('/library/eidoverse/'))).toHaveLength(10);
+
+ await expect(world.reconcilePendingEidoverseWorld()).resolves.toEqual({ reconciled: false, reason: 'current' });
+ });
+
+ it('uses one configured runtime origin for WebSocket projection and HTTP asset preflight', async () => {
+ vi.stubEnv('EIDOVERSE_WS_URL', 'wss://example-eidoverse.test:9443/custom/ws');
+
+ await world.projectEidoverseWorld();
+
+ expect(mocks.socketUrls.length).toBeGreaterThan(0);
+ expect(mocks.socketUrls.every((url) => url === 'wss://example-eidoverse.test:9443/custom/ws')).toBe(true);
+ expect(fetch.mock.calls.length).toBeGreaterThan(0);
+ expect(fetch.mock.calls.every(([input]) => (
+ new URL(String(input)).origin === 'https://example-eidoverse.test:9443'
+ ))).toBe(true);
+ });
+
+ it('keeps the previous atmosphere authoritative until V2 infrastructure exists during an upgrade', async () => {
+ await world.ensureEidoverseWorldConfig();
+ mocks.persistedState.lastAppliedDesignVersion = 1;
+ mocks.persistedState.pendingDesignVersion = 2;
+
+ await world.projectEidoverseWorld();
+
+ const authoredVerbs = mocks.sent.filter(({ type, verb }) => type === 'verb' && verb !== 'grant');
+ const firstInfrastructure = authoredVerbs.findIndex(({ verb }) => verb === 'spawn');
+ const firstEnvironment = authoredVerbs.findIndex(({ verb }) => verb === 'terrain');
+ expect(firstInfrastructure).toBeGreaterThanOrEqual(0);
+ expect(firstEnvironment).toBeGreaterThan(firstInfrastructure);
+ expect(authoredVerbs[firstEnvironment].sentAt - authoredVerbs[firstEnvironment - 1].sentAt).toBeGreaterThanOrEqual(4);
+ });
+
+ it('shares verb pacing between ownership retirement and the projection burst', async () => {
+ await world.ensureEidoverseWorldConfig();
+ mocks.persistedState.ownership.retired = [
+ { world: 'portos', id: 'Example Retired Owner A' },
+ { world: 'portos', id: 'Example Retired Owner B' },
+ ];
+
+ await world.projectEidoverseWorld();
+
+ const cosVerbs = mocks.sent.filter(({ type, actor }) => type === 'verb' && actor === 'portos-cos');
+ expect(cosVerbs.slice(0, 2).map(({ verb }) => verb)).toEqual(['grant', 'grant']);
+ expect(cosVerbs.some(({ verb }) => verb !== 'grant')).toBe(true);
+ for (let index = 1; index < cosVerbs.length; index += 1) {
+ expect(cosVerbs[index].sentAt - cosVerbs[index - 1].sentAt).toBeGreaterThanOrEqual(4);
+ }
+ });
+
+ it('excludes a catalog-listed asset whose bytes disappeared and locks a verified fallback', async () => {
+ const slots = world.DEFAULT_EIDOVERSE_PROJECTION_RECIPE.assetRecipe.slots;
+ const appPreferred = slots.app.preferredPaths[0];
+ const libraryFiles = Object.values(slots).flatMap((slot) => [
+ { path: slot.preferredPaths[0], size: Math.min(slot.maxBytes, 1_000_000) },
+ { path: slot.fallback, size: 1_000_000 },
+ ]);
+ fetch.mockImplementation(async (input, init) => {
+ const url = new URL(String(input));
+ if (url.pathname === '/version') return Response.json({ sha: 'example-build', commitTime: '2026-01-01T00:00:00.000Z' });
+ if (url.pathname === '/library-list') return Response.json(libraryFiles);
+ if (url.pathname === '/library-models') return Response.json([]);
+ if (url.pathname === `/library/${appPreferred}` && init?.method === 'HEAD') return new Response(null, { status: 404 });
+ if (url.pathname.startsWith('/library/')) return new Response(null, { status: 200 });
+ return new Response(null, { status: 404 });
+ });
+
+ const result = await world.projectEidoverseWorld();
+
+ expect(result.design.assetResolutions.app).toMatchObject({
+ path: slots.app.fallback,
+ strategy: 'fallback',
+ });
+ expect(fetch.mock.calls.some(([input]) => String(input).includes('/library-models'))).toBe(true);
+ });
+
+ it('writes only opaque resource keys and generic labels to the Eidoverse log', async () => {
+ mocks.appStatuses = [{
+ id: 'example-private-app-record',
+ name: 'Example confidential application',
+ overallStatus: 'online',
+ managed: true,
+ }];
+
+ await world.projectEidoverseWorld();
+
+ const serializedLog = JSON.stringify(mocks.sent.filter(({ type }) => type === 'verb'));
+ expect(serializedLog).toContain('"kind":"app"');
+ expect(serializedLog).toContain('"label":"Managed app"');
+ expect(serializedLog).toContain('"count":1');
+ expect(serializedLog).not.toMatch(/example-private-app-record|Example confidential application/);
+ });
+
+ it('fails before world mutation when the Eidoverse protocol identity is unavailable', async () => {
+ fetch.mockResolvedValueOnce(new Response(null, { status: 404 }));
+
+ await expect(world.projectEidoverseWorld()).rejects.toMatchObject({ code: 'EIDOVERSE_PROTOCOL_INCOMPATIBLE' });
+ expect(mocks.sent.some(({ type }) => type === 'verb')).toBe(false);
+ expect(mocks.persistedState).toMatchObject({
+ lastAppliedDesignVersion: null,
+ pendingDesignVersion: 2,
+ reconciliation: { status: 'failed' },
+ });
+ });
+
+ it('reports a transiently unreachable runtime without false update remediation', async () => {
+ fetch.mockRejectedValueOnce(new Error('Example startup race'));
+
+ await expect(world.projectEidoverseWorld()).rejects.toMatchObject({
+ status: 503,
+ code: 'EIDOVERSE_WORLD_UNAVAILABLE',
+ });
+ expect(mocks.sent.some(({ type }) => type === 'verb')).toBe(false);
+ expect(mocks.persistedState).toMatchObject({
+ pendingDesignVersion: 2,
+ reconciliation: {
+ status: 'failed',
+ errorCode: 'EIDOVERSE_WORLD_UNAVAILABLE',
+ errorContext: null,
+ },
+ });
+ });
+
+ it('preserves the prior design when required asset resolution fails', async () => {
+ await world.ensureEidoverseWorldConfig();
+ mocks.persistedState.lastAppliedDesignVersion = 1;
+ fetch.mockImplementation(async (input) => {
+ const url = new URL(String(input));
+ if (url.pathname === '/version') return Response.json({ sha: 'example-build', commitTime: '2026-01-01T00:00:00.000Z' });
+ if (url.pathname === '/library-list' || url.pathname === '/library-models') return Response.json([]);
+ return new Response(null, { status: 404 });
+ });
+
+ const error = await world.projectEidoverseWorld().then(() => null, (reason) => reason);
+ expect(error).toMatchObject({ code: 'EIDOVERSE_ASSET_RECIPE_UNRESOLVED' });
+ expect(error.message).toMatch(/nexus.*app.*agent/);
+ expect(mocks.sent.some(({ type }) => type === 'verb')).toBe(false);
+ expect(mocks.persistedState).toMatchObject({
+ lastAppliedDesignVersion: 1,
+ pendingDesignVersion: 2,
+ reconciliation: {
+ status: 'failed',
+ errorCode: 'EIDOVERSE_ASSET_RECIPE_UNRESOLVED',
+ errorContext: { missing: expect.arrayContaining(['nexus', 'app', 'agent']) },
+ },
+ });
+ });
+
+ it('verifies and records an explicit install-local store override', async () => {
+ await world.updateEidoverseWorldConfig({ assetOverrides: { app: 'store/example-local-asset' } });
+
+ const result = await world.projectEidoverseWorld();
+
+ expect(result.design.assetResolutions.app).toMatchObject({
+ path: 'store/example-local-asset',
+ userOverride: true,
+ shippedDefault: false,
+ });
+ expect(fetch.mock.calls.some(([input]) => String(input).includes('/library/store/example-local-asset'))).toBe(true);
+ });
+
+ it('resets only the selected district asset lock and override', async () => {
+ await world.projectEidoverseWorld();
+ await world.updateEidoverseWorldConfig({
+ assetOverrides: {
+ app: 'store/example-local-asset',
+ operations: 'store/example-legacy-operations',
+ district: 'store/example-path-marker',
+ },
+ });
+
+ const reset = await world.updateEidoverseWorldConfig({ reset: { scope: 'district', districtId: 'apps' } });
+
+ expect(reset.design.userOverrides.assets?.app).toBeUndefined();
+ expect(reset.design.assetResolutions.app).toBeUndefined();
+ expect(reset.design.assetResolutions.agent).toBeTruthy();
+ expect(reset.design.pendingVersion).toBe(2);
+
+ const nexusReset = await world.updateEidoverseWorldConfig({ reset: { scope: 'district', districtId: 'nexus' } });
+ expect(nexusReset.design.userOverrides.assets?.operations).toBeUndefined();
+ expect(nexusReset.design.userOverrides.assets?.district).toBeUndefined();
+ });
+
+ it('resets the effective sources and semantic assets for a custom district', async () => {
+ const recipe = structuredClone(world.DEFAULT_EIDOVERSE_PROJECTION_RECIPE);
+ recipe.districts = recipe.districts.map((district) => district.id === 'apps'
+ ? { ...district, id: 'example-yard', label: 'Example Yard', sources: ['goals'] }
+ : district);
+ recipe.includes.goals = false;
+ recipe.limits.goals = 1;
+ recipe.scale.goal = 2.5;
+ await world.updateEidoverseWorldConfig({
+ recipe,
+ assetOverrides: {
+ goal: 'store/example-local-goal',
+ district: 'store/example-local-district',
+ },
+ });
+
+ const reset = await world.updateEidoverseWorldConfig({
+ reset: { scope: 'district', districtId: 'example-yard' },
+ });
+
+ expect(reset.recipe.districts).toContainEqual(expect.objectContaining({
+ id: 'example-yard',
+ sources: ['goals'],
+ }));
+ expect(reset.recipe.includes.goals).toBe(world.DEFAULT_EIDOVERSE_PROJECTION_RECIPE.includes.goals);
+ expect(reset.recipe.limits.goals).toBe(world.DEFAULT_EIDOVERSE_PROJECTION_RECIPE.limits.goals);
+ expect(reset.recipe.scale.goal).toBe(world.DEFAULT_EIDOVERSE_PROJECTION_RECIPE.scale.goal);
+ expect(reset.design.userOverrides.assets?.goal).toBeUndefined();
+ expect(reset.design.userOverrides.assets?.district).toBeUndefined();
+
+ await expect(world.updateEidoverseWorldConfig({
+ reset: { scope: 'district', districtId: 'missing-yard' },
+ })).rejects.toMatchObject({ status: 400, code: 'EIDOVERSE_DISTRICT_NOT_FOUND' });
+ });
+
+ it('compensates partially applied V2 entities and leaves the prior design authoritative', async () => {
+ mocks.rejectVerb = 'comp';
+
+ await expect(world.projectEidoverseWorld()).rejects.toMatchObject({
+ code: 'EIDOVERSE_WORLD_VERB_REJECTED',
+ compensationStatus: 'complete',
+ });
+
+ const firstSpawn = mocks.sent.find((entry) => entry.verb === 'spawn' && entry.args.id.startsWith('portos-design-v2-'));
+ expect(firstSpawn).toBeTruthy();
+ expect(mocks.sent).toContainEqual(expect.objectContaining({
+ verb: 'remove',
+ args: { id: firstSpawn.args.id },
+ }));
+ expect(mocks.persistedState).toMatchObject({
+ lastAppliedDesignVersion: null,
+ pendingDesignVersion: 2,
+ reconciliation: {
+ status: 'failed',
+ checkpoint: 'compensation-complete',
+ compensationStatus: 'complete',
+ },
+ });
+ });
+
+ it('mows a newly applied field when a later environment operation fails', async () => {
+ mocks.rejectVerb = 'light';
+
+ await expect(world.projectEidoverseWorld()).rejects.toMatchObject({
+ code: 'EIDOVERSE_WORLD_VERB_REJECTED',
+ compensationStatus: 'complete',
+ });
+
+ expect(mocks.sent).toContainEqual(expect.objectContaining({
+ verb: 'grass',
+ args: { clear: true },
+ }));
+ });
});
diff --git a/server/services/eidoverseWorld.test.js b/server/services/eidoverseWorld.test.js
index 71165f85b..24bde42ee 100644
--- a/server/services/eidoverseWorld.test.js
+++ b/server/services/eidoverseWorld.test.js
@@ -2,23 +2,16 @@ import { describe, expect, it } from 'vitest';
import {
buildProjectionPlan,
DEFAULT_EIDOVERSE_PROJECTION_RECIPE,
+ projectedJiraTickets,
+ projectedStorage,
} from './eidoverseWorld.js';
import { eidoverseProjectionRecipeSchema } from '../lib/validation.js';
+const APP_FALLBACK = DEFAULT_EIDOVERSE_PROJECTION_RECIPE.assetRecipe.slots.app.fallback;
+
const emptySources = () => ({
- apps: [],
- agents: [],
- tasks: [],
- features: [],
- peers: [],
- health: null,
- productivity: [],
- activity: [],
- goals: [],
- memory: [],
- storage: [],
- jira: [],
- operations: [],
+ apps: [], agents: [], tasks: [], features: [], peers: [], health: null,
+ productivity: [], activity: [], goals: [], memory: [], storage: [], jira: [], operations: [],
});
const appSource = () => ({
@@ -27,116 +20,731 @@ const appSource = () => ({
health: { apps: { total: 1, online: 1 } },
});
+const currentEnvironment = (recipe = DEFAULT_EIDOVERSE_PROJECTION_RECIPE) => ({
+ terrain: recipe.environment.terrain,
+ sky: recipe.environment.sky,
+ grass: recipe.environment.grass,
+});
+
+const signalSpawn = (plan, kind) => plan.operations.find((operation) => (
+ operation.verb === 'spawn' && operation.args.id.startsWith(`portos-design-v2-signal-${kind}-`)
+));
+
+const snapshotFromPlan = (plan, { foldModelDefaults = false } = {}) => {
+ const state = { entities: {} };
+ for (const { verb, args } of plan.operations) {
+ if (['terrain', 'sky', 'grass'].includes(verb)) {
+ state[verb] = structuredClone(args);
+ } else if (verb === 'light') {
+ state.entities[args.id] = { kind: 'light', ...structuredClone(args) };
+ if (state.entities[args.id].day === true) delete state.entities[args.id].day;
+ } else if (verb === 'spawn') {
+ state.entities[args.id] = structuredClone(args);
+ if (foldModelDefaults && state.entities[args.id].yaw === 0) delete state.entities[args.id].yaw;
+ if (foldModelDefaults && state.entities[args.id].scale === 1) delete state.entities[args.id].scale;
+ } else if (verb === 'place') {
+ Object.assign(state.entities[args.id], structuredClone(args));
+ } else if (verb === 'comp') {
+ state.entities[args.id].comp ||= {};
+ state.entities[args.id].comp[args.type] = structuredClone(args.data);
+ } else if (verb === 'remove') {
+ delete state.entities[args.id];
+ }
+ }
+ return state;
+};
+
describe('Eidoverse PortOS projection plan', () => {
- it('keeps the shipped recipe valid at the route schema boundary', () => {
+ it('keeps the shipped V2 recipe valid at the route schema boundary', () => {
expect(eidoverseProjectionRecipeSchema.parse(DEFAULT_EIDOVERSE_PROJECTION_RECIPE)).toEqual(
DEFAULT_EIDOVERSE_PROJECTION_RECIPE,
);
});
- it('creates stable model and metadata operations for available sources', () => {
+ it('creates stable environment, district, signal, and metadata operations', () => {
const first = buildProjectionPlan({ source: appSource() });
const second = buildProjectionPlan({ source: appSource() });
expect(second).toEqual(first);
- expect(first.summary.sourceAvailability).toMatchObject({ apps: true, agents: true, health: true });
+ expect(first.summary).toMatchObject({
+ designVersion: 2,
+ liveEntityCount: 2,
+ infrastructureCount: 29,
+ sourceAvailability: { apps: true, agents: true, health: true, environment: true },
+ });
expect(first.operations).toEqual(expect.arrayContaining([
expect.objectContaining({ verb: 'terrain' }),
- expect.objectContaining({ verb: 'spawn', args: expect.objectContaining({ lib: DEFAULT_EIDOVERSE_PROJECTION_RECIPE.assets.app }) }),
- expect.objectContaining({ verb: 'comp', args: expect.objectContaining({ type: 'portos' }) }),
+ expect.objectContaining({ verb: 'sky', args: expect.objectContaining({ system: 'skymesh', hours: 7.2 }) }),
+ expect.objectContaining({ verb: 'grass' }),
+ expect.objectContaining({ verb: 'light', args: expect.objectContaining({ id: 'portos-design-v2-light-nexus' }) }),
+ expect.objectContaining({ verb: 'spawn', args: expect.objectContaining({ id: expect.stringContaining('signal-app-'), lib: APP_FALLBACK }) }),
+ expect.objectContaining({ verb: 'comp', args: expect.objectContaining({ type: 'portos', data: expect.objectContaining({ districtId: 'apps' }) }) }),
]));
- expect(first.summary.created).toBe(2);
+ expect(first.operations.some(({ args }) => /car|vehicle/i.test(args?.lib || ''))).toBe(false);
+ expect(first.operations).toContainEqual(expect.objectContaining({
+ layer: 'ambient',
+ verb: 'comp',
+ args: expect.objectContaining({ type: 'motion' }),
+ }));
});
- it('treats a zero limit as intentional and does not fall back to the source length', () => {
+ it('uses the install-local materialized asset lock in projection operations', () => {
+ const lockedApp = 'eidoverse/assets/models/example_locked_app.glb';
const recipe = {
...DEFAULT_EIDOVERSE_PROJECTION_RECIPE,
- limits: { ...DEFAULT_EIDOVERSE_PROJECTION_RECIPE.limits, apps: 0 },
+ assets: { ...DEFAULT_EIDOVERSE_PROJECTION_RECIPE.assets, app: lockedApp },
+ };
+ const plan = buildProjectionPlan({
+ source: appSource(),
+ recipe,
+ currentState: currentEnvironment(recipe),
+ });
+
+ expect(signalSpawn(plan, 'app').args.lib).toBe(lockedApp);
+ });
+
+ it('uses semantic slots for district landmarks and paths instead of a retired feature asset', () => {
+ const legacyFeature = 'store/example-legacy-feature';
+ const appLandmark = 'store/example-app-landmark';
+ const pathMarker = 'store/example-path-marker';
+ const recipe = {
+ ...DEFAULT_EIDOVERSE_PROJECTION_RECIPE,
+ assets: {
+ ...DEFAULT_EIDOVERSE_PROJECTION_RECIPE.assets,
+ feature: legacyFeature,
+ app: appLandmark,
+ district: pathMarker,
+ },
+ };
+ const plan = buildProjectionPlan({ source: emptySources(), recipe });
+ const appDistrict = plan.operations.find((operation) => (
+ operation.verb === 'spawn' && operation.args.id === 'portos-design-v2-infra-apps'
+ ));
+ const pathNode = plan.operations.find((operation) => (
+ operation.verb === 'spawn' && operation.args.id.startsWith('portos-design-v2-path-')
+ ));
+
+ expect(appDistrict.args.lib).toBe(appLandmark);
+ expect(pathNode.args.lib).toBe(pathMarker);
+ expect(plan.operations.filter(({ verb }) => verb === 'spawn').map(({ args }) => args.lib)).not.toContain(legacyFeature);
+ });
+
+ it('restores semantic components after an asset change respawns a model', () => {
+ const id = 'portos-design-v2-infra-agents';
+ const initial = buildProjectionPlan({ source: emptySources() });
+ const spawn = initial.operations.find((operation) => operation.verb === 'spawn' && operation.args.id === id);
+ const portos = initial.operations.find((operation) => (
+ operation.verb === 'comp' && operation.args.id === id && operation.args.type === 'portos'
+ ));
+ const motion = initial.operations.find((operation) => (
+ operation.verb === 'comp' && operation.args.id === id && operation.args.type === 'motion'
+ ));
+ const replacement = 'eidoverse/assets/models/example_locked_agent.glb';
+ const recipe = {
+ ...DEFAULT_EIDOVERSE_PROJECTION_RECIPE,
+ assets: { ...DEFAULT_EIDOVERSE_PROJECTION_RECIPE.assets, agent: replacement },
};
+ const plan = buildProjectionPlan({
+ source: emptySources(),
+ recipe,
+ currentState: {
+ ...currentEnvironment(recipe),
+ entities: {
+ [id]: {
+ ...spawn.args,
+ comp: { portos: portos.args.data, motion: motion.args.data },
+ },
+ },
+ },
+ });
+
+ expect(plan.operations).toEqual(expect.arrayContaining([
+ expect.objectContaining({ verb: 'remove', args: { id } }),
+ expect.objectContaining({ verb: 'spawn', args: expect.objectContaining({ id, lib: replacement }) }),
+ expect.objectContaining({ verb: 'comp', args: { id, type: 'portos', data: portos.args.data } }),
+ expect.objectContaining({ verb: 'comp', args: { id, type: 'motion', data: motion.args.data } }),
+ ]));
+ });
+
+ it('recognizes Eidoverse folded light defaults as already applied', () => {
+ const entities = Object.fromEntries(DEFAULT_EIDOVERSE_PROJECTION_RECIPE.environment.lights.map((light) => [
+ light.id,
+ {
+ kind: 'light',
+ pos: light.pos,
+ color: light.color,
+ intensity: light.intensity,
+ range: light.range,
+ keep: light.keep,
+ // The runtime omits `day` when its protocol-default value is true.
+ },
+ ]));
+ const plan = buildProjectionPlan({
+ source: emptySources(),
+ currentState: { ...currentEnvironment(), entities },
+ });
+
+ expect(plan.operations.filter(({ verb }) => verb === 'light')).toEqual([]);
+ });
+
+ it('converges after the runtime folds default model yaw and light fields', () => {
+ const first = buildProjectionPlan({ source: emptySources() });
+ const currentState = snapshotFromPlan(first, { foldModelDefaults: true });
+ const second = buildProjectionPlan({ source: emptySources(), currentState });
+
+ expect(second.operations).toEqual([]);
+ });
+
+ it('drives landmarks and signal placement from persisted district overrides', () => {
+ const recipe = structuredClone(DEFAULT_EIDOVERSE_PROJECTION_RECIPE);
+ const apps = recipe.districts.find(({ id }) => id === 'apps');
+ apps.label = 'Example App Garden';
+ apps.anchor = [50, 0, 50];
const plan = buildProjectionPlan({
source: appSource(),
recipe,
- currentState: { terrain: recipe.terrain, entities: {} },
+ currentState: currentEnvironment(recipe),
+ });
+ const landmark = plan.operations.find(({ verb, args }) => (
+ verb === 'spawn' && args.id === 'portos-design-v2-infra-apps'
+ ));
+ const signal = signalSpawn(plan, 'app');
+ const component = plan.operations.find(({ verb, args }) => (
+ verb === 'comp' && args.id === signal.args.id && args.type === 'portos'
+ ));
+
+ expect(landmark.args.pos).toEqual([50, 0, 50]);
+ expect(component.args.data).toMatchObject({
+ districtId: 'apps',
+ districtLabel: 'Example App Garden',
+ });
+ expect(Math.abs(signal.args.pos[0] - 50)).toBeLessThanOrEqual(13);
+ expect(Math.abs(signal.args.pos[2] - 50)).toBeLessThanOrEqual(13);
+ });
+
+ it('gives a custom district id finite defaults and converges on the next plan', () => {
+ const recipe = structuredClone(DEFAULT_EIDOVERSE_PROJECTION_RECIPE);
+ recipe.districts = [{
+ ...recipe.districts.find(({ id }) => id === 'apps'),
+ id: 'example-custom',
+ label: 'Example Custom District',
+ }];
+ const first = buildProjectionPlan({ source: emptySources(), recipe });
+ const landmark = first.operations.find(({ verb, args }) => (
+ verb === 'spawn' && args.id === 'portos-design-v2-infra-example-custom'
+ ));
+ const second = buildProjectionPlan({
+ source: emptySources(),
+ recipe,
+ currentState: snapshotFromPlan(first, { foldModelDefaults: true }),
+ });
+
+ expect(Number.isFinite(landmark.args.scale)).toBe(true);
+ expect(landmark.args.scale).toBe(1);
+ expect(second.operations).toEqual([]);
+ });
+
+ it('rejects and defensively ignores authored light ids outside the managed namespace', () => {
+ const manualId = 'example-manual-light';
+ const recipe = {
+ ...DEFAULT_EIDOVERSE_PROJECTION_RECIPE,
+ environment: {
+ ...DEFAULT_EIDOVERSE_PROJECTION_RECIPE.environment,
+ lights: [{
+ id: manualId,
+ pos: [0, 4, 0],
+ color: 0xffffff,
+ intensity: 4,
+ range: 12,
+ keep: true,
+ day: true,
+ }],
+ },
+ };
+ const plan = buildProjectionPlan({
+ source: emptySources(),
+ recipe,
+ currentState: {
+ ...currentEnvironment(recipe),
+ entities: { [manualId]: { id: manualId, lib: 'store/example-manual-model' } },
+ },
+ });
+
+ expect(eidoverseProjectionRecipeSchema.safeParse(recipe).success).toBe(false);
+ expect(plan.operations.some(({ args }) => args?.id === manualId)).toBe(false);
+ });
+
+ it('turns aggregate Nexus health into light plus non-color attention cues', () => {
+ const plan = buildProjectionPlan({
+ source: { ...emptySources(), health: { id: 'overview', status: 'error' } },
+ currentState: currentEnvironment(),
});
+ const nexusLight = plan.operations.find(({ verb, args }) => (
+ verb === 'light' && args.id === 'portos-design-v2-light-nexus'
+ ));
+ const healthCue = plan.operations.find(({ verb, args }) => (
+ verb === 'comp' && args.type === 'portos' && args.data?.kind === 'health'
+ ));
+
+ expect(nexusLight).toMatchObject({ layer: 'ambient', args: { color: 0xff4d6d, intensity: 28 } });
+ expect(healthCue.args.data).toMatchObject({
+ severity: 'error',
+ visualCue: { shape: 'spike', motion: 'urgent-bob' },
+ });
+ expect(plan.operations).toContainEqual(expect.objectContaining({
+ layer: 'ambient',
+ verb: 'comp',
+ args: expect.objectContaining({ id: healthCue.args.id, type: 'motion' }),
+ }));
+ });
+
+ it('preserves the adapters canonical attention status in spatial warning cues', () => {
+ const plan = buildProjectionPlan({
+ source: {
+ ...emptySources(),
+ apps: [{ id: 'apps-attention', status: 'attention', count: 3 }],
+ health: { id: 'overview', status: 'attention' },
+ },
+ currentState: currentEnvironment(),
+ });
+ const warnings = plan.operations
+ .filter(({ verb, args }) => verb === 'comp' && args.type === 'portos')
+ .map(({ args }) => args.data)
+ .filter(({ kind }) => ['app', 'health'].includes(kind));
+
+ expect(warnings).toHaveLength(2);
+ expect(warnings).toEqual(expect.arrayContaining([
+ expect.objectContaining({
+ kind: 'app',
+ severity: 'attention',
+ visualCue: { shape: 'diamond', motion: 'pulse' },
+ }),
+ expect.objectContaining({
+ kind: 'health',
+ severity: 'attention',
+ visualCue: { shape: 'diamond', motion: 'pulse' },
+ }),
+ ]));
+ });
+
+ it('keeps ordinary pending work and unread counts in the steady visual channel', () => {
+ const plan = buildProjectionPlan({
+ source: {
+ ...emptySources(),
+ productivity: [{
+ id: 'summary',
+ queue: { pendingApprovals: 2, pendingTasks: 4 },
+ }],
+ goals: [{ id: 'goal-example', status: 'active', todoPending: 2 }],
+ operations: [{ id: 'overview', status: 'active', notifications: { unread: 3 } }],
+ },
+ currentState: currentEnvironment(),
+ });
+ const components = plan.operations
+ .filter(({ verb, args }) => verb === 'comp' && args.type === 'portos')
+ .map(({ args }) => args.data)
+ .filter(({ kind }) => ['productivity', 'goal', 'operations'].includes(kind));
+
+ expect(components).toHaveLength(3);
+ expect(components).toEqual(expect.arrayContaining([
+ expect.objectContaining({ kind: 'productivity', status: 'steady', severity: 'normal' }),
+ expect.objectContaining({ kind: 'goal', status: 'active', severity: 'normal' }),
+ expect.objectContaining({ kind: 'operations', status: 'active', severity: 'normal' }),
+ ]));
+ expect(components.every(({ visualCue }) => visualCue.motion === 'steady')).toBe(true);
+ });
+
+ it('treats a zero limit as intentional', () => {
+ const recipe = {
+ ...DEFAULT_EIDOVERSE_PROJECTION_RECIPE,
+ limits: { ...DEFAULT_EIDOVERSE_PROJECTION_RECIPE.limits, apps: 0 },
+ };
+ const plan = buildProjectionPlan({ source: appSource(), recipe, currentState: { ...currentEnvironment(recipe), entities: {} } });
- expect(plan.operations.some((operation) => operation.verb === 'spawn' && operation.args.lib === recipe.assets.app)).toBe(false);
+ expect(signalSpawn(plan, 'app')).toBeUndefined();
expect(plan.summary.sourceCounts.apps).toBe(1);
});
- it('does not remove generated entities when their source is temporarily unavailable', () => {
- const created = buildProjectionPlan({ source: appSource(), currentState: { terrain: DEFAULT_EIDOVERSE_PROJECTION_RECIPE.terrain } });
- const appSpawn = created.operations.find((operation) => operation.verb === 'spawn' && operation.args.lib === DEFAULT_EIDOVERSE_PROJECTION_RECIPE.assets.app);
+ it('does not remove signals when their source is temporarily unavailable', () => {
+ const created = buildProjectionPlan({ source: appSource(), currentState: currentEnvironment() });
+ const appSpawn = signalSpawn(created, 'app');
+ const currentComponent = created.operations.find((operation) => (
+ operation.verb === 'comp' && operation.args.id === appSpawn.args.id && operation.args.type === 'portos'
+ )).args.data;
const unavailable = buildProjectionPlan({
source: { ...emptySources(), apps: null },
currentState: {
- terrain: DEFAULT_EIDOVERSE_PROJECTION_RECIPE.terrain,
- entities: { [appSpawn.args.id]: appSpawn.args },
+ ...currentEnvironment(),
+ entities: {
+ [appSpawn.args.id]: {
+ ...appSpawn.args,
+ comp: {
+ portos: currentComponent,
+ },
+ },
+ },
},
});
expect(unavailable.summary.sourceAvailability.apps).toBe(false);
- expect(unavailable.operations).not.toContainEqual({ verb: 'remove', args: { id: appSpawn.args.id } });
+ expect(unavailable.operations).not.toContainEqual(expect.objectContaining({ verb: 'remove', args: { id: appSpawn.args.id } }));
+ expect(unavailable.operations).toContainEqual(expect.objectContaining({
+ verb: 'comp',
+ args: expect.objectContaining({
+ id: appSpawn.args.id,
+ type: 'portos',
+ data: expect.objectContaining({
+ freshness: 'stale',
+ status: 'stale',
+ resourceKey: currentComponent.resourceKey,
+ }),
+ }),
+ }));
+ });
+
+ it('keeps stale signals inside the shared live-entity budget', () => {
+ const recipe = structuredClone(DEFAULT_EIDOVERSE_PROJECTION_RECIPE);
+ recipe.limits.apps = 48;
+ recipe.limits.peers = 8;
+ const peers = Array.from({ length: 8 }, (_, index) => ({
+ id: `peer-${index}`,
+ status: 'online',
+ }));
+ const initial = buildProjectionPlan({
+ source: { ...emptySources(), peers },
+ recipe,
+ });
+ const apps = Array.from({ length: 48 }, (_, index) => ({
+ id: `app-${index}`,
+ status: 'online',
+ }));
+ const mixed = buildProjectionPlan({
+ source: { ...emptySources(), apps, peers: null },
+ recipe,
+ currentState: snapshotFromPlan(initial),
+ });
+ const appSpawns = mixed.operations.filter(({ verb, args }) => (
+ verb === 'spawn' && args.id.startsWith('portos-design-v2-signal-app-')
+ ));
+
+ expect(mixed.summary.liveEntityCount).toBe(48);
+ expect(mixed.summary.districtCounts).toMatchObject({ apps: 40, federation: 8 });
+ expect(appSpawns).toHaveLength(40);
+ expect(mixed.operations).not.toContainEqual(expect.objectContaining({
+ verb: 'remove',
+ args: expect.objectContaining({ id: expect.stringContaining('signal-peer-') }),
+ }));
+ });
+
+ it('shares the budget between unavailable stale sources and current sources', () => {
+ const recipe = structuredClone(DEFAULT_EIDOVERSE_PROJECTION_RECIPE);
+ recipe.limits.apps = 48;
+ for (const sourceKey of Object.keys(recipe.limits)) recipe.limits[sourceKey] = Math.max(3, recipe.limits[sourceKey]);
+ const apps = Array.from({ length: 48 }, (_, index) => ({ id: `app-${index}`, status: 'online' }));
+ const initial = buildProjectionPlan({ source: { ...emptySources(), apps }, recipe });
+ const rows = (prefix) => Array.from({ length: 3 }, (_, index) => ({ id: `${prefix}-${index}`, status: 'active' }));
+ const mixed = buildProjectionPlan({
+ recipe,
+ source: {
+ ...emptySources(),
+ apps: null,
+ agents: rows('agent'),
+ tasks: rows('task'),
+ peers: rows('peer'),
+ health: { id: 'overview', status: 'healthy' },
+ productivity: rows('productivity'),
+ activity: rows('activity'),
+ goals: rows('goal'),
+ memory: rows('memory'),
+ storage: rows('storage'),
+ jira: rows('jira'),
+ operations: rows('operations'),
+ },
+ currentState: snapshotFromPlan(initial),
+ });
+
+ expect(mixed.summary.liveEntityCount).toBe(48);
+ expect(mixed.summary.districtCounts.apps).toBeGreaterThan(0);
+ expect(Object.entries(mixed.summary.districtCounts)
+ .filter(([district]) => district !== 'apps')
+ .every(([, count]) => count > 0)).toBe(true);
+ expect(mixed.summary.droppedBySource.apps).toBeGreaterThan(0);
+ expect(mixed.operations.some(({ verb, args }) => (
+ verb === 'spawn' && args.id.startsWith('portos-design-v2-signal-goal-')
+ ))).toBe(true);
+ });
+
+ it('shares a saturated live budget across every available semantic source', () => {
+ const recipe = structuredClone(DEFAULT_EIDOVERSE_PROJECTION_RECIPE);
+ recipe.maxEntities = 12;
+ for (const sourceKey of Object.keys(recipe.limits)) recipe.limits[sourceKey] = 48;
+ const rows = (prefix) => Array.from({ length: 3 }, (_, index) => ({
+ id: `${prefix}-${index}`,
+ status: 'active',
+ }));
+ const plan = buildProjectionPlan({
+ recipe,
+ source: {
+ ...emptySources(),
+ apps: rows('app'),
+ agents: rows('agent'),
+ tasks: rows('task'),
+ peers: rows('peer'),
+ health: { id: 'overview', status: 'healthy' },
+ productivity: rows('productivity'),
+ activity: rows('activity'),
+ goals: rows('goal'),
+ memory: rows('memory'),
+ storage: rows('storage'),
+ jira: rows('jira'),
+ operations: rows('operations'),
+ },
+ });
+
+ expect(plan.summary).toMatchObject({
+ liveEntityCount: 12,
+ maxLiveEntities: 12,
+ truncated: true,
+ });
+ expect(Object.values(plan.summary.districtCounts).every((count) => count > 0)).toBe(true);
+ expect(plan.summary.droppedBySource).toMatchObject({
+ apps: 2,
+ agents: 2,
+ tasks: 2,
+ goals: 2,
+ memory: 2,
+ storage: 2,
+ peers: 2,
+ });
});
- it('removes generated entities after a confirmed empty source read', () => {
- const created = buildProjectionPlan({ source: appSource(), currentState: { terrain: DEFAULT_EIDOVERSE_PROJECTION_RECIPE.terrain } });
- const appSpawn = created.operations.find((operation) => operation.verb === 'spawn' && operation.args.lib === DEFAULT_EIDOVERSE_PROJECTION_RECIPE.assets.app);
+ it('retires unsanitized V1 signals without charging them to the V2 live budget', () => {
+ const recipe = structuredClone(DEFAULT_EIDOVERSE_PROJECTION_RECIPE);
+ recipe.limits.apps = 48;
+ const apps = Array.from({ length: 48 }, (_, index) => ({
+ id: `app-${index}`,
+ status: 'online',
+ }));
+ const legacyEntities = Object.fromEntries(Array.from({ length: 3 }, (_, index) => [
+ `portos-projection-jira-${index}`,
+ {
+ lib: 'eidoverse/assets/models/example-legacy.glb',
+ comp: { portos: { label: `Private legacy ticket ${index}` } },
+ },
+ ]));
+ const plan = buildProjectionPlan({
+ source: { ...emptySources(), apps, jira: null },
+ recipe,
+ currentState: { ...currentEnvironment(recipe), entities: legacyEntities },
+ });
+ const legacyRemovals = plan.operations.filter(({ verb, args }) => (
+ verb === 'remove' && args.id.startsWith('portos-projection-jira-')
+ ));
+
+ expect(plan.summary.liveEntityCount).toBe(48);
+ expect(legacyRemovals).toHaveLength(3);
+ expect(JSON.stringify(plan.operations)).not.toMatch(/Private legacy ticket/);
+ });
+
+ it('removes signals after a confirmed empty source read', () => {
+ const created = buildProjectionPlan({ source: appSource(), currentState: currentEnvironment() });
+ const appSpawn = signalSpawn(created, 'app');
const empty = buildProjectionPlan({
source: { ...emptySources(), health: { apps: { total: 0, online: 0 } } },
- currentState: {
- terrain: DEFAULT_EIDOVERSE_PROJECTION_RECIPE.terrain,
- entities: { [appSpawn.args.id]: appSpawn.args },
- },
+ currentState: { ...currentEnvironment(), entities: { [appSpawn.args.id]: appSpawn.args } },
});
- expect(empty.operations).toContainEqual({ verb: 'remove', args: { id: appSpawn.args.id } });
- expect(empty.summary.removed).toBe(1);
+ expect(empty.operations).toContainEqual(expect.objectContaining({ verb: 'remove', args: { id: appSpawn.args.id } }));
});
- it('materializes the expanded OpenWorld resource contract as stable model metadata', () => {
+ it('materializes bounded WorldSignal metadata for the expanded PortOS contract', () => {
const source = {
...emptySources(),
health: { apps: { total: 1, online: 1 }, diskPercent: 42 },
productivity: [{ id: 'summary', label: 'Productivity', completedToday: 3 }],
- activity: [{ id: 'summary', label: 'Activity calendar', activeDays: 2 }],
+ activity: [{ id: 'activity-summary', label: 'Activity calendar', activeDays: 2 }],
goals: [{ id: 'goal-example', label: 'Example goal', progress: 50 }],
memory: [{ id: 'projects', label: 'Memory projects', count: 4 }],
storage: [{ id: 'database', label: 'PostgreSQL', status: 'online', tableCount: 3 }],
jira: [{ id: 'EX-1', label: 'Example ticket', status: 'To Do' }],
operations: [{ id: 'overview', label: 'PortOS operations', inbox: { total: 2 } }],
};
+ const plan = buildProjectionPlan({ source, currentState: currentEnvironment() });
+ const components = plan.operations
+ .filter((operation) => operation.verb === 'comp' && operation.args.type === 'portos')
+ .map((operation) => operation.args.data);
+
+ expect(components.find(({ kind }) => kind === 'goal')).toMatchObject({
+ managedBy: 'portos', resource: 'goals', route: '/goals/list', districtId: 'goals',
+ freshness: 'current', disclosure: 'aggregate', metrics: { progress: 50 },
+ });
+ expect(components.find(({ kind }) => kind === 'storage')).toMatchObject({ resource: 'storage', districtId: 'data', metrics: { tableCount: 3 } });
+ expect(components.find(({ kind }) => kind === 'operations')).toMatchObject({ resource: 'operations', districtId: 'nexus', metrics: { 'inbox.total': 2 } });
+ expect(JSON.stringify(components)).not.toMatch(/Example goal|Example ticket|EX-1|goal-example/);
+ expect(plan.summary.liveEntityCount).toBe(8);
+ });
+
+ it('aggregates storage and Jira without emitting private table, domain, ticket, or machine labels', () => {
+ const storage = projectedStorage({
+ db: { tables: [{ name: 'private_table', totalBytes: 10 }], sizeBytes: 10, migrations: { applied: 3 } },
+ fs: { domains: [{ name: 'private-domain', bytes: 20, files: 2 }], totalBytes: 20, totalFiles: 2 },
+ });
+ const jira = projectedJiraTickets([
+ { key: 'PRIVATE-1', summary: 'Private customer work', statusCategory: 'In Progress', priority: 'High', storyPoints: 3 },
+ { key: 'PRIVATE-2', summary: 'Another private item', statusCategory: 'To Do', priority: 'Urgent', storyPoints: 2 },
+ ]);
+
+ expect(storage).toHaveLength(2);
+ expect(storage).toEqual(expect.arrayContaining([
+ expect.objectContaining({ id: 'database', tableCount: 1 }),
+ expect.objectContaining({ id: 'filesystem', domainCount: 1 }),
+ ]));
+ expect(jira).toEqual(expect.arrayContaining([
+ expect.objectContaining({ id: 'jira-active', count: 1, storyPoints: 3 }),
+ expect.objectContaining({ id: 'jira-pending', count: 1, urgent: 1 }),
+ ]));
+ expect(JSON.stringify({ storage, jira })).not.toMatch(/private_table|private-domain|PRIVATE-|customer work|private item/i);
+ });
+
+ it('caps live signals at 48 and keeps uncapped placement stable when source order changes', () => {
+ const cappedApps = Array.from({ length: 80 }, (_, index) => ({ id: `app-${index}`, label: `Example app ${index}` }));
+ const stableApps = cappedApps.slice(0, 40);
+ const recipe = {
+ ...DEFAULT_EIDOVERSE_PROJECTION_RECIPE,
+ limits: { ...DEFAULT_EIDOVERSE_PROJECTION_RECIPE.limits, apps: 100 },
+ };
+ const capped = buildProjectionPlan({ source: { ...emptySources(), apps: cappedApps }, recipe });
+ const first = buildProjectionPlan({ source: { ...emptySources(), apps: stableApps }, recipe });
+ const second = buildProjectionPlan({ source: { ...emptySources(), apps: [...stableApps].reverse() }, recipe });
+ const appPlaces = (plan) => plan.operations
+ .filter((operation) => operation.verb === 'spawn' && operation.args.id.includes('signal-app-'))
+ .map(({ args }) => [args.id, args.pos]);
+
+ expect(capped.summary.liveEntityCount).toBe(48);
+ expect(appPlaces(second)).toEqual(appPlaces(first));
+ });
+
+ it('keeps source-adapter priority before applying per-source caps', () => {
const plan = buildProjectionPlan({
- source,
- currentState: { terrain: DEFAULT_EIDOVERSE_PROJECTION_RECIPE.terrain },
- });
-
- expect(plan.summary.sourceAvailability).toMatchObject({
- productivity: true,
- activity: true,
- goals: true,
- memory: true,
- storage: true,
- jira: true,
- operations: true,
- });
- expect(plan.summary.sourceCounts).toMatchObject({
- productivity: 1,
- activity: 1,
- goals: 1,
- memory: 1,
- storage: 1,
- jira: 1,
- operations: 1,
- });
-
- const components = new Map(
- plan.operations
- .filter((operation) => operation.verb === 'comp')
- .map((operation) => [operation.args.data.sourceId, operation.args.data]),
- );
- expect(components.get('goal-example')).toMatchObject({ resource: 'goals', progress: 50 });
- expect(components.get('database')).toMatchObject({ resource: 'storage', tableCount: 3 });
- expect(components.get('overview')).toMatchObject({ resource: 'operations', inbox: { total: 2 } });
- expect(plan.operations.filter((operation) => operation.verb === 'spawn')).toHaveLength(8);
+ source: {
+ ...emptySources(),
+ activity: [
+ { id: 'summary', activeDays: 7 },
+ { id: 'day-latest', tasks: 5 },
+ { id: 'day-second', tasks: 4 },
+ { id: 'day-third', tasks: 3 },
+ ],
+ memory: [
+ { id: 'largest', count: 10 },
+ { id: 'second-largest', count: 9 },
+ { id: 'third-largest', count: 8 },
+ { id: 'smallest', count: 1 },
+ ],
+ },
+ currentState: currentEnvironment(),
+ });
+ const components = plan.operations
+ .filter(({ verb, args }) => verb === 'comp' && args.type === 'portos')
+ .map(({ args }) => args.data);
+ const activity = components.filter(({ kind }) => kind === 'activity');
+ const memory = components.filter(({ kind }) => kind === 'memory');
+
+ expect(activity).toHaveLength(3);
+ expect(activity).toEqual(expect.arrayContaining([
+ expect.objectContaining({ metrics: expect.objectContaining({ activeDays: 7 }) }),
+ expect.objectContaining({ metrics: expect.objectContaining({ tasks: 5 }) }),
+ expect.objectContaining({ metrics: expect.objectContaining({ tasks: 4 }) }),
+ ]));
+ expect(memory.map(({ metrics }) => metrics.count).sort((left, right) => left - right)).toEqual([8, 9, 10]);
+ });
+
+ it('turns goal progress into observable constellation height', () => {
+ const plan = buildProjectionPlan({
+ source: { ...emptySources(), goals: [{ id: 'goal-example', progress: 75, status: 'active' }] },
+ currentState: currentEnvironment(),
+ });
+ const goal = signalSpawn(plan, 'goal');
+
+ expect(goal.args.pos[1]).toBeCloseTo(5.625, 3);
+ });
+
+ it('turns enabled feature flags into district affordances instead of extra props', () => {
+ const plan = buildProjectionPlan({
+ source: {
+ ...emptySources(),
+ features: [
+ { id: 'datadog', label: 'Datadog', enabled: true },
+ { id: 'jira', label: 'Jira', enabled: true },
+ ],
+ },
+ });
+ const districtComponents = new Map(plan.operations
+ .filter((operation) => operation.verb === 'comp' && operation.args.data.kind === 'district')
+ .map((operation) => [operation.args.data.districtId, operation.args.data]));
+
+ expect(districtComponents.get('apps').affordances).toEqual(['datadog']);
+ expect(districtComponents.get('goals').affordances).toEqual(['jira']);
+ expect(districtComponents.get('nexus').affordances).toEqual(['datadog', 'jira']);
+ expect(plan.summary.liveEntityCount).toBe(0);
+ });
+
+ it('honors feature inclusion and caps in district affordances and landmark scale', () => {
+ const source = {
+ ...emptySources(),
+ features: [
+ { id: 'datadog', enabled: true },
+ { id: 'jira', enabled: true },
+ ],
+ };
+ const excludedRecipe = structuredClone(DEFAULT_EIDOVERSE_PROJECTION_RECIPE);
+ excludedRecipe.includes.features = false;
+ const cappedRecipe = structuredClone(DEFAULT_EIDOVERSE_PROJECTION_RECIPE);
+ cappedRecipe.limits.features = 0;
+ const excluded = buildProjectionPlan({ source, recipe: excludedRecipe });
+ const capped = buildProjectionPlan({ source, recipe: cappedRecipe });
+ const empty = buildProjectionPlan({ source: { ...source, features: [] } });
+ const districtComponents = (plan) => plan.operations
+ .filter(({ verb, args }) => verb === 'comp' && args.data?.kind === 'district')
+ .map(({ args }) => args.data);
+ const appsLandmark = (plan) => plan.operations.find(({ verb, args }) => (
+ verb === 'spawn' && args.id === 'portos-design-v2-infra-apps'
+ ));
+
+ expect(districtComponents(excluded).every(({ affordances }) => affordances.length === 0)).toBe(true);
+ expect(districtComponents(capped).every(({ affordances }) => affordances.length === 0)).toBe(true);
+ expect(appsLandmark(excluded).args.scale).toBe(appsLandmark(empty).args.scale);
+ expect(appsLandmark(capped).args.scale).toBe(appsLandmark(empty).args.scale);
+ });
+
+ it('keeps authored architecture identical across installs while local signals differ', () => {
+ const first = buildProjectionPlan({ source: {
+ ...emptySources(),
+ apps: [{ id: 'install-a-app', status: 'online' }],
+ } });
+ const second = buildProjectionPlan({ source: {
+ ...emptySources(),
+ apps: [{ id: 'install-b-app', status: 'stopped' }],
+ } });
+ const architecture = (plan) => plan.operations.filter(({ layer }) => ['environment', 'infrastructure'].includes(layer));
+ const signals = (plan) => plan.operations.filter(({ layer }) => layer === 'live');
+
+ expect(architecture(second)).toEqual(architecture(first));
+ expect(signals(second)).not.toEqual(signals(first));
+ });
+
+ it('never mutates unrelated manual entities during reconciliation', () => {
+ const currentState = {
+ ...currentEnvironment(),
+ entities: {
+ 'manual-example': { lib: 'eidoverse/assets/models/example_manual.glb', pos: [1, 0, 1] },
+ 'portos-projection-app-retired': { lib: APP_FALLBACK, pos: [2, 0, 2] },
+ },
+ };
+ const plan = buildProjectionPlan({ source: emptySources(), currentState });
+
+ expect(plan.operations.some(({ args }) => args?.id === 'manual-example')).toBe(false);
+ expect(plan.operations).toContainEqual(expect.objectContaining({
+ layer: 'reconciliation', verb: 'remove', args: { id: 'portos-projection-app-retired' },
+ }));
});
});
diff --git a/server/services/eidoverseWorldProjection.js b/server/services/eidoverseWorldProjection.js
new file mode 100644
index 000000000..ea853658b
--- /dev/null
+++ b/server/services/eidoverseWorldProjection.js
@@ -0,0 +1,655 @@
+/**
+ * Pure layered plan for projecting bounded PortOS signals into Eidoverse.
+ *
+ * No I/O happens here: identical design, source, and snapshot inputs produce
+ * identical environment, infrastructure, live, ambient, and cleanup verbs.
+ */
+
+import { createHash } from 'node:crypto';
+import { canonicalStringify } from '../lib/objects.js';
+import {
+ EIDOVERSE_DISTRICTS_V2,
+ EIDOVERSE_MANAGED_PREFIX,
+ EIDOVERSE_MAX_LIVE_ENTITIES,
+ EIDOVERSE_PROJECTION_PREFIX,
+ EIDOVERSE_WORLD_DESIGN_V1,
+ EIDOVERSE_WORLD_DESIGN_V2,
+ EIDOVERSE_WORLD_DESIGN_VERSION,
+ extractEidoverseDesignOverrides,
+ migrateEidoverseWorldState,
+ resolveEidoverseDesign,
+ stableEidoverseUnit,
+} from '../lib/eidoverseWorldDesign.js';
+
+const LEGACY_PROJECTION_ID_PREFIX = 'portos-projection-';
+const PROJECTION_ID_PREFIX = EIDOVERSE_PROJECTION_PREFIX;
+const COMPONENT_TYPE = 'portos';
+const COMPONENT_RESOURCE_BY_KIND = Object.freeze({
+ app: 'apps', agent: 'agents', task: 'tasks', feature: 'features', peer: 'peers',
+ health: 'health', productivity: 'productivity', activity: 'activity', goal: 'goals',
+ memory: 'memory', storage: 'storage', jira: 'jira', operations: 'operations',
+});
+const COMPONENT_ROUTE_BY_KIND = Object.freeze({
+ app: '/apps', agent: '/cos/agents', task: '/cos/tasks', feature: '/settings/features',
+ peer: '/instances', health: '/cos/health', productivity: '/cos/productivity',
+ activity: '/cos/productivity', goal: '/goals/list', memory: '/brain/memory',
+ storage: '/settings/database', jira: '/goals/list', operations: '/cos/health',
+});
+const COMPONENT_LABEL_BY_KIND = Object.freeze({
+ app: 'Managed app', agent: 'Active agent', task: 'Active task', feature: 'District feature',
+ peer: 'Federated peer', health: 'PortOS health', productivity: 'Productivity summary',
+ activity: 'Activity pulse', goal: 'Active goal', memory: 'Memory aggregate',
+ storage: 'Data landmark', jira: 'Current work summary', operations: 'PortOS operations',
+});
+const DISTRICT_ASSET_SLOT = Object.freeze({
+ nexus: 'nexus',
+ apps: 'app',
+ agents: 'agent',
+ goals: 'goal',
+ memory: 'memory',
+ data: 'storage',
+ federation: 'peer',
+ activity: 'activity',
+});
+const DISTRICT_SCALE = Object.freeze({
+ nexus: 1.15,
+ apps: 1.1,
+ agents: 1.15,
+ goals: 1.35,
+ memory: 0.48,
+ data: 0.55,
+ federation: 0.78,
+ activity: 0.72,
+});
+
+export const DEFAULT_EIDOVERSE_PROJECTION_RECIPE = EIDOVERSE_WORLD_DESIGN_V2;
+
+const safeText = (value, fallback = '', max = 160) => {
+ if (typeof value !== 'string') return fallback;
+ const clean = value.replace(/[\u0000-\u001f\u007f]/g, ' ').trim();
+ return clean ? clean.slice(0, max) : fallback;
+};
+const shortHash = (value) => createHash('sha256').update(String(value)).digest('hex').slice(0, 12);
+
+function mergeRecipe(recipe) {
+ if (recipe?.version === 1) {
+ return migrateEidoverseWorldState({ schemaVersion: 1, recipe }).state.recipe;
+ }
+ return resolveEidoverseDesign(extractEidoverseDesignOverrides(recipe), recipe?.assets || {});
+}
+
+export const EIDOVERSE_PROJECTION_KINDS = Object.freeze([
+ { kind: 'app', source: 'apps', slot: 'app' },
+ { kind: 'agent', source: 'agents', slot: 'agent' },
+ { kind: 'task', source: 'tasks', slot: 'task' },
+ { kind: 'feature', source: 'features', slot: null },
+ { kind: 'peer', source: 'peers', slot: 'peer' },
+ { kind: 'health', source: 'health', slot: 'activity' },
+ { kind: 'productivity', source: 'productivity', slot: 'activity' },
+ { kind: 'activity', source: 'activity', slot: 'activity' },
+ { kind: 'goal', source: 'goals', slot: 'goal' },
+ { kind: 'memory', source: 'memory', slot: 'memory' },
+ { kind: 'storage', source: 'storage', slot: 'storage' },
+ { kind: 'jira', source: 'jira', slot: 'goal' },
+ { kind: 'operations', source: 'operations', slot: 'activity' },
+]);
+
+function sourceAvailable(source, key) {
+ if (key === 'health') return source.health !== null && source.health !== undefined;
+ return Array.isArray(source[key]);
+}
+
+function allocateRoundRobin(buckets, limit) {
+ const selected = new Map(buckets.map(({ key }) => [key, []]));
+ let count = 0;
+ for (let offset = 0; count < limit; offset += 1) {
+ let progressed = false;
+ for (const { key, values } of buckets) {
+ if (count >= limit) break;
+ if (offset >= values.length) continue;
+ selected.get(key).push(values[offset]);
+ count += 1;
+ progressed = true;
+ }
+ if (!progressed) break;
+ }
+ return selected;
+}
+
+function projectionEntityId(kind, sourceId) {
+ return `${PROJECTION_ID_PREFIX}${kind}-${shortHash(`${kind}:${sourceId}`)}`;
+}
+
+function signalKindFromEntityId(id) {
+ if (id.startsWith(PROJECTION_ID_PREFIX)) {
+ return id.slice(PROJECTION_ID_PREFIX.length).split('-')[0];
+ }
+ return null;
+}
+
+function districtForSource(districts, sourceKey) {
+ return districts.find(({ sources }) => sources.includes(sourceKey))
+ || districts[0]
+ || EIDOVERSE_DISTRICTS_V2[0];
+}
+
+function entityPosition(sourceId, sourceKey, districts) {
+ const district = districtForSource(districts, sourceKey);
+ if (district.id === 'activity') {
+ const along = (stableEidoverseUnit(`${district.id}:${sourceId}:along`) * 2 - 1) * 9;
+ const bend = Math.sin(along / 4.5) * 1.8;
+ return [
+ Number((district.anchor[0] + along).toFixed(2)),
+ district.anchor[1] + 0.2,
+ Number((district.anchor[2] + bend).toFixed(2)),
+ ];
+ }
+ const angle = stableEidoverseUnit(`${district.id}:${sourceId}:angle`) * Math.PI * 2;
+ const radius = 5.5 + stableEidoverseUnit(`${district.id}:${sourceId}:radius`) * 7.5;
+ return [
+ Number((district.anchor[0] + Math.cos(angle) * radius).toFixed(2)),
+ district.anchor[1],
+ Number((district.anchor[2] + Math.sin(angle) * radius).toFixed(2)),
+ ];
+}
+
+function worldSignal(kind, sourceKey, item, districts) {
+ const district = districtForSource(districts, sourceKey);
+ const sourceIdentity = safeText(item?.id, '', 160) || canonicalStringify(item);
+ const resourceKey = `${kind}-${shortHash(`${kind}:${sourceIdentity}`)}`;
+ const metrics = {};
+ for (const [key, value] of Object.entries(item || {}).slice(0, 20)) {
+ if (['id', 'label', 'status'].includes(key) || value === undefined) continue;
+ if (typeof value === 'number' && Number.isFinite(value)) metrics[key] = value;
+ else if (typeof value === 'boolean' || value === null) metrics[key] = value;
+ else if (Array.isArray(value)) metrics[`${key}Count`] = value.length;
+ else if (value && typeof value === 'object') {
+ for (const [nestedKey, nestedValue] of Object.entries(value).slice(0, 8)) {
+ if (typeof nestedValue === 'number' && Number.isFinite(nestedValue)) metrics[`${key}.${nestedKey}`] = nestedValue;
+ else if (typeof nestedValue === 'boolean') metrics[`${key}.${nestedKey}`] = nestedValue;
+ }
+ }
+ }
+ const rawStatus = String(item?.status || '').toLowerCase();
+ const errorStatus = /error|failed|unhealthy|offline|crash|blocked/.test(rawStatus);
+ const attentionStatus = rawStatus === 'attention'
+ || /paused|stopped|pending|unknown|not.started/.test(rawStatus)
+ || Object.entries(metrics).some(([key, value]) => (
+ /fail|error|alert/i.test(key) && typeof value === 'number' && value > 0
+ ));
+ const activeStatus = /active|running|online|healthy|success/.test(rawStatus);
+ const status = errorStatus ? 'error' : (attentionStatus ? 'attention' : (activeStatus ? 'active' : 'steady'));
+ const severity = status === 'error' ? 'error' : (status === 'attention' ? 'attention' : 'normal');
+ return {
+ schemaVersion: 1,
+ managedBy: 'portos',
+ designVersion: EIDOVERSE_WORLD_DESIGN_VERSION,
+ id: resourceKey,
+ resourceKey,
+ kind,
+ resource: COMPONENT_RESOURCE_BY_KIND[kind] || kind,
+ route: COMPONENT_ROUTE_BY_KIND[kind] || '/eidoverse',
+ districtId: district.id,
+ districtLabel: district.label,
+ label: COMPONENT_LABEL_BY_KIND[kind] || 'PortOS signal',
+ status,
+ severity,
+ freshness: 'current',
+ disclosure: 'aggregate',
+ visualCue: severity === 'error'
+ ? { shape: 'spike', motion: 'urgent-bob' }
+ : (severity === 'attention' ? { shape: 'diamond', motion: 'pulse' } : { shape: 'ring', motion: 'steady' }),
+ metrics,
+ };
+}
+
+function assetPathFor(recipe, kind, slot) {
+ return recipe.assets?.[kind]
+ || recipe.assets?.[slot]
+ || recipe.assetRecipe?.slots?.[slot]?.fallback
+ || EIDOVERSE_WORLD_DESIGN_V1.assets[kind]
+ || EIDOVERSE_WORLD_DESIGN_V1.assets.feature;
+}
+
+function containsConfiguredValue(current, desired) {
+ if (Array.isArray(desired)) return equal(current, desired);
+ if (desired && typeof desired === 'object') {
+ return Boolean(current) && Object.entries(desired).every(([key, value]) => containsConfiguredValue(current[key], value));
+ }
+ return current === desired;
+}
+
+function containsConfiguredLight(current, desired) {
+ if (!current || current.kind !== 'light') return false;
+ const { id: _id, ...configuration } = desired;
+ return containsConfiguredValue({
+ ...current,
+ keep: current.keep === true,
+ // Eidoverse folds the protocol default (`day: true`) by omitting it.
+ day: current.day !== false,
+ }, configuration);
+}
+
+function nexusStatusLight(light, source, existing) {
+ if (source.health === null || source.health === undefined) {
+ return existing?.kind === 'light'
+ ? {
+ ...light,
+ color: Number.isInteger(existing.color) ? existing.color : light.color,
+ intensity: typeof existing.intensity === 'number' ? existing.intensity : light.intensity,
+ }
+ : light;
+ }
+ if (source.health.status === 'error') return { ...light, color: 0xff4d6d, intensity: 28 };
+ if (source.health.status === 'attention') return { ...light, color: 0xffb86b, intensity: 25 };
+ if (source.health.status === 'healthy' || source.health.status === 'active') {
+ return { ...light, color: 0x65d9ff, intensity: 22 };
+ }
+ return { ...light, color: 0xa78bfa, intensity: 20 };
+}
+
+function upsertModel({
+ operations,
+ stateEntities,
+ desiredIds,
+ id,
+ lib,
+ pos,
+ yaw = 0,
+ scale = 1,
+ collide = 'box',
+ component,
+ motion = null,
+ layer = 'live',
+ motionLayer = 'ambient',
+}) {
+ const existing = stateEntities[id];
+ desiredIds.add(id);
+ let created = 0;
+ let updated = 0;
+ let removed = 0;
+ const respawned = !existing || existing.lib !== lib;
+ if (respawned) {
+ if (existing) {
+ operations.push({ layer, verb: 'remove', args: { id } });
+ removed += 1;
+ }
+ operations.push({ layer, verb: 'spawn', args: {
+ id, lib, pos, yaw, scale,
+ ...(collide ? { collide } : {}),
+ } });
+ created += 1;
+ } else if (!containsConfiguredValue({
+ ...existing,
+ yaw: existing.yaw ?? 0,
+ scale: existing.scale ?? 1,
+ }, { pos, yaw, scale })) {
+ operations.push({ layer, verb: 'place', args: { id, pos, yaw, scale } });
+ updated += 1;
+ }
+ const priorComponents = respawned ? undefined : existing.comp;
+ if (!equal(priorComponents?.[COMPONENT_TYPE], component)) {
+ operations.push({ layer, verb: 'comp', args: { id, type: COMPONENT_TYPE, data: component } });
+ if (existing) updated += 1;
+ }
+ if (!equal(priorComponents?.motion ?? null, motion)) {
+ operations.push({ layer: motionLayer, verb: 'comp', args: { id, type: 'motion', data: motion } });
+ if (existing) updated += 1;
+ }
+ return { created, updated, removed };
+}
+
+function equal(valueA, valueB) {
+ return canonicalStringify(valueA) === canonicalStringify(valueB);
+}
+
+/**
+ * Build the deterministic world operations without opening a socket. This is
+ * intentionally exported so recipe changes can be tested without a live
+ * Eidoverse process and so future renderers can reuse the same projection.
+ */
+export function buildProjectionPlan({ source = {}, recipe = DEFAULT_EIDOVERSE_PROJECTION_RECIPE, currentState = {} }) {
+ const effectiveRecipe = mergeRecipe(recipe);
+ const stateEntities = currentState?.entities && typeof currentState.entities === 'object'
+ ? currentState.entities
+ : {};
+ const operations = [];
+ const desiredIds = new Set();
+ const sourceAvailability = {};
+ let created = 0;
+ let updated = 0;
+ let removed = 0;
+
+ const environment = effectiveRecipe.environment;
+ const districts = Array.isArray(effectiveRecipe.districts) && effectiveRecipe.districts.length
+ ? effectiveRecipe.districts
+ : EIDOVERSE_DISTRICTS_V2;
+ sourceAvailability.environment = true;
+ if (!containsConfiguredValue(currentState?.terrain, environment.terrain)) {
+ operations.push({ layer: 'environment', verb: 'terrain', args: environment.terrain });
+ }
+ if (!containsConfiguredValue(currentState?.sky, environment.sky)) {
+ operations.push({ layer: 'environment', verb: 'sky', args: environment.sky });
+ }
+ if (!containsConfiguredValue(currentState?.grass, environment.grass)) {
+ operations.push({ layer: 'environment', verb: 'grass', args: environment.grass });
+ }
+
+ for (const authoredLight of environment.lights) {
+ if (!authoredLight.id.startsWith(EIDOVERSE_MANAGED_PREFIX)) continue;
+ const existing = stateEntities[authoredLight.id];
+ const light = authoredLight.id === `${EIDOVERSE_MANAGED_PREFIX}light-nexus`
+ ? nexusStatusLight(authoredLight, source, existing)
+ : authoredLight;
+ const layer = authoredLight.id === `${EIDOVERSE_MANAGED_PREFIX}light-nexus` ? 'ambient' : 'environment';
+ desiredIds.add(light.id);
+ if (!existing || existing.kind !== 'light') {
+ if (existing) {
+ operations.push({ layer, verb: 'remove', args: { id: light.id } });
+ removed += 1;
+ }
+ operations.push({ layer, verb: 'light', args: light });
+ created += 1;
+ } else if (!containsConfiguredLight(existing, light)) {
+ operations.push({ layer, verb: 'light', args: light });
+ updated += 1;
+ }
+ }
+
+ for (const district of districts) {
+ const id = `${EIDOVERSE_MANAGED_PREFIX}infra-${district.id}`;
+ const enabledSources = district.sources.filter((key) => effectiveRecipe.includes[key]);
+ const featureLimit = effectiveRecipe.limits.features ?? Number.POSITIVE_INFINITY;
+ const priorAffordances = stateEntities[id]?.comp?.[COMPONENT_TYPE]?.affordances || [];
+ const activeFeatureIds = effectiveRecipe.includes.features !== true
+ ? []
+ : (Array.isArray(source.features)
+ ? source.features
+ .filter((feature) => feature.enabled)
+ .slice(0, featureLimit)
+ .map((feature) => feature.id)
+ : priorAffordances.slice(0, featureLimit));
+ const featureDistricts = {
+ jira: ['goals'],
+ post: ['activity'],
+ datadog: ['apps', 'nexus'],
+ eidoverse: ['nexus'],
+ };
+ const affordances = activeFeatureIds.filter((featureId) => (
+ featureDistricts[featureId]?.includes(district.id) || district.id === 'nexus'
+ ));
+ const component = {
+ schemaVersion: 1,
+ managedBy: 'portos',
+ designVersion: EIDOVERSE_WORLD_DESIGN_VERSION,
+ kind: 'district',
+ districtId: district.id,
+ label: district.label,
+ direction: district.direction,
+ landmark: district.landmark,
+ accent: district.accent,
+ sources: district.sources,
+ enabledSources,
+ affordances,
+ route: district.sources.map((sourceKey) => (
+ COMPONENT_ROUTE_BY_KIND[EIDOVERSE_PROJECTION_KINDS.find((entry) => entry.source === sourceKey)?.kind]
+ )).find(Boolean) || '/eidoverse',
+ status: enabledSources.length ? 'active' : 'inactive',
+ visualCue: enabledSources.length ? { shape: 'open', motion: 'steady' } : { shape: 'closed', motion: 'still' },
+ };
+ const districtSlot = DISTRICT_ASSET_SLOT[district.id] ?? 'district';
+ const districtScale = DISTRICT_SCALE[district.id] ?? 1;
+ const delta = upsertModel({
+ operations,
+ stateEntities,
+ desiredIds,
+ id,
+ lib: assetPathFor(effectiveRecipe, district.id === 'nexus' ? 'operations' : null, districtSlot),
+ pos: district.anchor,
+ yaw: stableEidoverseUnit(`${district.id}:yaw`) * Math.PI * 2,
+ scale: Number((districtScale * (1 + Math.min(affordances.length, 3) * 0.035)).toFixed(3)),
+ component,
+ motion: ['agents', 'goals', 'memory'].includes(district.id)
+ ? {
+ type: 'bob',
+ amp: district.id === 'goals' ? 0.28 : 0.16,
+ period: district.id === 'memory' ? 5.5 : 4.2,
+ phase: Number((stableEidoverseUnit(`${district.id}:motion`) * Math.PI * 2).toFixed(4)),
+ }
+ : null,
+ layer: 'infrastructure',
+ });
+ created += delta.created;
+ updated += delta.updated;
+ removed += delta.removed;
+ }
+
+ let pathNodeCount = 0;
+ for (const path of effectiveRecipe.paths || []) {
+ path.nodes.forEach((pos, index) => {
+ const id = `${EIDOVERSE_MANAGED_PREFIX}path-${path.id}-${index + 1}`;
+ const delta = upsertModel({
+ operations,
+ stateEntities,
+ desiredIds,
+ id,
+ lib: assetPathFor(effectiveRecipe, null, 'district'),
+ pos,
+ yaw: 0,
+ scale: 0.14,
+ collide: null,
+ component: {
+ schemaVersion: 1,
+ managedBy: 'portos',
+ designVersion: EIDOVERSE_WORLD_DESIGN_VERSION,
+ kind: 'path-node',
+ pathId: path.id,
+ toDistrictId: path.toDistrictId,
+ label: path.label,
+ node: index + 1,
+ route: '/eidoverse',
+ status: 'active',
+ },
+ layer: 'infrastructure',
+ });
+ created += delta.created;
+ updated += delta.updated;
+ removed += delta.removed;
+ pathNodeCount += 1;
+ });
+ }
+
+ const liveEntityLimit = Math.min(
+ effectiveRecipe.maxEntities ?? EIDOVERSE_MAX_LIVE_ENTITIES,
+ EIDOVERSE_MAX_LIVE_ENTITIES,
+ );
+ const unavailableKinds = new Set(EIDOVERSE_PROJECTION_KINDS
+ .filter(({ source: sourceKey }) => (
+ effectiveRecipe.includes[sourceKey] && !sourceAvailable(source, sourceKey)
+ ))
+ .map(({ kind }) => kind));
+ for (const { source: sourceKey } of EIDOVERSE_PROJECTION_KINDS) {
+ sourceAvailability[sourceKey] = sourceAvailable(source, sourceKey);
+ }
+ const staleCandidates = Object.keys(stateEntities)
+ .map((id) => ({ id, kind: signalKindFromEntityId(id) }))
+ .filter(({ kind }) => kind && unavailableKinds.has(kind))
+ .sort((left, right) => left.id.localeCompare(right.id));
+ const staleBuckets = EIDOVERSE_PROJECTION_KINDS
+ .filter(({ kind, slot }) => unavailableKinds.has(kind) && slot)
+ .map(({ kind, source: sourceKey }) => ({
+ key: kind,
+ kind,
+ sourceKey,
+ values: staleCandidates
+ .filter((candidate) => candidate.kind === kind)
+ .slice(0, effectiveRecipe.limits[sourceKey] ?? staleCandidates.length),
+ }));
+
+ const liveBuckets = [];
+ for (const { kind, source: sourceKey, slot } of EIDOVERSE_PROJECTION_KINDS) {
+ const available = sourceAvailable(source, sourceKey);
+ if (!effectiveRecipe.includes[sourceKey] || !available || !slot) continue;
+ const values = kind === 'health' ? [source.health] : source[sourceKey];
+ const normalized = values
+ .filter(Boolean)
+ .map((item) => worldSignal(kind, sourceKey, item, districts));
+ liveBuckets.push({
+ key: kind,
+ kind,
+ sourceKey,
+ values: normalized
+ .slice(0, effectiveRecipe.limits[sourceKey] ?? normalized.length)
+ .sort((left, right) => left.id.localeCompare(right.id)),
+ });
+ }
+ const bucketsByKind = new Map([...staleBuckets, ...liveBuckets].map((bucket) => [bucket.kind, bucket]));
+ const signalBuckets = EIDOVERSE_PROJECTION_KINDS
+ .map(({ kind }) => bucketsByKind.get(kind))
+ .filter(Boolean);
+ const selectedSignals = allocateRoundRobin(signalBuckets, liveEntityLimit);
+ const retainedStaleCandidates = staleBuckets.flatMap(({ key }) => selectedSignals.get(key) || []);
+ const retainedStaleIds = new Set(retainedStaleCandidates.map(({ id }) => id));
+ const overBudgetStaleIds = new Set(staleCandidates
+ .filter(({ id }) => !retainedStaleIds.has(id))
+ .map(({ id }) => id));
+ let liveEntityCount = retainedStaleIds.size;
+ const districtCounts = Object.fromEntries(districts.map(({ id }) => [id, 0]));
+ const droppedBySource = {};
+ for (const { kind } of retainedStaleCandidates) {
+ const sourceKey = EIDOVERSE_PROJECTION_KINDS.find((entry) => entry.kind === kind)?.source;
+ if (!sourceKey) continue;
+ const district = districtForSource(districts, sourceKey);
+ districtCounts[district.id] = (districtCounts[district.id] || 0) + 1;
+ }
+ for (const { key, sourceKey, values } of signalBuckets) {
+ const dropped = values.length - (selectedSignals.get(key)?.length || 0);
+ if (dropped > 0) droppedBySource[sourceKey] = (droppedBySource[sourceKey] || 0) + dropped;
+ }
+
+ for (const { kind, source: sourceKey, slot } of EIDOVERSE_PROJECTION_KINDS) {
+ const available = sourceAvailable(source, sourceKey);
+ if (!effectiveRecipe.includes[`${sourceKey}`]) {
+ continue;
+ }
+ if (!available) {
+ for (const [id, existing] of Object.entries(stateEntities)) {
+ if (!id.startsWith(`${PROJECTION_ID_PREFIX}${kind}-`)) continue;
+ if (!retainedStaleIds.has(id)) continue;
+ desiredIds.add(id);
+ const priorComponent = existing?.comp?.[COMPONENT_TYPE] || {};
+ const priorMetrics = priorComponent.metrics || {};
+ const district = districtForSource(districts, sourceKey);
+ const resourceKey = priorComponent.managedBy === 'portos'
+ && priorComponent.kind === kind
+ && typeof priorComponent.resourceKey === 'string'
+ ? priorComponent.resourceKey
+ : `${kind}-${id.slice(`${PROJECTION_ID_PREFIX}${kind}-`.length)}`;
+ const staleComponent = {
+ schemaVersion: 1,
+ managedBy: 'portos',
+ designVersion: EIDOVERSE_WORLD_DESIGN_VERSION,
+ id: resourceKey,
+ resourceKey,
+ kind,
+ resource: COMPONENT_RESOURCE_BY_KIND[kind] || kind,
+ route: COMPONENT_ROUTE_BY_KIND[kind] || '/eidoverse',
+ districtId: district.id,
+ districtLabel: district.label,
+ label: COMPONENT_LABEL_BY_KIND[kind] || 'PortOS signal',
+ status: 'stale',
+ severity: 'attention',
+ freshness: 'stale',
+ disclosure: 'aggregate',
+ visualCue: { shape: 'diamond', motion: 'slow-pulse' },
+ metrics: Object.fromEntries(Object.entries(priorMetrics).filter(([, value]) => (
+ value === null || typeof value === 'boolean' || (typeof value === 'number' && Number.isFinite(value))
+ ))),
+ };
+ if (!equal(existing?.comp?.[COMPONENT_TYPE], staleComponent)) {
+ operations.push({ layer: 'live', verb: 'comp', args: { id, type: COMPONENT_TYPE, data: staleComponent } });
+ updated += 1;
+ }
+ const staleMotion = {
+ type: 'bob', amp: 0.12, period: 6,
+ phase: Number((stableEidoverseUnit(`${id}:stale`) * Math.PI * 2).toFixed(4)),
+ };
+ if (!equal(existing?.comp?.motion ?? null, staleMotion)) {
+ operations.push({ layer: 'ambient', verb: 'comp', args: { id, type: 'motion', data: staleMotion } });
+ updated += 1;
+ }
+ }
+ continue;
+ }
+ if (!slot) continue;
+ for (const signal of selectedSignals.get(kind) || []) {
+ const id = projectionEntityId(kind, signal.id);
+ const pos = entityPosition(signal.id, sourceKey, districts);
+ if (kind === 'goal' && typeof signal.metrics.progress === 'number') {
+ pos[1] += 1.5 + Math.max(0, Math.min(100, signal.metrics.progress)) * 0.055;
+ }
+ const scaleVariation = 0.92 + stableEidoverseUnit(`${signal.id}:scale`) * 0.16;
+ const statusScale = signal.severity === 'error' ? 1.22 : (signal.severity === 'attention' ? 1.1 : 1);
+ if (signal.severity === 'error') pos[1] += 1.6;
+ else if (signal.severity === 'attention') pos[1] += 0.7;
+ const shouldMove = signal.severity !== 'normal' || ['agent', 'activity', 'goal', 'jira', 'memory'].includes(kind);
+ const delta = upsertModel({
+ operations,
+ stateEntities,
+ desiredIds,
+ id,
+ lib: assetPathFor(effectiveRecipe, kind, slot),
+ pos,
+ yaw: stableEidoverseUnit(`${signal.id}:yaw`) * Math.PI * 2,
+ scale: Number(((effectiveRecipe.scale[kind] || 1) * scaleVariation * statusScale).toFixed(3)),
+ component: signal,
+ motion: shouldMove ? {
+ type: 'bob',
+ amp: signal.severity === 'error' ? 0.5 : (signal.severity === 'attention' ? 0.3 : 0.14),
+ period: signal.severity === 'error' ? 1.7 : (signal.severity === 'attention' ? 2.8 : 4.5),
+ phase: Number((stableEidoverseUnit(`${signal.id}:motion`) * Math.PI * 2).toFixed(4)),
+ } : null,
+ layer: 'live',
+ });
+ created += delta.created;
+ updated += delta.updated;
+ removed += delta.removed;
+ liveEntityCount += 1;
+ districtCounts[signal.districtId] += 1;
+ }
+ }
+
+ for (const id of Object.keys(stateEntities)) {
+ const isCurrentManaged = id.startsWith(EIDOVERSE_MANAGED_PREFIX);
+ const isLegacyManaged = id.startsWith(LEGACY_PROJECTION_ID_PREFIX);
+ if ((!isCurrentManaged && !isLegacyManaged) || desiredIds.has(id)) continue;
+ const kind = signalKindFromEntityId(id);
+ if (kind && unavailableKinds.has(kind) && !overBudgetStaleIds.has(id)) continue;
+ operations.push({ layer: 'reconciliation', verb: 'remove', args: { id } });
+ removed += 1;
+ }
+
+ return {
+ operations,
+ summary: {
+ created,
+ updated,
+ removed,
+ operationCount: operations.length,
+ designVersion: EIDOVERSE_WORLD_DESIGN_VERSION,
+ liveEntityCount,
+ maxLiveEntities: liveEntityLimit,
+ infrastructureCount: districts.length + pathNodeCount,
+ districtCounts,
+ sourceAvailability,
+ truncated: Object.keys(droppedBySource).length > 0,
+ droppedBySource,
+ sourceCounts: Object.fromEntries(EIDOVERSE_PROJECTION_KINDS.map(({ kind, source: sourceKey }) => [
+ sourceKey,
+ sourceAvailable(source, sourceKey)
+ ? (kind === 'health' ? 1 : source[sourceKey].length)
+ : null,
+ ])),
+ },
+ };
+}
diff --git a/server/services/eidoverseWorldSources.js b/server/services/eidoverseWorldSources.js
new file mode 100644
index 000000000..aebe39982
--- /dev/null
+++ b/server/services/eidoverseWorldSources.js
@@ -0,0 +1,485 @@
+/**
+ * Privacy-safe PortOS source adapters for Eidoverse World Design.
+ *
+ * This boundary reads local product state and emits only bounded, generic
+ * aggregates. Raw record titles, machine/network identity, personal health
+ * readings, prompts, journals, and transcripts never leave this module.
+ */
+
+import { createHash } from 'node:crypto';
+import { statfs } from 'node:fs/promises';
+import { getAllApps, getAppStatuses } from './apps.js';
+import { getStatus as getCosStatus, getAgents, getCosTasks, getTodayActivity } from './cos.js';
+import { getPendingCounts } from './review.js';
+import { getPeers } from './instances.js';
+import { getInstanceFeatures } from './instanceFeatures.js';
+import * as backup from './backup.js';
+import { getCountsByType } from './notifications.js';
+import { getCharacter } from './character.js';
+import { getVoiceConfig } from './voice/config.js';
+import { getMemoryStats } from '../lib/memoryStats.js';
+import { getGoals } from './identity.js';
+import { getActivityCalendar, getVelocityMetrics } from './productivity.js';
+import { getBrainGraphOverview } from './brainGraph.js';
+import { getInboxLogCounts } from './brainStorage.js';
+import { getOpenWorldIntrospection } from './openWorldIntrospection.js';
+import { fetchMyCurrentSprintTickets } from './jira.js';
+
+const safeText = (value, fallback = '', max = 160) => {
+ if (typeof value !== 'string') return fallback;
+ const clean = value.replace(/[\u0000-\u001f\u007f]/g, ' ').trim();
+ return clean ? clean.slice(0, max) : fallback;
+};
+
+const opaqueId = (namespace, value, fallback) => {
+ const source = safeText(value, fallback, 256);
+ return `${namespace}-${createHash('sha256').update(`${namespace}:${source}`).digest('hex').slice(0, 12)}`;
+};
+
+const coarseStatus = (value) => {
+ const status = String(value || '').toLowerCase();
+ if (/error|failed|unhealthy|offline|crash|blocked/.test(status)) return 'error';
+ if (/paused|stopped|pending|unknown|not.started|todo|to do/.test(status)) return 'attention';
+ if (/active|running|online|healthy|success|progress/.test(status)) return 'active';
+ return 'steady';
+};
+
+const abortError = (signal) => signal?.reason instanceof Error
+ ? signal.reason
+ : new DOMException(String(signal?.reason || 'The Eidoverse source read was canceled.'), 'AbortError');
+
+function throwIfAborted(signal) {
+ if (signal?.aborted) throw abortError(signal);
+}
+
+function waitWithSignal(promise, signal) {
+ if (!signal) return promise;
+ throwIfAborted(signal);
+ return new Promise((resolve, reject) => {
+ const onAbort = () => reject(abortError(signal));
+ signal.addEventListener('abort', onAbort, { once: true });
+ promise.then(
+ (value) => {
+ signal.removeEventListener('abort', onAbort);
+ resolve(value);
+ },
+ (error) => {
+ signal.removeEventListener('abort', onAbort);
+ reject(error);
+ },
+ );
+ });
+}
+
+const finiteOrNull = (value) => typeof value === 'number' && Number.isFinite(value) ? value : null;
+const nonNegativeOrNull = (value) => {
+ const number = finiteOrNull(value);
+ return number === null ? null : Math.max(0, number);
+};
+const percentageOrNull = (value) => {
+ const number = finiteOrNull(value);
+ return number === null ? null : Math.max(0, Math.min(100, number));
+};
+
+async function getDiskUsagePercent() {
+ const stats = await statfs('/').catch(() => null);
+ if (!stats) return null;
+ const total = stats.blocks * stats.bsize;
+ if (!(total > 0)) return null;
+ return Math.round(((total - stats.bavail * stats.bsize) / total) * 100);
+}
+
+function appSummary(apps) {
+ if (!Array.isArray(apps)) return null;
+ return {
+ total: apps.length,
+ online: apps.filter((app) => app.overallStatus === 'online').length,
+ stopped: apps.filter((app) => app.overallStatus === 'stopped').length,
+ notStarted: apps.filter((app) => app.overallStatus === 'not_started').length,
+ unknown: apps.filter((app) => app.overallStatus === 'unknown').length,
+ };
+}
+
+function projectedApps(apps) {
+ if (!Array.isArray(apps)) return null;
+ const groups = new Map();
+ for (const app of apps) {
+ const status = coarseStatus(app?.overallStatus);
+ const group = groups.get(status) || { count: 0, managed: 0 };
+ group.count += 1;
+ if (app?.managed === true) group.managed += 1;
+ groups.set(status, group);
+ }
+ return [...groups.entries()]
+ .sort(([left], [right]) => left.localeCompare(right))
+ .map(([status, group]) => ({
+ id: `apps-${status}`,
+ label: 'Managed app group',
+ status,
+ count: group.count,
+ managed: group.managed,
+ }));
+}
+
+function projectedProductivity(todayActivity, velocity, taskState) {
+ if (!todayActivity && !velocity) return null;
+ const stats = todayActivity?.stats || {};
+ const queue = {
+ pendingApprovals: Array.isArray(taskState?.awaitingApproval) ? taskState.awaitingApproval.length : null,
+ pendingTasks: Array.isArray(taskState?.tasks)
+ ? taskState.tasks.filter((task) => !['completed', 'done', 'archived'].includes(String(task?.status || '').toLowerCase())).length
+ : null,
+ };
+ queue.total = [queue.pendingApprovals, queue.pendingTasks].every((value) => value !== null)
+ ? queue.pendingApprovals + queue.pendingTasks
+ : null;
+ return [{
+ id: 'summary',
+ label: 'Productivity',
+ completedToday: nonNegativeOrNull(stats.completed ?? velocity?.today),
+ succeededToday: nonNegativeOrNull(stats.succeeded ?? velocity?.todaySuccesses),
+ failedToday: nonNegativeOrNull(stats.failed ?? velocity?.todayFailures),
+ successRate: percentageOrNull(stats.successRate),
+ velocity: finiteOrNull(velocity?.velocity),
+ averagePerDay: nonNegativeOrNull(velocity?.avgPerDay),
+ historicalDays: nonNegativeOrNull(velocity?.historicalDays),
+ queue,
+ running: todayActivity?.isRunning === true,
+ paused: todayActivity?.isPaused === true,
+ }];
+}
+
+function projectedActivity(calendar) {
+ if (!calendar || !Array.isArray(calendar.weeks)) return null;
+ if (calendar.weeks.length === 0) return [];
+ const days = calendar.weeks
+ .flatMap((week) => Array.isArray(week) ? week : [])
+ .filter((day) => day && typeof day === 'object' && day.isFuture !== true);
+ const today = days.find((day) => day.isToday === true);
+ const activeDays = days
+ .filter((day) => (nonNegativeOrNull(day.tasks) || 0) > 0)
+ .slice(-99)
+ .reverse();
+ const summary = calendar.summary || {};
+ return [
+ {
+ id: 'summary',
+ label: 'Activity calendar',
+ weeks: calendar.weeks.length,
+ activeDays: nonNegativeOrNull(summary.activeDays),
+ totalTasks: nonNegativeOrNull(summary.totalTasks),
+ totalSuccesses: nonNegativeOrNull(summary.totalSuccesses),
+ successRate: percentageOrNull(summary.successRate),
+ maxTasks: nonNegativeOrNull(calendar.maxTasks),
+ todayTasks: nonNegativeOrNull(today?.tasks),
+ },
+ ...activeDays.map((day, index) => ({
+ id: opaqueId('activity-day', day.date, `day-${index}`),
+ label: 'Activity day',
+ tasks: nonNegativeOrNull(day.tasks) ?? 0,
+ successes: nonNegativeOrNull(day.successes) ?? 0,
+ failures: nonNegativeOrNull(day.failures) ?? 0,
+ successRate: percentageOrNull(day.successRate),
+ isToday: day.isToday === true,
+ })),
+ ];
+}
+
+function projectedGoals(goalsData) {
+ if (!Array.isArray(goalsData?.goals)) return null;
+ const goals = goalsData.goals;
+ const children = new Map(goals.map((goal) => [goal?.id, 0]));
+ goals.forEach((goal) => {
+ if (goal?.parentId && children.has(goal.parentId)) children.set(goal.parentId, children.get(goal.parentId) + 1);
+ });
+ return goals.map((goal, index) => {
+ const milestones = Array.isArray(goal?.milestones) ? goal.milestones : [];
+ const todos = Array.isArray(goal?.todos) ? goal.todos : [];
+ return {
+ id: opaqueId('goal', goal?.id, `goal-${index}`),
+ label: 'Active goal',
+ status: coarseStatus(goal?.status || 'active'),
+ progress: percentageOrNull(goal?.progress) ?? 0,
+ milestoneTotal: milestones.length,
+ milestoneDone: milestones.filter((milestone) => milestone?.completed === true || Boolean(safeText(milestone?.completedAt, ''))).length,
+ todoTotal: todos.length,
+ todoPending: todos.filter((todo) => !['completed', 'done'].includes(String(todo?.status || '').toLowerCase()) && todo?.completed !== true).length,
+ childCount: children.get(goal?.id) || 0,
+ };
+ });
+}
+
+function projectedMemory(graph) {
+ if (!graph || !Array.isArray(graph.nodes)) return null;
+ const buckets = new Map();
+ const categoryById = new Map();
+ for (const node of graph.nodes) {
+ const category = safeText(node?.category || node?.brainType, 'other').toLowerCase() || 'other';
+ categoryById.set(node?.id, category);
+ const bucket = buckets.get(category) || { count: 0, importance: 0 };
+ bucket.count += 1;
+ bucket.importance += Math.max(0, finiteOrNull(node?.importance) ?? 1);
+ buckets.set(category, bucket);
+ }
+ const bridgeCounts = new Map();
+ for (const edge of Array.isArray(graph.edges) ? graph.edges : []) {
+ const from = categoryById.get(edge?.source);
+ const to = categoryById.get(edge?.target);
+ if (!from || !to || from === to) continue;
+ bridgeCounts.set(from, (bridgeCounts.get(from) || 0) + 1);
+ bridgeCounts.set(to, (bridgeCounts.get(to) || 0) + 1);
+ }
+ return [...buckets.entries()]
+ .sort(([a, left], [b, right]) => right.count - left.count || a.localeCompare(b))
+ .map(([category, bucket]) => ({
+ id: opaqueId('memory-category', category, 'other'),
+ label: 'Memory category',
+ count: bucket.count,
+ importance: bucket.importance,
+ bridgeCount: bridgeCounts.get(category) || 0,
+ totalMemories: graph.nodes.length,
+ totalEdges: Array.isArray(graph.edges) ? graph.edges.length : 0,
+ hasEmbeddings: graph.hasEmbeddings === true,
+ }));
+}
+
+export function projectedStorage(introspection) {
+ if (!introspection || typeof introspection !== 'object') return null;
+ const items = [];
+ const db = introspection.db;
+ const fsSection = introspection.fs;
+ const dbOnline = Array.isArray(db?.tables);
+ items.push({
+ id: 'database',
+ label: 'PostgreSQL',
+ area: 'database',
+ status: db === null ? 'offline' : (dbOnline ? 'online' : 'unknown'),
+ tableCount: dbOnline ? db.tables.length : null,
+ sizeBytes: finiteOrNull(db?.sizeBytes),
+ migrations: db?.migrations?.applied === undefined ? null : nonNegativeOrNull(db.migrations.applied),
+ });
+ const fsOnline = Array.isArray(fsSection?.domains);
+ items.push({
+ id: 'filesystem',
+ label: 'PortOS data files',
+ area: 'filesystem',
+ status: fsSection === null ? 'offline' : (fsOnline ? 'online' : 'unknown'),
+ domainCount: fsOnline ? fsSection.domains.length : null,
+ sizeBytes: finiteOrNull(fsSection?.totalBytes),
+ fileCount: nonNegativeOrNull(fsSection?.totalFiles),
+ });
+ if (!dbOnline) items.push({ id: 'database-attention', label: 'Data attention', area: 'anomaly', status: db === null ? 'offline' : 'unknown', count: 1 });
+ if (!fsOnline) items.push({ id: 'filesystem-attention', label: 'Data attention', area: 'anomaly', status: fsSection === null ? 'offline' : 'unknown', count: 1 });
+ return items;
+}
+
+function projectedOperations({ cosStatus, review, backupState, notifications, character, voiceConfig, memory, diskPercent, inboxCounts }) {
+ const values = [cosStatus, review, backupState, notifications, character, voiceConfig, memory, diskPercent, inboxCounts];
+ if (!values.some((value) => value !== null && value !== undefined)) return null;
+ const status = /failed|error|unhealthy/i.test(String(backupState?.status || ''))
+ ? 'error'
+ : ((review?.alert || 0) > 0 || cosStatus?.paused === true ? 'attention' : (cosStatus?.running ? 'active' : 'steady'));
+ return [{
+ id: 'overview',
+ label: 'PortOS operations',
+ status,
+ cos: cosStatus ? {
+ running: cosStatus.running === true,
+ paused: cosStatus.paused === true,
+ activeAgents: nonNegativeOrNull(cosStatus.activeAgents),
+ pausedAgents: nonNegativeOrNull(cosStatus.pausedAgents),
+ } : null,
+ ai: cosStatus ? {
+ running: cosStatus.running === true,
+ activeAgents: nonNegativeOrNull(cosStatus.activeAgents),
+ } : null,
+ review: review ? {
+ total: nonNegativeOrNull(review.total),
+ cos: nonNegativeOrNull(review.cos),
+ alerts: nonNegativeOrNull(review.alert),
+ } : null,
+ backup: backupState ? {
+ status: coarseStatus(backupState.status),
+ filesChanged: nonNegativeOrNull(backupState.filesChanged),
+ } : null,
+ notifications: notifications ? {
+ total: nonNegativeOrNull(notifications.total),
+ unread: nonNegativeOrNull(notifications.unread),
+ } : null,
+ inbox: inboxCounts ? {
+ total: nonNegativeOrNull(inboxCounts.total),
+ needsReview: nonNegativeOrNull(inboxCounts.needs_review),
+ classifying: nonNegativeOrNull(inboxCounts.classifying),
+ } : null,
+ character: character ? { level: nonNegativeOrNull(character.level) } : null,
+ voice: voiceConfig ? {
+ enabled: voiceConfig.enabled === true,
+ } : null,
+ memory: memory ? {
+ usedPercent: memory.total > 0 ? Math.round((memory.used / memory.total) * 100) : null,
+ } : null,
+ diskPercent: percentageOrNull(diskPercent),
+ }];
+}
+
+async function projectedJira(appConfig, featuresState) {
+ if (!Array.isArray(featuresState?.features)) return null;
+ const jiraFeature = featuresState.features.find((feature) => feature?.id === 'jira');
+ if (!jiraFeature) return null;
+ if (jiraFeature.enabled !== true) return [];
+ if (!Array.isArray(appConfig)) return null;
+ const specs = [...new Map(appConfig
+ .filter((app) => app?.jira?.enabled && app.jira.instanceId && app.jira.projectKey)
+ .map((app) => [`${app.jira.instanceId}/${app.jira.projectKey}`, {
+ instanceId: app.jira.instanceId,
+ projectKey: app.jira.projectKey,
+ }]))
+ .values()];
+ if (specs.length === 0) return [];
+ const batches = await Promise.all(specs.map((spec) => fetchMyCurrentSprintTickets(spec.instanceId, spec.projectKey)
+ .then((tickets) => Array.isArray(tickets) ? { tickets, failed: false } : { tickets: [], failed: true })
+ .catch(() => ({ tickets: [], failed: true }))));
+ if (batches.some((batch) => batch.failed)) return null;
+ return projectedJiraTickets(batches.flatMap((batch) => batch.tickets));
+}
+
+export function projectedJiraTickets(tickets) {
+ const groups = new Map();
+ for (const ticket of Array.isArray(tickets) ? tickets : []) {
+ const rawStatus = String(ticket?.statusCategory || ticket?.status || '').toLowerCase();
+ if (/done|complete|closed/.test(rawStatus)) continue;
+ const status = /progress|active|doing/.test(rawStatus) ? 'active' : (/block|error|fail/.test(rawStatus) ? 'blocked' : 'pending');
+ const current = groups.get(status) || { count: 0, storyPoints: 0, urgent: 0 };
+ current.count += 1;
+ current.storyPoints += nonNegativeOrNull(ticket?.storyPoints) || 0;
+ if (/highest|critical|urgent/i.test(String(ticket?.priority || ''))) current.urgent += 1;
+ groups.set(status, current);
+ }
+ return [...groups.entries()].sort(([left], [right]) => left.localeCompare(right)).map(([status, values]) => ({
+ id: `jira-${status}`,
+ label: 'Current work summary',
+ status,
+ ...values,
+ }));
+}
+
+function healthSnapshot({ apps, cosStatus, review, backupState, notifications, character, voiceConfig, memory, diskPercent }) {
+ const health = {
+ id: 'overview',
+ label: 'PortOS health',
+ apps: appSummary(apps),
+ cos: cosStatus ? {
+ running: cosStatus.running === true,
+ activeAgents: nonNegativeOrNull(cosStatus.activeAgents),
+ pausedAgents: nonNegativeOrNull(cosStatus.pausedAgents),
+ } : null,
+ review: review ? {
+ total: nonNegativeOrNull(review.total),
+ cos: nonNegativeOrNull(review.cos),
+ alerts: nonNegativeOrNull(review.alert),
+ } : null,
+ backup: backupState ? {
+ status: coarseStatus(backupState.status),
+ filesChanged: nonNegativeOrNull(backupState.filesChanged),
+ } : null,
+ memory: memory ? {
+ usedPercent: memory.total > 0 ? Math.round((memory.used / memory.total) * 100) : null,
+ } : null,
+ notifications: notifications ? {
+ total: nonNegativeOrNull(notifications.total),
+ unread: nonNegativeOrNull(notifications.unread),
+ } : null,
+ character: character ? { level: nonNegativeOrNull(character.level) } : null,
+ voice: voiceConfig ? {
+ enabled: voiceConfig.enabled === true,
+ } : null,
+ diskPercent: percentageOrNull(diskPercent),
+ };
+ const available = [apps, cosStatus, review, backupState, notifications, character, voiceConfig, memory, diskPercent]
+ .some((value) => value !== null && value !== undefined);
+ if (!available) return null;
+ const hasError = /failed|error|unhealthy/i.test(String(health.backup?.status || ''))
+ || (health.diskPercent ?? 0) >= 95
+ || (health.memory?.usedPercent ?? 0) >= 95;
+ const needsAttention = (health.apps?.stopped || 0) > 0
+ || (health.apps?.unknown || 0) > 0
+ || (health.review?.alerts || 0) > 0
+ || cosStatus?.paused === true
+ || (health.diskPercent ?? 0) >= 85
+ || (health.memory?.usedPercent ?? 0) >= 85;
+ health.status = hasError ? 'error' : (needsAttention ? 'attention' : 'healthy');
+ return health;
+}
+
+export async function collectEidoverseWorldSources({ signal } = {}) {
+ throwIfAborted(signal);
+ const reads = await waitWithSignal(Promise.all([
+ getAppStatuses().catch(() => null),
+ getAllApps({ includeArchived: false }).catch(() => null),
+ getAgents().catch(() => null),
+ getCosTasks().catch(() => null),
+ getCosStatus().catch(() => null),
+ getPendingCounts().catch(() => null),
+ getInstanceFeatures().catch(() => null),
+ getPeers().catch(() => null),
+ backup.getState().catch(() => null),
+ getCountsByType().catch(() => null),
+ getCharacter({ withSkills: false, withMetrics: false }).catch(() => null),
+ getVoiceConfig().catch(() => null),
+ getMemoryStats().catch(() => null),
+ getDiskUsagePercent(),
+ getTodayActivity().catch(() => null),
+ getVelocityMetrics().catch(() => null),
+ getActivityCalendar(12).catch(() => null),
+ getGoals().catch(() => null),
+ getBrainGraphOverview({ limit: 100 }).catch(() => null),
+ getInboxLogCounts().catch(() => null),
+ getOpenWorldIntrospection().catch(() => null),
+ ]), signal);
+ const [apps, appConfig, agents, taskState, cosStatus, review, featuresState, peers, backupState, notifications, character, voiceConfig, memory, diskPercent, todayActivity, velocity, activityCalendar, goalsData, memoryGraph, inboxCounts, introspection] = reads;
+
+ const projectedAgents = Array.isArray(agents)
+ ? agents.filter((agent) => ['running', 'paused'].includes(agent?.status)).map((agent, index) => ({
+ id: opaqueId('agent', agent.id, `agent-${index}`), label: 'Active agent', status: coarseStatus(agent.status),
+ }))
+ : null;
+ const projectedTasks = Array.isArray(taskState?.tasks)
+ ? taskState.tasks
+ .filter((task) => !['completed', 'done', 'archived'].includes(String(task?.status || '').toLowerCase()))
+ .map((task, index) => ({
+ id: opaqueId('task', task.id, `task-${index}`), label: 'Active task', status: coarseStatus(task.status || 'pending'),
+ }))
+ : null;
+ const projectedFeatures = Array.isArray(featuresState?.features)
+ ? featuresState.features.map((feature) => ({
+ id: safeText(feature.id, 'feature'), label: 'District feature', enabled: feature.enabled === true,
+ }))
+ : null;
+ const projectedPeers = Array.isArray(peers)
+ ? peers.map((peer, index) => ({
+ id: opaqueId('peer', peer.instanceId || peer.id, `peer-${index}`),
+ label: 'Federated peer',
+ enabled: peer.enabled !== false,
+ fullSync: peer.fullSync === true,
+ status: coarseStatus(peer.status),
+ }))
+ : null;
+ const health = healthSnapshot({ apps, cosStatus, review, backupState, notifications, character, voiceConfig, memory, diskPercent });
+ const jira = await waitWithSignal(projectedJira(appConfig, featuresState), signal);
+
+ return {
+ apps: projectedApps(apps),
+ agents: projectedAgents,
+ tasks: projectedTasks,
+ features: projectedFeatures,
+ peers: projectedPeers,
+ health,
+ productivity: projectedProductivity(todayActivity, velocity, taskState),
+ activity: projectedActivity(activityCalendar),
+ goals: projectedGoals(goalsData),
+ memory: projectedMemory(memoryGraph),
+ storage: projectedStorage(introspection),
+ jira,
+ operations: projectedOperations({ cosStatus, review, backupState, notifications, character, voiceConfig, memory, diskPercent, inboxCounts }),
+ };
+}
diff --git a/server/services/eidoverseWorldSources.test.js b/server/services/eidoverseWorldSources.test.js
new file mode 100644
index 000000000..bfaff4358
--- /dev/null
+++ b/server/services/eidoverseWorldSources.test.js
@@ -0,0 +1,207 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const sources = vi.hoisted(() => ({}));
+
+vi.mock('node:fs/promises', () => ({
+ statfs: vi.fn(async () => ({
+ blocks: 100,
+ bsize: 1,
+ bavail: 100 - sources.diskPercent,
+ })),
+}));
+vi.mock('./apps.js', () => ({
+ getAppStatuses: vi.fn(async () => sources.apps),
+ getAllApps: vi.fn(async () => sources.appConfig),
+}));
+vi.mock('./cos.js', () => ({
+ getAgents: vi.fn(async () => sources.agents),
+ getCosTasks: vi.fn(async () => sources.taskState),
+ getStatus: vi.fn(async () => sources.cosStatus),
+ getTodayActivity: vi.fn(async () => sources.todayActivity),
+}));
+vi.mock('./review.js', () => ({ getPendingCounts: vi.fn(async () => sources.review) }));
+vi.mock('./instances.js', () => ({ getPeers: vi.fn(async () => sources.peers) }));
+vi.mock('./instanceFeatures.js', () => ({
+ getInstanceFeatures: vi.fn(async () => sources.featuresState),
+}));
+vi.mock('./backup.js', () => ({ getState: vi.fn(async () => sources.backupState) }));
+vi.mock('./notifications.js', () => ({
+ getCountsByType: vi.fn(async () => sources.notifications),
+}));
+vi.mock('./character.js', () => ({ getCharacter: vi.fn(async () => sources.character) }));
+vi.mock('./voice/config.js', () => ({
+ getVoiceConfig: vi.fn(async () => sources.voiceConfig),
+}));
+vi.mock('../lib/memoryStats.js', () => ({
+ getMemoryStats: vi.fn(async () => sources.memory),
+}));
+vi.mock('./identity.js', () => ({ getGoals: vi.fn(async () => sources.goalsData) }));
+vi.mock('./productivity.js', () => ({
+ getActivityCalendar: vi.fn(async () => sources.activityCalendar),
+ getVelocityMetrics: vi.fn(async () => sources.velocity),
+}));
+vi.mock('./brainGraph.js', () => ({
+ getBrainGraphOverview: vi.fn(async () => sources.memoryGraph),
+}));
+vi.mock('./brainStorage.js', () => ({
+ getInboxLogCounts: vi.fn(async () => sources.inboxCounts),
+}));
+vi.mock('./openWorldIntrospection.js', () => ({
+ getOpenWorldIntrospection: vi.fn(async () => sources.introspection),
+}));
+vi.mock('./jira.js', () => ({
+ fetchMyCurrentSprintTickets: vi.fn(async () => []),
+}));
+
+const { collectEidoverseWorldSources } = await import('./eidoverseWorldSources.js');
+
+beforeEach(() => {
+ Object.assign(sources, {
+ apps: [{ id: 'app-example', overallStatus: 'online', managed: true }],
+ appConfig: [],
+ agents: [],
+ taskState: { tasks: [], awaitingApproval: [] },
+ cosStatus: { running: false, paused: false, activeAgents: 0, pausedAgents: 0 },
+ review: { total: 0, cos: 0, alert: 0 },
+ featuresState: { features: [] },
+ peers: [],
+ backupState: { status: 'complete', filesChanged: 0 },
+ notifications: { total: 0, unread: 0 },
+ character: { level: 1 },
+ voiceConfig: { enabled: false },
+ memory: { total: 100, used: 10 },
+ diskPercent: 10,
+ todayActivity: null,
+ velocity: null,
+ activityCalendar: { weeks: [] },
+ goalsData: { goals: [] },
+ memoryGraph: { nodes: [], edges: [], hasEmbeddings: false },
+ inboxCounts: { total: 0, needs_review: 0, classifying: 0 },
+ introspection: {
+ db: { tables: [], sizeBytes: 0, migrations: { applied: 0 } },
+ fs: { domains: [], totalBytes: 0, totalFiles: 0 },
+ },
+ });
+});
+
+describe('Eidoverse world source aggregation', () => {
+ it.each([
+ ['critical disk usage', { diskPercent: 95 }, 'error'],
+ ['failed backup', { backupState: { status: 'failed' } }, 'error'],
+ ['warning disk usage', { diskPercent: 85 }, 'attention'],
+ ['stopped app', { apps: [{ id: 'app-example', overallStatus: 'stopped' }] }, 'attention'],
+ ['healthy inputs', {}, 'healthy'],
+ ])('maps %s to the Nexus health state', async (_case, overrides, expected) => {
+ Object.assign(sources, overrides);
+
+ const result = await collectEidoverseWorldSources();
+
+ expect(result.health.status).toBe(expected);
+ });
+
+ it.each([
+ ['failed backup', { backupState: { status: 'failed' } }, 'error'],
+ ['review alert', { review: { total: 1, cos: 0, alert: 1 } }, 'attention'],
+ ['running CoS', { cosStatus: { running: true, paused: false, activeAgents: 1 } }, 'active'],
+ ['idle system', {}, 'steady'],
+ ])('maps %s to the operations signal', async (_case, overrides, expected) => {
+ Object.assign(sources, overrides);
+
+ const result = await collectEidoverseWorldSources();
+
+ expect(result.operations).toEqual([
+ expect.objectContaining({ id: 'overview', status: expected }),
+ ]);
+ });
+
+ it('distinguishes unreadable sources from confirmed empty sources', async () => {
+ Object.assign(sources, {
+ goalsData: null,
+ memoryGraph: null,
+ activityCalendar: null,
+ });
+ const unreadable = await collectEidoverseWorldSources();
+
+ expect(unreadable).toMatchObject({
+ goals: null,
+ memory: null,
+ activity: null,
+ });
+
+ Object.assign(sources, {
+ goalsData: { goals: [] },
+ memoryGraph: { nodes: [], edges: [] },
+ activityCalendar: { weeks: [] },
+ });
+ const empty = await collectEidoverseWorldSources();
+
+ expect(empty).toMatchObject({
+ goals: [],
+ memory: [],
+ activity: [],
+ });
+ });
+
+ it('groups app status without exposing app names', async () => {
+ sources.apps = [
+ { id: 'app-one', name: 'Example Secret One', overallStatus: 'online', managed: true },
+ { id: 'app-two', name: 'Example Secret Two', overallStatus: 'online', managed: false },
+ { id: 'app-three', name: 'Example Secret Three', overallStatus: 'stopped', managed: true },
+ ];
+
+ const result = await collectEidoverseWorldSources();
+
+ expect(result.apps).toEqual([
+ { id: 'apps-active', label: 'Managed app group', status: 'active', count: 2, managed: 1 },
+ { id: 'apps-attention', label: 'Managed app group', status: 'attention', count: 1, managed: 1 },
+ ]);
+ expect(JSON.stringify(result.apps)).not.toContain('Example Secret');
+ });
+
+ it('orders recent active days first after the activity summary', async () => {
+ sources.activityCalendar = {
+ weeks: [[
+ { date: '2026-01-01', tasks: 1, successes: 1 },
+ { date: '2026-01-02', tasks: 0, successes: 0 },
+ { date: '2026-01-03', tasks: 3, successes: 2, isToday: true },
+ ]],
+ summary: { activeDays: 2, totalTasks: 4, totalSuccesses: 3 },
+ maxTasks: 3,
+ };
+
+ const result = await collectEidoverseWorldSources();
+
+ expect(result.activity[0]).toMatchObject({ id: 'summary', activeDays: 2 });
+ expect(result.activity.slice(1).map(({ tasks }) => tasks)).toEqual([3, 1]);
+ });
+
+ it('emits a bounded productivity aggregate rather than task records', async () => {
+ sources.todayActivity = {
+ stats: { completed: 3, succeeded: 2, failed: 1, successRate: 67 },
+ isRunning: true,
+ isPaused: false,
+ };
+ sources.velocity = { velocity: 1.5, avgPerDay: 2, historicalDays: 7 };
+ sources.taskState = {
+ tasks: [
+ { id: 'task-one', title: 'Example private task', status: 'pending' },
+ { id: 'task-two', title: 'Example completed task', status: 'completed' },
+ ],
+ awaitingApproval: [{ id: 'approval-one' }],
+ };
+
+ const result = await collectEidoverseWorldSources();
+
+ expect(result.productivity).toEqual([
+ expect.objectContaining({
+ id: 'summary',
+ completedToday: 3,
+ succeededToday: 2,
+ failedToday: 1,
+ queue: { pendingApprovals: 1, pendingTasks: 1, total: 2 },
+ running: true,
+ }),
+ ]);
+ expect(JSON.stringify(result.productivity)).not.toContain('Example private task');
+ });
+});