From 56e946de3545a9a13a1076fe1d68e5d3565faa56 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Sun, 30 Aug 2026 17:17:03 -0700 Subject: [PATCH 01/20] [issue-5500] feat: ship Eidoverse World Design V2 Make PortOS installations project a legible, illuminated world with semantic districts, privacy-safe live signals, and staged reconciliation. Ship only a versioned asset recipe and deterministic local lock so Eidoverse owns model downloads and caches. Add upgrade-safe migration, rollback, management UI, documentation, and complete runtime coverage. --- .../eidoverse/EidoverseWorldDrawer.jsx | 424 ++++++ client/src/pages/Eidoverse.jsx | 672 ++++----- client/src/pages/Eidoverse.test.jsx | 332 +++-- docs/SELF_UPDATE.md | 10 + docs/STORAGE.md | 2 +- docs/features/eidoverse.md | 178 ++- .../323-eidoverse-world-design-v2.js | 44 + .../323-eidoverse-world-design-v2.test.js | 79 + server/lib/README.md | 1 + server/lib/eidoverseWorldDesign.js | 680 +++++++++ server/lib/eidoverseWorldDesign.test.js | 264 ++++ server/lib/index.js | 1 + server/lib/validation.js | 139 +- server/routes/eidoverseWorldRoutes.test.js | 31 + server/services/bootstrap.js | 11 + server/services/eidoverseWorld.js | 1304 ++++++++--------- .../services/eidoverseWorld.runtime.test.js | 290 +++- server/services/eidoverseWorld.test.js | 297 +++- server/services/eidoverseWorldProjection.js | 555 +++++++ server/services/eidoverseWorldSources.js | 481 ++++++ 20 files changed, 4428 insertions(+), 1367 deletions(-) create mode 100644 client/src/components/eidoverse/EidoverseWorldDrawer.jsx create mode 100644 scripts/migrations/323-eidoverse-world-design-v2.js create mode 100644 scripts/migrations/323-eidoverse-world-design-v2.test.js create mode 100644 server/lib/eidoverseWorldDesign.js create mode 100644 server/lib/eidoverseWorldDesign.test.js create mode 100644 server/services/eidoverseWorldProjection.js create mode 100644 server/services/eidoverseWorldSources.js diff --git a/client/src/components/eidoverse/EidoverseWorldDrawer.jsx b/client/src/components/eidoverse/EidoverseWorldDrawer.jsx new file mode 100644 index 000000000..07686b30e --- /dev/null +++ b/client/src/components/eidoverse/EidoverseWorldDrawer.jsx @@ -0,0 +1,424 @@ +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, + onSave, + onProject, + onReset, + onRefreshAssets, +}) { + const [activeTab, setActiveTab] = useDrawerTab('eidoverseTab', 'experience', TAB_IDS); + const [resetArmed, setResetArmed] = useState(false); + const design = worldState?.design || {}; + const reconciliation = design.reconciliation || {}; + const projectionSummary = worldState?.projection?.lastSummary || {}; + const busy = configStatus === 'saving' || projectionStatus === 'running'; + + 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

+

{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. +

+ {(recipeDraft?.districts || []).map((district) => ( +
+
+
+
+
+

{district.direction} · {district.landmark}

+
+ +
+
+ {district.sources.map((source) => ( +
+ mutateRecipe((current) => ({ + ...current, + includes: { ...current.includes, [source]: event.target.checked }, + }))} + /> +
+ +
+ {projectionSummary.sourceAvailability?.[source] === false + ? 'Stale · last good held' + : (projectionSummary.sourceCounts?.[source] ?? 'Not projected')} + {SOURCE_ROUTES[source] && Open in PortOS} +
+
+ +
+ ))} +
+
+ ))} +
+ ); + + 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.05], + ['fog', 'Fog', 0, 3, 0.05], + ].map(([key, label, min, max, step]) => ( + + ))} + +
+
+ +
+
+
+

Portable asset recipe

+

Paths and search terms ship; model bytes stay in Eidoverse.

+
+ +
+
+ {Object.entries(recipeDraft?.assetRecipe?.slots || {}).map(([slot, recipe]) => { + const resolution = design.assetResolutions?.[slot]; + return ( +
+
+

{titleCase(slot)}

+ + {resolution?.source || 'pending'} + +
+

{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} +

+ +
+ ); + })} +
+
+
+ ); + + 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.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.

+
+ + +
+
+ +
+

Reset PortOS world design

+

Clears install-local overrides and the asset lock. Eidoverse model bytes and non-PortOS world entities are untouched.

+ {!resetArmed ? ( + + ) : ( +
+ + +
+ )} +
+
+ ); + + const panel = activeTab === 'experience' + ? identityFields + : activeTab === 'districts' + ? districtFields + : activeTab === 'appearance' + ? appearanceFields + : updateFields; + + return ( + +
{ event.preventDefault(); onSave(); }}> + {panel} + {activeTab !== 'updates' && ( +
+ {configStatus && configStatus !== 'saving' && ( +

+ {configStatus === 'saved' ? 'Saved locally and queued for projection.' : configStatus} +

+ )} + + +
+ )} +
+
+ ); +} diff --git a/client/src/pages/Eidoverse.jsx b/client/src/pages/Eidoverse.jsx index d00718d64..11eb21d0a 100644 --- a/client/src/pages/Eidoverse.jsx +++ b/client/src/pages/Eidoverse.jsx @@ -1,8 +1,17 @@ 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 { getApp, getEidoverseWorldStatus, @@ -15,6 +24,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 +59,23 @@ 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'; +}; + export default function Eidoverse() { const requestGeneration = useRef(0); const configDraftRevision = 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 +86,24 @@ 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 [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); + } + }, []); const prepare = useCallback(() => { const generation = ++requestGeneration.current; @@ -109,9 +114,11 @@ export default function Eidoverse() { setError(''); setHostUrl(''); setHostInfo(null); + setIframeReady(false); setSetupState(null); setWorldState(null); setRecipeDraft(null); + setAssetOverridesDraft({}); setProjectionStatus('idle'); setProjectionError(''); setConfigStatus(''); @@ -156,6 +163,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 +175,47 @@ export default function Eidoverse() { const runProjection = useCallback(async () => { setProjectionStatus('running'); setProjectionError(''); - try { - const result = await projectEidoverseWorld(silent); - setWorldState((current) => current - ? { - ...current, - projection: result.projection || current.projection, - presence: result.presence || current.presence, + const submittedRevision = configDraftRevision.current; + const pollGeneration = ++projectionPollGeneration.current; + const poll = () => { + if (projectionPollGeneration.current !== pollGeneration) return; + getEidoverseWorldStatus(silent).then((status) => { + if (projectionPollGeneration.current !== pollGeneration) return; + applyWorldResponse(status, { replaceDraft: false }); + }).catch(() => {}).finally(() => { + if (projectionPollGeneration.current === pollGeneration) { + projectionPollTimer.current = setTimeout(poll, 750); } - : current); + }); + }; + projectionPollTimer.current = setTimeout(poll, 750); + return projectEidoverseWorld(silent).then((result) => { + const replaceDraft = 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 +223,85 @@ 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; + 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); - }; - - 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), - }, - } - : 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], - }, - } - : current); - }; + }, [applyWorldResponse, assetOverridesDraft, hostInfo, hostUrl, humanName, recipeDraft, runProjection, setupState, worldName]); - 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]); + const runConfigAction = useCallback(async (payload) => { + const submittedRevision = configDraftRevision.current; + 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; + if (draftIsCurrent) configDraftRevision.current += 1; + applyWorldResponse(updated, { replaceDraft: draftIsCurrent }); + setConfigStatus(draftIsCurrent ? 'saved' : ''); + void runProjection().catch(() => {}); + }, [applyWorldResponse, runProjection]); const actions = ( <> {hostUrl && ( - + )} {appId && ( - +