.
-const PROVIDER_FORM_TABS = [
- { id: 'connection', label: 'Connection' },
- { id: 'models', label: 'Models' },
- { id: 'generation', label: 'Generation' },
- { id: 'environment', label: 'Environment' },
-];
-const PROVIDER_FORM_TAB_IDS = PROVIDER_FORM_TABS.map(t => t.id);
-
-// Numeric bounds for the editor's number inputs. Declared once so an input's own
-// `min`/`max` and the submit-time check that stands in for it (the drawer
-// unmounts inactive tabs, so the browser can't validate a field the user isn't
-// looking at) can never drift apart. Mirrors the provider schema in
-// `server/lib/aiToolkit/validation.js`; `timeout` is absent because it has a
-// shared parser (`parseTimeoutMs`) that already owns its bounds.
-const PROVIDER_FIELD_RANGES = {
- tuiPromptDelayMs: { min: 250, max: 60000 },
- contextWindow: { min: 512, max: 2097152 },
- numCtx: { min: 512, max: 1048576 },
- temperature: { min: 0, max: 2 },
- topP: { min: 0, max: 1 },
-};
-
-const rangeMessage = (label, { min, max }, unit = '') =>
- `${label} must be between ${min.toLocaleString()} and ${max.toLocaleString()}${unit ? ` ${unit}` : ''}`;
-
export default function AIProviders() {
const [providers, setProviders] = useState([]);
// The CoS Agent Runner's exec allowlist, published by GET /api/providers.
@@ -920,7 +880,7 @@ export default function AIProviders() {
{runOutput && (
-
{runOutput}
+
{runOutput}
)}
@@ -1084,1103 +1044,3 @@ export default function AIProviders() {
);
}
-
-function ProviderForm({ provider, onClose, onSave, onEditProvider, allProviders = [], localModels = { ollama: [], lmstudio: [], ctxById: {}, hardwareCompatibilityByBackend: {} }, runnerAllowedCommands = null }) {
- const [formData, setFormData] = useState({
- name: provider?.name || '',
- type: provider?.type || 'cli',
- command: provider?.command || '',
- args: provider?.args?.join(' ') || '',
- endpoint: provider?.endpoint || '',
- apiKey: '',
- allowCustomEndpoint: provider?.allowCustomEndpoint === true,
- models: provider?.models || [],
- hardwareRequirements: provider?.hardwareRequirements,
- modelHardwareRequirements: provider?.modelHardwareRequirements,
- defaultModel: provider?.defaultModel || '',
- effort: provider?.effort || '',
- lightModel: provider?.lightModel || '',
- mediumModel: provider?.mediumModel || '',
- heavyModel: provider?.heavyModel || '',
- fallbackProvider: provider?.fallbackProvider || '',
- fallbackModel: provider?.fallbackModel || '',
- numCtx: provider?.numCtx ?? '',
- // All three seed from the record ONLY. Seeding a value the provider does not
- // have would let an unrelated Save pin it — the editor must be able to leave
- // "unset" alone, since unset is what lets each backend keep its own default.
- temperature: provider?.temperature ?? '',
- topP: provider?.topP ?? '',
- thinking: provider?.thinking === true ? 'true' : provider?.thinking === false ? 'false' : '',
- contextWindow: provider?.contextWindow ?? '',
- timeout: provider?.timeout || 300000,
- enabled: provider?.enabled !== false,
- textTransportEnabled: provider?.textTransportEnabled === true
- && provider?.textTransportReadRiskAcknowledged === true,
- textTransportReadRiskAcknowledged: provider?.textTransportReadRiskAcknowledged === true,
- envVars: provider?.envVars || {},
- secretEnvVars: provider?.secretEnvVars || [],
- headlessArgs: provider?.headlessArgs?.join(' ') || '',
- tuiPromptDelayMs: provider?.tuiPromptDelayMs || 2500
- });
-
- const [activeTab, setActiveTab] = useDrawerTab('providerTab', 'connection', PROVIDER_FORM_TAB_IDS);
-
- const [newEnvKey, setNewEnvKey] = useState('');
- const [newEnvValue, setNewEnvValue] = useState('');
- const [newEnvSecret, setNewEnvSecret] = useState(false);
-
- // Live installed Ollama/LM Studio models, folded into the model pickers so a
- // local provider shows what's actually installed — not just the stale `models`
- // list stored on the provider record (the "Command R+ / Gemma missing" bug).
- // Passed down from the page, which already holds this status for the cards.
- const liveModelsFor = (p) => {
- const backend = localBackendForProvider(p);
- return backend ? localModels[backend] : [];
- };
-
- const liveHardwareFor = (p) => {
- const backend = localBackendForProvider(p);
- return backend ? localModels.hardwareCompatibilityByBackend?.[backend] || {} : {};
- };
-
- // Generation pickers (default + light/medium/heavy tiers) — drop embedding-only
- // models (and internal sentinels) so an embedding can't be chosen as a model
- // that runs prompts, consistent with the fallback picker below.
- const mergedModels = mergeModelLists(formData.models, liveModelsFor(formData));
- // The server publishes compatibility for both the provider runtime and any
- // explicitly annotated model. Unknown probe results stay in the list; only a
- // definitive mismatch is hidden.
- const capabilityProvider = {
- ...provider,
- ...formData,
- id: provider?.id,
- models: mergedModels,
- modelHardwareCompatibility: {
- ...provider?.modelHardwareCompatibility,
- ...liveHardwareFor(formData),
- },
- };
- const availableModels = filterHardwareCompatibleProviderModels(
- filterGenerationModels(mergedModels),
- capabilityProvider,
- );
- const configuredModels = [
- formData.defaultModel,
- formData.lightModel,
- formData.mediumModel,
- formData.heavyModel,
- ].filter((model) => model
- && !isEmbeddingModel(model)
- && !availableModels.includes(model)
- && !isProviderModelHardwareCompatible(capabilityProvider, model));
- // A provider can pin its tiers to the "use the CLI's own default" sentinel
- // while still publishing a real model catalog (Antigravity: `agy models`
- // lists real ids, but PortOS leaves the tiers on agy's own default). The
- // sentinel is filtered out of `availableModels`, so without an explicit
- // option for it the four selects below would hold a value matching no option
- // and render blank — reading as "no model configured" when one is.
- const configuredDefault = configuredDefaultIn(mergedModels);
- // The markers that identify a backed provider (`ollamaBacked`, `llamaBacked`,
- // `gatewayBacked`) are NOT form fields, so a shape built from `formData`
- // alone loses them — which hid the effort ladder on the OpenCode-Ollama
- // providers, whose ladder is keyed on `ollamaBacked`. Merge the live edits
- // over the stored record instead, so edits to command/endpoint/envVars count
- // immediately while the markers survive.
- // Shared option list for the Default Model + Light/Medium/Heavy tier selects,
- // so the sentinel option can't be added to some and missed on others.
- const modelSelectOptions = (
- <>
- None
- {configuredDefault && (
- Use the CLI's configured default
- )}
- {[...new Set([...configuredModels, ...availableModels])].map(model => (
-
- {modelOptionLabel(model, localModels.ctxById, capabilityProvider)}
- {!availableModels.includes(model) ? ' (unavailable on this machine)' : ''}
-
- ))}
- >
- );
-
- // Filter out current provider from fallback options (treat undefined enabled as enabled)
- const fallbackOptions = allProviders.filter(p => p.id !== provider?.id
- && p.enabled !== false
- && (isProviderHardwareCompatible(p) || p.id === formData.fallbackProvider));
-
- // The fallback model is a model OF the selected fallback provider, so its
- // option list comes from that provider's `models` — merged with the live
- // installed list for local backends, and embedding-only models dropped (a
- // fallback runs prompts, so `nomic-embed-text` must never be selectable here).
- const selectedFallbackProvider = allProviders.find(p => p.id === formData.fallbackProvider);
- const fallbackCapabilityProvider = selectedFallbackProvider && {
- ...selectedFallbackProvider,
- modelHardwareCompatibility: {
- ...selectedFallbackProvider.modelHardwareCompatibility,
- ...liveHardwareFor(selectedFallbackProvider),
- },
- };
- const fallbackModelOptions = filterGenerationModels(
- mergeModelLists(selectedFallbackProvider?.models, liveModelsFor(selectedFallbackProvider)),
- );
- const compatibleFallbackModelOptions = filterHardwareCompatibleProviderModels(
- fallbackModelOptions,
- fallbackCapabilityProvider,
- );
- const fallbackModelIsUnavailable = Boolean(
- formData.fallbackModel
- && !isEmbeddingModel(formData.fallbackModel)
- && !compatibleFallbackModelOptions.includes(formData.fallbackModel)
- && !isProviderModelHardwareCompatible(fallbackCapabilityProvider, formData.fallbackModel)
- );
- // `capabilityProvider`, not `formData`: the per-model windows model refresh
- // recorded (`modelContextWindows`) live on the RECORD and are not form fields,
- // so reading formData alone reported the assumed 128K for a model whose real
- // window PortOS already knows.
- const plannedContextLabel = formatContextLength(
- effectiveModelContextWindow(capabilityProvider, formData.defaultModel)
- );
- // `num_ctx` is meaningful for any provider whose tokens come from Ollama, not
- // just `api` ones: an `api` provider sends it on every request, while an
- // Ollama-backed CLI/TUI (claude-ollama, opencode-ollama) talks to the daemon
- // itself, so PortOS applies it by reloading Ollama at that window before the
- // run (server/services/ollamaAgentContext.js). Gating the field to `api` left
- // those providers stuck on Ollama's VRAM-based 32K auto-pick, which an agent
- // harness overruns mid-task. Reads `capabilityProvider` because the
- // `ollamaBacked` marker that identifies opencode-ollama (whose envVars carry
- // no ANTHROPIC_BASE_URL) is not a form field.
- const showsNumCtx = formData.type === 'api' || isOllamaBackedProvider(capabilityProvider);
- // Default sampling/reasoning controls, offered only for the backends PortOS
- // actually forwards them to (see `generationControlsFor`). Reads
- // `capabilityProvider` for the same reason `showsNumCtx` does: `llamaBacked`
- // and friends are record markers, not form fields.
- const generationControls = generationControlsFor(capabilityProvider);
- const parseOptionalIntField = (value) => {
- const input = String(value ?? '').trim();
- if (!input) return null;
- return /^\d+$/.test(input) ? Number(input) : value;
- };
- const parseNumberField = (value) => {
- const input = String(value ?? '').trim();
- return input === '' ? undefined : Number(input);
- };
-
- // Every constraint the inputs themselves declare (`required`, `type="url"`,
- // `min`/`max`), restated as a check the SUBMIT path runs. The drawer mounts
- // only the active tab, so the browser's own constraint validation sees just
- // that panel: a Save pressed from Models would otherwise ship an unparseable
- // endpoint or an out-of-range num_ctx straight to the server and surface it as
- // a generic API error with no pointer to the offending field. Returns the tab
- // that owns the first problem plus its message, or null when the form is
- // valid. Order matches the tab order so the user is sent to the earliest
- // offending panel.
- const findValidationError = () => {
- const text = (value) => String(value ?? '').trim();
- const outOfRange = (value, { min, max }) => {
- const input = text(value);
- if (input === '') return false;
- const parsed = Number(input);
- return !Number.isFinite(parsed) || parsed < min || parsed > max;
- };
-
- if (!text(formData.name)) return { tab: 'connection', message: 'Name is required' };
- if (isProcessProvider(formData) && !text(formData.command)) {
- return { tab: 'connection', message: 'Command is required' };
- }
- if (formData.type === 'api') {
- if (!text(formData.endpoint)) return { tab: 'connection', message: 'Endpoint is required' };
- // Mirrors the field's `type="url"` and the server's `z.string().url()`:
- // an absolute URL with a scheme.
- if (!URL.canParse(text(formData.endpoint))) {
- return { tab: 'connection', message: 'Endpoint must be a full URL, e.g. http://localhost:1234/v1' };
- }
- }
- if (formData.type === 'tui' && outOfRange(formData.tuiPromptDelayMs, PROVIDER_FIELD_RANGES.tuiPromptDelayMs)) {
- return { tab: 'connection', message: rangeMessage('Prompt Paste Delay', PROVIDER_FIELD_RANGES.tuiPromptDelayMs, 'ms') };
- }
- if (text(formData.timeout) !== '' && parseTimeoutMs(formData.timeout) == null) {
- return {
- tab: 'generation',
- message: `Timeout must be a whole number of ms between ${TIMEOUT_INPUT_MIN_MS.toLocaleString()} and ${TIMEOUT_INPUT_MAX_MS.toLocaleString()}`,
- };
- }
- if (outOfRange(formData.contextWindow, PROVIDER_FIELD_RANGES.contextWindow)) {
- return { tab: 'generation', message: rangeMessage('Planning Window', PROVIDER_FIELD_RANGES.contextWindow, 'tokens') };
- }
- if (showsNumCtx && outOfRange(formData.numCtx, PROVIDER_FIELD_RANGES.numCtx)) {
- return { tab: 'generation', message: rangeMessage('Local num_ctx', PROVIDER_FIELD_RANGES.numCtx, 'tokens') };
- }
- if (generationControls?.temperature && outOfRange(formData.temperature, PROVIDER_FIELD_RANGES.temperature)) {
- return { tab: 'generation', message: rangeMessage('Temperature', PROVIDER_FIELD_RANGES.temperature) };
- }
- if (generationControls?.topP && outOfRange(formData.topP, PROVIDER_FIELD_RANGES.topP)) {
- return { tab: 'generation', message: rangeMessage('Top-P', PROVIDER_FIELD_RANGES.topP) };
- }
- return null;
- };
-
- const handleSubmit = async (e) => {
- e.preventDefault();
-
- const invalid = findValidationError();
- if (invalid) {
- setActiveTab(invalid.tab);
- toast.error(invalid.message);
- return;
- }
-
- const tuiPromptDelay = parseInt(formData.tuiPromptDelayMs, 10);
- // Blank input is omitted so the server keeps the current value. Non-empty
- // invalid input (e.g. '1e3', '500', 'abc') is sent as the raw string so
- // Number() cannot silently save an exponent form the client/runner reject;
- // the server's digit-only preprocess leaves it alone and z.number() produces
- // a clear validation error.
- const parsedTimeout = parseTimeoutMs(formData.timeout);
- const timeoutInput = String(formData.timeout ?? '').trim();
- const data = {
- ...formData,
- args: formData.args ? formData.args.split(' ').filter(Boolean) : [],
- headlessArgs: formData.headlessArgs ? formData.headlessArgs.split(' ').filter(Boolean) : [],
- contextWindow: parseOptionalIntField(formData.contextWindow),
- numCtx: showsNumCtx ? parseOptionalIntField(formData.numCtx) : null,
- // A blank generation field clears back to "let the backend pick" — `null`
- // rather than `undefined`, which the server's spread-merge would read as
- // "unchanged" and leave the old pin in place.
- ...(generationControls?.temperature ? { temperature: parseNumberField(formData.temperature) ?? null } : {}),
- ...(generationControls?.topP ? { topP: parseNumberField(formData.topP) ?? null } : {}),
- ...(generationControls?.thinking
- ? { thinking: formData.thinking === '' ? null : formData.thinking === 'true' }
- : {}),
- };
- // `data` opens as a spread of the WHOLE form, so a control this provider
- // doesn't offer rides along regardless of the branches above — and a blank
- // field is `''`, which is not a number (or a boolean) the server schema
- // accepts. Drop what can't be used; the server merges by spread, so
- // anything already stored is left alone.
- if (!generationControls?.temperature) delete data.temperature;
- if (!generationControls?.topP) delete data.topP;
- if (!generationControls?.thinking) delete data.thinking;
- // The generation/fallback pickers filter out embedding-only models, so a
- // stored embedding (from an older config) would be hidden in the UI yet
- // still spread into `data` and silently persisted on an unrelated edit.
- // Clear any embedding value that slipped through so the saved record matches
- // what the picker allows.
- for (const field of ['defaultModel', 'lightModel', 'mediumModel', 'heavyModel', 'fallbackModel']) {
- if (isEmbeddingModel(data[field])) data[field] = '';
- }
- // Effort is meaningful only for providers/models that expose an effort
- // ladder. Clear a stale value when an edit switches to an effort-less
- // provider or Antigravity model; narrowed ladders are clamped by the
- // server and remain visible in the selector.
- if (!isProcessProvider(data) || !effortLevelsForProvider({ ...provider, ...data, id: provider?.id }, data.defaultModel)) {
- data.effort = '';
- }
- if (parsedTimeout != null) {
- data.timeout = parsedTimeout;
- } else if (timeoutInput === '') {
- delete data.timeout;
- } else {
- data.timeout = formData.timeout;
- }
- if (formData.type === 'tui') {
- if (Number.isFinite(tuiPromptDelay)) data.tuiPromptDelayMs = tuiPromptDelay;
- else delete data.tuiPromptDelayMs;
- } else {
- delete data.tuiPromptDelayMs;
- }
- // These controls belong only to the advertised Codex subscription
- // transport. Do not stamp false capability fields onto unrelated provider
- // records when their editor saves an ordinary connection change.
- if (provider?.textTransport !== 'codex-app-server') {
- delete data.textTransportEnabled;
- delete data.textTransportReadRiskAcknowledged;
- }
-
- // Only send apiKey if user entered a new value (avoid overwriting existing key with empty string)
- if (!data.apiKey && provider) {
- delete data.apiKey;
- }
-
- if (provider) {
- await api.updateProvider(provider.id, data);
- } else {
- await api.createProvider(data);
- }
-
- onSave();
- };
-
- return (
-
- {/* The Drawer body remounts per active tab (key={currentTab}), so this
- whole form subtree is torn down and rebuilt on every tab switch. All
- mutable state (formData and the new-env-var row) therefore lives in
- this component, above the Drawer — never inside the panels below. */}
-
-
- );
-}
diff --git a/client/src/pages/BrainScanReport.jsx b/client/src/pages/BrainScanReport.jsx
index b0f34a920c..0711fc015d 100644
--- a/client/src/pages/BrainScanReport.jsx
+++ b/client/src/pages/BrainScanReport.jsx
@@ -70,8 +70,13 @@ export default function BrainScanReport() {
Brain Links
-
-
{data.link.title}
+
+
+ {data.link.title}
+
{data.link.url}
diff --git a/client/src/pages/Browser.jsx b/client/src/pages/Browser.jsx
index 61edaad53c..95ac7d95f1 100644
--- a/client/src/pages/Browser.jsx
+++ b/client/src/pages/Browser.jsx
@@ -295,7 +295,7 @@ export default function BrowserPage() {
-
+
{logs || }
diff --git a/client/src/pages/CatalogIngredient.jsx b/client/src/pages/CatalogIngredient.jsx
index c62926dc09..ec37fdfbb6 100644
--- a/client/src/pages/CatalogIngredient.jsx
+++ b/client/src/pages/CatalogIngredient.jsx
@@ -482,7 +482,10 @@ export default function CatalogIngredient() {
-
+
{record.name || '(untitled)'}
diff --git a/client/src/pages/CatalogIngredient.test.jsx b/client/src/pages/CatalogIngredient.test.jsx
index c21d05e22f..0512b7b9a6 100644
--- a/client/src/pages/CatalogIngredient.test.jsx
+++ b/client/src/pages/CatalogIngredient.test.jsx
@@ -129,6 +129,18 @@ beforeEach(() => {
});
describe('CatalogIngredient — character sheet', () => {
+ // The h1 is the ONLY place the ingredient's name appears on this route, and
+ // clipped text is neither selectable nor expandable — the tooltip is the sole
+ // access path to the rest of a long name on a phone (#5694).
+ it('exposes the full record name through the heading title attribute', async () => {
+ const longName = 'Augusta Ada King, Countess of Lovelace, Analytical Engine Programmer and Mathematician';
+ getCatalogIngredientDetails.mockImplementation(async () => detailsOf({ ...CHAR_FIXTURE, name: longName }));
+ renderPage();
+
+ const heading = await screen.findByRole('heading', { name: longName });
+ expect(heading.getAttribute('title')).toBe(longName);
+ });
+
it('requires confirmation before detaching media', async () => {
detachCatalogIngredientMedia.mockResolvedValue({});
getCatalogIngredientDetails.mockImplementation(async () => ({
diff --git a/client/src/pages/CharacterSheet.jsx b/client/src/pages/CharacterSheet.jsx
index 7007dd333f..7741209522 100644
--- a/client/src/pages/CharacterSheet.jsx
+++ b/client/src/pages/CharacterSheet.jsx
@@ -413,8 +413,7 @@ export default function CharacterSheet() {
const hpPct = Math.max(0, Math.min(100, (char.hp / char.maxHp) * 100));
// Level is age-derived now (#2673) — it no longer maps to an XP threshold, so the old
- // "XP toward next level" bar is gone. XP survives as a plain cumulative stat here; the
- // birthday-progress bar lives on the OpenWorld HUD badge (full page reframe is Slice 5).
+ // "XP toward next level" bar is gone. XP survives as a plain cumulative stat here.
const birthdayPct = Number.isFinite(char.ageYears)
? Math.round((char.ageYears - Math.floor(char.ageYears)) * 100)
: 0;
diff --git a/client/src/pages/ChiefOfStaff.jsx b/client/src/pages/ChiefOfStaff.jsx
index a392e8824a..41c2573524 100644
--- a/client/src/pages/ChiefOfStaff.jsx
+++ b/client/src/pages/ChiefOfStaff.jsx
@@ -77,6 +77,12 @@ const CANVAS_AVATAR_STYLES = new Set([
// Shared brand gradient for the "CoS" wordmark headings (clipped to text).
const COS_TITLE_GRADIENT = 'linear-gradient(135deg, #6366f1, #8b5cf6, #06b6d4)';
+// How long the avatar keeps "speaking" after an event that gives it something to
+// say. Exported so tests drain the timer by this value rather than a literal —
+// a hardcoded drain that no longer covers the timer lets a state update escape
+// act() again.
+export const SPEAKING_MS = 2000;
+
function TabLoadFallback({ label }) {
return
;
}
@@ -357,7 +363,7 @@ export default function ChiefOfStaff() {
const shortDesc = taskDesc ? taskDesc.substring(0, 60) + (taskDesc.length > 60 ? '...' : '') : 'Working on task...';
setStatusMessage(`Running: ${shortDesc}`);
setSpeaking(true);
- setTimeout(() => setSpeaking(false), 2000);
+ setTimeout(() => setSpeaking(false), SPEAKING_MS);
// Track active agent metadata for dynamic avatar resolution
if (data?.metadata) setActiveAgentMeta(data.metadata);
// Initialize empty output buffer for new agent
@@ -392,7 +398,7 @@ export default function ChiefOfStaff() {
const success = data?.result?.success;
setStatusMessage(success ? "Task completed successfully" : "Task failed - checking errors...");
setSpeaking(true);
- setTimeout(() => setSpeaking(false), 2000);
+ setTimeout(() => setSpeaking(false), SPEAKING_MS);
// Clear active agent metadata so avatar reverts to default
setActiveAgentMeta(null);
// Clean up live output buffer for completed agent to prevent memory growth
@@ -415,7 +421,7 @@ export default function ChiefOfStaff() {
setAgentState('investigating');
setStatusMessage(summarizeHealthIssues(data.issues));
setSpeaking(true);
- setTimeout(() => setSpeaking(false), 2000);
+ setTimeout(() => setSpeaking(false), SPEAKING_MS);
}
};
socket.on('cos:health:check', handleHealthCheck);
@@ -474,7 +480,7 @@ export default function ChiefOfStaff() {
setAgentState('thinking');
setStatusMessage("Starting daemon - scanning for tasks...");
setSpeaking(true);
- setTimeout(() => setSpeaking(false), 2000);
+ setTimeout(() => setSpeaking(false), SPEAKING_MS);
fetchData();
}
};
@@ -530,7 +536,7 @@ export default function ChiefOfStaff() {
setAgentState('thinking');
setStatusMessage("Evaluating tasks...");
setSpeaking(true);
- setTimeout(() => setSpeaking(false), 2000);
+ setTimeout(() => setSpeaking(false), SPEAKING_MS);
} catch (err) {
toast.error(err.message);
}
diff --git a/client/src/pages/ChiefOfStaff.test.jsx b/client/src/pages/ChiefOfStaff.test.jsx
index 3b506eb1ef..885ec0c36e 100644
--- a/client/src/pages/ChiefOfStaff.test.jsx
+++ b/client/src/pages/ChiefOfStaff.test.jsx
@@ -1,4 +1,4 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, screen, waitFor, fireEvent, act, within } from '@testing-library/react';
import { MemoryRouter, Routes, Route } from 'react-router';
@@ -61,7 +61,7 @@ vi.mock('../hooks/useProviderModels', () => ({
}),
}));
-const ChiefOfStaff = (await import('./ChiefOfStaff')).default;
+const { default: ChiefOfStaff, SPEAKING_MS } = await import('./ChiefOfStaff');
const config = {
avatarStyle: 'svg',
@@ -104,14 +104,49 @@ beforeEach(() => {
localLlm.getToolUseModels.mockResolvedValue({ models: [] });
});
-const renderConfigTab = () => render(
-
+// Tests that drain a debounce or the avatar's speaking timer install fake timers
+// (see `withFakeTimers` below); this restores real ones for everyone else.
+afterEach(() => vi.useRealTimers());
+
+// `shouldAdvanceTime` keeps the clock ticking on its own so testing-library's
+// `waitFor`/`findBy*` — which poll on a (now faked) interval and do not know how
+// to pump vitest's clock — still resolve, while `advanceTimersByTimeAsync` can
+// still jump a timer window instantly instead of sleeping through it.
+const withFakeTimers = () => vi.useFakeTimers({ shouldAdvanceTime: true });
+
+const renderPageAt = (tab) => render(
+
} />
,
);
+// #5857 — the page fans out several mocked reads on mount and each resolution
+// is its own macrotask, so a bare `findBy*` straight after render polls blind
+// through that whole chain. Under a contended worker it ran past testing-
+// library's async budget and flaked, while passing in isolation. The page
+// already publishes an exact settle signal — the loading branch's busy region
+// (asserted by `ChiefOfStaff loading skeleton` below) — so wait for that to
+// clear and let the following query spend its budget on one render instead of
+// the entire mount. Raising `asyncUtilTimeout` / lowering `maxWorkers` was
+// already spent on this file twice (#3474, client/vitest.config.js); the fix
+// is to settle, not to buy more budget.
+//
+// Every test that reaches into a tab's contents mounts through here. Only the
+// loading-skeleton guards below call `renderPageAt` bare — they hold a core
+// read open precisely so the busy branch is what renders, and settling would
+// hang.
+const renderSettledAt = async (tab) => {
+ const result = renderPageAt(tab);
+ await waitFor(() => expect(
+ screen.queryByRole('status', { name: 'Loading Chief of Staff' }),
+ ).toBeNull());
+ return result;
+};
+
+const renderSettledConfigTab = () => renderSettledAt('config');
+
// #4144 — `/cos` is an `isFullWidth` route, so its `` is a bare
// `relative overflow-hidden`. The old centered `h-64` BrailleSpinner reserved
// none of the loaded two-pane shell, and the whole page jumped into place on
@@ -121,7 +156,7 @@ describe('ChiefOfStaff loading skeleton', () => {
it('reserves the two-pane shell instead of a centered spinner while loading', async () => {
// Hold the first fetch open so the loading branch is what renders.
api.getCosStatus.mockReturnValue(new Promise(() => {}));
- const { container } = renderConfigTab();
+ const { container } = renderPageAt('config');
const busy = await screen.findByRole('status');
expect(busy).toHaveAttribute('aria-busy', 'true');
@@ -140,13 +175,7 @@ describe('ChiefOfStaff loading skeleton', () => {
});
api.getCosActionableInsights.mockReturnValue(new Promise((resolve) => { releaseInsights = resolve; }));
- render(
-
-
- } />
-
- ,
- );
+ renderPageAt('tasks');
expect(await screen.findByText('Example queued task')).toBeInTheDocument();
expect(screen.queryByRole('status', { name: 'Loading Chief of Staff' })).toBeNull();
@@ -160,7 +189,7 @@ describe('ChiefOfStaff loading skeleton', () => {
describe('ChiefOfStaff handleForceEvaluate', () => {
it('does not toast success or advance the status message when the evaluate fails', async () => {
api.forceCosEvaluate.mockRejectedValue(new Error('evaluate failed'));
- renderConfigTab();
+ await renderSettledConfigTab();
const button = await screen.findByRole('button', { name: /Force Evaluate/i });
fireEvent.click(button);
@@ -175,7 +204,7 @@ describe('ChiefOfStaff handleForceEvaluate', () => {
it('toasts success and advances the status message after the evaluate resolves', async () => {
api.forceCosEvaluate.mockResolvedValue({ success: true });
- renderConfigTab();
+ await renderSettledConfigTab();
const button = await screen.findByRole('button', { name: /Force Evaluate/i });
fireEvent.click(button);
@@ -192,7 +221,7 @@ describe('ChiefOfStaff handleForceEvaluate', () => {
describe('ChiefOfStaff daemon pause controls', () => {
it('pauses new CoS scheduling through the persistent pause API', async () => {
api.getCosStatus.mockResolvedValue({ running: true, paused: false, config, stats: {} });
- renderConfigTab();
+ await renderSettledConfigTab();
fireEvent.click((await screen.findAllByRole('button', { name: /pause chief of staff scheduling/i }))[0]);
@@ -211,7 +240,7 @@ describe('ChiefOfStaff daemon pause controls', () => {
config,
stats: {},
});
- renderConfigTab();
+ await renderSettledConfigTab();
expect((await screen.findAllByText('Paused')).length).toBeGreaterThan(0);
@@ -245,14 +274,6 @@ describe('ChiefOfStaff Learning card skipped label', () => {
totalCompleted: 20,
};
- const renderAt = (tab) => render(
-
-
- } />
-
- ,
- );
-
// The page renders more than one Learning card (the compact card in the CoS
// panel, plus the `mini` card in the ascii-mode stats bar — Tailwind-`hidden`,
// but jsdom applies no CSS so it is still queryable). Never index into a
@@ -268,7 +289,7 @@ describe('ChiefOfStaff Learning card skipped label', () => {
it('stacks the skipped label under the value instead of in a flex row', async () => {
api.getCosLearningSummary.mockResolvedValue(summaryWithSkipped);
- renderAt('config');
+ await renderSettledAt('config');
for (const card of await learningCards()) {
const value = within(card).getByText('84%');
@@ -287,7 +308,7 @@ describe('ChiefOfStaff Learning card skipped label', () => {
it('truncates the skipped label so it clips inside the card', async () => {
api.getCosLearningSummary.mockResolvedValue(summaryWithSkipped);
- renderAt('config');
+ await renderSettledAt('config');
for (const card of await learningCards()) {
expect(within(card).getByText(/skipped/).classList.contains('truncate')).toBe(true);
@@ -302,7 +323,7 @@ describe('ChiefOfStaff Learning card skipped label', () => {
// column spills past the border again — the exact reported bug, with the
// truncate still present and every other assertion here still green.
api.getCosLearningSummary.mockResolvedValue(summaryWithSkipped);
- renderAt('config');
+ await renderSettledAt('config');
// Scope to the compact cards: the ascii `mini` card's label parent is the
// itself (not a flex-item column), so this leg doesn't apply there.
@@ -320,7 +341,7 @@ describe('ChiefOfStaff Learning card skipped label', () => {
// 0% from disguising itself as "No data" — the highest-signal state reading
// as the empty one. Pins the branch against a future truthiness collapse.
api.getCosLearningSummary.mockResolvedValue({ overallSuccessRate: 0, skipped: 0, status: 'critical', totalCompleted: 12 });
- renderAt('config');
+ await renderSettledAt('config');
for (const card of await learningCards()) {
expect(within(card).getByText('0%')).toBeInTheDocument();
@@ -335,7 +356,7 @@ describe('ChiefOfStaff Learning card skipped label', () => {
// "No data", is wider than the compact card's ~45px text column and would
// render clipped as "No dat…" instead of wrapping.
api.getCosLearningSummary.mockResolvedValue({ overallSuccessRate: null, skipped: 0, status: 'unknown', totalCompleted: 0 });
- renderAt('config');
+ await renderSettledAt('config');
// Only the compact card spells the empty state "No data" — the ascii `mini`
// card renders an em dash — so scope to the card that actually shows it.
@@ -352,7 +373,7 @@ describe('ChiefOfStaff Learning card skipped label', () => {
// the server's status chain to keep classifying it that way — this fixture
// is deliberately the mismatched combination (skipped 3 / status 'warning').
api.getCosLearningSummary.mockResolvedValue(summaryWithSkipped);
- renderAt('config');
+ await renderSettledAt('config');
for (const card of await learningCards()) {
expect(within(card).getByText(/skipped/).className).toContain('text-port-error');
@@ -362,7 +383,7 @@ describe('ChiefOfStaff Learning card skipped label', () => {
it('omits the skipped label entirely when nothing was skipped', async () => {
api.getCosLearningSummary.mockResolvedValue({ ...summaryWithSkipped, skipped: 0, status: 'good' });
- renderAt('config');
+ await renderSettledAt('config');
expect(await screen.findAllByText('84%')).not.toHaveLength(0);
expect(screen.queryByText(/skipped/)).not.toBeInTheDocument();
@@ -382,16 +403,8 @@ describe('ChiefOfStaff insight freshness (#2654)', () => {
return entry?.[1];
};
- const renderAt = (tab) => render(
-
-
- } />
-
- ,
- );
-
it('does NOT re-fetch insights on a socket health-check (no feedback loop)', async () => {
- renderConfigTab();
+ await renderSettledConfigTab();
// The initial fetchData pulls insights once; wait for it before firing.
await waitFor(() => expect(api.getCosActionableInsights).toHaveBeenCalled());
const before = api.getCosActionableInsights.mock.calls.length;
@@ -412,7 +425,7 @@ describe('ChiefOfStaff insight freshness (#2654)', () => {
});
it('does NOT re-fetch insights on the manual "Run Check" button (no second process-restart)', async () => {
- renderAt('health');
+ await renderSettledAt('health');
await waitFor(() => expect(api.getCosActionableInsights).toHaveBeenCalled());
const before = api.getCosActionableInsights.mock.calls.length;
@@ -430,7 +443,7 @@ describe('ChiefOfStaff insight freshness (#2654)', () => {
it('keeps the Health tab pending until its own read settles', async () => {
let releaseHealth;
api.getCosHealth.mockReturnValue(new Promise((resolve) => { releaseHealth = resolve; }));
- renderAt('health');
+ await renderSettledAt('health');
expect(await screen.findByText('Loading health...')).toBeInTheDocument();
expect(screen.queryByText('All Systems Healthy')).not.toBeInTheDocument();
@@ -450,7 +463,7 @@ describe('ChiefOfStaff insight freshness (#2654)', () => {
lastCheck: '2026-01-01T00:00:02Z',
issues: [{ type: 'error', category: 'memory', message: 'FRESH_ISSUE' }],
});
- renderAt('health');
+ await renderSettledAt('health');
// Initial fetchData paints the fresh issue.
expect(await screen.findByText('FRESH_ISSUE')).toBeInTheDocument();
@@ -474,7 +487,7 @@ describe('ChiefOfStaff insight freshness (#2654)', () => {
lastCheck: '2026-01-01T00:00:02Z',
issues: [{ type: 'error', category: 'memory', message: 'FRESH_ISSUE' }],
});
- renderAt('health');
+ await renderSettledAt('health');
expect(await screen.findByText('FRESH_ISSUE')).toBeInTheDocument();
// A read with no (parseable) lastCheck must not overwrite the timestamped,
@@ -496,7 +509,7 @@ describe('ChiefOfStaff insight freshness (#2654)', () => {
lastCheck: '2026-01-01T00:00:02Z',
issues: [{ type: 'error', category: 'memory', message: 'FRESH_ISSUE' }],
});
- renderAt('health');
+ await renderSettledAt('health');
expect(await screen.findByText('FRESH_ISSUE')).toBeInTheDocument();
// A failed health read (rejects → .catch → null) must not blank the banner.
@@ -523,16 +536,10 @@ describe('ChiefOfStaff insight freshness (#2654)', () => {
describe('ChiefOfStaff task-change subscriptions', () => {
const getSocketHandler = (event) => socketStub.on.mock.calls.find(([evt]) => evt === event)?.[1];
- const renderTasksTab = () => render(
-
-
- } />
-
- ,
- );
+ const renderSettledTasksTab = () => renderSettledAt('tasks');
it('renders a newly queued system task straight off the watcher event', async () => {
- renderTasksTab();
+ await renderSettledTasksTab();
await waitFor(() => expect(api.getCosTasks).toHaveBeenCalled());
const before = api.getCosTasks.mock.calls.length;
@@ -551,7 +558,7 @@ describe('ChiefOfStaff task-change subscriptions', () => {
});
it('renders a submitted user task before the follow-up refresh resolves', async () => {
- renderTasksTab();
+ await renderSettledTasksTab();
await screen.findByRole('button', { name: 'Add test task' });
api.getCosTasks.mockReturnValue(new Promise(() => {}));
@@ -561,7 +568,8 @@ describe('ChiefOfStaff task-change subscriptions', () => {
});
it('coalesces a burst of task-store changes into a single refetch', async () => {
- renderTasksTab();
+ withFakeTimers();
+ await renderSettledTasksTab();
await waitFor(() => expect(api.getCosTasks).toHaveBeenCalled());
const before = api.getCosTasks.mock.calls.length;
@@ -578,12 +586,12 @@ describe('ChiefOfStaff task-change subscriptions', () => {
// running task's federation lease heartbeat), so the burst must settle into
// one refresh rather than one per event. Any extra flush would land inside
// the 400ms window the first one already closed.
- await act(async () => { await new Promise(resolve => setTimeout(resolve, 200)); });
+ await act(async () => { await vi.advanceTimersByTimeAsync(200); });
expect(api.getCosTasks.mock.calls.length).toBe(before + 1);
});
it('refreshes the queue without re-running the health-checking insights read', async () => {
- renderTasksTab();
+ await renderSettledTasksTab();
await waitFor(() => expect(api.getCosActionableInsights).toHaveBeenCalled());
const insightsBefore = api.getCosActionableInsights.mock.calls.length;
@@ -624,17 +632,11 @@ describe('ChiefOfStaff task unblock freshness', () => {
const renderBlockedTask = (insights = []) => {
api.getCosTasks.mockResolvedValue({ user: { tasks: [] }, cos: { tasks: [blockedTask] } });
api.getCosActionableInsights.mockResolvedValue({ insights });
- return render(
-
-
- } />
-
- ,
- );
+ return renderSettledAt('tasks');
};
it('moves a banner-unblocked task and removes its insight before refresh settles', async () => {
- renderBlockedTask([blockedInsight]);
+ await renderBlockedTask([blockedInsight]);
expect(await screen.findByText('1 blocked task')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'View Tasks' }));
const banner = screen.getByText('1 blocked task').closest('.border');
@@ -655,7 +657,7 @@ describe('ChiefOfStaff task unblock freshness', () => {
});
it('moves a card-unblocked task before its follow-up refresh settles', async () => {
- renderBlockedTask();
+ await renderBlockedTask();
expect(await screen.findByText('Blocked (1)')).toBeInTheDocument();
// Keep the post-mutation read pending so this assertion only passes when the
// card uses the shared optimistic update rather than waiting for onRefresh.
@@ -685,13 +687,7 @@ describe('ChiefOfStaff stale queue-read guard', () => {
// Initial paint.
api.getCosTasks.mockResolvedValue(stale);
- render(
-
-
- } />
-
- ,
- );
+ await renderSettledAt('tasks');
expect(await screen.findByText('STALE pending copy')).toBeInTheDocument();
// A spawn kicks off the slow full fetch, whose insights read we hold open so
@@ -731,13 +727,7 @@ describe('ChiefOfStaff Issues card', () => {
const renderWithIssues = (issues) => {
api.getCosStatus.mockResolvedValue({ running: true, config, stats: {} });
api.getCosHealth.mockResolvedValue({ lastCheck: '2026-01-01T00:00:00.000Z', issues });
- return render(
-
-
- } />
-
- ,
- );
+ return renderSettledConfigTab();
};
// Same "never index a match list" rule as the Learning card above: the page
@@ -751,20 +741,20 @@ describe('ChiefOfStaff Issues card', () => {
};
it('names the health issue in the status bubble instead of the generic investigating line', async () => {
- renderWithIssues([memoryWarning]);
+ await renderWithIssues([memoryWarning]);
expect(await screen.findByText(memoryWarning.message)).toBeInTheDocument();
expect(screen.queryByText('Investigating issue...')).not.toBeInTheDocument();
});
it('summarizes the count when more than one issue is open', async () => {
- renderWithIssues([memoryWarning, { type: 'error', category: 'processes', message: 'example-app failed to auto-restart' }]);
+ await renderWithIssues([memoryWarning, { type: 'error', category: 'processes', message: 'example-app failed to auto-restart' }]);
expect(await screen.findByText(/^2 health issues: /)).toBeInTheDocument();
});
it('makes every Issues tile a button that carries the issue summary', async () => {
- renderWithIssues([memoryWarning]);
+ await renderWithIssues([memoryWarning]);
for (const card of await issueCards()) {
expect(card).toHaveAttribute('title', memoryWarning.message);
@@ -773,7 +763,7 @@ describe('ChiefOfStaff Issues card', () => {
});
it('opens the Health tab when the tile is clicked', async () => {
- renderWithIssues([memoryWarning]);
+ await renderWithIssues([memoryWarning]);
const cards = await issueCards();
// Clicking the first is enough: the assertion above pins every variant to
@@ -789,7 +779,8 @@ describe('ChiefOfStaff Issues card', () => {
// The live path: a health check finishing while the page is open pushes the
// issue over the socket rather than through fetchData.
it('names the issue arriving on a live health-check socket event', async () => {
- renderWithIssues([]);
+ withFakeTimers();
+ await renderWithIssues([]);
// Wait for the clean first paint so the socket handler is registered.
for (const card of await issueCards()) expect(within(card).getByText('0')).toBeInTheDocument();
@@ -805,13 +796,13 @@ describe('ChiefOfStaff Issues card', () => {
expect(within(card).getByText('1')).toBeInTheDocument();
}
// Drain the >0 branch's speaking timer so no state update escapes act.
- await act(async () => { await new Promise((resolve) => setTimeout(resolve, 2100)); });
+ await act(async () => { await vi.advanceTimersByTimeAsync(SPEAKING_MS + 100); });
});
// Without these, deleting the `tone` prop would leave every tile neutral gray
// while the helper's own unit tests stayed green.
it('colors the tile amber for a warning-only check', async () => {
- renderWithIssues([memoryWarning]);
+ await renderWithIssues([memoryWarning]);
for (const card of await issueCards()) {
expect(card.className).toContain('border-port-warning');
@@ -820,7 +811,7 @@ describe('ChiefOfStaff Issues card', () => {
});
it('escalates the tile to red when an issue is error-level', async () => {
- renderWithIssues([memoryWarning, { type: 'error', category: 'processes', message: 'example-app failed to auto-restart' }]);
+ await renderWithIssues([memoryWarning, { type: 'error', category: 'processes', message: 'example-app failed to auto-restart' }]);
for (const card of await issueCards()) {
expect(card.className).toContain('border-port-error');
@@ -828,7 +819,7 @@ describe('ChiefOfStaff Issues card', () => {
});
it('stays a click-through to Health when there are no issues at all', async () => {
- renderWithIssues([]);
+ await renderWithIssues([]);
for (const card of await issueCards()) {
expect(card).toHaveAttribute('title', 'No issues detected — view system health');
@@ -843,24 +834,20 @@ describe('ChiefOfStaff Issues card', () => {
// health (tile, avatar state, status bubble) must come from the merged
// snapshot, or the bubble names an older issue than the tile is counting.
it('does not let a slow health read clobber a fresher socket-delivered check', async () => {
+ withFakeTimers();
const staleWarning = { type: 'warning', category: 'memory', message: 'Stale issue from the older read' };
api.getCosStatus.mockResolvedValue({ running: true, config, stats: {} });
// The slow read carries the OLDER timestamp; the socket event below is newer.
api.getCosHealth.mockResolvedValue({ lastCheck: '2026-01-01T00:00:00.000Z', issues: [staleWarning] });
- render(
-
-
- } />
-
- ,
- );
+ await renderSettledConfigTab();
expect(await screen.findByText(staleWarning.message)).toBeInTheDocument();
const handleHealthCheck = socketStub.on.mock.calls.find(([evt]) => evt === 'cos:health:check')?.[1];
await act(async () => {
handleHealthCheck({ metrics: { timestamp: '2026-01-02T00:00:00.000Z' }, issues: [memoryWarning] });
});
- await act(async () => { await new Promise((resolve) => setTimeout(resolve, 2100)); });
+ // Drain the >0 branch's speaking timer so no state update escapes act.
+ await act(async () => { await vi.advanceTimersByTimeAsync(SPEAKING_MS + 100); });
// Now force the slow batch to run again with its stale payload — the merge
// must keep the socket's newer check, for the tile AND the bubble.
diff --git a/client/src/pages/CreateApp.jsx b/client/src/pages/CreateApp.jsx
index ab000b8436..d0741625f2 100644
--- a/client/src/pages/CreateApp.jsx
+++ b/client/src/pages/CreateApp.jsx
@@ -82,6 +82,7 @@ export default function CreateApp() {
const [devUiPort, setDevUiPort] = useState('');
const [apiPort, setApiPort] = useState('');
const [buildCommand, setBuildCommand] = useState('');
+ const [updateCommand, setUpdateCommand] = useState('');
const [startCommands, setStartCommands] = useState('');
const [pm2Names, setPm2Names] = useState('');
const [pm2Status, setPm2Status] = useState(null);
@@ -234,6 +235,7 @@ export default function CreateApp() {
devUiPort: devUiPort ? parseInt(devUiPort, 10) : null,
apiPort: apiPort ? parseInt(apiPort, 10) : null,
buildCommand: buildCommand || undefined,
+ updateCommand: updateCommand || undefined,
startCommands: startCommands ? startCommands.split('\n').filter(Boolean) : [],
pm2ProcessNames: isNonPm2
? []
@@ -424,6 +426,21 @@ export default function CreateApp() {
)}
+
+
Update Command
+
setUpdateCommand(e.target.value)}
+ placeholder="npm run update"
+ className="w-full px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white focus:border-port-accent focus:outline-hidden font-mono text-sm"
+ />
+
+ Optional. Updates otherwise only check out the origin default branch and restart. A portos:update package script is also recognized.
+
+
+
Build Command
-
{commission.name}
+
+ {commission.name}
+
{commission.enabled ? 'Active' : 'Paused'}
diff --git a/client/src/pages/CreativeCommissions.jsx b/client/src/pages/CreativeCommissions.jsx
index 867fcbb51d..bdb185ca07 100644
--- a/client/src/pages/CreativeCommissions.jsx
+++ b/client/src/pages/CreativeCommissions.jsx
@@ -18,6 +18,7 @@ import { useEffect, useMemo, useState, useCallback } from 'react';
import { useNavigate, useLocation } from 'react-router';
import { Plus, Sparkles, Trash2, Clock, Cpu, Pause, Play, Zap } from 'lucide-react';
import PageSkeleton from '../components/ui/PageSkeleton';
+import EmptyState from '../components/EmptyState';
import toast from '../components/ui/Toast';
import Drawer from '../components/Drawer';
import ConfirmButtonPair from '../components/ui/ConfirmButtonPair';
@@ -148,19 +149,13 @@ export default function CreativeCommissions() {
{loading ? (
) : sorted.length === 0 ? (
-
-
-
No commissions yet
-
- Create a standing brief like “every night at 2am, make me something surreal” and it runs unattended.
-
-
navigate('/creative-commission/new')}
- className="inline-flex items-center gap-2 bg-port-accent hover:bg-blue-600 text-white px-3 py-2 rounded text-sm font-medium"
- >
- New Commission
-
-
+
) : (
{sorted.map((c) => (
diff --git a/client/src/pages/CreativeCommissions.test.jsx b/client/src/pages/CreativeCommissions.test.jsx
new file mode 100644
index 0000000000..6137bac60c
--- /dev/null
+++ b/client/src/pages/CreativeCommissions.test.jsx
@@ -0,0 +1,50 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router';
+
+vi.mock('../services/api', async (importOriginal) => ({
+ ...(await importOriginal()),
+ listCommissions: vi.fn(),
+ createCommission: vi.fn(),
+ updateCommission: vi.fn(),
+ deleteCommission: vi.fn(),
+ runCommissionNow: vi.fn(),
+}));
+// The create drawer's config form loads model catalogs on mount — irrelevant to
+// the index's empty state, and it would put real requests behind these renders.
+vi.mock('../components/creative-commission/CommissionConfigForm.jsx', () => ({ default: () => null }));
+vi.mock('../components/ui/Toast', () => ({
+ default: { success: vi.fn(), error: vi.fn(), info: vi.fn(), warning: vi.fn() },
+}));
+
+import CreativeCommissions from './CreativeCommissions';
+import { listCommissions } from '../services/api';
+
+const renderPage = () => render(
+
+
+
+);
+
+describe('CreativeCommissions index empty state', () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ it('routes the empty state to the create drawer via a named link', async () => {
+ listCommissions.mockResolvedValue([]);
+ renderPage();
+ expect(await screen.findByText('No commissions yet')).toBeInTheDocument();
+ // The only actionTo conversion — assert the Link branch in situ so a
+ // dropped actionTo/actionLabel leaves a detectable dead end.
+ const cta = screen.getByRole('link', { name: 'Create your first commission' });
+ expect(cta.getAttribute('href')).toBe('/creative-commission/new');
+ });
+
+ it('renders the commission list instead of the empty state once one exists', async () => {
+ listCommissions.mockResolvedValue([
+ { id: 'commission-1', name: 'Example Commission', enabled: true, schedule: {}, createdAt: '2026-01-01T00:00:00.000Z' },
+ ]);
+ renderPage();
+ expect(await screen.findByText('Example Commission')).toBeInTheDocument();
+ expect(screen.queryByRole('link', { name: 'Create your first commission' })).toBeNull();
+ });
+});
diff --git a/client/src/pages/CreativeDirectorDetail.jsx b/client/src/pages/CreativeDirectorDetail.jsx
index 5d69c559f7..e2f9cda5d9 100644
--- a/client/src/pages/CreativeDirectorDetail.jsx
+++ b/client/src/pages/CreativeDirectorDetail.jsx
@@ -240,7 +240,7 @@ export default function CreativeDirectorDetail() {
-
{project.name}
+
{project.name}
{project.id} • status: {project.status}
diff --git a/client/src/pages/Dashboard.jsx b/client/src/pages/Dashboard.jsx
index bc39cd32a0..fbd7e32d23 100644
--- a/client/src/pages/Dashboard.jsx
+++ b/client/src/pages/Dashboard.jsx
@@ -7,6 +7,7 @@ import LayoutEditor from '../components/dashboard/LayoutEditor';
import DashboardGrid, { readingOrderIds, reconcileGrid, synthesizeGrid } from '../components/dashboard/DashboardGrid.jsx';
import { WIDGETS_BY_ID, FALLBACK_LAYOUT } from '../components/dashboard/widgetRegistry.jsx';
import WidgetSkeleton from '../components/dashboard/WidgetSkeleton';
+import FirstRunCard from '../components/onboarding/FirstRunCard.jsx';
import { DASHBOARD_LAYOUT_CHANGED, INSTANCE_FEATURES_CHANGED } from '../constants/events.js';
import { ChevronsDownUp, GripHorizontal, Monitor, Move, Save, X } from 'lucide-react';
import * as api from '../services/api';
@@ -429,6 +430,7 @@ export default function Dashboard() {
return (
+
Dashboard
diff --git a/client/src/pages/Game.jsx b/client/src/pages/Game.jsx
index a74f20cfec..a4447fce85 100644
--- a/client/src/pages/Game.jsx
+++ b/client/src/pages/Game.jsx
@@ -391,7 +391,7 @@ export default function Game() {
-
{game.name}
+
{game.name}
{app?.name || 'Managed app unavailable'}
diff --git a/client/src/pages/HistoryPage.jsx b/client/src/pages/HistoryPage.jsx
index 1f0a879abc..5405f823c3 100644
--- a/client/src/pages/HistoryPage.jsx
+++ b/client/src/pages/HistoryPage.jsx
@@ -284,7 +284,7 @@ export function HistoryPage() {
Error
-
+
{entry.error}
@@ -296,7 +296,7 @@ export function HistoryPage() {
Additional Details
-
+
{JSON.stringify(
Object.fromEntries(
Object.entries(entry.details).filter(([k]) => !['command', 'output', 'runtime', 'exitCode'].includes(k))
diff --git a/client/src/pages/ImageGen.flux2VenvShare.test.jsx b/client/src/pages/ImageGen.flux2VenvShare.test.jsx
new file mode 100644
index 0000000000..c966cbb762
--- /dev/null
+++ b/client/src/pages/ImageGen.flux2VenvShare.test.jsx
@@ -0,0 +1,97 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { act, render, screen } from '@testing-library/react';
+import { MemoryRouter } from 'react-router';
+
+// Z-Image dispatches through the same shared torch venv FLUX.2 does
+// (usesDiffusersRunner in runnerFamilies.js / runners.js), so a broken venv
+// must surface the same install banner FLUX.2 models get — with FLUX.2-only
+// wording (and the HF-gated-repo token banner) suppressed, since Z-Image
+// isn't a gated repo.
+const MODEL = { id: 'z-turbo', name: 'Z-Image Turbo', runner: 'z-image' };
+
+vi.mock('../services/api', () => ({
+ getInstances: vi.fn(async () => ({ peers: [] })),
+ getImageGenStatus: vi.fn(async () => ({ connected: true, mode: 'local', model: 'Z-Image Turbo' })),
+ generateImage: vi.fn(async () => ({ jobId: 'job-1' })),
+ generateImageMultipart: vi.fn(async () => ({})),
+ listImageModels: vi.fn(async () => [MODEL]),
+ listLorasFull: vi.fn(async () => []),
+ listImageGallery: vi.fn(async () => []),
+ cancelImageGen: vi.fn(async () => ({})),
+ deleteImage: vi.fn(async () => ({})),
+ setImageHidden: vi.fn(async () => ({})),
+ cleanGalleryImage: vi.fn(async () => ({})),
+ getActiveImageJob: vi.fn(async () => ({ activeJob: null })),
+ getSettings: vi.fn(async () => ({ imageGen: { mode: 'local', local: { pythonPath: '/usr/bin/python3', modelId: 'z-turbo' } } })),
+ buildFormData: vi.fn(() => new FormData()),
+ listMediaJobs: vi.fn(async () => ({ jobs: [] })),
+ regenerateGalleryImage: vi.fn(async () => ({})),
+ getRegenAvailability: vi.fn(async () => ({ available: false })),
+ removeImageWatermark: vi.fn(async () => ({})),
+ getFlux2Status: vi.fn(async () => ({
+ venvInstalled: false, hfTokenPresent: false, licenseUrl: 'https://huggingface.co/example',
+ })),
+}));
+
+vi.mock('../hooks/useImageGenProgress', () => ({
+ useImageGenProgress: () => ({ progress: null, begin: vi.fn(), end: vi.fn(), resume: vi.fn() }),
+}));
+vi.mock('../hooks/useMediaJobSse', () => ({
+ useMediaJobSse: () => ({ attach: vi.fn(), eventSourceRef: { current: null } }),
+}));
+vi.mock('../hooks/useModelDownloadStatus', () => ({
+ useModelDownloadStatus: () => ({
+ getStatus: () => ({ cached: true }), start: vi.fn(), cancel: vi.fn(), repair: vi.fn(), refresh: vi.fn(),
+ downloading: false, repairing: false, progress: null, lastError: null, activeModelId: null, extra: {}, loading: false, statusError: null,
+ }),
+}));
+vi.mock('../hooks/useHfTokenStatus', () => ({ useHfTokenStatus: () => ({ present: true, refresh: vi.fn() }) }));
+vi.mock('../hooks/useAgyModels', () => ({ useAgyModels: () => ({ models: [], error: null }) }));
+vi.mock('../hooks/useMediaCompletionRefresh', () => ({ useMediaCompletionRefresh: vi.fn() }));
+vi.mock('../hooks/useMediaAnnotations', () => ({
+ useMediaAnnotations: () => ({ annotations: {}, updateAnnotation: vi.fn(), getCardProps: vi.fn(() => ({})) }),
+}));
+vi.mock('../hooks/useAutoRefetch', () => ({ useAutoRefetch: vi.fn() }));
+vi.mock('../hooks/usePreviewRoute', () => ({ default: () => [null, vi.fn()] }));
+vi.mock('../components/ui/Toast', () => ({
+ default: Object.assign(vi.fn(), { error: vi.fn(), success: vi.fn(), loading: vi.fn() }),
+}));
+vi.mock('../components/media/PromptEnhancer', () => ({ default: () => null }));
+vi.mock('../components/media/PromptFromMedia', () => ({ default: () => null }));
+vi.mock('../components/media/UniverseStylePicker', () => ({ default: () => null }));
+vi.mock('../components/media/StylePresetPicker', () => ({ default: () => null }));
+vi.mock('../components/media/MediaPreview', () => ({ default: () => null }));
+vi.mock('../components/media/MediaJobsQueue', () => ({ default: () => null }));
+vi.mock('../components/media/ResolutionField', () => ({ default: () => null }));
+vi.mock('../components/Drawer', () => ({ default: () => null }));
+vi.mock('../components/settings/ImageGenTab', () => ({ ImageGenTab: () => null }));
+vi.mock('../components/imageGen/Flux2InstallModal', () => ({ default: () => null }));
+vi.mock('../components/imageGen/GalleryImagePicker', () => ({ default: () => null }));
+vi.mock('../components/imageGen/InitImagePicker', () => ({ default: () => null }));
+vi.mock('../components/imageGen/ReferenceImagePicker', () => ({ default: () => null }));
+vi.mock('../components/imageGen/LoraPicker', () => ({ default: () => null }));
+
+const { default: ImageGen } = await import('./ImageGen.jsx');
+
+const mount = async () => {
+ await act(async () => {
+ render(
+
+
+ ,
+ );
+ });
+};
+
+describe('ImageGen shared-venv install banner for non-flux2 diffusers models', () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ it('shows the install banner (with model-specific wording, not an HF-token banner) when the shared venv is unhealthy', async () => {
+ await mount();
+
+ const banner = await screen.findByRole('button', { name: /Install FLUX.2/i });
+ expect(banner).toBeInTheDocument();
+ expect(screen.getByText(/Z-Image Turbo shares the FLUX.2 torch runtime/i)).toBeInTheDocument();
+ expect(screen.queryByText(/accept.*license/i)).not.toBeInTheDocument();
+ });
+});
diff --git a/client/src/pages/ImageGen.jsx b/client/src/pages/ImageGen.jsx
index 7cd7a4de17..cb369c9fd3 100644
--- a/client/src/pages/ImageGen.jsx
+++ b/client/src/pages/ImageGen.jsx
@@ -21,7 +21,7 @@ import PromptEnhancer from '../components/media/PromptEnhancer';
import PromptFromMedia from '../components/media/PromptFromMedia';
import BackendChipStrip from '../components/media/BackendChipStrip';
import { normalizeImage } from '../components/media/normalize';
-import { RUNNER_FAMILIES, loraCompatKey } from '../lib/runnerFamilies';
+import { RUNNER_FAMILIES, loraCompatKey, usesDiffusersRunner } from '../lib/runnerFamilies';
import { appendTriggerWords } from '../lib/loraTriggers';
import Flux2InstallModal from '../components/imageGen/Flux2InstallModal';
import HfTokenBanner from '../components/imageGen/HfTokenBanner';
@@ -38,6 +38,7 @@ import { useMediaCompletionRefresh } from '../hooks/useMediaCompletionRefresh';
import { useMediaAnnotations } from '../hooks/useMediaAnnotations';
import { useAutoRefetch } from '../hooks/useAutoRefetch';
import usePreviewRoute from '../hooks/usePreviewRoute';
+import useMounted from '../hooks/useMounted';
import {
Image as ImageIcon, Sparkles, Download, RefreshCw, Settings as SettingsIcon,
AlertTriangle, X, Film,
@@ -178,6 +179,13 @@ export default function ImageGen() {
const [initImage, setInitImage] = useState({ source: null, file: null, name: null, previewUrl: null });
const initImagePreviewRef = useRef(initImage.previewUrl);
initImagePreviewRef.current = initImage.previewUrl;
+ // Both upload handlers AWAIT EXIF normalization before they mint their object
+ // URL, so an unmount during that await would run the cleanup sweep below and
+ // the handler would then resume to create a url nothing is left to revoke.
+ // (The init image is the live leak: the reference handler mints inside a state
+ // updater React skips once unmounted — an implementation detail to guard
+ // against, not to rely on.)
+ const mountedRef = useMounted();
const [initImageStrength, setInitImageStrength] = useState(0.4);
// Visual gallery picker target: null (closed), { kind: 'init' }, or
// { kind: 'reference', slot: i }. The search/browse alternative to the plain
@@ -543,6 +551,7 @@ export default function ImageGen() {
const raw = e.target.files?.[0];
if (!raw) return;
const file = await normalizeImageOrientation(raw);
+ if (!mountedRef.current) return;
revokeIfBlob(initImagePreviewRef.current);
setInitImage({ source: 'upload', file, name: file.name, previewUrl: URL.createObjectURL(file) });
// Default the output resolution to the uploaded image's dimensions, clamped
@@ -574,10 +583,16 @@ export default function ImageGen() {
const raw = e.target.files?.[0];
if (!raw) return;
const file = await normalizeImageOrientation(raw);
+ if (!mountedRef.current) return;
+ // Mint the url OUTSIDE the updater. StrictMode invokes a functional updater
+ // twice in dev, and a url created inside it on the discarded pass is never
+ // stored — so it can never be revoked. (The revoke stays inside, where it
+ // can read the authoritative `prev`; revoking twice is a no-op.)
+ const previewUrl = URL.createObjectURL(file);
setReferenceImages((prev) => {
const next = [...prev];
revokeIfBlob(next[slotIndex]?.previewUrl);
- next[slotIndex] = { file, previewUrl: URL.createObjectURL(file), strength: next[slotIndex]?.strength ?? 1.0 };
+ next[slotIndex] = { file, previewUrl, strength: next[slotIndex]?.strength ?? 1.0 };
return next;
});
};
@@ -659,6 +674,12 @@ export default function ImageGen() {
const currentModel = models.find((m) => m.id === modelId);
const isFlux2Model = currentModel?.runner === RUNNER_FAMILIES.FLUX2;
+ // Z-Image/ERNIE/HiDream/Qwen dispatch through the same shared torch venv
+ // FLUX.2 does (isFlux2VenvHealthy in pythonSetup.js gates all of them), so
+ // the install/repair flow below has to cover them too — otherwise a user on
+ // one of those models hits the "FLUX.2 runtime" gate with no UI path to fix
+ // it, since the fetch + Install button used to be flux2-only.
+ const sharesFlux2Venv = isFlux2Model || usesDiffusersRunner(currentModel);
// Edit-only models (Qwen-Image-Edit) require a source image — submitting
// text-only crashes the runner, so the server rejects it and we gate the
// submit button + show a hint rather than letting the user hit a failed job.
@@ -776,14 +797,14 @@ export default function ImageGen() {
}, [refreshFlux2Status]);
useEffect(() => {
- if (!isFlux2Model) { setFlux2Status(null); return; }
+ if (!sharesFlux2Venv) { setFlux2Status(null); return; }
// Abort the in-flight request when the user switches models before it
// resolves — otherwise a stale response could re-show the banner for
- // a non-flux2 selection.
+ // a selection that doesn't share the venv.
const controller = new AbortController();
refreshFlux2Status(controller.signal);
return () => controller.abort();
- }, [isFlux2Model, modelId, refreshFlux2Status]);
+ }, [sharesFlux2Venv, modelId, refreshFlux2Status]);
// Lazy-fetch HF token presence for legacy mflux gated models (FLUX.1-dev).
// FLUX.2 has its own combined status fetch above (which also covers the
@@ -821,8 +842,11 @@ export default function ImageGen() {
}, [generating, refreshGallery]);
useAutoRefetch(pollQueue, 4000, { enabled: queueActive, pollOnly: true });
- const flux2Issue = isFlux2Model && flux2Status
- ? (!flux2Status.venvInstalled ? 'venv' : !flux2Status.hfTokenPresent ? 'token' : null)
+ // The HF-gated-repo "token" issue only applies to actual FLUX.2 models —
+ // Z-Image/ERNIE/HiDream/Qwen share the venv but aren't gated repos, so a
+ // missing HF token must not block them.
+ const flux2Issue = sharesFlux2Venv && flux2Status
+ ? (!flux2Status.venvInstalled ? 'venv' : (isFlux2Model && !flux2Status.hfTokenPresent) ? 'token' : null)
: null;
const { visibleGallery, hiddenGallery } = useMemo(() => {
const visible = gallery.filter((img) => !img.hidden);
@@ -1346,8 +1370,10 @@ export default function ImageGen() {
{flux2Issue === 'venv' && (
- FLUX.2 runtime isn't installed yet. PortOS can set it up automatically
- — torch + diffusers download, ~3-10 min on first run.
+ {isFlux2Model
+ ? "FLUX.2 runtime isn't installed yet."
+ : `${currentModel?.name || 'This model'} shares the FLUX.2 torch runtime, which isn't installed yet.`}
+ {' '}PortOS can set it up automatically — torch + diffusers download, ~3-10 min on first run.
({ created: [], revoked: [], fileSeq: 0 }));
+
+const nextFile = () => new File(['x'], `photo-${++state.fileSeq}.jpg`, { type: 'image/jpeg' });
+
+// Interactive stubs for the three pickers that own the object-URL lifecycle.
+// Each exposes the page's own handler as a button plus the previewUrl it is
+// currently rendering, so a test can assert the page never leaves a REVOKED
+// url wired to a live .
+vi.mock('../components/imageGen/InitImagePicker', () => ({
+ default: ({ initImage, onPick, onClear, onBrowse }) => (
+
+ onPick({ target: { files: [nextFile()] } })}>pick-init
+ clear-init
+ browse-init
+ {initImage.previewUrl || ''}
+
+ ),
+}));
+vi.mock('../components/imageGen/ReferenceImagePicker', () => ({
+ default: ({ referenceImages, onPick, onClear }) => (
+
+ {referenceImages.map((slot, i) => (
+
+ onPick(i, { target: { files: [nextFile()] } })}>{`pick-ref-${i}`}
+ onClear(i)}>{`clear-ref-${i}`}
+ {slot.previewUrl || ''}
+
+ ))}
+
+ ),
+}));
+vi.mock('../components/imageGen/GalleryImagePicker', () => ({
+ default: ({ open, onSelect }) => (open
+ ? onSelect({ filename: 'gallery-pick.png' })}>gallery-select
+ : null),
+}));
+
+vi.mock('../services/api', () => ({
+ getInstances: vi.fn(async () => ({ peers: [] })),
+ getImageGenStatus: vi.fn(async () => ({ connected: true, mode: 'codex' })),
+ generateImage: vi.fn(async () => ({ jobId: 'job-1' })),
+ generateImageMultipart: vi.fn(async () => ({})),
+ listImageModels: vi.fn(async () => [MODEL]),
+ listLorasFull: vi.fn(async () => []),
+ listImageGallery: vi.fn(async () => []),
+ cancelImageGen: vi.fn(async () => ({})),
+ deleteImage: vi.fn(async () => ({})),
+ setImageHidden: vi.fn(async () => ({})),
+ cleanGalleryImage: vi.fn(async () => ({})),
+ getActiveImageJob: vi.fn(async () => ({ activeJob: null })),
+ getSettings: vi.fn(async () => ({ imageGen: { mode: 'codex' } })),
+ buildFormData: vi.fn(() => new FormData()),
+ listMediaJobs: vi.fn(async () => ({ jobs: [] })),
+ regenerateGalleryImage: vi.fn(async () => ({})),
+ getRegenAvailability: vi.fn(async () => ({ available: false })),
+ removeImageWatermark: vi.fn(async () => ({})),
+ getFlux2Status: vi.fn(async () => ({ installed: false, ready: false })),
+}));
+
+vi.mock('../hooks/useImageGenProgress', () => ({
+ useImageGenProgress: () => ({ progress: null, begin: vi.fn(), end: vi.fn(), resume: vi.fn() }),
+}));
+vi.mock('../hooks/useMediaJobSse', () => ({
+ useMediaJobSse: () => ({ attach: vi.fn(), eventSourceRef: { current: null } }),
+}));
+vi.mock('../hooks/useModelDownloadStatus', () => ({
+ useModelDownloadStatus: () => ({
+ getStatus: () => ({ cached: true }), start: vi.fn(), cancel: vi.fn(), repair: vi.fn(), refresh: vi.fn(),
+ downloading: false, repairing: false, progress: null, lastError: null, activeModelId: null, extra: {}, loading: false, statusError: null,
+ }),
+}));
+vi.mock('../hooks/useHfTokenStatus', () => ({ useHfTokenStatus: () => ({ present: true, refresh: vi.fn() }) }));
+vi.mock('../hooks/useAgyModels', () => ({ useAgyModels: () => ({ models: [], error: null }) }));
+vi.mock('../hooks/useMediaCompletionRefresh', () => ({ useMediaCompletionRefresh: vi.fn() }));
+vi.mock('../hooks/useMediaAnnotations', () => ({
+ useMediaAnnotations: () => ({ annotations: {}, updateAnnotation: vi.fn(), getCardProps: vi.fn(() => ({})) }),
+}));
+vi.mock('../hooks/useAutoRefetch', () => ({ useAutoRefetch: vi.fn() }));
+vi.mock('../hooks/usePreviewRoute', () => ({ default: () => [null, vi.fn()] }));
+vi.mock('../components/ui/Toast', () => ({
+ default: Object.assign(vi.fn(), { error: vi.fn(), success: vi.fn(), loading: vi.fn() }),
+}));
+vi.mock('../components/media/PromptEnhancer', () => ({ default: () => null }));
+vi.mock('../components/media/PromptFromMedia', () => ({ default: () => null }));
+vi.mock('../components/media/UniverseStylePicker', () => ({ default: () => null }));
+vi.mock('../components/media/StylePresetPicker', () => ({ default: () => null }));
+vi.mock('../components/media/MediaPreview', () => ({ default: () => null }));
+vi.mock('../components/media/MediaJobsQueue', () => ({ default: () => null }));
+vi.mock('../components/media/ResolutionField', () => ({ default: () => null }));
+vi.mock('../components/Drawer', () => ({ default: () => null }));
+vi.mock('../components/settings/ImageGenTab', () => ({ ImageGenTab: () => null }));
+vi.mock('../components/imageGen/Flux2InstallModal', () => ({ default: () => null }));
+vi.mock('../components/imageGen/LoraPicker', () => ({ default: () => null }));
+
+const { default: ImageGen } = await import('./ImageGen.jsx');
+
+const mount = async ({ strict = false } = {}) => {
+ const tree = (
+
+
+
+ );
+ let result;
+ await act(async () => {
+ result = render(strict ? {tree} : tree);
+ });
+ return result;
+};
+
+const click = async (name) => {
+ await act(async () => { fireEvent.click(screen.getByRole('button', { name })); });
+};
+
+// Blob URLs created but not yet revoked — what a real tab would still be
+// pinning the underlying File for.
+const liveUrls = () => state.created.filter((u) => !state.revoked.includes(u));
+
+// jsdom ships none of these, so restoring means DELETING the property again —
+// assigning `undefined` back would leave an own property that later files see
+// as "present but broken".
+const stub = (host, key, value) => {
+ const had = Object.hasOwn(host, key);
+ const prev = host[key];
+ host[key] = value;
+ return () => { if (had) host[key] = prev; else delete host[key]; };
+};
+
+describe('ImageGen object-URL lifecycle', () => {
+ let restore = [];
+
+ beforeEach(() => {
+ state.created = [];
+ state.revoked = [];
+ state.fileSeq = 0;
+ restore = [
+ stub(URL, 'createObjectURL', vi.fn(() => {
+ const url = `blob:portos/${state.created.length + 1}`;
+ state.created.push(url);
+ return url;
+ })),
+ stub(URL, 'revokeObjectURL', vi.fn((url) => { state.revoked.push(url); })),
+ // The page EXIF-normalizes uploads through createImageBitmap; jsdom has
+ // no decoder, and the page's own `.catch` falls back to the original File.
+ stub(window, 'createImageBitmap', vi.fn(() => Promise.reject(new Error('no decoder')))),
+ ];
+ });
+
+ afterEach(() => {
+ // Unmount anything a test left mounted BEFORE the stubs go away: the
+ // global cleanup in src/test/setup.js runs after this hook, and the
+ // unmount sweep it triggers would reach a deleted revokeObjectURL.
+ cleanup();
+ restore.forEach((undo) => undo());
+ restore = [];
+ });
+
+ // The leak this file exists for: navigating away from /image-gen with images
+ // still selected must not pin their Files for the rest of the tab's life.
+ it('revokes every blob URL it created when the page unmounts', async () => {
+ const { unmount } = await mount();
+ await click('pick-init');
+ // Every slot, not just the first: a sweep that walked a truncated list
+ // would still pass on a two-slot sample.
+ for (let i = 0; i < 4; i += 1) await click(`pick-ref-${i}`);
+
+ await waitFor(() => expect(state.created).toHaveLength(5));
+ expect(state.revoked).toEqual([]);
+
+ await act(async () => { unmount(); });
+
+ expect(liveUrls()).toEqual([]);
+ });
+
+ // Each pick handler awaits (EXIF normalization) BEFORE it mints its url, so
+ // an unmount mid-await would otherwise resume past the sweep and create one
+ // nothing owns — a leak the steady-state test above cannot see.
+ it('creates no object URL for a pick that resolves after the page unmounted', async () => {
+ // One deferred normalization per pick — release ALL of them, or a handler
+ // left suspended would fake a pass.
+ const pending = [];
+ window.createImageBitmap = vi.fn(() => new Promise((_, reject) => {
+ pending.push(() => reject(new Error('no decoder')));
+ }));
+
+ const { unmount } = await mount();
+ await click('pick-init');
+ await click('pick-ref-0');
+ expect(pending).toHaveLength(2);
+ expect(state.created).toEqual([]);
+
+ await act(async () => { unmount(); });
+ // Let the suspended handlers resume all the way through their `.catch`
+ // fallback to the point where they would mint a url.
+ await act(async () => {
+ pending.forEach((fail) => fail());
+ await new Promise((r) => setTimeout(r, 0));
+ });
+
+ expect(state.created).toEqual([]);
+ });
+
+ // Replacing keeps reclaiming immediately, and the url left rendered must be
+ // the LIVE one — a revoked url wired to an renders a broken image.
+ it('revokes the replaced url on both the init image and a reference slot, never the live one', async () => {
+ await mount();
+ await click('pick-init');
+ await click('pick-ref-0');
+ await waitFor(() => expect(state.created).toHaveLength(2));
+ const [firstInit, firstRef] = state.created;
+
+ await click('pick-init');
+ await click('pick-ref-0');
+ await waitFor(() => expect(state.created).toHaveLength(4));
+ const [, , secondInit, secondRef] = state.created;
+
+ expect(state.revoked).toContain(firstInit);
+ expect(state.revoked).toContain(firstRef);
+ expect(state.revoked).not.toContain(secondInit);
+ expect(state.revoked).not.toContain(secondRef);
+ expect(screen.getByTestId('init-url')).toHaveTextContent(secondInit);
+ expect(screen.getByTestId('ref-url-0')).toHaveTextContent(secondRef);
+ });
+
+ // The app renders under StrictMode (client/src/main.jsx), which invokes a
+ // functional state updater TWICE in dev. A url minted inside the updater is
+ // created on both passes but only one is kept, so the other is unreachable
+ // and can never be revoked — a leak on every single reference pick.
+ it('mints exactly one url per pick under StrictMode', async () => {
+ const { unmount } = await mount({ strict: true });
+ await click('pick-init');
+ await click('pick-ref-0');
+
+ await waitFor(() => expect(screen.getByTestId('ref-url-0')).not.toHaveTextContent(''));
+ expect(state.created).toHaveLength(2);
+
+ await act(async () => { unmount(); });
+ expect(liveUrls()).toEqual([]);
+ });
+
+ // Clearing reclaims immediately, and the later unmount must not re-revoke a
+ // url the clear already released.
+ it('revokes on clear and does not revoke again at unmount', async () => {
+ const { unmount } = await mount();
+ await click('pick-init');
+ await click('pick-ref-0');
+ await waitFor(() => expect(state.created).toHaveLength(2));
+
+ await click('clear-init');
+ await click('clear-ref-0');
+ expect(liveUrls()).toEqual([]);
+
+ await act(async () => { unmount(); });
+ expect(state.revoked).toHaveLength(2);
+ });
+
+ // `revokeIfBlob` exists for exactly this: gallery previews are plain
+ // `/data/...` paths the whole app shares. Revoking one is a no-op on the
+ // blob registry but would be a bug the moment it were passed to revoke.
+ it('never revokes a gallery /data/ preview url', async () => {
+ const { unmount } = await mount();
+ await click('pick-init');
+ await waitFor(() => expect(state.created).toHaveLength(1));
+ const [blobUrl] = state.created;
+
+ // Swap the upload for a gallery pick: the blob is reclaimed, the
+ // `/data/...` path that replaces it is not a revoke candidate.
+ await click('browse-init');
+ await click('gallery-select');
+
+ await waitFor(() => expect(screen.getByTestId('init-url')).toHaveTextContent('/data/images/gallery-pick.png'));
+ expect(state.revoked).toEqual([blobUrl]);
+
+ await act(async () => { unmount(); });
+
+ expect(state.revoked).toEqual([blobUrl]);
+ });
+});
diff --git a/client/src/pages/Instances.jsx b/client/src/pages/Instances.jsx
index 31f7543e43..6799ee5b28 100644
--- a/client/src/pages/Instances.jsx
+++ b/client/src/pages/Instances.jsx
@@ -1,4 +1,4 @@
-import { useState, useEffect, useCallback, useMemo } from 'react';
+import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import {
Network, Plus, Trash2, RefreshCw, Edit3, Check, X,
Wifi, WifiOff, CircleDot,
@@ -11,6 +11,7 @@ import {
} from 'lucide-react';
import toast from '../components/ui/Toast';
import Pill from '../components/ui/Pill';
+import EmptyState from '../components/EmptyState';
import socket from '../services/socket';
import {
getInstances, updateSelfInstance, addPeer, updatePeer,
@@ -23,6 +24,7 @@ import {
import PeerAppsList from '../components/instances/PeerAppsList';
import PeerAgentsSection from '../components/instances/PeerAgentsSection';
import { SchemaGapBadge } from '../components/instances/SchemaGapBadge';
+import { DEFAULT_PEER_PORT } from '../lib/ports.js';
import PeerMediaProviderPanel from '../components/instances/PeerMediaProviderPanel';
import UnattendedRenderRouting from '../components/instances/UnattendedRenderRouting';
import BrainParityPanel from '../components/instances/BrainParityPanel';
@@ -202,9 +204,11 @@ function SelfCard({ self, onUpdate, syncStatus, tailnetInfo }) {
);
}
-function AddPeerForm({ onAdd }) {
+// Exported for focused tests (the port input's placeholder must advertise the
+// same default the form actually submits — see Instances.test.jsx).
+export function AddPeerForm({ onAdd, addressRef }) {
const [address, setAddress] = useState('');
- const [port, setPort] = useState('5555');
+ const [port, setPort] = useState(String(DEFAULT_PEER_PORT));
const [name, setName] = useState('');
const [showAuth, setShowAuth] = useState(false);
const [username, setUsername] = useState('');
@@ -215,7 +219,7 @@ function AddPeerForm({ onAdd }) {
e.preventDefault();
if (!address.trim()) return;
setAdding(true);
- const data = { address: address.trim(), port: parseInt(port, 10) || 5555 };
+ const data = { address: address.trim(), port: parseInt(port, 10) || DEFAULT_PEER_PORT };
if (name.trim()) data.name = name.trim();
// Only attach credentials when a password was entered — username alone
// (or neither) is treated as "no auth" by the server's sanitizer.
@@ -224,7 +228,7 @@ function AddPeerForm({ onAdd }) {
setAdding(false);
if (!result) return;
setAddress('');
- setPort('5555');
+ setPort(String(DEFAULT_PEER_PORT));
setName('');
setUsername('');
setPassword('');
@@ -240,6 +244,7 @@ function AddPeerForm({ onAdd }) {
setAddress(e.target.value)}
@@ -252,7 +257,7 @@ function AddPeerForm({ onAdd }) {
aria-label="Peer port"
value={port}
onChange={e => setPort(e.target.value)}
- placeholder="5554"
+ placeholder={String(DEFAULT_PEER_PORT)}
type="number"
min="1"
max="65535"
@@ -1242,6 +1247,9 @@ function PeerCard({ peer, onRefresh, syncStatus, tailnetInfo, parityReport }) {
}
export default function Instances() {
+ // The Add Peer form is always on screen above the peer grid, so the empty
+ // state's call to action focuses its address field rather than opening one.
+ const peerAddressRef = useRef(null);
const [self, setSelf] = useState(null);
const [peers, setPeers] = useState([]);
const [syncStatus, setSyncStatus] = useState(null);
@@ -1320,7 +1328,7 @@ export default function Instances() {
{/* Outside the peer-count guard on purpose: removing the last peer must
@@ -1356,11 +1364,13 @@ export default function Instances() {
)}
{peers.length === 0 && (
-
-
-
No peers registered yet.
-
Add a Tailscale IP address to connect to another PortOS instance.
-
+
peerAddressRef.current?.focus()}
+ />
)}
);
diff --git a/client/src/pages/Instances.test.jsx b/client/src/pages/Instances.test.jsx
new file mode 100644
index 0000000000..9bb1f810d8
--- /dev/null
+++ b/client/src/pages/Instances.test.jsx
@@ -0,0 +1,52 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { AddPeerForm } from './Instances.jsx';
+import { DEFAULT_PEER_PORT } from '../lib/ports.js';
+import { addPeer } from '../services/api';
+
+vi.mock('../services/api', () => ({
+ getInstances: vi.fn(),
+ updateSelfInstance: vi.fn(),
+ addPeer: vi.fn(),
+ updatePeer: vi.fn(),
+ removePeer: vi.fn(),
+ connectPeer: vi.fn(),
+ reciprocatePeer: vi.fn(),
+ probePeer: vi.fn(),
+ syncPeer: vi.fn(),
+ getTailnetInfo: vi.fn(),
+ getNetworkExposure: vi.fn(),
+ listPeerSubscriptions: vi.fn(),
+ getPeerFullSyncCoverage: vi.fn(),
+ getBrainParityReports: vi.fn(),
+}));
+
+vi.mock('../services/socket', () => ({ default: { on: vi.fn(), off: vi.fn(), emit: vi.fn() } }));
+
+describe('AddPeerForm port default', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ addPeer.mockResolvedValue({ id: 'peer-1' });
+ });
+
+ // Regression: the placeholder advertised :5554 (the Vite dev port) while the
+ // field defaulted to the API port, so clearing the field suggested a port
+ // PortOS never serves the API on.
+ it('advertises the same port in the placeholder as it defaults to', () => {
+ render( {}} />);
+ const portInput = screen.getByLabelText('Peer port');
+ expect(portInput).toHaveValue(DEFAULT_PEER_PORT);
+ expect(portInput.getAttribute('placeholder')).toBe(String(DEFAULT_PEER_PORT));
+ });
+
+ it('falls back to the default port when the field is cleared', async () => {
+ render( {}} />);
+ fireEvent.change(screen.getByLabelText('Peer address'), { target: { value: '192.0.2.10' } });
+ fireEvent.change(screen.getByLabelText('Peer port'), { target: { value: '' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Add' }));
+ await waitFor(() => expect(addPeer).toHaveBeenCalledWith({
+ address: '192.0.2.10',
+ port: DEFAULT_PEER_PORT,
+ }));
+ });
+});
diff --git a/client/src/pages/LocalLlmPlayground.jsx b/client/src/pages/LocalLlmPlayground.jsx
index a2717d7a59..4d6bfd8784 100644
--- a/client/src/pages/LocalLlmPlayground.jsx
+++ b/client/src/pages/LocalLlmPlayground.jsx
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { useAutoRefetch } from '../hooks/useAutoRefetch.js';
import useMounted from '../hooks/useMounted';
import { Link, useSearchParams } from 'react-router';
import { ArrowLeft, ArrowRightLeft, Brain, Check, ChevronDown, Clock, Copy, Cpu, Gauge, MessageSquare, Play, RefreshCw, Send, TriangleAlert, X } from 'lucide-react';
@@ -307,11 +308,7 @@ export default function LocalLlmPlayground() {
// Poll which models are warm in memory. A faster cadence while a run is busy
// so the "serving" flag and freed-slot countdown stay live; relaxed when idle.
- useEffect(() => {
- refreshLoaded();
- const interval = setInterval(refreshLoaded, busy ? 2000 : 6000);
- return () => clearInterval(interval);
- }, [refreshLoaded, busy]);
+ useAutoRefetch(refreshLoaded, busy ? 2000 : 6000, { pollOnly: true });
const installedTargets = useMemo(() => {
const models = [];
diff --git a/client/src/pages/Loops.jsx b/client/src/pages/Loops.jsx
index 2aff0023cc..8cdfed505b 100644
--- a/client/src/pages/Loops.jsx
+++ b/client/src/pages/Loops.jsx
@@ -12,6 +12,7 @@ import { formatDurationMs } from '../utils/formatters';
import BrailleSpinner from '../components/BrailleSpinner';
import { useAutoRefetch } from '../hooks/useAutoRefetch';
import { clickableProps } from '../lib/a11yKeyboard.js';
+import EmptyState from '../components/EmptyState';
const INTERVAL_PRESETS = [
{ label: '30s', value: '30s' },
@@ -40,9 +41,9 @@ function StatusBadge({ loop }) {
);
}
-function CreateLoopForm({ providers, onCreated }) {
+function CreateLoopForm({ providers, onCreated, promptRef }) {
const [prompt, setPrompt] = useState('');
- const [interval, setInterval] = useState('10m');
+ const [intervalPreset, setIntervalPreset] = useState('10m');
const [customInterval, setCustomInterval] = useState('');
const [name, setName] = useState('');
const [providerId, setProviderId] = useState('');
@@ -59,7 +60,7 @@ function CreateLoopForm({ providers, onCreated }) {
setCreating(true);
const data = {
prompt: prompt.trim(),
- interval: customInterval || interval,
+ interval: customInterval || intervalPreset,
name: name.trim() || undefined,
cwd: cwd.trim() || undefined,
providerId: providerId || undefined,
@@ -84,6 +85,7 @@ function CreateLoopForm({ providers, onCreated }) {
setPrompt(e.target.value)}
@@ -100,9 +102,9 @@ function CreateLoopForm({ providers, onCreated }) {
{ setInterval(p.value); setCustomInterval(''); }}
+ onClick={() => { setIntervalPreset(p.value); setCustomInterval(''); }}
className={`px-2 py-1 text-xs rounded border ${
- interval === p.value && !customInterval
+ intervalPreset === p.value && !customInterval
? 'border-port-accent bg-port-accent/20 text-port-accent'
: 'border-port-border text-gray-400 hover:border-gray-500'
}`}
@@ -274,7 +276,7 @@ function LoopCard({ loop, onAction, expandedId, onToggle }) {
Prompt
-
{loop.prompt}
+
{loop.prompt}
{loop.history?.length > 0 && (
@@ -339,6 +341,9 @@ const ACTION_MAP = {
};
export default function Loops() {
+ // The create form is always on screen above the list, so the empty state's
+ // call to action focuses its prompt field rather than opening anything.
+ const promptRef = useRef(null);
const [loops, setLoops] = useState([]);
const [providers, setProviders] = useState([]);
const [loading, setLoading] = useState(true);
@@ -395,23 +400,26 @@ export default function Loops() {
{runningCount > 0 && {runningCount} active }
{loops.length} total
-
+
-
+
{loading ? (
Loading loops...
) : loops.length === 0 ? (
-
-
-
No loops yet. Create one above to get started.
-
+ promptRef.current?.focus()}
+ />
) : (
{loops.map(loop => (
diff --git a/client/src/pages/Loops.test.jsx b/client/src/pages/Loops.test.jsx
index 2deacf6202..345cea90fd 100644
--- a/client/src/pages/Loops.test.jsx
+++ b/client/src/pages/Loops.test.jsx
@@ -1,5 +1,6 @@
import { describe, it, expect, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
vi.mock('../services/api', () => ({
getLoops: vi.fn(() => Promise.resolve([])),
@@ -15,9 +16,19 @@ vi.mock('../services/socket', () => ({
default: { on: vi.fn(), off: vi.fn(), emit: vi.fn() },
}));
-vi.mock('../hooks/useAutoRefetch', () => ({
- useAutoRefetch: vi.fn(),
-}));
+// Mirror the real hook's on-mount fetch (the page clears `loading` only from
+// that callback) while dropping the interval, which jsdom has no use for.
+vi.mock('../hooks/useAutoRefetch', async () => {
+ const { useEffect, useRef } = await import('react');
+ return {
+ useAutoRefetch: (fetchFn) => {
+ const fetchRef = useRef(fetchFn);
+ fetchRef.current = fetchFn;
+ useEffect(() => { fetchRef.current(); }, []);
+ return { refetch: () => fetchRef.current() };
+ },
+ };
+});
import Loops from './Loops';
@@ -33,3 +44,13 @@ describe('Loops new-loop form label associations', () => {
expect(label.getAttribute('for')).toBe(input.id);
});
});
+
+describe('Loops index empty state', () => {
+ it('offers a call to action that focuses the new-loop prompt', async () => {
+ render( );
+ expect(await screen.findByText('No loops yet')).toBeInTheDocument();
+ const cta = screen.getByRole('button', { name: 'Describe your first loop' });
+ await userEvent.click(cta);
+ expect(screen.getByLabelText('Loop prompt')).toHaveFocus();
+ });
+});
diff --git a/client/src/pages/LoraDatasetDetail.jsx b/client/src/pages/LoraDatasetDetail.jsx
index 25a6bb717d..cb8bed8a12 100644
--- a/client/src/pages/LoraDatasetDetail.jsx
+++ b/client/src/pages/LoraDatasetDetail.jsx
@@ -20,6 +20,7 @@ import toast from '../components/ui/Toast';
import FilePickerButton from '../components/ui/FilePickerButton';
import { IMAGE_ACCEPT } from '../utils/fileUpload';
import Modal from '../components/ui/Modal';
+import { useAutoRefetch } from '../hooks/useAutoRefetch.js';
import { useSseProgress } from '../hooks/useSseProgress';
import DatasetImageGrid from '../components/loraTraining/DatasetImageGrid';
import GenerateBatchDialog from '../components/loraTraining/GenerateBatchDialog';
@@ -27,6 +28,7 @@ import TrainingPanel from '../components/loraTraining/TrainingPanel';
import CaptionModelPicker from '../components/loraTraining/CaptionModelPicker';
import ImportGalleryDialog from '../components/loraTraining/ImportGalleryDialog';
import UniverseCharacterPicker from '../components/loraTraining/UniverseCharacterPicker';
+import { escapeRegExp } from '../lib/textUtils.js';
import {
getLoraDataset,
getLoraDatasetVariationAxes,
@@ -60,7 +62,7 @@ const captionHasTriggerWord = (caption, triggerWord) => {
const text = (caption || '').trim();
if (!text) return false;
if (!word) return true;
- const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const escaped = escapeRegExp(word);
return new RegExp(`(?:^|[^a-z0-9_])${escaped}(?:[^a-z0-9_]|$)`, 'i').test(text);
};
// Mirror of server/lib/loraDataset.js analyzeCaptionInvariants — flags the
@@ -75,7 +77,7 @@ const captionBody = (caption, triggerWord) => {
const word = (triggerWord || '').trim();
let body = (caption || '').trim();
if (word) {
- const escaped = word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const escaped = escapeRegExp(word);
body = body.replace(new RegExp(`^${escaped}(?=[\\s,]|$)\\s*,?\\s*`, 'i'), '');
}
return body;
@@ -371,11 +373,7 @@ export default function LoraDatasetDetail({ recordId }) {
// Poll while any image renders — the server heals stuck images on read.
const renderingCount = readiness.rendering;
- useEffect(() => {
- if (!renderingCount) return undefined;
- const timer = setInterval(refresh, 5000);
- return () => clearInterval(timer);
- }, [renderingCount, refresh]);
+ useAutoRefetch(refresh, 5000, { enabled: renderingCount > 0, immediate: false, pollOnly: true });
// Caption-run SSE — refetch on terminal so captions land in the grid, and
// surface failures the run reported. The server emits per-image `error`
diff --git a/client/src/pages/Media3D.jsx b/client/src/pages/Media3D.jsx
index f959aaccff..cb036ef960 100644
--- a/client/src/pages/Media3D.jsx
+++ b/client/src/pages/Media3D.jsx
@@ -14,7 +14,7 @@ import useUrlParams from '../hooks/useUrlParams';
import MediaImage from '../components/MediaImage';
import { imageTo3dStatusMeta } from '../components/media/imageTo3dStatus';
import ImageTo3dRenderOptions from '../components/media/ImageTo3dRenderOptions';
-import { renderOptionsBody } from '../lib/imageTo3dRenderOptions';
+import { renderOptionsBody, SUBJECT_SCALE_DEFAULT } from '../lib/imageTo3dRenderOptions';
import { isTargetReady, unavailableReasonLabel } from '../lib/imageTo3dReasons';
// Poll cadence while a render is in flight (a real TRELLIS.2 render is multi-minute).
@@ -44,6 +44,7 @@ export default function Media3D() {
const [detail, setDetail] = useState('auto');
const [alphaMode, setAlphaMode] = useState('');
const [normalMap, setNormalMap] = useState(false);
+ const [subjectScale, setSubjectScale] = useState(SUBJECT_SCALE_DEFAULT);
// Existing image-to-3D records (newest-first) so the page doubles as a library:
// each links to its `/3d/:id` detail view.
const [records, setRecords] = useState([]);
@@ -131,7 +132,9 @@ export default function Media3D() {
name: nameFromImageFilename(selectedImage.filename),
filename: selectedImage.filename,
target: selectedTarget.id,
- ...renderOptionsBody({ steps, seed, keyBackground, detail, alphaMode, normalMap }),
+ ...renderOptionsBody({
+ steps, seed, keyBackground, detail, alphaMode, normalMap, subjectScale,
+ }),
},
{ silent: true },
).catch((err) => {
@@ -140,7 +143,7 @@ export default function Media3D() {
});
if (created && mountedRef.current) { setModelId(created.id); setGenerating(true); patchRecord(created); }
}, [selectedImage, selectedTarget, steps, seed, keyBackground, detail, alphaMode, normalMap,
- updateParams, mountedRef, patchRecord]);
+ subjectScale, updateParams, mountedRef, patchRecord]);
// Why the Generate action is blocked, or null when it's ready to run. The runner
// (POST create → on-device render → landed .glb) is wired, so the terminal state
@@ -257,6 +260,9 @@ export default function Media3D() {
onSeedChange={setSeed}
keyBackground={keyBackground}
onKeyBackgroundChange={setKeyBackground}
+ subjectScale={subjectScale}
+ onSubjectScaleChange={setSubjectScale}
+ sourcePreviewUrl={selectedImage?.previewUrl || null}
disabled={generating}
/>
diff --git a/client/src/pages/Media3DDetail.jsx b/client/src/pages/Media3DDetail.jsx
index 0b08c0cea2..47848a45b1 100644
--- a/client/src/pages/Media3DDetail.jsx
+++ b/client/src/pages/Media3DDetail.jsx
@@ -9,7 +9,11 @@ import GlbViewer from '../components/media/GlbViewer';
import MediaImage from '../components/MediaImage';
import InlineConfirmRow from '../components/ui/InlineConfirmRow';
import ImageTo3dRenderOptions from '../components/media/ImageTo3dRenderOptions';
-import { fieldsFromRun, renderOptionsBody, runWantsTransparency } from '../lib/imageTo3dRenderOptions';
+import RigPanel from '../components/media/RigPanel';
+import ArExportPanel from '../components/media/ArExportPanel';
+import {
+ fieldsFromRun, renderOptionsBody, runWantsTransparency, SUBJECT_SCALE_DEFAULT,
+} from '../lib/imageTo3dRenderOptions';
import { imageTo3dStatusMeta } from '../components/media/imageTo3dStatus';
import toast from '../components/ui/Toast';
import PageSkeleton from '../components/ui/PageSkeleton';
@@ -30,6 +34,10 @@ export default function Media3DDetail() {
const [notFound, setNotFound] = useState(false);
const [busy, setBusy] = useState(false);
const [confirmingDelete, setConfirmingDelete] = useState(false);
+ // The three.js graph GlbViewer has loaded, handed up so the AR panel can
+ // re-serialize the very scene on screen to USDZ. `null` until the GLB parses
+ // (and again when it unloads) — the export button stays disabled until then.
+ const [loadedScene, setLoadedScene] = useState(null);
// Per-run knobs, seeded from the latest run once per id (NOT on every poll
// tick — that would clobber in-progress edits). Seed stays blank by design:
// see fieldsFromRun.
@@ -39,6 +47,7 @@ export default function Media3DDetail() {
const [detail, setDetail] = useState('auto');
const [alphaMode, setAlphaMode] = useState('');
const [normalMap, setNormalMap] = useState(false);
+ const [subjectScale, setSubjectScale] = useState(SUBJECT_SCALE_DEFAULT);
const optionsSeededFor = useRef(null);
const load = useCallback(async ({ initial = false } = {}) => {
@@ -81,6 +90,7 @@ export default function Media3DDetail() {
setDetail(fields.detail);
setAlphaMode(fields.alphaMode);
setNormalMap(fields.normalMap);
+ setSubjectScale(fields.subjectScale);
}, [record]);
const handleRegenerate = useCallback(async () => {
@@ -88,7 +98,7 @@ export default function Media3DDetail() {
setBusy(true);
const next = await generateImageTo3dModel(
id,
- renderOptionsBody({ steps, seed, keyBackground, detail, alphaMode, normalMap }),
+ renderOptionsBody({ steps, seed, keyBackground, detail, alphaMode, normalMap, subjectScale }),
{ silent: true },
).catch((err) => {
toast.error(err?.message || 'Could not start the render.');
@@ -96,7 +106,8 @@ export default function Media3DDetail() {
});
if (mountedRef.current) setBusy(false);
if (next && mountedRef.current) setRecord(next);
- }, [busy, record?.status, id, steps, seed, keyBackground, detail, alphaMode, normalMap, mountedRef]);
+ }, [busy, record?.status, id, steps, seed, keyBackground, detail, alphaMode, normalMap,
+ subjectScale, mountedRef]);
const handleDelete = useCallback(async () => {
const ok = await deleteImageTo3dModel(id, { silent: true }).then(() => true).catch((err) => {
@@ -179,6 +190,9 @@ export default function Media3DDetail() {
{Number.isInteger(latestRun?.seed) ? ` · seed ${latestRun.seed}` : ''}
{Number.isInteger(latestRun?.steps) ? ` · ${latestRun.steps} steps` : ''}
{latestRun?.sourceKeyed ? ' · background keyed' : ''}
+ {latestRun?.sourceFramed && Number.isFinite(latestRun?.subjectScale)
+ ? ` · framed at ${Math.round(latestRun.subjectScale * 100)}%`
+ : ''}
{' '}· updated {timeAgo(record.updatedAt)}
@@ -201,6 +215,9 @@ export default function Media3DDetail() {
normalMapSupported={record.supportsRenderOptions?.normalMap !== false}
normalMap={normalMap}
onNormalMapChange={setNormalMap}
+ subjectScale={subjectScale}
+ onSubjectScaleChange={setSubjectScale}
+ sourcePreviewUrl={record.sourceImage?.path || null}
disabled={busy || isGenerating}
/>
@@ -242,6 +259,7 @@ export default function Media3DDetail() {
src={meshSrc}
downloadHref={imageTo3dAssetUrl(record.id)}
forceOpaque={!renderedTransparent}
+ onSceneLoaded={setLoadedScene}
/>
{/* The viewer loads the decimated GLB because that is what a browser
can render; the decoder's full mesh is an order of magnitude
@@ -267,6 +285,10 @@ export default function Media3DDetail() {
)}
+
+
+
+
);
}
diff --git a/client/src/pages/Media3DDetail.test.jsx b/client/src/pages/Media3DDetail.test.jsx
index 262f6025a4..d03d544cf1 100644
--- a/client/src/pages/Media3DDetail.test.jsx
+++ b/client/src/pages/Media3DDetail.test.jsx
@@ -1,4 +1,5 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { useEffect } from 'react';
import { render, screen, fireEvent, waitFor, within } from '@testing-library/react';
import { MemoryRouter, Routes, Route } from 'react-router';
import Media3DDetail from './Media3DDetail';
@@ -12,15 +13,29 @@ vi.mock('../services/api', () => ({
deleteImageTo3dModel: (...a) => deleteImageTo3dModel(...a),
imageTo3dAssetUrl: (id) => `/api/image-to-3d/models/${id}/asset`,
imageTo3dFullMeshUrl: (id) => `/api/image-to-3d/models/${id}/full-mesh`,
+ imageTo3dUsdzUrl: (id) => `/api/image-to-3d/models/${id}/usdz`,
}));
-// GlbViewer wraps a WebGL canvas jsdom can't render — stub to a marker echoing src.
+// GlbViewer wraps a WebGL canvas jsdom can't render — stub to a marker echoing
+// src. It also hands the loaded three.js graph up via `onSceneLoaded`, so the
+// stub fires that with a marker: the AR export button is disabled until it
+// arrives, and dropping that prop is an invisible regression otherwise.
vi.mock('../components/media/GlbViewer', () => ({
- default: ({ src, forceOpaque }) => (
-
{src}
- ),
+ default: ({ src, forceOpaque, onSceneLoaded }) => {
+ useEffect(() => { onSceneLoaded?.({ marker: 'loaded-scene' }); }, [onSceneLoaded]);
+ return
{src}
;
+ },
+}));
+// The AR panel owns its own export/upload flow (covered by ArExportPanel.test.jsx);
+// stubbing it keeps this suite about the page — but it echoes whether the scene
+// reached it, which is the page's half of the contract.
+vi.mock('../components/media/ArExportPanel', () => ({
+ default: ({ scene }) =>
,
}));
vi.mock('../components/MediaImage', () => ({ default: ({ alt, src }) =>
}));
+// The rig panel owns its own readiness fetch + feature gate (covered by
+// RigPanel.test.jsx); stubbing it keeps this suite about the page.
+vi.mock('../components/media/RigPanel', () => ({ default: () =>
}));
vi.mock('../components/ui/Toast', () => ({ default: { error: vi.fn(), success: vi.fn() } }));
const record = (over = {}) => ({
@@ -58,6 +73,9 @@ describe('Media3DDetail', () => {
);
expect(screen.getByTestId('glb-viewer')).toHaveAttribute('data-force-opaque', 'true');
expect(screen.getByAltText('Source image')).toBeInTheDocument();
+ // The viewer's loaded scene has to reach the AR panel or its export button
+ // stays permanently disabled — a prop chain no other assertion touches.
+ expect(await screen.findByTestId('ar-export-panel')).toHaveAttribute('data-has-scene', 'true');
});
it('surfaces the render error for a failed record', async () => {
@@ -99,7 +117,7 @@ describe('Media3DDetail', () => {
// normalMap is sent explicitly rather than omitted. It defaults OFF (the bake
// can lose a render outright), and stating it keeps the body honest about what
// the run asked for even if that default ever moves.
- { steps: 24, keyBackground: false, normalMap: false },
+ { steps: 24, keyBackground: false, normalMap: false, subjectScale: 1 },
{ silent: true },
));
});
@@ -122,7 +140,7 @@ describe('Media3DDetail', () => {
fireEvent.click(screen.getByRole('button', { name: /re-render/i }));
await waitFor(() => expect(generateImageTo3dModel).toHaveBeenCalledWith(
'image3d-1',
- { steps: 48, keyBackground: false, normalMap: false },
+ { steps: 48, keyBackground: false, normalMap: false, subjectScale: 1 },
{ silent: true },
));
});
@@ -147,7 +165,32 @@ describe('Media3DDetail', () => {
fireEvent.click(screen.getByRole('button', { name: /re-render/i }));
await waitFor(() => expect(generateImageTo3dModel).toHaveBeenCalledWith(
'image3d-1',
- { keyBackground: false, detail: 'fast', alphaMode: 'auto', normalMap: false },
+ { keyBackground: false, detail: 'fast', alphaMode: 'auto', normalMap: false, subjectScale: 1 },
+ { silent: true },
+ ));
+ });
+
+ it('carries the subject framing over and reports it on the finished run', async () => {
+ // A framing that produced a good mesh is exactly what a re-render wants to keep;
+ // and the meta line has to say the render was reframed, or the user cannot tell
+ // whether the last result came from the source's own framing or this knob.
+ getImageTo3dModel.mockResolvedValue(record({
+ runs: [{
+ operationId: 'op-1', status: 'completed', percent: 100,
+ subjectScale: 0.65, sourceFramed: true,
+ }],
+ }));
+ generateImageTo3dModel.mockResolvedValue(record({ status: 'generating' }));
+ renderAt();
+ await screen.findByText('Example Beacon');
+
+ await waitFor(() => expect(screen.getByLabelText(/subject framing — 65%/i)).toHaveValue('0.65'));
+ expect(screen.getByText(/framed at 65%/i)).toBeInTheDocument();
+
+ fireEvent.click(screen.getByRole('button', { name: /re-render/i }));
+ await waitFor(() => expect(generateImageTo3dModel).toHaveBeenCalledWith(
+ 'image3d-1',
+ { keyBackground: false, normalMap: false, subjectScale: 0.65 },
{ silent: true },
));
});
diff --git a/client/src/pages/MediaCollectionDetail.jsx b/client/src/pages/MediaCollectionDetail.jsx
index 66f48fb655..b05398e2e8 100644
--- a/client/src/pages/MediaCollectionDetail.jsx
+++ b/client/src/pages/MediaCollectionDetail.jsx
@@ -5,6 +5,7 @@ import ShareToButton from '../components/sharing/ShareToButton';
import PageSkeleton from '../components/ui/PageSkeleton';
import toast from '../components/ui/Toast';
import MediaCard from '../components/media/MediaCard';
+import AttributionList from '../components/media/AttributionList';
import MediaPreview from '../components/media/MediaPreview';
import BulkTargetPicker from '../components/media/BulkTargetPicker';
import { normalizeImage, normalizeVideo } from '../components/media/normalize';
@@ -429,6 +430,8 @@ export default function MediaCollectionDetail() {
)}
+
+
{selectMode && (
diff --git a/client/src/pages/MediaCollections.jsx b/client/src/pages/MediaCollections.jsx
index de2b0158f6..14a2f7e6d8 100644
--- a/client/src/pages/MediaCollections.jsx
+++ b/client/src/pages/MediaCollections.jsx
@@ -1,7 +1,8 @@
-import { useEffect, useMemo, useState } from 'react';
+import { useEffect, useMemo, useRef, useState } from 'react';
import { Link, useNavigate } from 'react-router';
import { Plus, FolderOpen, Inbox, Trash2, Image as ImageIcon, Film, Search } from 'lucide-react';
import PageSkeleton from '../components/ui/PageSkeleton';
+import EmptyState from '../components/EmptyState';
import toast from '../components/ui/Toast';
import {
listMediaCollections, createMediaCollection, deleteMediaCollection,
@@ -63,6 +64,9 @@ const resolveCover = (collection, imagesByName, videosById) => {
};
export default function MediaCollections() {
+ // Focus target for the empty state's call to action — the create form sits
+ // above the list, so the button has to point back up at it.
+ const nameInputRef = useRef(null);
const navigate = useNavigate();
const [searchParams, updateParams] = useUrlParams();
const [collections, setCollections] = useState([]);
@@ -203,6 +207,7 @@ export default function MediaCollections() {
setName(e.target.value)}
@@ -271,9 +276,13 @@ export default function MediaCollections() {
isn't on screen. The grid is independent of both — a fresh install
with loose media shows the onboarding copy AND its Unsorted card. */}
{collections.length === 0 && !query ? (
-
- No collections yet. Create one above, or use the folder icon on any image/video card to start a new collection.
-
+ nameInputRef.current?.focus()}
+ />
) : (
<>
{hiddenEmptyCount > 0 && (
diff --git a/client/src/pages/MediaCollections.test.jsx b/client/src/pages/MediaCollections.test.jsx
index d6a4b49709..297c0c0278 100644
--- a/client/src/pages/MediaCollections.test.jsx
+++ b/client/src/pages/MediaCollections.test.jsx
@@ -248,6 +248,18 @@ describe('MediaCollections', () => {
expect(screen.queryByText(/Every collection here is empty/)).not.toBeInTheDocument();
});
+ it('gives the fresh-install empty state a call to action that focuses the create field', async () => {
+ const { listMediaCollections } = await import('../services/api');
+ listMediaCollections.mockResolvedValueOnce([]);
+ mockUnsortedItems = [];
+ renderPage();
+ const cta = await screen.findByRole('button', { name: 'Name your first collection' });
+ await userEvent.click(cta);
+ // Without the action the empty state is a dead end: the create form is
+ // above the list, so the button has to point back up at it.
+ expect(screen.getByLabelText('New collection name')).toHaveFocus();
+ });
+
it('reports a failed search as a failed search, not as a fresh install', async () => {
const { listMediaCollections } = await import('../services/api');
listMediaCollections.mockResolvedValueOnce([]);
diff --git a/client/src/pages/Models.jsx b/client/src/pages/Models.jsx
index 5e4370dfd2..1fdb93eebd 100644
--- a/client/src/pages/Models.jsx
+++ b/client/src/pages/Models.jsx
@@ -6,6 +6,8 @@ import PageSkeleton from '../components/ui/PageSkeleton';
import ModelsTabsHeader from '../components/models/ModelsTabsHeader';
import Image3dRuntimes from '../components/models/Image3dRuntimes';
import ModelStatusTab from '../components/models/ModelStatusTab';
+import CodeReviewersTab from '../components/settings/CodeReviewersTab';
+import HarnessesTab from '../components/models/HarnessesTab';
import EmbeddingsTab from '../components/settings/EmbeddingsTab';
import LocalModelAssessments from '../components/settings/LocalModelAssessments.jsx';
import { LocalLlmTab } from '../components/settings/LocalLlmTab';
@@ -27,7 +29,9 @@ const MediaModels = lazyWithReload(() => import('./MediaModels'));
* covers every KIND of model an install manages (#4728), not just text:
*
* - **3D** — image-to-3D runtime install/repair (TRELLIS.2, Pixal3D).
+ * - **Code Reviewers** — the review-loop chain and its model/effort pins.
* - **Embeddings** — the embedding model backing pgvector search.
+ * - **Harnesses** — the coding-agent CLIs/TUIs, their versions and model lists.
* - **LLMs** — focused runtime, model-library, and abuse-guard sub-routes.
* - **LoRAs** — installed image/video adapters.
* - **Media** — image/video checkpoints and the Hugging Face cache.
@@ -46,7 +50,9 @@ const MediaModels = lazyWithReload(() => import('./MediaModels'));
*/
const TAB_CONTENT = {
'3d': Image3dRuntimes,
+ 'code-reviewers': CodeReviewersTab,
embeddings: EmbeddingsTab,
+ harnesses: HarnessesTab,
llms: LocalLlmTab,
loras: Loras,
media: MediaModels,
diff --git a/client/src/pages/Models.test.jsx b/client/src/pages/Models.test.jsx
index 283f6eea36..2283016974 100644
--- a/client/src/pages/Models.test.jsx
+++ b/client/src/pages/Models.test.jsx
@@ -19,6 +19,8 @@ vi.mock('../components/settings/LocalLlmTab', () => ({
vi.mock('../components/settings/EmbeddingsTab', () => ({ default: () => embeddings panel
}));
vi.mock('../components/models/Image3dRuntimes', () => ({ default: () => 3d runtimes panel
}));
vi.mock('../components/models/ModelStatusTab', () => ({ default: () => status panel
}));
+vi.mock('../components/settings/CodeReviewersTab', () => ({ default: () => code reviewers panel
}));
+vi.mock('../components/models/HarnessesTab', () => ({ default: () => harnesses panel
}));
vi.mock('./Loras', () => ({ default: () => loras panel
}));
vi.mock('./LoraTraining', () => ({ default: () => training panel
}));
vi.mock('./MediaModels', () => ({ default: () => media models panel
}));
@@ -32,7 +34,9 @@ import Models from './Models';
// quietly going unrendered by a hand-maintained second list.
const PANEL_MARKER = {
'3d': '3d runtimes panel',
+ 'code-reviewers': 'code reviewers panel',
embeddings: 'embeddings panel',
+ harnesses: 'harnesses panel',
llms: 'llms panel',
loras: 'loras panel',
media: 'media models panel',
diff --git a/client/src/pages/MoodBoards.jsx b/client/src/pages/MoodBoards.jsx
index 0cb824c408..9083d911d3 100644
--- a/client/src/pages/MoodBoards.jsx
+++ b/client/src/pages/MoodBoards.jsx
@@ -14,6 +14,7 @@ import { Plus, Palette, Trash2, ImageIcon, FileText } from 'lucide-react';
import PageSkeleton from '../components/ui/PageSkeleton';
import toast from '../components/ui/Toast';
import InlineConfirmRow from '../components/ui/InlineConfirmRow';
+import EmptyState from '../components/EmptyState';
import { timeAgo } from '../utils/formatters';
import { listMoodBoards, createMoodBoard, deleteMoodBoard } from '../services/api';
@@ -46,6 +47,10 @@ export default function MoodBoards() {
useEffect(() => { load(); }, [load]);
const handleCreate = async () => {
+ // The header button is disabled while a create is in flight, but the empty
+ // state's call to action has no disabled state — guard here so a double
+ // click can't mint two boards.
+ if (creating) return;
setCreating(true);
const board = await createMoodBoard({ name: 'Untitled board' }, { silent: true }).catch(() => null);
setCreating(false);
@@ -86,9 +91,13 @@ export default function MoodBoards() {
{loading ? (
) : boards.length === 0 ? (
-
- No mood boards yet. Create one to start pinning references.
-
+
) : (
} />
-
-
-);
-
-describe('OpenWorld — fast travel wiring', () => {
- beforeEach(() => {
- sceneProps.current = null;
- openWorldDataState.loading = false;
- localStorage.clear();
- });
-
- it('keeps the scene mounted while the initial data bundle is loading', () => {
- openWorldDataState.loading = true;
-
- renderAt('/openworld');
-
- expect(screen.getByTestId('scene')).toBeInTheDocument();
- expect(screen.queryByText('ENTERING OPENWORLD')).not.toBeInTheDocument();
- });
-
- it('hands the scene no region on the plain overview route', () => {
- renderAt('/openworld');
- expect(sceneProps.current.focusedRegion).toBeNull();
- });
-
- it('resolves the :regionId route param into a region for the camera', () => {
- renderAt('/openworld/region/memory');
- expect(sceneProps.current.focusedRegion.id).toBe('memory');
- // Geography comes from the master town plan, not from the route.
- expect(sceneProps.current.focusedRegion.anchor).toBeDefined();
- });
-
- it('arms the first-person arrival point for a direct region deep link', () => {
- renderAt('/openworld/region/memory');
- expect(sceneProps.current.playerTeleport).toMatchObject({
- x: expect.any(Number),
- z: expect.any(Number),
- regionId: 'memory',
- token: 1,
- });
- });
-
- it('hands the scene a null region for an unknown id rather than crashing', () => {
- renderAt('/openworld/region/atlantis');
- expect(sceneProps.current.focusedRegion).toBeNull();
- });
-
- it('defaults to the Vibes world style, and reflects it in the scene settings', () => {
- renderAt('/openworld');
- expect(sceneProps.current.settings.worldStyle).toBe('vibes');
- expect(sceneProps.current.settings.timeOfDay).toMatch(/^vibes/);
- expect(sceneProps.current.palette.lowPoly).toBe(true);
- });
-
- it('honors a stored cyber style, restoring the original preset pair', () => {
- localStorage.setItem('portos-city-settings', JSON.stringify({ worldStyle: 'cyber' }));
- renderAt('/openworld');
- expect(sceneProps.current.settings.worldStyle).toBe('cyber');
- expect(sceneProps.current.settings.timeOfDay).toBe('sunset');
- expect(sceneProps.current.palette.lowPoly).toBe(false);
- });
-
- it('opens fast travel with M and warps to the picked region', () => {
- renderAt('/openworld');
- act(() => { fireEvent.keyDown(window, { key: 'm' }); });
-
- fireEvent.click(screen.getByLabelText('Teleport to Memory House'));
-
- expect(screen.getByTestId('path')).toHaveTextContent('/openworld/region/memory');
- expect(sceneProps.current.focusedRegion.id).toBe('memory');
- });
-
- it('opens fast travel from the HUD button too', () => {
- renderAt('/openworld');
- fireEvent.click(screen.getByText('hud-fast-travel'));
- expect(screen.getByLabelText('Teleport to Memory House')).toBeInTheDocument();
- });
-
- it('closes fast travel with Escape', () => {
- renderAt('/openworld');
- act(() => { fireEvent.keyDown(window, { key: 'm' }); });
- expect(screen.getByLabelText('Search village places')).toBeInTheDocument();
-
- act(() => { fireEvent.keyDown(window, { key: 'Escape' }); });
- expect(screen.queryByLabelText('Search village places')).not.toBeInTheDocument();
- });
-
- it('arms no arrival point until something is actually warped to', () => {
- renderAt('/openworld');
- expect(sceneProps.current.playerTeleport).toBeNull();
- });
-
- it(`arms the walking player's arrival point on every warp, exploring or not`, () => {
- // PlayerController mounts only in exploration mode and applies the current token on
- // mount, so arming it from the orbital overview is what makes "warp, then Tab in"
- // land at the region rather than the old spawn.
- renderAt('/openworld');
- act(() => { fireEvent.keyDown(window, { key: 'm' }); });
- fireEvent.click(screen.getByLabelText('Teleport to Memory House'));
-
- const teleport = sceneProps.current.playerTeleport;
- expect(teleport).toMatchObject({ x: expect.any(Number), z: expect.any(Number) });
- expect(teleport.token).toBe(1);
- });
-
- it('teleports the player when warping on foot', () => {
- localStorage.setItem('portos-city-settings', JSON.stringify({ explorationMode: true }));
- renderAt('/openworld');
- act(() => { fireEvent.keyDown(window, { key: 'm' }); });
- fireEvent.click(screen.getByLabelText('Teleport to Memory House'));
-
- const teleport = sceneProps.current.playerTeleport;
- expect(teleport).toMatchObject({ x: expect.any(Number), z: expect.any(Number) });
- expect(teleport.token).toBe(1);
- });
-
- it('bumps the teleport token when the same region is picked twice', () => {
- renderAt('/openworld');
-
- act(() => { fireEvent.keyDown(window, { key: 'm' }); });
- fireEvent.click(screen.getByLabelText('Teleport to Memory House'));
- expect(sceneProps.current.playerTeleport.token).toBe(1);
-
- act(() => { fireEvent.keyDown(window, { key: 'm' }); });
- fireEvent.click(screen.getByLabelText('Teleport to Memory House'));
- // Same destination, new warp — a plain {x,z} identity check would have swallowed this.
- expect(sceneProps.current.playerTeleport.token).toBe(2);
- });
-
- it('does not bank an M keypress while photo mode owns the camera', () => {
- // The panel is hidden in photo/playback mode; a live binding there would spring it
- // open the moment the user returned to the live view.
- renderAt('/openworld');
- fireEvent.click(screen.getByText('hud-photo'));
- act(() => { fireEvent.keyDown(window, { key: 'm' }); });
- expect(screen.queryByLabelText('Search village places')).not.toBeInTheDocument();
-
- fireEvent.keyDown(window, { key: 'Escape' }); // leave photo mode
- act(() => {});
- expect(screen.queryByLabelText('Search village places')).not.toBeInTheDocument();
- });
-
- it('tells the HUD which region is active', () => {
- renderAt('/openworld/region/data-harbor');
- expect(screen.getByTestId('hud-region')).toHaveTextContent('data-harbor');
- });
-
- it('keeps map interactions inside OpenWorld', () => {
- renderAt('/openworld/region/memory');
- act(() => { fireEvent.keyDown(window, { key: 'm' }); });
- expect(screen.queryByTitle(/Open\s+\//)).not.toBeInTheDocument();
- expect(screen.getByTestId('path')).toHaveTextContent('/openworld/region/memory');
- });
-
- it('returns to the overview from the panel', () => {
- renderAt('/openworld/region/memory');
- act(() => { fireEvent.keyDown(window, { key: 'm' }); });
- fireEvent.click(screen.getByText('OVERVIEW'));
- expect(screen.getByTestId('path')).toHaveTextContent('/openworld');
- expect(sceneProps.current.focusedRegion).toBeNull();
- });
-});
diff --git a/client/src/pages/OpenWorld.jsx b/client/src/pages/OpenWorld.jsx
deleted file mode 100644
index 5c11f92069..0000000000
--- a/client/src/pages/OpenWorld.jsx
+++ /dev/null
@@ -1,719 +0,0 @@
-import { useCallback, useState, useEffect, useMemo, useRef } from 'react';
-import { useNavigate, useLocation, useParams } from 'react-router';
-import { useOpenWorldData } from '../hooks/useOpenWorldData';
-import { useOpenWorldPlayback } from '../hooks/useOpenWorldPlayback';
-import useOpenWorldAudio from '../hooks/useOpenWorldAudio';
-import useKeyboardControls from '../hooks/useKeyboardControls';
-import useKeyboardShortcuts from '../hooks/useKeyboardShortcuts';
-import useKeyCapture from '../hooks/useKeyCapture';
-import { useAutoRefetch } from '../hooks/useAutoRefetch';
-import { mergeFrameIntoOpenWorldProps } from '../lib/openWorldPlaybackFrame';
-import * as api from '../services/api';
-import OpenWorldScene from '../components/openworld/OpenWorldScene';
-import OpenWorldHud from '../components/openworld/OpenWorldHud';
-import OpenWorldMobileControls from '../components/openworld/OpenWorldMobileControls';
-import OpenWorldPhotoOverlay from '../components/openworld/OpenWorldPhotoOverlay';
-import OpenWorldPlaybackOverlay from '../components/openworld/OpenWorldPlaybackOverlay';
-import { OpenWorldSettingsProvider, useOpenWorldSettingsContext } from '../components/openworld/OpenWorldSettingsContext';
-import OpenWorldSettingsDrawer from '../components/openworld/OpenWorldSettingsDrawer';
-import { computeFilterResult } from '../utils/openWorldFilter';
-import { resolveOpenWorldFocus } from '../utils/openWorldFocusState';
-import useOpenWorldViewport from '../hooks/useOpenWorldViewport';
-import { DEFAULT_PRESET_ID, cyclePreset } from '../utils/openWorldPhotoMode';
-import { computeSoundscape } from '../utils/openWorldSoundscape';
-import { deriveOpenWorldPalette, getTimeOfDayPreset, resolveOpenWorldTimeOfDay, resolveWorldStyle } from '../components/openworld/openWorldConstants';
-import OpenWorldFastTravel from '../components/openworld/OpenWorldFastTravel';
-import { getRegion, regionArrivalPoint, regionPath } from '../utils/openWorldRegions';
-import { getCollectiblesList, loadCollectedShardIds, saveCollectedShardIds } from '../utils/openWorldCollectibles';
-import { OpenWorldPaletteProvider } from '../components/openworld/OpenWorldPaletteContext';
-import { useThemeContext } from '../components/ThemeContext';
-import { useInstanceFeatures } from '../hooks/useInstanceFeatures';
-import { recommendOpenWorldStartTier } from '../utils/openWorldRenderBudget';
-
-// Internal render budgets only. These tiers are selected from sustained frame time and are
-// deliberately not persisted or exposed as player settings; art direction stays coherent while
-// the renderer sheds work on slower hardware.
-const RENDER_TIERS = {
- low: { particleDensity: 0.5, dpr: [1, 1] },
- medium: { particleDensity: 0.75, dpr: [1, 1.25] },
- high: { particleDensity: 1, dpr: [1, 1.25] },
- ultra: { particleDensity: 1.5, dpr: [1, 1.5] },
-};
-
-function OpenWorldInner() {
- const { apps, cosAgents, cosStatus, eventLogs, agentMap, reviewCounts, instances, systemHealth, notificationCounts, backupStatus, cosTasks, healthMetrics, voiceState, character, aiActivity, loading, connected } = useOpenWorldData();
- const { settings, updateSetting, resetNonce } = useOpenWorldSettingsContext();
-
- // Ambient soundscape (roadmap 3.4): the music's mood follows system health and its energy
- // follows live agent activity. Derived from data the page already has — no extra fetch.
- const activeAgentCount = useMemo(
- () => (cosAgents || []).filter(a => a.status === 'running' || a.state === 'coding' || a.state === 'thinking' || a.state === 'investigating').length,
- [cosAgents]
- );
- const soundscape = useMemo(
- () => computeSoundscape({ systemHealth, agentCount: activeAgentCount }),
- [systemHealth, activeAgentCount]
- );
- const { playSfx } = useOpenWorldAudio(settings, soundscape);
- const navigate = useNavigate();
- const location = useLocation();
- const { appId, regionId } = useParams();
- const { isDesktop } = useOpenWorldViewport();
- const {
- features: instanceFeatures,
- error: instanceFeaturesError,
- isFeatureEnabled,
- } = useInstanceFeatures();
- // OpenWorld browse surfaces use the hook's fail-open gate so a settings
- // hiccup never blanks navigation. Passive JIRA data is different: do not
- // poll or render tickets while participation is loading or unreadable.
- const isPassiveFeatureEnabled = useCallback((featureId) => (
- !featureId
- || (!instanceFeaturesError
- && Array.isArray(instanceFeatures)
- && instanceFeatures.some((feature) => feature?.id === featureId && feature.enabled === true))
- ), [instanceFeatures, instanceFeaturesError]);
- const visibleCollectibles = useMemo(
- () => getCollectiblesList(isPassiveFeatureEnabled),
- [isPassiveFeatureEnabled],
- );
-
- // URL-addressed building focus (issue #2593). The `/openworld/apps/:appId` route param is the
- // single source of truth for "which borough is focused" — reload/back-forward/deep-link all
- // restore it.
- const { hasFocus, focusedApp, notFound: focusNotFound } = useMemo(
- () => resolveOpenWorldFocus(appId, apps, { loading }),
- [appId, apps, loading],
- );
- // Fast travel: `/openworld/region/:regionId` is the same contract one level out — the URL says
- // which region you warped to, so a warp is shareable, bookmarkable, and reachable from ⌘K and
- // voice. The registry is static (no loading race), and an unknown id resolves to null, which
- // simply leaves the camera on the overview.
- const focusedRegion = useMemo(() => getRegion(regionId), [regionId]);
- const focusAgents = useMemo(() => agentMap?.get?.(appId)?.agents || [], [agentMap, appId]);
- // HUD safe area the focus camera frames around: the detail panel sits on the right (desktop) or
- // as a bottom sheet (compact), so keep the borough clear of it.
- const focusHudSafe = useMemo(
- () => (isDesktop ? { right: 0.28, bottom: 0 } : { right: 0, bottom: 0.5 }),
- [isDesktop],
- );
-
- // OpenWorld follows the active PortOS theme: the HUD recolors via the
- // `openworld-themed` CSS scope (see index.css) and the 3D scene's brand colors
- // + surround are derived from the same theme here.
- const { theme: openWorldTheme } = useThemeContext();
- // Art direction — 'vibes' (bright low-poly open world, the default) or 'cyber' (the
- // original neon night). It selects the time-of-day preset pair and the palette's
- // structural/decorative surfaces, so it must resolve before either.
- const worldStyle = resolveWorldStyle(settings?.worldStyle);
- // Pure: derive the themed palette and hand it down through OpenWorldPaletteContext (and,
- // inside , a second provider in OpenWorldScene since r3f's reconciler doesn't
- // bridge context). No more during-render mutation of a shared singleton.
- const openWorldPalette = useMemo(() => deriveOpenWorldPalette(openWorldTheme, worldStyle), [openWorldTheme, worldStyle]);
-
- // Open World renders day or night, following the theme mode by default; Cyber City is
- // always night (see resolveOpenWorldTimeOfDay). The resolved preset key is handed to the scene via a
- // settings override (OpenWorldSky/OpenWorldLights/OpenWorldGround read settings.timeOfDay), and the
- // backdrop takes the matching preset's mid-sky band so the DOM surround behind the
- // canvas agrees with the sky the scene actually paints, in either art direction.
- const openWorldTimeOfDay = resolveOpenWorldTimeOfDay(settings?.timeOfDay, openWorldPalette.isDay, worldStyle);
- const sceneBackground = openWorldTimeOfDay.daytime
- ? getTimeOfDayPreset(openWorldTimeOfDay.presetKey).midSky
- : openWorldPalette.nightBackground;
-
- // Rendering adapts silently. The player gets a stable art direction and the renderer
- // chooses its detail tier from sustained frame time; low/medium/high/ultra are internal
- // budgets, not design settings. This keeps the settings drawer focused on choices a player
- // can actually feel (world style, audio, and controls).
- const [renderStartTier] = useState(() => recommendOpenWorldStartTier({
- coarsePointer: typeof window !== 'undefined'
- && Boolean(window.matchMedia?.('(pointer: coarse)').matches),
- hardwareConcurrency: typeof navigator !== 'undefined' ? navigator.hardwareConcurrency : null,
- deviceMemory: typeof navigator !== 'undefined' ? navigator.deviceMemory : null,
- }));
- const [autoTier, setAutoTier] = useState(renderStartTier);
- const effectiveTier = autoTier;
-
- const sceneSettings = useMemo(() => {
- // Derive render-affecting fields from the adaptive tier. The settings store contains
- // player choices only, so old renderer knobs cannot override the coherent world look.
- const tierCfg = RENDER_TIERS[effectiveTier] || RENDER_TIERS.high;
- return {
- ...settings,
- effectiveTier,
- skyTheme: 'cyberpunk',
- timeOfDay: openWorldTimeOfDay.presetKey,
- particleDensity: tierCfg.particleDensity,
- dpr: tierCfg.dpr,
- ambientBrightness: 1,
- neonBrightness: worldStyle === 'cyber' ? 1.1 : 1,
- };
- }, [settings, effectiveTier, openWorldTimeOfDay.presetKey, worldStyle]);
-
- const [filter, setFilter] = useState(() => {
- // try/catch is necessary because sessionStorage values are external state
- // a corrupted/older-schema entry would throw and crash the page render.
- try {
- const raw = sessionStorage.getItem('openworld.filter');
- if (raw) {
- const parsed = JSON.parse(raw);
- if (parsed && typeof parsed.status === 'string') {
- return {
- status: parsed.status,
- search: typeof parsed.search === 'string' ? parsed.search : '',
- };
- }
- }
- } catch {
- // fall through to default
- }
- return { status: 'all', search: '' };
- });
-
- useEffect(() => {
- // setItem can throw (Safari private mode, storage quota); ignore — this
- // is a UX nicety, not load-bearing state.
- try {
- sessionStorage.setItem('openworld.filter', JSON.stringify(filter));
- } catch {
- // intentionally swallow
- }
- }, [filter]);
-
- const filterResult = useMemo(
- () => computeFilterResult({ apps, agentMap, status: filter.status, search: filter.search }),
- [apps, agentMap, filter.status, filter.search]
- );
-
- const showSettings = location.pathname === '/openworld/settings';
-
- // Mode precedence (issue #2593): entering exploration/photo/history while a borough is focused
- // clears the focused route first, so the focus camera + detail panel stand down deterministically
- // before the new mode takes the camera. `hasFocus` reads the URL, the single source of truth.
- const clearFocusRoute = useCallback(() => {
- if (hasFocus) navigate('/openworld');
- }, [hasFocus, navigate]);
-
- const handleToggleExploration = useCallback(() => {
- clearFocusRoute();
- updateSetting('explorationMode', !settings?.explorationMode);
- }, [clearFocusRoute, updateSetting, settings?.explorationMode]);
-
- // V (in exploration mode) swaps the follow-camera character view and first person.
- const handleToggleCameraView = useCallback(() => {
- updateSetting('cameraView', (settings?.cameraView ?? 'third') === 'first' ? 'third' : 'first');
- }, [updateSetting, settings?.cameraView]);
-
- const keysRef = useKeyboardControls(handleToggleExploration);
- const mobileInputRef = useRef({
- moveX: 0,
- moveY: 0,
- lookDeltaX: 0,
- lookDeltaY: 0,
- boost: false,
- jump: false,
- });
- const playerActionRef = useRef(null);
- const [proximityTarget, setProximityTarget] = useState(null);
-
- // Cyber Shards collectible state + live player pose
- const [collectedShardIds, setCollectedShardIds] = useState(() => loadCollectedShardIds());
- const [activeBursts, setActiveBursts] = useState([]);
- const [playerPose, setPlayerPose] = useState(null);
- const visibleCollectedCount = useMemo(
- () => visibleCollectibles.filter((shard) => collectedShardIds.has(shard.id)).length,
- [visibleCollectibles, collectedShardIds],
- );
-
- const handleCollectShard = useCallback((shard) => {
- setCollectedShardIds((prev) => {
- const next = new Set(prev);
- next.add(shard.id);
- saveCollectedShardIds(next);
- return next;
- });
- const burstId = `${shard.id}-${Date.now()}`;
- setActiveBursts((prev) => [
- ...prev.slice(-4),
- { id: burstId, x: shard.x, y: shard.y, z: shard.z, color: shard.color, age: 0 },
- ]);
- setTimeout(() => {
- setActiveBursts((prev) => prev.filter((b) => b.id !== burstId));
- }, 850);
- }, []);
-
- // --- World map / fast travel ----------------------------------------------
- // Warping stays under `/openworld/region/:regionId`; the route param drives the orbital
- // camera and, on foot, the player rig. The destination is still shareable, but it never
- // sends the player to the 2D page represented by that district.
- const [fastTravelOpen, setFastTravelOpen] = useState(false);
- // The walking player's arrival point for the latest warp, carrying a monotonic token so
- // PlayerController can tell "warp again to the same place" from a re-render with equal
- // coordinates — which is why it isn't derived from the region id. Direct region deep links
- // seed the first arrival synchronously; later route changes arm the same handoff in an effect.
- // Set on every warp, not only while exploring: PlayerController mounts only in exploration mode
- // and applies the current token on mount, so arming it unconditionally also means warping in the
- // orbital overview and THEN dropping in (Tab) lands you at the region you were looking at,
- // instead of back at your old spawn.
- const [playerTeleport, setPlayerTeleport] = useState(() => {
- if (!focusedRegion) return null;
- const arrival = regionArrivalPoint(focusedRegion);
- return arrival ? { ...arrival, regionId: focusedRegion.id, token: 1 } : null;
- });
- const lastRoutedRegionIdRef = useRef(regionId);
-
- const armPlayerTeleport = useCallback((region, force = false) => {
- const arrival = regionArrivalPoint(region);
- if (!arrival) return;
- setPlayerTeleport(prev => {
- if (!force && prev?.regionId === region.id) return prev;
- return { ...arrival, regionId: region.id, token: (prev?.token ?? 0) + 1 };
- });
- }, []);
-
- useEffect(() => {
- if (!focusedRegion) {
- lastRoutedRegionIdRef.current = regionId;
- return;
- }
- if (lastRoutedRegionIdRef.current === regionId) return;
- lastRoutedRegionIdRef.current = regionId;
- armPlayerTeleport(focusedRegion);
- }, [armPlayerTeleport, focusedRegion, regionId]);
-
- const handleTravelToRegion = useCallback((region) => {
- if (!region?.id) return;
- navigate(regionPath(region.id));
- // A map pick is an explicit warp even when the destination matches the current route (or
- // stale route state), so always re-arm it; browser back/forward and direct deep links are
- // armed by the effect above.
- armPlayerTeleport(region, true);
- playSfx?.('dataPulse');
- }, [armPlayerTeleport, navigate, playSfx]);
-
- const openFastTravel = useCallback(() => setFastTravelOpen(true), []);
-
- // Photo mode (roadmap 3.3): a cinematic capture mode with framing presets and a postcard
- // screenshot. The in-canvas OpenWorldPhotoCamera registers its capture function here via a ref so
- // the overlay (outside the Canvas) can trigger a grab. Exiting photo mode clears the fn.
- const [photoMode, setPhotoMode] = useState(false);
- const [photoPresetId, setPhotoPresetId] = useState(DEFAULT_PRESET_ID);
- // Depth-of-field for cinematic shots (roadmap 3.3) — on by default since it's the point of the
- // mode; the user can toggle it off (D / overlay button) for a fully-sharp frame.
- const [photoDof, setPhotoDof] = useState(true);
- const captureFnRef = useRef(null);
- const handlePhotoCaptureReady = useCallback((fn) => { captureFnRef.current = fn; }, []);
-
- // Playback / "history" mode (roadmap 3.6): scrub recorded city-state snapshots.
- // Transport state lives in the hook; the page swaps the current frame's data
- // into the scene props below. Mutually exclusive with photo mode.
- const playback = useOpenWorldPlayback();
-
- // M opens the fast-travel map. Deliberately open-only, not a toggle: the panel is a
- // , which owns Esc/backdrop dismissal — and the shared shortcut hook suppresses
- // itself while any `aria-modal` dialog is up, so a toggle binding could never have
- // fired the close half anyway. The hook also drops ⌘/Ctrl/Alt chords, auto-repeat, and
- // keystrokes typed into a field, so the HUD filter and the panel's own search box keep
- // their letters. Inactive in photo and playback mode: those own the camera and hide the
- // panel, so a live binding there would only bank an "open" that springs the panel the
- // moment the user returns to the live view.
- useKeyboardShortcuts(!photoMode && !playback.active, { m: openFastTravel, M: openFastTravel });
-
- // Entering photo mode leaves exploration + playback; they're mutually exclusive modes.
- const enterPhotoMode = useCallback(() => {
- clearFocusRoute();
- updateSetting('explorationMode', false);
- playback.exit();
- setPhotoPresetId(DEFAULT_PRESET_ID);
- setPhotoMode(true);
- }, [clearFocusRoute, updateSetting, playback]);
- const exitPhotoMode = useCallback(() => setPhotoMode(false), []);
-
- // Entering playback leaves photo + exploration mode.
- const enterPlayback = useCallback(() => {
- clearFocusRoute();
- setPhotoMode(false);
- updateSetting('explorationMode', false);
- playback.enter();
- }, [clearFocusRoute, updateSetting, playback]);
-
- // Esc exits photo mode; ←/→ cycle the framing preset; D toggles depth-of-field. Bound only while
- // photo mode is on so it doesn't shadow other shortcuts. These are ordinary shortcuts, not keys
- // an app-global handler already owns, so they ride useKeyboardShortcuts rather than a capture-phase
- // claim — which also yields them to any dialog opened on top of photo mode.
- useKeyboardShortcuts(photoMode, {
- Escape: () => setPhotoMode(false),
- ArrowLeft: () => setPhotoPresetId(id => cyclePreset(id, -1)),
- ArrowRight: () => setPhotoPresetId(id => cyclePreset(id, 1)),
- d: () => setPhotoDof(v => !v),
- D: () => setPhotoDof(v => !v),
- });
-
- // Playback keyboard transport: Esc exits, Space play/pause, ←/→ step a frame.
- // Bound only while playback is active. Claimed in the capture phase so Space
- // toggles playback WITHOUT also tripping the voice widget's app-global
- // push-to-talk hotkey; keys we do not handle pass through untouched.
- useKeyCapture({
- enabled: playback.active,
- onKeyDown: (e) => {
- if (e.key === 'Escape') playback.exit();
- else if (e.key === ' ') playback.togglePlay();
- else if (e.key === 'ArrowLeft') playback.step(-1);
- else if (e.key === 'ArrowRight') playback.step(1);
- else return false;
- return true;
- },
- });
-
- // Task-complete chime (roadmap 3.4): when a CoS task transitions to completed, play a reward
- // chime. Track the set of completed ids across socket updates and chime on each newly-seen one.
- // Seeded on first populated render (completedSeenRef === null) so a fresh page load doesn't
- // chime for every already-completed task in the backlog.
- const completedSeenRef = useRef(null);
- useEffect(() => {
- const completedIds = (cosTasks || []).filter(t => t?.status === 'completed').map(t => t.id);
- if (completedSeenRef.current === null) {
- completedSeenRef.current = new Set(completedIds);
- return;
- }
- let fired = false;
- for (const id of completedIds) {
- if (!completedSeenRef.current.has(id)) {
- completedSeenRef.current.add(id);
- if (!fired) { playSfx('taskComplete'); fired = true; } // one chime per batch, not per task
- }
- }
- }, [cosTasks, playSfx]);
-
- // Productivity data for HUD vitals and billboards. Let errors throw —
- // `useAutoRefetch` preserves the last-good snapshot on transient failures.
- const { data: productivityData } = useAutoRefetch(
- () => api.getCosQuickSummary({ silent: true }),
- 60_000,
- );
-
- // Activity calendar drives the productivity district's heatmap ground tiles and feeds the
- // task-flow river's throughput signal. Low-frequency: the daily contribution grid changes
- // slowly. Same last-good-snapshot semantics as productivityData.
- const { data: activityCalendar } = useAutoRefetch(
- () => api.getCosActivityCalendar(12, { silent: true }),
- 120_000,
- );
-
- // Life goals drive the goal-monument district. Same pattern as productivityData —
- // `useAutoRefetch` keeps the last-good snapshot on transient failures.
- const { data: goalsData } = useAutoRefetch(
- () => api.getGoals({ silent: true }),
- 120_000,
- );
-
- // Chronotype profile drives the ambient energy overlay — the city brightens during
- // peak focus hours and dims during recovery. Low-frequency: the daily schedule
- // rarely changes. Same last-good-snapshot semantics as the fetches above.
- const { data: chronotypeData } = useAutoRefetch(
- () => api.getChronotype({ silent: true }),
- 600_000,
- );
-
- // Long-term memory graph drives the knowledge district (crystal clusters + light bridges).
- // The graph changes slowly (new memories trickle in), so a 2-minute poll is plenty. Same
- // last-good-snapshot semantics as the fetches above.
- const { data: memoryGraph } = useAutoRefetch(
- () => api.getMemoryGraph({ silent: true }),
- 120_000,
- );
-
- // Brain-inbox backlog feeds the memory district's glowing well — `needs_review` is the count
- // of captures waiting for the user to sort. Lightweight; the well pulses harder as it grows.
- const { data: inboxData } = useAutoRefetch(
- () => api.getBrainInbox({ status: 'needs_review', limit: 1, silent: true }),
- 60_000,
- );
-
- // Storage introspection drives the Data Harbor district (DB table silos + data/ domain
- // racks on the waterfront). Server-side cache does the heavy lifting; a 2-minute poll
- // keeps the harbor current. `compare` strips the always-changing `ts` so a byte-identical
- // payload keeps its identity and the harbor subtree skips reconciliation.
- const { data: introspection } = useAutoRefetch(
- () => api.getOpenWorldIntrospection({ silent: true }),
- 120_000,
- {
- compare: (prev, next) =>
- JSON.stringify({ ...prev, ts: null }) === JSON.stringify({ ...next, ts: null }),
- },
- );
-
- // JIRA sprint district: the set of apps with JIRA wired up (each carries instanceId+projectKey),
- // collapsed to a stable signature that gates and re-triggers the poll only when that set changes.
- const jiraAppsKey = useMemo(
- () => (apps || [])
- .filter(a => a?.jira?.enabled && a.jira.instanceId && a.jira.projectKey)
- .map(a => `${a.jira.instanceId}/${a.jira.projectKey}`)
- .sort().join(','),
- [apps]
- );
- const jiraFeatureEnabled = isPassiveFeatureEnabled('jira');
- // Fetch each enabled app's current-sprint tickets and merge; the helper dedupes by key. Skip
- // the poll entirely when no app has JIRA configured. Keyed on `jiraAppsKey` so the closure (and
- // poll) refresh when JIRA apps appear/disappear.
- const fetchSprintTickets = useCallback(async () => {
- if (!jiraFeatureEnabled) return [];
- const specs = (apps || [])
- .filter(a => a?.jira?.enabled && a.jira.instanceId && a.jira.projectKey)
- .map(a => ({ instanceId: a.jira.instanceId, projectKey: a.jira.projectKey }));
- if (specs.length === 0) return [];
- const batches = await Promise.all(
- specs.map(j => api.getMySprintTickets(j.instanceId, j.projectKey, { silent: true }).catch(() => []))
- );
- return batches.flat();
- }, [apps, jiraFeatureEnabled]);
- const { data: jiraTickets } = useAutoRefetch(
- fetchSprintTickets,
- 120_000,
- { enabled: jiraFeatureEnabled && jiraAppsKey.length > 0 },
- );
-
- // Selecting a building focuses it in-place (issue #2593) — the URL becomes /openworld/apps/:id and the
- // camera/HUD stay inside OpenWorld. This is also the interaction target for the first-person player:
- // walking up to a building and pressing F opens its live status panel, never the 2D app page.
- const handleBuildingClick = useCallback((app) => {
- if (!app?.id) return;
- navigate(`/openworld/apps/${app.id}`);
- }, [navigate]);
-
- const handleJumpToFirst = useCallback(() => {
- const first = filterResult.matches[0];
- if (!first?.id) return;
- handleBuildingClick(first);
- }, [filterResult.matches, handleBuildingClick]);
-
- const handleTravelToRegionId = useCallback((id) => {
- const region = getRegion(id);
- if (region) handleTravelToRegion(region);
- }, [handleTravelToRegion]);
-
- // Every HUD attention item resolves to a building/region in the world. There is no external
- // page fallback: an item without a more specific destination opens the world map instead.
- const handleAttentionItem = useCallback((item) => {
- if (item?.appId) {
- handleBuildingClick({ id: item.appId });
- return;
- }
- if (item?.regionId) {
- handleTravelToRegionId(item.regionId);
- return;
- }
- openFastTravel();
- }, [handleBuildingClick, handleTravelToRegionId, openFastTravel]);
-
- // Close focus → back to the plain in-world overview. The panel's primary action also
- // re-focuses the building in OpenWorld rather than opening a separate PortOS page.
- const handleCloseFocus = useCallback(() => navigate('/openworld'), [navigate]);
- const handleFocusInWorld = useCallback((id) => {
- if (id) handleBuildingClick({ id });
- }, [handleBuildingClick]);
-
- // Headline numbers baked onto a captured city postcard. Derived from data the page already
- // has — no extra fetch. buildPostcardStats (in the overlay) omits absent/zero fields.
- const photoStats = useMemo(() => {
- const active = (apps || []).filter(a => !a.archived);
- return {
- online: active.filter(a => a.overallStatus === 'online').length,
- total: active.length,
- agents: (cosAgents || []).filter(a => a.status === 'running' || a.state === 'coding' || a.state === 'thinking').length,
- peers: (instances?.peers || []).filter(p => p.status === 'online').length,
- level: character?.level,
- };
- }, [apps, cosAgents, instances, character]);
-
- // In playback mode, overlay the current snapshot frame's data onto the props
- // the scene consumes. mergeFrameIntoOpenWorldProps returns ONLY the props the frame
- // can faithfully drive (apps, agentMap, cosStatus, backupStatus, character),
- // so anything it omits (the count-only and rich-array landmarks: task queue,
- // federation, health tower, memory, goals, jira, activity, productivity) keeps
- // its live value — the "freeze unfed landmarks at live" behavior; their
- // captured numbers show in the playback overlay instead. Returns null for an
- // unplayable frame → keep live.
- const playbackProps = useMemo(() => {
- if (!playback.active || !playback.currentFrame) return null;
- return mergeFrameIntoOpenWorldProps(playback.currentFrame, { apps, agentMap });
- }, [playback.active, playback.currentFrame, apps, agentMap]);
-
- const v = useCallback((key, live) => (playbackProps && key in playbackProps ? playbackProps[key] : live), [playbackProps]);
-
- // Keep the scene and app shell mounted while the initial data bundle arrives. The route's
- // Suspense boundary already covers the lazy OpenWorld chunk; replacing the entire page here
- // created a second full-screen loader after the first one, then remounted WebGL once the API
- // calls settled. Empty/default props are safe for every landmark, and OpenWorldScene's own
- // warm-up keeps the first data-driven layout cheap while the real values stream in.
-
- return (
-
-
-
- {/* The full HUD hides in photo + playback mode so the view is clean; each
- mode's overlay replaces it. */}
- {!photoMode && !playback.active && (
-
- )}
- {!photoMode && !playback.active && !isDesktop && settings?.explorationMode && !showSettings && !fastTravelOpen && (
-
- )}
- setPhotoDof(v => !v)}
- />
-
- {/* World map (M). Hidden in photo + playback mode, which own the camera and would
- fight a warp. Mounted OUTSIDE the HUD's pointer-events-none shell so it can take
- clicks, and above the CRT overlay so its panel isn't scanlined. */}
- setFastTravelOpen(false)}
- onTravel={handleTravelToRegion}
- activeRegionId={focusedRegion?.id || null}
- onLeaveRegion={() => navigate('/openworld')}
- isFeatureEnabled={isFeatureEnabled}
- />
- {/* Settings on the shared Drawer. Closing preserves other query params (e.g. an open
- openWorldPane) so the disclosure state survives. Rendering quality is automatic and
- intentionally absent from this player-facing surface. */}
- navigate(`/openworld${location.search}`)}
- />
-
-
- );
-}
-
-export default function OpenWorld() {
- return (
-
-
-
- );
-}
diff --git a/client/src/pages/OpenWorld.transport.test.jsx b/client/src/pages/OpenWorld.transport.test.jsx
deleted file mode 100644
index e6cd755bd8..0000000000
--- a/client/src/pages/OpenWorld.transport.test.jsx
+++ /dev/null
@@ -1,190 +0,0 @@
-import { describe, it, expect, beforeEach, vi } from 'vitest';
-import { render, screen, fireEvent, act } from '@testing-library/react';
-import { MemoryRouter, Routes, Route } from 'react-router';
-import { installVoiceHotkeySpy } from '../test/voiceHotkeySpy';
-
-// Same stubbing strategy as OpenWorld.fastTravel.test.jsx: the 3D scene and the HUD are
-// replaced with inert divs so the page's keyboard WIRING can be exercised in jsdom.
-vi.mock('../components/openworld/OpenWorldScene', () => ({ default: () =>
}));
-vi.mock('../components/openworld/OpenWorldHud', () => ({
- default: ({ onEnterPhotoMode }) => (
- hud-photo
- ),
-}));
-// Photo mode has no other observable surface once the overlay is stubbed, so the stub
-// records the props the page drives from its keyboard shortcuts.
-vi.mock('../components/openworld/OpenWorldPhotoOverlay', () => ({
- default: (props) => { photoProps.current = props; return null; },
-}));
-vi.mock('../components/openworld/OpenWorldPlaybackOverlay', () => ({ default: () => null }));
-vi.mock('../components/openworld/OpenWorldSettingsDrawer', () => ({ default: () => null }));
-
-vi.mock('../hooks/useOpenWorldData', async () => {
- const { OPEN_WORLD_DATA } = await import('../test/openWorldPageMocks.js');
- return { useOpenWorldData: () => OPEN_WORLD_DATA };
-});
-
-// The playback hook is the surface under test: the page binds its transport keys only
-// while `active`, and the spies below record what each key reached.
-const photoProps = vi.hoisted(() => ({ current: null }));
-const playback = vi.hoisted(() => ({
- active: true, currentFrame: null, snapshots: [], frameIndex: 0, stats: null,
- playing: false, speed: 1, loading: false, error: null,
- enter: vi.fn(), exit: vi.fn(), seek: vi.fn(), step: vi.fn(),
- togglePlay: vi.fn(), cycleSpeed: vi.fn(),
-}));
-vi.mock('../hooks/useOpenWorldPlayback', () => ({ useOpenWorldPlayback: () => playback }));
-vi.mock('../hooks/useOpenWorldAudio', () => ({ default: () => ({ playSfx: vi.fn(), isAudioReady: false }) }));
-vi.mock('../hooks/useAutoRefetch', () => ({ useAutoRefetch: () => ({ data: null }) }));
-vi.mock('../hooks/useInstanceFeatures', () => ({
- useInstanceFeatures: () => ({
- features: [],
- error: null,
- isFeatureEnabled: () => true,
- reload: () => Promise.resolve(),
- }),
-}));
-// Only the endpoints this page polls, from the shared page fixture. The vi.mock
-// CALL has to stay here (vitest hoists it), so the factory dynamic-imports it.
-vi.mock('../services/api', async () => (await import('../test/openWorldPageMocks.js')).openWorldApiMock(vi));
-
-const OpenWorld = (await import('./OpenWorld')).default;
-
-const renderPage = () => render(
-
- } />
-
-);
-
-describe('OpenWorld playback transport keys', () => {
- const voiceHotkey = installVoiceHotkeySpy();
-
- beforeEach(() => {
- playback.active = true;
- playback.exit.mockClear();
- playback.step.mockClear();
- playback.togglePlay.mockClear();
- localStorage.clear();
- });
-
- it('toggles play on Space without leaking the key to the global voice hotkey', () => {
- renderPage();
-
- act(() => { fireEvent.keyDown(document.body, { key: ' ', code: 'Space' }); });
-
- expect(playback.togglePlay).toHaveBeenCalledTimes(1);
- expect(voiceHotkey()).not.toHaveBeenCalled();
- });
-
- it('claims Escape and the arrow keys too', () => {
- renderPage();
-
- act(() => { fireEvent.keyDown(document.body, { key: 'ArrowLeft' }); });
- act(() => { fireEvent.keyDown(document.body, { key: 'ArrowRight' }); });
- act(() => { fireEvent.keyDown(document.body, { key: 'Escape' }); });
-
- expect(playback.step.mock.calls).toEqual([[-1], [1]]);
- expect(playback.exit).toHaveBeenCalledTimes(1);
- expect(voiceHotkey()).not.toHaveBeenCalled();
- });
-
- it('lets unhandled keys through to app-global listeners', () => {
- renderPage();
-
- act(() => { fireEvent.keyDown(document.body, { key: 'j' }); });
-
- expect(voiceHotkey()).toHaveBeenCalledTimes(1);
- expect(playback.togglePlay).not.toHaveBeenCalled();
- });
-
- it('ignores Space typed into a text field, leaving the transport alone', () => {
- const { container } = renderPage();
- const input = document.createElement('input');
- container.appendChild(input);
- input.focus();
-
- act(() => { fireEvent.keyDown(input, { key: ' ', code: 'Space' }); });
-
- expect(playback.togglePlay).not.toHaveBeenCalled();
- });
-
- it('yields the transport keys to an open dialog layer', () => {
- // The settings drawer renders aria-modal and closes on its own Escape handler; the
- // transport must not swallow that keystroke out from under it (useKeyCapture's
- // enabledInDialog default).
- const { container } = renderPage();
- const drawer = document.createElement('div');
- drawer.setAttribute('aria-modal', 'true');
- container.appendChild(drawer);
-
- act(() => { fireEvent.keyDown(document.body, { key: 'Escape' }); });
- act(() => { fireEvent.keyDown(document.body, { key: ' ', code: 'Space' }); });
-
- expect(playback.exit).not.toHaveBeenCalled();
- expect(playback.togglePlay).not.toHaveBeenCalled();
- expect(voiceHotkey()).toHaveBeenCalledTimes(2);
- });
-
- it('binds nothing while playback is inactive', () => {
- playback.active = false;
- renderPage();
-
- act(() => { fireEvent.keyDown(document.body, { key: ' ', code: 'Space' }); });
-
- expect(playback.togglePlay).not.toHaveBeenCalled();
- expect(voiceHotkey()).toHaveBeenCalledTimes(1);
- });
-});
-
-describe('OpenWorld photo mode shortcuts', () => {
- const voiceHotkey = installVoiceHotkeySpy();
-
- beforeEach(() => {
- playback.active = false;
- photoProps.current = null;
- localStorage.clear();
- });
-
- const enterPhotoMode = () => {
- const rendered = renderPage();
- fireEvent.click(screen.getByText('hud-photo'));
- return rendered;
- };
-
- it('cycles the framing preset and toggles depth of field', () => {
- enterPhotoMode();
- expect(photoProps.current.active).toBe(true);
- const first = photoProps.current.presetId;
- expect(photoProps.current.dofEnabled).toBe(true);
-
- act(() => { fireEvent.keyDown(document.body, { key: 'ArrowRight' }); });
- expect(photoProps.current.presetId).not.toBe(first);
-
- act(() => { fireEvent.keyDown(document.body, { key: 'ArrowLeft' }); });
- expect(photoProps.current.presetId).toBe(first);
-
- act(() => { fireEvent.keyDown(document.body, { key: 'd' }); });
- expect(photoProps.current.dofEnabled).toBe(false);
- });
-
- it('exits on Escape', () => {
- enterPhotoMode();
-
- act(() => { fireEvent.keyDown(document.body, { key: 'Escape' }); });
-
- expect(photoProps.current.active).toBe(false);
- });
-
- it('leaves its keys for an open dialog, and does not claim them from the app', () => {
- const { container } = enterPhotoMode();
- const dialog = document.createElement('div');
- dialog.setAttribute('aria-modal', 'true');
- container.appendChild(dialog);
-
- act(() => { fireEvent.keyDown(document.body, { key: 'Escape' }); });
-
- expect(photoProps.current.active).toBe(true);
- // Bubble-phase shortcuts, not a capture-phase claim: the app still sees the key.
- expect(voiceHotkey()).toHaveBeenCalledTimes(1);
- });
-});
diff --git a/client/src/pages/PipelineExport.jsx b/client/src/pages/PipelineExport.jsx
index ccf7f5e5e0..5c7699eda6 100644
--- a/client/src/pages/PipelineExport.jsx
+++ b/client/src/pages/PipelineExport.jsx
@@ -21,8 +21,13 @@ import {
proseExportManuscriptUrl,
proseExportEpubUrl,
proseExportPdfUrl,
+ listMediaCollections,
+ listImageGallery,
+ listVideoHistory,
} from '../services/api';
import { useAsyncAction } from '../hooks/useAsyncAction';
+import { normalizeImage, normalizeVideo } from '../components/media/normalize';
+import AttributionList from '../components/media/AttributionList';
// Kept in sync with server/lib/proseExportSettings.js (allow-lists + defaults).
const TRIM_SIZE_OPTIONS = [
@@ -73,6 +78,7 @@ export default function PipelineExport() {
const [loading, setLoading] = useState(true);
const [form, setForm] = useState(emptyForm);
const [savedForm, setSavedForm] = useState(emptyForm);
+ const [seriesAssets, setSeriesAssets] = useState([]);
useEffect(() => {
let canceled = false;
@@ -94,6 +100,34 @@ export default function PipelineExport() {
return () => { canceled = true; };
}, [seriesId, navigate]);
+ // Roll up licenses from the series media collection so a user about to
+ // publish can see the terms that applied when the pixels were made (#5638).
+ useEffect(() => {
+ let canceled = false;
+ Promise.all([
+ listMediaCollections({ silent: true }),
+ listImageGallery({ silent: true }),
+ listVideoHistory({ silent: true }),
+ ]).then(([collections, images, videos]) => {
+ if (canceled) return;
+ const list = Array.isArray(collections) ? collections : [];
+ const collection = list.find((c) => c.seriesId === seriesId || c.id === `sc-${seriesId}`);
+ if (!collection) { setSeriesAssets([]); return; }
+ const imagesByName = new Map((images || []).map((i) => [i.filename, i]));
+ const videosById = new Map((videos || []).map((v) => [v.id, v]));
+ const out = [];
+ for (const it of collection.items || []) {
+ if (it.kind === 'image' && imagesByName.has(it.ref)) {
+ out.push(normalizeImage(imagesByName.get(it.ref)));
+ } else if (it.kind === 'video' && videosById.has(it.ref)) {
+ out.push(normalizeVideo(videosById.get(it.ref)));
+ }
+ }
+ setSeriesAssets(out);
+ }).catch(() => { if (!canceled) setSeriesAssets([]); });
+ return () => { canceled = true; };
+ }, [seriesId]);
+
const dirty = useMemo(
() => JSON.stringify(form) !== JSON.stringify(savedForm),
[form, savedForm],
@@ -269,6 +303,11 @@ export default function PipelineExport() {
))}
+
+
);
}
diff --git a/client/src/pages/PipelineIssue.jsx b/client/src/pages/PipelineIssue.jsx
index a3f83feb58..d29c6cdaf2 100644
--- a/client/src/pages/PipelineIssue.jsx
+++ b/client/src/pages/PipelineIssue.jsx
@@ -331,7 +331,12 @@ export default function PipelineIssue() {
-
#{issue.number} — {issue.title}
+
+ #{issue.number} — {issue.title}
+
Series
-
- {series?.name || 'Manuscript'}
+
+
+ {series?.name || 'Manuscript'}
+
{/* View-mode toggle: Live (Grammarly) vs Review (annotated). */}
diff --git a/client/src/pages/PipelineSeries.jsx b/client/src/pages/PipelineSeries.jsx
index 249087b9fe..4809dbf284 100644
--- a/client/src/pages/PipelineSeries.jsx
+++ b/client/src/pages/PipelineSeries.jsx
@@ -231,8 +231,13 @@ export default function PipelineSeries() {
All Series
-
-
{series.name || 'Untitled series'}
+
+
+ {series.name || 'Untitled series'}
+
{series.writersRoomWorkId ? (
Preview
-
+
{preview}
@@ -1012,7 +1012,7 @@ export default function PromptManager() {
{jobSkillPreview && (
Effective Prompt Preview
-
+
{jobSkillPreview}
diff --git a/client/src/pages/QuotaBurn.jsx b/client/src/pages/QuotaBurn.jsx
index 25f98db20b..b8b1d6a103 100644
--- a/client/src/pages/QuotaBurn.jsx
+++ b/client/src/pages/QuotaBurn.jsx
@@ -35,7 +35,7 @@ export const SAVE_DEBOUNCE_MS = 500;
// How often to re-ask while a family's quota scrape is still running. A scrape
// is a 10-20s PTY spawn, so this is a handful of polls, not a busy loop.
-const PENDING_POLL_MS = 4000;
+export const PENDING_POLL_MS = 4000;
const EMPTY_CATALOG = { jobTypes: [], apps: [], universes: [], imageModes: [], providers: [] };
diff --git a/client/src/pages/QuotaBurn.test.jsx b/client/src/pages/QuotaBurn.test.jsx
index 961f3e10dd..b43c59aed0 100644
--- a/client/src/pages/QuotaBurn.test.jsx
+++ b/client/src/pages/QuotaBurn.test.jsx
@@ -2,8 +2,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter, Route, Routes } from 'react-router';
-import QuotaBurn, { SAVE_DEBOUNCE_MS } from './QuotaBurn';
-import { sleep } from '../utils/sleep';
+import QuotaBurn, { PENDING_POLL_MS, SAVE_DEBOUNCE_MS } from './QuotaBurn';
vi.mock('../services/api', () => ({
getQuotaBurn: vi.fn(),
@@ -88,6 +87,10 @@ const renderPage = (path = '/devtools/quota-burn') => render(
const setupSaveUser = () => userEvent.setup({ advanceTimers: vi.advanceTimersByTime });
const flushSave = () => act(async () => { await vi.advanceTimersByTimeAsync(SAVE_DEBOUNCE_MS); });
+// Past the debounce/poll window rather than up to its edge, so a "did not
+// happen" assertion runs AFTER the moment the thing would have happened.
+const pastSaveWindow = () => act(async () => { await vi.advanceTimersByTimeAsync(SAVE_DEBOUNCE_MS + 100); });
+const pastPollWindow = () => act(async () => { await vi.advanceTimersByTimeAsync(PENDING_POLL_MS + 100); });
// Mirrors UNSAVED_PATCH_KEY in the page — the session-scoped stash holding a
// patch the server never accepted.
@@ -127,12 +130,14 @@ describe('QuotaBurn page', () => {
expect(await screen.findByText(/reading quota…/)).toBeInTheDocument();
expect(screen.getByLabelText(/Run the quota-burn loop automatically/)).toBeInTheDocument();
- await act(async () => {
- await vi.advanceTimersByTimeAsync(4000);
- });
+ await pastPollWindow();
expect(await screen.findByText(/62% left/)).toBeInTheDocument();
expect(screen.queryByText(/reading quota…/)).not.toBeInTheDocument();
+ // Positive control for 'does NOT poll when nothing is pending': the poll
+ // DOES fire inside this window, so that test's silence means the guard
+ // held rather than that the window was too short to observe anything.
+ expect(api.getQuotaBurn).toHaveBeenCalledTimes(2);
} finally {
vi.useRealTimers();
}
@@ -190,10 +195,14 @@ describe('QuotaBurn page', () => {
});
it('does NOT poll when nothing is pending', async () => {
+ // Past a full poll interval, not the 100ms of wall clock this used to
+ // wait: a re-arming timer first fires at PENDING_POLL_MS, so a shorter
+ // window passed whether or not `enabled: anyPending` was there at all.
+ vi.useFakeTimers({ shouldAdvanceTime: true });
renderPage();
await screen.findByText(/62% left/);
// One load on mount, and no timer re-arming behind it.
- await sleep(100);
+ await pastPollWindow();
expect(api.getQuotaBurn).toHaveBeenCalledTimes(1);
});
@@ -670,20 +679,26 @@ describe('QuotaBurn save races', () => {
it('ignores a stash that is not a patch object', async () => {
// A hand-edited or older-build entry must not be replayed — the PUT body is
// an object, and anything else 400s the save the restore should rescue.
+ // Past the save debounce: a replayed stash PUTs at SAVE_DEBOUNCE_MS, so
+ // the 100ms of wall clock this used to wait passed with the shape guard
+ // deleted. 'restores a stashed patch on the next visit' is the positive
+ // control that a well-formed stash DOES save inside this same window.
+ vi.useFakeTimers({ shouldAdvanceTime: true });
globalThis.sessionStorage.setItem(STASH_KEY, '"not-a-patch"');
renderPage();
expect(await screen.findByText(/62% left/)).toBeInTheDocument();
- await sleep(100);
+ await pastSaveWindow();
expect(api.saveQuotaBurn).not.toHaveBeenCalled();
});
it('ignores an empty stash rather than announcing a restore of nothing', async () => {
+ vi.useFakeTimers({ shouldAdvanceTime: true });
globalThis.sessionStorage.setItem(STASH_KEY, '{}');
renderPage();
expect(await screen.findByText(/62% left/)).toBeInTheDocument();
- await sleep(100);
+ await pastSaveWindow();
expect(api.saveQuotaBurn).not.toHaveBeenCalled();
});
diff --git a/client/src/pages/Review.jsx b/client/src/pages/Review.jsx
index a1b0a5250a..6286526f45 100644
--- a/client/src/pages/Review.jsx
+++ b/client/src/pages/Review.jsx
@@ -740,11 +740,11 @@ function ReviewItem({ item, config, idScope, isEditing, onComplete, onDismiss, o
rows={2}
className="w-full bg-port-bg border border-port-border rounded px-2 py-1 text-sm text-gray-300 focus:outline-none focus:border-port-accent resize-none"
/>
-
-
onSaveEdit(item.id, editTitle.trim(), editDescription.trim())} aria-label="Save" className="p-1 text-port-success hover:text-port-success/80" title="Save">
+
+ onSaveEdit(item.id, editTitle.trim(), editDescription.trim())} aria-label="Save" className="min-h-[44px] min-w-[44px] inline-flex items-center justify-center p-1 text-port-success hover:text-port-success/80" title="Save">
-
+
diff --git a/client/src/pages/RunnerPage.jsx b/client/src/pages/RunnerPage.jsx
index bc5bd89823..b91a77ddff 100644
--- a/client/src/pages/RunnerPage.jsx
+++ b/client/src/pages/RunnerPage.jsx
@@ -23,7 +23,7 @@ export function RunnerPage() {
const [providers, setProviders] = useState([]);
const [selectedProvider, setSelectedProvider] = useState('');
const [selectedModel, setSelectedModel] = useState('');
- const [timeout, setTimeout] = useState(30);
+ const [timeoutMinutes, setTimeoutMinutes] = useState(30);
const [allowedCommands, setAllowedCommands] = useState([]);
const [screenshots, setScreenshots] = useState([]);
const [continueContext, setContinueContext] = useState(null);
@@ -190,7 +190,7 @@ ${prompt.trim()}`;
// ran in the PortOS directory instead of rejecting the misconfigured app
// it was pointed at (#3180).
workspaceName: selectedApp?.name,
- timeout: timeout * 60 * 1000, // Convert minutes to milliseconds
+ timeout: timeoutMinutes * 60 * 1000, // Convert minutes to milliseconds
screenshots: screenshots.map(s => s.path) // Include screenshot paths
}).catch(err => ({ error: err.message }));
@@ -388,8 +388,8 @@ ${prompt.trim()}`;
setTimeout(Number(e.target.value))}
+ value={timeoutMinutes}
+ onChange={(e) => setTimeoutMinutes(Number(e.target.value))}
className="px-3 py-2 bg-port-bg border border-port-border rounded-lg text-white text-sm"
>
5 min
@@ -507,7 +507,7 @@ ${prompt.trim()}`;
className="bg-port-bg border border-port-border rounded-lg p-3 sm:p-4 h-64 sm:h-80 overflow-auto font-mono text-xs sm:text-sm"
>
{output ? (
- {output}
+ {output}
) : (
Output will appear here...
)}
diff --git a/client/src/pages/Settings.jsx b/client/src/pages/Settings.jsx
index ff5d86044f..cc5a7c1764 100644
--- a/client/src/pages/Settings.jsx
+++ b/client/src/pages/Settings.jsx
@@ -5,9 +5,9 @@ import { ApiAccessTab } from '../components/settings/ApiAccessTab';
import { AutofixerTab } from '../components/settings/AutofixerTab';
import AiAssignmentsTab from '../components/settings/AiAssignmentsTab';
import { BackupTab } from '../components/settings/BackupTab';
-import CodeReviewersTab from '../components/settings/CodeReviewersTab';
import { DatabaseTab } from '../components/settings/DatabaseTab';
import InstanceFeaturesTab from '../components/settings/InstanceFeaturesTab';
+import CredentialsTab from '../components/settings/CredentialsTab';
import { TelegramTab } from '../components/settings/TelegramTab';
import { GeneralTab } from '../components/settings/GeneralTab';
import { MortalLoomTab } from '../components/settings/MortalLoomTab';
@@ -43,8 +43,8 @@ export default function Settings() {
case 'api-access': return ;
case 'autofixer': return ;
case 'backup': return ;
- case 'code-reviewers': return ;
case 'database': return ;
+ case 'credentials': return ;
case 'features': return ;
case 'security': return ;
case 'sharing': return ;
diff --git a/client/src/pages/Settings.tabs.test.jsx b/client/src/pages/Settings.tabs.test.jsx
index c40663d2a6..c21fa8993d 100644
--- a/client/src/pages/Settings.tabs.test.jsx
+++ b/client/src/pages/Settings.tabs.test.jsx
@@ -9,18 +9,16 @@ vi.mock('../services/api', () => ({
getInstanceFeatures: vi.fn().mockResolvedValue({ features: [] }),
}));
-// The two tabs this test distinguishes between. A slug with no `case` in
-// Settings.jsx falls through to the GeneralTab default, so the guard is
-// "code-reviewers renders the Code Reviewers panel, not General".
-vi.mock('../components/settings/CodeReviewersTab', () => ({
- default: () =>
,
-}));
+// The remaining Settings tabs this test distinguishes between.
vi.mock('../components/settings/GeneralTab', () => ({
GeneralTab: () =>
,
}));
vi.mock('../components/settings/InstanceFeaturesTab', () => ({
default: () =>
,
}));
+vi.mock('../components/settings/CredentialsTab', () => ({
+ default: () =>
,
+}));
const Settings = (await import('./Settings')).default;
@@ -32,21 +30,11 @@ const renderTab = (path) => render(
,
);
-describe('Settings — Code Reviewers tab', () => {
- it('is listed in the settings sub-nav', () => {
- const tab = TABS.find(t => t.id === 'code-reviewers');
- expect(tab?.to).toBe('/settings/code-reviewers');
- });
-
- it('routes /settings/code-reviewers to the Code Reviewers panel', async () => {
- renderTab('/settings/code-reviewers');
- await act(async () => {});
- expect(screen.getByTestId('code-reviewers-tab')).toBeTruthy();
- expect(screen.queryByTestId('general-tab')).toBeNull();
+describe('Settings — Instance Features tab', () => {
+ it('does not list Code Reviewers after it moved to Models', () => {
+ expect(TABS.some(t => t.id === 'code-reviewers')).toBe(false);
});
-});
-describe('Settings — Instance Features tab', () => {
it('is listed in the settings sub-nav', () => {
const tab = TABS.find(t => t.id === 'features');
expect(tab?.to).toBe('/settings/features');
@@ -66,3 +54,17 @@ describe('Settings — MortalLoom tab', () => {
expect(tab).toMatchObject({ to: '/settings/mortalloom', feature: 'health' });
});
});
+
+describe('Settings — Credentials tab', () => {
+ it('is listed in the settings sub-nav', () => {
+ const tab = TABS.find(t => t.id === 'credentials');
+ expect(tab?.to).toBe('/settings/credentials');
+ });
+
+ it('routes /settings/credentials to the credential inventory', async () => {
+ renderTab('/settings/credentials');
+ await act(async () => {});
+ expect(screen.getByTestId('credentials-tab')).toBeTruthy();
+ expect(screen.queryByTestId('general-tab')).toBeNull();
+ });
+});
diff --git a/client/src/pages/Shell.jsx b/client/src/pages/Shell.jsx
index ed43ca7105..e85cb91e5b 100644
--- a/client/src/pages/Shell.jsx
+++ b/client/src/pages/Shell.jsx
@@ -277,7 +277,7 @@ export default function Shell() {
{folderDropdownOpen && (
-
+
{appFolders.map(({ name, path }) => (
{
+ setSearchParams((prev) => {
+ const params = new URLSearchParams(prev);
+ if (next) params.set('newSprite', '1');
+ else params.delete('newSprite');
+ return params;
+ });
+ }, [setSearchParams]);
// Open the trimmer deep-linked to a run. `replace` keeps an in-trimmer source
// switch out of history; the default push lets Back return to the Library.
const openTrimmer = useCallback((runId, { replace = false } = {}) => {
@@ -464,7 +477,11 @@ export default function Sprites() {
>
Animation types
-
{ refresh(); navigate(`/sprites/${record.id}`); }} />
+ { refresh(); navigate(`/sprites/${record.id}`); }}
+ />
{/* Re-import while a sprite is open must refresh the open detail too,
not just the library list. */}
{ refresh(); if (id) setRetryTick((t) => t + 1); }} />
@@ -482,9 +499,13 @@ export default function Sprites() {
gridColsClass="grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5"
/>
) : records.length === 0 ? (
-
- No sprites yet. Import a production set from a sprite-pipeline checkout to get started.
-
+ setNewSpriteOpen(true)}
+ />
) : (
) : models.length === 0 ? (
-
- No procedural models yet.
-
+ setPickerOpen(true)}
+ />
) : (
{models.map((model) => (
diff --git a/client/src/pages/ThreejsModels.test.jsx b/client/src/pages/ThreejsModels.test.jsx
index bfff5b9e7b..30ac978344 100644
--- a/client/src/pages/ThreejsModels.test.jsx
+++ b/client/src/pages/ThreejsModels.test.jsx
@@ -104,6 +104,20 @@ describe('ThreejsModels', () => {
));
});
+ it('offers a call to action that opens the image picker when no models exist', async () => {
+ render(
+
+
+
+ );
+ expect(await screen.findByText('No procedural models yet')).toBeInTheDocument();
+ const cta = screen.getByRole('button', { name: 'Choose a source image' });
+ fireEvent.click(cta);
+ // The picker only renders while open, so its appearance proves the action
+ // is wired — a conversion that dropped it would leave a dead-end block.
+ expect(await screen.findByRole('button', { name: 'Pick alternate beacon' })).toBeInTheDocument();
+ });
+
it('hides the family picker rather than showing an empty select when the fetch fails', async () => {
// Creation must still work — it simply gets the general-purpose prompt.
listThreejsModelFamilies.mockRejectedValue(new Error('offline'));
diff --git a/client/src/pages/UsagePage.jsx b/client/src/pages/UsagePage.jsx
index 18f99bd3b8..0870235de2 100644
--- a/client/src/pages/UsagePage.jsx
+++ b/client/src/pages/UsagePage.jsx
@@ -60,11 +60,11 @@ function UsageMeter({ limit }) {
style={{ width: `${Math.min(100, Math.max(0, used))}%` }}
/>
-
+
{used}% used
{limit.resetsAt && (
-
- resets {formatResetsAt(limit.resetsAt)}
+
+ resets {formatResetsAt(limit.resetsAt)}
)}
@@ -833,8 +833,10 @@ function InternalUsageMetrics() {
Informational estimate of what this usage would have cost under API billing (PortOS runs on subscriptions).
{' '}
Measured rows are the provider CLI’s own per-message counts, read from its local
transcript — full per-turn input, output, and prompt-cache reads/writes, each priced at its own rate.
- {' '}
Estimated rows are runs with no readable transcript (local models, or a provider
- that writes none): input is approximated from the initial prompt only and cache traffic is not counted, so those rows
+ {' '}Grok rows are measured the same way once a turn completes; a run killed mid-turn falls back to its chat history.
+ {' '}
Estimated rows are runs with no token counts to read: Antigravity writes a
+ session transcript but no token fields, so its rows are sized from that transcript’s text, and a run with no session
+ file at all (local models) is approximated from the initial prompt only with no cache traffic counted — those rows
understate real usage substantially.
Rates are as of {report?.pricingAsOf || 'the last update'} and exclude batch and long-context tiers.
{' '}Rows marked ~ use an approximated rate.
diff --git a/client/src/pages/UsagePage.test.jsx b/client/src/pages/UsagePage.test.jsx
index 635cd4d06b..ce40a1aa6f 100644
--- a/client/src/pages/UsagePage.test.jsx
+++ b/client/src/pages/UsagePage.test.jsx
@@ -244,6 +244,31 @@ describe('UsagePage provider reset times', () => {
expect(reset.className.split(/\s+/)).not.toContain('hidden');
expect(reset.textContent).not.toContain(resetsAt);
});
+
+ it('gives the Grok weekly reset its own full-width row on the cramped mobile card', async () => {
+ // Grok shares a mobile grid cell with Codex, so its card is the narrowest —
+ // the reset row must stack under "% used" (flex-col) rather than squeeze
+ // onto the same line (flex-row), which was clipping/overlapping it.
+ const resetsAt = new Date(Date.now() + 2 * 24 * 60 * 60 * 1000 + 60_000).toISOString();
+ api.getProviderUsage.mockResolvedValue({
+ providers: [{
+ family: 'grok',
+ label: 'Grok',
+ supported: true,
+ limits: [{ key: 'weekly', label: 'Weekly', percentUsed: 8, percentRemaining: 92, resetsAt }],
+ activity: [],
+ approximate: true,
+ fetchedAt: new Date().toISOString()
+ }]
+ });
+
+ render(
);
+
+ const reset = await screen.findByText(/resets .*\(in 2d\)/);
+ expect(reset.className.split(/\s+/)).not.toContain('hidden');
+ const row = reset.parentElement;
+ expect(row.className.split(/\s+/)).toEqual(expect.arrayContaining(['flex-col', 'sm:flex-row']));
+ });
});
// The provider-quota section reads every provider on mount, well after the page
diff --git a/client/src/pages/VideoGen.composeWhileBusy.test.jsx b/client/src/pages/VideoGen.composeWhileBusy.test.jsx
index bed308fad6..415c4aecfb 100644
--- a/client/src/pages/VideoGen.composeWhileBusy.test.jsx
+++ b/client/src/pages/VideoGen.composeWhileBusy.test.jsx
@@ -1,152 +1,28 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
-import { MemoryRouter } from 'react-router';
+import { fireEvent, screen, waitFor } from '@testing-library/react';
+
+import {
+ loadVideoGenPage,
+ renderVideoGenPage,
+ resetVideoGenMockState,
+ state,
+ videoGenModel,
+ videoGenStatus,
+ videoGenTermsGate,
+} from '../test/videoGenPageMocks.jsx';
const TERMS_ID = 'minimax-h3-license-v1';
-const MODEL = {
- id: 'h3-one',
- name: 'MiniMax h3-one',
- repo: 'example-org/h3-one',
- revision: '1111111111111111111111111111111111111111',
- runtime: 'minimax_h3',
- supportedModes: ['text'],
- defaultFrames: 124,
- frameOptions: [124, 141],
- fpsOptions: [24],
- steps: 8,
- guidance: 0,
- samplerLocked: true,
- supportsNegativePrompt: false,
- supportsTiling: false,
- supportsDisableAudio: false,
- termsGate: {
- id: TERMS_ID,
- title: 'Terms for h3-one',
- summary: 'This model is available only in its applicable territory.',
- acknowledgement: `I am eligible and accept ${TERMS_ID}.`,
- licenseUrl: 'https://example.com/license',
- },
-};
-
-const state = vi.hoisted(() => ({
- generateVideo: vi.fn(),
- attach: vi.fn(),
- eventSourceRef: { current: null },
-}));
-
-vi.mock('../services/api', () => ({
- // The page offers a federated render target (#4348); with no peer opted in
- // as a media provider the picker renders nothing and every local path below
- // is unchanged.
- getInstances: vi.fn(async () => ({ peers: [] })),
- getVideoGenStatus: vi.fn(async () => ({
- connected: true,
- pythonPath: '/opt/example/python3',
- defaultModel: 'h3-one',
- models: [MODEL],
- byovRuntimes: [],
- systemMemoryGb: 128,
- backendDisclosures: [],
- })),
- generateVideo: (...args) => state.generateVideo(...args),
- cancelVideoGen: vi.fn(async () => ({})),
- listVideoHistory: vi.fn(async () => []),
- deleteVideoHistoryItem: vi.fn(async () => ({})),
- setVideoHidden: vi.fn(async () => ({})),
- extractLastFrame: vi.fn(async () => ({})),
- upscaleVideo: vi.fn(async () => ({})),
- listImageGallery: vi.fn(async () => []),
- patchSettingsSlice: vi.fn(async () => ({})),
- getActiveVideoJob: vi.fn(async () => ({ activeJob: null })),
- getSettings: vi.fn(async () => ({ imageGen: { grok: { enabled: false } } })),
- getVideoGenRuntimeStatus: vi.fn(async () => ({ installed: true, ready: true, current: true })),
- listLorasFull: vi.fn(async () => []),
- getProviders: vi.fn(async () => ({ providers: [] })),
- getVisionModels: vi.fn(async () => ({ models: [] })),
-}));
-
-vi.mock('../hooks/useModelDownloadStatus', () => ({
- TEXT_ENCODER_DOWNLOAD_ID: '__text_encoder__',
- useModelDownloadStatus: () => ({
- extra: {},
- loading: false,
- statusError: null,
- activeModelId: null,
- progress: null,
- lastError: null,
- downloading: false,
- repairing: false,
- getStatus: () => ({ id: MODEL.id, repo: MODEL.repo, cached: true, sizeBytes: 100 }),
- start: vi.fn(),
- cancel: vi.fn(),
- repair: vi.fn(),
- refresh: vi.fn(),
- }),
-}));
-
-vi.mock('../hooks/useMediaJobSse', () => ({
- useMediaJobSse: () => ({ attach: state.attach, eventSourceRef: state.eventSourceRef }),
-}));
-vi.mock('../hooks/useMediaCompletionRefresh', () => ({ useMediaCompletionRefresh: vi.fn() }));
-vi.mock('../hooks/useMediaAnnotations', () => ({
- useMediaAnnotations: () => ({ annotations: {}, updateAnnotation: vi.fn(), getCardProps: vi.fn(() => ({})) }),
-}));
-vi.mock('../hooks/usePreviewRoute', () => ({ default: () => [null, vi.fn()] }));
-vi.mock('../components/ui/Toast', () => ({
- default: Object.assign(vi.fn(), { error: vi.fn(), success: vi.fn(), loading: vi.fn() }),
-}));
-
-vi.mock('../components/media/PromptEnhancer', () => ({
- default: ({ disabled }) => (
-
Enhance with AI
- ),
-}));
-vi.mock('../components/media/PromptFromMedia', () => ({
- default: ({ disabled }) => (
-
Prompt from media
- ),
-}));
-vi.mock('../components/media/UniverseStylePicker', () => ({
- default: ({ onChange }) => (
-
onChange({
- id: 'u-1', name: 'Example Universe',
- influences: { embrace: ['inky linework'], avoid: ['glossy'] },
- })}
- >
- Use universe style
-
- ),
-}));
-
-vi.mock('../components/Drawer', () => ({ default: () => null }));
-vi.mock('../components/settings/ImageGenTab', () => ({ ImageGenTab: () => null }));
-vi.mock('../components/settings/LocalSetupPanel', () => ({ default: () => null }));
-vi.mock('../components/install/RuntimeInstallModal', () => ({
- default: ({ streamMethod }) =>
,
-}));
-vi.mock('../components/videoGen/FramePanel', () => ({ default: () => null }));
-vi.mock('../components/videoGen/KeyframePanel', () => ({ default: () => null }));
-vi.mock('../components/videoGen/AudioPanel', () => ({ default: () => null }));
-vi.mock('../components/videoGen/ExtendPanel', () => ({ default: () => null }));
-vi.mock('../components/videoGen/IcLoraPanel', () => ({ default: () => null }));
-vi.mock('../components/videoGen/AdvancedParamsPanel', () => ({ default: () => null }));
-vi.mock('../components/videoGen/RuntimeFingerprint', () => ({ default: () => null }));
-vi.mock('../components/videoGen/VideoGenGallery', () => ({ default: () => null }));
-vi.mock('../components/media/MediaPreview', () => ({ default: () => null }));
-vi.mock('../components/media/StylePresetPicker', () => ({ default: () => null }));
-vi.mock('../components/media/MediaJobsQueue', () => ({ default: () => null }));
-vi.mock('../components/imageGen/LoraPicker', () => ({ default: () => null }));
-vi.mock('../components/media/ResolutionField', () => ({ default: () => null }));
-
-const { default: VideoGen } = await import('./VideoGen.jsx');
+const MODEL = videoGenModel('h3-one', { termsGate: videoGenTermsGate(TERMS_ID) });
+
+await loadVideoGenPage();
describe('VideoGen compose-while-busy', () => {
beforeEach(() => {
- state.generateVideo.mockReset().mockReturnValue(new Promise(() => {}));
- state.attach.mockReset().mockReturnValue(new Promise(() => {}));
- state.eventSourceRef.current = null;
+ resetVideoGenMockState();
+ state.getVideoGenStatus.mockResolvedValue(videoGenStatus([MODEL]));
+ state.modelStatuses = { [MODEL.id]: { id: MODEL.id, repo: MODEL.repo, cached: true, sizeBytes: 100 } };
+ state.generateVideo.mockReturnValue(new Promise(() => {}));
+ state.attach.mockReturnValue(new Promise(() => {}));
vi.stubGlobal('open', vi.fn());
Object.defineProperty(globalThis.navigator, 'clipboard', {
configurable: true,
@@ -155,33 +31,19 @@ describe('VideoGen compose-while-busy', () => {
});
it('starts runtime installation through the non-idempotent POST stream', async () => {
- await act(async () => {
- render(
-
-
- ,
- );
- });
+ await renderVideoGenPage();
expect(screen.getByTestId('runtime-install-modal')).toHaveAttribute('data-stream-method', 'POST');
});
it('leaves Enhance with AI and Prompt from media usable so the next clip can be queued', async () => {
- await act(async () => {
- render(
-
-
- ,
- );
- });
+ await renderVideoGenPage();
const prompt = await screen.findByLabelText('Prompt');
fireEvent.change(prompt, { target: { value: 'a fox watches the rain' } });
await waitFor(() => expect(screen.getByRole('button', { name: /^Generate$/ })).toBeEnabled());
- await act(async () => {
- fireEvent.click(screen.getByRole('button', { name: /^Generate$/ }));
- });
+ fireEvent.click(screen.getByRole('button', { name: /^Generate$/ }));
await waitFor(() => expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument());
expect(screen.getByTestId('prompt-enhancer')).toHaveAttribute('data-disabled', '0');
@@ -190,13 +52,7 @@ describe('VideoGen compose-while-busy', () => {
});
it('includes the selected universe style in the submitted video prompt', async () => {
- await act(async () => {
- render(
-
-
- ,
- );
- });
+ await renderVideoGenPage();
fireEvent.click(screen.getByRole('button', { name: 'Use universe style' }));
fireEvent.change(await screen.findByLabelText('Prompt'), { target: { value: 'a fox watches the rain' } });
@@ -207,13 +63,7 @@ describe('VideoGen compose-while-busy', () => {
});
it('submits an additional render to the server queue while another render is active', async () => {
- await act(async () => {
- render(
-
-
- ,
- );
- });
+ await renderVideoGenPage();
const prompt = await screen.findByLabelText('Prompt');
fireEvent.change(prompt, { target: { value: 'a fox watches the rain' } });
@@ -229,5 +79,4 @@ describe('VideoGen compose-while-busy', () => {
prompt: 'a fox watches the rain',
});
});
-
});
diff --git a/client/src/pages/VideoGen.federatedTarget.test.jsx b/client/src/pages/VideoGen.federatedTarget.test.jsx
index caacef5061..69954f679f 100644
--- a/client/src/pages/VideoGen.federatedTarget.test.jsx
+++ b/client/src/pages/VideoGen.federatedTarget.test.jsx
@@ -1,32 +1,18 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
-import { MemoryRouter } from 'react-router';
+import { beforeEach, describe, expect, it } from 'vitest';
+import { act, fireEvent, screen, waitFor } from '@testing-library/react';
+
+import {
+ loadVideoGenPage,
+ renderVideoGenPage,
+ resetVideoGenMockState,
+ state,
+ videoGenModel,
+ videoGenStatus,
+ videoGenTermsGate,
+} from '../test/videoGenPageMocks.jsx';
const TERMS_ID = 'minimax-h3-license-v1';
-const MODEL = {
- id: 'h3-one',
- name: 'MiniMax h3-one',
- repo: 'example-org/h3-one',
- revision: '1111111111111111111111111111111111111111',
- runtime: 'minimax_h3',
- supportedModes: ['text'],
- defaultFrames: 124,
- frameOptions: [124, 141],
- fpsOptions: [24],
- steps: 8,
- guidance: 0,
- samplerLocked: true,
- supportsNegativePrompt: false,
- supportsTiling: false,
- supportsDisableAudio: false,
- termsGate: {
- id: TERMS_ID,
- title: 'Terms for h3-one',
- summary: 'This model is available only in its applicable territory.',
- acknowledgement: `I am eligible and accept ${TERMS_ID}.`,
- licenseUrl: 'https://example.com/license',
- },
-};
+const MODEL = videoGenModel('h3-one', { termsGate: videoGenTermsGate(TERMS_ID) });
// A peer opted in as a video provider, advertising one allowlisted model with a
// verifiable freshness window — the shape `GET /api/instances` returns.
@@ -51,120 +37,21 @@ const PEER = {
},
};
-const state = vi.hoisted(() => ({
- generateVideo: vi.fn(),
- attach: vi.fn(),
- eventSourceRef: { current: null },
-}));
-
-vi.mock('../services/api', () => ({
- getInstances: vi.fn(async () => ({ peers: [PEER] })),
- getVideoGenStatus: vi.fn(async () => ({
- connected: true,
- pythonPath: '/opt/example/python3',
- defaultModel: 'h3-one',
- models: [MODEL],
- byovRuntimes: [],
- systemMemoryGb: 128,
- backendDisclosures: [],
- })),
- generateVideo: (...args) => state.generateVideo(...args),
- cancelVideoGen: vi.fn(async () => ({})),
- listVideoHistory: vi.fn(async () => []),
- deleteVideoHistoryItem: vi.fn(async () => ({})),
- setVideoHidden: vi.fn(async () => ({})),
- extractLastFrame: vi.fn(async () => ({})),
- upscaleVideo: vi.fn(async () => ({})),
- listImageGallery: vi.fn(async () => []),
- patchSettingsSlice: vi.fn(async () => ({})),
- getActiveVideoJob: vi.fn(async () => ({ activeJob: null })),
- getSettings: vi.fn(async () => ({ imageGen: { grok: { enabled: false } } })),
- getVideoGenRuntimeStatus: vi.fn(async () => ({ installed: true, ready: true, current: true })),
- listLorasFull: vi.fn(async () => []),
- getProviders: vi.fn(async () => ({ providers: [] })),
- getVisionModels: vi.fn(async () => ({ models: [] })),
-}));
-
-vi.mock('../hooks/useModelDownloadStatus', () => ({
- TEXT_ENCODER_DOWNLOAD_ID: '__text_encoder__',
- useModelDownloadStatus: () => ({
- extra: {},
- loading: false,
- statusError: null,
- activeModelId: null,
- progress: null,
- lastError: null,
- downloading: false,
- repairing: false,
- getStatus: () => ({ id: MODEL.id, repo: MODEL.repo, cached: true, sizeBytes: 100 }),
- start: vi.fn(),
- cancel: vi.fn(),
- repair: vi.fn(),
- refresh: vi.fn(),
- }),
-}));
-
-vi.mock('../hooks/useMediaJobSse', () => ({
- useMediaJobSse: () => ({ attach: state.attach, eventSourceRef: state.eventSourceRef }),
-}));
-vi.mock('../hooks/useMediaCompletionRefresh', () => ({ useMediaCompletionRefresh: vi.fn() }));
-vi.mock('../hooks/useMediaAnnotations', () => ({
- useMediaAnnotations: () => ({ annotations: {}, updateAnnotation: vi.fn(), getCardProps: vi.fn(() => ({})) }),
-}));
-vi.mock('../hooks/usePreviewRoute', () => ({ default: () => [null, vi.fn()] }));
-vi.mock('../components/ui/Toast', () => ({
- default: Object.assign(vi.fn(), { error: vi.fn(), success: vi.fn(), loading: vi.fn() }),
-}));
-
-vi.mock('../components/media/PromptEnhancer', () => ({
- default: ({ disabled }) => (
-
Enhance with AI
- ),
-}));
-vi.mock('../components/media/PromptFromMedia', () => ({
- default: ({ disabled }) => (
-
Prompt from media
- ),
-}));
-
-vi.mock('../components/Drawer', () => ({ default: () => null }));
-vi.mock('../components/settings/ImageGenTab', () => ({ ImageGenTab: () => null }));
-vi.mock('../components/settings/LocalSetupPanel', () => ({ default: () => null }));
-vi.mock('../components/install/RuntimeInstallModal', () => ({ default: () => null }));
-vi.mock('../components/videoGen/FramePanel', () => ({ default: () => null }));
-vi.mock('../components/videoGen/KeyframePanel', () => ({ default: () => null }));
-vi.mock('../components/videoGen/AudioPanel', () => ({ default: () => null }));
-vi.mock('../components/videoGen/ExtendPanel', () => ({ default: () => null }));
-vi.mock('../components/videoGen/IcLoraPanel', () => ({ default: () => null }));
-vi.mock('../components/videoGen/AdvancedParamsPanel', () => ({ default: () => null }));
-vi.mock('../components/videoGen/RuntimeFingerprint', () => ({ default: () => null }));
-vi.mock('../components/videoGen/VideoGenGallery', () => ({ default: () => null }));
-vi.mock('../components/media/MediaPreview', () => ({ default: () => null }));
-vi.mock('../components/media/StylePresetPicker', () => ({ default: () => null }));
-vi.mock('../components/media/UniverseStylePicker', () => ({ default: () => null }));
-vi.mock('../components/media/MediaJobsQueue', () => ({ default: () => null }));
-vi.mock('../components/imageGen/LoraPicker', () => ({ default: () => null }));
-vi.mock('../components/media/ResolutionField', () => ({ default: () => null }));
-
-
-const { default: VideoGen } = await import('./VideoGen.jsx');
+await loadVideoGenPage();
const startRender = async (promptText = 'a fox watches the rain') => {
- await act(async () => {
- render(
-
-
- ,
- );
- });
+ await renderVideoGenPage();
fireEvent.change(await screen.findByLabelText('Prompt'), { target: { value: promptText } });
};
describe('VideoGen federated render target', () => {
beforeEach(() => {
- state.generateVideo.mockReset().mockReturnValue(new Promise(() => {}));
- state.attach.mockReset().mockReturnValue(new Promise(() => {}));
- state.eventSourceRef.current = null;
+ resetVideoGenMockState();
+ state.peers = [PEER];
+ state.getVideoGenStatus.mockResolvedValue(videoGenStatus([MODEL]));
+ state.modelStatuses = { [MODEL.id]: { id: MODEL.id, repo: MODEL.repo, cached: true, sizeBytes: 100 } };
+ state.generateVideo.mockReturnValue(new Promise(() => {}));
+ state.attach.mockReturnValue(new Promise(() => {}));
});
// The whole point of the picker: a peer's model reaches the generate route as
diff --git a/client/src/pages/VideoGen.jsx b/client/src/pages/VideoGen.jsx
index 2de0774e4e..73fb67267b 100644
--- a/client/src/pages/VideoGen.jsx
+++ b/client/src/pages/VideoGen.jsx
@@ -52,8 +52,7 @@ import AdvancedParamsPanel from '../components/videoGen/AdvancedParamsPanel';
import RuntimeFingerprint from '../components/videoGen/RuntimeFingerprint';
import ModelDisclosure from '../components/videoGen/ModelDisclosure';
import ModelRepairBanner from '../components/videoGen/ModelRepairBanner';
-import LiveVideoStage from '../components/videoGen/LiveVideoStage';
-import { resolveVideoStagePreview, VIDEO_STAGE_KIND } from '../lib/videoStagePreview';
+import RenderStatusCard from '../components/videoGen/RenderStatusCard';
import VideoGenGallery from '../components/videoGen/VideoGenGallery';
import GalleryImagePicker from '../components/imageGen/GalleryImagePicker';
import MediaPreview from '../components/media/MediaPreview';
@@ -64,7 +63,7 @@ import PromptFromMedia from '../components/media/PromptFromMedia';
import { normalizeVideo } from '../components/media/normalize';
import {
Film, Sparkles, Settings as SettingsIcon, RefreshCw, AlertTriangle,
- X, Type, Image as ImageIcon, GitBranch, ListPlus, Music, SlidersHorizontal,
+ X, Type, Image as ImageIcon, GitBranch, ListPlus, Music, SlidersHorizontal, MonitorOff,
} from 'lucide-react';
import toast from '../components/ui/Toast';
import MediaJobsQueue from '../components/media/MediaJobsQueue';
@@ -382,14 +381,15 @@ export default function VideoGen() {
const [progress, setProgress] = useState(null);
const [statusMsg, setStatusMsg] = useState('');
const [error, setError] = useState(null);
- // Main-stage preview state (#4588). `renderGeometry` is the server's
- // RESOLVED width/height — the form's values are what we asked for, and
- // videoGen snaps both edges down to the model's grid. Transient runner frames
- // are intentionally ignored: they are not useful for judging a video render.
- // `lastRender` is this session's finished clip, so the stage hands off to it
- // instead of going blank when the render lands.
- const [renderGeometry, setRenderGeometry] = useState(null);
- const [lastRender, setLastRender] = useState(null);
+ // Live render status (#5872). `phase` is the runner's own STAGE: id, mapped
+ // to a named step by the status card — with the queue's own 'queued' as one
+ // more phase id, so waiting in line and running share one piece of state
+ // instead of a boolean every SSE handler has to remember to clear.
+ // `renderStartedAt` drives the elapsed clock, which is what makes a silent
+ // phase (an ~89 GB checkpoint streaming onto the GPU) legible as work rather
+ // than as a hang.
+ const [phase, setPhase] = useState(null);
+ const [renderStartedAt, setRenderStartedAt] = useState(null);
const { attach, eventSourceRef } = useMediaJobSse('video');
// Hold the reject() of the in-flight runGeneration Promise so cancel can
// settle it. Without this, handleCancel() closes the EventSource but the
@@ -424,25 +424,41 @@ export default function VideoGen() {
const attachJobEvents = (jobId, { isCurrent = () => true, settleResolve = () => {}, settleReject = () => {}, withToast = true } = {}) => {
return attach(jobId, {
isCurrent,
- onQueued: (msg) => setStatusMsg(typeof msg.position === 'number' ? `Queued (position ${msg.position})` : 'Queued'),
- onStarted: () => setStatusMsg('Starting render…'),
- onRenderMeta: (msg) => setRenderGeometry({ width: msg.width, height: msg.height }),
- onStatus: (msg) => setStatusMsg(msg.message),
+ onQueued: (msg) => {
+ setPhase('queued');
+ setStatusMsg(typeof msg.position === 'number' ? `Queued (position ${msg.position})` : 'Queued');
+ },
+ onStarted: () => {
+ // Out of the queue, but the runner hasn't named a phase yet — clear it
+ // rather than guessing, and let the card default to "Loading model".
+ setPhase(null);
+ // The clock starts HERE, not at submit: time spent waiting in the queue
+ // is not render time, and counting it would make the elapsed figure
+ // jump backwards on a reload (which reads the worker's own startedAt).
+ setRenderStartedAt((at) => at ?? Date.now());
+ setStatusMsg('Starting render…');
+ },
+ // `phase` is presence-guarded on the wire: a frame that carries none must
+ // leave the last known phase alone rather than reset the step list to
+ // "unknown" on every bare status line.
+ onStatus: (msg) => {
+ if (msg.phase) setPhase(msg.phase);
+ setStatusMsg(msg.message);
+ },
onProgress: (msg) => {
setProgress({ progress: msg.progress });
+ if (msg.phase) setPhase(msg.phase);
// A bare tqdm percentage shouldn't blank the STATUS line that just
// preceded it; only overwrite when the progress event carries text.
if (msg.message) setStatusMsg(msg.message);
},
- onComplete: (msg) => {
+ onComplete: () => {
setGenerating(false);
setProgress({ progress: 1 });
+ setPhase(null);
setStatusMsg('Complete');
- // Hand the stage off from the conditioning media to the finished clip.
- if (msg.result) setLastRender(msg.result);
if (withToast) toast.success('Video generated');
refreshHistory();
- return msg.result;
},
onError: (msg) => {
setError(msg.error);
@@ -452,6 +468,7 @@ export default function VideoGen() {
},
onCanceled: (msg) => {
setGenerating(false);
+ setPhase(null);
setStatusMsg(msg.reason || 'Canceled');
if (withToast) toast(msg.reason || 'Render canceled');
return new Error(msg.reason || 'Canceled');
@@ -479,10 +496,13 @@ export default function VideoGen() {
// attachJobEvents runs.
if (runTokenRef.current > 0 || eventSourceRef.current) return;
applyResumedParams(job.params || {});
- // The in-flight render's own geometry, not the form's — a reload has to
- // size the stage by what the server resolved.
- if (job.render) setRenderGeometry({ width: job.render.width, height: job.render.height });
setGenerating(true);
+ setPhase(job.status === 'queued' ? 'queued' : null);
+ // The worker's own start time, so a reload keeps a truthful elapsed clock
+ // instead of restarting it and reading as a fresh render. A job still in
+ // the queue has no start time yet and gets no clock — the same rule the
+ // live path follows.
+ setRenderStartedAt(job.startedAt ? new Date(job.startedAt).getTime() : null);
// Skip a forced setProgress(0) here — attachJobEvents will replay the
// server's last SSE payload synchronously after EventSource open, and
// a job mid-render would otherwise visibly flash 0% before jumping
@@ -648,29 +668,13 @@ export default function VideoGen() {
const progressPct = progress?.progress != null ? Math.round(progress.progress * 100) : null;
- // What the main stage shows right now (#4588). Memoized because the stage
- // holds a pending descriptor by signature — a fresh object identity on every
- // keystroke in the prompt box would otherwise churn its hold/return guard.
- // Geometry prefers the server's resolved edges and falls back to the form's
- // (a queued job, or a runtime that never reports them).
- const extendSource = useMemo(
- () => (extendFromVideoId ? history.find((v) => v.id === extendFromVideoId) || null : null),
- [extendFromVideoId, history],
- );
- const stage = useMemo(() => resolveVideoStagePreview({
- generating,
- width: renderGeometry?.width ?? width,
- height: renderGeometry?.height ?? height,
- result: lastRender,
- extendSource,
- sourceImageFile, sourceUploadUrl,
- lastImageFile, lastUploadUrl,
- keyframes: keyframesActive ? keyframes : null,
- }), [
- generating, renderGeometry, width, height, lastRender, extendSource,
- sourceImageFile, sourceUploadUrl, lastImageFile, lastUploadUrl, keyframesActive, keyframes,
- ]);
- const showStage = generating || stage.kind !== VIDEO_STAGE_KIND.EMPTY;
+ // Will this render put the display to sleep? Both halves are server-owned so
+ // the warning can't drift from the behaviour: the model says whether its
+ // runtime needs the mitigation, and /status says whether this install will
+ // actually apply it (macOS, and the user hasn't opted out).
+ const rendersSleepDisplay = !!status?.displaySleepOnRender
+ && !!currentModel?.sleepsDisplayDuringRender
+ && !remoteTarget.isRemote && !isGrok;
// Run a single payload through the SSE pipeline. Returns a promise that
// resolves when the job completes (or rejects on error / cancel). The
@@ -692,10 +696,11 @@ export default function VideoGen() {
setProgress({ progress: 0 });
setStatusMsg('Starting...');
setError(null);
- // Stale-job isolation: the previous run's geometry must not be shown as if
- // it belonged to this one. `lastRender` deliberately survives so the stage
- // keeps the finished clip until this run produces something of its own.
- setRenderGeometry(null);
+ // Stale-job isolation: the previous run's phase and clock must not be shown
+ // as if they belonged to this one. The clock stays null until `started`
+ // lands, so it measures the render rather than the queue wait.
+ setPhase(null);
+ setRenderStartedAt(null);
const myToken = ++runTokenRef.current;
const isCurrent = () => myToken === runTokenRef.current;
@@ -870,19 +875,6 @@ export default function VideoGen() {
- {showStage && (
-
- )}
-
{statusFresh && status.connected === false && (() => {
@@ -1482,6 +1474,22 @@ export default function VideoGen() {
)}
+
+ {/* Said BEFORE the button is pressed, not after the screen is already
+ dark. A user who first learns about the sleep by watching their
+ display go black reads it as a crash and wakes it — which puts
+ WindowServer back in contention with Metal and risks the GPU
+ watchdog panic the sleep exists to avoid. */}
+ {rendersSleepDisplay && !generating && (
+
+
+
+ This model renders with your display asleep. The screen will go dark shortly
+ after you start — that is expected, and waking it can crash the render. Disable it
+ under Settings → Media Generation if you would rather keep the screen on.
+
+
+ )}
@@ -1496,6 +1504,16 @@ export default function VideoGen() {
+
+
({
- id,
- name: `Example ${id}`,
- repo: `example-org/${id}`,
- revision: '1111111111111111111111111111111111111111',
- runtime: 'minimax_h3',
- supportedModes: ['text'],
- defaultFrames: 124,
- frameOptions: [124, 141],
- fpsOptions: [24],
- steps: 8,
- guidance: 0,
- samplerLocked: true,
- supportsNegativePrompt: false,
- supportsTiling: false,
- supportsDisableAudio: false,
-});
-const MODEL_ONE = model('example-one');
-const MODEL_TWO = model('example-two');
-const statusPayload = (overrides = {}) => ({
- connected: true,
- pythonPath: '/opt/example/python3',
- defaultModel: MODEL_ONE.id,
- models: [MODEL_ONE, MODEL_TWO],
- byovRuntimes: [],
- systemMemoryGb: 128,
- backendDisclosures: [],
- ...overrides,
-});
-const state = vi.hoisted(() => ({
- modelStatuses: {},
- generateVideo: vi.fn(),
- startDownload: vi.fn(),
- repairModel: vi.fn(),
- attach: vi.fn(),
- eventSourceRef: { current: null },
- getVideoGenStatus: vi.fn(),
- runtimeInstallComplete: null,
-}));
-
-vi.mock('../services/api', () => ({
- // The page offers a federated render target (#4348); with no peer opted in
- // as a media provider the picker renders nothing and every local path below
- // is unchanged.
- getInstances: vi.fn(async () => ({ peers: [] })),
- getVideoGenStatus: (...args) => state.getVideoGenStatus(...args),
- generateVideo: (...args) => state.generateVideo(...args),
- cancelVideoGen: vi.fn(async () => ({})),
- listVideoHistory: vi.fn(async () => []),
- deleteVideoHistoryItem: vi.fn(async () => ({})),
- setVideoHidden: vi.fn(async () => ({})),
- extractLastFrame: vi.fn(async () => ({})),
- upscaleVideo: vi.fn(async () => ({})),
- listImageGallery: vi.fn(async () => []),
- patchSettingsSlice: vi.fn(async () => ({})),
- getActiveVideoJob: vi.fn(async () => ({ activeJob: null })),
- getSettings: vi.fn(async () => ({ imageGen: { grok: { enabled: false } } })),
- getVideoGenRuntimeStatus: vi.fn(async () => ({ installed: true, ready: true, current: true })),
- listLorasFull: vi.fn(async () => []),
- // The prompt-enhancement controls mount useProviderModels, which fetches the
- // provider list from a mount effect. Unmocked it throws out of a passive
- // effect — the tests still pass, but the unhandled rejection fails the run.
- getProviders: vi.fn(async () => ({ providers: [] })),
- getVisionModels: vi.fn(async () => ({ models: [] })),
-}));
-
-vi.mock('../components/media/PromptFromMedia', () => ({ default: () => null }));
-
-vi.mock('../hooks/useModelDownloadStatus', () => ({
- TEXT_ENCODER_DOWNLOAD_ID: '__text_encoder__',
- useModelDownloadStatus: () => ({
- extra: {},
- loading: false,
- statusError: null,
- activeModelId: null,
- progress: null,
- lastError: null,
- downloading: false,
- repairing: false,
- getStatus: (id) => state.modelStatuses[id] || null,
- start: state.startDownload,
- cancel: vi.fn(),
- repair: state.repairModel,
- refresh: vi.fn(),
- }),
-}));
-
-vi.mock('../hooks/useMediaJobSse', () => ({
- useMediaJobSse: () => ({ attach: state.attach, eventSourceRef: state.eventSourceRef }),
-}));
-vi.mock('../hooks/useMediaCompletionRefresh', () => ({ useMediaCompletionRefresh: vi.fn() }));
-vi.mock('../hooks/useMediaAnnotations', () => ({
- useMediaAnnotations: () => ({ annotations: {}, updateAnnotation: vi.fn(), getCardProps: vi.fn(() => ({})) }),
-}));
-vi.mock('../hooks/usePreviewRoute', () => ({ default: () => [null, vi.fn()] }));
-vi.mock('../components/ui/Toast', () => ({
- default: Object.assign(vi.fn(), { error: vi.fn(), success: vi.fn(), loading: vi.fn() }),
-}));
+const MODEL_ONE = videoGenModel('example-one');
+const MODEL_TWO = videoGenModel('example-two');
+const statusPayload = (overrides = {}) => videoGenStatus([MODEL_ONE, MODEL_TWO], overrides);
-// Keep the policy-bearing controls real; replace unrelated, heavyweight page
-// surfaces so this is a focused orchestration test rather than a gallery/SSE
-// integration suite.
-vi.mock('../components/Drawer', () => ({ default: () => null }));
-vi.mock('../components/settings/ImageGenTab', () => ({ ImageGenTab: () => null }));
-vi.mock('../components/settings/LocalSetupPanel', () => ({ default: () => null }));
-vi.mock('../components/install/RuntimeInstallModal', () => ({
- default: ({ onComplete }) => {
- state.runtimeInstallComplete = onComplete;
- return null;
- },
-}));
-vi.mock('../components/videoGen/FramePanel', () => ({ default: () => null }));
-vi.mock('../components/videoGen/KeyframePanel', () => ({ default: () => null }));
-vi.mock('../components/videoGen/AudioPanel', () => ({ default: () => null }));
-vi.mock('../components/videoGen/ExtendPanel', () => ({ default: () => null }));
-vi.mock('../components/videoGen/IcLoraPanel', () => ({ default: () => null }));
-vi.mock('../components/videoGen/AdvancedParamsPanel', () => ({ default: () => null }));
-vi.mock('../components/videoGen/RuntimeFingerprint', () => ({ default: () => null }));
-vi.mock('../components/videoGen/VideoGenGallery', () => ({ default: () => null }));
-vi.mock('../components/media/MediaPreview', () => ({ default: () => null }));
-vi.mock('../components/media/StylePresetPicker', () => ({ default: () => null }));
-vi.mock('../components/media/UniverseStylePicker', () => ({ default: () => null }));
-vi.mock('../components/media/MediaJobsQueue', () => ({ default: () => null }));
-vi.mock('../components/imageGen/LoraPicker', () => ({ default: () => null }));
-vi.mock('../components/media/ResolutionField', () => ({ default: () => null }));
-
-
-const { default: VideoGen } = await import('./VideoGen.jsx');
+await loadVideoGenPage();
const { VIDEO_GEN_STATUS_CACHE_KEY } = await import('../lib/videoGenStatusCache.js');
-const renderPage = async () => {
- let view;
- await act(async () => {
- view = render(
-
-
- ,
- );
- });
- return view;
-};
-
// A /status call the test settles by hand, so the page can be asserted mid-probe.
const deferredStatus = () => {
let settle;
@@ -164,15 +37,14 @@ describe('VideoGen model picker while /status is in flight', () => {
beforeEach(() => {
localStorage.clear();
sessionStorage.clear();
- state.modelStatuses = {};
- state.getVideoGenStatus.mockReset().mockResolvedValue(statusPayload());
- state.eventSourceRef.current = null;
- state.attach.mockReset().mockResolvedValue({ filename: 'example.mp4' });
+ resetVideoGenMockState();
+ state.getVideoGenStatus.mockResolvedValue(statusPayload());
+ state.attach.mockResolvedValue({ filename: 'example.mp4' });
});
it('keeps the Model field with a loading placeholder until the model list lands', async () => {
const resolveStatus = deferredStatus();
- await renderPage();
+ await renderVideoGenPage();
const field = screen.getByLabelText('Model');
expect(field).toBeDisabled();
@@ -185,7 +57,7 @@ describe('VideoGen model picker while /status is in flight', () => {
});
it('paints the cached model list on the next load instead of waiting for the probe', async () => {
- const first = await renderPage();
+ const first = await renderVideoGenPage();
await waitFor(() => expect(screen.getByLabelText('Model')).toHaveValue(MODEL_ONE.id));
// Only the model-shaping slice is persisted — python health never is.
expect(Object.keys(JSON.parse(sessionStorage.getItem(VIDEO_GEN_STATUS_CACHE_KEY))).sort())
@@ -193,7 +65,7 @@ describe('VideoGen model picker while /status is in flight', () => {
first.unmount();
const resolveStatus = deferredStatus();
- await renderPage();
+ await renderVideoGenPage();
const field = screen.getByLabelText('Model');
expect(field).toBeEnabled();
@@ -211,7 +83,7 @@ describe('VideoGen model picker while /status is in flight', () => {
missingPackages: ['torch'],
})));
const resolveStatus = deferredStatus();
- await renderPage();
+ await renderVideoGenPage();
expect(screen.getByLabelText('Model')).toHaveValue(MODEL_ONE.id);
expect(screen.getByText('Checking…')).toBeInTheDocument();
diff --git a/client/src/pages/VideoGen.terms.test.jsx b/client/src/pages/VideoGen.terms.test.jsx
index 1be975380f..76c5d4ece8 100644
--- a/client/src/pages/VideoGen.terms.test.jsx
+++ b/client/src/pages/VideoGen.terms.test.jsx
@@ -1,146 +1,22 @@
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
-import { MemoryRouter } from 'react-router';
+import { beforeEach, describe, expect, it } from 'vitest';
+import { act, fireEvent, screen, waitFor } from '@testing-library/react';
+
+import {
+ loadVideoGenPage,
+ renderVideoGenPage,
+ resetVideoGenMockState,
+ state,
+ videoGenModel,
+ videoGenStatus,
+ videoGenTermsGate,
+} from '../test/videoGenPageMocks.jsx';
const TERMS_ONE = 'minimax-h3-license-v1';
const TERMS_TWO = 'minimax-h3-license-v2';
-const model = (id, termsId) => ({
- id,
- name: `MiniMax ${id}`,
- repo: `example-org/${id}`,
- revision: '1111111111111111111111111111111111111111',
- runtime: 'minimax_h3',
- supportedModes: ['text'],
- defaultFrames: 124,
- frameOptions: [124, 141],
- fpsOptions: [24],
- steps: 8,
- guidance: 0,
- samplerLocked: true,
- supportsNegativePrompt: false,
- supportsTiling: false,
- supportsDisableAudio: false,
- termsGate: {
- id: termsId,
- title: `Terms for ${id}`,
- summary: 'This model is available only in its applicable territory.',
- acknowledgement: `I am eligible and accept ${termsId}.`,
- licenseUrl: 'https://example.com/license',
- },
-});
-const H3_ONE = model('h3-one', TERMS_ONE);
-const H3_TWO = model('h3-two', TERMS_TWO);
-
-const state = vi.hoisted(() => ({
- modelStatuses: {},
- generateVideo: vi.fn(),
- startDownload: vi.fn(),
- repairModel: vi.fn(),
- attach: vi.fn(),
- eventSourceRef: { current: null },
- getVideoGenStatus: vi.fn(),
- runtimeInstallComplete: null,
-}));
-
-vi.mock('../services/api', () => ({
- // The page offers a federated render target (#4348); with no peer opted in
- // as a media provider the picker renders nothing and every local path below
- // is unchanged.
- getInstances: vi.fn(async () => ({ peers: [] })),
- getVideoGenStatus: (...args) => state.getVideoGenStatus(...args),
- generateVideo: (...args) => state.generateVideo(...args),
- cancelVideoGen: vi.fn(async () => ({})),
- listVideoHistory: vi.fn(async () => []),
- deleteVideoHistoryItem: vi.fn(async () => ({})),
- setVideoHidden: vi.fn(async () => ({})),
- extractLastFrame: vi.fn(async () => ({})),
- upscaleVideo: vi.fn(async () => ({})),
- listImageGallery: vi.fn(async () => []),
- patchSettingsSlice: vi.fn(async () => ({})),
- getActiveVideoJob: vi.fn(async () => ({ activeJob: null })),
- getSettings: vi.fn(async () => ({ imageGen: { grok: { enabled: false } } })),
- getVideoGenRuntimeStatus: vi.fn(async () => ({ installed: true, ready: true, current: true })),
- listLorasFull: vi.fn(async () => []),
- // The prompt-enhancement controls mount useProviderModels, which fetches the
- // provider list from a mount effect. Unmocked it throws out of a passive
- // effect — the tests still pass, but the unhandled rejection fails the run.
- getProviders: vi.fn(async () => ({ providers: [] })),
- getVisionModels: vi.fn(async () => ({ models: [] })),
-}));
-
-vi.mock('../components/media/PromptFromMedia', () => ({ default: () => null }));
-
-vi.mock('../hooks/useModelDownloadStatus', () => ({
- TEXT_ENCODER_DOWNLOAD_ID: '__text_encoder__',
- useModelDownloadStatus: () => ({
- extra: {},
- loading: false,
- statusError: null,
- activeModelId: null,
- progress: null,
- lastError: null,
- downloading: false,
- repairing: false,
- getStatus: (id) => state.modelStatuses[id] || null,
- start: state.startDownload,
- cancel: vi.fn(),
- repair: state.repairModel,
- refresh: vi.fn(),
- }),
-}));
-
-vi.mock('../hooks/useMediaJobSse', () => ({
- useMediaJobSse: () => ({ attach: state.attach, eventSourceRef: state.eventSourceRef }),
-}));
-vi.mock('../hooks/useMediaCompletionRefresh', () => ({ useMediaCompletionRefresh: vi.fn() }));
-vi.mock('../hooks/useMediaAnnotations', () => ({
- useMediaAnnotations: () => ({ annotations: {}, updateAnnotation: vi.fn(), getCardProps: vi.fn(() => ({})) }),
-}));
-vi.mock('../hooks/usePreviewRoute', () => ({ default: () => [null, vi.fn()] }));
-vi.mock('../components/ui/Toast', () => ({
- default: Object.assign(vi.fn(), { error: vi.fn(), success: vi.fn(), loading: vi.fn() }),
-}));
-
-// Keep the policy-bearing controls real; replace unrelated, heavyweight page
-// surfaces so this is a focused orchestration test rather than a gallery/SSE
-// integration suite.
-vi.mock('../components/Drawer', () => ({ default: () => null }));
-vi.mock('../components/settings/ImageGenTab', () => ({ ImageGenTab: () => null }));
-vi.mock('../components/settings/LocalSetupPanel', () => ({ default: () => null }));
-vi.mock('../components/install/RuntimeInstallModal', () => ({
- default: ({ onComplete }) => {
- state.runtimeInstallComplete = onComplete;
- return null;
- },
-}));
-vi.mock('../components/videoGen/FramePanel', () => ({ default: () => null }));
-vi.mock('../components/videoGen/KeyframePanel', () => ({ default: () => null }));
-vi.mock('../components/videoGen/AudioPanel', () => ({ default: () => null }));
-vi.mock('../components/videoGen/ExtendPanel', () => ({ default: () => null }));
-vi.mock('../components/videoGen/IcLoraPanel', () => ({ default: () => null }));
-vi.mock('../components/videoGen/AdvancedParamsPanel', () => ({ default: () => null }));
-vi.mock('../components/videoGen/RuntimeFingerprint', () => ({ default: () => null }));
-vi.mock('../components/videoGen/VideoGenGallery', () => ({ default: () => null }));
-vi.mock('../components/media/MediaPreview', () => ({ default: () => null }));
-vi.mock('../components/media/StylePresetPicker', () => ({ default: () => null }));
-vi.mock('../components/media/UniverseStylePicker', () => ({ default: () => null }));
-vi.mock('../components/media/MediaJobsQueue', () => ({ default: () => null }));
-vi.mock('../components/imageGen/LoraPicker', () => ({ default: () => null }));
-vi.mock('../components/media/ResolutionField', () => ({ default: () => null }));
-
-const { default: VideoGen } = await import('./VideoGen.jsx');
-
-const renderPage = async () => {
- let view;
- await act(async () => {
- view = render(
-
-
- ,
- );
- });
- return view;
-};
+const H3_ONE = videoGenModel('h3-one', { termsGate: videoGenTermsGate(TERMS_ONE) });
+const H3_TWO = videoGenModel('h3-two', { termsGate: videoGenTermsGate(TERMS_TWO) });
+
+await loadVideoGenPage();
const prompt = () => screen.getByLabelText('Prompt');
const generate = () => screen.getByRole('button', { name: /^Generate$/ });
@@ -149,32 +25,22 @@ const enqueue = () => screen.getByRole('button', { name: /Add to queue/ });
describe('VideoGen MiniMax H3 orchestration', () => {
beforeEach(() => {
localStorage.clear();
+ resetVideoGenMockState();
state.modelStatuses = {
[H3_ONE.id]: { id: H3_ONE.id, repo: H3_ONE.repo, cached: true, sizeBytes: 100 },
[H3_TWO.id]: { id: H3_TWO.id, repo: H3_TWO.repo, cached: true, sizeBytes: 100 },
};
- state.generateVideo.mockReset().mockResolvedValue({ jobId: 'job-1' });
- state.startDownload.mockReset();
- state.repairModel.mockReset().mockResolvedValue({ ok: true });
- state.getVideoGenStatus.mockReset().mockResolvedValue({
- connected: true,
- pythonPath: '/opt/example/python3',
- defaultModel: 'h3-one',
- models: [H3_ONE, H3_TWO],
- byovRuntimes: [],
- systemMemoryGb: 128,
- backendDisclosures: [],
- });
- state.runtimeInstallComplete = null;
- state.eventSourceRef.current = null;
- state.attach.mockReset().mockImplementation(async (_jobId, handlers) => {
+ state.generateVideo.mockResolvedValue({ jobId: 'job-1' });
+ state.repair.mockResolvedValue({ ok: true });
+ state.getVideoGenStatus.mockResolvedValue(videoGenStatus([H3_ONE, H3_TWO]));
+ state.attach.mockImplementation(async (_jobId, handlers) => {
handlers.onComplete({ result: { filename: 'example.mp4' } });
return { filename: 'example.mp4' };
});
});
it('lets H3 generate and queue with no eligibility checkbox', async () => {
- await renderPage();
+ await renderVideoGenPage();
await waitFor(() => expect(screen.getByLabelText('Model')).toHaveValue(H3_ONE.id));
expect(screen.queryByRole('checkbox', { name: /I am eligible/ })).toBeNull();
@@ -205,12 +71,12 @@ describe('VideoGen MiniMax H3 orchestration', () => {
it('offers download without an eligibility acknowledgement', async () => {
state.modelStatuses[H3_ONE.id] = { id: H3_ONE.id, repo: H3_ONE.repo, cached: false, sizeBytes: 0 };
- await renderPage();
+ await renderVideoGenPage();
const download = await screen.findByRole('button', { name: /Download/ });
expect(download).toBeEnabled();
fireEvent.click(download);
- expect(state.startDownload).toHaveBeenCalledWith(H3_ONE.id);
+ expect(state.start).toHaveBeenCalledWith(H3_ONE.id);
});
it('offers integrity repair without an eligibility acknowledgement', async () => {
@@ -221,16 +87,16 @@ describe('VideoGen MiniMax H3 orchestration', () => {
sizeBytes: 100,
integrity: { status: 'bad', badFiles: [{ name: 'model.safetensors' }] },
};
- await renderPage();
+ await renderVideoGenPage();
const repair = await screen.findByRole('button', { name: /Repair model/ });
expect(repair).toBeEnabled();
fireEvent.click(repair);
- expect(state.repairModel).toHaveBeenCalledWith(H3_ONE.id);
+ expect(state.repair).toHaveBeenCalledWith(H3_ONE.id);
});
it('refreshes the model capability payload after runtime setup completes', async () => {
- await renderPage();
+ await renderVideoGenPage();
await waitFor(() => expect(screen.getByLabelText('Model')).toHaveValue(H3_ONE.id));
const before = state.getVideoGenStatus.mock.calls.length;
diff --git a/client/src/pages/VideoGen.textEncoderAutoDownload.test.jsx b/client/src/pages/VideoGen.textEncoderAutoDownload.test.jsx
index bb09e6e013..b22a213f35 100644
--- a/client/src/pages/VideoGen.textEncoderAutoDownload.test.jsx
+++ b/client/src/pages/VideoGen.textEncoderAutoDownload.test.jsx
@@ -8,169 +8,47 @@
* snap-to-stock on a model change) never does — those all reach the same
* setTextEncoderId, and a ~57 GB pull must follow a click, not a restore.
*/
-import { beforeEach, describe, expect, it, vi } from 'vitest';
-import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
-import { MemoryRouter } from 'react-router';
+import { beforeEach, describe, expect, it } from 'vitest';
+import { act, fireEvent, screen, waitFor } from '@testing-library/react';
+
+import {
+ loadVideoGenPage,
+ renderVideoGenPage,
+ resetVideoGenMockState,
+ state,
+ videoGenModel,
+ videoGenStatus,
+ videoGenTermsGate,
+} from '../test/videoGenPageMocks.jsx';
const TERMS_ID = 'minimax-h3-license-v1';
-const MODEL = {
- id: 'h3-one',
- name: 'MiniMax h3-one',
- repo: 'example-org/h3-one',
- revision: '1111111111111111111111111111111111111111',
- runtime: 'minimax_h3',
- supportedModes: ['text'],
- defaultFrames: 124,
- frameOptions: [124, 141],
- fpsOptions: [24],
- steps: 8,
- guidance: 0,
- samplerLocked: true,
- supportsNegativePrompt: false,
- supportsTiling: false,
- supportsDisableAudio: false,
+const MODEL = videoGenModel('h3-one', {
textEncoderOptions: [
{ id: 'stock', label: 'Stock', description: 'Ships with the model.', builtIn: true },
{ id: 'huihui-abliterated', label: 'Huihui abliterated', description: 'Abliterated.', builtIn: false, repo: 'example-org/abliterated', sizeBytes: 56962931632 },
],
- termsGate: {
- id: TERMS_ID,
- title: 'Terms for h3-one',
- summary: 'This model is available only in its applicable territory.',
- acknowledgement: `I am eligible and accept ${TERMS_ID}.`,
- licenseUrl: 'https://example.com/license',
- },
-};
-
-const state = vi.hoisted(() => ({
- start: vi.fn(),
- startWhenIdle: vi.fn(),
- downloadStatus: { downloading: false, loading: false, cached: false },
- queued: null,
- history: [],
- activeJob: null,
- generateVideo: vi.fn(),
- attach: vi.fn(),
- eventSourceRef: { current: null },
-}));
-
-vi.mock('../services/api', () => ({
- // The page offers a federated render target (#4348); with no peer opted in
- // as a media provider the picker renders nothing and every local path below
- // is unchanged.
- getInstances: vi.fn(async () => ({ peers: [] })),
- getVideoGenStatus: vi.fn(async () => ({
- connected: true,
- pythonPath: '/opt/example/python3',
- defaultModel: 'h3-one',
- models: [MODEL],
- byovRuntimes: [],
- systemMemoryGb: 128,
- backendDisclosures: [],
- })),
- generateVideo: (...args) => state.generateVideo(...args),
- cancelVideoGen: vi.fn(async () => ({})),
- listVideoHistory: vi.fn(async () => state.history),
- deleteVideoHistoryItem: vi.fn(async () => ({})),
- setVideoHidden: vi.fn(async () => ({})),
- extractLastFrame: vi.fn(async () => ({})),
- upscaleVideo: vi.fn(async () => ({})),
- listImageGallery: vi.fn(async () => []),
- patchSettingsSlice: vi.fn(async () => ({})),
- getActiveVideoJob: vi.fn(async () => ({ activeJob: state.activeJob })),
- getSettings: vi.fn(async () => ({ imageGen: { grok: { enabled: false } } })),
- getVideoGenRuntimeStatus: vi.fn(async () => ({ installed: true, ready: true, current: true })),
- listLorasFull: vi.fn(async () => []),
- getProviders: vi.fn(async () => ({ providers: [] })),
- getVisionModels: vi.fn(async () => ({ models: [] })),
-}));
-
-vi.mock('../hooks/useModelDownloadStatus', () => ({
- TEXT_ENCODER_DOWNLOAD_ID: '__text_encoder__',
- textEncoderDownloadId: (id) => `__text_encoder_option__:${id}`,
- useModelDownloadStatus: () => ({
- extra: {},
- loading: state.downloadStatus.loading,
- statusError: null,
- activeModelId: null,
- progress: null,
- lastError: null,
- downloading: state.downloadStatus.downloading,
- repairing: false,
- getStatus: (id) => (String(id).startsWith('__text_encoder_option__:')
- ? { id: 'huihui-abliterated', repo: 'example-org/abliterated', cached: state.downloadStatus.cached, sizeBytes: 0 }
- : { id: MODEL.id, repo: MODEL.repo, cached: true, sizeBytes: 100 }),
- start: state.start,
- startWhenIdle: state.startWhenIdle,
- queuedModelId: state.queued,
- cancel: vi.fn(),
- repair: vi.fn(),
- refresh: vi.fn(),
- }),
-}));
-
-vi.mock('../hooks/useMediaJobSse', () => ({
- useMediaJobSse: () => ({ attach: state.attach, eventSourceRef: state.eventSourceRef }),
-}));
-vi.mock('../hooks/useMediaCompletionRefresh', () => ({ useMediaCompletionRefresh: vi.fn() }));
-vi.mock('../hooks/useMediaAnnotations', () => ({
- useMediaAnnotations: () => ({ annotations: {}, updateAnnotation: vi.fn(), getCardProps: vi.fn(() => ({})) }),
-}));
-vi.mock('../hooks/usePreviewRoute', () => ({ default: () => [null, vi.fn()] }));
-vi.mock('../components/ui/Toast', () => ({
- default: Object.assign(vi.fn(), { error: vi.fn(), success: vi.fn(), loading: vi.fn() }),
-}));
-
-vi.mock('../components/media/PromptEnhancer', () => ({
- default: ({ disabled }) => (
- Enhance with AI
- ),
-}));
-vi.mock('../components/media/PromptFromMedia', () => ({
- default: ({ disabled }) => (
- Prompt from media
- ),
-}));
-
-vi.mock('../components/Drawer', () => ({ default: () => null }));
-vi.mock('../components/settings/ImageGenTab', () => ({ ImageGenTab: () => null }));
-vi.mock('../components/settings/LocalSetupPanel', () => ({ default: () => null }));
-vi.mock('../components/install/RuntimeInstallModal', () => ({ default: () => null }));
-vi.mock('../components/videoGen/FramePanel', () => ({ default: () => null }));
-vi.mock('../components/videoGen/KeyframePanel', () => ({ default: () => null }));
-vi.mock('../components/videoGen/AudioPanel', () => ({ default: () => null }));
-vi.mock('../components/videoGen/ExtendPanel', () => ({ default: () => null }));
-vi.mock('../components/videoGen/IcLoraPanel', () => ({ default: () => null }));
-vi.mock('../components/videoGen/AdvancedParamsPanel', () => ({ default: () => null }));
-vi.mock('../components/videoGen/RuntimeFingerprint', () => ({ default: () => null }));
-vi.mock('../components/videoGen/VideoGenGallery', () => ({ default: () => null }));
-vi.mock('../components/media/MediaPreview', () => ({ default: () => null }));
-vi.mock('../components/media/StylePresetPicker', () => ({ default: () => null }));
-vi.mock('../components/media/UniverseStylePicker', () => ({ default: () => null }));
-vi.mock('../components/media/MediaJobsQueue', () => ({ default: () => null }));
-vi.mock('../components/imageGen/LoraPicker', () => ({ default: () => null }));
-vi.mock('../components/media/ResolutionField', () => ({ default: () => null }));
+ termsGate: videoGenTermsGate(TERMS_ID),
+});
-const { default: VideoGen } = await import('./VideoGen.jsx');
+await loadVideoGenPage();
const SUBSTITUTE_ID = 'huihui-abliterated';
const DOWNLOAD_ID = `__text_encoder_option__:${SUBSTITUTE_ID}`;
const mountPage = async () => {
- await act(async () => {
- render( );
- });
+ await renderVideoGenPage();
return screen.findByLabelText('Text encoder');
};
describe('VideoGen substitute text-encoder auto-download', () => {
beforeEach(() => {
- state.start.mockReset();
- state.startWhenIdle.mockReset();
- state.queued = null;
- state.downloadStatus = { downloading: false, loading: false, cached: false };
- state.history = [];
- state.activeJob = null;
+ resetVideoGenMockState();
+ state.getVideoGenStatus.mockResolvedValue(videoGenStatus([MODEL]));
+ // The substitute is never resident: what these cases pin down is the
+ // request, and a cached encoder would short-circuit it.
+ state.getModelStatus = (id) => (String(id).startsWith('__text_encoder_option__:')
+ ? { id: SUBSTITUTE_ID, repo: 'example-org/abliterated', cached: false, sizeBytes: 0 }
+ : { id: MODEL.id, repo: MODEL.repo, cached: true, sizeBytes: 100 });
});
it('requests the pull when a substitute is selected', async () => {
@@ -202,7 +80,7 @@ describe('VideoGen substitute text-encoder auto-download', () => {
});
it('surfaces the queued state on the selected substitute', async () => {
- state.queued = DOWNLOAD_ID;
+ state.queuedModelId = DOWNLOAD_ID;
const select = await mountPage();
await act(async () => {
fireEvent.change(select, { target: { value: SUBSTITUTE_ID } });
diff --git a/client/src/pollingConventions.test.js b/client/src/pollingConventions.test.js
new file mode 100644
index 0000000000..e85c5a5a29
--- /dev/null
+++ b/client/src/pollingConventions.test.js
@@ -0,0 +1,146 @@
+// @vitest-environment node
+
+/**
+ * Repo-wide guard: data-fetch polling in a view goes through `useAutoRefetch`.
+ *
+ * `hooks/useAutoRefetch.js` short-circuits its tick while
+ * `document.visibilityState === 'hidden'` and re-fires once on the way back to
+ * visible. A hand-rolled `useEffect` + `setInterval` does neither, so it keeps
+ * hitting the server from a tab nobody is looking at — and PortOS is routinely
+ * left open in a background tab on a second machine over the tailnet. Eleven
+ * such polls had accumulated (#5697), several of which fanned out to `pm2
+ * jlist` or Ollama on every tick.
+ *
+ * The rule is deliberately blunt: **no `setInterval` at all** under
+ * `src/components/` or `src/pages/`, except for an allowlisted file carrying a
+ * one-line reason. A rule that tried to tell "a poll that fetches" from "a
+ * timer that ticks a clock" by looking at the callback body would have to
+ * recognize every API-call spelling in the tree, and would miss the next one.
+ * Banning the primitive outright and making the exceptions explicit is both
+ * simpler and stricter: a genuine local timer costs one allowlist line, and
+ * writing that line is the moment someone asks whether the hook fits.
+ *
+ * `src/hooks/` and `src/lib/` are out of scope — that is where a shared timer
+ * primitive (`useTimeTick`, `useCooldownTick`, the metronome transports, and
+ * `useAutoRefetch` itself) is supposed to live.
+ *
+ * ## What this guard CANNOT see
+ *
+ * It is a source grep, not an AST pass:
+ * - A poll built on `setTimeout` that re-arms itself is invisible.
+ * - A poll moved into a helper module outside the two scanned directories is
+ * invisible (that is also the sanctioned escape hatch, so this is by design).
+ * - `setInterval` written only inside a comment or a string counts.
+ * - It cannot tell an allowlisted file's *sanctioned* interval from a second,
+ * unsanctioned one added to the same file later. Allowlist entries are
+ * therefore meant to stay rare and small.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { existsSync, readFileSync } from 'fs';
+import { join, dirname } from 'path';
+import { fileURLToPath } from 'url';
+import { trackedSourceFiles } from './test/trackedFiles.js';
+
+const CLIENT_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+
+// View code only. A shared hook or lib module is where a timer primitive belongs.
+const SCAN_DIRS = ['src/components/', 'src/pages/'];
+
+/**
+ * `\b` keeps `resetInterval(` / `setIntervalPreset(` out, and requiring the
+ * open paren keeps a bare mention of the identifier out. `window.setInterval(`
+ * is intentionally still a match — it is the same timer.
+ */
+const TIMER_CALL = /\bsetInterval\s*\(/;
+
+/**
+ * Files allowed to schedule an interval directly, each with the reason it is
+ * not a data-fetch poll. Adding a row is the point at which to ask whether
+ * `useAutoRefetch` fits instead — a poll that talks to the server does not
+ * belong here.
+ */
+const ALLOWED = {
+ 'src/components/BrailleSpinner.jsx': 'advances the spinner glyph; no I/O',
+ 'src/components/calendar/DayView.jsx': 'moves the "now" line down the day grid; no I/O',
+ 'src/components/cos/tabs/AgentCard.jsx': 'elapsed-time clock tick (the card polls stats via useAutoRefetch)',
+ 'src/components/meatspace/post/PostDrillRunner.jsx': 'drill countdown timer; no I/O',
+ 'src/components/meatspace/post/PostLlmDrillRunner.jsx': 'drill countdown timer; no I/O',
+ 'src/components/meatspace/post/WordplayDrillUI.jsx': 'elapsed-time clock tick; no I/O',
+ 'src/components/music/MusicGenPanel.jsx': 'elapsed-time clock for a running generation (the job itself polls via useAutoRefetch)',
+ 'src/components/sprites/LoopTrimmer.jsx': 'advances the sprite playback frame; no I/O',
+ 'src/components/sprites/WalkWorkflow.jsx': 'counts ticks to self-cancel a stale-queued attach after ~60s — useAutoRefetch does not model a bounded poll',
+ 'src/components/voice/VoiceWidget.jsx': 'samples the in-memory VAD RMS level every 100ms; no I/O',
+ 'src/components/writers-room/ExercisePanel.jsx': 'elapsed-time clock tick; no I/O',
+ 'src/components/writers-room/WorkEditor.jsx': 'elapsed-time clock for the analysis-run banner; no I/O',
+ 'src/pages/Ambient.jsx': 'wall clock; no I/O',
+ 'src/pages/ThreejsModelDetail.jsx': 'bounded in-flight poll pool with a per-tick AbortController — useAutoRefetch does not model either',
+};
+
+const scannedFiles = () => trackedSourceFiles(CLIENT_ROOT)
+ .filter((file) => SCAN_DIRS.some((dir) => file.startsWith(dir)));
+
+const schedulesInterval = (file) => TIMER_CALL.test(readFileSync(join(CLIENT_ROOT, file), 'utf8'));
+
+describe('view polling goes through useAutoRefetch', () => {
+ it('has no hand-rolled setInterval outside the allowlist', () => {
+ const files = scannedFiles();
+ // A broken `git ls-files` (wrong cwd, detached checkout) would otherwise
+ // make this guard pass by scanning nothing at all.
+ expect(files.length).toBeGreaterThan(100);
+
+ const violations = files.filter((file) => !(file in ALLOWED) && schedulesInterval(file));
+
+ expect(
+ violations,
+ 'These view files schedule a raw `setInterval`. If it fetches data, it keeps '
+ + 'hitting the server from a hidden background tab — the exact load #5697 removed.\n'
+ + 'Fix: `useAutoRefetch(fetchFn, intervalMs, { enabled, pollOnly: true })` from '
+ + '`client/src/hooks/useAutoRefetch.js` — it pauses while the tab is hidden and '
+ + 're-fires once on the way back. Keep the site\'s existing gate as `enabled` '
+ + 'rather than an early return, and pass `immediate: false` when another effect '
+ + 'already owns the first fetch.\n'
+ + 'If it is genuinely a local timer (a clock tick, an animation frame advance, a '
+ + 'bounded self-cancelling poll), add it to ALLOWED in this file with a one-line '
+ + 'reason.\n'
+ + `Offenders:\n ${violations.join('\n ')}`,
+ ).toEqual([]);
+ });
+
+ // Burn-down: an allowlist entry that no longer needs to be there must go, or
+ // the list quietly becomes a list of files nobody has looked at in a year.
+ it('has no stale allowlist entry', () => {
+ const missing = Object.keys(ALLOWED).filter((file) => !existsSync(join(CLIENT_ROOT, file)));
+ expect(missing, `Allowlisted files that no longer exist:\n ${missing.join('\n ')}`).toEqual([]);
+
+ const noLongerNeeded = Object.keys(ALLOWED).filter((file) => !schedulesInterval(file));
+ expect(
+ noLongerNeeded,
+ 'These files are allowlisted but no longer call `setInterval`. Delete their rows '
+ + `from ALLOWED.\n ${noLongerNeeded.join('\n ')}`,
+ ).toEqual([]);
+ });
+
+ it('allowlists only files inside the scanned directories', () => {
+ const outside = Object.keys(ALLOWED).filter((file) => !SCAN_DIRS.some((dir) => file.startsWith(dir)));
+ expect(
+ outside,
+ `These rows can never be reached — the scan only covers ${SCAN_DIRS.join(' and ')}.\n ${outside.join('\n ')}`,
+ ).toEqual([]);
+ });
+
+ // Guards the guard: a detector that stopped recognizing the banned call would
+ // make the scan above vacuously green and let the bug class walk back in.
+ it('recognizes a scheduled interval and nothing that merely looks like one', () => {
+ expect(TIMER_CALL.test('const t = setInterval(refresh, 5000);')).toBe(true);
+ expect(TIMER_CALL.test('const t = setInterval (refresh, 5000);')).toBe(true);
+ expect(TIMER_CALL.test('const t = window.setInterval(refresh, 5000);')).toBe(true);
+
+ expect(TIMER_CALL.test('clearInterval(timer);')).toBe(false);
+ expect(TIMER_CALL.test('resetInterval(timer);')).toBe(false);
+ // A useState setter named for the value it sets, not the timer API.
+ expect(TIMER_CALL.test('setIntervalPreset(p.value);')).toBe(false);
+ // The sanctioned replacement must not read as a violation.
+ expect(TIMER_CALL.test("useAutoRefetch(load, 5000, { pollOnly: true });")).toBe(false);
+ });
+});
diff --git a/client/src/popoverClampConventions.test.js b/client/src/popoverClampConventions.test.js
new file mode 100644
index 0000000000..38d33967a9
--- /dev/null
+++ b/client/src/popoverClampConventions.test.js
@@ -0,0 +1,166 @@
+/**
+ * Repo-wide viewport clamp on fixed-width popovers.
+ *
+ * `Layout`'s root shell is `w-full max-w-full overflow-x-hidden`, so an
+ * absolutely-positioned panel wider than the viewport is CLIPPED, not made
+ * scrollable — the overflowing edge is simply unreachable. A filter panel
+ * declared `w-96` (384px) and anchored `right-3` is therefore wider than a 360px
+ * phone screen, and its whole left column of controls sits permanently off the
+ * left edge with nothing to scroll to (issue #5686).
+ *
+ * The rule: a class string that positions an element `absolute` AND fixes its
+ * width at 256px or more — `w-64` and up on the numeric scale, or the same size
+ * written as an arbitrary `w-[19rem]` / `w-[384px]` — must also carry a clamp
+ * that is *relative to the available width* and applies at the BASE viewport.
+ * The tree's canonical form is `max-w-[calc(100vw-1rem)]`
+ * (`components/pipeline/arcCanvas/VerifyScopeTooltip.jsx`), which keeps a fixed
+ * 8px gutter at every width rather than a proportional one that collapses on
+ * small screens; `max-w-[90vw]`, `max-w-full`, and `max-w-screen` also qualify.
+ *
+ * Both halves of that matter, and a looser "any `max-w-*` token" test would miss
+ * a real bug on each: `max-w-96` / `max-w-none` / `max-w-lg` are *absolute*
+ * ceilings that let the panel overflow a phone exactly as before, and a
+ * variant-prefixed `sm:max-w-[calc(100vw-1rem)]` leaves the narrow viewport —
+ * the only one that clips — entirely unclamped.
+ *
+ * Every token is read UNPREFIXED for the same reason: the base variant is the
+ * phone. A responsive sidebar that reads `absolute w-[80vw] max-w-xs md:static
+ * md:w-64` is correct as written — its fixed width belongs to the desktop
+ * variant, where it is no longer absolute (`brain/tabs/DailyLogTab.jsx`).
+ *
+ * Deliberately NOT flagged:
+ * - `min-w-*` / `max-w-*` tokens, which are not a fixed width (`sm:min-w-80` on
+ * a `left-3 right-3` media overlay is correct as written).
+ * - `fixed` positioning, which escapes the shell's overflow box.
+ * - Widths below 256px, which fit inside the narrowest viewport the app
+ * targets even with an anchor offset and a gutter.
+ *
+ * Scoped to git-tracked non-test sources; comments are masked first so a doc
+ * block quoting an example class string is documentation, not markup.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { readFileSync } from 'fs';
+import { join, dirname } from 'path';
+import { fileURLToPath } from 'url';
+import { trackedSourceFiles } from './test/trackedFiles.js';
+import { lineOf, maskComments, stringLiterals } from './test/classNameScan.js';
+
+const CLIENT_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+
+// Every token below is matched UNPREFIXED, because the base variant is the phone
+// and the phone is the viewport that clips.
+const ABSOLUTE = /^absolute$/;
+
+/**
+ * The narrowest viewport the app targets is 360px; a panel at or above 256px
+ * (`w-64`) is close enough to it that an anchor offset plus a gutter can push it
+ * over. Widths are compared in px so the numeric scale and an arbitrary value
+ * answer to one threshold.
+ */
+const MIN_CLAMPED_PX = 256;
+// `w-64` — the numeric scale, in 0.25rem steps at a 16px root. `min-w-80` and
+// `max-w-96` are not fixed widths: the token doesn't start at `w-`.
+const NUMERIC_WIDTH = /^w-(\d+(?:\.\d+)?)$/;
+// `w-[19rem]`, `w-[384px]` — the same bug written as an arbitrary value.
+const ARBITRARY_WIDTH = /^w-\[(\d+(?:\.\d+)?)(rem|px)\]$/;
+const ARBITRARY_CLAMP = /^max-w-\[(.+)\]$/;
+const RELATIVE_UNIT = /[dsl]?vw|%/;
+const BARE_RELATIVE = /^(\d+(?:\.\d+)?)(?:[dsl]?vw|%)$/;
+
+/** Fixed width in px, or 0 when the token doesn't declare one. */
+function fixedWidthPx(token) {
+ const numeric = NUMERIC_WIDTH.exec(token);
+ if (numeric) return Number(numeric[1]) * 4;
+ const arbitrary = ARBITRARY_WIDTH.exec(token);
+ if (!arbitrary) return 0;
+ return arbitrary[2] === 'rem' ? Number(arbitrary[1]) * 16 : Number(arbitrary[1]);
+}
+
+/**
+ * Whether the token caps the element against the width actually available.
+ * `max-w-96` / `max-w-lg` / `max-w-none` are absolute ceilings and do not — they
+ * leave the panel overflowing a phone exactly as before.
+ */
+function isViewportClamp(token) {
+ if (token === 'max-w-full' || token === 'max-w-screen') return true;
+ const arbitrary = ARBITRARY_CLAMP.exec(token);
+ if (!arbitrary) return false;
+ const value = arbitrary[1];
+ if (!RELATIVE_UNIT.test(value)) return false;
+ // `calc(100vw+10rem)` is relative, but it widens rather than clamps.
+ if (value.includes('+')) return false;
+ // A bare `200vw` / `200%` is over the available width, not a bound on it.
+ const bare = BARE_RELATIVE.exec(value);
+ return !bare || Number(bare[1]) <= 100;
+}
+
+function violationsIn(rawSource, file) {
+ const source = maskComments(rawSource);
+ return stringLiterals(source)
+ .filter(({ value }) => {
+ const tokens = value.split(/\s+/).filter(Boolean);
+ if (!tokens.some((token) => ABSOLUTE.test(token))) return false;
+ if (Math.max(0, ...tokens.map(fixedWidthPx)) < MIN_CLAMPED_PX) return false;
+ return !tokens.some(isViewportClamp);
+ })
+ .map(({ value, index }) => `${file}:${lineOf(source, index)} — "${value.trim()}"`);
+}
+
+const findViolations = (file) =>
+ violationsIn(readFileSync(join(CLIENT_ROOT, file), 'utf8'), file);
+
+describe('popover viewport-clamp conventions', () => {
+ const files = trackedSourceFiles(CLIENT_ROOT);
+
+ it('scans a populated client tree', () => {
+ expect(files.length).toBeGreaterThan(100);
+ });
+
+ // Without this the suite would still pass if the detector silently stopped
+ // matching anything — a green tree-wide guard proves nothing on its own.
+ it('flags an unclamped fixed-width popover and clears every safe form', () => {
+ const flagged = (markup) => violationsIn(`
`, 'probe.jsx').length;
+ expect(flagged('absolute top-12 right-3 z-20 p-4 w-96 shadow-xl')).toBe(1);
+ expect(flagged('absolute right-0 mt-1 w-72 rounded-lg')).toBe(1);
+ expect(flagged('absolute right-0 top-full w-64 max-h-80 overflow-y-auto')).toBe(1);
+ // A max-height is not a width clamp.
+ expect(flagged('absolute w-96 max-h-dvh-cap [--dvh-cap:80dvh]')).toBe(1);
+ // The clamp, in either of the tree's two forms, plus the keyword equivalents.
+ expect(flagged('absolute right-3 w-96 max-w-[calc(100vw-1rem)] p-4')).toBe(0);
+ expect(flagged('absolute w-80 max-w-[90vw] p-3')).toBe(0);
+ expect(flagged('absolute w-96 max-w-full')).toBe(0);
+ expect(flagged('absolute w-96 max-w-[calc(100%-1rem)]')).toBe(0);
+ // An absolute ceiling is not a clamp — it still overflows a 360px phone.
+ expect(flagged('absolute w-96 max-w-none')).toBe(1);
+ expect(flagged('absolute w-96 max-w-96')).toBe(1);
+ expect(flagged('absolute w-96 max-w-lg')).toBe(1);
+ expect(flagged('absolute w-96 max-w-screen-sm')).toBe(1);
+ // A variant-gated clamp leaves the narrow viewport — the one that clips — bare.
+ expect(flagged('absolute w-96 sm:max-w-[calc(100vw-1rem)]')).toBe(1);
+ // `min-w-*` is not a fixed width, at any variant prefix.
+ expect(flagged('absolute bottom-3 left-3 right-3 sm:right-auto sm:min-w-80')).toBe(0);
+ expect(flagged('absolute min-w-96 w-full')).toBe(0);
+ // A fixed width that belongs to a variant where the element is no longer
+ // absolute is not the bug; the base viewport already clamps.
+ expect(flagged('absolute md:static inset-y-0 left-0 w-[80vw] max-w-xs md:w-64')).toBe(0);
+ // Narrow panels fit a 360px phone unclamped.
+ expect(flagged('absolute right-0 w-56 rounded-lg')).toBe(0);
+ // The same bug written as an arbitrary value.
+ expect(flagged('absolute top-20 right-4 bottom-24 w-[19rem]')).toBe(1);
+ expect(flagged('absolute w-[384px] rounded-lg')).toBe(1);
+ expect(flagged('absolute w-[19rem] max-w-[calc(100vw-1rem)]')).toBe(0);
+ expect(flagged('absolute w-[12rem] rounded-lg')).toBe(0);
+ // Relative units that are not actually a bound.
+ expect(flagged('absolute w-96 max-w-[200%]')).toBe(1);
+ expect(flagged('absolute w-96 max-w-[calc(100vw+10rem)]')).toBe(1);
+ // `fixed` escapes the shell's overflow box; it is positioned to the viewport.
+ expect(flagged('fixed bottom-4 right-4 w-96 rounded-lg')).toBe(0);
+ // A doc comment quoting an example class string is not markup.
+ expect(violationsIn('// e.g. "absolute right-0 w-96"', 'probe.jsx')).toEqual([]);
+ });
+
+ it('never fixes a popover wider than a phone without a max-width', () => {
+ expect(files.flatMap((file) => findViolations(file))).toEqual([]);
+ });
+});
diff --git a/client/src/preWrapClasses.test.js b/client/src/preWrapClasses.test.js
new file mode 100644
index 0000000000..7fd116ff5a
--- /dev/null
+++ b/client/src/preWrapClasses.test.js
@@ -0,0 +1,157 @@
+/**
+ * Repo-wide: a wrapping `` also has to break unbroken tokens.
+ *
+ * `Layout`'s root shell is `w-full max-w-full overflow-x-hidden`, so a child
+ * wider than the viewport is CLIPPED rather than made scrollable — there is no
+ * scrollbar to recover the overflowing edge. `whitespace-pre-wrap` only wraps at
+ * *whitespace*, so a `` carrying it alone still runs off the clip edge the
+ * moment its content holds one unbroken token: an absolute path, a URL, a
+ * base64 blob, a minified JSON line, a stack frame (issue #5820, following the
+ * two no-wrap-class blocks fixed in #5675).
+ *
+ * The rule: a `` opener whose class tokens include `whitespace-pre-wrap`
+ * must also carry a break class. Which one depends on what the block renders,
+ * and the split is deliberate:
+ *
+ * - **Machine output** — logs, command output, JSON dumps, stack traces, raw
+ * model transcripts, API payloads — takes `break-all`, matching
+ * `components/ui/ProcessLogLines.jsx`, the canonical log renderer. A 400-char
+ * token there has no natural break point, and mid-token breaking is the only
+ * thing that keeps its tail on screen.
+ * - **Human-authored prose** — prompt templates, treatments, bios, user
+ * stories, wiki bodies — takes `break-words`, which breaks a word only when
+ * it cannot fit on a line of its own. Breaking mid-word on every line is
+ * worse to read than the rare long token in prose.
+ *
+ * `components/cos/JobCard.jsx` shows both in one component: `job.lastOutput` is
+ * `break-all`, `job.promptTemplate` is `break-words`.
+ *
+ * Deliberately NOT the fix, and so not accepted by this guard:
+ * - Relaxing `Layout`'s `overflow-x-hidden` — it is what stops one wide child
+ * from giving the whole app a horizontal scrollbar. The fix belongs in the leaf.
+ * - `overflow-x-auto` on the block — a horizontal scroller inside a card is
+ * worse on touch, where a wrapped block keeps every character reachable at 360px.
+ *
+ * Deliberately out of scope: a `` with no wrap class at all (it is a
+ * horizontal scroller by default, which a few blocks legitimately want) and
+ * `` spans.
+ *
+ * The opener is read as a whole tag rather than a line, because a JSX ``
+ * routinely splits its attributes across lines — a line-scoped grep silently
+ * passes over exactly those blocks. Every class token in the opener is pooled,
+ * across a conditional's branches too, so a block that supplies its break class
+ * from only one branch reads as covered; branch-precise checking would need the
+ * component actually rendered, and the regression this catches is the far more
+ * common one of a block with no break class on any path. Comments are masked
+ * first so this doc block quoting an example class string is documentation, not
+ * markup.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { readFileSync } from 'fs';
+import { join, dirname } from 'path';
+import { fileURLToPath } from 'url';
+import { trackedSourceFiles } from './test/trackedFiles.js';
+import { lineOf, maskComments, stringLiterals } from './test/classNameScan.js';
+
+const CLIENT_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+
+// ``, `/`, or `{` — so `` and a
+// ``-free identifier like `preview` are not openers.
+const PRE_OPENER = / {])/g;
+const WRAP = 'whitespace-pre-wrap';
+// `break-all` breaks anywhere; `break-words` (and Tailwind v4's `wrap-anywhere`
+// / `break-anywhere`) break only when the word cannot fit alone. All three keep
+// the tail of a long token on screen, which is the property under test.
+const BREAKS = new Set(['break-all', 'break-words', 'break-anywhere', 'wrap-anywhere']);
+
+/**
+ * The full opening tag starting at `start`, quotes and `{…}` expressions
+ * balanced, so a `>` inside `className={cond ? 'a>b' : ''}` doesn't end it early.
+ */
+function openingTag(source, start) {
+ let i = start;
+ let depth = 0;
+ while (i < source.length) {
+ const ch = source[i];
+ if (ch === '"' || ch === "'" || ch === '`') {
+ const quote = ch;
+ i += 1;
+ while (i < source.length && source[i] !== quote) i += source[i] === '\\' ? 2 : 1;
+ i += 1;
+ continue;
+ }
+ if (ch === '{') depth += 1;
+ else if (ch === '}') depth -= 1;
+ else if (ch === '>' && depth === 0) return source.slice(start, i + 1);
+ i += 1;
+ }
+ return source.slice(start);
+}
+
+// Splits on everything a Tailwind class cannot contain — whitespace, but also
+// the quotes, braces and `$` of a template literal — so a class supplied through
+// an interpolated branch (`` `whitespace-pre-wrap ${dense ? "break-all" : ""}` ``)
+// is read as the token it renders to rather than as `"break-all"`.
+const classTokens = (tag) =>
+ stringLiterals(tag)
+ .flatMap(({ value }) => value.split(/[^A-Za-z0-9_:/[\].%-]+/))
+ .filter(Boolean);
+
+function violationsIn(rawSource, file) {
+ const source = maskComments(rawSource);
+ const found = [];
+ PRE_OPENER.lastIndex = 0;
+ let match;
+ while ((match = PRE_OPENER.exec(source))) {
+ const tag = openingTag(source, match.index);
+ const tokens = classTokens(tag);
+ if (!tokens.includes(WRAP)) continue;
+ if (tokens.some((token) => BREAKS.has(token))) continue;
+ found.push(`${file}:${lineOf(source, match.index)}`);
+ }
+ return found;
+}
+
+const findViolations = (file) =>
+ violationsIn(readFileSync(join(CLIENT_ROOT, file), 'utf8'), file);
+
+describe(' wrap/break class conventions', () => {
+ const files = trackedSourceFiles(CLIENT_ROOT);
+
+ it('scans a populated client tree', () => {
+ expect(files.length).toBeGreaterThan(100);
+ });
+
+ // Without this the suite would still pass if the detector silently stopped
+ // matching anything — a green tree-wide guard proves nothing on its own.
+ it('flags a wrapping with no break class and clears every safe form', () => {
+ const flagged = (markup) => violationsIn(markup, 'probe.jsx').length;
+ expect(flagged('{log} ')).toBe(1);
+ expect(flagged('{log} ')).toBe(0);
+ expect(flagged('{prose} ')).toBe(0);
+ // A `` with no wrap class is a horizontal scroller by design.
+ expect(flagged('{log} ')).toBe(0);
+ expect(flagged('{log} ')).toBe(0);
+ // The opener routinely spans lines; a line-scoped scan would miss this one.
+ expect(flagged('\n {log}\n ')).toBe(1);
+ expect(flagged('\n {log}\n ')).toBe(0);
+ // Both halves of a composed class string count as one token set.
+ expect(flagged('{x} ')).toBe(0);
+ expect(flagged('{x} ')).toBe(1);
+ // A `>` inside the class expression does not end the opening tag early — if
+ // it did, the scan would see no class tokens at all and report nothing.
+ expect(flagged(' 2 ? "whitespace-pre-wrap" : "text-xs"}>{x} ')).toBe(1);
+ // Neither `overflow-x-auto` nor a max-height substitutes for a break class:
+ // the shell clips rather than scrolls, so the overflow is unreachable.
+ expect(flagged('{log} ')).toBe(1);
+ // A sibling tag whose name merely starts with "pre" is not a .
+ expect(flagged(' ')).toBe(0);
+ // A doc comment quoting an example opener is not markup.
+ expect(violationsIn('// e.g. ', 'probe.jsx')).toEqual([]);
+ });
+
+ it('never leaves a wrapping able to overflow the clipped shell', () => {
+ expect(files.flatMap((file) => findViolations(file))).toEqual([]);
+ });
+});
diff --git a/client/src/responsiveGridConventions.test.js b/client/src/responsiveGridConventions.test.js
index 7ef6b0fe09..282bd5157a 100644
--- a/client/src/responsiveGridConventions.test.js
+++ b/client/src/responsiveGridConventions.test.js
@@ -34,6 +34,7 @@ import { readFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { trackedSourceFiles } from './test/trackedFiles.js';
+import { lineOf, maskComments, stringLiterals } from './test/classNameScan.js';
const CLIENT_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
@@ -57,51 +58,6 @@ const ALLOWED = new Map([
],
]);
-/** Blank out `//` and block comments so a quoted example class isn't scanned as markup. */
-function maskComments(source) {
- let out = '';
- let i = 0;
- while (i < source.length) {
- const ch = source[i];
- if (ch === '/' && source[i + 1] === '/') {
- while (i < source.length && source[i] !== '\n') {
- out += ' ';
- i += 1;
- }
- continue;
- }
- if (ch === '/' && source[i + 1] === '*') {
- while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) {
- out += source[i] === '\n' ? '\n' : ' ';
- i += 1;
- }
- out += ' ';
- i += 2;
- continue;
- }
- if (ch === '"' || ch === "'" || ch === '`') {
- out += ch;
- i += 1;
- while (i < source.length && source[i] !== ch) {
- if (source[i] === '\\') {
- out += source.slice(i, i + 2);
- i += 2;
- continue;
- }
- out += source[i];
- i += 1;
- }
- out += source[i] ?? '';
- i += 1;
- continue;
- }
- out += ch;
- i += 1;
- }
- return out;
-}
-
-const STRING_LITERAL = /(['"`])((?:\\.|(?!\1)[\s\S])*?)\1/g;
// A column count that is the phone default: no `variant:` in front of it. Global,
// because a single class string can hold more than one bare token and the widest
// one is the one that decides whether the layout is legible.
@@ -118,22 +74,11 @@ function widestBareColumnCount(value) {
return widest;
}
-function lineOf(source, index) {
- return source.slice(0, index).split('\n').length;
-}
-
function violationsIn(rawSource, file) {
const source = maskComments(rawSource);
- const found = [];
- let match;
- STRING_LITERAL.lastIndex = 0;
- while ((match = STRING_LITERAL.exec(source))) {
- const value = match[2];
- if (widestBareColumnCount(value) < MIN_WIDE_COLUMNS) continue;
- if (PREFIXED_GRID.test(value)) continue;
- found.push(`${file}:${lineOf(source, match.index)} — "${value.trim()}"`);
- }
- return found;
+ return stringLiterals(source)
+ .filter(({ value }) => widestBareColumnCount(value) >= MIN_WIDE_COLUMNS && !PREFIXED_GRID.test(value))
+ .map(({ value, index }) => `${file}:${lineOf(source, index)} — "${value.trim()}"`);
}
const findViolations = (file) =>
diff --git a/client/src/services/README.md b/client/src/services/README.md
index 9dc448f9fa..c8a3c87075 100644
--- a/client/src/services/README.md
+++ b/client/src/services/README.md
@@ -51,6 +51,7 @@ toasts on throw). **Custom catch ⇒ `silent: true`** — otherwise toasts fire
| `apiHistory.js` | Historical logs / runs. |
| `apiLogs.js` | PM2 system logs: fetch a process's recent log tail (process list comes from `apiCommands.getProcessesList`). |
| `apiPorts.js` | Port scan/detect wrappers (no current UI callers; module kept for the catalog). |
+| `apiHarnesses.js` | Coding-agent harness (CLI/TUI) inventory for Models → Harnesses: installed vs latest version, the providers riding on each, and the model-catalog refresh. Install/update/remove is an SSE stream driven by `RuntimeInstallModal`, not a call here. |
| `apiProviders.js` | AI provider configuration, plus provider-runtime (CLI) install readiness for the per-card Install buttons, and the Codex / ChatGPT-subscription account calls (`getCodexAccount`, `startCodexLogin`, `cancelCodexLogin`, `codexLogout`) — sign-in STATE only, never a token. |
| `apiPrompts.js` | Prompt Manager: stage templates, variables, and job-skill templates (providers list reuses `apiProviders.getProviders`). |
| `apiReferenceRepos.js` | Per-app reference-repo registry. |
@@ -63,7 +64,7 @@ toasts on throw). **Custom catch ⇒ `silent: true`** — otherwise toasts fire
| `apiSchedules.js` | Automation schedules. |
| `apiQuotaBurn.js` | Quota Burn plan + live status, the job-type catalog its config form renders, and manual runs (`getQuotaBurn`/`getQuotaBurnCatalog`/`saveQuotaBurn`/`runQuotaBurn`), plus `rearmQuotaBurn` to put spent `run once` steps back into the rotation. |
| `apiRapidReader.js` | Rapid Reader's optional author-hosted Accelerando loader and machine-local shelf API. |
-| `apiSystem.js` | System info (CPU/memory/ports/alerts/active processing and local hardware capabilities) + D&D-style character sheet getter, plus the usage cost report and explicit historical reconciliation (`getUsage`, `getProviderUsage`, `getUsageBackfillStatus`/`startUsageBackfill`, `updateSubscriptionCosts` for the subscription-vs-API savings comparison, `updateUsageFleetBilling` to exclude an API-billed federated instance from Across Instances totals). |
+| `apiSystem.js` | System info (CPU/memory/ports/alerts/active processing and local hardware capabilities) + D&D-style character sheet getter, plus the usage cost report and explicit historical reconciliation (`getUsage`, `getProviderUsage`, `getUsageBackfillStatus`/`startUsageBackfill`, `updateSubscriptionCosts` for the subscription-vs-API savings comparison, `updateUsageFleetBilling` to exclude an API-billed federated instance from Across Instances totals). Also `getCredentialInventory` (`GET /settings/credentials`) — presence and source of each PortOS credential, never a value. |
| `apiAuth.js` | Optional login password — status, login, set/clear password. |
| `apiLoops.js` | Scheduled loops. |
@@ -109,7 +110,7 @@ toasts on throw). **Custom catch ⇒ `silent: true`** — otherwise toasts fire
| `apiSprites.js` | Sprite Manager records, asset library, production-set import (#2895), reference workflow: create/generate/lock (#2896), directional walk and per-track generation/approval, animation-type definition CRUD (#3153), trim/postprocess, and per-run source-frame listing for the Loop Trimmer's re-derive (#2980), and animation render-provider readiness (#4876). |
| `apiShell.js` | Shell sessions over HTTP: hand a photo (plus a message) to the agent TUI running in a session. Keystrokes/output stay on the `shell:*` socket protocol. |
| `apiThreejsModels.js` | Procedural Three.js model workspaces: gallery-image generation, refinement, source export, deletion, and the subject-family checklist options. |
-| `apiImageTo3d.js` | Image-to-3D (`/3d`): selectable targets (TRELLIS.2) with host availability/install status, and per-image model records — create/list/get/generate/delete + GLB asset URL and the full-resolution OBJ download URL. |
+| `apiImageTo3d.js` | Image-to-3D (`/3d`): selectable targets (TRELLIS.2) with host availability/install status, and per-image model records — create/list/get/generate/delete + GLB asset URL, the full-resolution OBJ download URL, and the AR Quick Look USDZ upload/download pair. |
| `apiPipeline.js` | Pipeline (issues + stages + canon). |
| `apiUniverseBuilder.js` | Universe Builder (generate + edit + commit). |
| `apiAuthors.js` | Author personas (name, writing style, bio, headshot description/style). |
@@ -136,7 +137,6 @@ toasts on throw). **Custom catch ⇒ `silent: true`** — otherwise toasts fire
| `apiOpenClaw.js` | File browser / picker backend. |
| `apiPalette.js` | Command-palette manifest + action dispatch. |
| `apiVoice.js` | Voice synthesis / processing. |
-| `apiOpenWorld.js` | OpenWorld snapshots — historical world-state series for the timeline scrubber (GET /snapshots) plus introspection. |
| `apiPrivacy.js` | Privacy Center — encrypted PII Vault + Trusted Organizations registry (status, vault CRUD + reveal, org CRUD, org holdings replace-set) + Digital Twin social-account cross-link + household subjects (subject CRUD, consent audit, and a `subjectId` scope passed in each wrapper's trailing `options`). |
## Browser-facing (DOM, voice, build) — not pure API wrappers
@@ -150,3 +150,4 @@ toasts on throw). **Custom catch ⇒ `silent: true`** — otherwise toasts fire
| `uiInteract.js` | Execute voice `ui_click` / `ui_fill` / `ui_select` against live DOM. |
| `domIndex.js` | DOM indexer for voice accessibility mode. |
| `staleBuildToast.jsx` | Sticky toast shown when server's build id differs from client's. |
+| `apiRigging.js` | Character rigging. `getRiggingReadiness()` (`GET /rigging/readiness`): whether this install's Blender runtime is provisioned, the resolved interpreter, the module version, the install command when it is not, and the auto-skin threshold defaults. `rigImageTo3dModel(id, body)` (`POST /rigging/models/:id`): auto-skin a rendered mesh behind the measured weight-coverage gate, resolving with the updated model record. |
diff --git a/client/src/services/api.js b/client/src/services/api.js
index efefc3a7f5..aa8c274e52 100644
--- a/client/src/services/api.js
+++ b/client/src/services/api.js
@@ -9,6 +9,7 @@ export * from './apiWorkspaceContexts.js';
export * from './apiReferenceRepos.js';
export * from './apiPorts.js';
export * from './apiScaffold.js';
+export * from './apiHarnesses.js';
export * from './apiProviders.js';
export * from './apiPrompts.js';
export * from './apiRuns.js';
@@ -84,10 +85,10 @@ export * from './apiImporter.js';
export * from './apiStoryBuilder.js';
export * from './apiVoice.js';
export * from './apiAuth.js';
-export * from './apiOpenWorld.js';
export * from './apiPrivacy.js';
export * from './apiQuotaBurn.js';
export * from './apiRapidReader.js';
+export * from './apiRigging.js';
// Default export for simplified imports (get/post/put/delete helpers)
export { default } from './apiCore.js';
diff --git a/client/src/services/apiAgents.js b/client/src/services/apiAgents.js
index fdd0f329a9..f2d62f247a 100644
--- a/client/src/services/apiAgents.js
+++ b/client/src/services/apiAgents.js
@@ -1,7 +1,7 @@
import { request } from './apiCore.js';
// Running Agents (Process Management)
-export const getRunningAgents = (options) => request('/agents', options);
+const getRunningAgents = (options) => request('/agents', options);
const killRunningAgent = (pid) => request(`/agents/${pid}`, { method: 'DELETE' });
// Legacy aliases
export const getAgents = getRunningAgents;
@@ -293,11 +293,6 @@ export const triggerCosOnDemandTask = (taskType, appId = null, options = {}) =>
body: JSON.stringify({ taskType, appId }),
...options
});
-export const resetCosTaskHistory = (taskType, appId = null, options = {}) => request('/cos/schedule/reset', {
- method: 'POST',
- body: JSON.stringify({ taskType, appId }),
- ...options
-});
// Autonomous Jobs
export const getCosJobs = (options = {}) => request('/cos/jobs', options);
diff --git a/client/src/services/apiCodeReview.js b/client/src/services/apiCodeReview.js
index 22d8cd58bf..f72953780a 100644
--- a/client/src/services/apiCodeReview.js
+++ b/client/src/services/apiCodeReview.js
@@ -2,6 +2,6 @@ import { request } from './apiCore.js';
// Code Review Defaults — the global default reviewer chain + per-backend
// model the Review Loop seeds from when a task/task-type doesn't pin its own.
-// Surfaced on the Settings → Code Reviewers page; persisted under
+// Surfaced on the Models → Code Reviewers page; persisted under
// `settings.codeReview` via PUT /api/settings.
export const getCodeReviewDefaults = (options) => request('/code-review/defaults', options);
diff --git a/client/src/services/apiHarnesses.js b/client/src/services/apiHarnesses.js
new file mode 100644
index 0000000000..4b3b792cff
--- /dev/null
+++ b/client/src/services/apiHarnesses.js
@@ -0,0 +1,31 @@
+/**
+ * Harnesses — the coding-agent CLIs/TUIs PortOS drives (Models → Harnesses).
+ *
+ * Read-side only. The install / update / remove stream is SSE and goes through
+ * the shared `RuntimeInstallModal`, which owns its own fetch-stream reader —
+ * pointing it at `/api/harnesses/action` with `runtime` and `action` in the
+ * query string, the same shape every BYO-runtime installer already uses.
+ */
+
+import { request } from './apiCore.js';
+
+/**
+ * Every harness with its installed version, the latest published version, and
+ * the provider records riding on it.
+ *
+ * `fresh` bypasses both the runtime-status TTL and the npm-registry cache —
+ * what the page's Refresh button sends, and what it re-reads after an action so
+ * a just-installed version shows without waiting a cache out.
+ */
+export const getHarnesses = ({ fresh = false, ...options } = {}) =>
+ request(`/harnesses${fresh ? '?fresh=1' : ''}`, options);
+
+/**
+ * Re-read one harness's own model catalog and write it to every provider that
+ * draws from it. Resolves `{ models, updated }`; rejects with the server's
+ * reason when the harness cannot list models or is signed out.
+ */
+export const refreshHarnessModels = (id, options) => request(
+ `/harnesses/models/refresh?runtime=${encodeURIComponent(id)}`,
+ { method: 'POST', ...options },
+);
diff --git a/client/src/services/apiImageTo3d.js b/client/src/services/apiImageTo3d.js
index 4a1d6d9503..890b30da85 100644
--- a/client/src/services/apiImageTo3d.js
+++ b/client/src/services/apiImageTo3d.js
@@ -21,9 +21,10 @@ export const createImageTo3dModel = (input, options) =>
});
// Re-run the render for an existing record (status → generating again).
-// `input` carries the optional per-run knobs ({ steps, seed, keyBackground }) —
-// they apply to this run only: absent steps → the pipeline default, absent
-// seed → the server rolls a fresh random one, absent keyBackground → no keying.
+// `input` carries the optional per-run knobs ({ steps, seed, keyBackground,
+// subjectScale, … }) — they apply to this run only: absent steps → the pipeline
+// default, absent seed → the server rolls a fresh random one, absent keyBackground →
+// no keying, absent subjectScale → the source keeps its own framing.
export const generateImageTo3dModel = (id, input = {}, options) =>
request(`/image-to-3d/models/${encodeURIComponent(id)}/generate`, {
method: 'POST',
@@ -50,3 +51,22 @@ export const imageTo3dAssetUrl = (id) =>
// 404 — not a sign the record is broken). The GLB stays the thing the viewer loads.
export const imageTo3dFullMeshUrl = (id) =>
`/api/image-to-3d/models/${encodeURIComponent(id)}/full-mesh`;
+
+// The stored AR Quick Look artifact, served `inline` as `model/vnd.usdz+zip` —
+// the exact header pair Safari requires before it will open an ``
+// target in AR. Deliberately NOT the record's static `usdzPath`: that mount
+// leaves the content type to mime lookup, and this contract is the feature.
+export const imageTo3dUsdzUrl = (id) =>
+ `/api/image-to-3d/models/${encodeURIComponent(id)}/usdz`;
+
+// Persist a USDZ the VIEWER produced (three's USDZExporter over the scene it has
+// already decoded — PortOS ships no USD toolchain). Raw bytes, not JSON: the
+// explicit content type overrides apiCore's `application/json` default so the
+// server's `express.raw` parser claims the body.
+export const uploadImageTo3dUsdz = (id, bytes, options) =>
+ request(`/image-to-3d/models/${encodeURIComponent(id)}/usdz`, {
+ method: 'POST',
+ body: bytes,
+ headers: { 'Content-Type': 'model/vnd.usdz+zip' },
+ ...options,
+ });
diff --git a/client/src/services/apiOpenWorld.js b/client/src/services/apiOpenWorld.js
deleted file mode 100644
index 0128a1596f..0000000000
--- a/client/src/services/apiOpenWorld.js
+++ /dev/null
@@ -1,20 +0,0 @@
-import { request } from './apiCore.js';
-
-// OpenWorld snapshots — historical world-state series for the timeline scrubber.
-// The capture pipeline (issue #877) records frames server-side; these read them.
-
-// GET /api/openworld/snapshots — the recorded series, oldest-first.
-// options: { since?: ISO string, limit?: number, silent?: boolean }
-export const getOpenWorldSnapshots = (options = {}) => {
- const { since, limit, ...rest } = options;
- const params = new URLSearchParams();
- if (since) params.set('since', since);
- if (limit != null) params.set('limit', limit);
- const qs = params.toString();
- return request(`/openworld/snapshots${qs ? `?${qs}` : ''}`, rest);
-};
-
-// GET /api/openworld/introspection — DB tables + data/ domain sizes for the Data
-// Harbor district. Server-cached; `db: null` means the database is unreachable.
-export const getOpenWorldIntrospection = (options = {}) =>
- request('/openworld/introspection', options);
diff --git a/client/src/services/apiRigging.js b/client/src/services/apiRigging.js
new file mode 100644
index 0000000000..d55c95b35e
--- /dev/null
+++ b/client/src/services/apiRigging.js
@@ -0,0 +1,20 @@
+import { request } from './apiCore.js';
+
+// Character rigging: the read-only readiness answer for this install's Blender
+// runtime. `refresh` forces a re-probe past the server's short-lived memo — use it
+// only for an explicit recheck, never on mount.
+
+export const getRiggingReadiness = ({ refresh = false, ...options } = {}) =>
+ request(`/rigging/readiness${refresh ? '?refresh=1' : ''}`, options);
+
+// Auto-skin a rendered image-to-3D mesh. Runs INLINE (minutes of local Blender CPU) and
+// resolves with the updated model record, so a refusal arrives as an error carrying the
+// measured sentence — "automatic weighting left 4.2% of vertices unweighted, ceiling is
+// 0.5%" — rather than a generic failure the user cannot act on. `input` may carry the
+// advanced overrides (`skeletonHint`, `weldDistance`, `unweightedCeiling`).
+export const rigImageTo3dModel = (id, input = {}, options) =>
+ request(`/rigging/models/${encodeURIComponent(id)}`, {
+ method: 'POST',
+ body: JSON.stringify(input),
+ ...options,
+ });
diff --git a/client/src/services/apiSystem.js b/client/src/services/apiSystem.js
index b94967a733..d870ba918e 100644
--- a/client/src/services/apiSystem.js
+++ b/client/src/services/apiSystem.js
@@ -4,24 +4,6 @@ import { downloadBlob } from '../lib/downloadBlob.js';
// Alerts
export const getAlertsSummary = (options) => request('/alerts/summary', options);
-// Character sheet (age-based level / XP / HP / usage-derived skills + metrics grid).
-// `skills: false` / `metrics: false` skip the server's domain stat fan-out for each derived
-// registry — pass them from callers that only read the persisted fields or the level (e.g.
-// the polling OpenWorld XP HUD badge). Both default on, so a caller that wants the whole
-// sheet just calls getCharacter().
-export const getCharacter = ({ skills = true, metrics = true, ...options } = {}) => {
- const params = new URLSearchParams();
- if (!skills) params.set('skills', '0');
- if (!metrics) params.set('metrics', '0');
- // `metrics=1` must go on the wire EXPLICITLY when skills are off, because the server infers
- // an absent `metrics` from `skills` (back-compat: a bare `?skills=0` predates the metrics
- // grid and means "cheap sheet"). Without this, `{ skills: false }` would silently drop the
- // metrics this wrapper's own default promises.
- else if (!skills) params.set('metrics', '1');
- const query = params.toString();
- return request(`/character${query ? `?${query}` : ''}`, options);
-};
-
// Health
export const checkHealth = (options) => request('/system/health', options);
export const getSystemHealth = (options) => request('/system/health/details', options);
@@ -40,6 +22,9 @@ export const triageSystemResources = (payload, options = {}) => request('/system
export const getActiveProcessing = (options) => request('/system/processing', options);
export const getNetworkExposure = (options) => request('/network-exposure/status', options);
export const getCapabilities = (options) => request('/capabilities', options);
+// Machine-local hardware facts used for UI fit and recommendation surfaces.
+// This endpoint deliberately stays outside peer-synced health payloads.
+export const getSystemCapabilities = (options) => request('/system/capabilities', options);
export const updateHealthThresholds = (thresholds, options = {}) => request('/system/health/thresholds', {
method: 'PUT',
body: JSON.stringify(thresholds),
@@ -67,6 +52,7 @@ export const syncPortosFork = (opts = {}, requestOpts = {}) => request('/update/
// Settings
export const getSettings = (options) => request('/settings', options);
export const getInstanceFeatures = (options) => request('/settings/features', options);
+export const getCredentialInventory = (options) => request('/settings/credentials', options);
export const updateInstanceFeature = (featureId, enabled, options = {}) => request(`/settings/features/${encodeURIComponent(featureId)}`, {
method: 'PUT',
body: JSON.stringify({ enabled }),
diff --git a/client/src/services/apiSystem.test.js b/client/src/services/apiSystem.test.js
index 3f73059133..d777b05877 100644
--- a/client/src/services/apiSystem.test.js
+++ b/client/src/services/apiSystem.test.js
@@ -6,12 +6,11 @@ vi.mock('./apiCore.js', () => ({
let request;
let patchSettingsSlice;
-let getCharacter;
beforeEach(async () => {
vi.resetModules();
({ request } = await import('./apiCore.js'));
- ({ patchSettingsSlice, getCharacter } = await import('./apiSystem.js'));
+ ({ patchSettingsSlice } = await import('./apiSystem.js'));
request.mockReset();
});
@@ -139,38 +138,4 @@ describe('patchSettingsSlice', () => {
});
});
-// The character query builder encodes a non-obvious server rule: an ABSENT `metrics` is
-// inferred from `skills` (back-compat — a bare `?skills=0` predates the metrics grid and has
-// only ever meant "cheap sheet"). So this wrapper must put `metrics` on the wire explicitly
-// whenever inference would contradict its own documented defaults.
-describe('getCharacter query building (#2676)', () => {
- const pathOf = () => request.mock.calls[0][0];
-
- it('sends no query at all when the caller wants the whole sheet', () => {
- getCharacter();
- expect(pathOf()).toBe('/character');
- });
-
- it('sends both flags off for the cheap path', () => {
- getCharacter({ skills: false, metrics: false });
- expect(pathOf()).toBe('/character?skills=0&metrics=0');
- });
-
- it('sends metrics=1 explicitly when skills are off but metrics are wanted', () => {
- // Without the explicit `1` the server would infer metrics=false from `skills=0` and
- // silently drop the metrics this wrapper's `metrics = true` default promises.
- getCharacter({ skills: false });
- expect(pathOf()).toBe('/character?skills=0&metrics=1');
- });
-
- it('sends only metrics=0 when metrics alone are declined', () => {
- getCharacter({ metrics: false });
- expect(pathOf()).toBe('/character?metrics=0');
- });
-
- it('forwards request options without leaking the flags into them', () => {
- getCharacter({ skills: false, metrics: false, silent: true });
- expect(request.mock.calls[0][1]).toEqual({ silent: true });
- });
-});
// @vitest-environment node
diff --git a/client/src/storageConventions.test.js b/client/src/storageConventions.test.js
new file mode 100644
index 0000000000..89094b5531
--- /dev/null
+++ b/client/src/storageConventions.test.js
@@ -0,0 +1,168 @@
+// @vitest-environment node
+
+/**
+ * Repo-wide guard: `lib/safeStorage.js` is the only place that touches Web Storage.
+ *
+ * `localStorage`/`sessionStorage` are not ordinary objects. They throw — not
+ * return null — in Safari private browsing, with cookies disabled, in a
+ * sandboxed iframe, and at quota; and in some of those the object still EXISTS,
+ * so a `typeof sessionStorage === 'undefined'` half-guard passes and the very
+ * next `setItem` throws anyway. `lib/safeStorage.js` exists to make every access
+ * best-effort, and its header already said "use these instead of touching
+ * `localStorage` inline". Three modules still did, and each one turned a lost
+ * preference into something worse (#5689):
+ *
+ * - `MusicDesigner.jsx` read storage in the COMPONENT RENDER BODY, so a throw
+ * was a render-phase exception that unmounted the whole Music Designer route.
+ * - `staleChunkReload.js` read/wrote its anti-loop flag unguarded inside the
+ * stale-bundle recovery path — a storage failure broke the mechanism whose
+ * entire job is recovering a user from a broken page.
+ * - `usePostSession.js` carried the `typeof`-only half-guard on a write that
+ * runs on every drill-state change, so a POST training run died mid-session.
+ *
+ * The rule is therefore structural rather than per-site: any raw member access
+ * fails this suite, and the fix is always the corresponding `safe*` helper.
+ *
+ * ## Allowlist
+ *
+ * - `src/lib/safeStorage.js` — it IS the guarded wrapper.
+ * - `src/test/**` — the storage polyfill and test helpers deliberately install
+ * and probe raw Storage objects; a guard must not trip over its own harness.
+ * - `src/utils/timeWindow.js` — enumerates keys via `.length` / `.key(i)` to
+ * prune stale per-day entries. `safeStorage` deliberately exposes no
+ * enumeration API (it would have to invent an iteration contract for a
+ * single caller), so this file keeps its own try/catch — which it already has
+ * on every access. Move it off the allowlist the day a second caller needs
+ * enumeration and the helper is worth adding.
+ *
+ * ## What this guard CANNOT see
+ *
+ * It is a source grep, not an AST pass. Comments are stripped (both forms, with
+ * a `:` lookbehind so a `https://` URL is not read as a line comment) because
+ * prose like "persisted to localStorage." otherwise reads as a member access
+ * spanning the newline; string literals are NOT stripped, so a storage call
+ * spelled inside a string would be flagged, and a real call sharing a line with
+ * a `//` inside a string would be missed. Aliasing (`const s = localStorage`),
+ * a computed base (`globalThis['localStorage']`), or a call funneled through a
+ * helper in another file all slip through. Those are unusual enough here that
+ * the grep earns its keep; closing them means moving to an AST pass.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { readFileSync } from 'fs';
+import { join, dirname } from 'path';
+import { fileURLToPath } from 'url';
+import { trackedSourceFiles } from './test/trackedFiles.js';
+
+const CLIENT_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+
+const WRAPPER_FILE = 'src/lib/safeStorage.js';
+const ALLOWED = [
+ WRAPPER_FILE,
+ 'src/utils/timeWindow.js',
+];
+const ALLOWED_PREFIXES = ['src/test/'];
+
+const isAllowed = (file) =>
+ ALLOWED.includes(file) || ALLOWED_PREFIXES.some((p) => file.startsWith(p));
+
+/**
+ * Block and line comments removed. Without this, a docblock sentence ending in
+ * "localStorage." followed by a line starting with a letter matches the member
+ * pattern and reports a file that never touches storage at all. The `[^:]`
+ * before `//` keeps `https://…` inside a comment or string from eating the rest
+ * of the line.
+ */
+const stripComments = (src) => src
+ .replace(/\/\*[\s\S]*?\*\//g, ' ')
+ .replace(/(^|[^:\\])\/\/[^\n]*/g, '$1');
+
+/**
+ * `localStorage.foo`, `window.sessionStorage.foo`, `globalThis.localStorage[…]`.
+ * A member name (or a bracket) is required so prose and bare identifiers are not
+ * matches; the lookbehind keeps an unrelated identifier ending in `…Storage`
+ * from counting. Optional chaining counts as an access: `?.` only guards a
+ * null/undefined base, not a `getItem` that throws — which is the whole failure
+ * mode here — so `sessionStorage?.setItem(…)` is exactly as unsafe as the plain
+ * form and must not read as already-guarded.
+ */
+const RAW_STORAGE = /(? m[0].trim());
+}
+
+describe('Web Storage access goes through lib/safeStorage', () => {
+ it('has no raw localStorage/sessionStorage access outside the wrapper', () => {
+ const files = trackedSourceFiles(CLIENT_ROOT);
+ // A broken `git ls-files` (wrong cwd, detached checkout) would otherwise make
+ // this guard pass by scanning nothing at all.
+ expect(files.length).toBeGreaterThan(100);
+
+ const violations = [];
+ for (const file of files) {
+ if (isAllowed(file)) continue;
+ const src = readFileSync(join(CLIENT_ROOT, file), 'utf8');
+ for (const hit of findRawStorageAccess(src)) violations.push(`${file}: ${hit}`);
+ }
+
+ expect(
+ violations,
+ 'These files touch `localStorage`/`sessionStorage` directly. Storage throws '
+ + '(Safari private mode, blocked storage, disabled cookies, quota) — in a render '
+ + 'body that unmounts the route, and in an effect it kills the flow mid-session.\n'
+ + 'Fix: use `safeReadStorage` / `safeReadJsonStorage` / `safeWriteStorage` / '
+ + '`safeWriteJsonStorage` / `safeRemoveStorage`, or the session variants '
+ + '`safeReadSession` / `safeWriteSession` / `safeReadJsonSession` / '
+ + '`safeWriteJsonSession` / `safeRemoveSession`, from `client/src/lib/safeStorage.js`.\n'
+ + `Offenders:\n ${violations.join('\n ')}`,
+ ).toEqual([]);
+ });
+
+ // Guards the guard: if the detector stops recognizing a raw access, the scan
+ // above goes vacuously green and the bug class walks straight back in.
+ it('flags every raw spelling and accepts the safe helpers', () => {
+ expect(findRawStorageAccess("localStorage.getItem('x')")).toEqual(['localStorage.g']);
+ expect(findRawStorageAccess("window.localStorage.setItem('x', '1')")).toEqual(['window.localStorage.s']);
+ expect(findRawStorageAccess('globalThis.sessionStorage?.removeItem(k)')).toEqual(['globalThis.sessionStorage?.r']);
+ expect(findRawStorageAccess("sessionStorage['x']")).toEqual(['sessionStorage[']);
+ expect(findRawStorageAccess('for (let i = 0; i < localStorage.length; i += 1) {}')).toEqual(['localStorage.l']);
+
+ expect(findRawStorageAccess("safeReadStorage('x')")).toEqual([]);
+ expect(findRawStorageAccess("safeWriteJsonSession('x', v)")).toEqual([]);
+ // A `typeof` presence check alone is the half-guard #5689 was about, but it
+ // is not itself an access — only the member call that follows is.
+ expect(findRawStorageAccess("if (typeof sessionStorage === 'undefined') return;")).toEqual([]);
+ });
+
+ it('does not flag prose that merely names the API', () => {
+ // The exact shapes present in the tree when this guard was written: a
+ // sentence-final "localStorage." whose next line starts with a letter.
+ expect(findRawStorageAccess(
+ '// nothing in this module reads or writes localStorage.\nconst TIERS = {};',
+ )).toEqual([]);
+ expect(findRawStorageAccess(
+ ' * Sidebar working-set state, persisted to localStorage.\n */\nexport const x = 1;',
+ )).toEqual([]);
+ // A URL inside a comment must not swallow the rest of the file and hide a
+ // real access on a later line.
+ expect(findRawStorageAccess(
+ "// see https://example.com/storage\nlocalStorage.getItem('x');",
+ )).toEqual(['localStorage.g']);
+ });
+
+ // The allowlist must keep naming files that really exist and really carry the
+ // shape — otherwise a rename turns an exemption into silent dead config.
+ it('allowlists only files that exist and still touch storage directly', () => {
+ const tracked = trackedSourceFiles(CLIENT_ROOT);
+ for (const file of ALLOWED) {
+ expect(tracked, `${file} is allowlisted but no longer tracked`).toContain(file);
+ const src = readFileSync(join(CLIENT_ROOT, file), 'utf8');
+ expect(
+ findRawStorageAccess(src).length,
+ `${file} no longer touches storage directly — drop it from the allowlist`,
+ ).toBeGreaterThan(0);
+ }
+ });
+});
diff --git a/client/src/test/classNameScan.js b/client/src/test/classNameScan.js
new file mode 100644
index 0000000000..c8827e8410
--- /dev/null
+++ b/client/src/test/classNameScan.js
@@ -0,0 +1,74 @@
+/**
+ * Shared string-literal scanner for the class-string convention guards.
+ *
+ * `src/responsiveGridConventions.test.js` and `src/popoverClampConventions.test.js`
+ * both enforce a narrow-viewport rule by reading every quoted string in the client
+ * tree and inspecting the Tailwind tokens inside it. They need the same two
+ * primitives — blank out comments so a doc block quoting an example class isn't
+ * scanned as markup, then walk the remaining string literals — so those live here
+ * once rather than being copied into each guard.
+ *
+ * Node-only consumers (test files) exclusively; kept in `src/test/` for the same
+ * reason as `trackedFiles.js` — `lib/` carries the enforced barrel + README rule
+ * and a test-only helper has no business in the browser barrel.
+ */
+
+/** Blank out `//` and block comments so a quoted example class isn't scanned as markup. */
+export function maskComments(source) {
+ let out = '';
+ let i = 0;
+ while (i < source.length) {
+ const ch = source[i];
+ if (ch === '/' && source[i + 1] === '/') {
+ while (i < source.length && source[i] !== '\n') {
+ out += ' ';
+ i += 1;
+ }
+ continue;
+ }
+ if (ch === '/' && source[i + 1] === '*') {
+ while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) {
+ out += source[i] === '\n' ? '\n' : ' ';
+ i += 1;
+ }
+ out += ' ';
+ i += 2;
+ continue;
+ }
+ if (ch === '"' || ch === "'" || ch === '`') {
+ out += ch;
+ i += 1;
+ while (i < source.length && source[i] !== ch) {
+ if (source[i] === '\\') {
+ out += source.slice(i, i + 2);
+ i += 2;
+ continue;
+ }
+ out += source[i];
+ i += 1;
+ }
+ out += source[i] ?? '';
+ i += 1;
+ continue;
+ }
+ out += ch;
+ i += 1;
+ }
+ return out;
+}
+
+const STRING_LITERAL = /(['"`])((?:\\.|(?!\1)[\s\S])*?)\1/g;
+
+/** Every quoted string in `source`, as `{ value, index }` in source order. */
+export function stringLiterals(source) {
+ const found = [];
+ let match;
+ STRING_LITERAL.lastIndex = 0;
+ while ((match = STRING_LITERAL.exec(source))) found.push({ value: match[2], index: match.index });
+ return found;
+}
+
+/** 1-based line number of a character offset. */
+export function lineOf(source, index) {
+ return source.slice(0, index).split('\n').length;
+}
diff --git a/client/src/test/openWorldPageMocks.js b/client/src/test/openWorldPageMocks.js
deleted file mode 100644
index 0babb2f453..0000000000
--- a/client/src/test/openWorldPageMocks.js
+++ /dev/null
@@ -1,96 +0,0 @@
-/**
- * Shared fixture payloads for the `OpenWorld` PAGE suites (transport, fast
- * travel, and whatever comes next). Each of those suites stubs the same 3D
- * scene, the same data hook, and the same nine API endpoints purely to get the
- * page mountable in jsdom — none of it is what any of them is testing, and a
- * new endpoint the page starts polling otherwise has to be added to every copy.
- *
- * The `vi.mock` CALLS must stay in each test file: vitest hoists them above the
- * imports, so a factory defined here can only be reached by `await import`ing
- * this module INSIDE the factory —
- *
- * vi.mock('../hooks/useOpenWorldData', async () => ({
- * useOpenWorldData: () => (await import('../test/openWorldPageMocks.js')).OPEN_WORLD_DATA,
- * }));
- *
- * — which is why these are plain exported values rather than a helper that
- * registers the mocks for you.
- *
- * Anything a suite ASSERTS on stays in that suite: the scene/HUD stubs that
- * record props, the playback spies, the per-test payload overrides.
- */
-
-/**
- * The `useOpenWorldData` return shape, with every field empty. A suite that
- * needs one field populated spreads this and overrides that field.
- */
-export const OPEN_WORLD_DATA = {
- apps: [],
- cosAgents: [],
- cosStatus: {},
- eventLogs: [],
- agentMap: new Map(),
- reviewCounts: {},
- instances: {},
- systemHealth: null,
- notificationCounts: {},
- backupStatus: null,
- cosTasks: [],
- healthMetrics: null,
- voiceState: null,
- character: null,
- aiActivity: null,
- loading: false,
- connected: true,
-};
-
-/**
- * Build the `services/api` stub — only the endpoints the page polls. The real
- * module pulls in the socket client, which has no place in a jsdom page test;
- * `useAutoRefetch` is stubbed alongside it, so none of these actually fire.
- *
- * Takes `vi` as an argument because a `vi.mock` factory runs before this
- * module's own imports would resolve.
- *
- * @param {typeof import('vitest').vi} vi
- */
-export function openWorldApiMock(vi) {
- return {
- getInstanceFeatures: vi.fn(async () => ({ features: [] })),
- getCosQuickSummary: vi.fn(async () => null),
- getCosActivityCalendar: vi.fn(async () => null),
- getGoals: vi.fn(async () => null),
- getChronotype: vi.fn(async () => null),
- getMemoryGraph: vi.fn(async () => null),
- getBrainInbox: vi.fn(async () => null),
- getOpenWorldIntrospection: vi.fn(async () => null),
- getMySprintTickets: vi.fn(async () => []),
- };
-}
-
-/**
- * The `useOpenWorldPlayback` return shape with playback INACTIVE — what a suite
- * that isn't testing the transport wants. Spies are built per call so two
- * suites never share call history.
- *
- * @param {typeof import('vitest').vi} vi
- */
-export function openWorldPlaybackMock(vi) {
- return {
- active: false,
- currentFrame: null,
- snapshots: [],
- frameIndex: 0,
- stats: null,
- playing: false,
- speed: 1,
- loading: false,
- error: null,
- enter: vi.fn(),
- exit: vi.fn(),
- seek: vi.fn(),
- step: vi.fn(),
- togglePlay: vi.fn(),
- cycleSpeed: vi.fn(),
- };
-}
diff --git a/client/src/test/videoGenPageMocks.jsx b/client/src/test/videoGenPageMocks.jsx
new file mode 100644
index 0000000000..937fe98776
--- /dev/null
+++ b/client/src/test/videoGenPageMocks.jsx
@@ -0,0 +1,249 @@
+/**
+ * Shared mock scaffold for the VideoGen page suites.
+ *
+ * Five suites (`pages/VideoGen.terms`, `.federatedTarget`, `.composeWhileBusy`,
+ * `.textEncoderAutoDownload`, `.modelLoading`) each used to carry a near-verbatim
+ * ~140-line copy of the same 25 `vi.mock` registrations, the same `state` object,
+ * the same model fixture and the same `renderPage()` helper. Every endpoint or
+ * hook the page started calling had to be added in five places, or four suites
+ * broke at once.
+ *
+ * **Importing this module registers the mocks** — that is the whole point, and it
+ * is what the vitest hoisting rules allow. `vi.mock` is hoisted to the top of the
+ * file it is written in, so these registrations run when this module is evaluated,
+ * which is while the importing test file's static imports are being resolved and
+ * therefore before its `await loadVideoGenPage()`. The relative specifiers resolve
+ * identically from here and from `pages/` (`src/test/../services/api` and
+ * `src/pages/../services/api` are the same module), so the mocked paths are the
+ * ones the page itself imports.
+ *
+ * Every mock reads through the exported `state`, so a suite varies behavior by
+ * assigning to it in `beforeEach` rather than by re-registering a mock. Call
+ * `resetVideoGenMockState()` first to get the documented defaults back.
+ */
+
+import { act, render } from '@testing-library/react';
+import { MemoryRouter } from 'react-router';
+import { vi } from 'vitest';
+
+/** Universe style the stub picker hands to the page when its button is clicked. */
+const DEFAULT_UNIVERSE_STYLE = {
+ id: 'u-1',
+ name: 'Example Universe',
+ influences: { embrace: ['inky linework'], avoid: ['glossy'] },
+};
+
+/**
+ * Every knob the five suites vary. Mutated in `beforeEach`; read lazily by the
+ * mock factories below, so a reassignment takes effect on the next render.
+ */
+export const state = {
+ /** `GET /api/instances` peers — a media-provider peer makes the target picker appear. */
+ peers: [],
+ /** `getVideoGenStatus`; a spy so a suite can defer it, count calls or vary the payload. */
+ getVideoGenStatus: vi.fn(),
+ generateVideo: vi.fn(),
+ attach: vi.fn(),
+ eventSourceRef: { current: null },
+ activeJob: null,
+ /** Cache-status entries keyed by download id, read by the default `getModelStatus`. */
+ modelStatuses: {},
+ /** Override to answer ids the map cannot (e.g. the `__text_encoder_option__:` prefix). */
+ getModelStatus: (id) => state.modelStatuses[id] ?? null,
+ start: vi.fn(),
+ startWhenIdle: vi.fn(),
+ queuedModelId: null,
+ repair: vi.fn(),
+ cancel: vi.fn(),
+ refresh: vi.fn(),
+ /** `RuntimeInstallModal`'s `onComplete`, captured so a suite can fire it. */
+ runtimeInstallComplete: null,
+ universeStyle: DEFAULT_UNIVERSE_STYLE,
+};
+
+const SPIES = ['getVideoGenStatus', 'generateVideo', 'attach', 'start', 'startWhenIdle', 'repair', 'cancel', 'refresh'];
+
+/** Restore every documented default, including fresh spies. Call it first in `beforeEach`. */
+export function resetVideoGenMockState() {
+ state.peers = [];
+ state.activeJob = null;
+ state.modelStatuses = {};
+ state.getModelStatus = (id) => state.modelStatuses[id] ?? null;
+ state.queuedModelId = null;
+ state.runtimeInstallComplete = null;
+ state.universeStyle = DEFAULT_UNIVERSE_STYLE;
+ state.eventSourceRef.current = null;
+ for (const key of SPIES) state[key].mockReset();
+}
+
+/** A video model as `GET /api/video-gen/status` reports it. */
+export const videoGenModel = (id, overrides = {}) => ({
+ id,
+ name: `MiniMax ${id}`,
+ repo: `example-org/${id}`,
+ revision: '1111111111111111111111111111111111111111',
+ runtime: 'minimax_h3',
+ supportedModes: ['text'],
+ defaultFrames: 124,
+ frameOptions: [124, 141],
+ fpsOptions: [24],
+ steps: 8,
+ guidance: 0,
+ samplerLocked: true,
+ supportsNegativePrompt: false,
+ supportsTiling: false,
+ supportsDisableAudio: false,
+ ...overrides,
+});
+
+/** The eligibility gate a territory-restricted model carries. */
+export const videoGenTermsGate = (termsId) => ({
+ id: termsId,
+ title: `Terms for ${termsId}`,
+ summary: 'This model is available only in its applicable territory.',
+ acknowledgement: `I am eligible and accept ${termsId}.`,
+ licenseUrl: 'https://example.com/license',
+});
+
+/** A `/status` payload over `models`; the first model is the default unless overridden. */
+export const videoGenStatus = (models, overrides = {}) => ({
+ connected: true,
+ pythonPath: '/opt/example/python3',
+ defaultModel: models[0]?.id ?? null,
+ models,
+ byovRuntimes: [],
+ systemMemoryGb: 128,
+ backendDisclosures: [],
+ ...overrides,
+});
+
+vi.mock('../services/api', () => ({
+ // The page offers a federated render target (#4348); with no peer opted in as
+ // a media provider the picker renders nothing and every local path is unchanged.
+ getInstances: vi.fn(async () => ({ peers: state.peers })),
+ getVideoGenStatus: (...args) => state.getVideoGenStatus(...args),
+ generateVideo: (...args) => state.generateVideo(...args),
+ cancelVideoGen: vi.fn(async () => ({})),
+ listVideoHistory: vi.fn(async () => []),
+ deleteVideoHistoryItem: vi.fn(async () => ({})),
+ setVideoHidden: vi.fn(async () => ({})),
+ extractLastFrame: vi.fn(async () => ({})),
+ upscaleVideo: vi.fn(async () => ({})),
+ listImageGallery: vi.fn(async () => []),
+ patchSettingsSlice: vi.fn(async () => ({})),
+ getActiveVideoJob: vi.fn(async () => ({ activeJob: state.activeJob })),
+ getSettings: vi.fn(async () => ({ imageGen: { grok: { enabled: false } } })),
+ getVideoGenRuntimeStatus: vi.fn(async () => ({ installed: true, ready: true, current: true })),
+ listLorasFull: vi.fn(async () => []),
+ // The prompt-enhancement controls mount useProviderModels, which fetches the
+ // provider list from a mount effect. Unmocked it throws out of a passive
+ // effect — the tests still pass, but the unhandled rejection fails the run.
+ getProviders: vi.fn(async () => ({ providers: [] })),
+ getVisionModels: vi.fn(async () => ({ models: [] })),
+}));
+
+vi.mock('../hooks/useModelDownloadStatus', () => ({
+ TEXT_ENCODER_DOWNLOAD_ID: '__text_encoder__',
+ textEncoderDownloadId: (id) => `__text_encoder_option__:${id}`,
+ useModelDownloadStatus: () => ({
+ extra: {},
+ loading: false,
+ statusError: null,
+ activeModelId: null,
+ progress: null,
+ lastError: null,
+ downloading: false,
+ repairing: false,
+ getStatus: (id) => state.getModelStatus(id),
+ start: state.start,
+ startWhenIdle: state.startWhenIdle,
+ queuedModelId: state.queuedModelId,
+ cancel: state.cancel,
+ repair: state.repair,
+ refresh: state.refresh,
+ }),
+}));
+
+vi.mock('../hooks/useMediaJobSse', () => ({
+ useMediaJobSse: () => ({ attach: state.attach, eventSourceRef: state.eventSourceRef }),
+}));
+vi.mock('../hooks/useMediaCompletionRefresh', () => ({ useMediaCompletionRefresh: vi.fn() }));
+vi.mock('../hooks/useMediaAnnotations', () => ({
+ useMediaAnnotations: () => ({ annotations: {}, updateAnnotation: vi.fn(), getCardProps: vi.fn(() => ({})) }),
+}));
+vi.mock('../hooks/usePreviewRoute', () => ({ default: () => [null, vi.fn()] }));
+vi.mock('../components/ui/Toast', () => ({
+ default: Object.assign(vi.fn(), { error: vi.fn(), success: vi.fn(), loading: vi.fn() }),
+}));
+
+// The prompt helpers stay observable rather than blanked: whether they remain
+// usable while a render is already in flight is itself one of the assertions.
+vi.mock('../components/media/PromptEnhancer', () => ({
+ default: ({ disabled }) => (
+ Enhance with AI
+ ),
+}));
+vi.mock('../components/media/PromptFromMedia', () => ({
+ default: ({ disabled }) => (
+ Prompt from media
+ ),
+}));
+// Interactive so a suite can drive the style into the page; inert everywhere else.
+vi.mock('../components/media/UniverseStylePicker', () => ({
+ default: ({ onChange }) => (
+ onChange(state.universeStyle)}>Use universe style
+ ),
+}));
+// Captures `onComplete` (the runtime-setup refresh) and exposes `streamMethod`.
+vi.mock('../components/install/RuntimeInstallModal', () => ({
+ default: ({ onComplete, streamMethod }) => {
+ state.runtimeInstallComplete = onComplete;
+ return
;
+ },
+}));
+
+// Keep the policy-bearing controls real; replace unrelated, heavyweight page
+// surfaces so these stay focused orchestration tests rather than a gallery/SSE
+// integration suite.
+vi.mock('../components/Drawer', () => ({ default: () => null }));
+vi.mock('../components/settings/ImageGenTab', () => ({ ImageGenTab: () => null }));
+vi.mock('../components/settings/LocalSetupPanel', () => ({ default: () => null }));
+vi.mock('../components/videoGen/FramePanel', () => ({ default: () => null }));
+vi.mock('../components/videoGen/KeyframePanel', () => ({ default: () => null }));
+vi.mock('../components/videoGen/AudioPanel', () => ({ default: () => null }));
+vi.mock('../components/videoGen/ExtendPanel', () => ({ default: () => null }));
+vi.mock('../components/videoGen/IcLoraPanel', () => ({ default: () => null }));
+vi.mock('../components/videoGen/AdvancedParamsPanel', () => ({ default: () => null }));
+vi.mock('../components/videoGen/RuntimeFingerprint', () => ({ default: () => null }));
+vi.mock('../components/videoGen/VideoGenGallery', () => ({ default: () => null }));
+vi.mock('../components/media/MediaPreview', () => ({ default: () => null }));
+vi.mock('../components/media/StylePresetPicker', () => ({ default: () => null }));
+vi.mock('../components/media/MediaJobsQueue', () => ({ default: () => null }));
+vi.mock('../components/imageGen/LoraPicker', () => ({ default: () => null }));
+vi.mock('../components/media/ResolutionField', () => ({ default: () => null }));
+
+let VideoGen = null;
+
+/**
+ * Import the page under the mocks above. Every suite loads it dynamically at
+ * module scope so the registrations are in place first; the component is kept
+ * here so `renderVideoGenPage()` needs no argument.
+ */
+export async function loadVideoGenPage() {
+ ({ default: VideoGen } = await import('../pages/VideoGen.jsx'));
+ return VideoGen;
+}
+
+/** Mount the page on its own route, flushing the mount effects. */
+export async function renderVideoGenPage() {
+ if (!VideoGen) throw new Error('await loadVideoGenPage() at module scope before rendering');
+ let view;
+ await act(async () => {
+ view = render(
+
+
+ ,
+ );
+ });
+ return view;
+}
diff --git a/client/src/test/viteChunkGroups.test.js b/client/src/test/viteChunkGroups.test.js
new file mode 100644
index 0000000000..54034ba5c3
--- /dev/null
+++ b/client/src/test/viteChunkGroups.test.js
@@ -0,0 +1,72 @@
+import { existsSync, readFileSync } from 'node:fs';
+import { resolve } from 'node:path';
+
+import { describe, expect, it } from 'vitest';
+
+import { CHUNK_GROUPS } from '../../vite.chunkGroups.js';
+
+const CLIENT_DIR = resolve(import.meta.dirname, '../..');
+
+// Resolve against the checked-in lockfiles, not an installed `node_modules`
+// tree: the lockfile is deterministic, covers nested (unflattened) transitive
+// dependencies the group regexes still match at any depth, and cannot be
+// satisfied by a stale directory left behind by an uninstalled package.
+const lockedPackageNames = () => {
+ const names = new Set();
+ for (const lockfile of ['package-lock.json', '../package-lock.json']) {
+ const file = resolve(CLIENT_DIR, lockfile);
+ if (!existsSync(file)) continue;
+ for (const key of Object.keys(JSON.parse(readFileSync(file, 'utf-8')).packages ?? {})) {
+ const marker = key.lastIndexOf('node_modules/');
+ if (marker !== -1) names.add(key.slice(marker + 'node_modules/'.length));
+ }
+ }
+ return [...names];
+};
+
+const LOCKED_PACKAGES = lockedPackageNames();
+
+const isInstalled = (name) => {
+ if (name.endsWith('*')) return LOCKED_PACKAGES.some((pkg) => pkg.startsWith(name.slice(0, -1)));
+ // A bare `@scope` entry stands for every package published under it.
+ if (name.startsWith('@') && !name.includes('/')) {
+ return LOCKED_PACKAGES.some((pkg) => pkg.startsWith(`${name}/`));
+ }
+ return LOCKED_PACKAGES.includes(name);
+};
+
+const groupNamed = (name) => CHUNK_GROUPS.find((group) => group.name === name);
+
+describe('vite chunk groups', () => {
+ // The regression: a group regex naming a package that is not installed matches
+ // nothing, so the named chunk quietly stops capturing what its comment claims.
+ // `vendor-three` shipped that way against the removed `three-fenestra` (#5725).
+ it('only names packages that are actually installed', () => {
+ const missing = CHUNK_GROUPS.flatMap(({ name, packages }) =>
+ packages.filter((pkg) => !isInstalled(pkg)).map((pkg) => `${name} -> ${pkg}`));
+ expect(LOCKED_PACKAGES.length).toBeGreaterThan(0);
+ expect(missing).toEqual([]);
+ });
+
+ it('captures the whole three stack on both path separators', () => {
+ const { test } = groupNamed('vendor-three');
+ expect(test.test('/app/node_modules/three/build/three.module.js')).toBe(true);
+ expect(test.test('/app/node_modules/three-stdlib/index.js')).toBe(true);
+ expect(test.test('/app/node_modules/three-mesh-bvh/src/index.js')).toBe(true);
+ expect(test.test('C:\\app\\node_modules\\@react-three\\fiber\\index.js')).toBe(true);
+ // A `three`-prefixed package we do not depend on must not be swept in.
+ expect(test.test('/app/node_modules/three-globe/index.js')).toBe(false);
+ });
+
+ it('keeps package names from bleeding across the separator', () => {
+ // A declared name must match a whole path segment: `react` must not swallow
+ // `react-redux`. The trailing separator is what enforces that.
+ const { test } = groupNamed('vendor-react');
+ expect(test.test('/app/node_modules/react/index.js')).toBe(true);
+ expect(test.test('/app/node_modules/react-redux/index.js')).toBe(false);
+ // Family prefixes still match every member.
+ const charts = groupNamed('vendor-charts').test;
+ expect(charts.test('/app/node_modules/d3-scale/src/band.js')).toBe(true);
+ expect(charts.test('/app/node_modules/victory-vendor/d3-scale.js')).toBe(true);
+ });
+});
diff --git a/client/src/utils/README.md b/client/src/utils/README.md
index 56b6d6c667..ce7293514e 100644
--- a/client/src/utils/README.md
+++ b/client/src/utils/README.md
@@ -22,8 +22,8 @@ grep -i "what you want to do" client/src/utils/README.md
| Module | Purpose |
|---|---|
-| `formatters` | Date/time/duration/byte/word formatters (`clamp`, `formatBytes`, `formatDownloadGb` (decimal-GB model download size, "~29 GB"), `formatCompactCount`, `formatCompactCountOrDash` (same, but an ABSENT count renders "—" rather than "0"), `timeAgo`, `formatAgeDays` (age in whole DAYS — "412 days ago" — where `timeAgo` would collapse to "1y ago"; model-download lists), `localDateKey` (browser-local `YYYY-MM-DD`), `shiftISODate` (DST-safe calendar-day shifts), `formatTimecode`, `formatDurationMs`, `formatDateShort`, `formatContextTokens` (suffix-less context length, "4K"), `throughputLabel` (a measured model's speed as one label — tok/s where the runtime reported token counts, `~` prefixed when frame-counted, else chars/s; never both), `parseTimeoutMs`, `formatCooldown`, `recommendedRamGb`, `nameFromImageFilename`, `formatUsd` — one USD renderer (`signed` puts the minus outside the `$`; `trimWhole` drops `.00` on a typed round price) — `formatWeight` / `formatPercent` — round unit-converted floats (`170.35000000000002` → `170.4 lbs`) so raw binary precision never reaches a tile — `middleTruncate` — clip a long string from the MIDDLE so its distinguishing tail survives, where CSS `line-clamp`/`text-overflow` always eats the end — …) plus timeout-input bounds and `getAppName`. Do not re-define formatters inside components. **Never write `new Date(x).toLocaleDateString()` inline** — pick the helper for the shape you want: `formatDateNumeric` ("3/5/2026", compact cells), `formatDateShort` ("Mar 5, 2026"), `formatDate` ("March 5, 2026"), `formatDateFull` ("Saturday, March 5, 2026"), `formatWeekdayDate` ("Monday, Mar 5", `{ weekday, year }`), `formatMonthDay` ("Mar 5"), `formatMonthYear` ("March 2026"), `formatWeekdayShort` ("Mon"), `formatWeekdayTime` ("Mon, 7:00 AM"), `formatTimeOfDay` ("1:30 PM"), `formatTimeOfDaySeconds` ("1:30:45 PM", log/queue rows), `formatClockTime` ("02:30:45 PM"; `{ seconds, hour12, timeZone }`), `formatDateTime`. Date display helpers anchor a bare `YYYY-MM-DD` at LOCAL midnight (a naive `new Date('2026-03-05')` is UTC midnight and renders as the previous day west of Greenwich) and take a fallback instead of rendering the literal "Invalid Date". |
-| `cronHelpers` | Cron preset list, friendly cron parsing/building, anchored recurrence parsing/building/description, `isCronExpression` detection, `describeCron` human-readable rendering, and `JOB_INTERVAL_OPTIONS` — the interval-mode cadences for autonomous jobs, mirroring the server's `INTERVAL_OPTIONS`. Import it rather than re-declaring the list in a scheduling component. |
+| `formatters` | Date/time/duration/byte/word formatters (`clamp`, `formatBytes`, `formatDownloadGb` (decimal-GB model download size, "~29 GB"), `formatCompactCount`, `formatCompactCountOrDash` (same, but an ABSENT count renders "—" rather than "0"), `timeAgo`, `formatAgeDays` (age in whole DAYS — "412 days ago" — where `timeAgo` would collapse to "1y ago"; model-download lists), `localDateKey` (browser-local `YYYY-MM-DD`), `shiftISODate` (DST-safe calendar-day shifts), `formatTimecode`, `formatDurationMs`, `formatDateShort`, `formatContextTokens` (suffix-less context length, "4K"), `throughputLabel` (a measured model's speed as one label — tok/s where the runtime reported token counts, `~` prefixed when frame-counted, else chars/s; never both), `parseTimeoutMs`, `formatCooldown`, `recommendedRamGb`, `nameFromImageFilename`, `formatUsd` — one USD renderer (`signed` puts the minus outside the `$`; `trimWhole` drops `.00` on a typed round price) — `formatWeight` / `formatPercent` — round unit-converted floats (`170.35000000000002` → `170.4 lbs`) so raw binary precision never reaches a tile — `middleTruncate` — clip a long string from the MIDDLE so its distinguishing tail survives, where CSS `line-clamp`/`text-overflow` always eats the end — …) plus timeout-input bounds and `getAppName`. Do not re-define formatters inside components. **Never write `new Date(x).toLocaleDateString()` inline** — pick the helper for the shape you want: `formatDateNumeric` ("3/5/2026", compact cells), `formatDateShort` ("Mar 5, 2026"), `formatDate` ("March 5, 2026"), `formatDateFull` ("Saturday, March 5, 2026"), `formatWeekdayDate` ("Monday, Mar 5", `{ weekday, year }`), `formatMonthDay` ("Mar 5"), `formatMonthYear` ("March 2026"), `formatWeekdayShort` ("Mon"), `formatWeekdayTime` ("Mon, 7:00 AM"), `formatTimeOfDay` ("1:30 PM"), `formatTimeOfDaySeconds` ("1:30:45 PM", log/queue rows), `formatClockTime` ("02:30:45 PM"; `{ seconds, hour12, timeZone }`), `formatDateTime`. `formatHourOfDay` renders a bare hour-of-day number (0-23) as a 12-hour label in one of four styles (`long` "3 PM", `compact` "3PM", `tiny` "3p", `lower` "3pm") — the canonical home for what five components each hand-rolled, so the same 3pm no longer renders four ways across screens. Date display helpers anchor a bare `YYYY-MM-DD` at LOCAL midnight (a naive `new Date('2026-03-05')` is UTC midnight and renders as the previous day west of Greenwich) and take a fallback instead of rendering the literal "Invalid Date". |
+| `cronHelpers` | Cron preset list, friendly cron parsing/building, anchored recurrence parsing/building/description, `isCronExpression` detection, `describeCron` human-readable rendering, `cronFromIntervalMs` numeric-interval → expression conversion (mirrors the server helper), and `JOB_INTERVAL_OPTIONS` — the interval-mode cadences for autonomous jobs, mirroring the server's `INTERVAL_OPTIONS`. Import it rather than re-declaring the list in a scheduling component. |
| `markdownText` | `markdownToPlainText(md)` — flatten markdown source to one plain-text string for a clamped preview: strips heading/list/blockquote/fence markers, unwraps emphasis, inline code and links, images → `[alt]`, collapses blank-line runs. Use whenever a card previews arbitrary agent-authored markdown — `line-clamp-N` does not clamp a subtree of block elements, and foreign `##` headings would otherwise join the page's heading outline. Underscore emphasis is word-boundary gated and `__…__` needs interior whitespace, so stack frames and user-agent strings (`10_15_7`, `__init__`) are never silently rewritten. `dropsMarkupWhenFlattened(md)` — did the flatten lose actual markup, as opposed to only normalizing whitespace? Gates a "Show more" disclosure so a short-but-lossy body stays reachable without putting a toggle on every body that merely lost a trailing newline. |
| `timeWindow` | Time-of-day window math (`isInTimeWindow`, `timeStringToMinutes`) and morning-layout auto-switch helpers (`pickActiveLayoutId`, `recordManualLayoutPick`). |
| `timezone` | Timezone day-key helpers (`dayKeyInTimezone`, `todayKeyInTimezone`) — browser mirror of the server's `todayInTimezone`, so date-scoped POST surfaces derive "today" in the user's configured timezone and agree with the server (#2681). |
@@ -37,11 +37,12 @@ grep -i "what you want to do" client/src/utils/README.md
| `easing` | Interpolation easing curves — `linear`, `easeIn`, `easeOut`, `smoothstep` (ease in/out), plus the `EASING_CURVES` map keyed by the declarative Three.js clip contract's easing enum. Extend here rather than re-defining an easing inline. |
| `hashString` | Deterministic string → 32-bit hash (stable colors, keys, seeds). |
| `modelFit` | `fitModelToHeight(object, { targetHeight, feetOnGround, yOffset })` — normalize a loaded GLB to a fixed on-screen height and anchor it vertically, either by bounding-box center (portrait framing) or by its lowest point (a figure standing on a ground plane). Recenters x/z, guards a zero-height model against an infinite scale, leaves a geometry-less object or a non-finite `targetHeight` untouched rather than writing `NaN`/`Infinity` through the transform, and resets to identity before measuring so a repeat call (StrictMode's double mount effect) converges instead of blowing the model back up. Call from an effect, never during render — a skinned mesh measures wrong before `useAnimations` binds the skeleton. |
+| `renderBudget` | Pure adaptive render-quality state machine: capability-aware startup tier (`recommendStartTier`), p75 frame-time windows, hysteresis, cooldown, warm-up/gap rejection (`createRenderBudget`, `recordFrame`, `restartWarmup`, `resetRenderBudget`, `getEffectiveTier`, `QUALITY_TIERS`, `DEFAULT_RENDER_BUDGET_CONFIG`). |
| `sleep` | `sleep(ms)` — promise-returning `setTimeout` for retry backoffs and race timeouts. Use instead of re-declaring a local `delay`. |
| `urlNormalize` | `isUrl` detection, `normalizeUrl` (optional git/`requireDot` modes), `isHttpUrl` / `isHttpsUrl` (safe-href checks), and `tiktokVideoId` / `tiktokEmbedSrc` (host-anchored TikTok video-id extraction + its Embed Player URL, so a reference embeds without loading TikTok's embed.js). |
| `platform` | `isMac` detection and `modKey` (⌘/Ctrl) for keyboard-shortcut display. |
| `navWorkingSet` | Recent/pinned nav persistence (`recordVisit`, `togglePin`, `isPinned`) plus `resolveRecentNavEntries` for mapping stored deep links back to their longest matching nav-manifest entry. Also `migrateLegacyNavPath(path, commands)` — maps a stored path onto the CURRENT path of the page that used to answer to it, driven by each command's own `previousPaths` (declared in `server/lib/navManifest.js` beside the page that moved, shipped whole in the palette manifest). A pin is a stored route path, so without it a moved page's pin stops matching the manifest and the sidebar row silently vanishes on update. |
-| `providers` | AI-provider type predicates and helpers (`isCliProvider`, `isApiProvider`, `isCodexProvider`, `isCodexSubscriptionProvider` (subscription readiness is keyed on the `codex` command, never an editable id), `isAntigravityProvider`, `isLaunchableTuiProvider` — a TUI provider carrying the server-resolved `tuiCommandLine`, i.e. one a human can start at a shell prompt; shared by the Providers card's "Launch in Shell" button and the Shell page's launch menu so the two can't disagree — `PROVIDER_GATEWAYS` / `gatewayForProvider` / `isGatewayBackedProvider` (an OpenCode wrapper front-ending a hosted OpenAI-compatible gateway — `orcarouter`, `openrouter` — which inherits its API key at spawn time from the sibling API provider whose id equals the gateway id; reads the generic `gatewayBacked` marker and, forever, the legacy per-gateway boolean. MIRROR of `server/lib/providerGateways.js`, which the browser cannot import; keep the three copies in lockstep), `filterSelectableModels`, `resolveCliEffort` (mirror — what a stored effort actually runs as, so the picker can name a clamped level), `configuredDefaultIn` — the sentinel a provider's catalog carries, so a picker can render an option matching a sentinel-valued tier instead of a blank select — `getProviderTimeout`, `resolveEffectiveProvider` — the provider a record actually runs on (its pin, else the active provider) plus whether it fell back, so a "Default" option can name what it resolves to — `resolveSeriesRunLlm` (mirror of server `seriesLlmOverride.js`: which provider/model a Pipeline **series** run resolves to — per-run override → `series.llm` → active provider) and `providerModelLabel` (the one "Provider / model" phrasing), configured-default sentinels, and the claude/codex/agy thinking-effort levels — `effortLevelsForProvider`, mirror of server `providerModels.js`). `TOOL_FREE_LOCAL_PROVIDER_IDS`, `TOOL_FREE_LOCAL_TEXT_CAPABILITIES`, `isToolFreeLocalProvider`, `isToolFreeLocalModel`, and `toolFreeLocalSelectionPolicy` share the fail-closed local/text/no-tools filter used by security-sensitive provider/model/effort pickers. `PUBLIC_REVIEW_NO_TOOL_POSTURE` / `PUBLIC_REVIEW_ACTIONS_POSTURE`, `supportsPublicReviewPosture`, and `publicReviewSelectionPolicy` are the pr-reviewer counterpart (mirror of server `agentExecutionProfiles.js`): a pipeline stage names a POSTURE, the server publishes each provider's `publicReviewPostures` on `GET /api/providers`, and the picker offers exactly the providers this install can enforce — no vendor is named on either side. Also `isRunnerAllowedCommand(command, allowedCommands)` — would the CoS Agent Runner (`/spawn`, `/spawn-tui`) accept this command? Mirrors only the *normalization* in `server/cos-runner/allowedCommands.js` (the list itself arrives as `runnerAllowedCommands` on `GET /api/providers`, because the allowlist is an exec boundary and stays hand-curated server-side); returns `null` for "list not fetched / field blank" so a failed fetch never renders a warning. Pinned by `server/cos-runner/allowedCommands.parity.test.js`. `isPrivateNetworkEndpoint(endpoint)` — loopback, RFC1918/tailnet address, or a `.local`/`.ts.net`/single-label host, i.e. somewhere an unauthenticated OpenAI-compatible server is a normal setup rather than a missing API key. `isLocalInstanceProvider(provider)` — the narrower question, mirroring server `localProviderRuntime.js#isLocalInstanceEndpoint`: does this provider talk to a daemon on THIS machine (loopback, or no endpoint at all)? Gate anything that explains a provider by inspecting the host PortOS runs on — install state, "start it from Settings → Local LLM" — since `localBackendForProvider` matches by NAME and would otherwise claim a peer's LM Studio. And `credentialSource(provider)` plus `providerCardState(provider, { runtime, status, keySetFor, envVarSet })` + `PROVIDER_CARD_STATE` — is a provider ready to run, benched, blocked on a missing prerequisite (CLI not installed / API key absent / empty credential environment variable), or simply switched off? Reads the SERVER's `missingPrerequisites` (published per provider on `GET /api/providers` from `server/lib/providerPrerequisites.js`, and the same computation `getFallbackProvider` routes on) and adds the local-app runtime shape plus tri-state checks for stored, inherited, and process environment credentials. Unknown lookup values mean "not probed", never "missing". Drives the AI Providers page card colors and grouping — distinct from `ProviderReadiness`/`GET /api/providers/readiness`, which probes the local daemon behind a provider. `resolvesOutsidePortosPath(provider)` — does this provider resolve its binary somewhere the runtime probe never looked (an explicit path in `command`, or its own `PATH` in `envVars`)? Mirror of the same two guards in the server's `providerRuntimeKey`, and what keeps the card's badge from accusing a working provider the router happily routes. And `providerRuntimeKey(provider)` — the key a provider's runtime is published under by `GET /api/providers/runtimes`, so a card can show its CLI install status (bare binary name for a cli/tui provider, provider id for an API provider fronted by a local app); the runtime table itself stays server-side. |
+| `providers` | AI-provider type predicates and helpers (`isCliProvider`, `isApiProvider`, `isCodexProvider`, `isCodexSubscriptionProvider` (subscription readiness is keyed on the `codex` command, never an editable id), `isAntigravityProvider`, `isLaunchableTuiProvider` — a TUI provider carrying the server-resolved `tuiCommandLine`, i.e. one a human can start at a shell prompt; shared by the Providers card's "Launch in Shell" button and the Shell page's launch menu so the two can't disagree — `PROVIDER_GATEWAYS` / `gatewayForProvider` / `isGatewayBackedProvider` (an OpenCode wrapper front-ending a hosted OpenAI-compatible gateway — `orcarouter`, `openrouter` — which inherits its API key at spawn time from the sibling API provider whose id equals the gateway id; reads the generic `gatewayBacked` marker and, forever, the legacy per-gateway boolean. MIRROR of `server/lib/providerGateways.js`, which the browser cannot import; keep the three copies in lockstep), `filterSelectableModels`, `resolveCliEffort` (mirror — what a stored effort actually runs as, so the picker can name a clamped level), `configuredDefaultIn` — the sentinel a provider's catalog carries, so a picker can render an option matching a sentinel-valued tier instead of a blank select — `getProviderTimeout`, `resolveEffectiveProvider` — the provider a record actually runs on (its pin, else the active provider) plus whether it fell back, so a "Default" option can name what it resolves to — `resolveSeriesRunLlm` (mirror of server `seriesLlmOverride.js`: which provider/model a Pipeline **series** run resolves to — per-run override → `series.llm` → active provider) and `providerModelLabel` (the one "Provider / model" phrasing), configured-default sentinels, and the claude/codex/agy thinking-effort levels — `effortLevelsForProvider`, mirror of server `providerModels.js`). `TOOL_FREE_LOCAL_PROVIDER_IDS`, `TOOL_FREE_LOCAL_TEXT_CAPABILITIES`, `isToolFreeLocalProvider`, `isToolFreeLocalModel`, and `toolFreeLocalSelectionPolicy` share the fail-closed local/text/no-tools filter used by security-sensitive provider/model/effort pickers. `PUBLIC_REVIEW_NO_TOOL_POSTURE` / `PUBLIC_REVIEW_ACTIONS_POSTURE`, `supportsPublicReviewPosture`, `enforcesPublicReviewPosture`, and `publicReviewSelectionPolicy` are the pr-reviewer counterpart (mirror of server `agentExecutionProfiles.js`): a pipeline stage names a POSTURE, the server publishes each provider's `publicReviewPostures` (runnable) and `publicReviewEnforcedPostures` (backed by a vendor sandbox recipe) on `GET /api/providers`, and the picker offers exactly the providers this install can run the stage on — no vendor is named on either side. Also `isRunnerAllowedCommand(command, allowedCommands)` — would the CoS Agent Runner (`/spawn`, `/spawn-tui`) accept this command? Mirrors only the *normalization* in `server/cos-runner/allowedCommands.js` (the list itself arrives as `runnerAllowedCommands` on `GET /api/providers`, because the allowlist is an exec boundary and stays hand-curated server-side); returns `null` for "list not fetched / field blank" so a failed fetch never renders a warning. Pinned by `server/cos-runner/allowedCommands.parity.test.js`. `isPrivateNetworkEndpoint(endpoint)` — loopback, RFC1918/tailnet address, or a `.local`/`.ts.net`/single-label host, i.e. somewhere an unauthenticated OpenAI-compatible server is a normal setup rather than a missing API key. `isLocalInstanceProvider(provider)` — the narrower question, mirroring server `localProviderRuntime.js#isLocalInstanceEndpoint`: does this provider talk to a daemon on THIS machine (loopback, or no endpoint at all)? Gate anything that explains a provider by inspecting the host PortOS runs on — install state, "start it from Settings → Local LLM" — since `localBackendForProvider` matches by NAME and would otherwise claim a peer's LM Studio. And `credentialSource(provider)` plus `providerCardState(provider, { runtime, status, keySetFor, envVarSet })` + `PROVIDER_CARD_STATE` — is a provider ready to run, benched, blocked on a missing prerequisite (CLI not installed / API key absent / empty credential environment variable), or simply switched off? Reads the SERVER's `missingPrerequisites` (published per provider on `GET /api/providers` from `server/lib/providerPrerequisites.js`, and the same computation `getFallbackProvider` routes on) and adds the local-app runtime shape plus tri-state checks for stored, inherited, and process environment credentials. Unknown lookup values mean "not probed", never "missing". Drives the AI Providers page card colors and grouping — distinct from `ProviderReadiness`/`GET /api/providers/readiness`, which probes the local daemon behind a provider. `resolvesOutsidePortosPath(provider)` — does this provider resolve its binary somewhere the runtime probe never looked (an explicit path in `command`, or its own `PATH` in `envVars`)? Mirror of the same two guards in the server's `providerRuntimeKey`, and what keeps the card's badge from accusing a working provider the router happily routes. And `providerRuntimeKey(provider)` — the key a provider's runtime is published under by `GET /api/providers/runtimes`, so a card can show its CLI install status (bare binary name for a cli/tui provider, provider id for an API provider fronted by a local app); the runtime table itself stays server-side. |
| `systemCapabilities` | Server-annotated hardware compatibility helpers: preserve model/provider choices when compatibility is unknown, and hide only entries whose `hardwareCompatibility.state` is definitively `unavailable`. |
| `layeredIntelligenceReasons` | Canonical gloss for the Layered Intelligence loop's run-outcome reason tokens, shared by the on-demand toast and the durable "Last run" line (`formatLiReason`, `liReasonTone`, `LI_NEUTRAL_REASONS`). |
@@ -62,49 +63,4 @@ grep -i "what you want to do" client/src/utils/README.md
| Module | Purpose |
|---|---|
-| `characterXp` | Character HUD badge math: `computeAgeView` (age-based level + progress to next birthday), `diffXp` (XP-gain / birthday burst diff), `birthDateCta` (missing-birth-date call to action), plus the legacy `levelFromXP` XP-curve lookup used by `openWorldArtifacts`. |
-
-## OpenWorld — scene compute helpers
-
-Pure `compute*` functions that turn PortOS state into 3D-scene descriptors for the OpenWorld
-districts. One module per district/feature; each exports a `compute` entry point plus
-its tunable constants and placement helpers.
-
-| Module | Purpose |
-|---|---|
-| `openWorldActivityHeatmap` | Calendar activity → per-tile heat levels (`computeActivityHeatmap`, `tileLevel`). |
-| `openWorldAgentMotion` | Agent orbit/trail motion math (`computeAgentOrbit`, `computeAgentTrailPoints`, trail colors). |
-| `openWorldAiCore` | AI-ops core: model tiers, beam thickness, and `computeAiCore` / `computeAiCoreBeams` from live AI status events. |
-| `openWorldAppMetrics` | Per-building live telemetry: aggregate an app's `pm2Status` CPU/memory/uptime/restarts into one snapshot (`computeAppMetrics`, `cpuTone`, `hasPm2Error`, `buildingSignalTone` — façade LED / rooftop stress flags). |
-| `openWorldArtifacts` | Earned-artifact milestones (level/goal) → placed artifact descriptors (`computeArtifacts`). |
-| `openWorldBackupVault` | Backup-vault health/alerting state and color (`computeBackupVault`, `vaultHealth`). |
-| `openWorldChronotype` | Chronotype energy curve by hour → brightness/tempo modifiers (`computeChronotypeEnergy`). |
-| `openWorldCollectibles` | Deterministic Cyber Shards collectible placement, collection overlap math, and progress tracking (`getCollectiblesList`, `checkShardCollection`, `getCollectionStats`, `CYBER_SHARDS`). |
-| `openWorldDataHarbor` | Data Harbor pier district: DB table silos + data/ domain racks from /api/openworld/introspection (`computeDataHarbor`). |
-| `openWorldDistrictLayout` | Shared district layout math: auto-columns, grid placement, tallying, metric→height scaling. |
-| `openWorldEasterEggs` | Unlockable easter eggs from context (date/character/goals) → placements (`computeEasterEggs`). |
-| `openWorldFederation` | Sync-peer reachability horizon: status color/opacity, bridge state, peer placement (`computeFederationHorizon`). |
-| `openWorldFilter` | Status-filter definitions and app-filtering result (`computeFilterResult`). |
-| `openWorldFocusCamera` | Pure camera-framing math for OpenWorld's camera targets: orbital `position`/`target` framing one borough (`computeFocusCamera`) or a whole fast-travel region (`computeRegionCamera`) for a given aspect ratio + HUD safe area. |
-| `openWorldFocusState` | Resolve the `/openworld/apps/:appId` route param + app list into `{ hasFocus, focusedApp, notFound }`, deferring the not-found flag until apps finish loading (`resolveOpenWorldFocus`). |
-| `openWorldFlowLines` | Inter-building flow-line connections between active/agent nodes (`computeFlowConnections`). |
-| `openWorldGoalMonuments` | Goal monuments & forest: stall detection, milestone segments, placement (`computeGoalMonuments`, `computeGoalForest`). |
-| `openWorldHealthTower` | Health-metric tower segments from the latest health entry (`computeHealthTower`). |
-| `openWorldInteriorWindows` | Per-building interior-mapping window grid + selection predicate for InteriorMappingMaterial panes (`computeWindowGrid`, `buildingHasInteriorWindows`, `INTERIOR_WINDOW`). |
-| `openWorldJiraDistrict` | Jira ticket district: ticket state, sprint structures, placement (`computeJiraDistrict`). |
-| `openWorldMemoryDistrict` | Brain-graph memory district: category clustering, bridges, placement (`computeMemoryDistrict`). |
-| `openWorldMiniMap` | Mini-map projection of building positions into 2D bounds, plus opt-in waterfront geography (bay/shoreline/harbor) read from `openWorldPlan` (`computeMiniMap`, `projectPoint`, `geographyWorldPoints`, `projectGeography`). |
-| `openWorldPhotoMode` | Photo-mode camera presets, the demand-loop fly stepper, postcard stats, and screenshot filename (`getPreset`, `cyclePreset`, `stepFly`). |
-| `openWorldPlan` | Master town plan: district parcels, shoreline/bay, plaza, transit loop, street network (`PARCELS`, `WORLD`, `computeStreets`, `computeStreetProps`, `isInWater`). |
-| `openWorldProximity` | Unified player proximity detection across warp pads, buildings, easter eggs, and district landmarks (`detectProximity`, `getResolvedLandmarks`, `WORLD_LANDMARKS`). |
-| `openWorldRegions` | OpenWorld fast-travel registry: named regions over the `openWorldPlan` parcels, each mapped to the PortOS page it visualizes (`OPEN_WORLD_REGIONS`, `getRegion`, `listRegions`, `searchRegions`, `regionArrivalPoint`, `regionPath`). |
-| `openWorldPlayerRig` | Exploration player-rig math: third-person follow camera, boom collision, damping, facing, avatar state (`thirdPersonCamera`, `resolveBoom`, `dampAngle`, `moveFacing`, `avatarState`). |
-| `openWorldRenderBudget` | Pure Auto-quality render-budget state machine: capability-aware startup tier (`recommendOpenWorldStartTier`), p75 frame-time windows, hysteresis, cooldown, warm-up/gap rejection (`createRenderBudget`, `recordFrame`, `restartWarmup`, `resetRenderBudget`, `getEffectiveTier`, `QUALITY_TIERS`, `DEFAULT_RENDER_BUDGET_CONFIG`). |
-| `openWorldRooftops` | Deterministic rooftop fixture kits (antenna/tank/AC/dish) per app name (`computeRooftopKit`). |
-| `openWorldProductivity` | Productivity monument from same-day throughput and velocity tiers (`computeProductivityMonument`). |
-| `openWorldSoundscape` | Ambient soundscape: mood/energy classification, chord selection (`computeSoundscape`), and the manual mood override (`applyMoodOverride`). |
-| `openWorldSpeedPads` | Luminous road speed boost pads placement and geometric local-coordinate overlap detection (`getSpeedPadsList`, `checkSpeedPadOverlap`, `SPEED_PADS`). |
-| `openWorldTaskFlowRiver` | Task-flow river width/speed from backlog & throughput (`computeTaskFlowRiver`). |
-| `openWorldTaskQueue` | Task-queue state/color from status counts (`computeTaskQueue`). |
-| `openWorldTimeline` | Bounded activity-log batch appends plus density bins and timeline buckets (`appendEventLogBatch`, `computeActivityDensity`, `buildTimelineBuckets`). |
-| `openWorldVoiceMarker` | Voice-agent marker state/color/label from voice status (`computeVoiceMarker`). |
+| `characterXp` | Character age and birthday-progress helpers: `computeAgeView`, `diffXp`, `birthDateCta`, and legacy `levelFromXP`. |
diff --git a/client/src/utils/cronHelpers.js b/client/src/utils/cronHelpers.js
index f1ba4b6866..1acee43a01 100644
--- a/client/src/utils/cronHelpers.js
+++ b/client/src/utils/cronHelpers.js
@@ -22,6 +22,38 @@ const DOW_MAP = { '0': 'Sun', '1': 'Mon', '2': 'Tue', '3': 'Wed', '4': 'Thu', '5
export const DEFAULT_TIME = '07:00';
export const DEFAULT_CRON = '0 7 * * *';
+// Weekly default for a Monday-morning cadence (mirrors the server's
+// DEFAULT_WEEKLY_CRON), so a task converted from a weekly cadence never lands
+// on a weekend.
+export const DEFAULT_WEEKLY_CRON = '0 7 * * 1';
+
+const MINUTE_MS = 60 * 1000;
+const HOUR_MS = 60 * MINUTE_MS;
+const DAY_MS = 24 * HOUR_MS;
+const WEEK_MS = 7 * DAY_MS;
+
+/**
+ * Approximate a numeric interval as a 5-field cron expression. Mirrors the
+ * server's `cronFromIntervalMs` (server/services/taskScheduleConstants.js) so a
+ * numeric cadence picker and the server-side migration derive the SAME
+ * expression — keep the two in lockstep.
+ */
+export function cronFromIntervalMs(intervalMs) {
+ const ms = Number(intervalMs);
+ if (!Number.isFinite(ms) || ms <= 0) return DEFAULT_CRON;
+ if (ms === WEEK_MS) return DEFAULT_WEEKLY_CRON;
+ if (ms >= DAY_MS) return DEFAULT_CRON;
+ if (ms >= HOUR_MS) {
+ const hours = Math.round(ms / HOUR_MS);
+ if (hours <= 1) return '0 * * * *';
+ // Only an even divisor of 24 lays out evenly across a day.
+ const step = [2, 3, 4, 6, 8, 12].find((h) => h >= hours) || 12;
+ return `0 */${step} * * *`;
+ }
+ const minutes = Math.min(59, Math.max(1, Math.round(ms / MINUTE_MS)));
+ return `*/${minutes} * * * *`;
+}
+
// Sunday-first, matching cron's day-of-week numbering (0 = Sunday).
export const WEEKDAYS = [
{ value: 0, short: 'S', label: 'Sun' },
diff --git a/client/src/utils/formatters.js b/client/src/utils/formatters.js
index 2f5feed712..f820ce85f6 100644
--- a/client/src/utils/formatters.js
+++ b/client/src/utils/formatters.js
@@ -222,6 +222,34 @@ export function formatClockTime(date, { timeZone, seconds = true, hour12 } = {})
}, '');
}
+const HOUR_MERIDIEM = {
+ long: [' AM', ' PM'],
+ compact: ['AM', 'PM'],
+ tiny: ['a', 'p'],
+ lower: ['am', 'pm'],
+};
+
+/**
+ * Format a bare hour-of-day number (0-23) as a 12-hour clock label.
+ * The canonical home for what five components each hand-rolled, which is why
+ * the same 3pm used to render as "3 PM", "3PM", "3p" and "3pm" on four screens.
+ * One helper with a `style` option rather than four helpers: every caller wants
+ * the same noon/midnight arithmetic and differs only in separator and case.
+ * @param {number|string|null} hour - Hour of day, 0-23 (numeric strings accepted)
+ * @param {object} [options]
+ * @param {'long'|'compact'|'tiny'|'lower'} [options.style='long'] - `3 PM` / `3PM` / `3p` / `3pm`
+ * @param {string} [options.fallback='—'] - Rendered for missing/non-numeric input
+ * @returns {string} Formatted hour, or the fallback
+ */
+export function formatHourOfDay(hour, { style = 'long', fallback = '—' } = {}) {
+ if (hour === null || hour === undefined || hour === '') return fallback;
+ const h = Number(hour);
+ if (!Number.isFinite(h)) return fallback;
+ const [am, pm] = HOUR_MERIDIEM[style] || HOUR_MERIDIEM.long;
+ const hr = h % 12 === 0 ? 12 : h % 12;
+ return `${hr}${h < 12 ? am : pm}`;
+}
+
/**
* Format a duration in milliseconds as a human-readable string
* @param {number} ms - Duration in milliseconds
diff --git a/client/src/utils/formatters.test.js b/client/src/utils/formatters.test.js
index f14c0aae86..8fcf7499f9 100644
--- a/client/src/utils/formatters.test.js
+++ b/client/src/utils/formatters.test.js
@@ -3,7 +3,7 @@ import {
clamp, formatContextLength, formatDurationMin, formatDurationMs, formatEventDateTime, timeAgo, formatAgeDays,
formatCooldown, formatCountdown, recommendedRamGb, parseTimeoutMs, formatDurationSec, middleTruncate,
formatWeight, formatPercent, formatUsd, formatBytes,
- formatDateNumeric, formatTimeOfDaySeconds, formatClockTime, formatWeekdayDate,
+ formatDateNumeric, formatTimeOfDaySeconds, formatClockTime, formatHourOfDay, formatWeekdayDate,
formatMonthDay, formatMonthYear, formatWeekdayShort, formatWeekdayTime, formatDateFull, formatDateShort, formatDateTime,
localDateKey, shiftISODate,
} from './formatters.js';
@@ -528,6 +528,40 @@ describe('canonical date/time formatters (#3870)', () => {
});
});
+ describe('formatHourOfDay', () => {
+ // Each of the five components this replaced re-derived the noon/midnight
+ // wrap by hand; the table pins 0 → 12 AM and 12 → 12 PM for every style.
+ const cases = [
+ [0, '12 AM', '12AM', '12a', '12am'],
+ [1, '1 AM', '1AM', '1a', '1am'],
+ [11, '11 AM', '11AM', '11a', '11am'],
+ [12, '12 PM', '12PM', '12p', '12pm'],
+ [13, '1 PM', '1PM', '1p', '1pm'],
+ [23, '11 PM', '11PM', '11p', '11pm'],
+ ];
+
+ it.each(cases)('renders hour %i in every style', (hour, long, compact, tiny, lower) => {
+ expect(formatHourOfDay(hour)).toBe(long);
+ expect(formatHourOfDay(hour, { style: 'long' })).toBe(long);
+ expect(formatHourOfDay(hour, { style: 'compact' })).toBe(compact);
+ expect(formatHourOfDay(hour, { style: 'tiny' })).toBe(tiny);
+ expect(formatHourOfDay(hour, { style: 'lower' })).toBe(lower);
+ });
+
+ it('accepts a numeric string, as hour-keyed stats records supply', () => {
+ expect(formatHourOfDay('9', { style: 'compact' })).toBe('9AM');
+ expect(formatHourOfDay('15', { style: 'compact' })).toBe('3PM');
+ });
+
+ it('renders the fallback for missing or non-numeric input', () => {
+ expect(formatHourOfDay(null)).toBe('—');
+ expect(formatHourOfDay(undefined)).toBe('—');
+ expect(formatHourOfDay('')).toBe('—');
+ expect(formatHourOfDay('abc')).toBe('—');
+ expect(formatHourOfDay(null, { fallback: 'n/a' })).toBe('n/a');
+ });
+ });
+
describe('formatWeekdayDate', () => {
const day = new Date(2026, 2, 5);
diff --git a/client/src/utils/index.js b/client/src/utils/index.js
index ece34d5a7d..99d79b0dc3 100644
--- a/client/src/utils/index.js
+++ b/client/src/utils/index.js
@@ -40,40 +40,4 @@ export * from './fileUpload.js';
// === OpenWorld — character & avatar ===
export * from './characterXp.js';
-// === OpenWorld — scene compute helpers (one per district / feature) ===
-export * from './openWorldActivityHeatmap.js';
-export * from './openWorldAgentMotion.js';
-export * from './openWorldAiCore.js';
-export * from './openWorldAppMetrics.js';
-export * from './openWorldArtifacts.js';
-export * from './openWorldBackupVault.js';
-export * from './openWorldChronotype.js';
-export * from './openWorldDataHarbor.js';
-export * from './openWorldDistrictLayout.js';
-export * from './openWorldEasterEggs.js';
-export * from './openWorldFederation.js';
-export * from './openWorldFilter.js';
-export * from './openWorldFocusCamera.js';
-export * from './openWorldFocusState.js';
-export * from './openWorldFlowLines.js';
-export * from './openWorldGoalMonuments.js';
-export * from './openWorldHealthTower.js';
-export * from './openWorldInteriorWindows.js';
-export * from './openWorldJiraDistrict.js';
-export * from './openWorldMemoryDistrict.js';
-export * from './openWorldMiniMap.js';
-export * from './openWorldPhotoMode.js';
-export * from './openWorldPlan.js';
-export * from './openWorldPlayerRig.js';
-export * from './openWorldRenderBudget.js';
-export * from './openWorldRooftops.js';
-export * from './openWorldProductivity.js';
-export * from './openWorldSoundscape.js';
-export * from './openWorldCollectibles.js';
-export * from './openWorldProximity.js';
-export * from './openWorldSpeedPads.js';
-export * from './openWorldTaskFlowRiver.js';
-export * from './openWorldTaskQueue.js';
-export * from './openWorldTimeline.js';
-export * from './openWorldVoiceMarker.js';
-export * from './openWorldRegions.js';
+export * from './renderBudget.js';
diff --git a/client/src/utils/openWorldActivityHeatmap.js b/client/src/utils/openWorldActivityHeatmap.js
deleted file mode 100644
index e927a41698..0000000000
--- a/client/src/utils/openWorldActivityHeatmap.js
+++ /dev/null
@@ -1,113 +0,0 @@
-// Pure, deterministic helpers for OpenWorld's productivity-district activity heatmap
-// (roadmap 2.6 follow-up, issue #817): a GitHub-style contribution grid rendered as a field
-// of ground tiles laid out around the throughput monument. Each tile is one day; its glow scales
-// with that day's completed-task count relative to the busiest day in the window. The grid
-// matches the calendar payload's shape exactly — weeks run left→right (x), day-of-week runs
-// front→back (z) — so the field reads like the contribution chart on the productivity tab.
-// No three.js / React imports so the topology is unit-testable (mirrors openWorldProductivity.js).
-
-import { MONUMENT } from './openWorldProductivity';
-
-export const HEATMAP = {
- // Anchored just east of the monument plinth so the grid frames the obelisk without
- // overlapping its footprint. Monument lives at MONUMENT.position ([-48, 0, 28]).
- origin: [MONUMENT.position[0] + MONUMENT.baseWidth * 1.4, 0, MONUMENT.position[2] - 12],
- tileSize: 1.6, // square footprint of each day tile
- tileGap: 0.4, // gap between adjacent tiles
- tileHeight: 0.18, // a thin slab so the field stays low to the ground
- maxWeeks: 14, // cap columns so the field never sprawls past the district (calendar default is 12)
-};
-
-const ACTIVE_COLOR = '#22c55e'; // port-success — completed work, GitHub-contribution green
-const TODAY_COLOR = '#3b82f6'; // port-accent — highlight the current day
-const EMPTY_COLOR = '#1a2030'; // near port-card — a quiet, unlit "no activity" tile
-
-const clamp01 = (n) => Math.max(0, Math.min(1, n));
-
-// Coerce to a finite, positive count or 0. Tile counts are never negative and a garbage
-// value should read as "no activity," not crash the field.
-function countOrZero(value) {
- return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0;
-}
-
-// Map a day's task count to a 0..1 intensity against the window's busiest day. A zero-task
-// day is 0 (drawn dim/empty); the busiest day(s) reach 1. A 0/garbage `maxTasks` falls back
-// to 1 so a single active day still lights up rather than dividing by zero.
-export function tileLevel(tasks, maxTasks) {
- const t = countOrZero(tasks);
- if (t === 0) return 0;
- const max = countOrZero(maxTasks);
- return clamp01(t / (max || 1));
-}
-
-// Full derived view-model for the component. `calendarData` is the `getActivityCalendar`
-// payload (`{ weeks: [[{ date, dayOfWeek, tasks, isToday, isFuture, ... }]], maxTasks,
-// summary }`). A missing/non-object payload, or one with no usable weeks,
-// yields `present: false` and an empty tile list so the field simply doesn't render rather
-// than crashing.
-export function computeActivityHeatmap(calendarData) {
- const payload = calendarData && typeof calendarData === 'object' ? calendarData : {};
- const rawWeeks = Array.isArray(payload.weeks) ? payload.weeks : [];
- // Keep only the most recent `maxWeeks` columns so the field stays inside the district.
- const weeks = rawWeeks.slice(-HEATMAP.maxWeeks);
- const maxTasks = countOrZero(payload.maxTasks) || 1;
-
- const { tileSize, tileGap, tileHeight } = HEATMAP;
- const step = tileSize + tileGap;
-
- const tiles = [];
- let activeCount = 0;
- let totalTasks = 0;
-
- weeks.forEach((week, weekIndex) => {
- const days = Array.isArray(week) ? week : [];
- days.forEach((day) => {
- const dayObj = day && typeof day === 'object' ? day : {};
- // Future days in the trailing partial week aren't real activity — skip them so the
- // grid doesn't render a row of empty tiles past today.
- if (dayObj.isFuture) return;
- const dow = typeof dayObj.dayOfWeek === 'number' && Number.isFinite(dayObj.dayOfWeek)
- ? dayObj.dayOfWeek
- : 0;
- const tasks = countOrZero(dayObj.tasks);
- const level = tileLevel(tasks, maxTasks);
- const isToday = dayObj.isToday === true;
- if (tasks > 0) {
- activeCount += 1;
- totalTasks += tasks;
- }
- tiles.push({
- key: dayObj.date || `${weekIndex}-${dow}`,
- // Weeks run along x (columns), day-of-week runs along z (front→back rows).
- x: weekIndex * step,
- z: dow * step,
- // Animation phase derived from grid indices (not world coords) so the component's
- // shimmer reads as a coherent diagonal wave sweeping across the field rather than
- // near-random per-tile flicker.
- phase: (weekIndex + dow) * 0.18,
- tasks,
- level,
- isToday,
- // Today is always picked out in accent blue, even on a zero-task day — it's a
- // location sentinel, not an activity reading. Otherwise empty days sit dark and
- // active days glow green scaled by level.
- color: isToday ? TODAY_COLOR : tasks === 0 ? EMPTY_COLOR : ACTIVE_COLOR,
- // Emissive intensity: today always reads clearly (legible even at zero tasks); other
- // empty tiles barely glow; active tiles ramp with level.
- intensity: isToday ? Math.max(0.5, 0.25 + level * 0.7) : tasks === 0 ? 0.04 : 0.18 + level * 0.6,
- });
- });
- });
-
- return {
- origin: HEATMAP.origin,
- tileSize,
- tileHeight,
- present: tiles.length > 0,
- weekCount: weeks.length,
- tiles,
- activeCount,
- totalTasks,
- maxTasks,
- };
-}
diff --git a/client/src/utils/openWorldActivityHeatmap.test.js b/client/src/utils/openWorldActivityHeatmap.test.js
deleted file mode 100644
index 12cd77b6f0..0000000000
--- a/client/src/utils/openWorldActivityHeatmap.test.js
+++ /dev/null
@@ -1,143 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { HEATMAP, tileLevel, computeActivityHeatmap } from './openWorldActivityHeatmap';
-
-// Minimal calendar payload mirroring getActivityCalendar's shape: weeks of 7 days.
-function makeCalendar(taskGrid, { maxTasks, todayIndex } = {}) {
- let flat = 0;
- const weeks = taskGrid.map((week, wi) =>
- week.map((tasks, dow) => {
- const idx = flat++;
- return {
- date: `2026-w${wi}-d${dow}`,
- dayOfWeek: dow,
- tasks,
- successes: tasks,
- failures: 0,
- successRate: 100,
- isToday: idx === todayIndex,
- isFuture: false,
- };
- })
- );
- const computedMax = Math.max(1, ...taskGrid.flat());
- return { weeks, maxTasks: maxTasks ?? computedMax };
-}
-
-describe('tileLevel', () => {
- it('maps tasks/maxTasks into 0..1', () => {
- expect(tileLevel(5, 10)).toBe(0.5);
- expect(tileLevel(10, 10)).toBe(1);
- });
-
- it('treats a zero-task day as level 0', () => {
- expect(tileLevel(0, 10)).toBe(0);
- });
-
- it('clamps above the busiest day to 1', () => {
- expect(tileLevel(20, 10)).toBe(1);
- });
-
- it('falls back to max=1 (not divide-by-zero) for a single active day', () => {
- expect(tileLevel(3, 0)).toBe(1);
- expect(tileLevel(3, undefined)).toBe(1);
- });
-
- it('reads garbage / negative tasks as 0', () => {
- expect(tileLevel(undefined, 10)).toBe(0);
- expect(tileLevel(-4, 10)).toBe(0);
- expect(tileLevel('5', 10)).toBe(0);
- expect(tileLevel(NaN, 10)).toBe(0);
- });
-});
-
-describe('computeActivityHeatmap', () => {
- it('carries the anchored origin through unchanged', () => {
- const vm = computeActivityHeatmap(makeCalendar([[0, 0, 0, 0, 0, 0, 0]]));
- expect(vm.origin).toEqual(HEATMAP.origin);
- });
-
- it('builds one tile per non-future day with grid-aligned x/z', () => {
- const cal = makeCalendar([
- [0, 1, 2, 0, 0, 0, 0],
- [3, 0, 0, 0, 0, 0, 4],
- ]);
- const vm = computeActivityHeatmap(cal);
- expect(vm.present).toBe(true);
- expect(vm.tiles).toHaveLength(14);
- const step = HEATMAP.tileSize + HEATMAP.tileGap;
- // Week 1, day-of-week 6 (the "4-task" tile) sits at x=step, z=6*step.
- const last = vm.tiles.find((t) => t.tasks === 4);
- expect(last.x).toBeCloseTo(step);
- expect(last.z).toBeCloseTo(6 * step);
- });
-
- it('scales intensity by the busiest day and totals active days/tasks', () => {
- const cal = makeCalendar([[0, 2, 4, 0, 0, 0, 0]], { maxTasks: 4 });
- const vm = computeActivityHeatmap(cal);
- expect(vm.maxTasks).toBe(4);
- expect(vm.activeCount).toBe(2);
- expect(vm.totalTasks).toBe(6);
- const busiest = vm.tiles.find((t) => t.tasks === 4);
- const lighter = vm.tiles.find((t) => t.tasks === 2);
- expect(busiest.level).toBe(1);
- expect(lighter.level).toBe(0.5);
- expect(busiest.intensity).toBeGreaterThan(lighter.intensity);
- });
-
- it('draws empty days dim and dark, not glowing', () => {
- const vm = computeActivityHeatmap(makeCalendar([[0, 1, 0, 0, 0, 0, 0]]));
- const empty = vm.tiles.find((t) => t.tasks === 0);
- expect(empty.level).toBe(0);
- expect(empty.color).toBe('#1a2030');
- expect(empty.intensity).toBeLessThan(0.1);
- });
-
- it('accents today and keeps it legible even on a light day', () => {
- const cal = makeCalendar([[0, 1, 0, 0, 0, 0, 0]], { maxTasks: 10, todayIndex: 1 });
- const vm = computeActivityHeatmap(cal);
- const today = vm.tiles.find((t) => t.isToday);
- expect(today.color).toBe('#3b82f6'); // accent
- expect(today.intensity).toBeGreaterThanOrEqual(0.5);
- });
-
- it('picks out a zero-task today as accent, not as a dark empty day', () => {
- // todayIndex 0 has 0 tasks — it's still the "you are here" tile, not an empty day.
- const cal = makeCalendar([[0, 3, 0, 0, 0, 0, 0]], { maxTasks: 3, todayIndex: 0 });
- const vm = computeActivityHeatmap(cal);
- const today = vm.tiles.find((t) => t.isToday);
- expect(today.tasks).toBe(0);
- expect(today.color).toBe('#3b82f6'); // accent, NOT the empty #1a2030
- expect(today.intensity).toBeGreaterThanOrEqual(0.5);
- });
-
- it('skips future days in the trailing partial week', () => {
- const cal = makeCalendar([[1, 0, 0, 0, 0, 0, 0]]);
- cal.weeks[0][3].isFuture = true;
- cal.weeks[0][4].isFuture = true;
- const vm = computeActivityHeatmap(cal);
- expect(vm.tiles).toHaveLength(5); // 7 days minus 2 future
- });
-
- it('caps columns at maxWeeks, keeping the most recent', () => {
- const grid = Array.from({ length: 20 }, () => [1, 0, 0, 0, 0, 0, 0]);
- const vm = computeActivityHeatmap(makeCalendar(grid));
- expect(vm.weekCount).toBe(HEATMAP.maxWeeks);
- });
-
- it('handles missing / non-object / empty input as not-present without crashing', () => {
- for (const bad of [null, undefined, 'nope', 42, [], {}, { weeks: 'oops' }]) {
- const vm = computeActivityHeatmap(bad);
- expect(vm.present).toBe(false);
- expect(vm.tiles).toEqual([]);
- expect(vm.origin).toEqual(HEATMAP.origin);
- }
- });
-
- it('tolerates a non-object day or missing dayOfWeek', () => {
- const vm = computeActivityHeatmap({ weeks: [[null, { tasks: 2 }]], maxTasks: 2 });
- expect(vm.tiles).toHaveLength(2);
- // both fall back to dayOfWeek 0 → z 0
- expect(vm.tiles.every((t) => t.z === 0)).toBe(true);
- });
-});
-// @vitest-environment node
diff --git a/client/src/utils/openWorldAgentMotion.js b/client/src/utils/openWorldAgentMotion.js
deleted file mode 100644
index fff8c8329f..0000000000
--- a/client/src/utils/openWorldAgentMotion.js
+++ /dev/null
@@ -1,80 +0,0 @@
-// Pure, deterministic helpers for animating OpenWorld agent entities along an
-// orbit path and rendering a fading motion trail behind them. No three.js /
-// React imports here so the geometry math can be unit-tested in isolation
-// (mirrors the openWorldTimeline.js helper pattern).
-
-export const AGENT_MOTION = {
- orbitRadius: 0.9, // horizontal orbit radius around the building anchor
- orbitSpeed: 0.6, // radians/sec around the anchor
- bobAmp: 0.3, // vertical bob amplitude (matches the legacy AgentEntity bob)
- bobSpeed: 1.5, // vertical bob speed
- trailSeconds: 1.6, // how far back in time the trail samples
- trailSamples: 24, // points along the trail at full quality
-};
-
-// Position of an agent at elapsed time `t` (seconds), as an offset relative to
-// its building anchor. `index` fans multiple agents on the same building into
-// distinct orbit phases so their paths and trails don't overlap.
-export function computeAgentOrbit(t, { index = 0, radius, orbitSpeed, bobAmp, bobSpeed } = {}) {
- const r = radius ?? AGENT_MOTION.orbitRadius;
- const os = orbitSpeed ?? AGENT_MOTION.orbitSpeed;
- const ba = bobAmp ?? AGENT_MOTION.bobAmp;
- const bs = bobSpeed ?? AGENT_MOTION.bobSpeed;
- const phase = index * (Math.PI * 0.5); // quarter-turn fan per agent
- const angle = t * os + phase;
- return {
- x: Math.cos(angle) * r,
- y: Math.sin(t * bs + index) * ba,
- z: Math.sin(angle) * r,
- };
-}
-
-// Resolve how many trail samples to draw for a given quality density.
-// `particleDensity` ranges ~0.5 (low) .. 1.5 (ultra). At/above the low floor it
-// scales linearly to the full sample count; below the floor the trail is
-// dropped entirely (returns 0) so weak hardware pays nothing for it.
-export function resolveTrailSamples(particleDensity = 1, maxSamples = AGENT_MOTION.trailSamples) {
- if (!(particleDensity >= 0.5)) return 0;
- const scaled = Math.round((maxSamples * Math.min(1.5, particleDensity)) / 1.5);
- return Math.max(2, scaled);
-}
-
-// Sample the orbit path backwards from time `t` into `samples` points, newest
-// first. Writes a flat [x0,y0,z0, x1,y1,z1, ...] of offsets relative to the
-// anchor — the caller adds the anchor position when placing the trail. Pass a
-// pre-allocated `out` array (e.g. the geometry's Float32Array) to fill it in
-// place and avoid a per-frame allocation on the render hot path; otherwise a
-// fresh Array is allocated and returned.
-export function computeAgentTrailPoints(
- t,
- opts = {},
- samples = AGENT_MOTION.trailSamples,
- trailSeconds = AGENT_MOTION.trailSeconds,
- out = null,
-) {
- const n = Math.max(2, samples);
- const pts = out || new Array(n * 3);
- for (let i = 0; i < n; i++) {
- const dt = (i / (n - 1)) * trailSeconds; // 0 at head (newest), trailSeconds at tail
- const p = computeAgentOrbit(t - dt, opts);
- pts[i * 3] = p.x;
- pts[i * 3 + 1] = p.y;
- pts[i * 3 + 2] = p.z;
- }
- return pts;
-}
-
-// Per-vertex color ramp aligned with computeAgentTrailPoints: full color at the
-// head (newest) fading to black at the tail. `rgb` is a [r,g,b] triple in 0..1.
-// Pairs with an additive-blended lineBasicMaterial so black reads as transparent.
-export function computeTrailColors(rgb, samples = AGENT_MOTION.trailSamples) {
- const n = Math.max(2, samples);
- const colors = new Array(n * 3);
- for (let i = 0; i < n; i++) {
- const fade = 1 - i / (n - 1); // 1 at head .. 0 at tail
- colors[i * 3] = rgb[0] * fade;
- colors[i * 3 + 1] = rgb[1] * fade;
- colors[i * 3 + 2] = rgb[2] * fade;
- }
- return colors;
-}
diff --git a/client/src/utils/openWorldAgentMotion.test.js b/client/src/utils/openWorldAgentMotion.test.js
deleted file mode 100644
index 9717d75b86..0000000000
--- a/client/src/utils/openWorldAgentMotion.test.js
+++ /dev/null
@@ -1,145 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import {
- AGENT_MOTION,
- computeAgentOrbit,
- resolveTrailSamples,
- computeAgentTrailPoints,
- computeTrailColors,
-} from './openWorldAgentMotion';
-
-describe('computeAgentOrbit', () => {
- it('places the agent on a circle of the configured radius (ignoring bob)', () => {
- const { x, z } = computeAgentOrbit(0, { radius: 2, bobAmp: 0 });
- expect(Math.hypot(x, z)).toBeCloseTo(2, 6);
- });
-
- it('at t=0 with no bob sits at angle 0 (x=radius, z=0)', () => {
- const p = computeAgentOrbit(0, { radius: 1, bobAmp: 0 });
- expect(p.x).toBeCloseTo(1, 6);
- expect(p.z).toBeCloseTo(0, 6);
- expect(p.y).toBeCloseTo(0, 6);
- });
-
- it('advances around the circle as time progresses', () => {
- const a = computeAgentOrbit(0, { radius: 1, orbitSpeed: 1, bobAmp: 0 });
- const b = computeAgentOrbit(Math.PI / 2, { radius: 1, orbitSpeed: 1, bobAmp: 0 });
- // quarter turn: x→0, z→1
- expect(b.x).toBeCloseTo(0, 6);
- expect(b.z).toBeCloseTo(1, 6);
- expect(a.x).not.toBeCloseTo(b.x, 3);
- });
-
- it('fans agents on the same building into distinct phases by index', () => {
- const a0 = computeAgentOrbit(0, { radius: 1, bobAmp: 0, index: 0 });
- const a1 = computeAgentOrbit(0, { radius: 1, bobAmp: 0, index: 1 });
- expect(a0.x).not.toBeCloseTo(a1.x, 3);
- });
-
- it('applies vertical bob within the configured amplitude', () => {
- let maxY = 0;
- for (let t = 0; t < 20; t += 0.05) {
- maxY = Math.max(maxY, Math.abs(computeAgentOrbit(t, { bobAmp: 0.3 }).y));
- }
- expect(maxY).toBeGreaterThan(0.25);
- expect(maxY).toBeLessThanOrEqual(0.3 + 1e-9);
- });
-
- it('falls back to AGENT_MOTION defaults when opts are omitted', () => {
- const { x, z } = computeAgentOrbit(0);
- expect(Math.hypot(x, z)).toBeCloseTo(AGENT_MOTION.orbitRadius, 6);
- });
-});
-
-describe('resolveTrailSamples', () => {
- it('drops the trail entirely below the low-quality floor', () => {
- expect(resolveTrailSamples(0)).toBe(0);
- expect(resolveTrailSamples(0.4)).toBe(0);
- });
-
- it('renders a short trail at the low preset (0.5)', () => {
- expect(resolveTrailSamples(0.5, 24)).toBe(8);
- });
-
- it('scales up to the full sample count at ultra (1.5)', () => {
- expect(resolveTrailSamples(1.5, 24)).toBe(24);
- expect(resolveTrailSamples(1.0, 24)).toBe(16);
- });
-
- it('clamps densities above ultra to the max sample count', () => {
- expect(resolveTrailSamples(5, 24)).toBe(24);
- });
-
- it('never returns fewer than 2 points for a renderable trail', () => {
- expect(resolveTrailSamples(0.5, 2)).toBe(2);
- });
-});
-
-describe('computeAgentTrailPoints', () => {
- it('returns samples*3 flat coordinates', () => {
- const pts = computeAgentTrailPoints(1, {}, 10);
- expect(pts).toHaveLength(30);
- });
-
- it('head (first point) matches the current orbit position', () => {
- const t = 3.21;
- const opts = { index: 2 };
- const head = computeAgentOrbit(t, opts);
- const pts = computeAgentTrailPoints(t, opts, 12);
- expect(pts[0]).toBeCloseTo(head.x, 6);
- expect(pts[1]).toBeCloseTo(head.y, 6);
- expect(pts[2]).toBeCloseTo(head.z, 6);
- });
-
- it('tail (last point) matches the orbit position trailSeconds in the past', () => {
- const t = 3.21;
- const opts = { index: 0 };
- const tail = computeAgentOrbit(t - AGENT_MOTION.trailSeconds, opts);
- const pts = computeAgentTrailPoints(t, opts, 8, AGENT_MOTION.trailSeconds);
- const last = pts.length - 3;
- expect(pts[last]).toBeCloseTo(tail.x, 6);
- expect(pts[last + 1]).toBeCloseTo(tail.y, 6);
- expect(pts[last + 2]).toBeCloseTo(tail.z, 6);
- });
-
- it('clamps to a minimum of 2 points', () => {
- expect(computeAgentTrailPoints(0, {}, 1)).toHaveLength(6);
- });
-
- it('fills a pre-allocated out buffer in place and returns it (no allocation)', () => {
- const out = new Float32Array(8 * 3);
- const ret = computeAgentTrailPoints(2.5, { index: 1 }, 8, AGENT_MOTION.trailSeconds, out);
- expect(ret).toBe(out);
- const fresh = computeAgentTrailPoints(2.5, { index: 1 }, 8);
- for (let i = 0; i < fresh.length; i++) {
- expect(out[i]).toBeCloseTo(fresh[i], 5);
- }
- });
-});
-
-describe('computeTrailColors', () => {
- it('returns samples*3 channel values', () => {
- expect(computeTrailColors([1, 0.5, 0.25], 10)).toHaveLength(30);
- });
-
- it('is full color at the head and black at the tail', () => {
- const rgb = [0.2, 0.4, 0.8];
- const colors = computeTrailColors(rgb, 5);
- expect(colors[0]).toBeCloseTo(rgb[0], 6);
- expect(colors[1]).toBeCloseTo(rgb[1], 6);
- expect(colors[2]).toBeCloseTo(rgb[2], 6);
- const last = colors.length - 3;
- expect(colors[last]).toBeCloseTo(0, 6);
- expect(colors[last + 1]).toBeCloseTo(0, 6);
- expect(colors[last + 2]).toBeCloseTo(0, 6);
- });
-
- it('fades monotonically from head to tail', () => {
- const colors = computeTrailColors([1, 1, 1], 6);
- const reds = [];
- for (let i = 0; i < colors.length; i += 3) reds.push(colors[i]);
- for (let i = 1; i < reds.length; i++) {
- expect(reds[i]).toBeLessThan(reds[i - 1]);
- }
- });
-});
-// @vitest-environment node
diff --git a/client/src/utils/openWorldAiCore.js b/client/src/utils/openWorldAiCore.js
deleted file mode 100644
index c6028dce59..0000000000
--- a/client/src/utils/openWorldAiCore.js
+++ /dev/null
@@ -1,279 +0,0 @@
-// Pure, deterministic helpers for OpenWorld's AI Core landmark (roadmap 2.1): a central
-// spire above downtown from which all model activity radiates. The server broadcasts
-// phase-tagged `ai:status` events (start → model:loading → model:loaded → complete/error)
-// for every LLM/model call; this module tracks the in-flight set and derives the core's
-// glow/beam state. No three.js / React imports so the logic is unit-testable (mirrors
-// openWorldBackupVault.js).
-//
-// When a call originates on behalf of a managed app or CoS-agent workspace, the event
-// carries `appId` / `workspacePath`; `computeAiCoreBeams` maps that to the building's world
-// position and aims the beam there, scaling its thickness by the call's tokens/sec. Ops
-// with no building association (most PortOS-internal calls — taste summaries, embeddings)
-// keep the generic radial fan-out. Model tier is a best-effort heuristic from the model
-// name (the event has no explicit tier).
-
-import { PARCELS } from './openWorldPlan';
-
-export const AI_CORE = {
- position: PARCELS.aiCore.anchor, // world center — the kinetic core anchors The Port
- height: 6,
- apexY: 5.5, // beams/glow originate from the suspended seed above the plaza
- maxBeams: 6, // visible activity beams cap
- // How long an op with no terminal (complete/error) event lingers before being pruned,
- // so a dropped/never-finished call can't pin the core "busy" forever.
- opMaxAgeMs: 60_000,
- // How long after the last op start the core keeps its "just fired" flare.
- flareMs: 1200,
- // How long a just-completed op lingers as an "afterglow" beam. The provider only reports
- // token throughput on the completion event (which ends the op), so this window is what
- // lets a measured tokens/sec actually thicken a beam before it fades — without it, every
- // beam would render at the base thickness.
- afterglowMs: 1500,
- // Generic (un-targeted) radial beam length, in world units.
- radialLength: 16,
- // Beam thickness clamps (world units). Un-measured ops use `beamThicknessBase`;
- // measured throughput scales between base and max across `beamThicknessTopTokensPerSec`.
- beamThicknessBase: 0.18,
- beamThicknessMax: 0.6,
- beamThicknessTopTokensPerSec: 200,
-};
-
-const TIER_COLORS = {
- light: '#22d3ee', // cyan — fast/cheap models
- medium: '#3b82f6', // port-accent — standard models
- heavy: '#a855f7', // violet — large/expensive models
- idle: '#334155', // slate — core at rest
-};
-
-// Phases that mean the op is no longer in flight.
-const TERMINAL_PHASES = new Set(['complete', 'error']);
-
-// Best-effort model-tier classification from the model name. The `ai:status` event has no
-// explicit tier, so we pattern-match common families. Unknown models fall to `medium`.
-export function modelTier(model) {
- if (!model || typeof model !== 'string') return 'medium';
- const m = model.toLowerCase();
- if (/\b(opus|70b|72b|405b|large|heavy|ultra|max)\b/.test(m) || /:(70|72|405)b/.test(m)) return 'heavy';
- if (/(haiku|mini|flash|lite|light|tiny|small|1\.5b|3b|7b|8b|9b|nano|gemma)/.test(m)) return 'light';
- return 'medium';
-}
-
-export function tierColor(tier) {
- return TIER_COLORS[tier] || TIER_COLORS.medium;
-}
-
-// Rank tiers so the core can pick the "loudest" active tier when several ops overlap.
-const TIER_RANK = { light: 1, medium: 2, heavy: 3 };
-
-// Coerce a finite, non-negative tokens/sec from an event; otherwise null ("unknown").
-// Keeps "the provider didn't report usage" distinct from a measured zero. Only an actual
-// number counts — `null`/`undefined`/`''` (which `Number()` would coerce to 0/NaN) are
-// "unknown", per the sentinel-vs-empty convention.
-function readTokensPerSec(v) {
- return typeof v === 'number' && Number.isFinite(v) && v >= 0 ? v : null;
-}
-
-// True while an op should still draw a beam: in flight (within opMaxAgeMs), or just
-// completed and within its afterglow window. `done` ops keep their last `ts` and are timed
-// against `afterglowMs` so a measured tokens/sec gets a moment to thicken its beam.
-function opWithinWindow(op, now) {
- const age = now - op.ts;
- return op.done ? age <= AI_CORE.afterglowMs : age <= AI_CORE.opMaxAgeMs;
-}
-
-// Apply one `ai:status` event to the in-flight op map (plain object keyed by op id),
-// returning a NEW object. A terminal phase (complete/error) doesn't drop the op outright:
-// if it reported token throughput, the op is kept briefly as a `done` afterglow entry (so
-// the measured tokens/sec can size its beam — throughput only arrives at completion); a
-// terminal phase with no throughput drops the op immediately. Every other phase adds/updates
-// it with the event's model, building association (`appId`/`workspacePath`), last-known
-// tokens/sec, and a last-seen timestamp. Entries past their window are pruned so a
-// never-completed op can't wedge the core busy. Pure — `now` is injected.
-export function applyAiStatusEvent(ops, event, now = Date.now()) {
- const next = {};
- // Prune ops past their (in-flight or afterglow) window first.
- for (const [id, op] of Object.entries(ops || {})) {
- if (opWithinWindow(op, now)) next[id] = op;
- }
- const id = event?.id;
- if (!id) return next;
- const prev = next[id] || {};
- const tokensPerSec = readTokensPerSec(event.tokensPerSec) ?? prev.tokensPerSec ?? null;
- if (TERMINAL_PHASES.has(event.phase)) {
- // Drop immediately when there's nothing to show; otherwise keep a short afterglow so
- // the just-measured throughput visibly thickens the beam before it fades. Re-read the
- // association from the event (every phase event carries it) — the in-flight entry may
- // have been pruned at opMaxAgeMs on a long call, so falling back to `prev` alone would
- // lose the building target.
- if (tokensPerSec === null) {
- delete next[id];
- return next;
- }
- next[id] = {
- id,
- done: true,
- model: event.model || prev.model || null,
- tier: modelTier(event.model || prev.model),
- appId: event.appId ?? prev.appId ?? null,
- workspacePath: event.workspacePath ?? prev.workspacePath ?? null,
- tokensPerSec,
- ts: now,
- };
- return next;
- }
- next[id] = {
- id,
- done: false,
- model: event.model || prev.model || null,
- tier: modelTier(event.model || prev.model),
- // Association is stamped at start; carry it forward across intermediate phases even if
- // a later event omits it.
- appId: event.appId ?? prev.appId ?? null,
- workspacePath: event.workspacePath ?? prev.workspacePath ?? null,
- // Throughput typically only arrives on later phases; keep the last measured value.
- tokensPerSec,
- ts: now,
- };
- return next;
-}
-
-// True when `child` is `parent` itself or a path nested under it — a boundary-aware check so
-// `/repos/app` does NOT match the sibling `/repos/app-other`. Compares on a trailing-slash
-// normalized form. Pure.
-function isPathUnder(child, parent) {
- if (!child || !parent) return false;
- if (child === parent) return true;
- const base = parent.endsWith('/') ? parent : `${parent}/`;
- return child.startsWith(base);
-}
-
-// Drop every op past its window (in-flight or afterglow). Returns the SAME reference when
-// nothing changed so callers can skip a no-op state update; otherwise a new pruned object.
-// Used by a one-shot timer so a `done`/flare beam fades on schedule even when no further
-// `ai:status` event arrives to trigger the reducer. Pure.
-export function pruneAiOps(ops, now = Date.now()) {
- const entries = Object.entries(ops || {});
- const kept = entries.filter(([, op]) => opWithinWindow(op, now));
- if (kept.length === entries.length) return ops;
- return Object.fromEntries(kept);
-}
-
-// Map an op's building association to an app id. Prefers an explicit `appId`; otherwise
-// matches the app whose `repoPath` is the longest path-boundary prefix of the op's
-// `workspacePath` (a CoS-agent worktree lives under its app's repo). Returns null when
-// nothing matches. Pure.
-export function resolveOpAppId(op, apps = []) {
- if (op?.appId) return op.appId;
- const wp = op?.workspacePath;
- if (!wp || !Array.isArray(apps)) return null;
- let best = null;
- let bestLen = -1;
- for (const app of apps) {
- if (app?.repoPath && isPathUnder(wp, app.repoPath) && app.repoPath.length > bestLen) {
- best = app.id;
- bestLen = app.repoPath.length;
- }
- }
- return best;
-}
-
-// Map measured tokens/sec to a beam thickness, clamped between base and max. A null/unknown
-// throughput renders at the base thickness so an un-instrumented call still draws a beam.
-export function beamThickness(tokensPerSec) {
- const base = AI_CORE.beamThicknessBase;
- const max = AI_CORE.beamThicknessMax;
- const tps = readTokensPerSec(tokensPerSec);
- if (tps === null) return base;
- const frac = Math.min(tps / AI_CORE.beamThicknessTopTokensPerSec, 1);
- return base + (max - base) * frac;
-}
-
-// Derive the core's view-model from the in-flight op map. `lastStartTs` (the timestamp of
-// the most recent op start) drives a brief flare; `now` is injected for determinism.
-export function computeAiCore(ops, lastStartTs = 0, now = Date.now()) {
- // Only in-flight ops count toward "busy"/concurrency; `done` afterglow ops still draw a
- // beam (see computeAiCoreBeams) but the call is finished, so they don't inflate the count.
- const active = Object.values(ops || {}).filter(op => !op.done && now - op.ts <= AI_CORE.opMaxAgeMs);
- const activeCount = active.length;
- const busy = activeCount > 0;
- // Loudest active tier wins the color; idle when nothing is in flight.
- const tier = busy
- ? active.reduce((hi, op) => (TIER_RANK[op.tier] > TIER_RANK[hi] ? op.tier : hi), 'light')
- : 'idle';
- const flaring = lastStartTs > 0 && now - lastStartTs <= AI_CORE.flareMs;
- return {
- position: AI_CORE.position,
- height: AI_CORE.height,
- apexY: AI_CORE.apexY,
- activeCount,
- busy,
- tier,
- color: busy ? tierColor(tier) : TIER_COLORS.idle,
- // Beam count tracks concurrency, capped; at least one beam while flaring even if the
- // op already cleared (so a fast call still produces a visible pulse).
- beamCount: Math.min(Math.max(activeCount, flaring ? 1 : 0), AI_CORE.maxBeams),
- flaring,
- // Idle core breathes faintly; busy core glows; a flare spikes intensity briefly.
- intensity: busy ? 0.7 + Math.min(activeCount, 4) * 0.075 : flaring ? 0.6 : 0.25,
- };
-}
-
-// Build the per-beam descriptors the renderer draws from the apex. Each op still within its
-// window (in flight, or completed within the afterglow) becomes one beam: if it resolves to
-// a building whose world position is known, the beam is `targeted` and aims at that building
-// (in apex-local space); otherwise it falls back to a generic radial beam at an even angle.
-// Thickness scales by the op's measured tokens/sec — which is why afterglow ops are kept:
-// throughput is only known once the call completes.
-//
-// ops — op map (from applyAiStatusEvent), including afterglow `done` entries
-// positions — Map of building world positions (OpenWorldScene's layout)
-// apps — app records (for workspacePath → app resolution)
-// apexY — world Y of the spire apex (beams originate here)
-// color — fallback color when an op has no tier (e.g. the core's active-tier color)
-// now — injected for determinism
-//
-// Returns up to AI_CORE.maxBeams descriptors. Pure.
-export function computeAiCoreBeams(ops, positions, apps = [], apexY = AI_CORE.apexY, color, now = Date.now()) {
- const active = Object.values(ops || {})
- .filter(op => opWithinWindow(op, now))
- .slice(0, AI_CORE.maxBeams);
-
- const getPos = (id) => {
- if (!id || !positions) return null;
- // Tolerate both a Map and a plain object so callers can pass either.
- return typeof positions.get === 'function' ? positions.get(id) : positions[id];
- };
-
- // Radial fallback angles spread evenly across however many beams we draw, so a mix of
- // targeted + radial beams still reads as a balanced fan.
- const total = Math.max(active.length, 1);
-
- return active.map((op, i) => {
- const appId = resolveOpAppId(op, apps);
- const pos = getPos(appId);
- const thickness = beamThickness(op.tokensPerSec);
- // Color by the op's own tier so a `done` afterglow beam keeps its tier color even when
- // the core has gone idle (no in-flight op left to set the core color).
- const beamColor = op.tier ? tierColor(op.tier) : color;
- if (pos && Number.isFinite(pos.x) && Number.isFinite(pos.z)) {
- // Apex-local target: building roof-ish height so the beam arcs down to the building.
- return {
- key: op.id,
- targeted: true,
- appId,
- // Vector from the apex (group origin) to the building, in apex-local space.
- target: [pos.x, -apexY + 4, pos.z],
- thickness,
- color: beamColor,
- };
- }
- return {
- key: op.id,
- targeted: false,
- angle: (i / total) * Math.PI * 2,
- length: AI_CORE.radialLength,
- thickness,
- color: beamColor,
- };
- });
-}
diff --git a/client/src/utils/openWorldAiCore.test.js b/client/src/utils/openWorldAiCore.test.js
deleted file mode 100644
index 7fdc4c4cd7..0000000000
--- a/client/src/utils/openWorldAiCore.test.js
+++ /dev/null
@@ -1,364 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import {
- AI_CORE,
- modelTier,
- tierColor,
- applyAiStatusEvent,
- computeAiCore,
- resolveOpAppId,
- beamThickness,
- computeAiCoreBeams,
- pruneAiOps,
-} from './openWorldAiCore';
-
-const NOW = 1_000_000;
-const ev = (id, phase, model, extra = {}) => ({ id, phase, model, ...extra });
-
-describe('modelTier', () => {
- it('classifies heavy families', () => {
- expect(modelTier('claude-opus-4-8')).toBe('heavy');
- expect(modelTier('llama-3.1-70b')).toBe('heavy');
- expect(modelTier('qwen2.5:72b')).toBe('heavy');
- });
- it('classifies light families', () => {
- expect(modelTier('claude-haiku-4-5')).toBe('light');
- expect(modelTier('gpt-4o-mini')).toBe('light');
- expect(modelTier('gemini-1.5-flash')).toBe('light');
- expect(modelTier('llama-3-8b')).toBe('light');
- });
- it('defaults unknown / missing to medium', () => {
- expect(modelTier('some-unknown-model')).toBe('medium');
- expect(modelTier(undefined)).toBe('medium');
- expect(modelTier(null)).toBe('medium');
- expect(modelTier(42)).toBe('medium');
- });
-});
-
-describe('tierColor', () => {
- it('maps tiers to distinct colors and falls back to medium', () => {
- expect(tierColor('light')).toBe('#22d3ee');
- expect(tierColor('medium')).toBe('#3b82f6');
- expect(tierColor('heavy')).toBe('#a855f7');
- expect(tierColor('bogus')).toBe(tierColor('medium'));
- });
-});
-
-describe('applyAiStatusEvent', () => {
- it('adds an op on a non-terminal phase', () => {
- const ops = applyAiStatusEvent({}, ev('a', 'start', 'gpt-4o-mini'), NOW);
- expect(Object.keys(ops)).toEqual(['a']);
- expect(ops.a.tier).toBe('light');
- expect(ops.a.ts).toBe(NOW);
- });
-
- it('keeps the op across intermediate phases, refreshing its timestamp', () => {
- let ops = applyAiStatusEvent({}, ev('a', 'start', 'claude-opus-4-8'), NOW);
- ops = applyAiStatusEvent(ops, ev('a', 'model:loading', 'claude-opus-4-8'), NOW + 500);
- expect(Object.keys(ops)).toEqual(['a']);
- expect(ops.a.ts).toBe(NOW + 500);
- expect(ops.a.tier).toBe('heavy');
- });
-
- it('removes the op on complete and on error', () => {
- let ops = applyAiStatusEvent({}, ev('a', 'start', 'm'), NOW);
- ops = applyAiStatusEvent(ops, ev('b', 'start', 'm'), NOW);
- ops = applyAiStatusEvent(ops, ev('a', 'complete', 'm'), NOW + 1);
- expect(Object.keys(ops)).toEqual(['b']);
- ops = applyAiStatusEvent(ops, ev('b', 'error', 'm'), NOW + 2);
- expect(Object.keys(ops)).toEqual([]);
- });
-
- it('prunes ops older than opMaxAgeMs', () => {
- let ops = applyAiStatusEvent({}, ev('old', 'start', 'm'), NOW);
- // A later event prunes the stale 'old' op while adding the new one.
- ops = applyAiStatusEvent(ops, ev('new', 'start', 'm'), NOW + AI_CORE.opMaxAgeMs + 1);
- expect(Object.keys(ops)).toEqual(['new']);
- });
-
- it('ignores an event with no id but still prunes', () => {
- let ops = applyAiStatusEvent({}, ev('old', 'start', 'm'), NOW);
- ops = applyAiStatusEvent(ops, { phase: 'start', model: 'm' }, NOW + AI_CORE.opMaxAgeMs + 1);
- expect(Object.keys(ops)).toEqual([]);
- });
-
- it('returns a new object (no mutation of the input)', () => {
- const input = {};
- const out = applyAiStatusEvent(input, ev('a', 'start', 'm'), NOW);
- expect(out).not.toBe(input);
- expect(Object.keys(input)).toEqual([]);
- });
-});
-
-describe('computeAiCore', () => {
- it('is idle with no ops', () => {
- const vm = computeAiCore({}, 0, NOW);
- expect(vm.busy).toBe(false);
- expect(vm.activeCount).toBe(0);
- expect(vm.tier).toBe('idle');
- expect(vm.color).toBe('#334155');
- expect(vm.beamCount).toBe(0);
- expect(vm.intensity).toBe(0.25);
- expect(vm.position).toEqual(AI_CORE.position);
- });
-
- it('glows with the loudest active tier when ops overlap', () => {
- const ops = {
- a: { id: 'a', tier: 'light', ts: NOW },
- b: { id: 'b', tier: 'heavy', ts: NOW },
- c: { id: 'c', tier: 'medium', ts: NOW },
- };
- const vm = computeAiCore(ops, NOW, NOW);
- expect(vm.busy).toBe(true);
- expect(vm.activeCount).toBe(3);
- expect(vm.tier).toBe('heavy');
- expect(vm.color).toBe('#a855f7');
- expect(vm.beamCount).toBe(3);
- });
-
- it('caps beam count at maxBeams', () => {
- const ops = {};
- for (let i = 0; i < AI_CORE.maxBeams + 5; i++) ops[i] = { id: String(i), tier: 'medium', ts: NOW };
- const vm = computeAiCore(ops, NOW, NOW);
- expect(vm.activeCount).toBe(AI_CORE.maxBeams + 5);
- expect(vm.beamCount).toBe(AI_CORE.maxBeams);
- });
-
- it('ignores stale ops past opMaxAgeMs', () => {
- const ops = { a: { id: 'a', tier: 'heavy', ts: NOW } };
- const vm = computeAiCore(ops, 0, NOW + AI_CORE.opMaxAgeMs + 1);
- expect(vm.busy).toBe(false);
- expect(vm.activeCount).toBe(0);
- });
-
- it('flares briefly after a start even once the op has cleared', () => {
- const vm = computeAiCore({}, NOW, NOW + 200);
- expect(vm.busy).toBe(false);
- expect(vm.flaring).toBe(true);
- expect(vm.beamCount).toBe(1); // a fast call still pulses
- expect(vm.intensity).toBe(0.6);
- });
-
- it('stops flaring after flareMs', () => {
- const vm = computeAiCore({}, NOW, NOW + AI_CORE.flareMs + 1);
- expect(vm.flaring).toBe(false);
- expect(vm.beamCount).toBe(0);
- });
-});
-
-describe('applyAiStatusEvent building association', () => {
- it('stamps appId / workspacePath / tokensPerSec from the event', () => {
- const ops = applyAiStatusEvent({}, ev('a', 'start', 'gpt-4o', { appId: 'app-1', workspacePath: '/r/x', tokensPerSec: 90 }), NOW);
- expect(ops.a.appId).toBe('app-1');
- expect(ops.a.workspacePath).toBe('/r/x');
- expect(ops.a.tokensPerSec).toBe(90);
- });
-
- it('carries association forward when a later phase omits it', () => {
- let ops = applyAiStatusEvent({}, ev('a', 'start', 'm', { appId: 'app-1' }), NOW);
- ops = applyAiStatusEvent(ops, ev('a', 'model:loading', 'm'), NOW + 100);
- expect(ops.a.appId).toBe('app-1');
- });
-
- it('updates tokensPerSec when a later phase reports it, keeping last-known otherwise', () => {
- let ops = applyAiStatusEvent({}, ev('a', 'start', 'm'), NOW);
- expect(ops.a.tokensPerSec).toBeNull();
- ops = applyAiStatusEvent(ops, ev('a', 'provider:starting', 'm', { tokensPerSec: 150 }), NOW + 10);
- expect(ops.a.tokensPerSec).toBe(150);
- ops = applyAiStatusEvent(ops, ev('a', 'provider:starting', 'm'), NOW + 20);
- expect(ops.a.tokensPerSec).toBe(150); // preserved
- });
-
- it('drops a terminal op with no throughput immediately', () => {
- let ops = applyAiStatusEvent({}, ev('a', 'start', 'm', { appId: 'app-1' }), NOW);
- ops = applyAiStatusEvent(ops, ev('a', 'complete', 'm'), NOW + 1);
- expect(Object.keys(ops)).toEqual([]);
- });
-
- it('keeps a completed op as a done afterglow when it reported throughput', () => {
- let ops = applyAiStatusEvent({}, ev('a', 'start', 'm', { appId: 'app-1' }), NOW);
- ops = applyAiStatusEvent(ops, ev('a', 'complete', 'm', { tokens: 200, tokensPerSec: 80 }), NOW + 5);
- expect(ops.a.done).toBe(true);
- expect(ops.a.tokensPerSec).toBe(80);
- expect(ops.a.appId).toBe('app-1'); // association carried onto the afterglow
- });
-
- it('reads the association from the completion event when the in-flight op was already pruned', () => {
- // A long call (300s) whose in-flight entry was pruned at opMaxAgeMs (60s); the completion
- // event still carries appId, so the afterglow must target the building, not go radial.
- const completeEvent = ev('a', 'complete', 'm', { appId: 'app-9', workspacePath: '/r/x', tokensPerSec: 40 });
- const ops = applyAiStatusEvent({}, completeEvent, NOW); // empty prior map → no prev
- expect(ops.a.done).toBe(true);
- expect(ops.a.appId).toBe('app-9');
- expect(ops.a.workspacePath).toBe('/r/x');
- });
-
- it('prunes a done afterglow op once afterglowMs has passed', () => {
- let ops = applyAiStatusEvent({}, ev('a', 'start', 'm'), NOW);
- ops = applyAiStatusEvent(ops, ev('a', 'complete', 'm', { tokensPerSec: 80 }), NOW + 5);
- // A later event past the afterglow window prunes the done op.
- ops = applyAiStatusEvent(ops, ev('b', 'start', 'm'), NOW + 5 + AI_CORE.afterglowMs + 1);
- expect(Object.keys(ops)).toEqual(['b']);
- });
-});
-
-describe('resolveOpAppId', () => {
- const apps = [
- { id: 'outer', repoPath: '/repos/proj' },
- { id: 'inner', repoPath: '/repos/proj/packages/web' },
- ];
- it('prefers an explicit appId', () => {
- expect(resolveOpAppId({ appId: 'x', workspacePath: '/repos/proj' }, apps)).toBe('x');
- });
- it('matches the longest repoPath prefix of workspacePath', () => {
- expect(resolveOpAppId({ workspacePath: '/repos/proj/packages/web/src' }, apps)).toBe('inner');
- expect(resolveOpAppId({ workspacePath: '/repos/proj/docs' }, apps)).toBe('outer');
- expect(resolveOpAppId({ workspacePath: '/repos/proj' }, apps)).toBe('outer'); // exact match
- });
- it('does not match a sibling path that merely shares a prefix', () => {
- // /repos/proj-other is NOT under /repos/proj — boundary-aware, not raw startsWith.
- expect(resolveOpAppId({ workspacePath: '/repos/proj-other/src' }, apps)).toBeNull();
- });
- it('returns null when nothing matches', () => {
- expect(resolveOpAppId({ workspacePath: '/elsewhere' }, apps)).toBeNull();
- expect(resolveOpAppId({}, apps)).toBeNull();
- expect(resolveOpAppId(null, apps)).toBeNull();
- });
-});
-
-describe('readTokensPerSec sentinel (via afterglow retention)', () => {
- it('treats null / empty-string throughput as unknown, not a measured zero', () => {
- // A terminal event with no real throughput must drop the op (unknown), not keep it as
- // a zero-throughput afterglow.
- let ops = applyAiStatusEvent({}, ev('a', 'start', 'm'), NOW);
- ops = applyAiStatusEvent(ops, ev('a', 'complete', 'm', { tokensPerSec: null }), NOW + 1);
- expect(Object.keys(ops)).toEqual([]);
- ops = applyAiStatusEvent({}, ev('b', 'start', 'm'), NOW);
- ops = applyAiStatusEvent(ops, ev('b', 'complete', 'm', { tokensPerSec: '' }), NOW + 1);
- expect(Object.keys(ops)).toEqual([]);
- });
- it('keeps a genuine zero-throughput afterglow distinct from unknown', () => {
- let ops = applyAiStatusEvent({}, ev('a', 'start', 'm'), NOW);
- ops = applyAiStatusEvent(ops, ev('a', 'complete', 'm', { tokensPerSec: 0 }), NOW + 1);
- expect(ops.a?.done).toBe(true);
- expect(ops.a.tokensPerSec).toBe(0);
- });
-});
-
-describe('pruneAiOps', () => {
- it('returns the same reference when nothing expired', () => {
- const ops = { a: { id: 'a', ts: NOW } };
- expect(pruneAiOps(ops, NOW)).toBe(ops);
- });
- it('drops expired in-flight and afterglow ops', () => {
- const ops = {
- live: { id: 'live', ts: NOW },
- stale: { id: 'stale', ts: NOW - AI_CORE.opMaxAgeMs - 1 },
- glow: { id: 'glow', done: true, ts: NOW - AI_CORE.afterglowMs - 1 },
- };
- const pruned = pruneAiOps(ops, NOW);
- expect(Object.keys(pruned)).toEqual(['live']);
- });
-});
-
-describe('beamThickness', () => {
- it('renders base thickness for unknown / null throughput', () => {
- expect(beamThickness(null)).toBe(AI_CORE.beamThicknessBase);
- expect(beamThickness(undefined)).toBe(AI_CORE.beamThicknessBase);
- expect(beamThickness('nope')).toBe(AI_CORE.beamThicknessBase);
- });
- it('scales toward max with throughput and clamps at the top', () => {
- expect(beamThickness(0)).toBe(AI_CORE.beamThicknessBase);
- expect(beamThickness(AI_CORE.beamThicknessTopTokensPerSec)).toBe(AI_CORE.beamThicknessMax);
- expect(beamThickness(AI_CORE.beamThicknessTopTokensPerSec * 10)).toBe(AI_CORE.beamThicknessMax);
- const mid = beamThickness(AI_CORE.beamThicknessTopTokensPerSec / 2);
- expect(mid).toBeGreaterThan(AI_CORE.beamThicknessBase);
- expect(mid).toBeLessThan(AI_CORE.beamThicknessMax);
- });
-});
-
-describe('computeAiCoreBeams', () => {
- const apps = [{ id: 'app-1', repoPath: '/repos/one' }];
- const positions = new Map([['app-1', { x: 10, z: -6, district: 'downtown' }]]);
-
- it('targets the building for an app-associated op (apex-local target vector)', () => {
- const ops = { a: { id: 'a', appId: 'app-1', tokensPerSec: 200, ts: NOW } };
- const beams = computeAiCoreBeams(ops, positions, apps, AI_CORE.apexY, '#fff', NOW);
- expect(beams).toHaveLength(1);
- expect(beams[0].targeted).toBe(true);
- expect(beams[0].appId).toBe('app-1');
- expect(beams[0].target).toEqual([10, -AI_CORE.apexY + 4, -6]);
- expect(beams[0].thickness).toBe(AI_CORE.beamThicknessMax); // 200 tok/s → max
- });
-
- it('resolves a workspacePath op to its app building', () => {
- const ops = { a: { id: 'a', workspacePath: '/repos/one/worktrees/x', ts: NOW } };
- const beams = computeAiCoreBeams(ops, positions, apps, AI_CORE.apexY, '#fff', NOW);
- expect(beams[0].targeted).toBe(true);
- expect(beams[0].appId).toBe('app-1');
- });
-
- it('falls back to a radial beam when there is no building association', () => {
- const ops = { a: { id: 'a', ts: NOW }, b: { id: 'b', appId: 'unknown', ts: NOW } };
- const beams = computeAiCoreBeams(ops, positions, apps, AI_CORE.apexY, '#fff', NOW);
- expect(beams).toHaveLength(2);
- expect(beams.every(b => !b.targeted)).toBe(true);
- expect(beams[0]).toMatchObject({ angle: 0, length: AI_CORE.radialLength });
- });
-
- it('accepts a plain-object position map and caps at maxBeams', () => {
- const ops = {};
- for (let i = 0; i < AI_CORE.maxBeams + 4; i++) ops[i] = { id: String(i), ts: NOW };
- const beams = computeAiCoreBeams(ops, {}, apps, AI_CORE.apexY, '#fff', NOW);
- expect(beams).toHaveLength(AI_CORE.maxBeams);
- });
-
- it('ignores stale ops past opMaxAgeMs', () => {
- const ops = { a: { id: 'a', appId: 'app-1', ts: NOW } };
- const beams = computeAiCoreBeams(ops, positions, apps, AI_CORE.apexY, '#fff', NOW + AI_CORE.opMaxAgeMs + 1);
- expect(beams).toHaveLength(0);
- });
-
- it('colors each beam by its own tier, falling back to the passed color when tier is absent', () => {
- const ops = {
- a: { id: 'a', appId: 'app-1', tier: 'heavy', ts: NOW },
- b: { id: 'b', tier: 'light', ts: NOW }, // radial
- c: { id: 'c', ts: NOW }, // no tier → fallback color
- };
- const beams = computeAiCoreBeams(ops, positions, apps, AI_CORE.apexY, '#fallback', NOW);
- const byKey = Object.fromEntries(beams.map(b => [b.key, b]));
- expect(byKey.a.color).toBe(tierColor('heavy'));
- expect(byKey.b.color).toBe(tierColor('light'));
- expect(byKey.c.color).toBe('#fallback');
- });
-
- it('draws a done afterglow op with its measured thickness, then drops it after afterglowMs', () => {
- const ops = { a: { id: 'a', appId: 'app-1', done: true, tokensPerSec: 200, ts: NOW } };
- const within = computeAiCoreBeams(ops, positions, apps, AI_CORE.apexY, '#fff', NOW + AI_CORE.afterglowMs - 1);
- expect(within).toHaveLength(1);
- expect(within[0].targeted).toBe(true);
- expect(within[0].thickness).toBe(AI_CORE.beamThicknessMax);
- const after = computeAiCoreBeams(ops, positions, apps, AI_CORE.apexY, '#fff', NOW + AI_CORE.afterglowMs + 1);
- expect(after).toHaveLength(0);
- });
-});
-
-describe('computeAiCore with afterglow ops', () => {
- it('does not count a done afterglow op toward busy/activeCount', () => {
- const ops = {
- a: { id: 'a', tier: 'heavy', ts: NOW }, // in flight
- b: { id: 'b', tier: 'light', done: true, tokensPerSec: 50, ts: NOW }, // afterglow
- };
- const vm = computeAiCore(ops, 0, NOW);
- expect(vm.activeCount).toBe(1);
- expect(vm.busy).toBe(true);
- expect(vm.tier).toBe('heavy');
- });
-
- it('reads idle when only done afterglow ops remain', () => {
- const ops = { b: { id: 'b', tier: 'light', done: true, tokensPerSec: 50, ts: NOW } };
- const vm = computeAiCore(ops, 0, NOW);
- expect(vm.busy).toBe(false);
- expect(vm.activeCount).toBe(0);
- });
-});
-// @vitest-environment node
diff --git a/client/src/utils/openWorldAppMetrics.js b/client/src/utils/openWorldAppMetrics.js
deleted file mode 100644
index 327ba201b0..0000000000
--- a/client/src/utils/openWorldAppMetrics.js
+++ /dev/null
@@ -1,89 +0,0 @@
-// Per-building live telemetry (roadmap 1.1). `GET /api/apps` already ships per-process
-// PM2 metrics (`cpu` %, `memory` bytes, `uptime` ms, restart counts) inside each app's
-// `pm2Status` map — this module aggregates them into the single snapshot the building
-// hologram and focus panel render. Pure: no fetching, no React.
-
-const ONLINE_STATES = new Set(['online', 'running']);
-
-const sum = (values) => values.reduce((acc, v) => acc + (Number.isFinite(v) ? v : 0), 0);
-
-/**
- * Aggregate an app's per-process PM2 metrics into one building-level snapshot.
- *
- * CPU and memory sum across ONLINE processes only — a stopped or errored worker
- * reports no live usage, and counting it as 0 would silently deflate a hot app's
- * numbers. Uptime is the MINIMUM uptime among online processes: the honest "how
- * long has this whole app been stable" reading when any worker has recently
- * restarted. Restarts count every process (historical signal, live or not).
- *
- * @param {object|null} app - enriched app record (`pm2Status` from GET /api/apps)
- * @returns {{hasMetrics: boolean, totalProcs: number, onlineProcs: number,
- * cpuPercent: number|null, memBytes: number, uptimeMs: number|null,
- * restarts: number, unstableRestarts: number}}
- * `hasMetrics` is false when the app carries no PM2 status at all (non-PM2 apps,
- * archived shells, failed reads) so callers can omit the rows entirely instead of
- * rendering dashes.
- */
-export function computeAppMetrics(app) {
- const statuses = Object.values(app?.pm2Status || {});
- const online = statuses.filter((p) => ONLINE_STATES.has(p?.status));
- const uptimes = online.map((p) => p?.uptime).filter(Number.isFinite);
-
- return {
- hasMetrics: statuses.length > 0,
- totalProcs: statuses.length,
- onlineProcs: online.length,
- cpuPercent: online.length ? Math.round(sum(online.map((p) => p?.cpu)) * 10) / 10 : null,
- memBytes: sum(online.map((p) => p?.memory)),
- uptimeMs: uptimes.length ? Math.min(...uptimes) : null,
- restarts: sum(statuses.map((p) => p?.restarts)),
- unstableRestarts: sum(statuses.map((p) => p?.unstableRestarts)),
- };
-}
-
-/**
- * Stress tone for a live metric pair, shared by the hologram row and the focus
- * panel stat blocks so a hot building reads the same everywhere. Thresholds are
- * deliberately coarse — this is glanceable atmosphere, not monitoring.
- */
-export function cpuTone(cpuPercent) {
- if (!Number.isFinite(cpuPercent)) return 'idle';
- if (cpuPercent >= 85) return 'hot';
- if (cpuPercent >= 40) return 'busy';
- return 'calm';
-}
-
-const SIGNAL_COLORS = Object.freeze({
- errored: '#f43f5e',
- hot: '#ef4444',
- busy: '#f59e0b',
- online: '#10b981',
- idle: '#64748b',
-});
-
-export function hasPm2Error(pm2Status) {
- return Object.values(pm2Status || {}).some((p) => p?.status === 'errored' || p?.status === 'error');
-}
-
-/**
- * Glanceable façade/rooftop signal for a building. Playback hides live CPU/restart
- * atmosphere so a historical frame doesn't grow smoke from today's metrics; the
- * snapshot's own `status` (and any PM2 error on that frame) still paints the LED.
- */
-export function buildingSignalTone({ status, metrics, pm2Status, playback = false } = {}) {
- const pm2Errored = hasPm2Error(pm2Status);
- const errored = status === 'errored' || pm2Errored || (!playback && (metrics?.unstableRestarts || 0) > 0);
- const cpu = !playback && metrics?.hasMetrics ? cpuTone(metrics.cpuPercent) : 'idle';
- const hot = cpu === 'hot';
- const busy = cpu === 'busy';
- const tone = errored ? 'errored' : hot ? 'hot' : busy ? 'busy' : status === 'online' ? 'online' : 'idle';
- // Playback keeps the snapshot LED color but drops live atmosphere (pulse/smoke/sparks)
- // so a historical frame does not grow today's CPU plume.
- return {
- tone,
- color: SIGNAL_COLORS[tone],
- pulsing: !playback && (errored || hot || busy),
- smoke: !playback && hot,
- sparks: !playback && errored,
- };
-}
diff --git a/client/src/utils/openWorldAppMetrics.test.js b/client/src/utils/openWorldAppMetrics.test.js
deleted file mode 100644
index a89c35a945..0000000000
--- a/client/src/utils/openWorldAppMetrics.test.js
+++ /dev/null
@@ -1,147 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { computeAppMetrics, cpuTone, hasPm2Error, buildingSignalTone } from './openWorldAppMetrics';
-
-describe('computeAppMetrics', () => {
- it('sums cpu/memory across online processes and takes the minimum uptime', () => {
- const app = {
- pm2Status: {
- web: { status: 'online', cpu: 12.4, memory: 150 * 1024 * 1024, uptime: 5000 },
- worker: { status: 'online', cpu: 30.2, memory: 50 * 1024 * 1024, uptime: 1000 },
- },
- };
- expect(computeAppMetrics(app)).toMatchObject({
- hasMetrics: true,
- totalProcs: 2,
- onlineProcs: 2,
- cpuPercent: 42.6,
- memBytes: 200 * 1024 * 1024,
- // The youngest online process bounds "how long the whole app has been stable".
- uptimeMs: 1000,
- });
- });
-
- it('excludes non-online processes from live usage but keeps their restarts', () => {
- const app = {
- pm2Status: {
- web: { status: 'online', cpu: 10, memory: 1024, uptime: 60_000 },
- worker: { status: 'errored', cpu: 99, memory: 999_999, uptime: 5, restarts: 3, unstableRestarts: 2 },
- },
- };
- const m = computeAppMetrics(app);
- expect(m.onlineProcs).toBe(1);
- expect(m.cpuPercent).toBe(10);
- expect(m.memBytes).toBe(1024);
- expect(m.uptimeMs).toBe(60_000);
- expect(m.restarts).toBe(3);
- expect(m.unstableRestarts).toBe(2);
- });
-
- it('reports null cpu/uptime (not zero) when nothing is online', () => {
- const app = {
- pm2Status: {
- web: { status: 'stopped', cpu: 0, memory: 0 },
- },
- };
- const m = computeAppMetrics(app);
- expect(m.hasMetrics).toBe(true);
- expect(m.cpuPercent).toBeNull();
- expect(m.uptimeMs).toBeNull();
- expect(m.memBytes).toBe(0);
- });
-
- it('flags no metrics for non-PM2 / failed-read apps', () => {
- expect(computeAppMetrics({}).hasMetrics).toBe(false);
- expect(computeAppMetrics({ pm2Status: {} }).hasMetrics).toBe(false);
- expect(computeAppMetrics(null).hasMetrics).toBe(false);
- });
-
- it('tolerates missing numeric fields on status entries', () => {
- const app = { pm2Status: { web: { status: 'online' } } };
- const m = computeAppMetrics(app);
- expect(m.cpuPercent).toBe(0);
- expect(m.memBytes).toBe(0);
- expect(m.uptimeMs).toBeNull();
- });
-});
-
-describe('cpuTone', () => {
- it('buckets by threshold with an idle bucket for absent data', () => {
- expect(cpuTone(null)).toBe('idle');
- expect(cpuTone(0)).toBe('calm');
- expect(cpuTone(39.9)).toBe('calm');
- expect(cpuTone(40)).toBe('busy');
- expect(cpuTone(84.9)).toBe('busy');
- expect(cpuTone(85)).toBe('hot');
- });
-});
-
-describe('hasPm2Error', () => {
- it('treats errored and error process statuses as a PM2 error', () => {
- expect(hasPm2Error({ web: { status: 'online' } })).toBe(false);
- expect(hasPm2Error({ web: { status: 'errored' } })).toBe(true);
- expect(hasPm2Error({ web: { status: 'error' } })).toBe(true);
- expect(hasPm2Error(null)).toBe(false);
- });
-});
-
-describe('buildingSignalTone', () => {
- const hotMetrics = { hasMetrics: true, cpuPercent: 92, unstableRestarts: 0 };
- const calmMetrics = { hasMetrics: true, cpuPercent: 12, unstableRestarts: 0 };
-
- it('maps a healthy online app to a calm green LED with no rooftop effects', () => {
- expect(buildingSignalTone({
- status: 'online',
- metrics: calmMetrics,
- pm2Status: { web: { status: 'online' } },
- })).toMatchObject({
- tone: 'online',
- color: '#10b981',
- pulsing: false,
- smoke: false,
- sparks: false,
- });
- });
-
- it('raises smoke on a hot CPU and sparks on a PM2 error', () => {
- expect(buildingSignalTone({
- status: 'online',
- metrics: hotMetrics,
- pm2Status: { web: { status: 'online' } },
- })).toMatchObject({ tone: 'hot', pulsing: true, smoke: true, sparks: false });
-
- expect(buildingSignalTone({
- status: 'online',
- metrics: calmMetrics,
- pm2Status: { web: { status: 'errored' } },
- })).toMatchObject({ tone: 'errored', pulsing: true, smoke: false, sparks: true });
- });
-
- it('hides live CPU smoke during playback while keeping the snapshot status LED', () => {
- expect(buildingSignalTone({
- status: 'online',
- metrics: hotMetrics,
- pm2Status: { web: { status: 'online' } },
- playback: true,
- })).toMatchObject({
- tone: 'online',
- pulsing: false,
- smoke: false,
- sparks: false,
- });
- });
-
- it('keeps a snapshot error LED during playback without live sparks or pulse', () => {
- expect(buildingSignalTone({
- status: 'online',
- metrics: hotMetrics,
- pm2Status: { web: { status: 'errored' } },
- playback: true,
- })).toMatchObject({
- tone: 'errored',
- color: '#f43f5e',
- pulsing: false,
- smoke: false,
- sparks: false,
- });
- });
-});
diff --git a/client/src/utils/openWorldArtifacts.js b/client/src/utils/openWorldArtifacts.js
deleted file mode 100644
index 6f8b33c6eb..0000000000
--- a/client/src/utils/openWorldArtifacts.js
+++ /dev/null
@@ -1,132 +0,0 @@
-// Pure, deterministic helpers for OpenWorld's "earned artifacts" (roadmap 3.5): a small
-// "Hall of Achievements" cluster of trophies/statues that only appear once a milestone is
-// earned. Artifacts are DERIVED from data the city already has — no new endpoint:
-// • level milestones — D&D-style character level crossing a threshold (level-up statues)
-// • completed-goal milestones — every Nth completed life goal (achievement trophies)
-// Nothing earned → empty cluster (no crash). No three.js / React imports so the topology is
-// unit-testable (mirrors openWorldGoalMonuments.js / openWorldProductivity.js).
-
-import { levelFromXP } from './characterXp';
-import { gridIndexToPosition } from './openWorldDistrictLayout';
-import { PARCELS } from './openWorldPlan';
-
-// Hall of Achievements — a clear cluster in the +X / -Z quadrant, between the task-queue,
-// health tower, goal-monument row, and voice beacon. Artifacts lay out in a tight grid
-// centered on this base, anchored by the master plan (openWorldPlan.js).
-export const ARTIFACTS = {
- base: PARCELS.artifacts.anchor, // center of the cluster
- spacing: 6, // distance between adjacent pedestals (both x and z)
- columns: 3, // grid width before wrapping to the next row (toward -Z)
- pedestalWidth: 2,
- pedestalHeight: 1.2,
- emblemSize: 1.4, // size of the glowing emblem atop a pedestal
-};
-
-// Tiers drive the emblem color + glow so a richer milestone reads brighter. Colors reuse the
-// PortOS Tailwind design tokens.
-const TIERS = {
- bronze: { color: '#f59e0b', intensity: 0.45 }, // port-warning
- silver: { color: '#94a3b8', intensity: 0.6 }, // slate-light
- gold: { color: '#22c55e', intensity: 0.85 }, // port-success
-};
-
-// Level-up milestones: a statue per crossed level threshold. Below level 2 nothing is earned
-// (level 1 is the starting state). Higher levels read as richer tiers.
-export const LEVEL_MILESTONES = [
- { level: 2, tier: 'bronze', label: 'NOVICE' },
- { level: 5, tier: 'silver', label: 'ADEPT' },
- { level: 10, tier: 'gold', label: 'MASTER' },
-];
-
-// Completed-goal milestones: a trophy for every Nth completed life goal.
-export const GOAL_MILESTONES = [
- { count: 1, tier: 'bronze', label: 'FIRST GOAL' },
- { count: 5, tier: 'silver', label: '5 GOALS' },
- { count: 10, tier: 'gold', label: '10 GOALS' },
-];
-
-// Coerce a value to a finite number or return null (the "absent" sentinel) so an absent field
-// never collapses into a real 0.
-function finiteOrNull(value) {
- return typeof value === 'number' && Number.isFinite(value) ? value : null;
-}
-
-// Effective character level. The level is age-based now (#2673): a finite `level` is the
-// age-derived value; an explicit `null` means the birthDate is unset, so age — the ONLY level
-// source — is unknown and we return null WITHOUT resurrecting an XP-derived level (otherwise
-// XP would silently unlock level trophies/eggs while the HUD shows "LV —"). Only an ABSENT
-// `level` field (a legacy payload that predates age-based level) falls back to XP.
-export function effectiveLevel(character) {
- const lvl = character?.level;
- // Any finite nonnegative level is authoritative — including 0, a legitimate age level for a
- // birthDate less than a year ago. (`>= 1` would reject it and let XP unlock level artifacts.)
- if (Number.isFinite(lvl) && lvl >= 0) return Math.floor(lvl);
- if (lvl === null) return null;
- const xp = finiteOrNull(character?.xp);
- if (xp === null) return null;
- return levelFromXP(xp);
-}
-
-// Count completed goals from the goals payload. Accepts either the API wrapper `{ goals: [] }`
-// or a bare array; a missing/garbage input counts as 0.
-export function completedGoalCount(goals) {
- const list = Array.isArray(goals) ? goals : Array.isArray(goals?.goals) ? goals.goals : [];
- return list.filter((g) => g && typeof g === 'object' && g.status === 'completed').length;
-}
-
-// Build the list of EARNED artifact descriptors (kind/label/tier/threshold) from the three
-// inputs, before placement. Deterministic and side-effect-free.
-export function earnedArtifacts({ character, goals } = {}) {
- const earned = [];
-
- const level = effectiveLevel(character);
- if (level !== null) {
- for (const m of LEVEL_MILESTONES) {
- if (level >= m.level) {
- earned.push({ id: `level-${m.level}`, kind: 'level', tier: m.tier, label: m.label, threshold: m.level });
- }
- }
- }
-
- const completed = completedGoalCount(goals);
- for (const m of GOAL_MILESTONES) {
- if (completed >= m.count) {
- earned.push({ id: `goals-${m.count}`, kind: 'goal', tier: m.tier, label: m.label, threshold: m.count });
- }
- }
-
- return earned;
-}
-
-// Place a descriptor into the cluster grid. `index` is the 0-based slot; the grid fills left→
-// right across ARTIFACTS.columns, then wraps to the next row toward -Z. Centered on base.x.
-export function placeArtifact(descriptor, index) {
- const tier = TIERS[descriptor.tier] || TIERS.bronze;
- return {
- ...descriptor,
- color: tier.color,
- intensity: tier.intensity,
- position: gridIndexToPosition(index, {
- base: ARTIFACTS.base,
- columns: ARTIFACTS.columns,
- spacing: ARTIFACTS.spacing,
- rowDir: -1, // rows wrap toward -Z
- }),
- };
-}
-
-// Full derived view-model for the component. Injects all inputs; an all-absent / nothing-earned
-// state yields an empty cluster (`hasData: false`) rather than a crash. Ordering is stable
-// (level → goal, each ascending threshold) so the cluster doesn't reshuffle across
-// refetches.
-export function computeArtifacts({ character, goals } = {}) {
- const descriptors = earnedArtifacts({ character, goals });
- const artifacts = descriptors.map((d, i) => placeArtifact(d, i));
-
- return {
- base: ARTIFACTS.base,
- artifacts,
- total: artifacts.length,
- hasData: artifacts.length > 0,
- };
-}
diff --git a/client/src/utils/openWorldArtifacts.test.js b/client/src/utils/openWorldArtifacts.test.js
deleted file mode 100644
index 3ab030895d..0000000000
--- a/client/src/utils/openWorldArtifacts.test.js
+++ /dev/null
@@ -1,159 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import {
- ARTIFACTS,
- LEVEL_MILESTONES,
- GOAL_MILESTONES,
- effectiveLevel,
- completedGoalCount,
- earnedArtifacts,
- placeArtifact,
- computeArtifacts,
-} from './openWorldArtifacts';
-
-const completedGoals = (n) =>
- Array.from({ length: n }, (_, i) => ({ id: `g-${i}`, status: 'completed' }));
-
-describe('effectiveLevel', () => {
- it('trusts a stored, consistent level', () => {
- expect(effectiveLevel({ level: 7 })).toBe(7);
- expect(effectiveLevel({ level: 3.9 })).toBe(3); // floored
- });
-
- it('derives level from xp only when the level field is ABSENT (legacy payload)', () => {
- expect(effectiveLevel({ xp: 300 })).toBe(2); // 300 xp → level 2
- expect(effectiveLevel({ xp: 0 })).toBe(1);
- });
-
- it('returns null for an explicit null level (age unknown) — no XP fallback', () => {
- // Age-based level (#2673): birthDate unset → level: null. XP must not resurrect a level,
- // or trophies/eggs would unlock while the HUD shows "LV —".
- expect(effectiveLevel({ level: null, xp: 999999 })).toBeNull();
- expect(effectiveLevel({ level: null, xp: 0 })).toBeNull();
- });
-
- it('treats age level 0 (birthDate < 1yr ago) as authoritative, not an XP fallback', () => {
- expect(effectiveLevel({ level: 0, xp: 999999 })).toBe(0);
- });
-
- it('returns null when neither level nor xp is usable', () => {
- expect(effectiveLevel(null)).toBeNull();
- expect(effectiveLevel({})).toBeNull();
- expect(effectiveLevel({ level: null, xp: 'nope' })).toBeNull();
- });
-});
-
-describe('completedGoalCount', () => {
- it('counts only completed goals', () => {
- const goals = [
- { id: 'a', status: 'completed' },
- { id: 'b', status: 'active' },
- { id: 'c', status: 'completed' },
- { id: 'd', status: 'abandoned' },
- ];
- expect(completedGoalCount(goals)).toBe(2);
- });
-
- it('accepts the API wrapper { goals: [] }', () => {
- expect(completedGoalCount({ goals: completedGoals(3) })).toBe(3);
- });
-
- it('is 0 for missing / garbage / null-entry input', () => {
- for (const bad of [null, undefined, 'nope', 42, {}]) {
- expect(completedGoalCount(bad)).toBe(0);
- }
- expect(completedGoalCount([null, 'x', { status: 'completed' }])).toBe(1);
- });
-});
-
-describe('earnedArtifacts — thresholds', () => {
- it('returns nothing when nothing is earned', () => {
- expect(earnedArtifacts({})).toEqual([]);
- expect(earnedArtifacts({ character: { level: 1 }, goals: [] })).toEqual([]);
- });
-
- it('earns level milestones at/above each threshold', () => {
- const ids = earnedArtifacts({ character: { level: 5 } }).map((a) => a.id);
- expect(ids).toContain('level-2');
- expect(ids).toContain('level-5');
- expect(ids).not.toContain('level-10');
- });
-
- it('does not earn a level statue at level 1', () => {
- expect(earnedArtifacts({ character: { level: 1 } })).toEqual([]);
- });
-
- it('earns goal milestones for completed-goal counts', () => {
- const ids = earnedArtifacts({ goals: completedGoals(5) }).map((a) => a.id);
- expect(ids).toContain('goals-1');
- expect(ids).toContain('goals-5');
- expect(ids).not.toContain('goals-10');
- });
-
- it('combines level and goal sources in stable order', () => {
- const earned = earnedArtifacts({
- character: { level: 2 },
- goals: completedGoals(1),
- });
- expect(earned.map((a) => a.kind)).toEqual(['level', 'goal']);
- expect(earned.map((a) => a.id)).toEqual(['level-2', 'goals-1']);
- });
-
- it('attaches a tier + label to each descriptor', () => {
- const [a] = earnedArtifacts({ character: { level: 2 } });
- expect(a.tier).toBe('bronze');
- expect(a.label).toBe('NOVICE');
- expect(a.threshold).toBe(2);
- });
-});
-
-describe('placeArtifact', () => {
- it('centers the first row on base.x and resolves the tier color', () => {
- const placed = placeArtifact({ id: 'x', kind: 'level', tier: 'gold', label: 'L' }, 1);
- // index 1 is the middle column of a 3-wide grid → centered on base.x
- expect(placed.position[0]).toBeCloseTo(ARTIFACTS.base[0]);
- expect(placed.position[2]).toBe(ARTIFACTS.base[2]);
- expect(placed.color).toBe('#22c55e');
- expect(placed.intensity).toBeGreaterThan(0);
- });
-
- it('wraps to the next row toward -Z after columns are filled', () => {
- const first = placeArtifact({ id: 'a', tier: 'bronze' }, 0);
- const wrapped = placeArtifact({ id: 'b', tier: 'bronze' }, ARTIFACTS.columns);
- expect(wrapped.position[0]).toBeCloseTo(first.position[0]); // same column
- expect(wrapped.position[2]).toBe(ARTIFACTS.base[2] - ARTIFACTS.spacing); // one row back
- });
-
- it('falls back to the bronze tier for an unknown tier', () => {
- const placed = placeArtifact({ id: 'x', tier: 'unknown' }, 0);
- expect(placed.color).toBe('#f59e0b');
- });
-});
-
-describe('computeArtifacts', () => {
- it('handles all-absent input as an empty cluster (no crash)', () => {
- const vm = computeArtifacts({});
- expect(vm.artifacts).toEqual([]);
- expect(vm.total).toBe(0);
- expect(vm.hasData).toBe(false);
- expect(vm.base).toEqual(ARTIFACTS.base);
- });
-
- it('handles a fully-undefined call', () => {
- const vm = computeArtifacts();
- expect(vm.hasData).toBe(false);
- });
-
- it('places one artifact per earned milestone', () => {
- const vm = computeArtifacts({
- character: { level: 10 }, // 3 level milestones
- goals: completedGoals(10), // 3 goal milestones
- });
- expect(vm.total).toBe(LEVEL_MILESTONES.length + GOAL_MILESTONES.length);
- expect(vm.hasData).toBe(true);
- for (const a of vm.artifacts) {
- expect(a.position).toHaveLength(3);
- expect(typeof a.color).toBe('string');
- }
- });
-});
-// @vitest-environment node
diff --git a/client/src/utils/openWorldBackupVault.js b/client/src/utils/openWorldBackupVault.js
deleted file mode 100644
index d9a8b89d5e..0000000000
--- a/client/src/utils/openWorldBackupVault.js
+++ /dev/null
@@ -1,92 +0,0 @@
-// Pure, deterministic helpers for OpenWorld's backup-vault landmark (roadmap 2.3):
-// a small monument west of downtown whose color, label, and pulse reflect backup
-// health. The vault derives "staleness" from the time since the last snapshot, so a
-// backup that hasn't run in too long glows red and reads as needing attention. No
-// three.js / React imports so the topology is unit-testable (mirrors openWorldFederation.js).
-
-import { PARCELS } from './openWorldPlan';
-
-export const VAULT = {
- position: PARCELS.backupVault.anchor, // west of the building grid, anchored by the master plan (openWorldPlan.js)
- width: 5,
- height: 8,
- // Staleness thresholds, in ms since the last snapshot. A fresh backup is healthy;
- // between fresh and stale it ages to amber; past `staleMs` it goes red.
- freshMs: 24 * 60 * 60 * 1000, // < 1 day → healthy
- staleMs: 3 * 24 * 60 * 60 * 1000, // ≥ 3 days → stale
-};
-
-// Color per health classification — reuses the PortOS Tailwind design tokens so the
-// vault speaks the same visual language as the rest of the UI.
-const HEALTH_COLORS = {
- ok: '#22c55e', // port-success — recent snapshot
- aging: '#f59e0b', // port-warning — getting old
- stale: '#ef4444', // port-error — overdue
- error: '#ef4444', // port-error — last run failed
- degraded: '#f59e0b', // port-warning — files saved but DB dump failed
- never: '#64748b', // slate — never backed up / not configured
- running: '#3b82f6', // port-accent — a backup is in flight
-};
-
-// Map persisted backup state → a health classification. `state.status` is the stored
-// status ('never' | 'ok' | 'degraded' | 'error'); `state.lastRun` is the ISO timestamp of the last
-// run (or null); `state.running` is set true while a backup is in flight (socket-driven).
-// `now` is injected so the staleness derivation is deterministic in tests.
-export function vaultHealth(state, now = Date.now()) {
- if (state?.running) return 'running';
- const status = state?.status || 'never';
- if (status === 'error') return 'error';
- // 'degraded' = files backed up but the DB dump failed — alert, don't read as
- // PROTECTED. Classified before the staleness path so a recent degraded run
- // (fresh lastRun) can't fall through to 'ok'.
- if (status === 'degraded') return 'degraded';
- if (status === 'never' || !state?.lastRun) return 'never';
- const last = new Date(state.lastRun).getTime();
- if (!Number.isFinite(last)) return 'never';
- const age = now - last;
- if (age >= VAULT.staleMs) return 'stale';
- if (age >= VAULT.freshMs) return 'aging';
- return 'ok';
-}
-
-export function vaultColor(health) {
- return HEALTH_COLORS[health] || HEALTH_COLORS.never;
-}
-
-// Should the vault read as needing attention (urgent pulse, brighter glow)?
-export function vaultIsAlerting(health) {
- return health === 'stale' || health === 'error' || health === 'degraded';
-}
-
-// Short uppercase label rendered under the monument.
-export function vaultStatusLabel(health) {
- switch (health) {
- case 'running': return 'BACKING UP';
- case 'ok': return 'PROTECTED';
- case 'aging': return 'AGING';
- case 'stale': return 'STALE';
- case 'error': return 'FAILED';
- case 'degraded': return 'DB FAILED';
- default: return 'NO BACKUP';
- }
-}
-
-// Full derived view-model for the component: geometry + health + color + alert flag +
-// emissive intensity (brighter while running or alerting, calm otherwise). `now` is
-// injected so the whole view-model is deterministic under test.
-export function computeBackupVault(state, now = Date.now()) {
- const health = vaultHealth(state, now);
- const alerting = vaultIsAlerting(health);
- return {
- position: VAULT.position,
- width: VAULT.width,
- height: VAULT.height,
- health,
- color: vaultColor(health),
- alerting,
- running: health === 'running',
- statusLabel: vaultStatusLabel(health),
- lastRun: state?.lastRun ?? null,
- intensity: health === 'running' ? 1 : alerting ? 0.85 : 0.5,
- };
-}
diff --git a/client/src/utils/openWorldBackupVault.test.js b/client/src/utils/openWorldBackupVault.test.js
deleted file mode 100644
index 0154974f7e..0000000000
--- a/client/src/utils/openWorldBackupVault.test.js
+++ /dev/null
@@ -1,134 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import {
- VAULT,
- vaultHealth,
- vaultColor,
- vaultIsAlerting,
- vaultStatusLabel,
- computeBackupVault,
-} from './openWorldBackupVault';
-
-const NOW = new Date('2026-06-03T12:00:00Z').getTime();
-const hoursAgo = (h) => new Date(NOW - h * 60 * 60 * 1000).toISOString();
-const daysAgo = (d) => new Date(NOW - d * 24 * 60 * 60 * 1000).toISOString();
-
-describe('vaultHealth', () => {
- it('returns "never" when there is no state, no lastRun, or status never', () => {
- expect(vaultHealth(undefined, NOW)).toBe('never');
- expect(vaultHealth({}, NOW)).toBe('never');
- expect(vaultHealth({ status: 'never', lastRun: null }, NOW)).toBe('never');
- });
-
- it('returns "never" when lastRun is an unparseable timestamp', () => {
- expect(vaultHealth({ status: 'ok', lastRun: 'not-a-date' }, NOW)).toBe('never');
- });
-
- it('returns "ok" for a snapshot inside the fresh window', () => {
- expect(vaultHealth({ status: 'ok', lastRun: hoursAgo(2) }, NOW)).toBe('ok');
- });
-
- it('ages to "aging" between the fresh and stale thresholds', () => {
- expect(vaultHealth({ status: 'ok', lastRun: daysAgo(2) }, NOW)).toBe('aging');
- });
-
- it('goes "stale" once past the stale threshold', () => {
- expect(vaultHealth({ status: 'ok', lastRun: daysAgo(5) }, NOW)).toBe('stale');
- });
-
- it('treats the thresholds as inclusive lower bounds', () => {
- expect(vaultHealth({ status: 'ok', lastRun: new Date(NOW - VAULT.freshMs).toISOString() }, NOW)).toBe('aging');
- expect(vaultHealth({ status: 'ok', lastRun: new Date(NOW - VAULT.staleMs).toISOString() }, NOW)).toBe('stale');
- });
-
- it('reports "error" when the last run failed, regardless of age', () => {
- expect(vaultHealth({ status: 'error', lastRun: hoursAgo(1) }, NOW)).toBe('error');
- });
-
- it('reports "running" when a backup is in flight, overriding everything', () => {
- expect(vaultHealth({ status: 'error', lastRun: daysAgo(9), running: true }, NOW)).toBe('running');
- });
-
- it('reports "degraded" when files saved but the DB dump failed, even on a fresh run', () => {
- expect(vaultHealth({ status: 'degraded', lastRun: hoursAgo(1) }, NOW)).toBe('degraded');
- });
-});
-
-describe('vaultColor', () => {
- it('maps each health to a distinct token; stale and error share the error red', () => {
- expect(vaultColor('ok')).toBe('#22c55e');
- expect(vaultColor('aging')).toBe('#f59e0b');
- expect(vaultColor('stale')).toBe('#ef4444');
- expect(vaultColor('error')).toBe('#ef4444');
- expect(vaultColor('running')).toBe('#3b82f6');
- expect(vaultColor('never')).toBe('#64748b');
- expect(vaultColor('degraded')).toBe('#f59e0b');
- });
-
- it('falls back to the never color for an unknown health', () => {
- expect(vaultColor('bogus')).toBe(vaultColor('never'));
- });
-});
-
-describe('vaultIsAlerting', () => {
- it('alerts on stale, error, or degraded', () => {
- expect(vaultIsAlerting('stale')).toBe(true);
- expect(vaultIsAlerting('error')).toBe(true);
- expect(vaultIsAlerting('degraded')).toBe(true);
- expect(vaultIsAlerting('ok')).toBe(false);
- expect(vaultIsAlerting('aging')).toBe(false);
- expect(vaultIsAlerting('running')).toBe(false);
- expect(vaultIsAlerting('never')).toBe(false);
- });
-});
-
-describe('vaultStatusLabel', () => {
- it('gives a label for every health and a default for unknown', () => {
- expect(vaultStatusLabel('running')).toBe('BACKING UP');
- expect(vaultStatusLabel('ok')).toBe('PROTECTED');
- expect(vaultStatusLabel('aging')).toBe('AGING');
- expect(vaultStatusLabel('stale')).toBe('STALE');
- expect(vaultStatusLabel('error')).toBe('FAILED');
- expect(vaultStatusLabel('degraded')).toBe('DB FAILED');
- expect(vaultStatusLabel('never')).toBe('NO BACKUP');
- expect(vaultStatusLabel('bogus')).toBe('NO BACKUP');
- });
-});
-
-describe('computeBackupVault', () => {
- it('carries the fixed geometry through unchanged', () => {
- const vm = computeBackupVault({ status: 'ok', lastRun: hoursAgo(1) }, NOW);
- expect(vm.position).toEqual(VAULT.position);
- expect(vm.width).toBe(VAULT.width);
- expect(vm.height).toBe(VAULT.height);
- });
-
- it('a fresh backup is calm, protected, and not alerting', () => {
- const vm = computeBackupVault({ status: 'ok', lastRun: hoursAgo(1) }, NOW);
- expect(vm.health).toBe('ok');
- expect(vm.alerting).toBe(false);
- expect(vm.running).toBe(false);
- expect(vm.statusLabel).toBe('PROTECTED');
- expect(vm.intensity).toBe(0.5);
- });
-
- it('a stale backup alerts and burns brighter', () => {
- const vm = computeBackupVault({ status: 'ok', lastRun: daysAgo(5) }, NOW);
- expect(vm.health).toBe('stale');
- expect(vm.alerting).toBe(true);
- expect(vm.intensity).toBe(0.85);
- expect(vm.color).toBe('#ef4444');
- });
-
- it('a running backup is the brightest and exposes running=true', () => {
- const vm = computeBackupVault({ status: 'ok', lastRun: hoursAgo(1), running: true }, NOW);
- expect(vm.health).toBe('running');
- expect(vm.running).toBe(true);
- expect(vm.intensity).toBe(1);
- });
-
- it('passes lastRun through for the time-since label, null when never', () => {
- expect(computeBackupVault({ status: 'ok', lastRun: hoursAgo(3) }, NOW).lastRun).toBe(hoursAgo(3));
- expect(computeBackupVault({ status: 'never' }, NOW).lastRun).toBeNull();
- });
-});
-// @vitest-environment node
diff --git a/client/src/utils/openWorldChronotype.js b/client/src/utils/openWorldChronotype.js
deleted file mode 100644
index 1a658e2ddc..0000000000
--- a/client/src/utils/openWorldChronotype.js
+++ /dev/null
@@ -1,138 +0,0 @@
-// Pure, deterministic helpers for OpenWorld's chronotype energy overlay (roadmap 3.1):
-// the city brightens and quickens during the user's peak focus hours and dims/slows
-// during wind-down and sleep. Energy is derived from the digital-twin chronotype
-// profile's recommended daily schedule (wake / peak focus / wind-down / sleep). No
-// three.js / React imports so the topology is unit-testable (mirrors openWorldBackupVault.js).
-//
-// The hour-of-day is ALWAYS injected as a parameter — no `new Date()` here — so the
-// energy curve is deterministic in tests. The component computes the live hour and
-// passes it in.
-//
-// `computeChronotypeEnergy` is the module's ONLY export: everything below it is an
-// internal step of that one calculation, and keeping it unexported keeps the flat
-// `client/src/utils/index.js` barrel free of generic names like `parseHour` that
-// would collide with a future module.
-
-import { clamp } from './formatters.js';
-
-// Sentinel energy for "no usable profile" — distinct from any real curve value so a
-// missing profile is recognizable. It maps to NEUTRAL_MODIFIERS (no visible change),
-// NOT to peak brightness, so an unconfigured city looks untouched rather than washed out.
-const NEUTRAL_ENERGY = 1.0;
-
-// Tasteful clamp ranges so the overlay stays atmospheric, never washing out or
-// blacking out the existing scene. Brightness rides slightly above/below 1; tempo
-// (animation-speed multiplier) is gentler still.
-const ENERGY_RANGE = {
- brightnessMin: 0.7,
- brightnessMax: 1.15,
- tempoMin: 0.8,
- tempoMax: 1.15,
-};
-
-// What the overlay applies when there's no usable chronotype profile: a true no-op —
-// brightness and tempo at 1.0 so the scene renders exactly as it would without the
-// overlay. Keeps "unconfigured / failed to fetch" visually distinct from "low energy."
-const NEUTRAL_MODIFIERS = { energy: NEUTRAL_ENERGY, brightness: 1.0, tempo: 1.0 };
-
-// Parse "HH:MM" → fractional hours in [0,24). Returns NaN for unparseable input.
-// Hours past midnight that belong to "today's" late night (e.g. a 00:30 sleep time)
-// are returned as-is (0.5); callers that need a continuous timeline normalize.
-function parseHour(str) {
- if (typeof str !== 'string') return NaN;
- const [h, m] = str.split(':').map(Number);
- if (!Number.isFinite(h) || !Number.isFinite(m)) return NaN;
- const val = h + m / 60;
- return val >= 0 && val < 24 ? val : NaN;
-}
-
-// Circular distance between two hours on a 24h clock (shortest arc), in [0,12].
-function hourDistance(a, b) {
- const d = Math.abs(a - b) % 24;
- return Math.min(d, 24 - d);
-}
-
-// Build the set of energy anchor points from the chronotype recommendations.
-// Each anchor is { hour, energy } where energy ∈ [0,1]: 1 at the center of peak
-// focus, low through wind-down and sleep, mid on waking. Returns null when the
-// profile lacks the timing fields we need (caller falls back to neutral).
-function buildAnchors(profile) {
- const rec = profile?.recommendations;
- if (!rec) return null;
-
- const wake = parseHour(rec.wakeTime);
- const peakStart = parseHour(rec.peakFocusStart);
- const peakEnd = parseHour(rec.peakFocusEnd);
- const windDown = parseHour(rec.windDownStart);
- const sleep = parseHour(rec.sleepTime);
-
- const anchors = [];
-
- // Peak focus center → maximum energy. This is the anchor the overlay is built
- // around, so we require at least the peak window to be present.
- if (Number.isFinite(peakStart) && Number.isFinite(peakEnd)) {
- const center = peakStart + ((peakEnd - peakStart + 24) % 24) / 2;
- anchors.push({ hour: center % 24, energy: 1.0 });
- } else {
- return null;
- }
-
- // Waking → ramping up (mid energy).
- if (Number.isFinite(wake)) anchors.push({ hour: wake, energy: 0.55 });
-
- // Wind-down → low energy.
- if (Number.isFinite(windDown)) anchors.push({ hour: windDown, energy: 0.3 });
-
- // Sleep → lowest energy (recovery / dim city).
- if (Number.isFinite(sleep)) anchors.push({ hour: sleep, energy: 0.12 });
-
- return anchors;
-}
-
-// Given the chronotype profile and the current hour (0..23, fractional ok), compute
-// an energy level in [0,1]. Energy is the inverse-distance-weighted blend of the
-// nearest anchors on the 24h clock — smooth, wrap-around-aware, no hard edges.
-// Returns `null` (sentinel) when there's no usable profile or the hour is non-finite,
-// so callers can distinguish "no profile" from a real curve value that happens to be
-// 1.0 (peak focus center). Callers map null → neutral (no visible change).
-function computeEnergy(profile, hour) {
- const anchors = buildAnchors(profile);
- if (!anchors || !Number.isFinite(hour)) return null;
-
- let weightSum = 0;
- let energySum = 0;
- for (const a of anchors) {
- const dist = hourDistance(hour, a.hour);
- // Exact hit on an anchor returns it directly (avoids divide-by-zero).
- if (dist < 1e-6) return clamp(a.energy, 0, 1);
- const weight = 1 / (dist * dist);
- weightSum += weight;
- energySum += weight * a.energy;
- }
- return clamp(energySum / weightSum, 0, 1);
-}
-
-// Map an energy level 0..1 → the tasteful, clamped display modifiers the overlay
-// applies: a brightness multiplier and a tempo (animation-speed) multiplier. Energy
-// 1 → top of each range, energy 0 → bottom; linear in between. A null/non-finite
-// energy (no usable profile) returns NEUTRAL_MODIFIERS — a true no-op.
-function energyModifiers(energy) {
- if (!Number.isFinite(energy)) return { ...NEUTRAL_MODIFIERS };
- const e = clamp(energy, 0, 1);
- const { brightnessMin, brightnessMax, tempoMin, tempoMax } = ENERGY_RANGE;
- return {
- energy: e,
- brightness: clamp(brightnessMin + e * (brightnessMax - brightnessMin), brightnessMin, brightnessMax),
- tempo: clamp(tempoMin + e * (tempoMax - tempoMin), tempoMin, tempoMax),
- };
-}
-
-// Full derived view-model for the component: energy + brightness + tempo. `hour` is
-// injected so the whole view-model is deterministic under test. A missing/partial
-// profile (or non-finite hour) yields NEUTRAL_MODIFIERS — a true no-op (brightness
-// and tempo at 1.0), so an unconfigured city is untouched rather than washed out or
-// dimmed. A real curve value of exactly 1.0 (peak focus center) still maps to peak
-// brightness because computeEnergy returns null — not 1.0 — for the "no profile" case.
-export function computeChronotypeEnergy(profile, hour) {
- return energyModifiers(computeEnergy(profile, hour));
-}
diff --git a/client/src/utils/openWorldChronotype.test.js b/client/src/utils/openWorldChronotype.test.js
deleted file mode 100644
index 6d487b2210..0000000000
--- a/client/src/utils/openWorldChronotype.test.js
+++ /dev/null
@@ -1,181 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { computeChronotypeEnergy } from './openWorldChronotype';
-
-// The module's internals (parseHour / computeEnergy / energyModifiers and the range
-// constants) are deliberately NOT exported — computeChronotypeEnergy is the sole
-// public entry point, so every behavior below is exercised through it. The expected
-// bounds are written as literals here rather than imported, so a change to the
-// tasteful ranges has to be re-affirmed in the test instead of silently agreeing
-// with itself.
-const BRIGHTNESS_MIN = 0.7;
-const BRIGHTNESS_MAX = 1.15;
-const TEMPO_MIN = 0.8;
-const TEMPO_MAX = 1.15;
-// A missing/partial profile is a true no-op: brightness and tempo untouched.
-const NEUTRAL = { energy: 1.0, brightness: 1.0, tempo: 1.0 };
-
-// A representative "intermediate" chronotype profile (mirrors the shape returned by
-// GET /api/digital-twin/identity/chronotype — only the recommendations the overlay
-// reads are included).
-const PROFILE = {
- type: 'intermediate',
- recommendations: {
- wakeTime: '07:00',
- sleepTime: '23:00',
- peakFocusStart: '09:30',
- peakFocusEnd: '13:00',
- windDownStart: '21:30',
- },
-};
-// Peak focus center is (9.5 + 13) / 2 = 11.25.
-const PEAK_HOUR = 11.25;
-
-describe('computeChronotypeEnergy — energy curve', () => {
- it('is highest at the peak focus center', () => {
- const atPeak = computeChronotypeEnergy(PROFILE, PEAK_HOUR);
- const atWake = computeChronotypeEnergy(PROFILE, 7);
- const atSleep = computeChronotypeEnergy(PROFILE, 23);
- expect(atPeak.energy).toBeGreaterThan(atWake.energy);
- expect(atPeak.energy).toBeGreaterThan(atSleep.energy);
- expect(atPeak.energy).toBeCloseTo(1.0, 5);
- });
-
- it('is lowest during recovery (sleep) hours', () => {
- const atSleep = computeChronotypeEnergy(PROFILE, 23);
- expect(atSleep.energy).toBeLessThan(computeChronotypeEnergy(PROFILE, PEAK_HOUR).energy);
- expect(atSleep.energy).toBeLessThan(0.5);
- });
-
- it('parses HH:MM recommendations into fractional hours (09:30 peak start ⇒ 11.25 center)', () => {
- // The peak center is the maximum of the curve, so an exact hit at 11.25 proves
- // the ":30" half-hour was parsed as 9.5 rather than truncated to 9 or 10.
- expect(computeChronotypeEnergy(PROFILE, 11.25).energy).toBeCloseTo(1.0, 5);
- expect(computeChronotypeEnergy(PROFILE, 11).energy).toBeLessThan(1.0);
- expect(computeChronotypeEnergy(PROFILE, 11.5).energy).toBeLessThan(1.0);
- });
-
- it('handles wrap-around midnight for an evening chronotype with after-midnight sleep', () => {
- const evening = {
- recommendations: {
- wakeTime: '08:30',
- sleepTime: '00:30', // 12:30 AM — wraps past midnight
- peakFocusStart: '11:00',
- peakFocusEnd: '15:00',
- windDownStart: '23:00',
- },
- };
- // Land exactly on the after-midnight sleep anchor (00:30 → hour 0.5). An exact
- // anchor hit returns that anchor's energy verbatim, so 0.12 proves the wrapped
- // "00:30" was parsed and anchored at 0.5 — a weaker "1 AM is below 0.5" check
- // would still pass if 00:30 were dropped entirely, since the 23:00 wind-down
- // anchor alone keeps the small hours low.
- const atSleepAnchor = computeChronotypeEnergy(evening, 0.5);
- expect(atSleepAnchor.energy).toBeCloseTo(0.12, 10);
- // ...and that low energy maps through the bottom of the display ranges:
- // brightness = 0.7 + 0.12 * (1.15 - 0.7), tempo = 0.8 + 0.12 * (1.15 - 0.8).
- expect(atSleepAnchor.brightness).toBeCloseTo(0.754, 10);
- expect(atSleepAnchor.tempo).toBeCloseTo(0.842, 10);
-
- // The post-midnight small hours read lower than the peak center.
- const at1am = computeChronotypeEnergy(evening, 1);
- const atPeak = computeChronotypeEnergy(evening, 13); // peak center
- expect(at1am.energy).toBeLessThan(atPeak.energy);
- expect(at1am.energy).toBeLessThan(0.5);
- });
-
- it('blends anchors across the midnight seam (circular distance, not absolute)', () => {
- // Only two anchors, and the low one sits exactly on midnight: peak center 12:00,
- // sleep 00:00. Hours 23:00 and 01:00 are one hour from the sleep anchor and
- // eleven from the peak in EITHER direction, so on a 24h clock they must produce
- // the same energy. With plain |a - b| the pre-midnight hour would read as 23
- // hours from sleep and fall to the peak anchor instead — the symmetry below is
- // what distinguishes the two.
- const midnightSleeper = {
- recommendations: { peakFocusStart: '11:00', peakFocusEnd: '13:00', sleepTime: '00:00' },
- };
- const before = computeChronotypeEnergy(midnightSleeper, 23);
- const after = computeChronotypeEnergy(midnightSleeper, 1);
- expect(before.energy).toBeCloseTo(after.energy, 10);
- // Both sit near the sleep anchor's 0.12, nowhere near the peak's 1.0.
- expect(before.energy).toBeLessThan(0.2);
- expect(after.energy).toBeLessThan(0.2);
- });
-});
-
-describe('computeChronotypeEnergy — neutral no-op fallbacks', () => {
- it('returns the neutral no-op for a missing or partial profile (no crash)', () => {
- expect(computeChronotypeEnergy(null, 12)).toEqual(NEUTRAL);
- expect(computeChronotypeEnergy({}, 12)).toEqual(NEUTRAL);
- expect(computeChronotypeEnergy({ recommendations: {} }, 12)).toEqual(NEUTRAL);
- // peak window missing → can't anchor → neutral
- expect(computeChronotypeEnergy({ recommendations: { wakeTime: '07:00' } }, 12)).toEqual(NEUTRAL);
- });
-
- it('returns the neutral no-op when the peak window is unparseable', () => {
- const bad = (peakFocusStart, peakFocusEnd) => ({
- recommendations: { ...PROFILE.recommendations, peakFocusStart, peakFocusEnd },
- });
- expect(computeChronotypeEnergy(bad('not-a-time', '13:00'), 12)).toEqual(NEUTRAL);
- expect(computeChronotypeEnergy(bad('09:30', '24:00'), 12)).toEqual(NEUTRAL);
- expect(computeChronotypeEnergy(bad(null, '13:00'), 12)).toEqual(NEUTRAL);
- expect(computeChronotypeEnergy(bad(undefined, undefined), 12)).toEqual(NEUTRAL);
- });
-
- it('returns the neutral no-op when the hour is not finite', () => {
- expect(computeChronotypeEnergy(PROFILE, NaN)).toEqual(NEUTRAL);
- expect(computeChronotypeEnergy(PROFILE, undefined)).toEqual(NEUTRAL);
- });
-
- it('drops an unparseable secondary anchor rather than falling back to neutral', () => {
- const peak = { peakFocusStart: '09:30', peakFocusEnd: '13:00' };
- const withBadSleep = { recommendations: { ...peak, sleepTime: 'nope' } };
- const withNoSleep = { recommendations: { ...peak } };
- const withGoodSleep = { recommendations: { ...peak, sleepTime: '23:00' } };
- // Evaluate away from every anchor: an exact anchor hit short-circuits before the
- // other anchors are blended, which would hide whether the bad one was dropped.
- const OFF_ANCHOR = 20;
-
- const bad = computeChronotypeEnergy(withBadSleep, OFF_ANCHOR);
- expect(bad).not.toEqual(NEUTRAL);
- // An unparseable sleep time is dropped, leaving the same curve as omitting it...
- expect(bad.energy).toBeCloseTo(computeChronotypeEnergy(withNoSleep, OFF_ANCHOR).energy, 10);
- // ...and a parseable one genuinely changes the curve, so the equality above is
- // evidence the anchor was dropped rather than evidence anchors do nothing.
- expect(bad.energy).toBeGreaterThan(computeChronotypeEnergy(withGoodSleep, OFF_ANCHOR).energy);
- });
-});
-
-describe('computeChronotypeEnergy — display modifiers', () => {
- it('maps the peak focus center to the top of each clamped range', () => {
- const m = computeChronotypeEnergy(PROFILE, PEAK_HOUR);
- expect(m.brightness).toBeCloseTo(BRIGHTNESS_MAX);
- expect(m.tempo).toBeCloseTo(TEMPO_MAX);
- // A real curve value of exactly 1.0 must map to peak brightness, NOT the neutral no-op.
- expect(m.brightness).not.toBe(1.0);
- });
-
- it('peak hour → higher brightness and tempo than a recovery hour', () => {
- const peak = computeChronotypeEnergy(PROFILE, PEAK_HOUR);
- const recovery = computeChronotypeEnergy(PROFILE, 23);
- expect(peak.brightness).toBeGreaterThan(recovery.brightness);
- expect(peak.tempo).toBeGreaterThan(recovery.tempo);
- });
-
- it('keeps energy, brightness and tempo inside the tasteful bounds across the whole day', () => {
- for (let h = 0; h < 24; h += 0.5) {
- const m = computeChronotypeEnergy(PROFILE, h);
- expect(m.energy).toBeGreaterThanOrEqual(0);
- expect(m.energy).toBeLessThanOrEqual(1);
- expect(m.brightness).toBeGreaterThanOrEqual(BRIGHTNESS_MIN);
- expect(m.brightness).toBeLessThanOrEqual(BRIGHTNESS_MAX);
- expect(m.tempo).toBeGreaterThanOrEqual(TEMPO_MIN);
- expect(m.tempo).toBeLessThanOrEqual(TEMPO_MAX);
- }
- });
-
- it('exposes only computeChronotypeEnergy as the module surface', async () => {
- const mod = await import('./openWorldChronotype');
- expect(Object.keys(mod)).toEqual(['computeChronotypeEnergy']);
- });
-});
-// @vitest-environment node
diff --git a/client/src/utils/openWorldCollectibles.js b/client/src/utils/openWorldCollectibles.js
deleted file mode 100644
index b4116c33f7..0000000000
--- a/client/src/utils/openWorldCollectibles.js
+++ /dev/null
@@ -1,119 +0,0 @@
-// Pure deterministic helpers for OpenWorld's collectible Cyber Shards system.
-// Shards are floating, glowing crystal pickups scattered along roads, plazas, and
-// landmarks. Driving or walking over a shard collects it with particle + audio feedback,
-// increments the session score, and unlocks discovery recognition.
-// No three.js / React imports — pure, testable in node.
-
-import { WORLD } from './openWorldPlan';
-
-export const SHARD_COLLECTION_RADIUS = 2.4;
-export const DEFAULT_SHARD_Y = 1.0;
-
-// Curated deterministic list of collectible Cyber Shards across all major quarters.
-export const CYBER_SHARDS = [
- // Plaza & Central Spine
- { id: 'shard-plaza-n', label: 'Plaza Zenith', x: 0, y: DEFAULT_SHARD_Y, z: -12, color: '#06b6d4', value: 10 },
- { id: 'shard-plaza-s', label: 'Plaza Gateway', x: 0, y: DEFAULT_SHARD_Y, z: 14, color: '#06b6d4', value: 10 },
- { id: 'shard-plaza-e', label: 'Plaza East Wing', x: 13, y: DEFAULT_SHARD_Y, z: 0, color: '#06b6d4', value: 10 },
- { id: 'shard-plaza-w', label: 'Plaza West Wing', x: -13, y: DEFAULT_SHARD_Y, z: 0, color: '#06b6d4', value: 10 },
-
- // Harbor Avenue (Northbound)
- { id: 'shard-avenue-mid', label: 'Avenue Walk', x: 0, y: DEFAULT_SHARD_Y, z: -28, color: '#38bdf8', value: 15 },
- { id: 'shard-avenue-shore', label: 'Shoreline Overlook', x: 0, y: DEFAULT_SHARD_Y, z: WORLD.shorelineZ + 2, color: '#38bdf8', value: 15 },
- { id: 'shard-harbor-pier', label: 'Harbor Pier Head', x: 0, y: DEFAULT_SHARD_Y, z: -68, color: '#0ea5e9', value: 25 },
-
- // Western Districts (Memory, Backup, Jira, Productivity)
- { id: 'shard-memory-steps', label: 'Memory Crystal Shard', x: -38, y: DEFAULT_SHARD_Y, z: -24, color: '#a855f7', value: 20 },
- { id: 'shard-backup-vault', label: 'Vault Crypt Cache', x: -30, y: DEFAULT_SHARD_Y, z: -8, color: '#f59e0b', value: 20 },
- { id: 'shard-jira-yard', label: 'Sprint Yard Crate', x: -16, y: DEFAULT_SHARD_Y, z: -38, color: '#ec4899', value: 20, feature: 'jira' },
- { id: 'shard-productivity', label: 'Focus Terrace Spark', x: -44, y: DEFAULT_SHARD_Y, z: 24, color: '#22c55e', value: 20 },
- { id: 'shard-quiet-corner', label: 'Secret Shard', x: -44, y: DEFAULT_SHARD_Y, z: 38, color: '#e879f9', value: 30 },
-
- // Eastern Districts (Task Queue, Health, Goals, Artifacts)
- { id: 'shard-task-queue', label: 'Queue Stream Shard', x: 30, y: DEFAULT_SHARD_Y, z: -8, color: '#f97316', value: 20 },
- { id: 'shard-health-tower', label: 'Wellness Pulse', x: 44, y: DEFAULT_SHARD_Y, z: 24, color: '#10b981', value: 20 },
- { id: 'shard-goal-monuments', label: 'Goal Milestone Shard', x: 26, y: DEFAULT_SHARD_Y, z: -36, color: '#eab308', value: 25 },
- { id: 'shard-artifacts-hall', label: 'Hall of Trophies Shard', x: 40, y: DEFAULT_SHARD_Y, z: -24, color: '#facc15', value: 25 },
-
- // Downtown Ring Road Corners
- { id: 'shard-ring-ne', label: 'Boulevard North-East', x: 21, y: DEFAULT_SHARD_Y, z: -21, color: '#06b6d4', value: 15 },
- { id: 'shard-ring-sw', label: 'Boulevard South-West', x: -21, y: DEFAULT_SHARD_Y, z: 21, color: '#06b6d4', value: 15 },
-];
-
-export const TOTAL_SHARDS = CYBER_SHARDS.length;
-
-export const isCollectibleVisible = (shard, isFeatureEnabled) => (
- !shard?.feature
- || typeof isFeatureEnabled !== 'function'
- || isFeatureEnabled(shard.feature)
-);
-
-// Return all shards with placement metadata and individual animation phase offsets.
-export function getCollectiblesList(isFeatureEnabled) {
- return CYBER_SHARDS
- .filter((shard) => isCollectibleVisible(shard, isFeatureEnabled))
- .map((shard, index) => ({
- ...shard,
- pulsePhase: (index * 0.13) % 1,
- }));
-}
-
-// Check which uncollected shards are within range of the player position.
-// Returns array of newly collected shard objects.
-export function checkShardCollection(playerPos, shards = CYBER_SHARDS, collectedSet = new Set(), radius = SHARD_COLLECTION_RADIUS) {
- if (!playerPos || typeof playerPos.x !== 'number' || typeof playerPos.z !== 'number') {
- return [];
- }
- const rSq = radius * radius;
- const newlyCollected = [];
-
- for (const shard of shards) {
- if (!shard || collectedSet.has(shard.id)) continue;
- const dx = playerPos.x - shard.x;
- const dz = playerPos.z - shard.z;
- const distSq = dx * dx + dz * dz;
- if (distSq <= rSq) {
- newlyCollected.push(shard);
- }
- }
-
- return newlyCollected;
-}
-
-// Compute progress summary from collected set
-export function getCollectionStats(collectedSet = new Set(), totalCount = TOTAL_SHARDS) {
- const count = collectedSet instanceof Set ? collectedSet.size : Array.isArray(collectedSet) ? collectedSet.length : 0;
- const clampedCount = Math.min(totalCount, Math.max(0, count));
- const percentage = totalCount > 0 ? Math.round((clampedCount / totalCount) * 100) : 0;
- return {
- collectedCount: clampedCount,
- totalCount,
- percentage,
- allCollected: clampedCount >= totalCount && totalCount > 0,
- };
-}
-
-// Session storage persistence helpers
-const STORAGE_KEY = 'openworld.shards';
-
-export function loadCollectedShardIds() {
- try {
- if (typeof sessionStorage === 'undefined') return new Set();
- const raw = sessionStorage.getItem(STORAGE_KEY);
- if (!raw) return new Set();
- const parsed = JSON.parse(raw);
- return Array.isArray(parsed) ? new Set(parsed) : new Set();
- } catch {
- return new Set();
- }
-}
-
-export function saveCollectedShardIds(set) {
- try {
- if (typeof sessionStorage === 'undefined') return;
- const list = Array.from(set || []);
- sessionStorage.setItem(STORAGE_KEY, JSON.stringify(list));
- } catch {
- // Swallow storage exceptions safely
- }
-}
diff --git a/client/src/utils/openWorldCollectibles.test.js b/client/src/utils/openWorldCollectibles.test.js
deleted file mode 100644
index f296ef5b9f..0000000000
--- a/client/src/utils/openWorldCollectibles.test.js
+++ /dev/null
@@ -1,98 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import {
- CYBER_SHARDS,
- TOTAL_SHARDS,
- getCollectiblesList,
- checkShardCollection,
- getCollectionStats,
- SHARD_COLLECTION_RADIUS,
- isCollectibleVisible,
-} from './openWorldCollectibles';
-import { isWalkable } from './openWorldPlan';
-
-describe('openWorldCollectibles', () => {
- it('defines a non-empty array of valid collectible shards with unique ids', () => {
- expect(CYBER_SHARDS.length).toBeGreaterThanOrEqual(10);
- expect(TOTAL_SHARDS).toBe(CYBER_SHARDS.length);
-
- const ids = new Set();
- CYBER_SHARDS.forEach((shard) => {
- expect(typeof shard.id).toBe('string');
- expect(typeof shard.label).toBe('string');
- expect(typeof shard.x).toBe('number');
- expect(typeof shard.y).toBe('number');
- expect(typeof shard.z).toBe('number');
- expect(typeof shard.color).toBe('string');
- expect(typeof shard.value).toBe('number');
- expect(isWalkable(shard.x, shard.z), shard.id).toBe(true);
- expect(ids.has(shard.id)).toBe(false);
- ids.add(shard.id);
- });
- });
-
- it('getCollectiblesList returns copies with pulse phases', () => {
- const list = getCollectiblesList();
- expect(list.length).toBe(CYBER_SHARDS.length);
- list.forEach((s) => {
- expect(s.pulsePhase).toBeGreaterThanOrEqual(0);
- expect(s.pulsePhase).toBeLessThanOrEqual(1);
- });
- });
-
- it('checkShardCollection detects uncollected shards within radius', () => {
- const shard = CYBER_SHARDS[0];
- const playerPos = { x: shard.x + 0.5, z: shard.z + 0.5 };
- const collectedSet = new Set();
-
- const result = checkShardCollection(playerPos, CYBER_SHARDS, collectedSet, SHARD_COLLECTION_RADIUS);
- expect(result.some((s) => s.id === shard.id)).toBe(true);
-
- // If already in collectedSet, does not return again
- collectedSet.add(shard.id);
- const resultAfter = checkShardCollection(playerPos, CYBER_SHARDS, collectedSet, SHARD_COLLECTION_RADIUS);
- expect(resultAfter.some((s) => s.id === shard.id)).toBe(false);
- });
-
- it('checkShardCollection ignores far player positions or invalid inputs', () => {
- expect(checkShardCollection(null)).toEqual([]);
- expect(checkShardCollection({ x: 'invalid', z: 0 })).toEqual([]);
- expect(checkShardCollection({ x: 9999, z: 9999 }, CYBER_SHARDS, new Set())).toEqual([]);
- });
-
- it('hides the Sprint Yard shard when JIRA is disabled', () => {
- const jiraOff = (featureId) => featureId !== 'jira';
- const jiraShard = CYBER_SHARDS.find((shard) => shard.feature === 'jira');
-
- expect(isCollectibleVisible(jiraShard, jiraOff)).toBe(false);
- expect(getCollectiblesList(jiraOff).map((shard) => shard.id)).not.toContain('shard-jira-yard');
- expect(getCollectiblesList(() => true).map((shard) => shard.id)).toContain('shard-jira-yard');
- });
-
- it('getCollectionStats calculates accurate percentages and completion flags', () => {
- const emptyStats = getCollectionStats(new Set(), 10);
- expect(emptyStats).toEqual({
- collectedCount: 0,
- totalCount: 10,
- percentage: 0,
- allCollected: false,
- });
-
- const halfSet = new Set(['s1', 's2', 's3', 's4', 's5']);
- const halfStats = getCollectionStats(halfSet, 10);
- expect(halfStats).toEqual({
- collectedCount: 5,
- totalCount: 10,
- percentage: 50,
- allCollected: false,
- });
-
- const fullSet = new Set(['s1', 's2', 's3', 's4']);
- const fullStats = getCollectionStats(fullSet, 4);
- expect(fullStats).toEqual({
- collectedCount: 4,
- totalCount: 4,
- percentage: 100,
- allCollected: true,
- });
- });
-});
diff --git a/client/src/utils/openWorldDataHarbor.js b/client/src/utils/openWorldDataHarbor.js
deleted file mode 100644
index 30342ed1c4..0000000000
--- a/client/src/utils/openWorldDataHarbor.js
+++ /dev/null
@@ -1,182 +0,0 @@
-// Pure, deterministic helpers for OpenWorld's Data Harbor: a pier district over the bay
-// (master-plan parcel `dataHarbor`, north shore) that makes the install's storage legible
-// at a glance. The PostgreSQL datastore becomes the **database quay** — one disk-stack silo
-// per table, stack height log-scaled by row count, disk radius log-scaled by relation size,
-// an orbiting ring marking pgvector (embedding) tables, and a migration obelisk at the pier
-// head. The `data/` filesystem becomes the **archive racks** — one shipping-container rack
-// per domain directory, lit slats log-scaled by disk usage. Data arrives from
-// GET /api/openworld/introspection; `db: null` (unreachable) renders as a dimmed offline quay,
-// distinct from a reachable-but-empty database. Structure colors are resolved by the
-// component from the live theme palette (deterministic per name via getAccentColor). No
-// three.js / React imports so the topology is unit-testable (mirrors openWorldMemoryDistrict.js).
-
-import { clamp, formatBytes, formatCompactCount } from './formatters';
-import { scaleMetricToHeight } from './openWorldDistrictLayout';
-import { PARCELS } from './openWorldPlan';
-
-export const DATA_HARBOR = {
- base: PARCELS.dataHarbor.anchor, // pier district over the bay (see openWorldPlan.js)
- deckY: 0.55, // pier deck height above the water
- quayOffsetX: 11, // west quay (silos) / east yard (racks) x-distance from the pier axis
- maxSilos: 10, // visible table-silo cap; the rest fold into the overflow count
- maxRacks: 8, // visible domain-rack cap
- siloSpacing: 3.6, // x-distance between adjacent silos
- rowGap: 4.4, // z-distance between the two rows of a quay/yard
- rowFrontZ: 1.5, // front (bay-side) row's z offset from the district base
- diskHeight: 0.42, // one disk in a silo stack
- diskGap: 0.14, // vertical gap between disks
- minDisks: 1,
- maxDisks: 7, // a packed table stays a legible stack, not a tower
- minDiskRadius: 0.65,
- maxDiskRadius: 1.35,
- rackSpacing: 3.1, // x-distance between adjacent racks
- rackWidth: 2.3,
- rackDepth: 1.6,
- rackHeight: 3.2,
- rackSlats: 8, // emissive slat rows per rack; lit count tracks the fill ratio
-};
-
-// log2-scale `value` into [0, 1] against `max`. Delegates the curve to the shared
-// scaleMetricToHeight so every district's log scaling stays one implementation.
-const logRatio = (value, max) => {
- const scaledMax = scaleMetricToHeight(max, { k: 1 });
- if (scaledMax <= 0) return 0;
- return clamp(scaleMetricToHeight(value, { k: 1 }) / scaledMax, 0, 1);
-};
-
-// Two-row layout shared by the silo quay and the rack yard: items split into a front
-// (bay-side) row and a back row, each row x-centered; `stagger` shifts the back row by a
-// half-step (the silo quay uses it so stacks read brick-laid rather than gridded).
-const twoRowOffset = (index, total, spacing, stagger = 0) => {
- const perRow = Math.ceil(total / 2);
- const row = index < perRow ? 0 : 1;
- const col = row === 0 ? index : index - perRow;
- const rowCount = row === 0 ? perRow : total - perRow;
- return {
- dx: col * spacing - ((rowCount - 1) * spacing) / 2 + row * stagger,
- dz: DATA_HARBOR.rowFrontZ - row * DATA_HARBOR.rowGap,
- };
-};
-
-// Silo geometry for the visible top-N tables (already size-sorted by the server; re-sorted
-// here defensively).
-function computeSilos(tables) {
- const sorted = [...tables].sort((a, b) => (b.totalBytes ?? 0) - (a.totalBytes ?? 0));
- const visible = sorted.slice(0, DATA_HARBOR.maxSilos);
- const maxRows = Math.max(...visible.map((t) => t.rowEstimate ?? 0), 0);
- const maxBytes = Math.max(...visible.map((t) => t.totalBytes ?? 0), 0);
- const [bx, , bz] = DATA_HARBOR.base;
-
- return visible.map((table, i) => {
- const { dx, dz } = twoRowOffset(i, visible.length, DATA_HARBOR.siloSpacing, DATA_HARBOR.siloSpacing / 2);
- const diskCount = clamp(
- Math.round(1 + logRatio(table.rowEstimate, maxRows) * (DATA_HARBOR.maxDisks - 1)),
- DATA_HARBOR.minDisks,
- DATA_HARBOR.maxDisks,
- );
- const diskRadius = DATA_HARBOR.minDiskRadius
- + logRatio(table.totalBytes, maxBytes) * (DATA_HARBOR.maxDiskRadius - DATA_HARBOR.minDiskRadius);
- return {
- name: table.name,
- x: bx - DATA_HARBOR.quayOffsetX + dx,
- z: bz + dz,
- diskCount,
- diskRadius,
- height: diskCount * (DATA_HARBOR.diskHeight + DATA_HARBOR.diskGap),
- hasEmbedding: Boolean(table.hasEmbedding),
- rowEstimate: table.rowEstimate ?? 0,
- totalBytes: table.totalBytes ?? 0,
- label: String(table.name || '').toUpperCase(),
- sublabel: `${formatCompactCount(table.rowEstimate ?? 0)} ROWS`,
- bytesLabel: formatBytes(table.totalBytes ?? 0),
- };
- });
-}
-
-// Rack geometry for the visible top-N data/ domains (server sorts by size; defensively
-// re-sorted). Same two-row layout — a tight container yard.
-function computeRacks(domains) {
- const sorted = [...domains].sort((a, b) => (b.bytes ?? 0) - (a.bytes ?? 0));
- const visible = sorted.slice(0, DATA_HARBOR.maxRacks);
- const maxBytes = Math.max(...visible.map((d) => d.bytes ?? 0), 0);
- const [bx, , bz] = DATA_HARBOR.base;
-
- return visible.map((domain, i) => {
- const { dx, dz } = twoRowOffset(i, visible.length, DATA_HARBOR.rackSpacing);
- const fillRatio = logRatio(domain.bytes, maxBytes);
- return {
- name: domain.name,
- x: bx + DATA_HARBOR.quayOffsetX + dx,
- z: bz + dz,
- width: DATA_HARBOR.rackWidth,
- depth: DATA_HARBOR.rackDepth,
- height: DATA_HARBOR.rackHeight,
- fillRatio,
- litSlats: clamp(Math.round(fillRatio * DATA_HARBOR.rackSlats), domain.bytes > 0 ? 1 : 0, DATA_HARBOR.rackSlats),
- bytes: domain.bytes ?? 0,
- files: domain.files ?? 0,
- label: String(domain.name || '').toUpperCase(),
- sublabel: formatBytes(domain.bytes ?? 0),
- };
- });
-}
-
-// A deck sized to contain a set of structures (plus a margin) — so a spacing or cap
-// retune can never strand a silo off the pier. Falls back to a minimum platform so an
-// empty quay still reads as a deck, not open water.
-function deckFor(structures, centerX, centerZ, margin = 2.2) {
- let maxHalfW = 5;
- let maxHalfD = 4;
- for (const s of structures) {
- const r = s.diskRadius ?? Math.max(s.width ?? 0, s.depth ?? 0) / 2;
- maxHalfW = Math.max(maxHalfW, Math.abs(s.x - centerX) + r);
- maxHalfD = Math.max(maxHalfD, Math.abs(s.z - centerZ) + r);
- }
- return { x: centerX, z: centerZ, w: (maxHalfW + margin) * 2, d: (maxHalfD + margin) * 2 };
-}
-
-// The whole district model from one introspection payload.
-// - introspection missing entirely → { empty: true } (nothing renders — first load)
-// - db: null → dbDown: true (quay renders dimmed + "DB OFFLINE")
-// - fs: null → racks: [] (rack pier renders empty deck)
-export function computeDataHarbor(introspection) {
- if (!introspection || typeof introspection !== 'object') return { empty: true };
-
- const db = introspection.db && Array.isArray(introspection.db.tables) ? introspection.db : null;
- const fsSection = introspection.fs && Array.isArray(introspection.fs.domains) ? introspection.fs : null;
- const tables = db?.tables ?? [];
- const domains = fsSection?.domains ?? [];
-
- const [bx, , bz] = DATA_HARBOR.base;
- const silos = computeSilos(tables);
- const racks = computeRacks(domains);
- const rowCenterZ = bz + DATA_HARBOR.rowFrontZ - DATA_HARBOR.rowGap / 2;
-
- return {
- empty: false,
- dbDown: !db,
- base: DATA_HARBOR.base,
- silos,
- racks,
- decks: [
- deckFor(silos, bx - DATA_HARBOR.quayOffsetX, rowCenterZ),
- deckFor(racks, bx + DATA_HARBOR.quayOffsetX, rowCenterZ),
- ],
- obelisk: db?.migrations
- ? { applied: db.migrations.applied ?? 0, lastApplied: db.migrations.lastApplied ?? null, x: bx, z: bz - 7 }
- : null,
- totals: {
- tableCount: tables.length,
- dbSizeBytes: db?.sizeBytes ?? null,
- dbSizeLabel: db?.sizeBytes != null ? formatBytes(db.sizeBytes) : null,
- fsBytes: fsSection?.totalBytes ?? null,
- fsLabel: fsSection?.totalBytes != null ? formatBytes(fsSection.totalBytes) : null,
- fsFiles: fsSection?.totalFiles ?? null,
- domainCount: domains.length,
- },
- overflow: {
- tables: Math.max(0, tables.length - DATA_HARBOR.maxSilos),
- domains: Math.max(0, domains.length - DATA_HARBOR.maxRacks),
- },
- };
-}
diff --git a/client/src/utils/openWorldDataHarbor.test.js b/client/src/utils/openWorldDataHarbor.test.js
deleted file mode 100644
index 71db338d0e..0000000000
--- a/client/src/utils/openWorldDataHarbor.test.js
+++ /dev/null
@@ -1,133 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { computeDataHarbor, DATA_HARBOR } from './openWorldDataHarbor';
-import { PARCELS, isInWater, WORLD } from './openWorldPlan';
-
-const table = (name, rowEstimate, totalBytes, hasEmbedding = false) =>
- ({ name, rowEstimate, totalBytes, hasEmbedding });
-const domain = (name, bytes, files) => ({ name, bytes, files });
-
-const happyIntrospection = () => ({
- ts: '2026-06-09T00:00:00.000Z',
- db: {
- sizeBytes: 5_000_000,
- tables: [
- table('memories', 1200, 900_000, true),
- table('catalog_scraps', 40, 50_000, true),
- table('schema_migrations', 12, 8_000),
- ],
- migrations: { applied: 12, lastApplied: '2026-06-01T00:00:00.000Z' },
- },
- fs: {
- domains: [domain('images', 3_100_000_000, 2400), domain('brain', 800_000, 60)],
- totalBytes: 3_100_800_000,
- totalFiles: 2460,
- },
-});
-
-describe('computeDataHarbor', () => {
- it('returns empty for a missing payload (nothing fetched yet)', () => {
- expect(computeDataHarbor(null)).toEqual({ empty: true });
- expect(computeDataHarbor(undefined)).toEqual({ empty: true });
- });
-
- it('flags dbDown when db is null without losing the filesystem racks', () => {
- const district = computeDataHarbor({ ...happyIntrospection(), db: null });
- expect(district.dbDown).toBe(true);
- expect(district.silos).toEqual([]);
- expect(district.obelisk).toBeNull();
- expect(district.racks).toHaveLength(2);
- });
-
- it('distinguishes a reachable-but-empty db from a down one', () => {
- const district = computeDataHarbor({
- ts: 'x',
- db: { sizeBytes: 1000, tables: [], migrations: null },
- fs: { domains: [], totalBytes: 0, totalFiles: 0 },
- });
- expect(district.dbDown).toBe(false);
- expect(district.silos).toEqual([]);
- });
-
- it('builds one silo per table with monotonic log scaling', () => {
- const district = computeDataHarbor(happyIntrospection());
- expect(district.silos).toHaveLength(3);
- const byName = Object.fromEntries(district.silos.map((s) => [s.name, s]));
- // More rows → at least as many disks; more bytes → at least as wide.
- expect(byName.memories.diskCount).toBeGreaterThanOrEqual(byName.catalog_scraps.diskCount);
- expect(byName.catalog_scraps.diskCount).toBeGreaterThanOrEqual(byName.schema_migrations.diskCount);
- expect(byName.memories.diskRadius).toBeGreaterThanOrEqual(byName.catalog_scraps.diskRadius);
- expect(byName.memories.diskCount).toBeLessThanOrEqual(DATA_HARBOR.maxDisks);
- // Embedding flag passes through.
- expect(byName.memories.hasEmbedding).toBe(true);
- expect(byName.schema_migrations.hasEmbedding).toBe(false);
- // Labels are render-ready.
- expect(byName.memories.label).toBe('MEMORIES');
- expect(byName.memories.sublabel).toBe('1.2K ROWS');
- });
-
- it('caps silos/racks at the visible max and reports the overflow', () => {
- const intro = happyIntrospection();
- intro.db.tables = Array.from({ length: 14 }, (_, i) => table(`t${i}`, i * 10, i * 1000));
- intro.fs.domains = Array.from({ length: 11 }, (_, i) => domain(`d${i}`, i * 1000, i));
- const district = computeDataHarbor(intro);
- expect(district.silos).toHaveLength(DATA_HARBOR.maxSilos);
- expect(district.racks).toHaveLength(DATA_HARBOR.maxRacks);
- expect(district.overflow).toEqual({ tables: 4, domains: 3 });
- // The visible set is the biggest-by-size, not the first-N.
- expect(district.silos.map((s) => s.name)).toContain('t13');
- expect(district.silos.map((s) => s.name)).not.toContain('t0');
- });
-
- it('keeps every structure inside the harbor parcel, over the water, on a deck', () => {
- const intro = happyIntrospection();
- intro.db.tables = Array.from({ length: 14 }, (_, i) => table(`t${i}`, i * 10, i * 1000));
- intro.fs.domains = Array.from({ length: 11 }, (_, i) => domain(`d${i}`, i * 1000, i));
- const district = computeDataHarbor(intro);
- const parcel = PARCELS.dataHarbor;
- const inParcel = (x, z, name) => {
- expect(Math.abs(x - parcel.anchor[0]), name).toBeLessThanOrEqual(parcel.w / 2);
- expect(Math.abs(z - parcel.anchor[2]), name).toBeLessThanOrEqual(parcel.d / 2);
- };
- for (const s of [...district.silos, ...district.racks]) {
- inParcel(s.x, s.z, s.name);
- expect(isInWater(s.x, s.z), s.name).toBe(true);
- // Every structure stands on one of the decks the helper emitted.
- const onDeck = district.decks.some((deck) =>
- Math.abs(s.x - deck.x) <= deck.w / 2 && Math.abs(s.z - deck.z) <= deck.d / 2);
- expect(onDeck, `${s.name} on a deck`).toBe(true);
- }
- inParcel(district.obelisk.x, district.obelisk.z, 'obelisk');
- });
-
- it('scales rack slats by log byte share with a lit floor for non-empty domains', () => {
- const district = computeDataHarbor(happyIntrospection());
- const images = district.racks.find((r) => r.name === 'images');
- const brain = district.racks.find((r) => r.name === 'brain');
- expect(images.litSlats).toBe(DATA_HARBOR.rackSlats); // the max domain is fully lit
- expect(brain.litSlats).toBeGreaterThanOrEqual(1); // non-empty never reads as unlit
- expect(brain.litSlats).toBeLessThan(images.litSlats);
- expect(images.sublabel).toBe('2.9 GB');
- });
-
- it('carries totals and the migration obelisk', () => {
- const district = computeDataHarbor(happyIntrospection());
- expect(district.obelisk).toMatchObject({ applied: 12, lastApplied: '2026-06-01T00:00:00.000Z' });
- expect(district.totals.tableCount).toBe(3);
- expect(district.totals.dbSizeLabel).toBe('4.8 MB');
- expect(district.totals.fsLabel).toBe('2.9 GB');
- expect(district.totals.domainCount).toBe(2);
- });
-
- it('is deterministic', () => {
- expect(computeDataHarbor(happyIntrospection())).toEqual(computeDataHarbor(happyIntrospection()));
- });
-});
-
-describe('harbor sits inside the world', () => {
- it('parcel is in the bay but inside the world bound', () => {
- const [x, , z] = DATA_HARBOR.base;
- expect(isInWater(x, z)).toBe(true);
- expect(Math.abs(z)).toBeLessThanOrEqual(WORLD.bound);
- });
-});
-// @vitest-environment node
diff --git a/client/src/utils/openWorldDistrictLayout.js b/client/src/utils/openWorldDistrictLayout.js
deleted file mode 100644
index 518e531f6e..0000000000
--- a/client/src/utils/openWorldDistrictLayout.js
+++ /dev/null
@@ -1,89 +0,0 @@
-// Shared, pure layout primitives for OpenWorld districts. Several district modules had
-// converged on the same three computations — column-wrapped grid placement, count-into-buckets
-// tallies, and log-scaled clamped heights — each rolling its own copy. These helpers are the
-// single source of truth they now delegate to. No three.js / React imports so every district
-// stays headless-testable (mirrors openWorldJiraDistrict.js / openWorldMemoryDistrict.js / openWorldTaskQueue.js).
-
-// ---------------------------------------------------------------------------
-// Grid placement: index → world position, wrapping into columns and X-centered.
-// ---------------------------------------------------------------------------
-
-// Auto-pick a roughly-square column count for `count` items (downtown/warehouse grow both ways
-// as apps are added). Floors at 1 so an empty or single-item grid still has a valid column.
-export function autoColumns(count) {
- const n = Number.isFinite(count) ? count : 0;
- return Math.max(1, Math.ceil(Math.sqrt(Math.max(0, n))));
-}
-
-// Column-wrapped grid position for the `index`-th cell, returned as `[x, y, z]`.
-// - columns: cells per row before wrapping (use autoColumns(n) for the sqrt-auto mode).
-// - spacing: distance between adjacent cells (applied on both axes).
-// - base: [x, y, z] origin of the grid (default origin).
-// - rowDir: +1 lays successive rows toward +Z, -1 toward -Z.
-// - rowCount: when provided, rows are *centered* on Z (offset by half the grid depth) — the
-// downtown behavior; omit it to have rows extend outward from the base (jira yard,
-// warehouse). Columns are always X-centered regardless.
-export function gridIndexToPosition(index, opts = {}) {
- const { columns = 1, spacing = 1, base = [0, 0, 0], rowDir = 1, rowCount = null } = opts;
- const cols = Math.max(1, columns);
- const col = index % cols;
- const row = Math.floor(index / cols);
- const offsetX = ((cols - 1) * spacing) / 2;
- const offsetZ = rowCount != null ? ((Math.max(1, rowCount) - 1) * spacing) / 2 : 0;
- return [
- base[0] + col * spacing - offsetX,
- base[1],
- base[2] + rowDir * (row * spacing - offsetZ),
- ];
-}
-
-// ---------------------------------------------------------------------------
-// Bucketing: count (and optionally weight) items by a categorical field.
-// ---------------------------------------------------------------------------
-
-// Count items into buckets keyed by `keyFn(item)`, returned as a plain `{ key: count }` object.
-// `seed` pre-creates zeroed buckets so a caller that depends on a fixed set of keys (e.g. a status
-// breakdown where `other` must always be present) always gets them; unknown keys are added on
-// demand. Tolerates a non-array input (returns just the seeded zeros).
-export function tallyByKey(items, keyFn, seed = []) {
- const counts = {};
- for (const key of seed) counts[key] = 0;
- for (const item of Array.isArray(items) ? items : []) {
- const key = keyFn(item);
- counts[key] = (counts[key] || 0) + 1;
- }
- return counts;
-}
-
-// Group items into buckets keyed by `keyFn`, each carrying a `count` and a summed `weight`
-// (`weightFn` defaults to 1 per item — i.e. weight == count). Returns an array of
-// `{ key, count, weight }` sorted by count desc, then key asc — the stable order districts render
-// in. Use when a district needs both a population and an accumulated magnitude per bucket.
-export function groupByFieldValue(items, keyFn, { weightFn = () => 1, seed = [] } = {}) {
- const buckets = new Map();
- for (const key of seed) buckets.set(key, { key, count: 0, weight: 0 });
- for (const item of Array.isArray(items) ? items : []) {
- const key = keyFn(item);
- const entry = buckets.get(key) || { key, count: 0, weight: 0 };
- const w = weightFn(item);
- entry.count += 1;
- entry.weight += Number.isFinite(w) ? w : 0;
- buckets.set(key, entry);
- }
- return [...buckets.values()].sort(
- (a, b) => b.count - a.count || String(a.key).localeCompare(String(b.key)),
- );
-}
-
-// ---------------------------------------------------------------------------
-// Height: log-scale a metric into a clamped band so big values don't dwarf the skyline.
-// ---------------------------------------------------------------------------
-
-// height = base + log2(1 + value) * k, clamped to [min, max]. `value` is floored at 0 (a zero or
-// missing metric yields exactly `base`). Districts use this so a chunky ticket / heavy memory
-// cluster reads as taller while staying within a legible band.
-export function scaleMetricToHeight(value, { min = 0, max = Infinity, k = 1, base = 0 } = {}) {
- const v = Math.max(0, Number.isFinite(value) ? value : 0);
- const scaled = base + Math.log2(1 + v) * k;
- return Math.min(max, Math.max(min, scaled));
-}
diff --git a/client/src/utils/openWorldDistrictLayout.test.js b/client/src/utils/openWorldDistrictLayout.test.js
deleted file mode 100644
index 50d03071de..0000000000
--- a/client/src/utils/openWorldDistrictLayout.test.js
+++ /dev/null
@@ -1,131 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import {
- autoColumns,
- gridIndexToPosition,
- tallyByKey,
- groupByFieldValue,
- scaleMetricToHeight,
-} from './openWorldDistrictLayout';
-
-describe('autoColumns', () => {
- it('returns a roughly-square column count', () => {
- expect(autoColumns(0)).toBe(1); // empty grid still has a valid column
- expect(autoColumns(1)).toBe(1);
- expect(autoColumns(4)).toBe(2);
- expect(autoColumns(5)).toBe(3); // ceil(sqrt(5)) = 3
- expect(autoColumns(9)).toBe(3);
- expect(autoColumns(10)).toBe(4);
- });
-
- it('floors at 1 for negative / NaN input', () => {
- expect(autoColumns(-3)).toBe(1);
- expect(autoColumns(NaN)).toBe(1);
- });
-});
-
-describe('gridIndexToPosition', () => {
- it('wraps into columns and centers X', () => {
- const opts = { columns: 3, spacing: 2, base: [0, 0, 0] };
- // 3 columns, spacing 2 → X offset = (3-1)*2/2 = 2, so cols sit at -2, 0, +2
- expect(gridIndexToPosition(0, opts)).toEqual([-2, 0, 0]);
- expect(gridIndexToPosition(1, opts)).toEqual([0, 0, 0]);
- expect(gridIndexToPosition(2, opts)).toEqual([2, 0, 0]);
- // index 3 wraps to row 1, col 0 → rows extend toward +Z by default
- expect(gridIndexToPosition(3, opts)).toEqual([-2, 0, 2]);
- });
-
- it('carries base x/y/z through', () => {
- const p = gridIndexToPosition(0, { columns: 1, spacing: 1, base: [5, 7, 9] });
- expect(p).toEqual([5, 7, 9]); // single column → no X offset
- });
-
- it('lays rows toward -Z when rowDir is -1 (jira yard)', () => {
- const opts = { columns: 2, spacing: 3, base: [0, 0, 0], rowDir: -1 };
- expect(gridIndexToPosition(2, opts)).toEqual([-1.5, 0, -3]); // row 1 recedes to -Z
- });
-
- it('centers rows on Z when rowCount is given (downtown)', () => {
- // 4 items, 2 cols → 2 rows, spacing 2 → Z offset = (2-1)*2/2 = 1, rows at -1 and +1
- const opts = { columns: 2, spacing: 2, base: [0, 0, 0], rowCount: 2 };
- expect(gridIndexToPosition(0, opts)).toEqual([-1, 0, -1]);
- expect(gridIndexToPosition(2, opts)).toEqual([-1, 0, 1]);
- });
-});
-
-describe('tallyByKey', () => {
- it('counts items into buckets by key', () => {
- const items = [{ s: 'a' }, { s: 'b' }, { s: 'a' }];
- expect(tallyByKey(items, (i) => i.s)).toEqual({ a: 2, b: 1 });
- });
-
- it('seeds fixed buckets to zero so absent keys are present', () => {
- expect(tallyByKey([], (i) => i.s, ['a', 'b'])).toEqual({ a: 0, b: 0 });
- const items = [{ s: 'a' }];
- expect(tallyByKey(items, (i) => i.s, ['a', 'b', 'c'])).toEqual({ a: 1, b: 0, c: 0 });
- });
-
- it('tolerates a non-array input', () => {
- expect(tallyByKey(null, (i) => i.s, ['a'])).toEqual({ a: 0 });
- expect(tallyByKey(undefined, (i) => i.s)).toEqual({});
- });
-});
-
-describe('groupByFieldValue', () => {
- it('groups, counts, and sums weight, sorted by count desc then key asc', () => {
- const items = [
- { c: 'work', w: 2 },
- { c: 'home', w: 5 },
- { c: 'work', w: 3 },
- ];
- const out = groupByFieldValue(items, (i) => i.c, { weightFn: (i) => i.w });
- expect(out).toEqual([
- { key: 'work', count: 2, weight: 5 },
- { key: 'home', count: 1, weight: 5 },
- ]);
- });
-
- it('defaults weight to 1 per item when no weightFn', () => {
- const items = [{ c: 'x' }, { c: 'x' }, { c: 'y' }];
- expect(groupByFieldValue(items, (i) => i.c)).toEqual([
- { key: 'x', count: 2, weight: 2 },
- { key: 'y', count: 1, weight: 1 },
- ]);
- });
-
- it('treats a non-finite weight as 0 contribution', () => {
- const items = [{ c: 'x', w: NaN }, { c: 'x', w: 4 }];
- const out = groupByFieldValue(items, (i) => i.c, { weightFn: (i) => i.w });
- expect(out).toEqual([{ key: 'x', count: 2, weight: 4 }]);
- });
-
- it('breaks count ties by key ascending', () => {
- const items = [{ c: 'b' }, { c: 'a' }];
- expect(groupByFieldValue(items, (i) => i.c).map((b) => b.key)).toEqual(['a', 'b']);
- });
-
- it('seeds buckets so an empty group still appears', () => {
- expect(groupByFieldValue([], (i) => i.c, { seed: ['a'] })).toEqual([
- { key: 'a', count: 0, weight: 0 },
- ]);
- });
-});
-
-describe('scaleMetricToHeight', () => {
- it('log-scales and clamps to the band', () => {
- // base 0.9 + log2(1+1)*1.1 = 0.9 + 1.1 = 2.0
- expect(scaleMetricToHeight(1, { max: 4.5, k: 1.1, base: 0.9 })).toBeCloseTo(2.0);
- // clamps at max
- expect(scaleMetricToHeight(1000, { max: 4.5, k: 1.1, base: 0.9 })).toBe(4.5);
- });
-
- it('floors value at 0 → yields exactly base', () => {
- expect(scaleMetricToHeight(0, { min: 1.2, max: 4.5, k: 0.7, base: 1.2 })).toBe(1.2);
- expect(scaleMetricToHeight(-9, { min: 1.2, max: 4.5, k: 0.7, base: 1.2 })).toBe(1.2);
- expect(scaleMetricToHeight(NaN, { base: 3 })).toBe(3);
- });
-
- it('respects the min clamp', () => {
- expect(scaleMetricToHeight(0, { min: 2, base: 0 })).toBe(2);
- });
-});
-// @vitest-environment node
diff --git a/client/src/utils/openWorldEasterEggs.js b/client/src/utils/openWorldEasterEggs.js
deleted file mode 100644
index f0a255656b..0000000000
--- a/client/src/utils/openWorldEasterEggs.js
+++ /dev/null
@@ -1,121 +0,0 @@
-// Pure, deterministic helpers for OpenWorld's "easter eggs" (roadmap 3.5 follow-up, #824):
-// hidden/rare artifacts that only appear when a special, non-obvious condition is met. Unlike
-// the earned-artifact trophies (openWorldArtifacts.js), which mark expected milestones, easter eggs
-// are surprises — a developer's date (Apr 1), a numerically-special level (the "leet" 13/42),
-// or an exact all-goals-complete state. Each is DERIVED from data the city
-// already has plus a caller-supplied date — nothing here calls `new Date()` so every condition
-// is unit-testable on a fixed day. No three.js / React imports (mirrors openWorldArtifacts.js).
-//
-// Easter eggs render as small, glowing, rare emblems tucked at the city's edge — found, not
-// announced. A given egg appears at most once (deduped by id) and the set is stable-ordered so
-// the placement doesn't reshuffle across refetches.
-
-import { hashString } from './hashString';
-import { effectiveLevel, completedGoalCount } from './openWorldArtifacts';
-import { PARCELS } from './openWorldPlan';
-
-// Placement: a tight cluster at the far -X / +Z corner, deliberately off in a quiet quadrant
-// away from the achievement hall (+X/-Z) so an egg reads as "hidden" rather than featured.
-export const EGGS = {
- base: PARCELS.easterEggs.anchor,
- spacing: 5,
- columns: 2,
- size: 1.1,
-};
-
-// Each egg is { id, label, hint, color, test }. `test` is a pure predicate over the derived
-// context { date, level, completedGoals, totalGoals }. Ordered most-special first
-// for stable placement. Colors lean into rare/arcade neon (the city's accent palette).
-export const EASTER_EGGS = [
- {
- id: 'april-fools',
- label: '?!',
- hint: "APRIL FOOLS",
- color: '#ec4899',
- test: ({ date }) =>
- !!date && typeof date.getMonth === 'function' && date.getMonth() + 1 === 4 && date.getDate() === 1,
- },
- {
- id: 'leet',
- label: '1337',
- hint: 'LEET',
- color: '#22c55e',
- // The classic "elite" level. Exact match, not a threshold — it's a wink, not a milestone.
- test: ({ level }) => level === 13,
- },
- {
- id: 'answer',
- label: '42',
- hint: 'THE ANSWER',
- color: '#3b82f6',
- test: ({ level }) => level === 42,
- },
- {
- id: 'clean-sweep',
- label: '★',
- hint: 'CLEAN SWEEP',
- color: '#fcd34d',
- // Every tracked goal completed (and there is at least one) — a rare, perfect board.
- test: ({ completedGoals, totalGoals }) => totalGoals > 0 && completedGoals === totalGoals,
- },
-];
-
-// Count the "real" goal entries (objects) from a list or the API `{ goals: [] }` wrapper, so the
-// clean-sweep egg can compare completed vs total. Mirrors completedGoalCount's input tolerance.
-function totalGoalCount(goals) {
- const list = Array.isArray(goals) ? goals : Array.isArray(goals?.goals) ? goals.goals : [];
- return list.filter((g) => g && typeof g === 'object').length;
-}
-
-// Normalize the raw inputs into the flat predicate context, reusing the openWorldArtifacts derivations
-// so the egg conditions stay consistent with the earned-artifact trophies.
-export function eggContext({ date, character, goals } = {}) {
- return {
- date: date || null,
- level: effectiveLevel(character), // floored level or null (xp-derived if needed)
- completedGoals: completedGoalCount(goals),
- totalGoals: totalGoalCount(goals),
- };
-}
-
-// Build the list of UNLOCKED egg descriptors. Deterministic, side-effect-free, stable order.
-export function unlockedEggs(inputs = {}) {
- const ctx = eggContext(inputs);
- return EASTER_EGGS.filter((egg) => egg.test(ctx)).map((egg) => ({
- id: egg.id,
- label: egg.label,
- hint: egg.hint,
- color: egg.color,
- }));
-}
-
-// Place an egg descriptor into the corner cluster grid (left→right, wrapping toward +Z), seeded
-// per-id so the per-egg float phase differs. Mirrors openWorldArtifacts.placeArtifact.
-export function placeEgg(descriptor, index) {
- const col = index % EGGS.columns;
- const row = Math.floor(index / EGGS.columns);
- const xOffset = (col - (EGGS.columns - 1) / 2) * EGGS.spacing;
- const x = EGGS.base[0] + xOffset;
- const z = EGGS.base[2] + row * EGGS.spacing;
- const seed = hashString(descriptor.id);
-
- return {
- ...descriptor,
- position: [x, EGGS.size + 0.6, z],
- phase: (seed % 100) / 100,
- };
-}
-
-// Full derived view-model for the easter-egg component. Inject the date so calendar eggs are
-// deterministic in tests. Nothing unlocked → empty cluster (`hasData: false`), never a crash.
-export function computeEasterEggs(inputs = {}) {
- const descriptors = unlockedEggs(inputs);
- const eggs = descriptors.map((d, i) => placeEgg(d, i));
-
- return {
- base: EGGS.base,
- eggs,
- total: eggs.length,
- hasData: eggs.length > 0,
- };
-}
diff --git a/client/src/utils/openWorldEasterEggs.test.js b/client/src/utils/openWorldEasterEggs.test.js
deleted file mode 100644
index b5cf1904e9..0000000000
--- a/client/src/utils/openWorldEasterEggs.test.js
+++ /dev/null
@@ -1,123 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import {
- EGGS,
- EASTER_EGGS,
- eggContext,
- unlockedEggs,
- placeEgg,
- computeEasterEggs,
-} from './openWorldEasterEggs';
-
-const on = (month, day) => new Date(2025, month - 1, day, 12, 0, 0);
-const completedGoals = (n) =>
- Array.from({ length: n }, (_, i) => ({ id: `g-${i}`, status: 'completed' }));
-
-describe('eggContext', () => {
- it('normalizes level to a floored nonnegative int, else null', () => {
- expect(eggContext({ character: { level: 7.9 } }).level).toBe(7);
- // Age level 0 (birthDate < 1yr ago) is authoritative now (#2673), not null.
- expect(eggContext({ character: { level: 0 } }).level).toBe(0);
- // Explicit null (no birthDate) and an absent character both yield null.
- expect(eggContext({ character: { level: null } }).level).toBeNull();
- expect(eggContext({}).level).toBeNull();
- });
-
- it('counts goals and total from a list or wrapper, ignoring junk entries', () => {
- const ctx = eggContext({ goals: [{ status: 'completed' }, { status: 'active' }, null, 'x'] });
- expect(ctx.completedGoals).toBe(1);
- expect(ctx.totalGoals).toBe(2); // two real objects, junk dropped
- });
-
-});
-
-describe('unlockedEggs — conditions', () => {
- it('unlocks nothing for an empty/default context', () => {
- expect(unlockedEggs({})).toEqual([]);
- });
-
- it('unlocks the April Fools egg only on Apr 1', () => {
- expect(unlockedEggs({ date: on(4, 1) }).map((e) => e.id)).toContain('april-fools');
- expect(unlockedEggs({ date: on(4, 2) }).map((e) => e.id)).not.toContain('april-fools');
- expect(unlockedEggs({ date: on(3, 1) }).map((e) => e.id)).not.toContain('april-fools');
- });
-
- it('unlocks the leet egg at exactly level 13 (not a threshold)', () => {
- expect(unlockedEggs({ character: { level: 13 } }).map((e) => e.id)).toContain('leet');
- expect(unlockedEggs({ character: { level: 14 } }).map((e) => e.id)).not.toContain('leet');
- expect(unlockedEggs({ character: { level: 12 } }).map((e) => e.id)).not.toContain('leet');
- });
-
- it('unlocks the answer egg at exactly level 42', () => {
- expect(unlockedEggs({ character: { level: 42 } }).map((e) => e.id)).toContain('answer');
- expect(unlockedEggs({ character: { level: 41 } }).map((e) => e.id)).not.toContain('answer');
- });
-
- it('unlocks the clean-sweep egg only when all (>=1) goals are completed', () => {
- expect(unlockedEggs({ goals: completedGoals(3) }).map((e) => e.id)).toContain('clean-sweep');
- const mixed = [...completedGoals(2), { id: 'x', status: 'active' }];
- expect(unlockedEggs({ goals: mixed }).map((e) => e.id)).not.toContain('clean-sweep');
- expect(unlockedEggs({ goals: [] }).map((e) => e.id)).not.toContain('clean-sweep'); // empty board does not count
- });
-
- it('preserves the stable table order when several unlock at once', () => {
- const ids = unlockedEggs({
- date: on(4, 1),
- character: { level: 13 },
- goals: completedGoals(2),
- }).map((e) => e.id);
- // april-fools precedes leet precedes clean-sweep in EASTER_EGGS
- expect(ids).toEqual(['april-fools', 'leet', 'clean-sweep']);
- });
-
- it('every egg descriptor carries an id, label, hint, and color', () => {
- for (const egg of EASTER_EGGS) {
- expect(typeof egg.id).toBe('string');
- expect(typeof egg.label).toBe('string');
- expect(typeof egg.hint).toBe('string');
- expect(typeof egg.color).toBe('string');
- expect(typeof egg.test).toBe('function');
- }
- });
-});
-
-describe('placeEgg', () => {
- it('centers the first column on base.x and wraps toward +Z', () => {
- const first = placeEgg({ id: 'a', color: '#fff' }, 0);
- const wrapped = placeEgg({ id: 'b', color: '#fff' }, EGGS.columns);
- expect(wrapped.position[0]).toBeCloseTo(first.position[0]); // same column
- expect(wrapped.position[2]).toBe(EGGS.base[2] + EGGS.spacing); // one row toward +Z
- });
-
- it('attaches a deterministic 0..1 phase per id', () => {
- const a = placeEgg({ id: 'leet', color: '#fff' }, 0);
- const b = placeEgg({ id: 'leet', color: '#fff' }, 0);
- expect(a.phase).toBe(b.phase);
- expect(a.phase).toBeGreaterThanOrEqual(0);
- expect(a.phase).toBeLessThanOrEqual(1);
- });
-});
-
-describe('computeEasterEggs', () => {
- it('handles an empty input as an empty cluster (no crash)', () => {
- const vm = computeEasterEggs({});
- expect(vm.eggs).toEqual([]);
- expect(vm.total).toBe(0);
- expect(vm.hasData).toBe(false);
- expect(vm.base).toEqual(EGGS.base);
- });
-
- it('handles a fully-undefined call', () => {
- expect(computeEasterEggs().hasData).toBe(false);
- });
-
- it('places one egg per unlocked condition', () => {
- const vm = computeEasterEggs({ date: on(4, 1), character: { level: 13 } });
- expect(vm.total).toBe(2);
- expect(vm.hasData).toBe(true);
- for (const e of vm.eggs) {
- expect(e.position).toHaveLength(3);
- expect(typeof e.color).toBe('string');
- }
- });
-});
-// @vitest-environment node
diff --git a/client/src/utils/openWorldFederation.js b/client/src/utils/openWorldFederation.js
deleted file mode 100644
index cb0f6a9255..0000000000
--- a/client/src/utils/openWorldFederation.js
+++ /dev/null
@@ -1,83 +0,0 @@
-// Pure, deterministic helpers for OpenWorld's "federation horizon": placing sync
-// peers as distant skyline silhouettes and deriving their reachability (opacity)
-// and sync-bridge state from real instance data. A void marker is always present
-// so the horizon stays meaningful even on a single-instance install. No three.js
-// / React imports so the topology is unit-testable (mirrors openWorldFlowLines.js).
-
-import { hashString } from './hashString';
-
-export const FEDERATION = {
- radius: 76, // distant ring, beyond OpenWorldSkyline (~55–70) so peers read as far cities
- bridgeReach: 16, // how far the sync bridge stretches inward from a peer toward the city
- onlineColor: '#8b5cf6', // violet — matches the existing INSTANCE MESH beacon
- offlineColor: '#ef4444',
- unknownColor: '#64748b', // slate — also the void marker's color
-};
-
-// Silhouette opacity by reachability. Online peers read clearly; unknown/unprobed
-// are dim; offline are barely visible — but never zero, so the horizon never goes
-// empty when a peer drops.
-export function reachabilityOpacity(status) {
- if (status === 'online') return 0.55;
- if (status === 'offline') return 0.18;
- return 0.3; // unknown / not yet probed
-}
-
-export function statusColor(status) {
- if (status === 'online') return FEDERATION.onlineColor;
- if (status === 'offline') return FEDERATION.offlineColor;
- return FEDERATION.unknownColor;
-}
-
-// The sync "bridge" between this install and a peer. It's `active` (solid, bright)
-// only when the peer is online AND sync is enabled; `broken` (dashed) when the
-// peer is offline or has recent sync failures; otherwise a faint idle link.
-export function bridgeState(peer) {
- const online = peer?.status === 'online';
- const failing = peer?.status === 'offline' || (peer?.consecutiveFailures || 0) > 0;
- const active = online && !!peer?.syncEnabled;
- return {
- active,
- broken: failing,
- intensity: active ? 1 : online ? 0.5 : 0.2,
- };
-}
-
-// Place one peer on the distant ring. Angle/height/width are derived from a stable
-// hash of the peer id, so a peer keeps its spot (and shape) across reloads and is
-// independent of its position in the list.
-export function placePeer(peer, index, { radius = FEDERATION.radius } = {}) {
- const seed = hashString(peer?.id || peer?.name || peer?.address || `peer-${index}`);
- const angle = ((seed % 3600) / 3600) * Math.PI * 2;
- const r = radius + (seed % 11); // slight depth variation across the ring
- return {
- id: peer?.id || `peer-${index}`,
- name: peer?.name || peer?.host || peer?.address || 'peer',
- status: peer?.status || 'unknown',
- online: peer?.status === 'online',
- angle,
- position: [Math.cos(angle) * r, 0, Math.sin(angle) * r],
- height: 18 + (seed % 14), // taller than the faint skyline so peers stand out
- width: 3 + (seed % 3),
- opacity: reachabilityOpacity(peer?.status),
- color: statusColor(peer?.status),
- bridge: bridgeState(peer),
- };
-}
-
-// Build the full horizon: a placement per peer plus a fixed "void machine" marker
-// (the reserved zone for the remote primary instance) that is always rendered, so
-// the federation horizon is visible even with zero peers.
-export function computeFederationHorizon(peers, opts = {}) {
- const radius = opts.radius ?? FEDERATION.radius;
- const placed = (peers || []).map((peer, i) => placePeer(peer, i, { radius }));
- const voidMarker = {
- id: 'void-machine',
- position: [0, 0, -(radius + 6)],
- height: 28,
- width: 6,
- color: FEDERATION.unknownColor,
- opacity: 0.22,
- };
- return { peers: placed, voidMarker };
-}
diff --git a/client/src/utils/openWorldFederation.test.js b/client/src/utils/openWorldFederation.test.js
deleted file mode 100644
index b26169cd5e..0000000000
--- a/client/src/utils/openWorldFederation.test.js
+++ /dev/null
@@ -1,115 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import {
- FEDERATION,
- reachabilityOpacity,
- statusColor,
- bridgeState,
- placePeer,
- computeFederationHorizon,
-} from './openWorldFederation';
-
-describe('reachabilityOpacity', () => {
- it('orders online > unknown > offline, all non-zero', () => {
- const online = reachabilityOpacity('online');
- const unknown = reachabilityOpacity('unknown');
- const offline = reachabilityOpacity('offline');
- expect(online).toBeGreaterThan(unknown);
- expect(unknown).toBeGreaterThan(offline);
- expect(offline).toBeGreaterThan(0); // offline peers stay faintly visible
- });
-
- it('treats an unrecognised/missing status as unknown', () => {
- expect(reachabilityOpacity(undefined)).toBe(reachabilityOpacity('unknown'));
- });
-});
-
-describe('statusColor', () => {
- it('maps each status to its color', () => {
- expect(statusColor('online')).toBe(FEDERATION.onlineColor);
- expect(statusColor('offline')).toBe(FEDERATION.offlineColor);
- expect(statusColor('whatever')).toBe(FEDERATION.unknownColor);
- });
-});
-
-describe('bridgeState', () => {
- it('is active and unbroken when online and syncing', () => {
- const b = bridgeState({ status: 'online', syncEnabled: true });
- expect(b).toMatchObject({ active: true, broken: false });
- expect(b.intensity).toBe(1);
- });
-
- it('is broken when offline', () => {
- const b = bridgeState({ status: 'offline', syncEnabled: true });
- expect(b.active).toBe(false);
- expect(b.broken).toBe(true);
- });
-
- it('is broken when online but accumulating sync failures', () => {
- const b = bridgeState({ status: 'online', syncEnabled: true, consecutiveFailures: 3 });
- expect(b.broken).toBe(true);
- });
-
- it('is an idle (not active, not broken) link when online with sync disabled', () => {
- const b = bridgeState({ status: 'online', syncEnabled: false });
- expect(b.active).toBe(false);
- expect(b.broken).toBe(false);
- expect(b.intensity).toBe(0.5); // online but idle sits between active (1) and unreachable (0.2)
- });
-});
-
-describe('placePeer', () => {
- const peer = { id: 'peer-abc', name: 'studio', status: 'online', syncEnabled: true };
-
- it('is deterministic for the same peer id', () => {
- expect(placePeer(peer, 0)).toEqual(placePeer(peer, 5));
- });
-
- it('places the peer on the distant ring near the configured radius', () => {
- const { position } = placePeer(peer, 0);
- const r = Math.hypot(position[0], position[2]);
- expect(r).toBeGreaterThanOrEqual(FEDERATION.radius);
- expect(r).toBeLessThan(FEDERATION.radius + 11);
- });
-
- it('carries the peer color, opacity, and bridge derived from status', () => {
- const placed = placePeer(peer, 0);
- expect(placed.color).toBe(FEDERATION.onlineColor);
- expect(placed.opacity).toBe(reachabilityOpacity('online'));
- expect(placed.bridge.active).toBe(true);
- expect(placed.online).toBe(true);
- });
-
- it('gives distinct peers distinct angles', () => {
- const a = placePeer({ id: 'aaa' }, 0);
- const b = placePeer({ id: 'zzz' }, 1);
- expect(a.angle).not.toBeCloseTo(b.angle, 3);
- });
-
- it('falls back to a stable label and unknown status for a bare peer', () => {
- const placed = placePeer({ id: 'x' }, 2);
- expect(placed.name).toBe('peer');
- expect(placed.status).toBe('unknown');
- expect(placed.color).toBe(FEDERATION.unknownColor);
- });
-});
-
-describe('computeFederationHorizon', () => {
- it('always returns a void marker, even with no peers', () => {
- expect(computeFederationHorizon([]).voidMarker.id).toBe('void-machine');
- expect(computeFederationHorizon(undefined).voidMarker.id).toBe('void-machine');
- expect(computeFederationHorizon([]).peers).toEqual([]);
- });
-
- it('places the void marker behind downtown beyond the ring', () => {
- const { voidMarker } = computeFederationHorizon([], { radius: 50 });
- expect(voidMarker.position).toEqual([0, 0, -56]);
- });
-
- it('returns one placement per peer', () => {
- const peers = [{ id: 'a', status: 'online' }, { id: 'b', status: 'offline' }];
- const { peers: placed } = computeFederationHorizon(peers);
- expect(placed).toHaveLength(2);
- expect(placed.map(p => p.id)).toEqual(['a', 'b']);
- });
-});
-// @vitest-environment node
diff --git a/client/src/utils/openWorldFilter.js b/client/src/utils/openWorldFilter.js
deleted file mode 100644
index 2fd197b066..0000000000
--- a/client/src/utils/openWorldFilter.js
+++ /dev/null
@@ -1,42 +0,0 @@
-export const STATUS_FILTERS = [
- { id: 'all', label: 'ALL', match: () => true },
- { id: 'online', label: 'ONLINE', match: (app) => !app.archived && app.overallStatus === 'online' },
- { id: 'stopped', label: 'STOPPED', match: (app) => !app.archived && app.overallStatus === 'stopped' },
- {
- id: 'errored',
- label: 'ERRORED',
- match: (app) => {
- if (app.archived) return false;
- const pm2 = app.pm2Status || {};
- return Object.values(pm2).some(s => s?.status === 'errored' || s?.status === 'error');
- },
- },
- {
- id: 'agent',
- label: 'AGENT',
- match: (app, { agentMap }) => agentMap?.has?.(app.id),
- },
-];
-
-export function computeFilterResult({ apps, status, search, agentMap }) {
- const matcher = STATUS_FILTERS.find(f => f.id === status) || STATUS_FILTERS[0];
- const trimmed = (search || '').trim().toLowerCase();
- const matches = [];
- const dimmed = new Set();
-
- (apps || []).forEach(app => {
- const passesStatus = matcher.match(app, { agentMap });
- const haystack = [app.name, app.id, ...(app.tags || [])]
- .filter(Boolean)
- .join(' ')
- .toLowerCase();
- const passesSearch = trimmed.length === 0 || haystack.includes(trimmed);
- if (passesStatus && passesSearch) {
- matches.push(app);
- } else {
- dimmed.add(app.id);
- }
- });
-
- return { matches, dimmed };
-}
diff --git a/client/src/utils/openWorldFilter.test.js b/client/src/utils/openWorldFilter.test.js
deleted file mode 100644
index dc4befed78..0000000000
--- a/client/src/utils/openWorldFilter.test.js
+++ /dev/null
@@ -1,41 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { computeFilterResult } from './openWorldFilter.js';
-
-const apps = [
- { id: 'a', name: 'Alpha', tags: ['prod'], archived: false, overallStatus: 'online', pm2Status: {} },
- { id: 'b', name: 'Beta', tags: [], archived: false, overallStatus: 'stopped', pm2Status: {} },
- { id: 'c', name: 'Gamma', tags: ['ops'], archived: false, overallStatus: 'online', pm2Status: { web: { status: 'errored' } } },
- { id: 'd', name: 'Delta', tags: [], archived: true, overallStatus: 'online', pm2Status: {} },
-];
-
-describe('computeFilterResult', () => {
- it('returns every app as a match for status=all with no search', () => {
- const { matches, dimmed } = computeFilterResult({ apps, status: 'all', search: '' });
- expect(matches.map((a) => a.id)).toEqual(['a', 'b', 'c', 'd']);
- expect(dimmed.size).toBe(0);
- });
-
- it('excludes archived apps from online/stopped/errored', () => {
- // `online` is overallStatus; an app can also match `errored` via pm2.
- expect(computeFilterResult({ apps, status: 'online' }).matches.map((a) => a.id)).toEqual(['a', 'c']);
- expect(computeFilterResult({ apps, status: 'stopped' }).matches.map((a) => a.id)).toEqual(['b']);
- expect(computeFilterResult({ apps, status: 'errored' }).matches.map((a) => a.id)).toEqual(['c']);
- });
-
- it('filters by name, id, or tag and puts the rest in dimmed', () => {
- const byName = computeFilterResult({ apps, status: 'all', search: 'alp' });
- expect(byName.matches.map((a) => a.id)).toEqual(['a']);
- expect([...byName.dimmed]).toEqual(['b', 'c', 'd']);
-
- const byTag = computeFilterResult({ apps, status: 'all', search: 'ops' });
- expect(byTag.matches.map((a) => a.id)).toEqual(['c']);
- });
-
- it('restricts the agent filter to ids present in agentMap', () => {
- const agentMap = new Set(['b']);
- const { matches, dimmed } = computeFilterResult({ apps, status: 'agent', agentMap });
- expect(matches.map((a) => a.id)).toEqual(['b']);
- expect(dimmed.has('a')).toBe(true);
- });
-});
-// @vitest-environment node
diff --git a/client/src/utils/openWorldFlowLines.js b/client/src/utils/openWorldFlowLines.js
deleted file mode 100644
index 56361dd13d..0000000000
--- a/client/src/utils/openWorldFlowLines.js
+++ /dev/null
@@ -1,75 +0,0 @@
-// Pure, deterministic helpers for OpenWorld's inter-building "flow lines": which
-// active buildings connect to each other, and how intense each stream is. The
-// topology is derived from real operational state — a building is a flow source
-// only when it's online, and a link runs "hot" when either endpoint has a
-// running agent — rather than the previous random nearest-neighbor decoration.
-// No three.js / React imports here so the topology can be unit-tested in
-// isolation (mirrors the openWorldTimeline.js / openWorldAgentMotion.js helper pattern).
-
-import { hashString } from './hashString';
-
-export const FLOW = {
- maxNeighbors: 2, // connect each active building to up to N nearest active neighbors
- hotColor: '#22d3ee', // links touching a building with a running agent (work in flight)
- idleColor: '#3b82f6', // links between plain-online buildings (steady-state traffic)
- basePackets: 1, // packets travelling each direction on a steady link
- hotPackets: 2, // packets each direction when an endpoint has a running agent
- baseSpeed: 0.12, // packet speed on a steady link (fraction of the path per second)
- hotSpeedBonus: 0.06, // extra speed on a hot link — work-in-flight reads as faster traffic
-};
-
-// Build the flow-line connection set between ACTIVE downtown buildings.
-// positions — Map from the city layout
-// activeIds — Set of buildings that are online (flow sources/sinks)
-// agentIds — Set of buildings that currently have >=1 running agent
-// Returns deterministic descriptors: { key, start:[x,y,z], end:[x,y,z], color,
-// hot, packets, speed }. `hot`/color/packets/speed encode the link's intensity
-// (color = type, packets+speed = volume) so the renderer stays presentation-only.
-export function computeFlowConnections({ positions, activeIds, agentIds, maxNeighbors = FLOW.maxNeighbors } = {}) {
- if (!positions || !activeIds || activeIds.size < 2) return [];
-
- const entries = [];
- positions.forEach((pos, id) => {
- if (pos.district === 'downtown' && activeIds.has(id)) {
- entries.push({ id, x: pos.x, z: pos.z });
- }
- });
- if (entries.length < 2) return [];
-
- const conns = [];
- const seen = new Set();
-
- for (let i = 0; i < entries.length; i++) {
- const a = entries[i];
- const neighbors = [];
- for (let j = 0; j < entries.length; j++) {
- if (i === j) continue;
- const b = entries[j];
- const dist = (a.x - b.x) ** 2 + (a.z - b.z) ** 2;
- neighbors.push({ id: b.id, x: b.x, z: b.z, dist });
- }
- neighbors.sort((m, n) => m.dist - n.dist);
-
- const take = Math.min(maxNeighbors, neighbors.length);
- for (let n = 0; n < take; n++) {
- const b = neighbors[n];
- const key = [a.id, b.id].sort().join('→');
- if (seen.has(key)) continue;
- seen.add(key);
-
- const hot = agentIds?.has(a.id) || agentIds?.has(b.id) || false;
- const variation = (hashString(key) % 100) / 100; // 0..1, deterministic per link
- conns.push({
- key,
- start: [a.x, 0.5, a.z],
- end: [b.x, 0.5, b.z],
- color: hot ? FLOW.hotColor : FLOW.idleColor,
- hot,
- packets: hot ? FLOW.hotPackets : FLOW.basePackets,
- speed: FLOW.baseSpeed + (hot ? FLOW.hotSpeedBonus : 0) + variation * 0.05,
- });
- }
- }
-
- return conns;
-}
diff --git a/client/src/utils/openWorldFlowLines.test.js b/client/src/utils/openWorldFlowLines.test.js
deleted file mode 100644
index bb87cbe9d6..0000000000
--- a/client/src/utils/openWorldFlowLines.test.js
+++ /dev/null
@@ -1,90 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { FLOW, computeFlowConnections } from './openWorldFlowLines';
-
-// Four downtown buildings in a row plus one non-downtown and one offscreen.
-const positions = new Map([
- ['a', { x: 0, z: 0, district: 'downtown' }],
- ['b', { x: 2, z: 0, district: 'downtown' }],
- ['c', { x: 4, z: 0, district: 'downtown' }],
- ['d', { x: 6, z: 0, district: 'downtown' }],
- ['w', { x: 0, z: 20, district: 'warehouse' }],
-]);
-
-describe('computeFlowConnections', () => {
- it('returns nothing with fewer than two active buildings', () => {
- expect(computeFlowConnections({ positions, activeIds: new Set(['a']), agentIds: new Set() })).toEqual([]);
- expect(computeFlowConnections({ positions, activeIds: new Set(), agentIds: new Set() })).toEqual([]);
- expect(computeFlowConnections({})).toEqual([]);
- });
-
- it('only connects buildings that are active (online)', () => {
- const conns = computeFlowConnections({
- positions,
- activeIds: new Set(['a', 'b']),
- agentIds: new Set(),
- });
- expect(conns).toHaveLength(1);
- expect(conns[0].key).toBe('a→b');
- });
-
- it('excludes non-downtown districts even when marked active', () => {
- const conns = computeFlowConnections({
- positions,
- activeIds: new Set(['a', 'w']),
- agentIds: new Set(),
- });
- // 'w' is warehouse district → not a flow source, leaving <2 downtown actives
- expect(conns).toEqual([]);
- });
-
- it('connects each active building to up to maxNeighbors nearest active neighbors, deduped', () => {
- const conns = computeFlowConnections({
- positions,
- activeIds: new Set(['a', 'b', 'c', 'd']),
- agentIds: new Set(),
- maxNeighbors: 2,
- });
- const keys = conns.map(c => c.key).sort();
- // a↔b, b↔c, c↔d (adjacent), plus a→c and b→d as second-nearest — deduped both ways
- expect(new Set(keys).size).toBe(keys.length); // no duplicate keys
- expect(keys).toContain('a→b');
- expect(keys).toContain('c→d');
- // never a self-link
- expect(keys.every(k => k.split('→')[0] !== k.split('→')[1])).toBe(true);
- });
-
- it('marks a link hot (agent color, more+faster packets) when either endpoint has a running agent', () => {
- const conns = computeFlowConnections({
- positions,
- activeIds: new Set(['a', 'b']),
- agentIds: new Set(['b']),
- });
- expect(conns[0].hot).toBe(true);
- expect(conns[0].color).toBe(FLOW.hotColor);
- expect(conns[0].packets).toBe(FLOW.hotPackets);
- expect(conns[0].speed).toBeGreaterThan(FLOW.baseSpeed);
- });
-
- it('marks a link idle (steady color, base packets) when neither endpoint has an agent', () => {
- const conns = computeFlowConnections({
- positions,
- activeIds: new Set(['a', 'b']),
- agentIds: new Set(['c']), // agent on an unrelated building
- });
- expect(conns[0].hot).toBe(false);
- expect(conns[0].color).toBe(FLOW.idleColor);
- expect(conns[0].packets).toBe(FLOW.basePackets);
- });
-
- it('produces a deterministic topology across calls', () => {
- const args = { positions, activeIds: new Set(['a', 'b', 'c']), agentIds: new Set(['a']) };
- expect(computeFlowConnections(args)).toEqual(computeFlowConnections(args));
- });
-
- it('places stream endpoints at the buildings xz with a fixed y', () => {
- const conns = computeFlowConnections({ positions, activeIds: new Set(['a', 'b']), agentIds: new Set() });
- expect(conns[0].start).toEqual([0, 0.5, 0]);
- expect(conns[0].end).toEqual([2, 0.5, 0]);
- });
-});
-// @vitest-environment node
diff --git a/client/src/utils/openWorldFocusCamera.js b/client/src/utils/openWorldFocusCamera.js
deleted file mode 100644
index 79f55a9ff1..0000000000
--- a/client/src/utils/openWorldFocusCamera.js
+++ /dev/null
@@ -1,183 +0,0 @@
-// Pure camera-framing math for OpenWorld's building focus mode (issue #2593). Given a single
-// borough's ground position + tower height, the current viewport aspect ratio, and the HUD safe
-// area, it computes an orbital camera `position` and look-at `target` that frame the WHOLE borough
-// without intersecting its geometry and without hiding it under the on-screen detail panel.
-//
-// No React / three.js imports so the topology stays unit-testable (mirrors openWorldMiniMap.js). Callers
-// (OpenWorldFocusCamera) convert the returned `[x, y, z]` tuples into THREE.Vector3 and animate toward
-// them.
-
-// Vertical field of view of the city camera (matches OpenWorldScene's ).
-export const CITY_CAMERA_FOV_DEG = 50;
-
-// How far the orbital camera may sit from its target. OpenWorldScene passes this straight to
-// OrbitControls as `maxDistance`, and every framing helper here clamps to it — the two MUST
-// be the same number. When they weren't, a fast-travel warp to a big district on a phone
-// computed ~218 units while the controls capped at 120, so OrbitControls yanked the camera
-// in the moment the fly handed control back and the region never fit the frame.
-// Sized to fit the widest region (60-unit Downtown) on a narrow portrait viewport, where the
-// horizontal extent — not the HUD — is what forces the camera back.
-export const CITY_MAX_ORBIT_DISTANCE = 240;
-
-// Ground-footprint radius of a single borough: the process ring (BOROUGH_PARAMS.processRingRadius
-// = 3) plus a process building's half-footprint and a little breathing room. Buildings never spread
-// wider than this on the ground, so a sphere of this radius (grown by the tower height) bounds the
-// entire borough.
-export const BOROUGH_GROUND_RADIUS = 4.5;
-
-// Empty space left around the framed borough (1 = edge-to-edge, 1.35 = 35% margin).
-const FRAMING_MARGIN = 1.35;
-
-// Extra vertical reach above the tower for the things that float over it — the building hologram
-// (~tower + 1.8) and the stacked AgentEntity markers — so a borough with several active agents
-// isn't clipped above the frame. A fixed cushion (the pure math only knows the tower `height`, not
-// the live agent count) that comfortably covers a typical stack.
-const BOROUGH_TOP_CLEARANCE = 3.0;
-
-// How far above the horizon the focus camera sits (~40°). Keeps the shot looking slightly down onto
-// the borough like the overview, without going full top-down.
-const PITCH_RAD = (40 * Math.PI) / 180;
-
-// A HUD panel can never eat more than this fraction of an axis for framing purposes — a floor that
-// stops a degenerate viewport from pushing the camera to infinity.
-const MIN_USABLE_FRACTION = 0.35;
-
-const clamp01 = (v) => (v < 0 ? 0 : v > 1 ? 1 : v);
-const toRad = (deg) => (deg * Math.PI) / 180;
-const finiteOr = (v, fallback) => (Number.isFinite(v) ? v : fallback);
-
-// Compute the framing camera for one borough.
-// building — { x, z, height } layout entry (from computeOpenWorldLayout). Missing/invalid fields fall
-// back to sane defaults so a not-yet-resolved building can't produce NaNs.
-// aspect — viewport width / height (portrait < 1 needs the camera farther back).
-// fovDeg — vertical FOV in degrees (defaults to the city camera's 50).
-// hudSafe — { right, bottom } fractions (0..1) of the viewport occupied by the HUD, so the
-// borough frames in the CLEAR region rather than under the detail panel.
-// Returns { position:[x,y,z], target:[x,y,z], distance, radius }.
-export function computeFocusCamera({ building, aspect = 1, fovDeg = CITY_CAMERA_FOV_DEG, hudSafe } = {}) {
- const bx = finiteOr(building?.x, 0);
- const bz = finiteOr(building?.z, 0);
- const heightRaw = finiteOr(building?.height, 4);
- const height = heightRaw > 0 ? heightRaw : 4;
-
- // Bounding radius: the wider of the borough's ground footprint and half its tower height (plus the
- // hologram/agent clearance above), so a tall skinny tower, a short wide cluster, and a
- // many-agent borough all stay fully in frame.
- const radius = Math.max(BOROUGH_GROUND_RADIUS, height * 0.6 + BOROUGH_TOP_CLEARANCE) * FRAMING_MARGIN;
-
- return frameFromRadius({
- centerX: bx,
- centerZ: bz,
- targetY: height * 0.45,
- radius,
- aspect,
- fovDeg,
- hudSafe,
- pitchRad: PITCH_RAD,
- });
-}
-
-// Shared optics for every framing helper here: given a bounding sphere on the ground plane,
-// back the camera off far enough that the sphere fits the HUD-reduced viewport, then pan the
-// whole shot clear of the HUD panels. Kept in one place so a borough and a whole district
-// can't drift into different framing rules.
-function frameFromRadius({ centerX, centerZ, targetY, radius, aspect, fovDeg, hudSafe, pitchRad }) {
- const safeAspect = Number.isFinite(aspect) && aspect > 0 ? aspect : 1;
- const safeFov = Number.isFinite(fovDeg) && fovDeg > 0 ? fovDeg : CITY_CAMERA_FOV_DEG;
-
- // Usable viewport fraction once the HUD safe area is subtracted, clamped to a floor.
- const right = clamp01(hudSafe?.right ?? 0);
- const bottom = clamp01(hudSafe?.bottom ?? 0);
- const usableW = Math.max(MIN_USABLE_FRACTION, 1 - right);
- const usableH = Math.max(MIN_USABLE_FRACTION, 1 - bottom);
-
- const halfV = Math.tan(toRad(safeFov) / 2);
- const halfH = halfV * safeAspect;
-
- // Distance so the bounding sphere fits both the (HUD-reduced) vertical and horizontal extents.
- const distV = radius / (halfV * usableH);
- const distH = radius / (halfH * usableW);
- // Clamped to the ceiling the controls enforce: a fly that ended beyond it would be snapped
- // back by OrbitControls the instant it re-enabled. A subject too large to fit at the cap is
- // framed as well as the cap allows — imperfect, but stable instead of jarring.
- const distance = Math.min(CITY_MAX_ORBIT_DISTANCE, Math.max(distV, distH));
-
- // Pan the framed region so the subject sits in the clear area: push it left of a right-edge panel
- // and up above a bottom-edge panel. Panning moves camera + target by the same world delta.
- const visHalfW = distance * halfH;
- const visHalfH = distance * halfV;
- const shiftX = right * visHalfW;
- const shiftY = bottom * visHalfH;
-
- const target = [centerX + shiftX, targetY - shiftY, centerZ];
-
- // Camera above + on the +Z side (like the overview camera), pitched down by `pitchRad`.
- const position = [
- target[0],
- target[1] + distance * Math.sin(pitchRad),
- centerZ + distance * Math.cos(pitchRad),
- ];
-
- return { position, target, distance, radius };
-}
-
-// --- Region framing (fast travel) -------------------------------------------
-// Same optics as computeFocusCamera, but framing a whole district parcel instead of one
-// borough: the bounding radius comes from the parcel's [w × d] footprint rather than a
-// tower's height. Used by the `/openworld/region/:regionId` warp so every region arrives
-// at a consistent, fully-in-frame establishing shot.
-
-// Empty space around a framed region — a touch tighter than a single borough's, since a
-// district already reads as a group and doesn't need the extra breathing room.
-const REGION_FRAMING_MARGIN = 1.2;
-
-// A parcel with no declared footprint (or a degenerate one) still needs a usable shot.
-const MIN_REGION_RADIUS = 10;
-
-// Regions are framed from a little higher than a borough (~46°) so the district's LAYOUT
-// reads — you're arriving to see a place, not to inspect one building's facade.
-const REGION_PITCH_RAD = (46 * Math.PI) / 180;
-
-// Look slightly above the ground plane so the district's structures, not the pavement,
-// sit at the center of frame.
-const REGION_TARGET_Y = 3;
-
-// Compute the establishing camera for one fast-travel region.
-// region — { anchor: [x, y, z], w, d } (from openWorldRegions.getRegion).
-// bounds — optional { minX, maxX, minZ, maxZ } of what is ACTUALLY on the ground for a
-// data-driven district (downtown / the archive grid, whose extent grows with the
-// install's app count). When given it supersedes the parcel's nominal w/d, so a
-// big install frames its real skyline instead of clipping the outer towers; the
-// parcel is still the floor, so a near-empty install doesn't zoom into one tower.
-// aspect / fovDeg / hudSafe — as computeFocusCamera.
-// Returns { position:[x,y,z], target:[x,y,z], distance, radius }.
-export function computeRegionCamera({ region, bounds, aspect = 1, fovDeg = CITY_CAMERA_FOV_DEG, hudSafe } = {}) {
- const anchor = Array.isArray(region?.anchor) ? region.anchor : [];
- let cx = finiteOr(anchor[0], 0);
- let cz = finiteOr(anchor[2], 0);
- let w = Math.max(0, finiteOr(region?.w, 0));
- let d = Math.max(0, finiteOr(region?.d, 0));
-
- const hasBounds = [bounds?.minX, bounds?.maxX, bounds?.minZ, bounds?.maxZ].every(Number.isFinite);
- if (hasBounds) {
- // Center on the live cloud and take the larger of live vs nominal on each axis — a grid
- // that has outgrown its parcel widens the shot, one that hasn't keeps the parcel's.
- cx = (bounds.minX + bounds.maxX) / 2;
- cz = (bounds.minZ + bounds.maxZ) / 2;
- w = Math.max(w, bounds.maxX - bounds.minX + BOROUGH_GROUND_RADIUS * 2);
- d = Math.max(d, bounds.maxZ - bounds.minZ + BOROUGH_GROUND_RADIUS * 2);
- }
-
- const radius = Math.max(MIN_REGION_RADIUS, Math.hypot(w, d) / 2) * REGION_FRAMING_MARGIN;
-
- return frameFromRadius({
- centerX: cx,
- centerZ: cz,
- targetY: REGION_TARGET_Y,
- radius,
- aspect,
- fovDeg,
- hudSafe,
- pitchRad: REGION_PITCH_RAD,
- });
-}
diff --git a/client/src/utils/openWorldFocusCamera.test.js b/client/src/utils/openWorldFocusCamera.test.js
deleted file mode 100644
index 97f1f23625..0000000000
--- a/client/src/utils/openWorldFocusCamera.test.js
+++ /dev/null
@@ -1,189 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { computeFocusCamera, BOROUGH_GROUND_RADIUS, CITY_CAMERA_FOV_DEG, computeRegionCamera, CITY_MAX_ORBIT_DISTANCE } from './openWorldFocusCamera';
-
-const building = { x: 12, z: -24, height: 6 };
-
-describe('computeFocusCamera', () => {
- it('targets the borough centre (up the tower) with no HUD offset', () => {
- const { target } = computeFocusCamera({ building, aspect: 1.6 });
- expect(target[0]).toBeCloseTo(building.x, 6);
- expect(target[2]).toBeCloseTo(building.z, 6);
- expect(target[1]).toBeGreaterThan(0);
- expect(target[1]).toBeLessThan(building.height);
- });
-
- it('places the camera above the tower and in front (+Z) so it never intersects geometry', () => {
- const { position } = computeFocusCamera({ building, aspect: 1.6 });
- expect(position[1]).toBeGreaterThan(building.height);
- expect(position[2]).toBeGreaterThan(building.z);
- });
-
- it('pulls the camera farther back for a portrait (narrow) aspect than a landscape one', () => {
- const portrait = computeFocusCamera({ building, aspect: 0.5 });
- const landscape = computeFocusCamera({ building, aspect: 2.0 });
- expect(portrait.distance).toBeGreaterThan(landscape.distance);
- });
-
- it('frames a taller tower from farther away', () => {
- const shortB = computeFocusCamera({ building: { x: 0, z: 0, height: 3 }, aspect: 1.6 });
- const tallB = computeFocusCamera({ building: { x: 0, z: 0, height: 40 }, aspect: 1.6 });
- expect(tallB.distance).toBeGreaterThan(shortB.distance);
- expect(tallB.radius).toBeGreaterThan(shortB.radius);
- });
-
- it('reserves headroom above the tower for the hologram / floating agents', () => {
- const { target, radius } = computeFocusCamera({ building, aspect: 1.6 });
- // The framed sphere reaches well above the tower top so agent markers are not clipped.
- expect(target[1] + radius).toBeGreaterThan(building.height + 3);
- });
-
- it('uses the borough ground radius floor for short buildings', () => {
- const { radius } = computeFocusCamera({ building: { x: 0, z: 0, height: 1 }, aspect: 1.6 });
- expect(radius).toBeGreaterThanOrEqual(BOROUGH_GROUND_RADIUS);
- });
-
- it('backs off and shifts the target when the HUD occupies the right edge', () => {
- // A portrait-ish aspect makes the horizontal extent the binding constraint, so shrinking the
- // usable width strictly increases the distance (in landscape a pan alone keeps it clear).
- const bare = computeFocusCamera({ building, aspect: 0.6 });
- const withPanel = computeFocusCamera({ building, aspect: 0.6, hudSafe: { right: 0.28 } });
- expect(withPanel.distance).toBeGreaterThan(bare.distance);
- // Target pans +x (borough shifts left, into the clear area beside the panel).
- expect(withPanel.target[0]).toBeGreaterThan(bare.target[0]);
- // Camera pans with the target so the view direction is preserved.
- expect(withPanel.position[0]).toBeCloseTo(withPanel.target[0], 6);
- });
-
- it('raises the framed region above a bottom-edge HUD panel', () => {
- const bare = computeFocusCamera({ building, aspect: 1.0 });
- const withSheet = computeFocusCamera({ building, aspect: 1.0, hudSafe: { bottom: 0.45 } });
- expect(withSheet.distance).toBeGreaterThan(bare.distance);
- // Panning up moves the target down in world space (building rises on screen).
- expect(withSheet.target[1]).toBeLessThan(bare.target[1]);
- });
-
- it('returns finite numbers for a missing/degenerate building', () => {
- const { position, target, distance } = computeFocusCamera({ building: undefined, aspect: 0 });
- [...position, ...target, distance].forEach((n) => expect(Number.isFinite(n)).toBe(true));
- });
-
- it('defaults to the city camera FOV', () => {
- const a = computeFocusCamera({ building, aspect: 1.6 });
- const b = computeFocusCamera({ building, aspect: 1.6, fovDeg: CITY_CAMERA_FOV_DEG });
- expect(a.distance).toBeCloseTo(b.distance, 6);
- });
-});
-// @vitest-environment node
-
-describe('computeRegionCamera', () => {
- const region = { id: 'memory', anchor: [-44, 0, -30], w: 22, d: 22 };
-
- it('centers the shot on the parcel anchor', () => {
- const { target } = computeRegionCamera({ region, aspect: 16 / 9 });
- expect(target[0]).toBeCloseTo(region.anchor[0], 6);
- expect(target[2]).toBeCloseTo(region.anchor[2], 6);
- });
-
- it('sits above and on the +Z side of the region, like the overview camera', () => {
- const { position, target } = computeRegionCamera({ region, aspect: 16 / 9 });
- expect(position[1]).toBeGreaterThan(target[1]);
- expect(position[2]).toBeGreaterThan(region.anchor[2]);
- });
-
- it('backs off farther for a bigger parcel', () => {
- const small = computeRegionCamera({ region, aspect: 16 / 9 });
- const big = computeRegionCamera({ region: { ...region, w: 66, d: 40 }, aspect: 16 / 9 });
- expect(big.distance).toBeGreaterThan(small.distance);
- });
-
- it('floors the radius so a zero-footprint parcel still frames usefully', () => {
- const { distance, radius } = computeRegionCamera({ region: { anchor: [0, 0, 0], w: 0, d: 0 }, aspect: 1 });
- expect(radius).toBeGreaterThan(0);
- expect(Number.isFinite(distance)).toBe(true);
- });
-
- it('pans clear of the HUD safe area', () => {
- const bare = computeRegionCamera({ region, aspect: 16 / 9 });
- const withPanel = computeRegionCamera({ region, aspect: 16 / 9, hudSafe: { right: 0.28 } });
- expect(withPanel.target[0]).toBeGreaterThan(bare.target[0]);
- });
-
- it('survives a missing/degenerate region without producing NaNs', () => {
- for (const bad of [undefined, {}, { anchor: null }, { anchor: [NaN, 0, NaN], w: NaN, d: NaN }]) {
- const { position, target } = computeRegionCamera({ region: bad, aspect: 16 / 9 });
- for (const n of [...position, ...target]) expect(Number.isFinite(n)).toBe(true);
- }
- });
-});
-
-import { listRegions } from './openWorldRegions';
-
-describe('framing stays inside the orbit-distance ceiling', () => {
- // OpenWorldScene hands CITY_MAX_ORBIT_DISTANCE straight to OrbitControls as maxDistance. A fly
- // that ended beyond it would be yanked back the instant the controls re-enabled, so the
- // framing math must never return a farther distance than the controls will keep.
- const VIEWPORTS = [
- ['desktop', 16 / 9, { right: 0.28, bottom: 0 }],
- ['narrow phone', 390 / 780, { right: 0, bottom: 0.5 }],
- ['degenerate', 0.2, { right: 0.9, bottom: 0.9 }],
- ];
-
- it('every real region frames within the ceiling on every viewport', () => {
- const over = [];
- for (const region of listRegions()) {
- for (const [name, aspect, hudSafe] of VIEWPORTS) {
- const { distance } = computeRegionCamera({ region, aspect, fovDeg: 50, hudSafe });
- if (distance > CITY_MAX_ORBIT_DISTANCE) over.push(`${region.id} @ ${name}: ${distance.toFixed(1)}`);
- }
- }
- expect(over).toEqual([]);
- });
-
- it('clamps rather than exceeding the ceiling for an absurdly large region', () => {
- const { distance } = computeRegionCamera({
- region: { anchor: [0, 0, 0], w: 5000, d: 5000 }, aspect: 0.4, fovDeg: 50,
- });
- expect(distance).toBe(CITY_MAX_ORBIT_DISTANCE);
- });
-
- it('clamps the borough focus camera too — they share the optics', () => {
- const { distance } = computeFocusCamera({
- building: { x: 0, z: 0, height: 100000 }, aspect: 0.4, fovDeg: 50,
- });
- expect(distance).toBe(CITY_MAX_ORBIT_DISTANCE);
- });
-});
-
-describe('computeRegionCamera — data-driven districts', () => {
- const parcel = { anchor: [0, 0, 0], w: 60, d: 60 };
-
- it('widens and re-centers on live layout bounds that outgrew the parcel', () => {
- const nominal = computeRegionCamera({ region: parcel, aspect: 16 / 9 });
- const grown = computeRegionCamera({
- region: parcel,
- bounds: { minX: -90, maxX: 90, minZ: -40, maxZ: 40 },
- aspect: 16 / 9,
- });
- expect(grown.radius).toBeGreaterThan(nominal.radius);
- // Re-centered on the live cloud, which here is symmetric about the parcel anchor.
- expect(grown.target[2]).toBeCloseTo(0, 6);
- });
-
- it('keeps the parcel as a floor so a near-empty install does not zoom into one tower', () => {
- const nominal = computeRegionCamera({ region: parcel, aspect: 16 / 9 });
- const tiny = computeRegionCamera({
- region: parcel,
- bounds: { minX: -6, maxX: 6, minZ: -6, maxZ: 6 },
- aspect: 16 / 9,
- });
- expect(tiny.radius).toBe(nominal.radius);
- });
-
- it('ignores partial or non-finite bounds rather than producing NaNs', () => {
- for (const bounds of [null, undefined, {}, { minX: 0, maxX: 10 }, { minX: NaN, maxX: 1, minZ: 0, maxZ: 1 }]) {
- const { position, target, radius } = computeRegionCamera({ region: parcel, bounds, aspect: 16 / 9 });
- for (const n of [...position, ...target, radius]) expect(Number.isFinite(n)).toBe(true);
- expect(radius).toBe(computeRegionCamera({ region: parcel, aspect: 16 / 9 }).radius);
- }
- });
-});
diff --git a/client/src/utils/openWorldFocusState.js b/client/src/utils/openWorldFocusState.js
deleted file mode 100644
index 17f0347b2d..0000000000
--- a/client/src/utils/openWorldFocusState.js
+++ /dev/null
@@ -1,18 +0,0 @@
-// Pure resolver for OpenWorld's URL-addressed building focus (issue #2593). Maps the
-// `/openworld/apps/:appId` route param + the live app list into a concrete render state.
-//
-// The key subtlety: a valid deep link whose app list is still loading must NOT flash the
-// "building not found" fallback. So `notFound` is only true once the list has finished loading
-// AND the id still matches nothing (deleted/archived-away/never-existed id).
-
-export function resolveOpenWorldFocus(appId, apps, { loading = false } = {}) {
- const hasFocus = typeof appId === 'string' && appId.length > 0;
- if (!hasFocus) return { hasFocus: false, focusedApp: null, notFound: false };
-
- const list = Array.isArray(apps) ? apps : [];
- const focusedApp = list.find((a) => a?.id === appId) || null;
- // Still loading → keep waiting (a valid id may resolve once apps arrive). Loaded + missing → 404.
- const notFound = !focusedApp && !loading;
-
- return { hasFocus: true, focusedApp, notFound };
-}
diff --git a/client/src/utils/openWorldFocusState.test.js b/client/src/utils/openWorldFocusState.test.js
deleted file mode 100644
index a28a90ae47..0000000000
--- a/client/src/utils/openWorldFocusState.test.js
+++ /dev/null
@@ -1,45 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { resolveOpenWorldFocus } from './openWorldFocusState';
-
-const apps = [
- { id: 'alpha', name: 'Alpha' },
- { id: 'beta', name: 'Beta' },
-];
-
-describe('resolveOpenWorldFocus', () => {
- it('reports no focus when there is no appId (overview)', () => {
- expect(resolveOpenWorldFocus(null, apps)).toEqual({ hasFocus: false, focusedApp: null, notFound: false });
- expect(resolveOpenWorldFocus('', apps)).toEqual({ hasFocus: false, focusedApp: null, notFound: false });
- expect(resolveOpenWorldFocus(undefined, apps)).toEqual({ hasFocus: false, focusedApp: null, notFound: false });
- });
-
- it('resolves a valid id to its app', () => {
- const res = resolveOpenWorldFocus('beta', apps);
- expect(res.hasFocus).toBe(true);
- expect(res.focusedApp).toBe(apps[1]);
- expect(res.notFound).toBe(false);
- });
-
- it('does NOT flag not-found while the app list is still loading (deep link + reload)', () => {
- const res = resolveOpenWorldFocus('beta', [], { loading: true });
- expect(res.hasFocus).toBe(true);
- expect(res.focusedApp).toBeNull();
- expect(res.notFound).toBe(false);
- });
-
- it('flags not-found for a stale/deleted id once loading has finished', () => {
- const res = resolveOpenWorldFocus('ghost', apps, { loading: false });
- expect(res.hasFocus).toBe(true);
- expect(res.focusedApp).toBeNull();
- expect(res.notFound).toBe(true);
- });
-
- it('tolerates a non-array app list', () => {
- expect(resolveOpenWorldFocus('alpha', null, { loading: false })).toEqual({
- hasFocus: true,
- focusedApp: null,
- notFound: true,
- });
- });
-});
-// @vitest-environment node
diff --git a/client/src/utils/openWorldGoalMonuments.js b/client/src/utils/openWorldGoalMonuments.js
deleted file mode 100644
index 022afdf0de..0000000000
--- a/client/src/utils/openWorldGoalMonuments.js
+++ /dev/null
@@ -1,356 +0,0 @@
-// Pure, deterministic helpers for OpenWorld's "goal monuments" (roadmap 2.7): each
-// life goal renders as a structure in a northeast monument district. Active goals are
-// construction sites (scaffolded/partial towers whose build completeness tracks
-// progress); completed goals are polished, fully-built monuments that shimmer; stalled
-// goals (active but with no recent progress) and abandoned goals read dim. The goal
-// list is capped to a sensible row; the rest are summarized by an overflow marker. No
-// three.js / React imports so the topology is unit-testable (mirrors openWorldFederation.js
-// / openWorldHealthTower.js).
-
-import { PARCELS } from './openWorldPlan';
-
-export const MONUMENTS = {
- // Northeast monument district — a row anchored by the master plan (openWorldPlan.js), clear
- // of the vault, task-queue, voice beacon, health tower, AI core, and productivity district.
- base: PARCELS.goals.anchor, // center of the row
- spacing: 9, // x-distance between adjacent monuments
- z: PARCELS.goals.anchor[2], // shared depth of the row
- maxMonuments: 8, // cap; goals beyond this fold into an overflow marker
- minHeight: 2, // floor height so even a 0%-progress site reads as a small structure
- fullHeight: 12, // height of a completed (100%) monument
- baseWidth: 2.4,
-};
-
-// Status → visual treatment. `dim` collapses opacity + glow so stalled/abandoned goals
-// recede; `built` flags a finished monument (drives the polished material + shimmer).
-const STATUS_STYLES = {
- completed: { color: '#22c55e', opacity: 1, intensity: 0.7, dim: false, built: true }, // port-success
- active: { color: '#3b82f6', opacity: 0.92, intensity: 0.4, dim: false, built: false }, // port-accent — under construction
- stalled: { color: '#f59e0b', opacity: 0.55, intensity: 0.18, dim: true, built: false }, // port-warning, dimmed
- abandoned: { color: '#64748b', opacity: 0.32, intensity: 0.08, dim: true, built: false }, // slate, dimmed
-};
-
-const DEFAULT_STYLE = STATUS_STYLES.active;
-
-const clamp01 = (n) => Math.max(0, Math.min(1, n));
-
-// Days since the most recent progressHistory entry (or createdAt as a fallback).
-// Returns null when there's nothing to measure from, so callers can distinguish
-// "no signal" from a real recency.
-export function daysSinceLastProgress(goal, now = Date.now()) {
- const history = Array.isArray(goal?.progressHistory) ? goal.progressHistory : [];
- let latest = null;
- for (const entry of history) {
- const t = entry?.date ? Date.parse(entry.date) : NaN;
- if (Number.isFinite(t) && (latest === null || t > latest)) latest = t;
- }
- if (latest === null) {
- const created = goal?.createdAt ? Date.parse(goal.createdAt) : NaN;
- if (Number.isFinite(created)) latest = created;
- }
- if (latest === null) return null;
- return Math.max(0, (now - latest) / (1000 * 60 * 60 * 24));
-}
-
-// Derive the *effective* status used for visualization. The stored status enum is
-// active | completed | abandoned (server: goalStatusEnum). "stalled" is a derived
-// state — an active, not-yet-complete goal with no progress in STALL_DAYS — so the
-// monument visibly dims when a goal goes quiet. Threshold is conservative.
-export const STALL_DAYS = 45;
-
-export function effectiveGoalStatus(goal, now = Date.now()) {
- const raw = goal?.status;
- if (raw === 'completed') return 'completed';
- if (raw === 'abandoned') return 'abandoned';
- // Treat anything else as active for styling purposes.
- const progress = Number.isFinite(goal?.progress) ? goal.progress : 0;
- if (progress >= 100) return 'completed';
- const idleDays = daysSinceLastProgress(goal, now);
- if (idleDays !== null && idleDays >= STALL_DAYS) return 'stalled';
- return 'active';
-}
-
-// Build completeness 0..1: completed monuments are always fully built; everything else
-// tracks `progress` (clamped). A present-but-zero progress reads as a 1-floor stub, not
-// nothing, so a freshly-created goal still shows on the row.
-export function buildCompleteness(goal, status) {
- if (status === 'completed') return 1;
- const progress = Number.isFinite(goal?.progress) ? goal.progress : 0;
- return clamp01(progress / 100);
-}
-
-// A milestone is "done" when it carries a completion timestamp (`completedAt`, the
-// server's stored field) or an explicit `completed: true` flag. Absent both, it's
-// pending. Kept tolerant so an older/foreign goal record doesn't crash the view.
-export function isMilestoneDone(milestone) {
- if (!milestone || typeof milestone !== 'object') return false;
- if (milestone.completed === true) return true;
- return typeof milestone.completedAt === 'string' && milestone.completedAt.length > 0;
-}
-
-// Break a monument's height into ordered milestone segments (floors). Each segment
-// reports its vertical extent (`y0`..`y1`, centered at `cy`) and whether the milestone
-// is done — so the component can render completed floors solid and pending floors as
-// translucent scaffold rungs. `height` is the monument's built+scaffold total; segments
-// are sorted by the milestone `order` field (stable, ties keep input order) and split the
-// height evenly. A goal with no milestones returns an empty array (the caller falls back
-// to the plain built/scaffold split). Pure + deterministic — no three.js.
-export function computeMilestoneSegments(goal, height) {
- const raw = Array.isArray(goal?.milestones) ? goal.milestones.filter((m) => m && typeof m === 'object') : [];
- if (raw.length === 0 || !(height > 0)) return [];
-
- // Stable order: by `order` field ascending, ties keep original index.
- const ordered = raw
- .map((milestone, i) => ({ milestone, i }))
- .sort((a, b) => {
- const oa = Number.isFinite(a.milestone.order) ? a.milestone.order : a.i;
- const ob = Number.isFinite(b.milestone.order) ? b.milestone.order : b.i;
- if (oa !== ob) return oa - ob;
- return a.i - b.i;
- });
-
- const segHeight = height / ordered.length;
- return ordered.map(({ milestone }, slot) => {
- const y0 = slot * segHeight;
- const y1 = y0 + segHeight;
- return {
- id: milestone.id || `ms-${slot}`,
- title: typeof milestone.title === 'string' && milestone.title ? milestone.title : `Milestone ${slot + 1}`,
- order: slot,
- done: isMilestoneDone(milestone),
- y0,
- y1,
- cy: (y0 + y1) / 2,
- segHeight,
- };
- });
-}
-
-// Stamp a monument view-model with its milestone segments + done/total counts. Used by
-// placeMonument and again by the forest layout after a spire's height is boosted (the
-// segments must be recomputed against the taller height). Mutates and returns `monument`.
-function attachMilestones(monument, goal, height) {
- monument.segments = computeMilestoneSegments(goal, height);
- monument.milestoneTotal = monument.segments.length;
- monument.milestoneDone = monument.segments.filter((s) => s.done).length;
- return monument;
-}
-
-// Status ordering shared by the flat row and the forest: completed first (trophies up
-// front), then active, stalled, abandoned; ties broken by goal id for a layout that
-// doesn't reshuffle across refetches. Used as an Array.sort comparator over items that
-// expose `{ status, id }`.
-const STATUS_RANK = { completed: 0, active: 1, stalled: 2, abandoned: 3 };
-function compareByStatusThenId(a, b) {
- const sa = STATUS_RANK[a.status] ?? 9;
- const sb = STATUS_RANK[b.status] ?? 9;
- if (sa !== sb) return sa - sb;
- return String(a.id || '').localeCompare(String(b.id || ''));
-}
-
-// Map one goal to its placed monument view-model. `index` is the slot in the row (0-based);
-// `count` is how many monuments are actually placed, so the row is centered on MONUMENTS.base.
-// When `position` is supplied (goal-forest layout) it overrides the centered-row placement,
-// so the same monument view-model serves both the flat row and the hierarchy spires.
-// `heightScale` (forest spires) boosts the tower height; passing it here means milestone
-// segments are computed once against the final height instead of re-segmented after.
-export function placeMonument(goal, index, count, now = Date.now(), position = null, heightScale = 1) {
- const status = effectiveGoalStatus(goal, now);
- const style = STATUS_STYLES[status] || DEFAULT_STYLE;
- const completeness = buildCompleteness(goal, status);
- const height = (MONUMENTS.minHeight + completeness * (MONUMENTS.fullHeight - MONUMENTS.minHeight)) * heightScale;
-
- // Center the row: slot 0 sits at the leftmost, the middle slot aligns with base.x.
- const offset = (index - (count - 1) / 2) * MONUMENTS.spacing;
- const x = MONUMENTS.base[0] + offset;
-
- return attachMilestones({
- id: goal?.id || `goal-${index}`,
- title: typeof goal?.title === 'string' && goal.title ? goal.title : 'Untitled Goal',
- status,
- progress: Number.isFinite(goal?.progress) ? clamp01(goal.progress / 100) * 100 : 0,
- completeness, // 0..1 — fraction of the monument that is "built"
- color: style.color,
- opacity: style.opacity,
- intensity: style.intensity,
- dim: style.dim,
- built: style.built,
- height,
- width: MONUMENTS.baseWidth,
- position: Array.isArray(position) ? position : [x, 0, MONUMENTS.z],
- }, goal, height);
-}
-
-// Full derived view-model for the component. `goals` is the raw goals list (the API
-// returns `{ goals: [...] }`; callers should pass `data?.goals`). A missing / non-array
-// input yields an empty district rather than a crash. Goals beyond MONUMENTS.maxMonuments
-// fold into an `overflow` marker placed just past the end of the row.
-export function computeGoalMonuments(goals, now = Date.now()) {
- const list = Array.isArray(goals) ? goals.filter((g) => g && typeof g === 'object') : [];
-
- // Stable ordering so the row doesn't reshuffle across refetches (see STATUS_RANK).
- const ranked = list
- .map((goal) => ({ goal, id: goal?.id, status: effectiveGoalStatus(goal, now) }))
- .sort(compareByStatusThenId);
-
- const visible = ranked.slice(0, MONUMENTS.maxMonuments);
- const overflowCount = Math.max(0, ranked.length - visible.length);
-
- const monuments = visible.map(({ goal }, index) =>
- placeMonument(goal, index, visible.length, now)
- );
-
- // Overflow marker sits one slot past the right end of the row.
- let overflow = null;
- if (overflowCount > 0) {
- const offset = (visible.length - (visible.length - 1) / 2) * MONUMENTS.spacing;
- overflow = {
- count: overflowCount,
- position: [MONUMENTS.base[0] + offset, 0, MONUMENTS.z],
- };
- }
-
- const completedCount = ranked.filter((r) => r.status === 'completed').length;
- const activeCount = ranked.filter((r) => r.status === 'active').length;
-
- return {
- base: MONUMENTS.base,
- monuments,
- overflow,
- total: ranked.length,
- completedCount,
- activeCount,
- hasData: ranked.length > 0,
- };
-}
-
-// Goal-tree (hierarchy) layout. Goals carry `parentId`; the server's getGoalsTree()
-// builds the same parent→child forest. Here we lay each ROOT goal out as a central spire
-// (taller than a flat-row monument) with its direct children clustered in a ring around
-// it and a link drawn from each child up to the parent apex — so a glance reads which
-// goals roll up under which. Multiple roots are spread along the row depth so their
-// clusters don't overlap. Pure + deterministic (no three.js): the component consumes the
-// returned positions/links directly.
-export const FOREST = {
- base: MONUMENTS.base, // shared center with the flat row
- clusterSpacing: 26, // x-distance between adjacent root clusters
- childRadius: 7.5, // ring radius of children around their root spire
- spireBoost: 1.5, // root spires render this much taller than a flat monument
- maxRoots: 4, // cap root clusters so the district stays legible
- maxChildren: 6, // cap children per root (ring slots); extras fold into the root's count
-};
-
-// Build the { id -> goal, children: [...] } forest from a flat goals list using parentId.
-// Mirrors getGoalsTree()'s tree builder: a goal whose parentId points at a present goal
-// becomes that goal's child; everything else (null/dangling parentId) is a root. Cycles
-// are impossible because the server validates parentId against ancestor cycles on write,
-// but we still guard by only attaching when the parent exists and isn't the node itself.
-export function buildGoalForest(goals) {
- const list = Array.isArray(goals) ? goals.filter((g) => g && typeof g === 'object' && g.id) : [];
- const byId = new Map(list.map((g) => [g.id, { goal: g, children: [] }]));
- const roots = [];
- for (const node of byId.values()) {
- const pid = node.goal.parentId;
- if (pid && pid !== node.goal.id && byId.has(pid)) {
- byId.get(pid).children.push(node);
- } else {
- roots.push(node);
- }
- }
- return { roots, byId };
-}
-
-// Total number of goals nested under a forest node (its children, grandchildren, …),
-// excluding the node itself. The forest layout renders only two visible levels (root spire
-// + child ring), so a node's deeper descendants are summarized by this count rather than
-// drawn — nothing is silently dropped. Guarded against the (server-prevented) cycle case
-// via a visited set so a hand-corrupted parentId loop can't recurse forever.
-export function countDescendants(node, seen = new Set()) {
- if (!node || seen.has(node)) return 0;
- seen.add(node);
- let total = 0;
- for (const child of node.children || []) {
- total += 1 + countDescendants(child, seen);
- }
- return total;
-}
-
-// Full hierarchy view-model. Returns root spires (each a placed monument with extra
-// height) plus their child monuments arranged in a ring, and `links` joining each child
-// apex-ward to its root. Roots are ordered completed→active→stalled→abandoned (same as the
-// flat row) so finished towers lead; ties broken by id for stable layout across refetches.
-export function computeGoalForest(goals, now = Date.now()) {
- const { roots } = buildGoalForest(goals);
-
- // Same completed→active→stalled→abandoned ordering as the flat row (see STATUS_RANK).
- const rankedRoots = roots
- .map((node) => ({ node, id: node.goal?.id, status: effectiveGoalStatus(node.goal, now) }))
- .sort(compareByStatusThenId);
-
- const visibleRoots = rankedRoots.slice(0, FOREST.maxRoots);
- // Root overflow counts the folded-away root trees AND everything nested under them, so a
- // child-bearing root past the cap isn't silently undercounted (it's `roots + descendants`).
- const rootOverflow = rankedRoots
- .slice(FOREST.maxRoots)
- .reduce((sum, { node }) => sum + 1 + countDescendants(node), 0);
-
- const clusters = visibleRoots.map(({ node }, rootIndex) => {
- // Spread root clusters along x, centered on FOREST.base.
- const clusterX = FOREST.base[0] + (rootIndex - (visibleRoots.length - 1) / 2) * FOREST.clusterSpacing;
- const clusterZ = FOREST.base[2];
-
- // Root spire — a placed monument boosted in height so it visually anchors the cluster.
- // The spireBoost is applied inside placeMonument so milestone floors fill the taller
- // tower in one pass (no discarded re-segment).
- const spire = placeMonument(node.goal, 0, 1, now, [clusterX, 0, clusterZ], FOREST.spireBoost);
- spire.isSpire = true;
-
- const childNodes = node.children.slice(0, FOREST.maxChildren);
- // Child overflow counts the folded-away children plus their own sub-trees, mirroring
- // the descendantCount surfaced on displayed children — nothing nested vanishes silently.
- const childOverflow = node.children
- .slice(FOREST.maxChildren)
- .reduce((sum, child) => sum + 1 + countDescendants(child), 0);
-
- // Children ring around the spire. A single child sits directly in front; multiple
- // children spread evenly across a forward-facing arc so links don't cross the spire.
- const children = childNodes.map((child, ci) => {
- const n = childNodes.length;
- const angle = n === 1 ? Math.PI / 2 : (Math.PI / (n + 1)) * (ci + 1); // 0..PI forward arc
- const cx = clusterX + Math.cos(angle) * FOREST.childRadius;
- const cz = clusterZ + Math.sin(angle) * FOREST.childRadius; // +z = toward the camera/front
- const m = placeMonument(child.goal, 0, 1, now, [cx, 0, cz]);
- m.parentId = node.goal.id;
- // Deeper descendants aren't drawn (the layout is two levels); surface their count so
- // a grandchild-bearing sub-goal advertises its sub-tree instead of hiding it.
- m.descendantCount = countDescendants(child);
- return m;
- });
-
- // Links: from each child's apex up to the root spire's apex. Towers rise from a 0.4
- // plinth, so the nominal top sits at 0.4 + height — links join near the tips (segmented
- // towers leave a sub-floor gap below this from the inter-floor spacing, visually fine).
- const links = children.map((child) => ({
- from: [child.position[0], 0.4 + child.height, child.position[2]],
- to: [spire.position[0], 0.4 + spire.height, spire.position[2]],
- childId: child.id,
- }));
-
- return { spire, children, links, childOverflow };
- });
-
- return {
- base: FOREST.base,
- clusters,
- rootOverflow,
- rootCount: rankedRoots.length,
- hasData: rankedRoots.length > 0,
- // A forest is only worth showing when at least one root actually has children;
- // otherwise it's just the flat row with extra spacing. The component uses this to
- // decide whether to render the hierarchy view vs. fall back to the flat row.
- // Derived from ALL ranked roots (pre-cap) so a child-bearing root that overflows past
- // FOREST.maxRoots still flips the district into the forest layout instead of the flat
- // row — otherwise its sub-tree would be invisible AND uncounted.
- hasHierarchy: rankedRoots.some(({ node }) => node.children.length > 0),
- };
-}
diff --git a/client/src/utils/openWorldGoalMonuments.test.js b/client/src/utils/openWorldGoalMonuments.test.js
deleted file mode 100644
index dbcb9248c4..0000000000
--- a/client/src/utils/openWorldGoalMonuments.test.js
+++ /dev/null
@@ -1,448 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import {
- MONUMENTS,
- FOREST,
- STALL_DAYS,
- daysSinceLastProgress,
- effectiveGoalStatus,
- buildCompleteness,
- placeMonument,
- computeGoalMonuments,
- isMilestoneDone,
- computeMilestoneSegments,
- buildGoalForest,
- countDescendants,
- computeGoalForest,
-} from './openWorldGoalMonuments';
-
-const NOW = Date.parse('2026-06-03T00:00:00Z');
-const daysAgo = (d) => new Date(NOW - d * 24 * 60 * 60 * 1000).toISOString();
-
-const goal = (over = {}) => ({
- id: 'goal-1',
- title: 'Run a marathon',
- status: 'active',
- progress: 50,
- createdAt: daysAgo(10),
- progressHistory: [{ date: daysAgo(5), value: 50 }],
- ...over,
-});
-
-describe('daysSinceLastProgress', () => {
- it('measures from the most recent progressHistory entry', () => {
- const g = goal({ progressHistory: [{ date: daysAgo(3), value: 20 }, { date: daysAgo(10), value: 5 }] });
- expect(daysSinceLastProgress(g, NOW)).toBeCloseTo(3, 1);
- });
-
- it('falls back to createdAt when no history', () => {
- const g = goal({ progressHistory: [], createdAt: daysAgo(7) });
- expect(daysSinceLastProgress(g, NOW)).toBeCloseTo(7, 1);
- });
-
- it('returns null when there is no usable date', () => {
- expect(daysSinceLastProgress({ progressHistory: [] }, NOW)).toBeNull();
- expect(daysSinceLastProgress(null, NOW)).toBeNull();
- expect(daysSinceLastProgress({ progressHistory: [{ date: 'nope' }] }, NOW)).toBeNull();
- });
-});
-
-describe('effectiveGoalStatus', () => {
- it('maps stored completed/abandoned through unchanged', () => {
- expect(effectiveGoalStatus(goal({ status: 'completed' }), NOW)).toBe('completed');
- expect(effectiveGoalStatus(goal({ status: 'abandoned' }), NOW)).toBe('abandoned');
- });
-
- it('treats progress >= 100 as completed even if stored active', () => {
- expect(effectiveGoalStatus(goal({ status: 'active', progress: 100 }), NOW)).toBe('completed');
- });
-
- it('derives stalled for an active goal with no progress past the threshold', () => {
- const stale = goal({ progressHistory: [{ date: daysAgo(STALL_DAYS + 5), value: 50 }] });
- expect(effectiveGoalStatus(stale, NOW)).toBe('stalled');
- });
-
- it('keeps a recently-progressed active goal active', () => {
- const fresh = goal({ progressHistory: [{ date: daysAgo(2), value: 50 }] });
- expect(effectiveGoalStatus(fresh, NOW)).toBe('active');
- });
-
- it('defaults to active for an unknown/missing status', () => {
- expect(effectiveGoalStatus({ progressHistory: [{ date: daysAgo(1) }] }, NOW)).toBe('active');
- });
-});
-
-describe('buildCompleteness', () => {
- it('is always 1 for a completed monument', () => {
- expect(buildCompleteness(goal({ progress: 12 }), 'completed')).toBe(1);
- });
-
- it('tracks progress/100 for active, clamped', () => {
- expect(buildCompleteness(goal({ progress: 40 }), 'active')).toBeCloseTo(0.4);
- expect(buildCompleteness(goal({ progress: 250 }), 'active')).toBe(1);
- expect(buildCompleteness(goal({ progress: -5 }), 'active')).toBe(0);
- });
-
- it('treats missing progress as 0 (a floor stub), not a crash', () => {
- expect(buildCompleteness({}, 'active')).toBe(0);
- });
-});
-
-describe('placeMonument', () => {
- it('maps status to color/opacity/dim/built', () => {
- const completed = placeMonument(goal({ status: 'completed' }), 0, 1, NOW);
- expect(completed.built).toBe(true);
- expect(completed.dim).toBe(false);
- expect(completed.completeness).toBe(1);
- expect(completed.height).toBeCloseTo(MONUMENTS.fullHeight);
-
- const abandoned = placeMonument(goal({ status: 'abandoned' }), 0, 1, NOW);
- expect(abandoned.dim).toBe(true);
- expect(abandoned.built).toBe(false);
- expect(abandoned.opacity).toBeLessThan(completed.opacity);
- });
-
- it('progress drives height between min and full', () => {
- const half = placeMonument(goal({ status: 'active', progress: 50, progressHistory: [{ date: daysAgo(1) }] }), 0, 1, NOW);
- expect(half.height).toBeGreaterThan(MONUMENTS.minHeight);
- expect(half.height).toBeLessThan(MONUMENTS.fullHeight);
- });
-
- it('centers a single monument on base.x', () => {
- const only = placeMonument(goal(), 0, 1, NOW);
- expect(only.position[0]).toBeCloseTo(MONUMENTS.base[0]);
- expect(only.position[2]).toBe(MONUMENTS.z);
- });
-
- it('lays out a row centered around base.x with consistent spacing', () => {
- const a = placeMonument(goal(), 0, 3, NOW);
- const b = placeMonument(goal(), 1, 3, NOW);
- const c = placeMonument(goal(), 2, 3, NOW);
- expect(b.position[0]).toBeCloseTo(MONUMENTS.base[0]); // middle slot on center
- expect(b.position[0] - a.position[0]).toBeCloseTo(MONUMENTS.spacing);
- expect(c.position[0] - b.position[0]).toBeCloseTo(MONUMENTS.spacing);
- });
-
- it('falls back to a title and id without crashing', () => {
- const m = placeMonument({}, 2, 5, NOW);
- expect(m.title).toBe('Untitled Goal');
- expect(m.id).toBe('goal-2');
- });
-});
-
-describe('computeGoalMonuments', () => {
- it('handles missing / non-array input as an empty district', () => {
- for (const bad of [null, undefined, 'nope', 42, {}]) {
- const vm = computeGoalMonuments(bad, NOW);
- expect(vm.monuments).toEqual([]);
- expect(vm.overflow).toBeNull();
- expect(vm.hasData).toBe(false);
- expect(vm.total).toBe(0);
- }
- });
-
- it('places one monument per goal up to the cap', () => {
- const goals = Array.from({ length: 4 }, (_, i) => goal({ id: `g-${i}` }));
- const vm = computeGoalMonuments(goals, NOW);
- expect(vm.monuments).toHaveLength(4);
- expect(vm.overflow).toBeNull();
- expect(vm.total).toBe(4);
- expect(vm.hasData).toBe(true);
- });
-
- it('caps at maxMonuments and folds the rest into an overflow marker', () => {
- const goals = Array.from({ length: MONUMENTS.maxMonuments + 3 }, (_, i) => goal({ id: `g-${i}` }));
- const vm = computeGoalMonuments(goals, NOW);
- expect(vm.monuments).toHaveLength(MONUMENTS.maxMonuments);
- expect(vm.overflow).not.toBeNull();
- expect(vm.overflow.count).toBe(3);
- expect(vm.overflow.position[2]).toBe(MONUMENTS.z);
- expect(vm.total).toBe(MONUMENTS.maxMonuments + 3);
- });
-
- it('orders completed monuments before active before stalled before abandoned', () => {
- const goals = [
- goal({ id: 'ab', status: 'abandoned' }),
- goal({ id: 'co', status: 'completed' }),
- goal({ id: 'st', progressHistory: [{ date: daysAgo(STALL_DAYS + 1) }] }),
- goal({ id: 'ac', progressHistory: [{ date: daysAgo(1) }] }),
- ];
- const vm = computeGoalMonuments(goals, NOW);
- expect(vm.monuments.map((m) => m.status)).toEqual(['completed', 'active', 'stalled', 'abandoned']);
- });
-
- it('reports completed / active counts', () => {
- const goals = [
- goal({ id: 'a', status: 'completed' }),
- goal({ id: 'b', status: 'completed' }),
- goal({ id: 'c', progressHistory: [{ date: daysAgo(1) }] }),
- ];
- const vm = computeGoalMonuments(goals, NOW);
- expect(vm.completedCount).toBe(2);
- expect(vm.activeCount).toBe(1);
- });
-
- it('skips null / non-object entries without crashing', () => {
- const vm = computeGoalMonuments([null, goal({ id: 'ok' }), 'bad', 42], NOW);
- expect(vm.monuments).toHaveLength(1);
- expect(vm.monuments[0].id).toBe('ok');
- });
-});
-
-const milestone = (over = {}) => ({ id: `ms-${over.order ?? 0}`, title: `MS`, order: 0, completedAt: null, ...over });
-
-describe('isMilestoneDone', () => {
- it('is true when completedAt is a non-empty string', () => {
- expect(isMilestoneDone(milestone({ completedAt: '2026-01-01T00:00:00Z' }))).toBe(true);
- });
-
- it('is true when the explicit completed flag is set', () => {
- expect(isMilestoneDone(milestone({ completed: true, completedAt: null }))).toBe(true);
- });
-
- it('is false when neither signal is present', () => {
- expect(isMilestoneDone(milestone({ completedAt: null }))).toBe(false);
- expect(isMilestoneDone(milestone({ completedAt: '' }))).toBe(false);
- expect(isMilestoneDone(null)).toBe(false);
- expect(isMilestoneDone('nope')).toBe(false);
- });
-});
-
-describe('computeMilestoneSegments', () => {
- it('returns an empty array for a goal with no milestones', () => {
- expect(computeMilestoneSegments(goal({ milestones: [] }), 10)).toEqual([]);
- expect(computeMilestoneSegments(goal({ milestones: undefined }), 10)).toEqual([]);
- expect(computeMilestoneSegments(goal(), 0)).toEqual([]);
- });
-
- it('splits the height evenly into ordered, stacked floors', () => {
- const g = goal({ milestones: [milestone({ order: 0 }), milestone({ order: 1 }), milestone({ order: 2 })] });
- const segs = computeMilestoneSegments(g, 12);
- expect(segs).toHaveLength(3);
- expect(segs.map((s) => s.segHeight)).toEqual([4, 4, 4]);
- expect(segs[0].y0).toBe(0);
- expect(segs[0].y1).toBe(4);
- expect(segs[1].y0).toBe(4);
- expect(segs[2].y1).toBeCloseTo(12);
- expect(segs[1].cy).toBe(6);
- });
-
- it('sorts by the order field, ties keep input order', () => {
- const g = goal({ milestones: [
- milestone({ id: 'c', order: 2, title: 'Third' }),
- milestone({ id: 'a', order: 0, title: 'First' }),
- milestone({ id: 'b', order: 1, title: 'Second' }),
- ] });
- const segs = computeMilestoneSegments(g, 9);
- expect(segs.map((s) => s.title)).toEqual(['First', 'Second', 'Third']);
- expect(segs.map((s) => s.order)).toEqual([0, 1, 2]); // re-indexed slot order
- });
-
- it('falls back to input index when the order field is absent (manual milestones)', () => {
- // addMilestone() on the server omits `order`; computeMilestoneSegments must keep input
- // order for those (sort key = input index). An explicit `order` (AI phases set it)
- // sorts by its value; ties with an index-keyed entry break by original input index.
- const g = goal({ milestones: [
- { id: 'm1', title: 'First added' }, // no order → key = index 0
- { id: 'm2', title: 'Second added' }, // no order → key = index 1
- { id: 'm3', title: 'Phase', order: 5 }, // explicit order 5 → sorts last
- ] });
- const segs = computeMilestoneSegments(g, 9);
- expect(segs.map((s) => s.title)).toEqual(['First added', 'Second added', 'Phase']);
- });
-
- it('marks the done flag per milestone', () => {
- const g = goal({ milestones: [
- milestone({ order: 0, completedAt: '2026-01-01T00:00:00Z' }),
- milestone({ order: 1, completedAt: null }),
- ] });
- const segs = computeMilestoneSegments(g, 8);
- expect(segs[0].done).toBe(true);
- expect(segs[1].done).toBe(false);
- });
-
- it('skips non-object milestone entries', () => {
- const g = goal({ milestones: [null, milestone({ order: 0 }), 42] });
- expect(computeMilestoneSegments(g, 6)).toHaveLength(1);
- });
-});
-
-describe('placeMonument with milestones', () => {
- it('attaches milestone segments and done/total counts', () => {
- const g = goal({ status: 'active', progress: 60, progressHistory: [{ date: daysAgo(1) }], milestones: [
- milestone({ order: 0, completedAt: '2026-01-01T00:00:00Z' }),
- milestone({ order: 1, completedAt: '2026-02-01T00:00:00Z' }),
- milestone({ order: 2, completedAt: null }),
- ] });
- const m = placeMonument(g, 0, 1, NOW);
- expect(m.segments).toHaveLength(3);
- expect(m.milestoneTotal).toBe(3);
- expect(m.milestoneDone).toBe(2);
- });
-
- it('honors an explicit position override (forest layout)', () => {
- const m = placeMonument(goal(), 0, 1, NOW, [5, 0, -10]);
- expect(m.position).toEqual([5, 0, -10]);
- });
-
- it('reports zero milestones for a goal without any', () => {
- const m = placeMonument(goal({ milestones: [] }), 0, 1, NOW);
- expect(m.segments).toEqual([]);
- expect(m.milestoneTotal).toBe(0);
- expect(m.milestoneDone).toBe(0);
- });
-});
-
-describe('buildGoalForest', () => {
- it('attaches children under a present parent and leaves the rest as roots', () => {
- const goals = [
- goal({ id: 'root', parentId: null }),
- goal({ id: 'child-a', parentId: 'root' }),
- goal({ id: 'child-b', parentId: 'root' }),
- goal({ id: 'orphan', parentId: 'gone' }), // dangling parentId → root
- ];
- const { roots } = buildGoalForest(goals);
- const ids = roots.map((r) => r.goal.id).sort();
- expect(ids).toEqual(['orphan', 'root']);
- const root = roots.find((r) => r.goal.id === 'root');
- expect(root.children.map((c) => c.goal.id).sort()).toEqual(['child-a', 'child-b']);
- });
-
- it('does not attach a goal to itself', () => {
- const { roots } = buildGoalForest([goal({ id: 'self', parentId: 'self' })]);
- expect(roots).toHaveLength(1);
- expect(roots[0].children).toHaveLength(0);
- });
-
- it('ignores entries without an id or non-objects', () => {
- const { roots } = buildGoalForest([null, 'x', { parentId: 'p' }, goal({ id: 'ok' })]);
- expect(roots.map((r) => r.goal.id)).toEqual(['ok']);
- });
-
- it('nests grandchildren under children (multi-level)', () => {
- const { roots } = buildGoalForest([
- goal({ id: 'root', parentId: null }),
- goal({ id: 'child', parentId: 'root' }),
- goal({ id: 'grand', parentId: 'child' }),
- ]);
- expect(roots).toHaveLength(1);
- const child = roots[0].children[0];
- expect(child.goal.id).toBe('child');
- expect(child.children.map((g) => g.goal.id)).toEqual(['grand']);
- });
-});
-
-describe('countDescendants', () => {
- const forestOf = (goals) => buildGoalForest(goals).roots[0];
-
- it('counts children and grandchildren, excluding the node itself', () => {
- const root = forestOf([
- goal({ id: 'root', parentId: null }),
- goal({ id: 'c1', parentId: 'root' }),
- goal({ id: 'c2', parentId: 'root' }),
- goal({ id: 'g1', parentId: 'c1' }),
- goal({ id: 'g2', parentId: 'c1' }),
- ]);
- expect(countDescendants(root)).toBe(4); // c1, c2, g1, g2
- });
-
- it('is 0 for a leaf', () => {
- const root = forestOf([goal({ id: 'solo', parentId: null })]);
- expect(countDescendants(root)).toBe(0);
- });
-});
-
-describe('computeGoalForest', () => {
- const tree = () => [
- goal({ id: 'apex', parentId: null, status: 'active', progressHistory: [{ date: daysAgo(1) }] }),
- goal({ id: 'c1', parentId: 'apex', progressHistory: [{ date: daysAgo(1) }] }),
- goal({ id: 'c2', parentId: 'apex', progressHistory: [{ date: daysAgo(1) }] }),
- ];
-
- it('reports hasHierarchy only when a root has children', () => {
- const flat = computeGoalForest([goal({ id: 'a' }), goal({ id: 'b' })], NOW);
- expect(flat.hasHierarchy).toBe(false);
- const nested = computeGoalForest(tree(), NOW);
- expect(nested.hasHierarchy).toBe(true);
- });
-
- it('places a root spire taller than a flat monument and clusters its children', () => {
- const vm = computeGoalForest(tree(), NOW);
- expect(vm.clusters).toHaveLength(1);
- const cluster = vm.clusters[0];
- expect(cluster.spire.isSpire).toBe(true);
- const flat = placeMonument(goal({ id: 'apex', status: 'active', progressHistory: [{ date: daysAgo(1) }] }), 0, 1, NOW);
- expect(cluster.spire.height).toBeCloseTo(flat.height * FOREST.spireBoost);
- expect(cluster.children).toHaveLength(2);
- // Children carry a parentId back-reference and one link each to the spire apex.
- expect(cluster.children.every((c) => c.parentId === 'apex')).toBe(true);
- expect(cluster.links).toHaveLength(2);
- // Links join the tower tops, which sit a 0.4 plinth above the height baseline.
- expect(cluster.links[0].to).toEqual([cluster.spire.position[0], 0.4 + cluster.spire.height, cluster.spire.position[2]]);
- });
-
- it('centers a single root cluster on FOREST.base', () => {
- const vm = computeGoalForest(tree(), NOW);
- expect(vm.clusters[0].spire.position[0]).toBeCloseTo(FOREST.base[0]);
- expect(vm.clusters[0].spire.position[2]).toBe(FOREST.base[2]);
- });
-
- it('caps root clusters and reports the overflow (folded roots + their descendants)', () => {
- const goals = [];
- for (let i = 0; i < FOREST.maxRoots + 2; i++) {
- goals.push(goal({ id: `r${i}`, parentId: null }));
- goals.push(goal({ id: `c${i}`, parentId: `r${i}` }));
- }
- const vm = computeGoalForest(goals, NOW);
- expect(vm.clusters).toHaveLength(FOREST.maxRoots);
- // 2 overflowed roots, each with 1 child → 2 * (1 + 1) = 4 goals folded away.
- expect(vm.rootOverflow).toBe(4);
- expect(vm.rootCount).toBe(FOREST.maxRoots + 2);
- });
-
- it('flips to the forest view when only an OVERFLOWED root has children', () => {
- // First maxRoots roots are flat leaves; the next root (which overflows the cap) is the
- // only one with a child. hasHierarchy must be derived pre-cap so the forest still shows.
- // Status ties sort by id ascending, so the flat roots use ids that sort BEFORE the deep
- // one ('aflat*' < 'zdeep-root') to guarantee the child-bearing root lands past the cap.
- const goals = [];
- for (let i = 0; i < FOREST.maxRoots; i++) goals.push(goal({ id: `aflat${i}`, parentId: null }));
- goals.push(goal({ id: 'zdeep-root', parentId: null }));
- goals.push(goal({ id: 'zdeep-child', parentId: 'zdeep-root' }));
- const vm = computeGoalForest(goals, NOW);
- // The child-bearing root is beyond FOREST.maxRoots, so it is NOT in the visible clusters…
- expect(vm.clusters.every((c) => c.children.length === 0)).toBe(true);
- // …yet the district must still pick the forest layout (pre-cap hierarchy detection)…
- expect(vm.hasHierarchy).toBe(true);
- // …and the overflowed root + its child are both counted, not silently dropped.
- expect(vm.rootOverflow).toBe(2);
- });
-
- it('surfaces a descendant count on a child that has its own sub-tree (no silent drop)', () => {
- const vm = computeGoalForest([
- goal({ id: 'apex', parentId: null }),
- goal({ id: 'child', parentId: 'apex', progressHistory: [{ date: daysAgo(1) }] }),
- goal({ id: 'grand1', parentId: 'child' }),
- goal({ id: 'grand2', parentId: 'child' }),
- ], NOW);
- const child = vm.clusters[0].children.find((c) => c.id === 'child');
- expect(child.descendantCount).toBe(2); // grand1 + grand2, summarized not dropped
- });
-
- it('caps children per root and reports childOverflow', () => {
- const goals = [goal({ id: 'apex', parentId: null })];
- for (let i = 0; i < FOREST.maxChildren + 3; i++) goals.push(goal({ id: `c${i}`, parentId: 'apex' }));
- const vm = computeGoalForest(goals, NOW);
- expect(vm.clusters[0].children).toHaveLength(FOREST.maxChildren);
- expect(vm.clusters[0].childOverflow).toBe(3);
- });
-
- it('handles missing / non-array input as an empty forest', () => {
- for (const bad of [null, undefined, 'nope', 42, {}]) {
- const vm = computeGoalForest(bad, NOW);
- expect(vm.clusters).toEqual([]);
- expect(vm.hasData).toBe(false);
- expect(vm.hasHierarchy).toBe(false);
- }
- });
-});
-// @vitest-environment node
diff --git a/client/src/utils/openWorldHealthTower.js b/client/src/utils/openWorldHealthTower.js
deleted file mode 100644
index 481b5e3d1a..0000000000
--- a/client/src/utils/openWorldHealthTower.js
+++ /dev/null
@@ -1,98 +0,0 @@
-// Pure, deterministic helpers for OpenWorld's biometric vitals tower (roadmap 2.9): a
-// stacked landmark in a far-southeast wellness district whose segments visualize the
-// latest Apple Health metrics (heart rate / steps / sleep / active calories). Each
-// segment's height and glow track that metric's normalized level; a metric with no data
-// reads dim (absent) — distinct from a metric whose value is legitimately zero (e.g. a
-// 0-step day). No three.js / React imports so the topology is unit-testable (mirrors
-// openWorldBackupVault.js / openWorldTaskQueue.js).
-
-import { PARCELS } from './openWorldPlan';
-
-export const TOWER = {
- position: PARCELS.health.anchor, // far southeast — a wellness district anchored by the master plan (openWorldPlan.js)
- baseRadius: 3.2,
- segmentHeight: 3, // height of a fully-lit (level === 1) segment
- segmentGap: 0.25, // vertical gap between stacked segments
- minHeight: 0.35, // floor height so an absent/zero segment still reads as a thin disc, not nothing
-};
-
-// One descriptor per visualized metric, in stacking order (bottom → top). `key` is the
-// Apple Health metric name as returned by GET /api/health/metrics/latest; `target` is the
-// value mapped to a full (level === 1) segment; `color` reuses the meatspace health palette
-// (which itself maps to PortOS tokens) so the tower speaks the same visual language.
-export const METRICS = [
- { key: 'heart_rate', label: 'HEART', unit: 'bpm', target: 120, color: '#ef4444' }, // port-error red — the pulsing segment
- { key: 'step_count', label: 'STEPS', unit: 'steps', target: 10000, color: '#3b82f6' }, // port-accent blue
- { key: 'active_energy', label: 'CALORIES', unit: 'Cal', target: 600, color: '#f59e0b' }, // port-warning amber
- { key: 'sleep_analysis', label: 'SLEEP', unit: 'hrs', target: 8, color: '#8b5cf6' }, // violet
-];
-
-const DIM_COLOR = '#475569'; // slate — an absent (no-data) segment
-
-const clamp01 = (n) => Math.max(0, Math.min(1, n));
-
-// Normalize a raw metric value to a 0..1 level against its target, clamped. Returns null
-// for a non-finite input so callers can distinguish "no usable number" from a real 0.
-export function normalizeLevel(value, target) {
- if (typeof value !== 'number' || !Number.isFinite(value)) return null;
- if (typeof target !== 'number' || !Number.isFinite(target) || target <= 0) return null;
- return clamp01(value / target);
-}
-
-// Derive a single segment's view-model from the latest-metrics payload entry. The endpoint
-// returns `{ date, value } | null` per metric — null (or a missing key) is the "absent"
-// sentinel and must NOT collapse into the same state as a present value of 0.
-export function computeSegment(descriptor, entry) {
- // Absent: key missing, null entry, or a non-numeric/absent value field.
- const rawValue = entry && typeof entry === 'object' ? entry.value : undefined;
- const hasValue = typeof rawValue === 'number' && Number.isFinite(rawValue);
- // normalizeLevel returns null when the value/target can't be normalized; an absent or
- // unnormalizable segment renders at level 0 (its `present` flag disambiguates from a real 0).
- const level = (hasValue ? normalizeLevel(rawValue, descriptor.target) : null) ?? 0;
- return {
- key: descriptor.key,
- label: descriptor.label,
- unit: descriptor.unit,
- present: hasValue,
- value: hasValue ? rawValue : null,
- date: entry && typeof entry === 'object' ? entry.date ?? null : null,
- level, // 0..1; 0 for both an absent segment and a legitimate zero — `present` disambiguates
- color: hasValue ? descriptor.color : DIM_COLOR,
- // Lit segment height scales with level (with a thin floor so it's always visible); an
- // absent segment collapses to the floor height and reads dim.
- height: TOWER.minHeight + (hasValue ? level : 0) * TOWER.segmentHeight,
- // Emissive intensity: present segments glow proportional to level (with a small base so
- // even a zero-value-but-present segment is faintly lit); absent segments stay dark.
- intensity: hasValue ? 0.25 + level * 0.75 : 0.08,
- };
-}
-
-// Full derived view-model for the component: a fixed base position plus a bottom→top stack
-// of segments with their y offsets pre-computed. `latest` is the raw latest-metrics payload
-// (`{ [metricKey]: { date, value } | null }`); a missing/non-object payload yields an
-// all-absent tower rather than a crash.
-export function computeHealthTower(latest) {
- const payload = latest && typeof latest === 'object' ? latest : {};
- let y = 0;
- const segments = METRICS.map((descriptor) => {
- const segment = computeSegment(descriptor, payload[descriptor.key]);
- const placed = { ...segment, y: y + segment.height / 2 };
- y += segment.height + TOWER.segmentGap;
- return placed;
- });
- const presentCount = segments.filter((s) => s.present).length;
- // The heart-rate segment drives the heartbeat pulse; pre-extract its level/intensity/
- // presence so the component's per-frame loop never re-searches the segment list.
- const heart = segments.find((s) => s.key === 'heart_rate');
- return {
- position: TOWER.position,
- baseRadius: TOWER.baseRadius,
- segments,
- totalHeight: y - (segments.length ? TOWER.segmentGap : 0), // top of the highest segment
- presentCount,
- hasData: presentCount > 0,
- heartPresent: heart?.present ?? false,
- heartLevel: heart?.level ?? 0,
- heartIntensity: heart?.intensity ?? 0,
- };
-}
diff --git a/client/src/utils/openWorldHealthTower.test.js b/client/src/utils/openWorldHealthTower.test.js
deleted file mode 100644
index 2a10f11db4..0000000000
--- a/client/src/utils/openWorldHealthTower.test.js
+++ /dev/null
@@ -1,158 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import {
- TOWER,
- METRICS,
- normalizeLevel,
- computeSegment,
- computeHealthTower,
-} from './openWorldHealthTower';
-
-const full = {
- heart_rate: { date: '2026-06-01', value: 60 },
- step_count: { date: '2026-06-01', value: 5000 },
- active_energy: { date: '2026-06-01', value: 300 },
- sleep_analysis: { date: '2026-06-01', value: 4 },
-};
-
-describe('normalizeLevel', () => {
- it('maps value/target into 0..1', () => {
- expect(normalizeLevel(5000, 10000)).toBe(0.5);
- expect(normalizeLevel(120, 120)).toBe(1);
- });
-
- it('clamps above target to 1 and below zero to 0', () => {
- expect(normalizeLevel(99999, 10000)).toBe(1);
- expect(normalizeLevel(-50, 10000)).toBe(0);
- });
-
- it('returns null for non-finite values or bad targets', () => {
- expect(normalizeLevel(NaN, 10000)).toBeNull();
- expect(normalizeLevel(undefined, 10000)).toBeNull();
- expect(normalizeLevel('5000', 10000)).toBeNull();
- expect(normalizeLevel(5000, 0)).toBeNull();
- expect(normalizeLevel(5000, -1)).toBeNull();
- });
-
- it('treats a legitimate zero value as level 0, not absent', () => {
- expect(normalizeLevel(0, 10000)).toBe(0);
- });
-});
-
-describe('computeSegment', () => {
- const heart = METRICS.find((m) => m.key === 'heart_rate');
-
- it('derives a present segment with proportional level and full color', () => {
- const seg = computeSegment(heart, { date: '2026-06-01', value: 60 });
- expect(seg.present).toBe(true);
- expect(seg.value).toBe(60);
- expect(seg.level).toBe(0.5);
- expect(seg.color).toBe(heart.color);
- expect(seg.height).toBeGreaterThan(TOWER.minHeight);
- });
-
- it('distinguishes a zero-value-but-present metric from an absent one', () => {
- const zero = computeSegment(heart, { date: '2026-06-01', value: 0 });
- expect(zero.present).toBe(true);
- expect(zero.value).toBe(0);
- expect(zero.level).toBe(0);
- expect(zero.color).toBe(heart.color); // still lit color, not the dim slate
- expect(zero.height).toBeCloseTo(TOWER.minHeight);
- expect(zero.intensity).toBeGreaterThan(0.08); // faintly lit, above the absent floor
-
- const absent = computeSegment(heart, null);
- expect(absent.present).toBe(false);
- expect(absent.value).toBeNull();
- expect(absent.level).toBe(0);
- expect(absent.color).not.toBe(heart.color); // dim slate
- expect(absent.height).toBeCloseTo(TOWER.minHeight);
- expect(absent.intensity).toBeLessThan(zero.intensity);
- });
-
- it('treats a missing/undefined entry as absent without crashing', () => {
- expect(computeSegment(heart, undefined).present).toBe(false);
- expect(computeSegment(heart, {}).present).toBe(false);
- expect(computeSegment(heart, { date: '2026-06-01', value: null }).present).toBe(false);
- expect(computeSegment(heart, { value: 'oops' }).present).toBe(false);
- });
-
- it('clamps a level above target to 1', () => {
- const seg = computeSegment(heart, { value: 999 });
- expect(seg.level).toBe(1);
- expect(seg.height).toBeCloseTo(TOWER.minHeight + TOWER.segmentHeight);
- });
-});
-
-describe('computeHealthTower', () => {
- it('carries the fixed position and base radius through unchanged', () => {
- const vm = computeHealthTower(full);
- expect(vm.position).toEqual(TOWER.position);
- expect(vm.baseRadius).toBe(TOWER.baseRadius);
- });
-
- it('produces one segment per metric in stacking order', () => {
- const vm = computeHealthTower(full);
- expect(vm.segments).toHaveLength(METRICS.length);
- expect(vm.segments.map((s) => s.key)).toEqual(METRICS.map((m) => m.key));
- });
-
- it('stacks segments upward with strictly increasing y', () => {
- const vm = computeHealthTower(full);
- for (let i = 1; i < vm.segments.length; i++) {
- expect(vm.segments[i].y).toBeGreaterThan(vm.segments[i - 1].y);
- }
- });
-
- it('reports presentCount and hasData from full metrics', () => {
- const vm = computeHealthTower(full);
- expect(vm.presentCount).toBe(METRICS.length);
- expect(vm.hasData).toBe(true);
- expect(vm.heartLevel).toBeCloseTo(0.5);
- });
-
- it('handles partial metrics — absent segments dim, present ones lit', () => {
- const vm = computeHealthTower({ step_count: { value: 10000 } });
- expect(vm.presentCount).toBe(1);
- expect(vm.hasData).toBe(true);
- const steps = vm.segments.find((s) => s.key === 'step_count');
- const heart = vm.segments.find((s) => s.key === 'heart_rate');
- expect(steps.present).toBe(true);
- expect(steps.level).toBe(1);
- expect(heart.present).toBe(false);
- expect(heart.level).toBe(0);
- });
-
- it('handles an all-null payload as an all-absent tower (no crash)', () => {
- const vm = computeHealthTower({
- heart_rate: null,
- step_count: null,
- active_energy: null,
- sleep_analysis: null,
- });
- expect(vm.presentCount).toBe(0);
- expect(vm.hasData).toBe(false);
- expect(vm.segments.every((s) => !s.present)).toBe(true);
- });
-
- it('handles null / undefined / non-object input as all-absent', () => {
- for (const bad of [null, undefined, 'nope', 42, []]) {
- const vm = computeHealthTower(bad);
- expect(vm.presentCount).toBe(0);
- expect(vm.hasData).toBe(false);
- expect(vm.segments).toHaveLength(METRICS.length);
- }
- });
-
- it('keeps all segment levels within [0,1]', () => {
- const vm = computeHealthTower({
- heart_rate: { value: -10 },
- step_count: { value: 1e9 },
- active_energy: { value: 0 },
- sleep_analysis: { value: 8 },
- });
- for (const s of vm.segments) {
- expect(s.level).toBeGreaterThanOrEqual(0);
- expect(s.level).toBeLessThanOrEqual(1);
- }
- });
-});
-// @vitest-environment node
diff --git a/client/src/utils/openWorldInteriorWindows.js b/client/src/utils/openWorldInteriorWindows.js
deleted file mode 100644
index 92aa06b94c..0000000000
--- a/client/src/utils/openWorldInteriorWindows.js
+++ /dev/null
@@ -1,111 +0,0 @@
-// Procedural window-grid layout for OpenWorld buildings that use the
-// InteriorMappingMaterial interior-mapping material (parallax fake-3D rooms
-// behind flat window panes). The geometry/material live in
-// `client/src/components/openworld/BuildingWindows.jsx`; this module is the pure,
-// side-effect-free math it consumes so the placement is unit-testable without a
-// WebGL context (headless GL can't render the city — see the city visual
-// verification notes).
-//
-// A building's four vertical faces are each tiled with a centered grid of square
-// panes. Each pane becomes one InstancedMesh instance carrying a deterministic
-// `windowId` (vec3) the shader hashes to pick an interior room cell and to decide
-// lit/unlit + warm/cool, so a building keeps the exact same lit rooms forever
-// (same determinism contract as the flat window texture and rooftop kits).
-
-// Pane + spacing geometry, in building-local world units. Tuned so a default
-// 2.0-wide face reads as ~4 columns of distinct windows and a 5-tall online
-// tower as ~7 stacked floors.
-export const INTERIOR_WINDOW = {
- size: 0.26, // square pane edge
- gapX: 0.14, // horizontal gap between panes
- gapY: 0.22, // vertical gap (taller → reads as stacked floors)
- inset: 0.015, // how far the pane sits proud of the wall (avoids z-fighting)
- edgePad: 0.2, // horizontal margin kept clear at each face edge
- marginBottom: 0.5, // skip the ground-floor / base-glow band
- marginTop: 0.5, // skip the roof cap + crown floor band
- // Below this building height there isn't enough clear facade for a readable
- // grid, so the building keeps just its flat emissive window texture.
- minHeight: 3,
-};
-
-// Which buildings get interior-mapped windows. Kept deliberately narrow ("some
-// of the buildings"): online, non-archived towers tall enough for a real grid.
-// Stopped/idle/short buildings stay on the cheaper flat texture. Height is read
-// from the already-resolved value (getBuildingHeight) rather than recomputed.
-export function buildingHasInteriorWindows(app, height) {
- if (!app || app.archived) return false;
- if (!(height >= INTERIOR_WINDOW.minHeight)) return false;
- return app.overallStatus === 'online';
-}
-
-// Count panes that fit along a span, centered, given pane size + gap. Returns 0
-// when not even one pane fits in the usable span.
-function fitCount(span, size, gap) {
- if (!(span > 0)) return 0;
- return Math.max(0, Math.floor((span + gap) / (size + gap)));
-}
-
-// The four vertical faces, as (faceIndex, rotationY about the building's up axis,
-// outward normal axis + sign). PlaneGeometry faces +Z at rotation 0.
-const FACES = [
- { index: 0, axis: 'z', sign: 1, rotationY: 0 }, // front (+Z)
- { index: 1, axis: 'z', sign: -1, rotationY: Math.PI }, // back (-Z)
- { index: 2, axis: 'x', sign: 1, rotationY: Math.PI / 2 }, // right (+X)
- { index: 3, axis: 'x', sign: -1, rotationY: -Math.PI / 2 }, // left (-X)
-];
-
-// Compute every window instance for a building. Returns a flat array of
-// `{ position: [x,y,z], rotationY, windowId: [a,b,c] }`. `planeSize` (the square
-// pane edge) is uniform across all panes, so the material's `planeSize` uniform
-// is just `INTERIOR_WINDOW.size`.
-export function computeWindowGrid({ width, depth, height, seed = 0 }) {
- const { size, gapX, gapY, inset, edgePad, marginBottom, marginTop } = INTERIOR_WINDOW;
- const usableH = height - marginBottom - marginTop;
- const rows = fitCount(usableH, size, gapY);
- if (rows <= 0) return [];
-
- const gridH = rows * size + (rows - 1) * gapY;
- const startY = marginBottom + (usableH - gridH) / 2 + size / 2;
- const stepY = size + gapY;
-
- const windows = [];
- for (const face of FACES) {
- // The horizontal extent of this face: building width for front/back, depth
- // for the sides. The pane plane is always normal to the face's axis.
- const faceWidth = face.axis === 'z' ? width : depth;
- const usableW = faceWidth - 2 * edgePad;
- const cols = fitCount(usableW, size, gapX);
- if (cols <= 0) continue;
-
- const gridW = cols * size + (cols - 1) * gapX;
- const startH = -gridW / 2 + size / 2;
- const stepX = size + gapX;
- // Distance from center out to the (slightly proud) face plane.
- const offset = (face.axis === 'z' ? depth : width) / 2 + inset;
-
- for (let r = 0; r < rows; r++) {
- const y = startY + r * stepY;
- for (let c = 0; c < cols; c++) {
- const h = startH + c * stepX; // position along the face's horizontal axis
- const position = face.axis === 'z'
- ? [h, y, face.sign * offset]
- : [face.sign * offset, y, h];
- // Deterministic, well-spread seed per pane so the shader hash varies
- // per window, per face, and per building. Keep every component SMALL: the
- // IDs round-trip through a Float32Array for the instanced attribute, and
- // float32 loses sub-integer precision past ~16.7M — a raw app-name hash
- // (hundreds of millions) would swamp the c/r offsets and collapse every
- // pane on a face to one room. A bounded per-building phase shifts the
- // hash input without dominating the per-pane column/row offsets.
- const phase = seed % 360;
- const windowId = [
- c + face.index * 2.3 + phase * 0.11,
- r + face.index * 1.7 + phase * 0.07,
- face.index + (seed % 17) * 0.13,
- ];
- windows.push({ position, rotationY: face.rotationY, windowId });
- }
- }
- }
- return windows;
-}
diff --git a/client/src/utils/openWorldInteriorWindows.test.js b/client/src/utils/openWorldInteriorWindows.test.js
deleted file mode 100644
index f3aec3ce64..0000000000
--- a/client/src/utils/openWorldInteriorWindows.test.js
+++ /dev/null
@@ -1,102 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import {
- INTERIOR_WINDOW,
- buildingHasInteriorWindows,
- computeWindowGrid,
-} from './openWorldInteriorWindows';
-
-describe('buildingHasInteriorWindows', () => {
- it('selects online, non-archived towers tall enough for a grid', () => {
- expect(buildingHasInteriorWindows({ overallStatus: 'online' }, 5)).toBe(true);
- });
-
- it('rejects archived buildings even when online and tall', () => {
- expect(buildingHasInteriorWindows({ overallStatus: 'online', archived: true }, 5)).toBe(false);
- });
-
- it('rejects non-online statuses', () => {
- for (const overallStatus of ['stopped', 'not_started', 'not_found', 'unknown']) {
- expect(buildingHasInteriorWindows({ overallStatus }, 5)).toBe(false);
- }
- });
-
- it('rejects towers below the minimum height', () => {
- expect(buildingHasInteriorWindows({ overallStatus: 'online' }, INTERIOR_WINDOW.minHeight - 0.01)).toBe(false);
- expect(buildingHasInteriorWindows({ overallStatus: 'online' }, INTERIOR_WINDOW.minHeight)).toBe(true);
- });
-
- it('rejects a missing app', () => {
- expect(buildingHasInteriorWindows(null, 5)).toBe(false);
- expect(buildingHasInteriorWindows(undefined, 5)).toBe(false);
- });
-});
-
-describe('computeWindowGrid', () => {
- // Asymmetric width !== depth so the per-face extent logic is actually
- // exercised (a width/depth swap in the placement math would change results).
- const dims = { width: 3, depth: 2, height: 5, seed: 42 };
-
- it('tiles all four vertical faces', () => {
- const windows = computeWindowGrid(dims);
- expect(windows.length).toBeGreaterThan(0);
- const rotations = new Set(windows.map((w) => w.rotationY.toFixed(4)));
- expect(rotations).toEqual(
- new Set([0, Math.PI, Math.PI / 2, -Math.PI / 2].map((r) => r.toFixed(4)))
- );
- });
-
- it('is deterministic for the same input', () => {
- expect(computeWindowGrid(dims)).toEqual(computeWindowGrid(dims));
- });
-
- it('varies window ids by seed', () => {
- const a = computeWindowGrid(dims);
- const b = computeWindowGrid({ ...dims, seed: 43 });
- expect(a[0].windowId).not.toEqual(b[0].windowId);
- });
-
- it('keeps per-pane window ids distinct after the Float32Array round-trip, even for a large hash', () => {
- // The instanced attribute stores windowId in a Float32Array, which loses
- // sub-integer precision past ~16.7M. A raw app-name hash this large must not
- // collapse every pane on a face to the same id (which would make every room
- // identical). Quantize through Float32Array exactly as the GPU upload does.
- const windows = computeWindowGrid({ width: 3, depth: 2, height: 5, seed: 1234567890 });
- const buf = new Float32Array(windows.length * 3);
- windows.forEach((w, i) => buf.set(w.windowId, i * 3));
- const keys = new Set(Array.from({ length: windows.length }, (_, i) => buf.slice(i * 3, i * 3 + 3).join(',')));
- // Distinct ids per pane (allow a tiny collision margin from the modular phase).
- expect(keys.size).toBeGreaterThan(windows.length * 0.9);
- });
-
- it('places panes proud of the correct face plane', () => {
- const { width, depth } = dims;
- const offset = depth / 2 + INTERIOR_WINDOW.inset;
- for (const w of computeWindowGrid(dims)) {
- const [x, , z] = w.position;
- if (w.rotationY === 0) expect(z).toBeCloseTo(offset, 5);
- else if (w.rotationY === Math.PI) expect(z).toBeCloseTo(-offset, 5);
- else if (w.rotationY === Math.PI / 2) expect(x).toBeCloseTo(width / 2 + INTERIOR_WINDOW.inset, 5);
- else if (w.rotationY === -Math.PI / 2) expect(x).toBeCloseTo(-(width / 2 + INTERIOR_WINDOW.inset), 5);
- }
- });
-
- it('keeps every pane within the usable vertical band and its own face width', () => {
- const { width, depth, height } = dims;
- for (const w of computeWindowGrid(dims)) {
- const [x, y, z] = w.position;
- expect(y).toBeGreaterThanOrEqual(INTERIOR_WINDOW.marginBottom);
- expect(y).toBeLessThanOrEqual(height - INTERIOR_WINDOW.marginTop);
- // Front/back faces span `width` (along x); side faces span `depth` (along
- // z). Each pane must stay inside the half-extent of its own face.
- const frontBack = w.rotationY === 0 || w.rotationY === Math.PI;
- const horiz = frontBack ? x : z;
- const halfExtent = (frontBack ? width : depth) / 2;
- expect(Math.abs(horiz)).toBeLessThanOrEqual(halfExtent);
- }
- });
-
- it('returns no windows when the tower is too short for a single row', () => {
- expect(computeWindowGrid({ width: 2, depth: 2, height: 1, seed: 1 })).toEqual([]);
- });
-});
-// @vitest-environment node
diff --git a/client/src/utils/openWorldJiraDistrict.js b/client/src/utils/openWorldJiraDistrict.js
deleted file mode 100644
index 14b50c4d0a..0000000000
--- a/client/src/utils/openWorldJiraDistrict.js
+++ /dev/null
@@ -1,114 +0,0 @@
-// Pure, deterministic helpers for OpenWorld's JIRA sprint district (roadmap 3.7): the current
-// sprint's tickets become a small construction yard southeast-north of downtown. Each ticket is
-// a structure whose form reads its workflow state — To-Do tickets are stacked crates waiting to
-// be built, In-Progress tickets are under-construction frames (scaffold), and Done tickets are
-// finished, lit buildings. Tickets are gathered across every JIRA-enabled app, deduped by key.
-// No three.js / React imports so the topology is unit-testable (mirrors openWorldTaskQueue.js etc.).
-
-import { gridIndexToPosition, tallyByKey, scaleMetricToHeight } from './openWorldDistrictLayout';
-import { PARCELS } from './openWorldPlan';
-
-export const JIRA_DISTRICT = {
- // North-of-downtown yard between the goal monuments and downtown, on the -X side so it
- // doesn't collide with the artifact hall or memory quarter. Anchored by openWorldPlan.js;
- // sits near the shoreline on purpose — a dockside construction yard.
- base: PARCELS.jira.anchor,
- columns: 6, // structures per row before wrapping toward -Z
- spacing: 3.2, // distance between adjacent structures
- maxStructures: 24, // cap; overflow folds into a "+N MORE" marker
- crateSize: 1.1,
- maxHeight: 4.5,
-};
-
-// JIRA's three status categories. We normalize the API's statusCategory name to one of these
-// buckets; an unrecognized/blank category is treated as 'todo' (safest — it shows as unbuilt).
-export const SPRINT_STATES = {
- todo: { key: 'todo', label: 'TO DO', color: '#64748b' }, // slate — not started
- inProgress: { key: 'inProgress', label: 'IN PROGRESS', color: '#f59e0b' }, // amber — building
- done: { key: 'done', label: 'DONE', color: '#22c55e' }, // green — complete
-};
-
-// Map a ticket's JIRA statusCategory to one of our three buckets. JIRA uses the category names
-// "To Do" / "In Progress" / "Done" (and the key forms new/indeterminate/done); accept both.
-export function ticketState(ticket) {
- const raw = String(ticket?.statusCategory || '').toLowerCase().trim();
- if (raw === 'done' || raw === 'complete') return 'done';
- if (raw === 'in progress' || raw === 'indeterminate') return 'inProgress';
- return 'todo'; // "to do" / "new" / unknown / blank
-}
-
-// Story-point-driven height so a chunky ticket reads as a taller structure; defaults to a 1-point
-// floor when the ticket carries no estimate so every ticket is at least a visible crate.
-export function structureHeight(storyPoints) {
- const pts = Number.isFinite(storyPoints) && storyPoints > 0 ? storyPoints : 1;
- return scaleMetricToHeight(pts, { max: JIRA_DISTRICT.maxHeight, k: 1.1, base: 0.9 });
-}
-
-// Dedupe tickets across apps by key (the same JIRA ticket can surface under two apps that share a
-// project) and sort into a stable render order: done → in-progress → to-do, then by key, so the
-// yard reads left-to-right as "finished work piling up" with active work in the middle.
-const STATE_ORDER = { done: 0, inProgress: 1, todo: 2 };
-export function dedupeAndSort(tickets) {
- const byKey = new Map();
- for (const t of Array.isArray(tickets) ? tickets : []) {
- if (!t?.key || byKey.has(t.key)) continue;
- byKey.set(t.key, t);
- }
- return [...byKey.values()].sort((a, b) => {
- const sa = STATE_ORDER[ticketState(a)] ?? 3;
- const sb = STATE_ORDER[ticketState(b)] ?? 3;
- return sa - sb || String(a.key).localeCompare(String(b.key));
- });
-}
-
-// Grid position for the i-th structure in the yard: rows wrap toward -Z, centered on the base X.
-export function structurePosition(index, opts = {}) {
- return gridIndexToPosition(index, {
- base: opts.base || JIRA_DISTRICT.base,
- columns: opts.columns ?? JIRA_DISTRICT.columns,
- spacing: opts.spacing ?? JIRA_DISTRICT.spacing,
- rowDir: -1, // rows pile toward -Z so the yard reads receding from the viewer
- });
-}
-
-// Tally tickets by bucket — drives the district label ("3/8 DONE") and per-state counts.
-export function tallyStates(tickets) {
- return tallyByKey(tickets, ticketState, ['todo', 'inProgress', 'done']);
-}
-
-// Full derived view-model for the component: positioned structures (capped, overflow summarized)
-// + per-state tallies. `tickets` is the merged sprint-ticket list across all JIRA-enabled apps.
-// Pure + deterministic — same tickets in, same yard out — so the whole thing is headless-testable.
-export function computeJiraDistrict(tickets, opts = {}) {
- const sorted = dedupeAndSort(tickets);
- const counts = tallyStates(sorted);
- const total = sorted.length;
- const maxStructures = opts.maxStructures ?? JIRA_DISTRICT.maxStructures;
-
- const shown = sorted.slice(0, maxStructures);
- const structures = shown.map((t, i) => {
- const state = ticketState(t);
- return {
- key: t.key,
- summary: t.summary || t.key,
- state,
- color: SPRINT_STATES[state].color,
- height: structureHeight(t.storyPoints),
- position: structurePosition(i, opts),
- url: t.url || null,
- };
- });
-
- const overflow = total > maxStructures ? total - maxStructures : 0;
-
- return {
- base: opts.base || JIRA_DISTRICT.base,
- structures,
- counts,
- total,
- overflow,
- // Overflow marker sits just past the last rendered structure.
- overflowPosition: overflow > 0 ? structurePosition(maxStructures, opts) : null,
- empty: total === 0,
- };
-}
diff --git a/client/src/utils/openWorldJiraDistrict.test.js b/client/src/utils/openWorldJiraDistrict.test.js
deleted file mode 100644
index 3ef3221310..0000000000
--- a/client/src/utils/openWorldJiraDistrict.test.js
+++ /dev/null
@@ -1,133 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import {
- JIRA_DISTRICT,
- SPRINT_STATES,
- ticketState,
- structureHeight,
- dedupeAndSort,
- structurePosition,
- tallyStates,
- computeJiraDistrict,
-} from './openWorldJiraDistrict';
-
-describe('ticketState', () => {
- it('maps JIRA category names', () => {
- expect(ticketState({ statusCategory: 'Done' })).toBe('done');
- expect(ticketState({ statusCategory: 'In Progress' })).toBe('inProgress');
- expect(ticketState({ statusCategory: 'To Do' })).toBe('todo');
- });
- it('maps JIRA category key forms', () => {
- expect(ticketState({ statusCategory: 'indeterminate' })).toBe('inProgress');
- expect(ticketState({ statusCategory: 'new' })).toBe('todo');
- });
- it('defaults unknown/blank to todo', () => {
- expect(ticketState({ statusCategory: '' })).toBe('todo');
- expect(ticketState({})).toBe('todo');
- expect(ticketState(null)).toBe('todo');
- });
-});
-
-describe('structureHeight', () => {
- it('floors a point-less ticket to a visible crate', () => {
- expect(structureHeight(undefined)).toBeGreaterThan(0.5);
- expect(structureHeight(0)).toBe(structureHeight(undefined));
- });
- it('grows with story points and clamps', () => {
- expect(structureHeight(8)).toBeGreaterThan(structureHeight(2));
- expect(structureHeight(1000)).toBeLessThanOrEqual(JIRA_DISTRICT.maxHeight);
- });
-});
-
-describe('dedupeAndSort', () => {
- it('dedupes by key (first wins)', () => {
- const out = dedupeAndSort([
- { key: 'A-1', statusCategory: 'To Do' },
- { key: 'A-1', statusCategory: 'Done' },
- ]);
- expect(out).toHaveLength(1);
- expect(ticketState(out[0])).toBe('todo'); // first occurrence kept
- });
- it('orders done → in-progress → to-do, then by key', () => {
- const out = dedupeAndSort([
- { key: 'A-3', statusCategory: 'To Do' },
- { key: 'A-2', statusCategory: 'Done' },
- { key: 'A-1', statusCategory: 'In Progress' },
- ]);
- expect(out.map(t => t.key)).toEqual(['A-2', 'A-1', 'A-3']);
- });
- it('handles non-array input', () => {
- expect(dedupeAndSort(undefined)).toEqual([]);
- });
-});
-
-describe('structurePosition', () => {
- it('wraps rows toward -Z at the column count', () => {
- const p0 = structurePosition(0);
- const pWrap = structurePosition(JIRA_DISTRICT.columns);
- expect(pWrap[2]).toBeLessThan(p0[2]); // next row is further -Z
- expect(pWrap[0]).toBeCloseTo(p0[0], 5); // back to the first column's X
- });
- it('centers the row on base X', () => {
- const cols = JIRA_DISTRICT.columns;
- const xs = Array.from({ length: cols }, (_, i) => structurePosition(i)[0]);
- const mean = xs.reduce((a, b) => a + b, 0) / xs.length;
- expect(mean).toBeCloseTo(JIRA_DISTRICT.base[0], 5);
- });
-});
-
-describe('tallyStates', () => {
- it('counts each bucket', () => {
- const counts = tallyStates([
- { statusCategory: 'Done' },
- { statusCategory: 'Done' },
- { statusCategory: 'In Progress' },
- { statusCategory: 'To Do' },
- ]);
- expect(counts).toEqual({ todo: 1, inProgress: 1, done: 2 });
- });
-});
-
-describe('SPRINT_STATES', () => {
- it('has a color + label for each bucket', () => {
- for (const k of ['todo', 'inProgress', 'done']) {
- expect(SPRINT_STATES[k].color).toMatch(/^#/);
- expect(typeof SPRINT_STATES[k].label).toBe('string');
- }
- });
-});
-
-describe('computeJiraDistrict', () => {
- it('is empty with no tickets', () => {
- const d = computeJiraDistrict([]);
- expect(d.empty).toBe(true);
- expect(d.structures).toEqual([]);
- expect(d.total).toBe(0);
- });
- it('handles undefined input', () => {
- expect(computeJiraDistrict(undefined).empty).toBe(true);
- });
- it('builds positioned, colored structures with counts', () => {
- const d = computeJiraDistrict([
- { key: 'P-1', summary: 'Build login', statusCategory: 'In Progress', storyPoints: 3, url: 'http://x/P-1' },
- { key: 'P-2', summary: 'Ship it', statusCategory: 'Done' },
- ]);
- expect(d.total).toBe(2);
- expect(d.counts.done).toBe(1);
- expect(d.structures[0].state).toBe('done'); // done sorts first
- expect(d.structures[0].position).toHaveLength(3);
- expect(d.structures.find(s => s.key === 'P-1').color).toBe(SPRINT_STATES.inProgress.color);
- });
- it('folds the tail into an overflow count', () => {
- const tickets = [];
- for (let i = 0; i < 30; i++) tickets.push({ key: `P-${i}`, statusCategory: 'To Do' });
- const d = computeJiraDistrict(tickets, { maxStructures: 10 });
- expect(d.structures).toHaveLength(10);
- expect(d.overflow).toBe(20);
- expect(d.overflowPosition).toHaveLength(3);
- });
- it('falls back summary to key', () => {
- const d = computeJiraDistrict([{ key: 'P-9', statusCategory: 'To Do' }]);
- expect(d.structures[0].summary).toBe('P-9');
- });
-});
-// @vitest-environment node
diff --git a/client/src/utils/openWorldMemoryDistrict.js b/client/src/utils/openWorldMemoryDistrict.js
deleted file mode 100644
index 1252ce4b1e..0000000000
--- a/client/src/utils/openWorldMemoryDistrict.js
+++ /dev/null
@@ -1,172 +0,0 @@
-// Pure, deterministic helpers for OpenWorld's memory / knowledge district (roadmap 3.2):
-// a quiet quarter in the northwest where the user's long-term memory graph crystallizes
-// into the city. Each memory *category* becomes a cluster of glowing crystals — taller and
-// brighter the more (and more important) the memories it holds — and edges that cross
-// category boundaries arc between clusters as light bridges, so the shape of how knowledge
-// connects is legible at a glance. No three.js / React imports so the topology is
-// unit-testable (mirrors openWorldFederation.js / openWorldBackupVault.js).
-
-import { hashString } from './hashString';
-import { groupByFieldValue, scaleMetricToHeight } from './openWorldDistrictLayout';
-import { PARCELS } from './openWorldPlan';
-
-export const MEMORY_DISTRICT = {
- // Northwest quadrant — mirrors the artifact cluster at NE, clear of the productivity
- // district, the backup vault, and downtown. Anchored by the master plan (openWorldPlan.js).
- base: PARCELS.memory.anchor,
- radius: 9, // clusters arrange on a ring of this radius around the district center
- maxCrystalsPerCluster: 7, // visual cap; overflow is summarized in the cluster label
- crystalSpacing: 1.4, // horizontal spread of crystals within a cluster
- minCrystalHeight: 1.2,
- maxCrystalHeight: 4.5,
- bridgeY: 2.2, // height the light bridges arc at
- maxClusters: 8, // most-populous categories rendered; the rest fold into "OTHER"
-};
-
-// Per-category crystal color. Reuses the PortOS neon palette feel; unknown/uncategorized
-// memories fall back to slate so an unclassified cluster still reads as present-but-quiet.
-const CATEGORY_COLORS = {
- personal: '#ec4899', // pink
- work: '#3b82f6', // port-accent blue
- technical: '#06b6d4', // cyan
- health: '#22c55e', // port-success green
- finance: '#f59e0b', // port-warning amber
- relationships: '#f43f5e', // rose
- preferences: '#a855f7', // violet
- ideas: '#8b5cf6', // purple
- other: '#64748b', // slate — fallback
-};
-
-// Deterministic color for any category string: known categories map to their token, unknown
-// ones hash into the neon palette so two distinct unknown categories still look distinct.
-const PALETTE = ['#ec4899', '#3b82f6', '#06b6d4', '#22c55e', '#f59e0b', '#f43f5e', '#a855f7', '#8b5cf6'];
-export function categoryColor(category) {
- const key = String(category || 'other').toLowerCase();
- if (CATEGORY_COLORS[key]) return CATEGORY_COLORS[key];
- return PALETTE[hashString(key) % PALETTE.length];
-}
-
-// Normalize a raw graph node's category to a stable, lowercase bucket key. Missing/blank →
-// 'other' so uncategorized memories collect into one cluster instead of vanishing.
-export function categoryKey(node) {
- const raw = node?.category;
- if (typeof raw !== 'string' || raw.trim() === '') return 'other';
- return raw.trim().toLowerCase();
-}
-
-// Group graph nodes into per-category buckets, each carrying a node count and a summed
-// importance (importance defaults to 1 when absent so every memory contributes some mass).
-// Returns buckets sorted by count desc, then category asc for a stable order.
-export function groupByCategory(nodes) {
- const nodeImportance = (node) => (Number.isFinite(node?.importance) ? node.importance : 1);
- return groupByFieldValue(nodes, categoryKey, { weightFn: nodeImportance }).map(
- ({ key, count, weight }) => ({ category: key, count, importance: weight }),
- );
-}
-
-// Place a cluster on the district ring. Angle is seeded by the category name (not the index)
-// so a category keeps its spot as the graph grows, and index is a stable tiebreaker fan-out
-// so two categories hashing near the same angle still separate.
-export function placeCluster(category, index, total, opts = {}) {
- const base = opts.base || MEMORY_DISTRICT.base;
- const radius = opts.radius ?? MEMORY_DISTRICT.radius;
- const hashAngle = (hashString(category) % 360) * (Math.PI / 180);
- const fan = total > 0 ? (index / total) * Math.PI * 2 : 0;
- // Blend the hash angle with an even fan so clusters neither overlap nor drift on regrouping.
- const angle = hashAngle * 0.5 + fan * 0.5;
- return [
- base[0] + Math.cos(angle) * radius,
- base[1],
- base[2] + Math.sin(angle) * radius,
- ];
-}
-
-// Crystal height scales with the cluster's total importance, clamped to the configured band so
-// a huge category doesn't dwarf the skyline and a tiny one is still visible.
-export function clusterHeight(importance) {
- const { minCrystalHeight, maxCrystalHeight } = MEMORY_DISTRICT;
- return scaleMetricToHeight(importance, {
- min: minCrystalHeight,
- max: maxCrystalHeight,
- k: 0.7,
- base: minCrystalHeight,
- });
-}
-
-// Build the bridges between category clusters from the graph's cross-category edges. Each edge
-// whose endpoints live in different categories increments that category-pair's weight; the
-// result is a deduped, sorted list of { from, to, weight, count } the renderer arcs as light.
-// `linked` edges count double vs `similar` so explicit links read as stronger connective tissue.
-export function computeBridges(nodes, edges) {
- const catOf = new Map();
- for (const node of Array.isArray(nodes) ? nodes : []) {
- catOf.set(node?.id, categoryKey(node));
- }
- const pairs = new Map();
- for (const edge of Array.isArray(edges) ? edges : []) {
- const a = catOf.get(edge?.source);
- const b = catOf.get(edge?.target);
- if (!a || !b || a === b) continue; // intra-cluster edges don't bridge
- const [from, to] = a < b ? [a, b] : [b, a];
- const key = `${from}|${to}`;
- const w = edge?.type === 'linked' ? 2 : 1;
- const entry = pairs.get(key) || { from, to, weight: 0, count: 0 };
- entry.weight += w;
- entry.count += 1;
- pairs.set(key, entry);
- }
- return [...pairs.values()].sort((a, b) => b.weight - a.weight || a.from.localeCompare(b.from));
-}
-
-// Full derived view-model for the component: positioned clusters (capped at maxClusters with the
-// overflow folded into a single 'other'-style summary) plus the light bridges between them. Pure
-// and deterministic — same graph in, same scene out — so the whole thing is testable headless.
-export function computeMemoryDistrict(graph, opts = {}) {
- const nodes = Array.isArray(graph?.nodes) ? graph.nodes : [];
- const edges = Array.isArray(graph?.edges) ? graph.edges : [];
- const maxClusters = opts.maxClusters ?? MEMORY_DISTRICT.maxClusters;
-
- const grouped = groupByCategory(nodes);
-
- // Fold the long tail of small categories into one "+N more" overflow cluster so the district
- // stays readable; its mass is the sum of what it absorbs.
- let clustersData = grouped;
- if (grouped.length > maxClusters) {
- const rest = grouped.slice(maxClusters - 1);
- const overflow = {
- category: 'other',
- count: rest.reduce((s, c) => s + c.count, 0),
- importance: rest.reduce((s, c) => s + c.importance, 0),
- overflowOf: rest.length,
- };
- clustersData = [...grouped.slice(0, maxClusters - 1), overflow];
- }
-
- const total = clustersData.length;
- const clusters = clustersData.map((c, i) => ({
- category: c.category,
- label: c.overflowOf ? `+${c.overflowOf} MORE` : c.category.toUpperCase(),
- count: c.count,
- importance: c.importance,
- color: categoryColor(c.category),
- position: placeCluster(c.category, i, total, opts),
- height: clusterHeight(c.importance),
- crystals: Math.min(MEMORY_DISTRICT.maxCrystalsPerCluster, Math.max(1, c.count)),
- isOverflow: !!c.overflowOf,
- }));
-
- // Bridges reference category keys; resolve them to cluster positions, dropping any whose
- // endpoint folded into overflow (those edges are summarized away rather than mis-drawn).
- const posByCategory = new Map(clusters.map(c => [c.category, c.position]));
- const bridges = computeBridges(nodes, edges)
- .map(b => ({ ...b, fromPos: posByCategory.get(b.from), toPos: posByCategory.get(b.to) }))
- .filter(b => b.fromPos && b.toPos);
-
- return {
- base: opts.base || MEMORY_DISTRICT.base,
- clusters,
- bridges,
- totalMemories: nodes.length,
- empty: nodes.length === 0,
- };
-}
diff --git a/client/src/utils/openWorldMemoryDistrict.test.js b/client/src/utils/openWorldMemoryDistrict.test.js
deleted file mode 100644
index c88715f76c..0000000000
--- a/client/src/utils/openWorldMemoryDistrict.test.js
+++ /dev/null
@@ -1,172 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import {
- MEMORY_DISTRICT,
- categoryColor,
- categoryKey,
- groupByCategory,
- placeCluster,
- clusterHeight,
- computeBridges,
- computeMemoryDistrict,
-} from './openWorldMemoryDistrict';
-
-describe('categoryKey', () => {
- it('lowercases and trims a category', () => {
- expect(categoryKey({ category: ' Work ' })).toBe('work');
- });
- it('falls back to "other" for missing/blank categories', () => {
- expect(categoryKey({})).toBe('other');
- expect(categoryKey({ category: ' ' })).toBe('other');
- expect(categoryKey(null)).toBe('other');
- });
-});
-
-describe('categoryColor', () => {
- it('maps known categories to their token color', () => {
- expect(categoryColor('work')).toBe('#3b82f6');
- expect(categoryColor('Health')).toBe('#22c55e'); // case-insensitive
- });
- it('is deterministic for unknown categories', () => {
- expect(categoryColor('quokkas')).toBe(categoryColor('quokkas'));
- });
-});
-
-describe('groupByCategory', () => {
- it('counts nodes and sums importance per category', () => {
- const nodes = [
- { id: 'a', category: 'work', importance: 3 },
- { id: 'b', category: 'work', importance: 2 },
- { id: 'c', category: 'health', importance: 5 },
- ];
- const grouped = groupByCategory(nodes);
- expect(grouped[0]).toMatchObject({ category: 'work', count: 2, importance: 5 });
- expect(grouped[1]).toMatchObject({ category: 'health', count: 1, importance: 5 });
- });
- it('defaults importance to 1 when absent', () => {
- const grouped = groupByCategory([{ id: 'a', category: 'x' }, { id: 'b', category: 'x' }]);
- expect(grouped[0].importance).toBe(2);
- });
- it('sorts by count desc then category asc', () => {
- const nodes = [
- { id: '1', category: 'zeta' },
- { id: '2', category: 'alpha' },
- { id: '3', category: 'alpha' },
- { id: '4', category: 'beta' },
- ];
- expect(groupByCategory(nodes).map(g => g.category)).toEqual(['alpha', 'beta', 'zeta']);
- });
- it('handles non-array input', () => {
- expect(groupByCategory(undefined)).toEqual([]);
- });
-});
-
-describe('placeCluster', () => {
- it('is deterministic for the same category regardless of fan index span', () => {
- const a = placeCluster('work', 1, 4);
- const b = placeCluster('work', 1, 4);
- expect(a).toEqual(b);
- });
- it('places the cluster near the district ring radius from the base', () => {
- const base = MEMORY_DISTRICT.base;
- const [x, , z] = placeCluster('work', 0, 3);
- const r = Math.hypot(x - base[0], z - base[2]);
- expect(r).toBeCloseTo(MEMORY_DISTRICT.radius, 5);
- });
- it('gives different categories different positions', () => {
- expect(placeCluster('aaa', 0, 3)).not.toEqual(placeCluster('zzz', 1, 3));
- });
-});
-
-describe('clusterHeight', () => {
- it('clamps to the configured band', () => {
- expect(clusterHeight(0)).toBeGreaterThanOrEqual(MEMORY_DISTRICT.minCrystalHeight);
- expect(clusterHeight(1e9)).toBeLessThanOrEqual(MEMORY_DISTRICT.maxCrystalHeight);
- });
- it('grows monotonically with importance', () => {
- expect(clusterHeight(10)).toBeGreaterThan(clusterHeight(2));
- });
-});
-
-describe('computeBridges', () => {
- const nodes = [
- { id: 'a', category: 'work' },
- { id: 'b', category: 'health' },
- { id: 'c', category: 'work' },
- ];
- it('only bridges cross-category edges', () => {
- const edges = [
- { source: 'a', target: 'c', type: 'similar' }, // intra-category → skipped
- { source: 'a', target: 'b', type: 'similar' }, // cross → bridge
- ];
- const bridges = computeBridges(nodes, edges);
- expect(bridges).toHaveLength(1);
- expect(bridges[0]).toMatchObject({ from: 'health', to: 'work', count: 1 });
- });
- it('weights linked edges double vs similar', () => {
- const linked = computeBridges(nodes, [{ source: 'a', target: 'b', type: 'linked' }]);
- const similar = computeBridges(nodes, [{ source: 'a', target: 'b', type: 'similar' }]);
- expect(linked[0].weight).toBe(2 * similar[0].weight);
- });
- it('aggregates multiple edges between the same category pair', () => {
- const edges = [
- { source: 'a', target: 'b', type: 'similar' },
- { source: 'c', target: 'b', type: 'similar' },
- ];
- const bridges = computeBridges(nodes, edges);
- expect(bridges).toHaveLength(1);
- expect(bridges[0].count).toBe(2);
- });
- it('handles missing edges/nodes gracefully', () => {
- expect(computeBridges(undefined, undefined)).toEqual([]);
- expect(computeBridges(nodes, [{ source: 'a', target: 'missing' }])).toEqual([]);
- });
-});
-
-describe('computeMemoryDistrict', () => {
- it('marks empty when there are no nodes', () => {
- const d = computeMemoryDistrict({ nodes: [], edges: [] });
- expect(d.empty).toBe(true);
- expect(d.clusters).toEqual([]);
- expect(d.totalMemories).toBe(0);
- });
- it('handles undefined graph', () => {
- expect(computeMemoryDistrict(undefined).empty).toBe(true);
- });
- it('builds one cluster per category with positions and labels', () => {
- const graph = {
- nodes: [
- { id: 'a', category: 'work', importance: 3 },
- { id: 'b', category: 'health', importance: 1 },
- ],
- edges: [{ source: 'a', target: 'b', type: 'linked' }],
- };
- const d = computeMemoryDistrict(graph);
- expect(d.clusters).toHaveLength(2);
- expect(d.clusters.map(c => c.label)).toContain('WORK');
- expect(d.totalMemories).toBe(2);
- expect(d.bridges).toHaveLength(1);
- expect(d.bridges[0].fromPos).toBeDefined();
- expect(d.bridges[0].toPos).toBeDefined();
- });
- it('folds the long tail into a single overflow cluster', () => {
- const nodes = [];
- for (let i = 0; i < 12; i++) nodes.push({ id: `n${i}`, category: `cat${i}` });
- const d = computeMemoryDistrict({ nodes, edges: [] }, { maxClusters: 5 });
- expect(d.clusters).toHaveLength(5);
- const overflow = d.clusters.find(c => c.isOverflow);
- expect(overflow).toBeDefined();
- expect(overflow.label).toMatch(/MORE/);
- });
- it('drops bridges whose endpoint folded into overflow', () => {
- const nodes = [];
- for (let i = 0; i < 12; i++) nodes.push({ id: `n${i}`, category: `cat${i}` });
- // edge between two rare categories that both fold away
- const d = computeMemoryDistrict(
- { nodes, edges: [{ source: 'n10', target: 'n11', type: 'linked' }] },
- { maxClusters: 5 },
- );
- // n10/n11's categories aren't rendered as their own clusters → bridge dropped
- expect(d.bridges.every(b => b.fromPos && b.toPos)).toBe(true);
- });
-});
-// @vitest-environment node
diff --git a/client/src/utils/openWorldMiniMap.js b/client/src/utils/openWorldMiniMap.js
deleted file mode 100644
index 572b23850f..0000000000
--- a/client/src/utils/openWorldMiniMap.js
+++ /dev/null
@@ -1,249 +0,0 @@
-// Pure, deterministic helpers for OpenWorld's mini-map overlay (roadmap 2.8): a top-down
-// HUD map that plots every building as a dot at its REAL city-layout position. The layout
-// itself comes from `computeOpenWorldLayout(apps)` (the same function OpenWorldScene uses to place
-// buildings), so the map can't drift from the actual city. This module only handles the
-// projection math — world (x, z) ground coordinates → normalized 0..1 map coordinates for a
-// fixed-size map box — plus bounds and empty/degenerate handling. No React / three.js
-// imports so the topology stays unit-testable (mirrors openWorldTaskQueue.js).
-//
-// Geography awareness: every island and Signal Trail causeway is read from the SAME
-// archipelago plan (`openWorldPlan.js`) the 3D scene uses. `geographyWorldPoints()` feeds
-// the island extents into the map bounds and `projectGeography()` projects the actual
-// island/link geometry, so the HUD map cannot drift back into the old rectangular city.
-
-import {
- ARCHIPELAGO_ISLANDS,
- ARCHIPELAGO_LINKS,
- PARCELS,
- archipelagoLinkPoints,
-} from './openWorldPlan';
-
-// Padding (as a fraction of the box) so dots never sit exactly on the frame edge.
-export const MINI_MAP_PADDING = 0.08;
-
-// Compute the world-space bounds of a set of { x, z } layout positions. Returns null for an
-// empty input so callers can render an "empty city" state rather than a degenerate box.
-export function computeBounds(positions) {
- const list = Array.isArray(positions) ? positions : [];
- if (list.length === 0) return null;
-
- let minX = Infinity;
- let maxX = -Infinity;
- let minZ = Infinity;
- let maxZ = -Infinity;
- for (const p of list) {
- if (p.x < minX) minX = p.x;
- if (p.x > maxX) maxX = p.x;
- if (p.z < minZ) minZ = p.z;
- if (p.z > maxZ) maxZ = p.z;
- }
- return { minX, maxX, minZ, maxZ };
-}
-
-// Compute bounds for one layout district directly from the Map returned by
-// computeOpenWorldLayout. Keeping the filter + minimum-count gate beside the
-// canonical extrema reducer prevents visual layers from re-implementing the
-// Infinity/-Infinity sentinel loop (and forgetting the empty-district case).
-export function computeDistrictBounds(positions, district, { minCount = 1 } = {}) {
- if (!positions || typeof positions.forEach !== 'function') return null;
- const entries = [];
- positions.forEach((position) => {
- if (position?.district === district) entries.push(position);
- });
- if (entries.length < minCount) return null;
- return computeBounds(entries);
-}
-
-// Project a single world (x, z) into normalized 0..1 map coordinates given the world bounds.
-// `nx` runs left→right with world +x; `ny` runs top→bottom with world +z (so the map reads
-// like a top-down floor plan). A zero-width or zero-height span (one app, or a row/column)
-// centers along that axis instead of dividing by zero. `padding` insets the usable area so
-// dots clear the frame. Results are clamped to [0, 1].
-export function projectPoint(point, bounds, padding = MINI_MAP_PADDING) {
- if (!bounds) return { nx: 0.5, ny: 0.5 };
- const spanX = bounds.maxX - bounds.minX;
- const spanZ = bounds.maxZ - bounds.minZ;
- const usable = 1 - 2 * padding;
-
- const fracX = spanX > 0 ? (point.x - bounds.minX) / spanX : 0.5;
- const fracZ = spanZ > 0 ? (point.z - bounds.minZ) / spanZ : 0.5;
-
- const nx = padding + fracX * usable;
- const ny = padding + fracZ * usable;
- return { nx: clamp01(nx), ny: clamp01(ny) };
-}
-
-// Keep dense projected markers aimable on compact touch surfaces. The first marker keeps
-// its true projection; later markers that land too close are fanned out in a deterministic
-// ring so the map remains spatially honest without stacking hit targets invisibly.
-export function spreadProjectedPoints(points, { minDistance = 0.075, offset = 0.045 } = {}) {
- const list = Array.isArray(points) ? points : [];
- const placed = [];
- return list.map((point, index) => {
- const nearby = placed.filter((candidate) => {
- const dx = candidate.nx - point.nx;
- const dy = candidate.ny - point.ny;
- return Math.sqrt(dx * dx + dy * dy) < minDistance;
- });
- if (nearby.length === 0) {
- const result = { ...point };
- placed.push(result);
- return result;
- }
-
- const ring = Math.ceil(nearby.length / 6);
- const slot = nearby.length % 6;
- const angle = (slot / 6) * Math.PI * 2 + index * 0.17;
- const result = {
- ...point,
- nx: clamp01(point.nx + Math.cos(angle) * offset * ring),
- ny: clamp01(point.ny + Math.sin(angle) * offset * ring),
- };
- placed.push(result);
- return result;
- });
-}
-
-function clamp01(v) {
- if (v < 0) return 0;
- if (v > 1) return 1;
- return v;
-}
-
-// World-space extrema for every island. These are folded into the map bounds so the
-// complete playable world remains visible even when the install has few or no app buildings.
-export function geographyWorldPoints() {
- return ARCHIPELAGO_ISLANDS.flatMap((island) => {
- const [x, z] = island.center;
- return [
- { x: x - island.radiusX, z },
- { x: x + island.radiusX, z },
- { x, z: z - island.radiusZ },
- { x, z: z + island.radiusZ },
- ];
- });
-}
-
-// Normalized (0..1) projection of the authored islands and their Signal Trail links.
-export function projectGeography(bounds, padding = MINI_MAP_PADDING) {
- if (!bounds) return null;
- return {
- islands: ARCHIPELAGO_ISLANDS.map((island) => {
- const [x, z] = island.center;
- const center = projectPoint({ x, z }, bounds, padding);
- const edgeX = projectPoint({ x: x + island.radiusX, z }, bounds, padding);
- const edgeZ = projectPoint({ x, z: z + island.radiusZ }, bounds, padding);
- return {
- id: island.id,
- label: island.label,
- biome: island.biome,
- nx: center.nx,
- ny: center.ny,
- radiusX: Math.abs(edgeX.nx - center.nx),
- radiusY: Math.abs(edgeZ.ny - center.ny),
- };
- }),
- links: ARCHIPELAGO_LINKS.map((link) => ({
- id: link.id,
- points: archipelagoLinkPoints(link).map(([x, z]) => projectPoint({ x, z }, bounds, padding)),
- })),
- };
-}
-
-// Project the live player pose (position + heading) into normalized mini-map coordinates.
-// Returns null when bounds or player position are absent/invalid.
-export function projectPlayer(playerPos, heading = 0, bounds = null, padding = MINI_MAP_PADDING) {
- if (!playerPos || typeof playerPos.x !== 'number' || typeof playerPos.z !== 'number' || !bounds) {
- return null;
- }
- const { nx, ny } = projectPoint({ x: playerPos.x, z: playerPos.z }, bounds, padding);
- // Three.js / rover heading increases counter-clockwise (0 faces north / -Z; -π/2 faces east / +X).
- // CSS rotation is clockwise in screen space, so we negate the angle to align the blip's pointer.
- const rotationDeg = -(heading * 180) / Math.PI;
- return { nx, ny, rotationDeg };
-}
-
-// Project major district landmarks onto the mini-map
-const MAP_LANDMARKS = [
- { id: 'ai-core', parcel: 'aiCore', label: 'AI Core', color: '#06b6d4' },
- { id: 'backup-vault', parcel: 'backupVault', label: 'Vault', color: '#f59e0b' },
- { id: 'task-queue', parcel: 'taskQueue', label: 'Queue', color: '#f97316' },
- { id: 'wellness', parcel: 'health', label: 'Health', color: '#10b981' },
- { id: 'memory', parcel: 'memory', label: 'Memory', color: '#a855f7' },
- { id: 'goals', parcel: 'goals', label: 'Goals', color: '#eab308' },
- { id: 'artifacts', parcel: 'artifacts', label: 'Artifacts', color: '#facc15' },
- { id: 'productivity', parcel: 'productivity', label: 'Productivity', color: '#22c55e' },
-];
-
-export function projectLandmarks(bounds, padding = MINI_MAP_PADDING) {
- if (!bounds) return [];
- const results = [];
- for (const lm of MAP_LANDMARKS) {
- const parcel = PARCELS[lm.parcel];
- if (!parcel) continue;
- const { nx, ny } = projectPoint({ x: parcel.anchor[0], z: parcel.anchor[2] }, bounds, padding);
- results.push({
- id: lm.id,
- label: lm.label,
- color: lm.color,
- nx,
- ny,
- });
- }
- return results;
-}
-
-// Full derived view-model for the mini-map component. Takes the layout `positions` Map (the
-// return value of `computeOpenWorldLayout(apps)`, keyed by app id) plus the `apps` array (for
-// status/name/archived metadata), and produces a flat list of plotted dots with normalized
-// coordinates, the world bounds, and a count. Apps missing a layout position are skipped
-// (defensive — every active/archived app should have one). Handles empty/non-array inputs by
-// returning an empty, bounds-null view.
-//
-// `opts.geography` (default false) folds the whole archipelago into the bounds and returns
-// its projected island/link view-model. The live overlay passes `true`.
-export function computeMiniMap(apps, positions, opts = {}) {
- const padding = opts.padding ?? MINI_MAP_PADDING;
- const includeGeography = opts.geography === true;
- const includeLandmarks = opts.landmarks === true;
- const appList = Array.isArray(apps) ? apps : [];
- const posMap = positions instanceof Map ? positions : new Map();
-
- const placed = [];
- for (const app of appList) {
- const pos = posMap.get(app?.id);
- if (!pos) continue;
- placed.push({ app, pos });
- }
-
- // Geography anchors expand the box to the complete playable world, but never become dots.
- const boundsPoints = placed.map(({ pos }) => pos);
- if (includeGeography) boundsPoints.push(...geographyWorldPoints());
- const bounds = computeBounds(boundsPoints);
-
- const dots = placed.map(({ app, pos }) => {
- const { nx, ny } = projectPoint(pos, bounds, padding);
- return {
- id: app.id,
- name: app.name || app.id,
- status: app.archived ? 'archived' : (app.overallStatus || 'not_started'),
- archived: Boolean(app.archived),
- district: pos.district,
- nx,
- ny,
- };
- });
-
- const player = opts.player ? projectPlayer(opts.player.position, opts.player.heading, bounds, padding) : null;
- const landmarks = includeLandmarks ? projectLandmarks(bounds, padding) : [];
-
- return {
- dots,
- bounds,
- count: dots.length,
- empty: dots.length === 0,
- geography: includeGeography ? projectGeography(bounds, padding) : null,
- player,
- landmarks,
- };
-}
diff --git a/client/src/utils/openWorldMiniMap.test.js b/client/src/utils/openWorldMiniMap.test.js
deleted file mode 100644
index bfce6e9b76..0000000000
--- a/client/src/utils/openWorldMiniMap.test.js
+++ /dev/null
@@ -1,322 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import {
- MINI_MAP_PADDING,
- computeBounds,
- computeDistrictBounds,
- projectPoint,
- computeMiniMap,
- geographyWorldPoints,
- projectGeography,
- projectPlayer,
- projectLandmarks,
- spreadProjectedPoints,
-} from './openWorldMiniMap';
-import { ARCHIPELAGO_ISLANDS, ARCHIPELAGO_LINKS } from './openWorldPlan';
-
-const pos = (x, z, district = 'downtown') => ({ x, z, district });
-
-describe('computeBounds', () => {
- it('returns null for empty / non-array input', () => {
- expect(computeBounds([])).toBeNull();
- expect(computeBounds(undefined)).toBeNull();
- expect(computeBounds(null)).toBeNull();
- });
-
- it('computes the min/max box for many points', () => {
- const b = computeBounds([pos(-12, 0), pos(0, -12), pos(12, 12), pos(0, 0)]);
- expect(b).toEqual({ minX: -12, maxX: 12, minZ: -12, maxZ: 12 });
- });
-
- it('collapses to a zero-span box for a single point', () => {
- const b = computeBounds([pos(5, -3)]);
- expect(b).toEqual({ minX: 5, maxX: 5, minZ: -3, maxZ: -3 });
- });
-});
-
-describe('computeDistrictBounds', () => {
- it('returns null when a populated layout has no entries in the requested district', () => {
- const positions = new Map([
- ['archived-a', { x: -6, z: 16, district: 'warehouse' }],
- ['archived-b', { x: 6, z: 16, district: 'warehouse' }],
- ]);
-
- expect(computeDistrictBounds(positions, 'downtown', { minCount: 2 })).toBeNull();
- });
-
- it('uses the canonical bounds reducer after applying the district and count gate', () => {
- const positions = new Map([
- ['a', { x: -4, z: 3, district: 'downtown' }],
- ['b', { x: 7, z: -2, district: 'downtown' }],
- ['archived', { x: 100, z: 100, district: 'warehouse' }],
- ]);
-
- expect(computeDistrictBounds(positions, 'downtown', { minCount: 2 })).toEqual({
- minX: -4,
- maxX: 7,
- minZ: -2,
- maxZ: 3,
- });
- });
-});
-
-describe('projectPoint', () => {
- const bounds = { minX: -10, maxX: 10, minZ: -10, maxZ: 10 };
- const p = MINI_MAP_PADDING;
- const usable = 1 - 2 * p;
-
- it('maps the min corner to the padded top-left', () => {
- const { nx, ny } = projectPoint(pos(-10, -10), bounds);
- expect(nx).toBeCloseTo(p);
- expect(ny).toBeCloseTo(p);
- });
-
- it('maps the max corner to the padded bottom-right', () => {
- const { nx, ny } = projectPoint(pos(10, 10), bounds);
- expect(nx).toBeCloseTo(p + usable);
- expect(ny).toBeCloseTo(p + usable);
- });
-
- it('maps the center to the middle of the box', () => {
- const { nx, ny } = projectPoint(pos(0, 0), bounds);
- expect(nx).toBeCloseTo(0.5);
- expect(ny).toBeCloseTo(0.5);
- });
-
- it('maps +x right and +z down (top-down floor plan)', () => {
- const right = projectPoint(pos(10, 0), bounds);
- const left = projectPoint(pos(-10, 0), bounds);
- const down = projectPoint(pos(0, 10), bounds);
- const up = projectPoint(pos(0, -10), bounds);
- expect(right.nx).toBeGreaterThan(left.nx);
- expect(down.ny).toBeGreaterThan(up.ny);
- });
-
- it('centers a point along a zero-span axis instead of dividing by zero', () => {
- const colBounds = { minX: 5, maxX: 5, minZ: -10, maxZ: 10 };
- const { nx, ny } = projectPoint(pos(5, 0), colBounds);
- expect(nx).toBeCloseTo(0.5); // x span is zero → centered
- expect(ny).toBeCloseTo(0.5); // z span resolves normally
- });
-
- it('clamps out-of-bounds points into [0, 1]', () => {
- const { nx, ny } = projectPoint(pos(1000, -1000), bounds);
- expect(nx).toBeGreaterThanOrEqual(0);
- expect(nx).toBeLessThanOrEqual(1);
- expect(ny).toBeGreaterThanOrEqual(0);
- expect(ny).toBeLessThanOrEqual(1);
- });
-
- it('falls back to center when bounds are null', () => {
- expect(projectPoint(pos(3, 7), null)).toEqual({ nx: 0.5, ny: 0.5 });
- });
-});
-
-describe('spreadProjectedPoints', () => {
- it('keeps isolated points at their true projection', () => {
- const points = [{ id: 'a', nx: 0.2, ny: 0.2 }, { id: 'b', nx: 0.8, ny: 0.8 }];
- expect(spreadProjectedPoints(points)).toEqual(points);
- });
-
- it('fans colliding points into visible deterministic positions', () => {
- const points = Array.from({ length: 4 }, (_, index) => ({ id: String(index), nx: 0.5, ny: 0.5 }));
- const first = spreadProjectedPoints(points);
- const second = spreadProjectedPoints(points);
- expect(first).toEqual(second);
- expect(new Set(first.map((point) => `${point.nx}:${point.ny}`)).size).toBe(4);
- });
-});
-
-describe('computeMiniMap', () => {
- const positions = (entries) => new Map(entries.map(([id, x, z, district]) => [id, pos(x, z, district)]));
-
- it('returns an empty, bounds-null view for no apps', () => {
- const vm = computeMiniMap([], new Map());
- expect(vm.empty).toBe(true);
- expect(vm.count).toBe(0);
- expect(vm.dots).toEqual([]);
- expect(vm.bounds).toBeNull();
- });
-
- it('tolerates non-array apps / non-Map positions', () => {
- const vm = computeMiniMap(undefined, undefined);
- expect(vm.empty).toBe(true);
- expect(vm.count).toBe(0);
- });
-
- it('plots a single app at the center', () => {
- const apps = [{ id: 'a', name: 'Alpha', overallStatus: 'online' }];
- const vm = computeMiniMap(apps, positions([['a', 0, 0]]));
- expect(vm.count).toBe(1);
- expect(vm.empty).toBe(false);
- expect(vm.dots[0].nx).toBeCloseTo(0.5);
- expect(vm.dots[0].ny).toBeCloseTo(0.5);
- expect(vm.dots[0].status).toBe('online');
- });
-
- it('projects many apps within the padded box and preserves order', () => {
- const apps = [
- { id: 'a', overallStatus: 'online' },
- { id: 'b', overallStatus: 'stopped' },
- { id: 'c', overallStatus: 'online' },
- ];
- const vm = computeMiniMap(apps, positions([['a', -12, -12], ['b', 12, 12], ['c', 0, 0]]));
- expect(vm.count).toBe(3);
- expect(vm.dots.map(d => d.id)).toEqual(['a', 'b', 'c']);
- for (const d of vm.dots) {
- expect(d.nx).toBeGreaterThanOrEqual(0);
- expect(d.nx).toBeLessThanOrEqual(1);
- expect(d.ny).toBeGreaterThanOrEqual(0);
- expect(d.ny).toBeLessThanOrEqual(1);
- }
- });
-
- it('marks archived apps with the archived status regardless of overallStatus', () => {
- const apps = [{ id: 'a', overallStatus: 'online', archived: true }];
- const vm = computeMiniMap(apps, positions([['a', 0, 0, 'warehouse']]));
- expect(vm.dots[0].status).toBe('archived');
- expect(vm.dots[0].archived).toBe(true);
- expect(vm.dots[0].district).toBe('warehouse');
- });
-
- it('defaults a missing status to not_started', () => {
- const apps = [{ id: 'a' }];
- const vm = computeMiniMap(apps, positions([['a', 0, 0]]));
- expect(vm.dots[0].status).toBe('not_started');
- });
-
- it('falls back to the id when an app has no name', () => {
- const apps = [{ id: 'svc-42', overallStatus: 'online' }];
- const vm = computeMiniMap(apps, positions([['svc-42', 0, 0]]));
- expect(vm.dots[0].name).toBe('svc-42');
- });
-
- it('skips apps that have no layout position', () => {
- const apps = [
- { id: 'a', overallStatus: 'online' },
- { id: 'ghost', overallStatus: 'online' },
- ];
- const vm = computeMiniMap(apps, positions([['a', 0, 0]]));
- expect(vm.count).toBe(1);
- expect(vm.dots.map(d => d.id)).toEqual(['a']);
- });
-
- it('omits geography by default (pure building bounds)', () => {
- const apps = [{ id: 'a', overallStatus: 'online' }];
- const vm = computeMiniMap(apps, positions([['a', 0, 0]]));
- expect(vm.geography).toBeNull();
- });
-
- it('keeps the playable archipelago visible for an install with no app buildings', () => {
- const vm = computeMiniMap([], new Map(), { geography: true });
- expect(vm.geography).not.toBeNull();
- expect(vm.geography.islands).toHaveLength(ARCHIPELAGO_ISLANDS.length);
- expect(vm.bounds).not.toBeNull();
- expect(vm.empty).toBe(true);
- });
-
- it('folds the whole archipelago into the bounds when geography is enabled', () => {
- const apps = [{ id: 'a', overallStatus: 'online' }];
- const land = computeMiniMap(apps, positions([['a', 0, 0]]));
- const sea = computeMiniMap(apps, positions([['a', 0, 0]]), { geography: true });
- expect(sea.bounds.minZ).toBeLessThan(land.bounds.minZ);
- expect(sea.bounds.maxZ).toBeGreaterThan(land.bounds.maxZ);
- expect(sea.bounds.minX).toBeLessThan(land.bounds.minX);
- expect(sea.bounds.maxX).toBeGreaterThan(land.bounds.maxX);
- expect(sea.geography).not.toBeNull();
- expect(sea.geography.links).toHaveLength(ARCHIPELAGO_LINKS.length);
- });
-
- it('projects every island and every link inside the normalized map', () => {
- const apps = [
- { id: 'a', overallStatus: 'online' },
- { id: 'b', overallStatus: 'online' },
- ];
- const vm = computeMiniMap(apps, positions([['a', -20, 20], ['b', 20, 40]]), { geography: true });
- for (const island of vm.geography.islands) {
- for (const value of [island.nx, island.ny, island.radiusX, island.radiusY]) {
- expect(value).toBeGreaterThanOrEqual(0);
- expect(value).toBeLessThanOrEqual(1);
- }
- }
- for (const link of vm.geography.links) {
- expect(link.points.length).toBeGreaterThanOrEqual(2);
- link.points.forEach((point) => {
- expect(point.nx).toBeGreaterThanOrEqual(0);
- expect(point.nx).toBeLessThanOrEqual(1);
- expect(point.ny).toBeGreaterThanOrEqual(0);
- expect(point.ny).toBeLessThanOrEqual(1);
- });
- }
- });
-});
-
-describe('geographyWorldPoints', () => {
- it('returns four extrema for every island in the master plan', () => {
- const pts = geographyWorldPoints();
- expect(pts).toHaveLength(ARCHIPELAGO_ISLANDS.length * 4);
- ARCHIPELAGO_ISLANDS.forEach((island, index) => {
- const offset = index * 4;
- expect(pts[offset]).toEqual({ x: island.center[0] - island.radiusX, z: island.center[1] });
- expect(pts[offset + 1]).toEqual({ x: island.center[0] + island.radiusX, z: island.center[1] });
- expect(pts[offset + 2]).toEqual({ x: island.center[0], z: island.center[1] - island.radiusZ });
- expect(pts[offset + 3]).toEqual({ x: island.center[0], z: island.center[1] + island.radiusZ });
- });
- });
-});
-
-describe('projectGeography', () => {
- it('returns null when bounds are null', () => {
- expect(projectGeography(null)).toBeNull();
- });
-
- it('projects islands and links into normalized coordinates', () => {
- const bounds = { minX: -60, maxX: 60, minZ: -70, maxZ: 60 };
- const geo = projectGeography(bounds);
- expect(geo.islands).toHaveLength(ARCHIPELAGO_ISLANDS.length);
- expect(geo.links).toHaveLength(ARCHIPELAGO_LINKS.length);
- expect(geo.islands.find((island) => island.id === 'harbor')?.label).toBe('Data Harbor');
- });
-});
-
-describe('projectPlayer', () => {
- const bounds = { minX: -60, maxX: 60, minZ: -70, maxZ: 60 };
-
- it('projects player position and converts heading to degrees for clockwise CSS rotation', () => {
- const player = projectPlayer({ x: 0, z: 0 }, 0, bounds);
- expect(player).not.toBeNull();
- expect(player.nx).toBeCloseTo(0.5);
- expect(player.rotationDeg).toBeCloseTo(0);
-
- // Rover heading -π/2 (turning right / east / +X) corresponds to +90° clockwise CSS rotation:
- const playerEast = projectPlayer({ x: 10, z: -10 }, -Math.PI / 2, bounds);
- expect(playerEast.rotationDeg).toBeCloseTo(90);
- });
-
- it('returns null on invalid / absent inputs', () => {
- expect(projectPlayer(null, 0, bounds)).toBeNull();
- expect(projectPlayer({ x: 0, z: 0 }, 0, null)).toBeNull();
- });
-});
-
-describe('projectLandmarks', () => {
- const bounds = { minX: -60, maxX: 60, minZ: -70, maxZ: 60 };
-
- it('projects landmarks onto normalized coordinates', () => {
- const landmarks = projectLandmarks(bounds);
- expect(landmarks.length).toBeGreaterThanOrEqual(5);
- landmarks.forEach((lm) => {
- expect(typeof lm.id).toBe('string');
- expect(typeof lm.label).toBe('string');
- expect(lm.nx).toBeGreaterThanOrEqual(0);
- expect(lm.nx).toBeLessThanOrEqual(1);
- expect(lm.ny).toBeGreaterThanOrEqual(0);
- expect(lm.ny).toBeLessThanOrEqual(1);
- });
- });
-
- it('returns empty array when bounds are null', () => {
- expect(projectLandmarks(null)).toEqual([]);
- });
-});
-
-// @vitest-environment node
diff --git a/client/src/utils/openWorldPhotoMode.js b/client/src/utils/openWorldPhotoMode.js
deleted file mode 100644
index 4d1399ce12..0000000000
--- a/client/src/utils/openWorldPhotoMode.js
+++ /dev/null
@@ -1,129 +0,0 @@
-// Pure, deterministic helpers for OpenWorld's photo mode (roadmap 3.3): cinematic camera
-// presets, the camera-fly stepper, the "OpenWorld postcard" stats overlay, and screenshot filename
-// generation. No three.js / React imports so it's unit-testable (mirrors the other city
-// helpers). The component reads these presets to fly the camera and composites the postcard
-// caption from a live stats snapshot the page passes in.
-import { smoothstep } from './easing';
-
-// Cinematic camera presets. Each is a stable framing of the city — position + look-at target —
-// the photo UI cycles through. Tuned against the default orbital view (camera at [0,25,45]
-// looking at origin) and the ±60-unit landmark ring, so every preset keeps downtown and at
-// least one landmark district in frame.
-export const PHOTO_PRESETS = [
- { id: 'establishing', label: 'ESTABLISHING', position: [0, 28, 52], target: [0, 2, 0] },
- { id: 'downtown', label: 'DOWNTOWN', position: [0, 10, 26], target: [0, 4, 0] },
- { id: 'skyline', label: 'SKYLINE', position: [44, 6, 44], target: [-6, 8, -6] },
- { id: 'overhead', label: 'OVERHEAD', position: [0, 70, 0.01], target: [0, 0, 0] },
- { id: 'horizon', label: 'HORIZON', position: [0, 4, 60], target: [0, 10, -60] },
- // Low-angle is the most dramatic, close-in framing — a wider aperture pushes a shallower,
- // more cinematic falloff so the foreground subject pops against a soft background.
- { id: 'low-angle', label: 'LOW ANGLE', position: [-18, 2, 30], target: [0, 12, 0], dof: { aperture: 0.09 } },
-];
-
-export const DEFAULT_PRESET_ID = 'establishing';
-
-// Resolve a preset by id, falling back to the default establishing shot for an unknown id so a
-// stale persisted id can never strand the camera with no framing.
-export function getPreset(id) {
- return PHOTO_PRESETS.find(p => p.id === id) || PHOTO_PRESETS.find(p => p.id === DEFAULT_PRESET_ID);
-}
-
-// Depth-of-field defaults for cinematic photo-mode shots (roadmap 3.3). `aperture` and `maxblur`
-// shape the blur falloff for three's BokehPass; both are intentionally gentle so the effect reads
-// as "cinematic" rather than "broken". A preset may override either via an optional `dof: { … }`
-// field. The focal distance is NOT a default — it's derived per preset from the camera framing
-// (see `presetFocusDistance`) so the subject the camera is pointed at always stays sharp without
-// hand-tuning a separate focus number that could drift from the framing.
-export const DOF_DEFAULTS = { aperture: 0.05, maxblur: 0.012 };
-
-// Distance (world units) from a preset's camera position to its look-at target. This is the focal
-// plane: BokehPass keeps geometry at this depth sharp and blurs nearer/farther geometry. Deriving
-// it from the preset's own position→target keeps focus locked to whatever the shot frames. Pure
-// (no three.js) so it stays unit-testable alongside the other helpers.
-export function presetFocusDistance(preset) {
- if (!Array.isArray(preset?.position) || !Array.isArray(preset?.target)) return 1;
- const [px, py, pz] = preset.position;
- const [tx, ty, tz] = preset.target;
- const dx = px - tx;
- const dy = py - ty;
- const dz = pz - tz;
- const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
- return Number.isFinite(dist) && dist > 0 ? dist : 1;
-}
-
-// Accept a per-preset override only when it's a positive finite number; otherwise fall back to the
-// default. A negative/zero/NaN aperture or maxblur is meaningless to the bokeh shader (blur radius
-// is non-negative), so a malformed hand-edited preset can't push a broken value into the pass.
-const positiveOr = (value, fallback) => (Number.isFinite(value) && value > 0 ? value : fallback);
-
-// Resolve the BokehPass parameters for a preset: derived focal distance + (per-preset-overridable)
-// aperture/maxblur. Used by OpenWorldDepthOfField to build and re-tune the pass when the preset changes.
-export function getDofParams(presetId) {
- const preset = getPreset(presetId);
- const override = preset?.dof || {};
- return {
- focus: presetFocusDistance(preset),
- aperture: positiveOr(override.aperture, DOF_DEFAULTS.aperture),
- maxblur: positiveOr(override.maxblur, DOF_DEFAULTS.maxblur),
- };
-}
-
-// Step to the next/previous preset in the ring (wraps). Used by the ‹ › controls and arrow keys.
-export function cyclePreset(currentId, direction = 1) {
- const idx = PHOTO_PRESETS.findIndex(p => p.id === currentId);
- const base = idx === -1 ? 0 : idx;
- const next = (base + direction + PHOTO_PRESETS.length) % PHOTO_PRESETS.length;
- return PHOTO_PRESETS[next].id;
-}
-
-// Photo mode runs the Canvas frameloop in "demand" mode (roadmap 3.6): the scene animates only
-// while the camera is flying to a preset, then freezes for a clean, deliberate still. This pure
-// stepper advances the fly progress by an elapsed delta and reports whether the loop still needs
-// pumping. `FLY_DURATION` is the seconds the cinematic ease takes (slower than the exploration
-// transition). `stepFly` returns the clamped next progress, the eased interpolation factor `t`,
-// and `done` (true once settled) so the component can stop invalidating the demand loop.
-export const FLY_DURATION = 1.1;
-
-// Cap the per-step delta to a frame-sized maximum. In demand mode the loop sleeps while the scene
-// is frozen, so the FIRST frame after a freeze (e.g. when the user cycles presets) carries a
-// delta equal to the whole idle gap — often several seconds. Unclamped, that would complete the
-// fly in a single step and the camera would snap instead of animating. Clamping keeps every fly
-// smooth (~at least FLY_DURATION/MAX_FLY_DELTA frames) regardless of how long the scene was idle.
-export const MAX_FLY_DELTA = 1 / 30; // seconds — one 30fps frame
-
-export function stepFly(progress, deltaSeconds) {
- const rawDelta = Number.isFinite(deltaSeconds) && deltaSeconds > 0 ? deltaSeconds : 0;
- const safeDelta = Math.min(rawDelta, MAX_FLY_DELTA);
- const next = Math.min(1, (Number.isFinite(progress) ? progress : 1) + safeDelta / FLY_DURATION);
- return { progress: next, t: smoothstep(next), done: next >= 1 };
-}
-
-// Build the short stat lines printed on a "city postcard". Pulls a handful of headline numbers
-// from a stats snapshot the page already has (apps, agents, peers, level). Missing fields are
-// omitted rather than rendered as "0/undefined", so a sparse install still prints a clean card.
-export function buildPostcardStats(snapshot = {}) {
- const lines = [];
- const { online, total, agents, peers, level } = snapshot;
- if (Number.isFinite(total)) lines.push(`${online ?? 0}/${total} SYSTEMS ONLINE`);
- if (Number.isFinite(agents) && agents > 0) lines.push(`${agents} AGENT${agents === 1 ? '' : 'S'} ACTIVE`);
- if (Number.isFinite(peers) && peers > 0) lines.push(`${peers} PEER${peers === 1 ? '' : 'S'} LINKED`);
- if (Number.isFinite(level)) lines.push(`LEVEL ${level}`);
- return lines;
-}
-
-// Pad a number to two digits without Date formatting (Date.now is unavailable in some contexts;
-// the timestamp is always passed in from the caller).
-const pad2 = (n) => String(n).padStart(2, '0');
-
-// Build a stable, filesystem-safe screenshot filename from a Date. Format:
-// `openworld-YYYYMMDD-HHMMSS.png`. The Date is injected so the function is deterministic in
-// tests and the caller controls the clock.
-export function screenshotFilename(date = new Date()) {
- const y = date.getFullYear();
- const m = pad2(date.getMonth() + 1);
- const d = pad2(date.getDate());
- const hh = pad2(date.getHours());
- const mm = pad2(date.getMinutes());
- const ss = pad2(date.getSeconds());
- return `openworld-${y}${m}${d}-${hh}${mm}${ss}.png`;
-}
diff --git a/client/src/utils/openWorldPhotoMode.test.js b/client/src/utils/openWorldPhotoMode.test.js
deleted file mode 100644
index ff58f25606..0000000000
--- a/client/src/utils/openWorldPhotoMode.test.js
+++ /dev/null
@@ -1,179 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import {
- PHOTO_PRESETS,
- DEFAULT_PRESET_ID,
- getPreset,
- cyclePreset,
- stepFly,
- FLY_DURATION,
- MAX_FLY_DELTA,
- buildPostcardStats,
- screenshotFilename,
- DOF_DEFAULTS,
- presetFocusDistance,
- getDofParams,
-} from './openWorldPhotoMode';
-import { smoothstep } from './easing';
-
-describe('PHOTO_PRESETS', () => {
- it('has unique ids and a position + target each', () => {
- const ids = PHOTO_PRESETS.map(p => p.id);
- expect(new Set(ids).size).toBe(ids.length);
- for (const p of PHOTO_PRESETS) {
- expect(p.position).toHaveLength(3);
- expect(p.target).toHaveLength(3);
- expect(typeof p.label).toBe('string');
- }
- });
- it('includes the default preset id', () => {
- expect(PHOTO_PRESETS.some(p => p.id === DEFAULT_PRESET_ID)).toBe(true);
- });
-});
-
-describe('getPreset', () => {
- it('resolves a known id', () => {
- expect(getPreset('downtown').id).toBe('downtown');
- });
- it('falls back to the default for an unknown id', () => {
- expect(getPreset('nope').id).toBe(DEFAULT_PRESET_ID);
- expect(getPreset(undefined).id).toBe(DEFAULT_PRESET_ID);
- });
-});
-
-describe('cyclePreset', () => {
- it('advances forward and wraps', () => {
- const first = PHOTO_PRESETS[0].id;
- const last = PHOTO_PRESETS[PHOTO_PRESETS.length - 1].id;
- expect(cyclePreset(last, 1)).toBe(first);
- });
- it('advances backward and wraps', () => {
- const first = PHOTO_PRESETS[0].id;
- const last = PHOTO_PRESETS[PHOTO_PRESETS.length - 1].id;
- expect(cyclePreset(first, -1)).toBe(last);
- });
- it('treats an unknown current id as the first preset', () => {
- expect(cyclePreset('bogus', 1)).toBe(PHOTO_PRESETS[1].id);
- });
-});
-
-describe('stepFly', () => {
- it('advances progress by delta/duration and eases t for a frame-sized delta', () => {
- const { progress, t, done } = stepFly(0, MAX_FLY_DELTA);
- expect(progress).toBeCloseTo(MAX_FLY_DELTA / FLY_DURATION, 5);
- expect(t).toBeCloseTo(smoothstep(progress), 5);
- expect(done).toBe(false);
- });
- it('clamps a huge idle delta to one frame so a fly never snaps in a single step', () => {
- // In demand mode the first frame after a freeze carries the whole idle gap as delta. The fly
- // must still advance only one frame's worth, not jump straight to settled.
- const { progress, done } = stepFly(0, 9999);
- expect(progress).toBeCloseTo(MAX_FLY_DELTA / FLY_DURATION, 5);
- expect(done).toBe(false);
- });
- it('clamps progress to 1 and reports done once enough frames accumulate', () => {
- const { progress, t, done } = stepFly(0.999, MAX_FLY_DELTA);
- expect(progress).toBe(1);
- expect(t).toBe(1);
- expect(done).toBe(true);
- });
- it('reports done when already settled (no negative drift)', () => {
- expect(stepFly(1, 0.016).done).toBe(true);
- expect(stepFly(1, 0.016).progress).toBe(1);
- });
- it('treats a non-positive or non-finite delta as no advance', () => {
- expect(stepFly(0.3, 0).progress).toBeCloseTo(0.3, 5);
- expect(stepFly(0.3, -1).progress).toBeCloseTo(0.3, 5);
- expect(stepFly(0.3, NaN).progress).toBeCloseTo(0.3, 5);
- });
- it('treats a non-finite progress as settled', () => {
- expect(stepFly(undefined, 0.016).progress).toBe(1);
- expect(stepFly(NaN, 0.016).done).toBe(true);
- });
-});
-
-describe('buildPostcardStats', () => {
- it('renders the headline lines that have data', () => {
- const lines = buildPostcardStats({ online: 3, total: 5, agents: 2, peers: 1, level: 7 });
- expect(lines).toContain('3/5 SYSTEMS ONLINE');
- expect(lines).toContain('2 AGENTS ACTIVE');
- expect(lines).toContain('1 PEER LINKED');
- expect(lines).toContain('LEVEL 7');
- });
- it('omits zero/absent fields rather than printing 0', () => {
- const lines = buildPostcardStats({ online: 0, total: 2, agents: 0, peers: 0 });
- expect(lines).toEqual(['0/2 SYSTEMS ONLINE']);
- });
- it('singularizes agent/peer counts of one', () => {
- const lines = buildPostcardStats({ agents: 1, peers: 1, total: 1, online: 1 });
- expect(lines).toContain('1 AGENT ACTIVE');
- expect(lines).toContain('1 PEER LINKED');
- });
- it('handles an empty snapshot', () => {
- expect(buildPostcardStats()).toEqual([]);
- expect(buildPostcardStats({})).toEqual([]);
- });
- it('renders level 0', () => {
- expect(buildPostcardStats({ level: 0 })).toContain('LEVEL 0');
- });
-});
-
-describe('screenshotFilename', () => {
- it('formats a zero-padded timestamped name', () => {
- const d = new Date(2026, 5, 3, 9, 7, 5); // 2026-06-03 09:07:05 (month is 0-based)
- expect(screenshotFilename(d)).toBe('openworld-20260603-090705.png');
- });
- it('is deterministic for the same date', () => {
- const d = new Date(2026, 0, 1, 0, 0, 0);
- expect(screenshotFilename(d)).toBe(screenshotFilename(d));
- });
-});
-
-describe('presetFocusDistance', () => {
- it('is the euclidean distance from camera position to look-at target', () => {
- // position 3-4-0 from origin target → 5 (3-4-5 triangle)
- expect(presetFocusDistance({ position: [3, 4, 0], target: [0, 0, 0] })).toBe(5);
- });
- it('is always positive and finite for every shipped preset', () => {
- for (const p of PHOTO_PRESETS) {
- const d = presetFocusDistance(p);
- expect(Number.isFinite(d)).toBe(true);
- expect(d).toBeGreaterThan(0);
- }
- });
- it('falls back to 1 for malformed presets (never zero focal plane)', () => {
- expect(presetFocusDistance(undefined)).toBe(1);
- expect(presetFocusDistance({})).toBe(1);
- expect(presetFocusDistance({ position: [0, 0, 0], target: [0, 0, 0] })).toBe(1); // distance 0 → fallback
- });
-});
-
-describe('getDofParams', () => {
- it('derives focus from the preset framing and applies the default aperture/maxblur', () => {
- const params = getDofParams('downtown');
- expect(params.focus).toBe(presetFocusDistance(getPreset('downtown')));
- expect(params.aperture).toBe(DOF_DEFAULTS.aperture);
- expect(params.maxblur).toBe(DOF_DEFAULTS.maxblur);
- });
- it('falls back to the default preset for an unknown id', () => {
- expect(getDofParams('nope').focus).toBe(getDofParams(DEFAULT_PRESET_ID).focus);
- });
- it('honors a per-preset aperture override while still defaulting unspecified fields', () => {
- // low-angle ships a wider aperture override but no maxblur override.
- const lowAngle = getPreset('low-angle');
- expect(lowAngle.dof?.aperture).toBe(0.09); // guards the shipped override against drift
- const params = getDofParams('low-angle');
- expect(params.aperture).toBe(0.09);
- expect(params.maxblur).toBe(DOF_DEFAULTS.maxblur); // unspecified → default
- });
- it('always returns positive finite aperture/maxblur and a positive focus', () => {
- for (const p of PHOTO_PRESETS) {
- const params = getDofParams(p.id);
- expect(params.focus).toBeGreaterThan(0);
- expect(params.aperture).toBeGreaterThan(0);
- expect(params.maxblur).toBeGreaterThan(0);
- expect(Number.isFinite(params.aperture)).toBe(true);
- expect(Number.isFinite(params.maxblur)).toBe(true);
- }
- });
-});
-// @vitest-environment node
diff --git a/client/src/utils/openWorldPlan.js b/client/src/utils/openWorldPlan.js
deleted file mode 100644
index dbabf7cd1a..0000000000
--- a/client/src/utils/openWorldPlan.js
+++ /dev/null
@@ -1,446 +0,0 @@
-// OpenWorld's master archipelago plan — THE single source of truth for its geography.
-// Every district helper anchors its parcel here (instead of each module hardcoding its own
-// compass position), so streets, the transit loop, the waterfront, and the overlap/shoreline
-// invariant tests all read the same map. No three.js / React imports — the whole plan is
-// plain data + pure math, unit-testable in node (mirrors openWorldDistrictLayout.js).
-//
-// The Port sits at the center; Memory Wilds and Maker Reach branch north, Signal Cape
-// carries their trails to Data Harbor, and Archive Rise branches south into the Focus
-// Gardens and Wellness Grove. Open water makes those regions readable at a glance.
-
-export const WORLD = {
- bound: 180, // hard XZ world bound (matches PlayerController)
- shorelineZ: -56, // land for z > shorelineZ; water (the bay) beyond
- landHalf: 60, // half-extent of the paved city ground plane (x ±landHalf, z shoreline→+landHalf)
- // The new world is a chain of raised islands. Live landmarks still sit at y=0,
- // while the ocean and distant terrain sit well below the playable shelves.
- terrainY: -2.8,
- waterY: -2.55,
- groundY: 0,
- waterSpan: 520, // how far the water plane extends past the shoreline / to each side
-};
-
-// OpenWorld is an authored archipelago rather than a rectangular city slab. Each
-// island owns a recognizable PortOS biome; bridges make a continuous Signal Trail
-// through all of them. The same data drives rendering, collision, and the mini-map.
-// Radii are deliberately generous around landmark anchors so a live-data structure
-// never appears to float off the terrain when its own footprint grows slightly.
-export const ARCHIPELAGO_ISLANDS = [
- // The street-level game now reads as one cozy valley rather than eight empty plates.
- // Overlap is deliberate: the named neighborhoods remain legible on the map, while the
- // playable ground joins into an Animal-Crossing-like village with no dead ocean gaps.
- { id: 'core', label: 'The Port', center: [0, -4], radiusX: 56, radiusZ: 51, seed: 111, biome: 'port' },
- { id: 'memory', label: 'Memory Wilds', center: [-39, -33], radiusX: 29, radiusZ: 24, seed: 223, biome: 'memory' },
- { id: 'forge', label: 'Maker Reach', center: [39, -29], radiusX: 29, radiusZ: 24, seed: 337, biome: 'forge' },
- { id: 'signal', label: 'Signal Cape', center: [7, -48], radiusX: 20, radiusZ: 15, seed: 449, biome: 'signal' },
- { id: 'harbor', label: 'Data Harbor', center: [0, -66], radiusX: 31, radiusZ: 18, seed: 557, biome: 'harbor' },
- { id: 'archive', label: 'Archive Rise', center: [0, 40], radiusX: 46, radiusZ: 28, seed: 661, biome: 'archive' },
- { id: 'garden', label: 'Focus Gardens', center: [-45, 32], radiusX: 23, radiusZ: 20, seed: 773, biome: 'garden' },
- { id: 'wellness', label: 'Wellness Grove', center: [45, 30], radiusX: 23, radiusZ: 20, seed: 887, biome: 'wellness' },
-];
-
-// The close game renders one continuous valley shelf. Named neighborhood islands remain
-// as semantic geography for orbital view and compatibility, while this outline is the
-// street-level ground and the Village Map silhouette.
-export const VILLAGE_GROUND = {
- id: 'village-ground',
- label: 'PortOS Village',
- center: [0, -5],
- radiusX: 74,
- radiusZ: 80,
- seed: 1061,
- biome: 'port',
-};
-
-// Causeways use explicit waypoints so the visual path can arc around landmarks.
-// `width` is the full rendered deck width and also the ground-collision corridor.
-export const ARCHIPELAGO_LINKS = [
- { id: 'core-memory', from: 'core', to: 'memory', width: 5.2, points: [[-18, -11], [-28, -22]] },
- { id: 'core-forge', from: 'core', to: 'forge', width: 5.2, points: [[18, -10], [28, -19]] },
- { id: 'core-signal', from: 'core', to: 'signal', width: 5.6, points: [[2, -27], [5, -35]] },
- { id: 'signal-harbor', from: 'signal', to: 'harbor', width: 5.6, points: [[5, -50], [2, -57]] },
- { id: 'core-archive', from: 'core', to: 'archive', width: 6.2, points: [[0, 26], [0, 31]] },
- { id: 'archive-garden', from: 'archive', to: 'garden', width: 4.8, points: [[-18, 39], [-32, 36]] },
- { id: 'archive-wellness', from: 'archive', to: 'wellness', width: 4.8, points: [[18, 38], [32, 34]] },
- { id: 'memory-signal', from: 'memory', to: 'signal', width: 4.4, points: [[-23, -39], [-9, -42]] },
- { id: 'forge-signal', from: 'forge', to: 'signal', width: 4.4, points: [[24, -36], [15, -41]] },
-];
-
-const islandById = (id) => ARCHIPELAGO_ISLANDS.find((island) => island.id === id);
-
-export const archipelagoLinkPoints = (link) => {
- const from = islandById(link?.from);
- const to = islandById(link?.to);
- if (!from || !to) return [];
- return [from.center, ...(link.points || []), to.center];
-};
-
-const pointNearSegment = (x, z, start, end, radius) => {
- const dx = end[0] - start[0];
- const dz = end[1] - start[1];
- const lengthSq = dx * dx + dz * dz;
- if (lengthSq <= 1e-9) return Math.hypot(x - start[0], z - start[1]) <= radius;
- const t = Math.max(0, Math.min(1, ((x - start[0]) * dx + (z - start[1]) * dz) / lengthSq));
- return Math.hypot(x - (start[0] + dx * t), z - (start[1] + dz * t)) <= radius;
-};
-
-export const isOnArchipelagoIsland = (x, z, inset = 0) => ARCHIPELAGO_ISLANDS.some((island) => {
- const safeX = Math.max(0.5, island.radiusX - inset);
- const safeZ = Math.max(0.5, island.radiusZ - inset);
- const nx = (x - island.center[0]) / safeX;
- const nz = (z - island.center[1]) / safeZ;
- return nx * nx + nz * nz <= 1;
-});
-
-// Street-level rendering uses one continuous valley shelf rather than the orbital
-// archipelago plates. Keep its collision outline beside the authored ground descriptor so
-// the rover never hits an invisible island boundary while still visibly on village grass.
-export const isOnVillageGround = (x, z, inset = 0) => {
- const safeX = Math.max(0.5, VILLAGE_GROUND.radiusX - inset);
- const safeZ = Math.max(0.5, VILLAGE_GROUND.radiusZ - inset);
- const nx = (x - VILLAGE_GROUND.center[0]) / safeX;
- const nz = (z - VILLAGE_GROUND.center[1]) / safeZ;
- return nx * nx + nz * nz <= 1;
-};
-
-export const isOnArchipelagoLink = (x, z, padding = 0) => ARCHIPELAGO_LINKS.some((link) => {
- const points = archipelagoLinkPoints(link);
- const radius = link.width / 2 + padding;
- for (let index = 1; index < points.length; index += 1) {
- if (pointNearSegment(x, z, points[index - 1], points[index], radius)) return true;
- }
- return false;
-});
-
-// Land parcels. `anchor` is the district's center on the ground; `w`/`d` are the static
-// footprint (x-width / z-depth) used by the invariant tests and the tinted ground pads.
-// `dynamic: true` marks data-driven grids (downtown/warehouse) whose extent grows with the
-// install — they're excluded from static-footprint checks. Anchors mirror the hand-tuned
-// pre-plan positions except: the voice beacon steps aside for the harbor avenue, and the
-// Data Harbor is new — piers over the bay, straight ahead of the default camera.
-export const PARCELS = {
- // noPad: the plaza paints its own sidewalk ring — no tinted ground pad.
- aiCore: { anchor: [0, 0, 0], w: 24, d: 24, noPad: true, label: 'AI CORE PLAZA' },
- downtown: { anchor: [0, 0, 0], w: 60, d: 60, dynamic: true, label: 'DOWNTOWN' },
- warehouse: { anchor: [0, 0, 30], w: 60, d: 60, dynamic: true, label: 'ARCHIVE DISTRICT' },
- backupVault: { anchor: [-34, 0, -10], w: 7, d: 7, label: 'BACKUP VAULT' },
- taskQueue: { anchor: [34, 0, -10], w: 8, d: 8, label: 'TASK QUEUE' },
- memory: { anchor: [-44, 0, -30], w: 22, d: 22, label: 'MEMORY QUARTER' },
- jira: { anchor: [-20, 0, -44], w: 18, d: 12, label: 'SPRINT YARD' },
- // Goal monuments + the artifact hall grow toward each other with enough earned
- // milestones (a pre-plan, visually-tolerated rarity) — footprints reflect typical installs.
- goals: { anchor: [30, 0, -40], w: 66, d: 5, label: 'GOAL MONUMENTS' },
- artifacts: { anchor: [44, 0, -28], w: 16, d: 14, label: 'HALL OF ACHIEVEMENTS' },
- // Stepped off the avenue centerline (was [0,0,-40]) so the plaza→harbor avenue runs clear.
- voice: { anchor: [9, 0, -38], w: 5, d: 5, label: 'VOICE BEACON' },
- productivity: { anchor: [-48, 0, 28], w: 10, d: 10, label: 'PRODUCTIVITY' },
- health: { anchor: [48, 0, 28], w: 8, d: 8, label: 'WELLNESS TOWER' },
- easterEggs: { anchor: [-46, 0, 40], w: 8, d: 10, label: 'QUIET CORNER' },
- // Over the water: a pier district between the shoreline and the federation horizon.
- dataHarbor: { anchor: [0, 0, -64], w: 40, d: 16, water: true, label: 'DATA HARBOR' },
-};
-
-export const PLAZA = { center: [0, 0, 0], radius: 12, sidewalkOuter: 14.5 };
-
-// Street-level cottage footprints. Rendering, rover collision, and camera avoidance all
-// share these envelopes so the cozy village is a physical place instead of set dressing
-// the player can ghost through. The central pavilion stays open; only its floating core
-// is solid so the rover can still circle between the benches and arches.
-export const VILLAGE_COLLIDERS = [
- { id: 'core', shape: 'circle', x: 0, z: 0, radius: 2.35, height: 5.2 },
- ...[
- 'memory', 'backupVault', 'taskQueue', 'warehouse', 'health', 'productivity',
- 'jira', 'easterEggs', 'goals', 'voice', 'artifacts', 'dataHarbor',
- ].map((parcelId) => {
- const [x, , z] = PARCELS[parcelId].anchor;
- return { id: parcelId, shape: 'box', x, z, halfWidth: 3.1, halfDepth: 2.6, height: 6.4 };
- }),
-];
-
-// Curved village lanes are the visual and play rhythm of street-level OpenWorld. The
-// broad heart loop keeps a landmark or garden entering the frame every few seconds; short
-// branches terminate at real PortOS destinations. Curves are rendered from this registry,
-// so art direction and vehicle route composition cannot drift apart.
-export const VILLAGE_ROUTES = [
- {
- id: 'heart-loop',
- kind: 'road',
- width: 5.4,
- closed: true,
- points: [[0, 36], [-16, 32], [-27, 18], [-29, 1], [-20, -14], [-5, -22], [13, -20], [28, -8], [29, 11], [19, 28]],
- },
- { id: 'harbor-lane', kind: 'road', width: 5.2, points: [[-5, -22], [0, -36], [3, -50], [0, -64]] },
- { id: 'memory-lane', kind: 'path', width: 3.8, points: [[-20, -14], [-31, -22], [-44, -30]] },
- { id: 'maker-lane', kind: 'path', width: 3.8, points: [[13, -20], [28, -26], [43, -28]] },
- { id: 'garden-lane', kind: 'path', width: 3.6, points: [[-16, 32], [-30, 34], [-47, 31]] },
- { id: 'wellness-lane', kind: 'path', width: 3.6, points: [[19, 28], [32, 30], [47, 28]] },
- { id: 'arrival-lane', kind: 'road', width: 5.4, points: [[0, 50], [0, 43], [0, 36]] },
-];
-
-// Managed apps become a little market around the Common in exploration mode. The first
-// ring reads from the main road; a staggered second ring lets larger installs keep their
-// identity without rebuilding the village around app count. Archived apps belong to the
-// Archive Lodge and are summarized there instead of occupying active market stalls.
-export const VILLAGE_APP_MARKET = {
- innerRadius: 12.8,
- outerRadius: 17.2,
- innerCapacity: 8,
- maxKiosks: 18,
- halfWidth: 1.28,
- halfDepth: 0.92,
- height: 3.25,
-};
-
-const APP_STATUS_ORDER = { online: 0, stopped: 1, not_started: 2, unknown: 3, not_found: 4 };
-
-export function computeVillageAppLayout(apps = []) {
- const active = (Array.isArray(apps) ? apps : [])
- .filter((app) => app?.id && !app.archived)
- .sort((a, b) => {
- const statusDelta = (APP_STATUS_ORDER[a.overallStatus] ?? 5) - (APP_STATUS_ORDER[b.overallStatus] ?? 5);
- if (statusDelta !== 0) return statusDelta;
- return String(a.name || a.id).localeCompare(String(b.name || b.id));
- })
- .slice(0, VILLAGE_APP_MARKET.maxKiosks);
-
- const positions = new Map();
- active.forEach((app, index) => {
- const outer = index >= VILLAGE_APP_MARKET.innerCapacity;
- const ringIndex = outer ? index - VILLAGE_APP_MARKET.innerCapacity : index;
- const ringCount = outer
- ? Math.max(1, active.length - VILLAGE_APP_MARKET.innerCapacity)
- : Math.min(active.length, VILLAGE_APP_MARKET.innerCapacity);
- const radius = outer ? VILLAGE_APP_MARKET.outerRadius : VILLAGE_APP_MARKET.innerRadius;
- const stagger = outer ? Math.PI / Math.max(3, ringCount) : 0;
- // Begin in the Common's front-right quarter so even a one-app install presents a
- // storefront on arrival instead of hiding its only kiosk behind the pavilion.
- const angle = (ringIndex / ringCount) * Math.PI * 2 + stagger + Math.PI / 4;
- const x = Math.cos(angle) * radius;
- const z = Math.sin(angle) * radius;
- positions.set(app.id, {
- x,
- z,
- yaw: Math.atan2(x, z),
- district: 'village-market',
- height: VILLAGE_APP_MARKET.height,
- halfWidth: VILLAGE_APP_MARKET.halfWidth,
- halfDepth: VILLAGE_APP_MARKET.halfDepth,
- compact: true,
- });
- });
- return positions;
-}
-
-const smoothstep = (edge0, edge1, value) => {
- const t = Math.max(0, Math.min(1, (value - edge0) / (edge1 - edge0)));
- return t * t * (3 - 2 * t);
-};
-
-// Pure height function shared by the terrain mesh, player grounding, camera, and rover
-// suspension. Broad rolls establish a toy-diorama silhouette; fine ripples are deliberately
-// small enough for a Kabsch-fitted chassis to reveal them without making the route bumpy.
-export function openWorldTerrainHeight(x, z) {
- let influence = 0;
- let seed = 0;
- for (const island of ARCHIPELAGO_ISLANDS) {
- const nx = (x - island.center[0]) / island.radiusX;
- const nz = (z - island.center[1]) / island.radiusZ;
- const radius = Math.hypot(nx, nz);
- if (radius >= 1) continue;
- const nextInfluence = smoothstep(1, 0.18, radius);
- if (nextInfluence > influence) {
- influence = nextInfluence;
- seed = island.seed;
- }
- }
- if (influence <= 0) return 0;
-
- const broad = Math.sin((x + seed * 0.01) * 0.075) * 0.28
- + Math.cos((z - seed * 0.006) * 0.09) * 0.22;
- const fine = Math.sin(x * 0.31 + z * 0.19) * 0.055;
- const arrivalHump = 0.48 * Math.exp(-((x * x) + ((z - 42) * (z - 42)) * 0.55) / 34);
- const harborDip = -0.28 * Math.exp(-((x * x) + ((z + 56) * (z + 56))) / 95);
-
- // Keep the immediate doorstep of every destination calm so cottages and live-data
- // monuments share a dependable foundation in both game and orbital modes.
- const nearestParcel = Object.values(PARCELS).reduce((nearest, parcel) => {
- const distance = Math.hypot(x - parcel.anchor[0], z - parcel.anchor[2]);
- return Math.min(nearest, distance);
- }, Infinity);
- const doorstep = smoothstep(3.5, 9, nearestParcel);
-
- return (broad + fine + arrivalHump + harborDip) * influence * (0.28 + doorstep * 0.72);
-}
-
-// Elevated transit loop — a closed ride through every quarter, rendered as a glowing tube
-// with trams orbiting it. District stops are DERIVED from their parcel anchors (pulled 10%
-// toward the city center so the track skims districts instead of impaling their monuments)
-// — move a parcel and its tram stop follows. The harbor-gate stop (on the shoreline, since
-// the track stays over land) is the one explicit point.
-const TRANSIT_Y = 9; // track height — above street props, below most rooftops
-const TRANSIT_STOP_PULL = 0.1;
-const districtStop = (id) => {
- const [x, , z] = PARCELS[id].anchor;
- return { id, point: [x * (1 - TRANSIT_STOP_PULL), TRANSIT_Y, z * (1 - TRANSIT_STOP_PULL)] };
-};
-export const TRANSIT = {
- y: TRANSIT_Y,
- stopPull: TRANSIT_STOP_PULL,
- stops: [
- districtStop('productivity'),
- districtStop('backupVault'),
- districtStop('memory'),
- districtStop('jira'),
- { id: 'harborGate', point: [0, TRANSIT_Y, WORLD.shorelineZ + 9] },
- districtStop('goals'),
- districtStop('artifacts'),
- districtStop('taskQueue'),
- districtStop('health'),
- districtStop('warehouse'),
- ],
- tramCount: 3,
- tramSpeed: 0.012, // loop fraction per second — a leisurely orbit (~80s per lap)
-};
-
-// True when a ground position sits in the bay (used by the skyline ring to skip silhouettes
-// that would otherwise stand in the water, and by the player controller to keep walking
-// players on land). `margin` extends the water zone toward land (positive = stricter).
-export const isInWater = (_x, z, margin = 0) => z < WORLD.shorelineZ + margin;
-
-// True where a ground-level player may stand: inside the continuous village shelf or on
-// an authored causeway. Flying players (above rooftop height) ignore this entirely.
-export const isWalkable = (x, z) => {
- // Pull the collision boundary slightly inside the decorative village rim so the
- // rover cannot balance over open water on its outside wheels. Causeways get a
- // small forgiving shoulder for analog controls and high-speed boost entries.
- return isOnVillageGround(x, z, 0.75) || isOnArchipelagoLink(x, z, 0.45);
-};
-
-// ---------------------------------------------------------------------------
-// Streets: ring road + spokes + the harbor avenue, as flat rotated rectangles.
-// ---------------------------------------------------------------------------
-
-const RING_RADIUS = 30; // octagonal ring road just outside the downtown grid
-const ROAD_WIDTH = 3.2;
-// Exported: the Data Harbor's pier gangway continues the avenue over the water, so the
-// two widths must agree or the shoreline joint shows a seam.
-export const AVENUE_WIDTH = 4.6;
-const SPOKE_CLEARANCE = 6; // stop a spoke this short of the district anchor
-
-// Static parcels that get a street spoke from the ring road. The harbor is served by the
-// avenue; downtown/warehouse sit inside/astride the ring; aiCore is the plaza itself.
-const SPOKE_PARCELS = [
- 'backupVault', 'taskQueue', 'memory', 'jira', 'goals',
- 'artifacts', 'productivity', 'health', 'easterEggs',
-];
-
-// A street segment is a centered rectangle: rotate a [length × width] quad by `angle`
-// (radians, around Y) at ground position [x, z].
-const segment = (x1, z1, x2, z2, width) => {
- const dx = x2 - x1;
- const dz = z2 - z1;
- return {
- x: (x1 + x2) / 2,
- z: (z1 + z2) / 2,
- length: Math.hypot(dx, dz),
- angle: Math.atan2(dz, dx),
- width,
- };
-};
-
-// The full street network, derived from the plan. Pure + deterministic; the component
-// merges every rectangle into one geometry, so count here is free.
-export function computeStreets() {
- const segments = [];
-
- // Octagonal ring road around downtown.
- const ringPoints = [];
- for (let k = 0; k < 8; k++) {
- const a = (k / 8) * Math.PI * 2 + Math.PI / 8; // flat edges face the compass directions
- ringPoints.push([Math.cos(a) * RING_RADIUS, Math.sin(a) * RING_RADIUS]);
- }
- for (let k = 0; k < 8; k++) {
- const [x1, z1] = ringPoints[k];
- const [x2, z2] = ringPoints[(k + 1) % 8];
- segments.push({ ...segment(x1, z1, x2, z2, ROAD_WIDTH), kind: 'ring' });
- }
-
- // Spokes: ring → each served district, stopping short of the anchor.
- const crosswalks = [];
- for (const id of SPOKE_PARCELS) {
- const [ax, , az] = PARCELS[id].anchor;
- const dist = Math.hypot(ax, az);
- if (dist <= RING_RADIUS + SPOKE_CLEARANCE) continue; // hugs the ring already
- const ux = ax / dist;
- const uz = az / dist;
- const inner = RING_RADIUS - ROAD_WIDTH / 2; // tuck under the ring edge — no gap at the joint
- const outer = dist - SPOKE_CLEARANCE;
- segments.push({ ...segment(ux * inner, uz * inner, ux * outer, uz * outer, ROAD_WIDTH), kind: 'spoke', to: id });
- // Crosswalk band where the spoke meets the ring.
- crosswalks.push({ x: ux * RING_RADIUS, z: uz * RING_RADIUS, angle: Math.atan2(uz, ux), length: ROAD_WIDTH * 1.4, width: 2.0 });
- }
-
- // The grand avenue: plaza edge → shoreline, straight up the city's axis to the harbor.
- segments.push({
- ...segment(0, -(PLAZA.radius - 1), 0, WORLD.shorelineZ + 1, AVENUE_WIDTH),
- kind: 'avenue',
- });
-
- // A short southern arrival lane gives the rover a deliberate place to enter the
- // world when the install has no app data yet. It connects the downtown ring to
- // the default drop-in area without cutting through the central plaza.
- segments.push({
- ...segment(0, PLAZA.sidewalkOuter + 15, 0, WORLD.landHalf + 12, AVENUE_WIDTH),
- kind: 'arrival',
- });
-
- return { segments, crosswalks, plazaRing: { inner: PLAZA.radius, outer: PLAZA.sidewalkOuter } };
-}
-
-// ---------------------------------------------------------------------------
-// Street props: lamp posts along every street, planting trees ringing the plaza.
-// ---------------------------------------------------------------------------
-
-const LAMP_SPACING = 11; // world units between lamp pairs along a street
-const LAMP_SIDE_OFFSET = 2.6; // lateral distance from the street centerline
-
-// Lamp + tree positions for a given street layout. `density` scales counts (quality
-// presets): 0 → no props, 1 → full. Deterministic — same input, same town furniture.
-export function computeStreetProps(streets, density = 1) {
- const lamps = [];
- const trees = [];
- if (!streets || density <= 0) return { lamps, trees };
-
- const spacing = LAMP_SPACING / Math.min(1.5, Math.max(0.25, density));
- for (const seg of streets.segments) {
- const count = Math.floor(seg.length / spacing);
- const cos = Math.cos(seg.angle);
- const sin = Math.sin(seg.angle);
- for (let i = 0; i < count; i++) {
- // March along the segment; alternate which side of the street the lamp stands on.
- const t = (i + 0.5) / count - 0.5;
- const side = i % 2 === 0 ? 1 : -1;
- const along = t * seg.length;
- const off = side * (seg.width / 2 + LAMP_SIDE_OFFSET - 1);
- lamps.push({
- x: seg.x + cos * along - sin * off,
- z: seg.z + sin * along + cos * off,
- });
- }
- }
-
- // Trees around the plaza sidewalk, skipping the avenue mouth (north) so the walkway
- // to the harbor stays open. The stable scale variation keeps the grove from reading
- // as a repeated ring of identical icons.
- const treeCount = Math.round(10 * Math.min(1.5, density));
- const treeRadius = (streets.plazaRing.inner + streets.plazaRing.outer) / 2 + 0.6;
- for (let i = 0; i < treeCount; i++) {
- const a = (i / treeCount) * Math.PI * 2 + Math.PI / 2; // start at the south point
- const x = Math.cos(a) * treeRadius;
- const z = Math.sin(a) * treeRadius;
- if (z < -treeRadius * 0.86) continue; // the avenue mouth
- trees.push({ x, z, seed: i, scale: 0.86 + ((i * 7) % 5) * 0.07 });
- }
-
- return { lamps, trees };
-}
diff --git a/client/src/utils/openWorldPlan.test.js b/client/src/utils/openWorldPlan.test.js
deleted file mode 100644
index 3846f93cd5..0000000000
--- a/client/src/utils/openWorldPlan.test.js
+++ /dev/null
@@ -1,277 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import {
- ARCHIPELAGO_ISLANDS,
- ARCHIPELAGO_LINKS,
- WORLD,
- PARCELS,
- PLAZA,
- TRANSIT,
- VILLAGE_APP_MARKET,
- archipelagoLinkPoints,
- computeVillageAppLayout,
- isInWater,
- isOnArchipelagoIsland,
- isOnVillageGround,
- isWalkable,
- computeStreets,
- computeStreetProps,
-} from './openWorldPlan';
-
-const staticParcels = Object.entries(PARCELS).filter(([, p]) => !p.dynamic);
-
-describe('openWorldPlan PARCELS', () => {
- it('keeps every parcel inside the world bound', () => {
- for (const [id, p] of Object.entries(PARCELS)) {
- expect(Math.abs(p.anchor[0]) + p.w / 2, id).toBeLessThanOrEqual(WORLD.bound);
- expect(Math.abs(p.anchor[2]) + p.d / 2, id).toBeLessThanOrEqual(WORLD.bound);
- }
- });
-
- it('keeps land parcels on land and water parcels in the bay', () => {
- for (const [id, p] of staticParcels) {
- const nearEdge = p.anchor[2] - p.d / 2; // most-northern (bay-ward) edge
- if (p.water) {
- expect(isInWater(p.anchor[0], p.anchor[2]), id).toBe(true);
- } else {
- expect(nearEdge, id).toBeGreaterThan(WORLD.shorelineZ);
- }
- }
- });
-
- it('keeps static parcels (except the plaza itself) clear of the AI Core plaza', () => {
- for (const [id, p] of staticParcels) {
- if (id === 'aiCore') continue;
- const dist = Math.hypot(p.anchor[0], p.anchor[2]);
- expect(dist - Math.max(p.w, p.d) / 2, id).toBeGreaterThanOrEqual(PLAZA.radius - 0.01);
- }
- });
-
- it('keeps the new harbor parcel clear of every land parcel', () => {
- const harbor = PARCELS.dataHarbor;
- for (const [id, p] of staticParcels) {
- if (id === 'dataHarbor') continue;
- const xOverlap = Math.abs(harbor.anchor[0] - p.anchor[0]) < (harbor.w + p.w) / 2;
- const zOverlap = Math.abs(harbor.anchor[2] - p.anchor[2]) < (harbor.d + p.d) / 2;
- expect(xOverlap && zOverlap, `dataHarbor vs ${id}`).toBe(false);
- }
- });
-
- it('every parcel carries an anchor, footprint, and label', () => {
- for (const [id, p] of Object.entries(PARCELS)) {
- expect(p.anchor, id).toHaveLength(3);
- expect(p.w, id).toBeGreaterThan(0);
- expect(p.d, id).toBeGreaterThan(0);
- expect(typeof p.label, id).toBe('string');
- }
- });
-});
-
-describe('isInWater', () => {
- it('classifies the bay vs land around the shoreline', () => {
- expect(isInWater(0, WORLD.shorelineZ - 1)).toBe(true);
- expect(isInWater(0, WORLD.shorelineZ + 1)).toBe(false);
- expect(isInWater(0, 0)).toBe(false);
- });
-
- it('margin extends the water zone toward land', () => {
- expect(isInWater(0, WORLD.shorelineZ + 2, 4)).toBe(true);
- expect(isInWater(0, WORLD.shorelineZ + 6, 4)).toBe(false);
- });
-});
-
-describe('isWalkable', () => {
- it('allows the continuous village shelf and connected causeways while blocking open water', () => {
- ARCHIPELAGO_ISLANDS.forEach((island) => {
- expect(isWalkable(island.center[0], island.center[1]), island.id).toBe(true);
- expect(isOnArchipelagoIsland(island.center[0], island.center[1]), island.id).toBe(true);
- });
- ARCHIPELAGO_LINKS.forEach((link) => {
- const points = archipelagoLinkPoints(link);
- for (let index = 1; index < points.length; index += 1) {
- const [x1, z1] = points[index - 1];
- const [x2, z2] = points[index];
- expect(isWalkable((x1 + x2) / 2, (z1 + z2) / 2), link.id).toBe(true);
- }
- });
- // The close game renders one valley outline, including grass between the named
- // orbital islands. That visible shelf must not contain an invisible collision wall.
- expect(isOnArchipelagoIsland(68, 0, 0.75)).toBe(false);
- expect(isOnVillageGround(68, 0, 0.75)).toBe(true);
- expect(isWalkable(68, 0)).toBe(true);
- expect(isWalkable(78, -58)).toBe(false);
- expect(isWalkable(-75, 0)).toBe(false);
- });
-
- it('keeps every island inside the hard world bound', () => {
- ARCHIPELAGO_ISLANDS.forEach((island) => {
- expect(Math.abs(island.center[0]) + island.radiusX, island.id).toBeLessThanOrEqual(WORLD.bound);
- expect(Math.abs(island.center[1]) + island.radiusZ, island.id).toBeLessThanOrEqual(WORLD.bound);
- });
- });
-});
-
-describe('computeVillageAppLayout', () => {
- it('places active apps in compact market rings and leaves archived apps at the lodge', () => {
- const apps = [
- { id: 'stopped', name: 'Stopped', overallStatus: 'stopped' },
- { id: 'online', name: 'Online', overallStatus: 'online' },
- { id: 'archived', name: 'Archived', archived: true },
- ];
- const positions = computeVillageAppLayout(apps);
-
- expect([...positions.keys()]).toEqual(['online', 'stopped']);
- expect(positions.has('archived')).toBe(false);
- for (const position of positions.values()) {
- expect(position.compact).toBe(true);
- expect(position.halfWidth).toBe(VILLAGE_APP_MARKET.halfWidth);
- expect(position.halfDepth).toBe(VILLAGE_APP_MARKET.halfDepth);
- expect(Math.hypot(position.x, position.z)).toBeCloseTo(VILLAGE_APP_MARKET.innerRadius, 6);
- }
- });
-
- it('caps stalls deterministically for very large installs', () => {
- const apps = Array.from({ length: VILLAGE_APP_MARKET.maxKiosks + 5 }, (_, index) => ({
- id: `app-${index}`,
- name: `App ${index}`,
- overallStatus: 'online',
- }));
- const first = computeVillageAppLayout(apps);
- const second = computeVillageAppLayout([...apps].reverse());
-
- expect(first.size).toBe(VILLAGE_APP_MARKET.maxKiosks);
- expect([...first]).toEqual([...second]);
- });
-});
-
-describe('TRANSIT', () => {
- it('keeps the loop track on land, above the streets, below rooftop scale', () => {
- expect(TRANSIT.y).toBeGreaterThan(6);
- expect(TRANSIT.y).toBeLessThan(14);
- for (const stop of TRANSIT.stops) {
- expect(stop.point[1], stop.id).toBe(TRANSIT.y);
- expect(isInWater(stop.point[0], stop.point[2]), stop.id).toBe(false);
- expect(Math.abs(stop.point[0]), stop.id).toBeLessThanOrEqual(WORLD.bound);
- expect(Math.abs(stop.point[2]), stop.id).toBeLessThanOrEqual(WORLD.bound);
- }
- });
-
- it('has unique stop ids', () => {
- const ids = TRANSIT.stops.map((s) => s.id);
- expect(new Set(ids).size).toBe(ids.length);
- });
-
- it('derives district stops from their parcel anchors — moving a parcel moves its stop', () => {
- for (const stop of TRANSIT.stops) {
- const parcel = PARCELS[stop.id];
- if (!parcel) continue; // harborGate / warehouse promenade are explicit
- expect(stop.point[0], stop.id).toBeCloseTo(parcel.anchor[0] * (1 - TRANSIT.stopPull), 6);
- expect(stop.point[2], stop.id).toBeCloseTo(parcel.anchor[2] * (1 - TRANSIT.stopPull), 6);
- }
- // The loop serves most quarters: at least 7 stops are parcel-derived.
- expect(TRANSIT.stops.filter((s) => PARCELS[s.id]).length).toBeGreaterThanOrEqual(7);
- });
-});
-
-describe('computeStreets', () => {
- const streets = computeStreets();
-
- it('is deterministic', () => {
- expect(computeStreets()).toEqual(streets);
- });
-
- it('builds a closed 8-segment ring road', () => {
- const ring = streets.segments.filter((s) => s.kind === 'ring');
- expect(ring).toHaveLength(8);
- for (const seg of ring) {
- // Every ring segment's center sits on the ring radius (chord midpoint, slightly inside).
- expect(Math.hypot(seg.x, seg.z)).toBeGreaterThan(20);
- expect(Math.hypot(seg.x, seg.z)).toBeLessThan(31);
- }
- });
-
- it('runs a spoke toward every outlying served district', () => {
- const spokeTargets = streets.segments.filter((s) => s.kind === 'spoke').map((s) => s.to);
- for (const id of ['memory', 'jira', 'goals', 'artifacts', 'productivity', 'health', 'easterEggs']) {
- expect(spokeTargets, id).toContain(id);
- }
- // Each spoke ends short of its district anchor (clearance) and starts at the ring.
- for (const seg of streets.segments.filter((s) => s.kind === 'spoke')) {
- const [ax, , az] = PARCELS[seg.to].anchor;
- const anchorDist = Math.hypot(ax, az);
- const segFar = Math.hypot(seg.x, seg.z) + seg.length / 2;
- expect(segFar, seg.to).toBeLessThan(anchorDist);
- }
- });
-
- it('runs the avenue from the plaza to the shoreline without entering the water', () => {
- const avenue = streets.segments.find((s) => s.kind === 'avenue');
- expect(avenue).toBeTruthy();
- expect(avenue.x).toBe(0);
- const farEdge = avenue.z - avenue.length / 2;
- expect(farEdge).toBeGreaterThanOrEqual(WORLD.shorelineZ);
- });
-
- it('provides a southern arrival lane for the default rover drop-in', () => {
- const arrival = streets.segments.find((s) => s.kind === 'arrival');
- expect(arrival).toBeTruthy();
- expect(arrival.x).toBe(0);
- expect(arrival.z + arrival.length / 2).toBeGreaterThan(WORLD.landHalf);
- expect(arrival.z - arrival.length / 2).toBeGreaterThan(PLAZA.sidewalkOuter);
- });
-
- it('keeps every street on land', () => {
- for (const seg of streets.segments) {
- const cos = Math.cos(seg.angle);
- const sin = Math.sin(seg.angle);
- for (const t of [-0.5, 0, 0.5]) {
- const z = seg.z + sin * t * seg.length;
- expect(isInWater(seg.x + cos * t * seg.length, z), seg.kind).toBe(false);
- }
- }
- });
-
- it('places a crosswalk where each spoke meets the ring', () => {
- const spokes = streets.segments.filter((s) => s.kind === 'spoke');
- expect(streets.crosswalks).toHaveLength(spokes.length);
- });
-});
-
-describe('computeStreetProps', () => {
- const streets = computeStreets();
-
- it('is deterministic and density-scaled', () => {
- const full = computeStreetProps(streets, 1);
- expect(computeStreetProps(streets, 1)).toEqual(full);
- const half = computeStreetProps(streets, 0.5);
- expect(half.lamps.length).toBeLessThan(full.lamps.length);
- expect(half.lamps.length).toBeGreaterThan(0);
- });
-
- it('returns no props at zero density or missing streets', () => {
- expect(computeStreetProps(streets, 0)).toEqual({ lamps: [], trees: [] });
- expect(computeStreetProps(null, 1)).toEqual({ lamps: [], trees: [] });
- });
-
- it('keeps lamps on land and inside the world bound', () => {
- const { lamps } = computeStreetProps(streets, 1.5);
- expect(lamps.length).toBeGreaterThan(10);
- for (const lamp of lamps) {
- expect(isInWater(lamp.x, lamp.z)).toBe(false);
- expect(Math.abs(lamp.x)).toBeLessThanOrEqual(WORLD.bound);
- expect(Math.abs(lamp.z)).toBeLessThanOrEqual(WORLD.bound);
- }
- });
-
- it('rings the plaza with trees but leaves the avenue mouth open', () => {
- const { trees } = computeStreetProps(streets, 1);
- expect(trees.length).toBeGreaterThan(5);
- for (const tree of trees) {
- const r = Math.hypot(tree.x, tree.z);
- expect(r).toBeGreaterThan(PLAZA.radius);
- // No tree blocks the avenue (north sector around x=0, z negative).
- const onAvenue = Math.abs(tree.x) < 3 && tree.z < -PLAZA.radius * 0.8;
- expect(onAvenue).toBe(false);
- }
- });
-});
-// @vitest-environment node
diff --git a/client/src/utils/openWorldPlayerRig.js b/client/src/utils/openWorldPlayerRig.js
deleted file mode 100644
index 194763e2d4..0000000000
--- a/client/src/utils/openWorldPlayerRig.js
+++ /dev/null
@@ -1,466 +0,0 @@
-// Pure math for OpenWorld's exploration-mode player rig: the third-person follow camera
-// (spherical boom behind the character with building-aware shortening), frame-rate-
-// independent damping, shortest-arc facing, and the avatar's animation-state classifier.
-// PlayerController owns the THREE.Vector3 plumbing; every formula lives here on plain
-// `{x, y, z}` objects so the whole rig is node-testable (no three.js / React imports).
-
-// Convention (matches PlayerController): forward at yaw is (-sin(yaw), 0, -cos(yaw));
-// the camera hangs BEHIND the character at (+sin(yaw), 0, +cos(yaw)) scaled by the boom.
-
-// Shared rig dimensions — the controller, the avatar, and the boom collision all read
-// these (restating them at call sites is how walk collision and camera collision drift).
-export const EYE_HEIGHT = 1.6;
-// Camera boom padding stays deliberately generous so the view does not clip into a
-// facade. Player movement uses the shape-aware colliders below instead of this radius.
-export const BUILDING_COLLISION_RADIUS = 3.5;
-export const PLAYER_COLLISION_RADIUS = 0.55;
-export const DEFAULT_SPAWN_Z = 48;
-
-export const THIRD_PERSON = {
- boom: 10.5, // intimate chase framing: the rover is a character, not a map cursor
- shoulder: 0.72, // slight lateral offset keeps the next cottage visible past the cab
- height: 3.1, // low enough for fences and trees to create useful occlusion
- isometricYaw: Math.PI / 18, // nearly aligned with the arrival lane and village gate
- isometricPitch: 0.36, // cozy diorama angle without flattening the street into a map
- fov: 42, // restrained perspective keeps nearby props substantial
- minPitch: -0.45, // looking up from under the character — floor-limited
- maxPitch: 1.15, // looking down over the character
- minCamY: 0.6, // the camera never dips into the pavement
- lookAhead: 3.2, // compose the rover against the next bend and doorway
- lookHeight: 1.05, // aim just over the roof so village silhouettes fill the frame
- camDampRate: 10, // close camera needs a little less float through tight paths
- lookDampRate: 14, // aim smoothing stays tighter than position
-};
-
-// Scroll-wheel boom zoom: a multiplier over THIRD_PERSON.boom. Steps are multiplicative
-// (exp of the wheel delta) so each notch feels equally sized at both ends of the range,
-// the way pinch-zoom does.
-export const BOOM_ZOOM = {
- min: 0.45,
- max: 2.1,
- wheelRate: 0.0011, // exp factor per unit of WheelEvent.deltaY
-};
-
-export const nextBoomZoom = (current, deltaY) =>
- Math.min(BOOM_ZOOM.max, Math.max(BOOM_ZOOM.min, current * Math.exp(deltaY * BOOM_ZOOM.wheelRate)));
-
-// Arcade vehicle tuning for the default rover. It deliberately stops short of a full
-// rigid-body simulation: OpenWorld needs a dependable, low-latency toy-car feel on a
-// dashboard canvas, while still borrowing the important reference-game cues — ramped
-// acceleration, a real brake, reverse, speed-weighted steering, and a little drift.
-export const VEHICLE = {
- bodyLength: 2.5,
- bodyWidth: 1.46,
- maxSpeed: 24,
- boostMaxSpeed: 38,
- reverseMaxSpeed: 10,
- acceleration: 22,
- boostAcceleration: 31,
- coastDeceleration: 5.5,
- brakeDeceleration: 34,
- turnRate: 2.8,
- maxWheelAngle: 0.62,
- steeringResponse: 9,
-};
-
-// The visible wheels extend a little beyond the body box. Keep that small envelope in
-// one place so the rover cannot visually overlap a wall without bringing back the old
-// multi-unit empty cushion around every building.
-export const VEHICLE_COLLISION = {
- halfWidth: 0.92,
- halfLength: 1.36,
-};
-
-const centroid = (points) => {
- const total = points.reduce((sum, point) => ({
- x: sum.x + point.x,
- y: sum.y + point.y,
- z: sum.z + point.z,
- }), { x: 0, y: 0, z: 0 });
- const count = Math.max(1, points.length);
- return { x: total.x / count, y: total.y / count, z: total.z / count };
-};
-
-const rotateByQuaternion = (point, quaternion) => {
- const { x, y, z, w } = quaternion;
- const ix = w * point.x + y * point.z - z * point.y;
- const iy = w * point.y + z * point.x - x * point.z;
- const iz = w * point.z + x * point.y - y * point.x;
- const iw = -x * point.x - y * point.y - z * point.z;
- return {
- x: ix * w + iw * -x + iy * -z - iz * -y,
- y: iy * w + iw * -y + iz * -x - ix * -z,
- z: iz * w + iw * -z + ix * -y - iy * -x,
- };
-};
-
-// Horn's quaternion form of the Kabsch fit. Given matching point clouds, return the
-// one rigid transform that best maps `reference` onto `target`. The suspension uses it
-// to let four contradictory wheel heights agree on one rigid chassis pose—roll, pitch,
-// and ride-height emerge from the fit instead of being faked from steering input.
-export function solveKabschTransform(reference, target) {
- if (!Array.isArray(reference) || !Array.isArray(target) || reference.length !== target.length || reference.length < 3) {
- return { rotation: { x: 0, y: 0, z: 0, w: 1 }, translation: { x: 0, y: 0, z: 0 }, residual: 0 };
- }
- const fromCenter = centroid(reference);
- const toCenter = centroid(target);
- const covariance = Array.from({ length: 3 }, () => [0, 0, 0]);
- reference.forEach((point, index) => {
- const p = [point.x - fromCenter.x, point.y - fromCenter.y, point.z - fromCenter.z];
- const q = [target[index].x - toCenter.x, target[index].y - toCenter.y, target[index].z - toCenter.z];
- for (let row = 0; row < 3; row += 1) {
- for (let column = 0; column < 3; column += 1) covariance[row][column] += p[row] * q[column];
- }
- });
- const [[sxx, sxy, sxz], [syx, syy, syz], [szx, szy, szz]] = covariance;
- const horn = [
- [sxx + syy + szz, syz - szy, szx - sxz, sxy - syx],
- [syz - szy, sxx - syy - szz, sxy + syx, szx + sxz],
- [szx - sxz, sxy + syx, -sxx + syy - szz, syz + szy],
- [sxy - syx, szx + sxz, syz + szy, -sxx - syy + szz],
- ];
- // Shift the symmetric matrix above zero so power iteration finds its greatest
- // algebraic eigenvalue rather than whichever signed eigenvalue has greatest magnitude.
- const shift = Math.max(...horn.map((row) => row.reduce((sum, value) => sum + Math.abs(value), 0))) + 1e-9;
- let vector = [1, 0, 0, 0];
- for (let iteration = 0; iteration < 18; iteration += 1) {
- const next = horn.map((row, rowIndex) => row.reduce((sum, value, column) => sum + value * vector[column], shift * vector[rowIndex]));
- const length = Math.hypot(...next) || 1;
- vector = next.map((value) => value / length);
- }
- const rotation = { w: vector[0], x: vector[1], y: vector[2], z: vector[3] };
- const rotatedCenter = rotateByQuaternion(fromCenter, rotation);
- const translation = {
- x: toCenter.x - rotatedCenter.x,
- y: toCenter.y - rotatedCenter.y,
- z: toCenter.z - rotatedCenter.z,
- };
- const residual = Math.sqrt(reference.reduce((sum, point, index) => {
- const rotated = rotateByQuaternion(point, rotation);
- const dx = rotated.x + translation.x - target[index].x;
- const dy = rotated.y + translation.y - target[index].y;
- const dz = rotated.z + translation.z - target[index].z;
- return sum + dx * dx + dy * dy + dz * dz;
- }, 0) / reference.length);
- return { rotation, translation, residual };
-}
-
-export function solveVehicleSuspensionPose({
- x = 0,
- z = 0,
- heading = 0,
- centerHeight = 0,
- halfWidth = VEHICLE.bodyWidth * 0.57,
- halfLength = VEHICLE.bodyLength * 0.31,
- heightAt = () => centerHeight,
-} = {}) {
- const reference = [
- { x: -halfWidth, y: 0, z: halfLength },
- { x: halfWidth, y: 0, z: halfLength },
- { x: -halfWidth, y: 0, z: -halfLength },
- { x: halfWidth, y: 0, z: -halfLength },
- ];
- const cosine = Math.cos(heading);
- const sine = Math.sin(heading);
- const wheelOffsets = reference.map((mount) => {
- const worldX = x + mount.x * cosine + mount.z * sine;
- const worldZ = z - mount.x * sine + mount.z * cosine;
- return heightAt(worldX, worldZ) - centerHeight;
- });
- const target = reference.map((mount, index) => ({ ...mount, y: wheelOffsets[index] }));
- const pose = solveKabschTransform(reference, target);
- // The rigid chassis pose already accounts for the shared slope under all four mounts.
- // Per-wheel travel is only the vertical residual that the best-fit plane could not
- // explain. Applying the full wheel offset again would double the slope: uphill tires
- // float while downhill tires sink even though the chassis is already tilted correctly.
- const wheelTravel = reference.map((mount, index) => {
- const fitted = rotateByQuaternion(mount, pose.rotation);
- return wheelOffsets[index] - (fitted.y + pose.translation.y);
- });
- return { ...pose, wheelOffsets, wheelTravel };
-}
-
-export const clampPitch = (pitch) =>
- Math.min(THIRD_PERSON.maxPitch, Math.max(THIRD_PERSON.minPitch, pitch));
-
-// Desired third-person camera + aim point for a rig pose. Pure — collision is applied
-// separately via resolveBoom so callers can damp toward the resolved point.
-export function thirdPersonCamera({
- pos,
- yaw,
- pitch,
- boom = THIRD_PERSON.boom,
- pitchOffset = 0,
-}) {
- const p = clampPitch(pitch + pitchOffset);
- const back = boom * Math.cos(p);
- const sinYaw = Math.sin(yaw);
- const cosYaw = Math.cos(yaw);
- // Right vector at this yaw (for the shoulder offset).
- const rightX = cosYaw;
- const rightZ = -sinYaw;
- return {
- camera: {
- x: pos.x + sinYaw * back + rightX * THIRD_PERSON.shoulder,
- y: Math.max(THIRD_PERSON.minCamY, pos.y + THIRD_PERSON.height + boom * Math.sin(p)),
- z: pos.z + cosYaw * back + rightZ * THIRD_PERSON.shoulder,
- },
- lookAt: {
- x: pos.x - sinYaw * THIRD_PERSON.lookAhead,
- y: pos.y + THIRD_PERSON.lookHeight,
- z: pos.z - cosYaw * THIRD_PERSON.lookAhead,
- },
- };
-}
-
-// True when a camera point lands inside a building safety cylinder. The camera keeps a
-// simpler generous envelope than the movement solver so the boom does not clip a facade.
-const insideBuilding = (point, building, radius) =>
- point.y < (building.height ?? 4) + 0.5
- && Math.hypot(point.x - building.x, point.z - building.z) < radius;
-
-// Walk the camera in toward the aim anchor until it clears every building safety cylinder,
-// returning `{ t, point }` — the boom fraction and the resolved camera position (so the
-// caller never re-derives the lerp). "Collision-aware enough" — a sampled pull-in, not a
-// raycast. `buildings` is an array or any iterable of { x, z, height }; pass a memoized
-// array on hot paths (an iterable is re-collected per call).
-export function resolveBoom({ anchor, camera, buildings, radius = BUILDING_COLLISION_RADIUS }) {
- const list = Array.isArray(buildings) ? buildings : buildings ? [...buildings] : [];
- const at = (t) => ({
- x: anchor.x + (camera.x - anchor.x) * t,
- y: anchor.y + (camera.y - anchor.y) * t,
- z: anchor.z + (camera.z - anchor.z) * t,
- });
- if (list.length === 0) return { t: 1, point: at(1) };
- const steps = [1, 0.85, 0.7, 0.55, 0.4, 0.3];
- for (const t of steps) {
- const point = at(t);
- if (!list.some((b) => insideBuilding(point, b, radius))) return { t, point };
- }
- return { t: 0.25, point: at(0.25) };
-}
-
-const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
-
-const circleIntersectsBox = (point, collider, radius) => {
- const halfWidth = Math.max(0, collider.halfWidth ?? 0);
- const halfDepth = Math.max(0, collider.halfDepth ?? 0);
- const closestX = clamp(point.x, collider.x - halfWidth, collider.x + halfWidth);
- const closestZ = clamp(point.z, collider.z - halfDepth, collider.z + halfDepth);
- const dx = point.x - closestX;
- const dz = point.z - closestZ;
- return dx * dx + dz * dz < radius * radius;
-};
-
-const vehicleAxes = (heading) => ({
- right: { x: Math.cos(heading), z: -Math.sin(heading) },
- forward: { x: -Math.sin(heading), z: -Math.cos(heading) },
-});
-
-const vehicleIntersectsCircle = (point, collider, body) => {
- const { right, forward } = vehicleAxes(body.heading ?? 0);
- const dx = collider.x - point.x;
- const dz = collider.z - point.z;
- const localX = dx * right.x + dz * right.z;
- const localZ = dx * forward.x + dz * forward.z;
- const closestX = clamp(localX, -body.halfWidth, body.halfWidth);
- const closestZ = clamp(localZ, -body.halfLength, body.halfLength);
- const offsetX = localX - closestX;
- const offsetZ = localZ - closestZ;
- return offsetX * offsetX + offsetZ * offsetZ < (collider.radius ?? 0) ** 2;
-};
-
-const vehicleIntersectsBox = (point, collider, body) => {
- const { right, forward } = vehicleAxes(body.heading ?? 0);
- const axes = [
- { x: 1, z: 0 },
- { x: 0, z: 1 },
- right,
- forward,
- ];
- const dx = collider.x - point.x;
- const dz = collider.z - point.z;
- const halfWidth = Math.max(0, collider.halfWidth ?? 0);
- const halfDepth = Math.max(0, collider.halfDepth ?? 0);
-
- return axes.every((axis) => {
- const distance = Math.abs(dx * axis.x + dz * axis.z);
- const vehicleProjection = Math.abs(axis.x * right.x + axis.z * right.z) * body.halfWidth
- + Math.abs(axis.x * forward.x + axis.z * forward.z) * body.halfLength;
- const colliderProjection = Math.abs(axis.x) * halfWidth + Math.abs(axis.z) * halfDepth;
- return distance < vehicleProjection + colliderProjection;
- });
-};
-
-const bodyIntersects = (point, colliders, body) => {
- const bodyType = body?.type || 'circle';
- if (bodyType === 'vehicle') {
- return colliders.some((collider) => {
- if (!Number.isFinite(collider?.x) || !Number.isFinite(collider?.z)) return false;
- return collider.shape === 'circle'
- ? vehicleIntersectsCircle(point, collider, body)
- : vehicleIntersectsBox(point, collider, body);
- });
- }
-
- const radius = Math.max(0, body?.radius ?? PLAYER_COLLISION_RADIUS);
- return colliders.some((collider) => {
- if (!Number.isFinite(collider?.x) || !Number.isFinite(collider?.z)) return false;
- if (collider.shape === 'circle') {
- const combinedRadius = radius + Math.max(0, collider.radius ?? 0);
- const dx = point.x - collider.x;
- const dz = point.z - collider.z;
- return dx * dx + dz * dz < combinedRadius * combinedRadius;
- }
- return circleIntersectsBox(point, collider, radius);
- });
-};
-
-// Move a player body through static 2D colliders. Each world axis is swept in small
-// increments, then the first colliding increment is binary-searched to the contact
-// point. Resolving X and Z independently gives the familiar arcade-game wall slide,
-// while the sweep prevents a fast rover frame from tunneling through a pylon.
-export function moveWithCollisions({
- position,
- displacement,
- colliders = [],
- body = { type: 'circle', radius: PLAYER_COLLISION_RADIUS },
- maxSampleDistance = 0.25,
-}) {
- const current = { x: position?.x ?? 0, z: position?.z ?? 0 };
- const shapes = Array.isArray(colliders) ? colliders : [];
- const stepDistance = Math.max(0.01, Number.isFinite(maxSampleDistance) ? maxSampleDistance : 0.25);
- const blockedAxes = { x: false, z: false };
- let blocked = false;
-
- const moveAxis = (axis, amount) => {
- if (!Number.isFinite(amount) || amount === 0) return;
- const sampleCount = Math.max(1, Math.ceil(Math.abs(amount) / stepDistance));
- const sample = amount / sampleCount;
-
- for (let index = 0; index < sampleCount; index += 1) {
- const start = current[axis];
- const end = start + sample;
- const candidate = { x: current.x, z: current.z };
- candidate[axis] = end;
- if (!bodyIntersects(candidate, shapes, body)) {
- current[axis] = end;
- continue;
- }
-
- let safe = 0;
- let contact = 1;
- for (let iteration = 0; iteration < 10; iteration += 1) {
- const midpoint = (safe + contact) * 0.5;
- const probe = { x: current.x, z: current.z };
- probe[axis] = start + sample * midpoint;
- if (bodyIntersects(probe, shapes, body)) contact = midpoint;
- else safe = midpoint;
- }
- current[axis] = start + sample * safe;
- blocked = true;
- blockedAxes[axis] = true;
- return;
- }
- };
-
- moveAxis('x', displacement?.x ?? 0);
- moveAxis('z', displacement?.z ?? 0);
-
- return { ...current, blocked, blockedAxes };
-}
-
-// Frame-rate-independent damping factor: lerp by this each frame and the closure rate
-// stays constant whether the frame took 4ms or 40ms.
-export const dampFactor = (rate, delta) => 1 - Math.exp(-rate * Math.max(0, delta));
-
-const approach = (value, target, distance) => {
- if (value < target) return Math.min(target, value + distance);
- if (value > target) return Math.max(target, value - distance);
- return target;
-};
-
-// Advance the rover by one frame. Keeping this pure makes the handling tunable without
-// tying the math to React or Three.js, and gives the controller one stable contract for
-// keyboard, touch, and future gamepad inputs.
-export function stepVehicle({
- speed = 0,
- heading = 0,
- wheelAngle = 0,
- throttle = 0,
- steer = 0,
- boost = false,
- brake = false,
- delta = 0.016,
-}) {
- const dt = Math.min(0.05, Math.max(0, delta));
- const gas = Math.max(-1, Math.min(1, throttle));
- const steering = Math.max(-1, Math.min(1, steer));
- const targetLimit = gas < 0
- ? VEHICLE.reverseMaxSpeed
- : boost ? VEHICLE.boostMaxSpeed : VEHICLE.maxSpeed;
- const targetSpeed = brake ? 0 : gas * targetLimit;
- const changingDirection = speed !== 0 && targetSpeed !== 0 && Math.sign(speed) !== Math.sign(targetSpeed);
- const response = brake || changingDirection
- ? VEHICLE.brakeDeceleration
- : Math.abs(gas) > 0.01 ? (boost && gas > 0 ? VEHICLE.boostAcceleration : VEHICLE.acceleration) : VEHICLE.coastDeceleration;
- const nextSpeed = approach(speed, targetSpeed, response * dt);
- const targetWheelAngle = steering * VEHICLE.maxWheelAngle;
- const nextWheelAngle = wheelAngle + (targetWheelAngle - wheelAngle) * dampFactor(VEHICLE.steeringResponse, dt);
- const speedRatio = Math.min(1, Math.abs(nextSpeed) / VEHICLE.boostMaxSpeed);
- const reverseFactor = nextSpeed < -0.05 ? -1 : 1;
- const nextHeading = heading - nextWheelAngle * speedRatio * VEHICLE.turnRate * dt * reverseFactor;
- const forwardX = -Math.sin(nextHeading);
- const forwardZ = -Math.cos(nextHeading);
-
- return {
- speed: nextSpeed,
- heading: nextHeading,
- wheelAngle: nextWheelAngle,
- speedRatio,
- // `skid` is intentionally a visual signal, not a second physics state. It lets the
- // rover lean into a hard turn and gives the player feedback before a full tire-trail
- // system exists.
- skid: Math.min(1, Math.abs(nextWheelAngle / VEHICLE.maxWheelAngle) * speedRatio),
- displacement: {
- x: forwardX * nextSpeed * dt,
- z: forwardZ * nextSpeed * dt,
- },
- };
-}
-
-// Shortest-arc angular lerp — never spins the long way around ±π.
-export function dampAngle(current, target, factor) {
- let diff = (target - current) % (Math.PI * 2);
- if (diff > Math.PI) diff -= Math.PI * 2;
- if (diff < -Math.PI) diff += Math.PI * 2;
- return current + diff * factor;
-}
-
-// The facing angle of the character for a local movement input: `forward` is +1 for W /
-// -1 for S, `strafe` is +1 for D / -1 for A. The camera yaw stays mouse-driven; the
-// character turns toward where it's actually going (strafe = quarter-turn run, S = run
-// toward the camera).
-export function moveFacing(yaw, { forward = 0, strafe = 0 }) {
- if (forward === 0 && strafe === 0) return yaw;
- // World-space movement direction.
- const sinYaw = Math.sin(yaw);
- const cosYaw = Math.cos(yaw);
- const dx = -sinYaw * forward + cosYaw * strafe;
- const dz = -cosYaw * forward - sinYaw * strafe;
- // Character forward is (-sin θ, -cos θ): solve θ so it aligns with (dx, dz).
- return Math.atan2(-dx, -dz);
-}
-
-// The avatar's animation state for the current rig pose.
-export function avatarState({ moving = false, sprinting = false, airborne = false }) {
- if (airborne) return 'hover';
- if (!moving) return 'idle';
- return sprinting ? 'run' : 'walk';
-}
-
-// Banking target from yaw angular velocity (rad/s): lean into turns, clamped so the
-// character never keels over. Callers damp toward this.
-export function bankAngle(yawRate, max = 0.25, gain = 0.08) {
- return Math.min(max, Math.max(-max, -yawRate * gain));
-}
diff --git a/client/src/utils/openWorldPlayerRig.test.js b/client/src/utils/openWorldPlayerRig.test.js
deleted file mode 100644
index 50d1b3d529..0000000000
--- a/client/src/utils/openWorldPlayerRig.test.js
+++ /dev/null
@@ -1,323 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import {
- DEFAULT_SPAWN_Z, THIRD_PERSON, BOOM_ZOOM, clampPitch, thirdPersonCamera, nextBoomZoom, resolveBoom,
- dampFactor, dampAngle, moveFacing, avatarState, bankAngle, stepVehicle,
- moveWithCollisions, solveKabschTransform, solveVehicleSuspensionPose, VEHICLE_COLLISION,
-} from './openWorldPlayerRig';
-import { isWalkable } from './openWorldPlan';
-
-const pos = { x: 0, y: 1.6, z: 0 };
-
-describe('authored arrival', () => {
- it('starts the rover on The Port rather than deriving spawn from app layout', () => {
- expect(DEFAULT_SPAWN_Z).toBeGreaterThan(0);
- expect(DEFAULT_SPAWN_Z).toBeGreaterThan(40);
- expect(DEFAULT_SPAWN_Z).toBeLessThan(55);
- expect(isWalkable(0, DEFAULT_SPAWN_Z)).toBe(true);
- });
-});
-
-describe('terrain suspension', () => {
- it('preserves an identity point cloud with no residual', () => {
- const points = [
- { x: -1, y: 0, z: 1 },
- { x: 1, y: 0, z: 1 },
- { x: -1, y: 0, z: -1 },
- { x: 1, y: 0, z: -1 },
- ];
- const pose = solveKabschTransform(points, points);
- expect(pose.rotation.w).toBeCloseTo(1, 6);
- expect(pose.rotation.x).toBeCloseTo(0, 6);
- expect(pose.rotation.z).toBeCloseTo(0, 6);
- expect(pose.residual).toBeCloseTo(0, 6);
- });
-
- it('fits the chassis to four independently sampled wheel contacts', () => {
- const pose = solveVehicleSuspensionPose({
- x: 4,
- z: -7,
- heading: 0.4,
- centerHeight: 0,
- halfWidth: 0.8,
- halfLength: 1.1,
- heightAt: (x, z) => x * 0.1 + z * 0.06,
- });
- expect(new Set(pose.wheelOffsets.map((value) => value.toFixed(4))).size).toBeGreaterThan(2);
- expect(Math.hypot(pose.rotation.x, pose.rotation.z)).toBeGreaterThan(0.02);
- expect(Math.hypot(pose.rotation.x, pose.rotation.y, pose.rotation.z, pose.rotation.w)).toBeCloseTo(1, 6);
- expect(pose.residual).toBeLessThan(0.02);
- });
-
- it('uses only the small fit residual for per-wheel travel on a planar slope', () => {
- const pose = solveVehicleSuspensionPose({
- x: 0,
- z: 0,
- heading: 0,
- centerHeight: 0,
- halfWidth: 0.8,
- halfLength: 1.1,
- heightAt: (_x, z) => z * 0.25,
- });
-
- expect(Math.max(...pose.wheelOffsets.map(Math.abs))).toBeGreaterThan(0.2);
- expect(Math.max(...pose.wheelTravel.map(Math.abs))).toBeLessThan(0.01);
- });
-});
-
-describe('nextBoomZoom', () => {
- it('zooms in for an upward wheel scroll and out for a downward one', () => {
- const zoomIn = nextBoomZoom(1, -120);
- const zoomOut = nextBoomZoom(1, 120);
- expect(zoomIn).toBeLessThan(1);
- expect(zoomOut).toBeGreaterThan(1);
- });
-
- it('is symmetric per notch — equal deltas move the multiplier equally in log space', () => {
- const zoomIn = nextBoomZoom(1, -120);
- const zoomOut = nextBoomZoom(1, 120);
- expect(Math.log(zoomIn)).toBeCloseTo(-Math.log(zoomOut), 6);
- });
-
- it('clamps both ends so the boom never vanishes nor leaves the world scale', () => {
- expect(nextBoomZoom(1, -1e9)).toBe(BOOM_ZOOM.min);
- expect(nextBoomZoom(1, 1e9)).toBe(BOOM_ZOOM.max);
- });
-
- it('feeds thirdPersonCamera as a boom multiplier', () => {
- const base = thirdPersonCamera({ pos, yaw: 0, pitch: 0 });
- const close = thirdPersonCamera({ pos, yaw: 0, pitch: 0, boom: THIRD_PERSON.boom * BOOM_ZOOM.min });
- expect(Math.abs(close.camera.z - pos.z)).toBeLessThan(Math.abs(base.camera.z - pos.z));
- });
-});
-
-describe('thirdPersonCamera', () => {
- it('hangs the camera behind the character at yaw 0 (facing -Z → camera at +Z)', () => {
- const { camera, lookAt } = thirdPersonCamera({ pos, yaw: 0, pitch: 0 });
- expect(camera.z).toBeGreaterThan(pos.z + THIRD_PERSON.boom * 0.9);
- expect(camera.x).toBeCloseTo(THIRD_PERSON.shoulder, 5); // right vector at yaw 0 is +X
- expect(camera.y).toBeCloseTo(pos.y + THIRD_PERSON.height, 5);
- // Aim leads the character forward (-Z) at chest height.
- expect(lookAt.z).toBeCloseTo(-THIRD_PERSON.lookAhead, 5);
- expect(lookAt.y).toBeCloseTo(pos.y + THIRD_PERSON.lookHeight, 5);
- });
-
- it('tracks yaw — at yaw π/2 (facing -X) the camera sits at +X', () => {
- const { camera } = thirdPersonCamera({ pos, yaw: Math.PI / 2, pitch: 0 });
- expect(camera.x).toBeGreaterThan(THIRD_PERSON.boom * 0.9);
- expect(Math.abs(camera.z)).toBeLessThan(1.5); // just the shoulder offset
- });
-
- it('raises the camera with positive pitch and clamps both ends', () => {
- const level = thirdPersonCamera({ pos, yaw: 0, pitch: 0 });
- const high = thirdPersonCamera({ pos, yaw: 0, pitch: 0.8 });
- expect(high.camera.y).toBeGreaterThan(level.camera.y);
- // Clamped: an absurd pitch matches the clamp boundary exactly.
- const over = thirdPersonCamera({ pos, yaw: 0, pitch: 9 });
- const atMax = thirdPersonCamera({ pos, yaw: 0, pitch: THIRD_PERSON.maxPitch });
- expect(over.camera.y).toBeCloseTo(atMax.camera.y, 6);
- expect(clampPitch(-9)).toBe(THIRD_PERSON.minPitch);
- });
-
- it('supports a separate isometric tilt without changing the rig pitch', () => {
- const level = thirdPersonCamera({ pos, yaw: 0, pitch: 0 });
- const tilted = thirdPersonCamera({
- pos,
- yaw: 0,
- pitch: 0,
- pitchOffset: THIRD_PERSON.isometricPitch,
- });
- expect(tilted.camera.y).toBeGreaterThan(level.camera.y);
- expect(tilted.camera.z).toBeLessThan(level.camera.z);
- });
-
- it('never dips the camera into the pavement', () => {
- const { camera } = thirdPersonCamera({ pos: { x: 0, y: 1.6, z: 0 }, yaw: 0, pitch: -0.45 });
- expect(camera.y).toBeGreaterThanOrEqual(THIRD_PERSON.minCamY);
- });
-});
-
-describe('resolveBoom', () => {
- const anchor = { x: 0, y: 2, z: 0 };
-
- it('keeps the full boom with no obstructions', () => {
- const clear = resolveBoom({ anchor, camera: { x: 0, y: 3, z: 7 }, buildings: [] });
- expect(clear.t).toBe(1);
- expect(clear.point).toEqual({ x: 0, y: 3, z: 7 });
- expect(resolveBoom({ anchor, camera: { x: 0, y: 3, z: 7 }, buildings: null }).t).toBe(1);
- });
-
- it('shortens the boom when the camera lands inside a building cylinder', () => {
- const buildings = [{ x: 0, z: 7, height: 10 }];
- const { t, point } = resolveBoom({ anchor, camera: { x: 0, y: 3, z: 7 }, buildings });
- expect(t).toBeLessThan(1);
- // The resolved point actually clears the cylinder.
- expect(Math.abs(point.z - 7)).toBeGreaterThanOrEqual(3.5);
- });
-
- it('ignores buildings the camera clears above (flyover)', () => {
- const buildings = [{ x: 0, z: 7, height: 4 }];
- expect(resolveBoom({ anchor, camera: { x: 0, y: 12, z: 7 }, buildings }).t).toBe(1);
- });
-
- it('accepts any iterable (e.g. Map.values())', () => {
- const map = new Map([['a', { x: 0, z: 7, height: 10 }]]);
- const { t } = resolveBoom({ anchor, camera: { x: 0, y: 3, z: 7 }, buildings: map.values() });
- expect(t).toBeLessThan(1);
- });
-});
-
-describe('moveWithCollisions', () => {
- const box = { shape: 'box', x: 0, z: 0, halfWidth: 1, halfDepth: 1 };
-
- it('stops at a box facade instead of using a large center radius', () => {
- const result = moveWithCollisions({
- position: { x: -4, z: 0 },
- displacement: { x: 4, z: 0 },
- colliders: [box],
- body: { type: 'circle', radius: 0.5 },
- });
-
- expect(result.blocked).toBe(true);
- expect(result.x).toBeCloseTo(-1.5, 2);
- expect(result.z).toBe(0);
- expect(Number.isFinite(result.x)).toBe(true);
- });
-
- it('slides along a wall while preserving movement on the open axis', () => {
- const result = moveWithCollisions({
- position: { x: -3, z: 0 },
- displacement: { x: 3, z: 0.8 },
- colliders: [box],
- body: { type: 'circle', radius: 0.5 },
- });
-
- expect(result.blockedAxes.x).toBe(true);
- expect(result.blockedAxes.z).toBe(false);
- expect(result.x).toBeCloseTo(-1.5, 2);
- expect(result.z).toBeCloseTo(0.8, 2);
- });
-
- it('uses the rover footprint and heading for box contact', () => {
- const result = moveWithCollisions({
- position: { x: -4, z: 0 },
- displacement: { x: 4, z: 0 },
- colliders: [box],
- body: { type: 'vehicle', heading: 0, ...VEHICLE_COLLISION },
- });
-
- expect(result.blocked).toBe(true);
- expect(result.x).toBeCloseTo(-1 - VEHICLE_COLLISION.halfWidth, 2);
- });
-
- it('treats round process pylons as their actual small footprint', () => {
- const result = moveWithCollisions({
- position: { x: -3, z: 0 },
- displacement: { x: 3, z: 0 },
- colliders: [{ shape: 'circle', x: 0, z: 0, radius: 0.46 }],
- body: { type: 'circle', radius: 0.5 },
- });
-
- expect(result.blocked).toBe(true);
- expect(result.x).toBeCloseTo(-0.96, 2);
- });
-});
-
-describe('dampFactor', () => {
- it('is monotonic in delta and bounded to [0, 1)', () => {
- const a = dampFactor(8, 0.004);
- const b = dampFactor(8, 0.016);
- const c = dampFactor(8, 0.1);
- expect(a).toBeLessThan(b);
- expect(b).toBeLessThan(c);
- expect(c).toBeLessThan(1);
- expect(dampFactor(8, 0)).toBe(0);
- expect(dampFactor(8, -1)).toBe(0); // negative delta can't overshoot backward
- });
-
- it('two small steps ≈ one big step (frame-rate independence)', () => {
- const one = dampFactor(8, 0.032);
- const half = dampFactor(8, 0.016);
- const twoStep = half + (1 - half) * half;
- expect(twoStep).toBeCloseTo(one, 6);
- });
-});
-
-describe('dampAngle', () => {
- it('moves toward the target', () => {
- expect(dampAngle(0, 1, 0.5)).toBeCloseTo(0.5, 6);
- });
-
- it('takes the shortest arc across ±π', () => {
- // From just below π to just above -π is a tiny step, not a full spin.
- const next = dampAngle(Math.PI - 0.1, -Math.PI + 0.1, 0.5);
- expect(next).toBeGreaterThan(Math.PI - 0.1); // continues past π, not backward
- expect(next - (Math.PI - 0.1)).toBeCloseTo(0.1, 5);
- });
-});
-
-describe('stepVehicle', () => {
- it('ramps speed instead of teleporting to the target velocity', () => {
- const first = stepVehicle({ throttle: 1, delta: 0.016 });
- expect(first.speed).toBeGreaterThan(0);
- expect(first.speed).toBeLessThan(24);
- expect(first.displacement.z).toBeLessThan(0);
- });
-
- it('coasts down and brakes faster', () => {
- const coasting = stepVehicle({ speed: 12, delta: 0.016 });
- const braking = stepVehicle({ speed: 12, throttle: 1, brake: true, delta: 0.016 });
- expect(coasting.speed).toBeLessThan(12);
- expect(braking.speed).toBeLessThan(coasting.speed);
- });
-
- it('steers more as speed builds and reverses the turn in reverse', () => {
- const forward = stepVehicle({ speed: 18, heading: 0, steer: 1, delta: 0.016 });
- const reverse = stepVehicle({ speed: -8, heading: 0, steer: 1, delta: 0.016 });
- expect(forward.heading).toBeLessThan(0);
- expect(reverse.heading).toBeGreaterThan(0);
- expect(forward.wheelAngle).toBeGreaterThan(0);
- });
-});
-
-describe('moveFacing', () => {
- it('faces the camera yaw when moving forward', () => {
- expect(moveFacing(0.3, { forward: 1, strafe: 0 })).toBeCloseTo(0.3, 6);
- });
-
- it('quarter-turns for a pure strafe', () => {
- const right = moveFacing(0, { forward: 0, strafe: 1 });
- // Strafing right at yaw 0 moves along +X; the character faces +X → θ = -π/2.
- expect(right).toBeCloseTo(-Math.PI / 2, 6);
- const left = moveFacing(0, { forward: 0, strafe: -1 });
- expect(left).toBeCloseTo(Math.PI / 2, 6);
- });
-
- it('faces the camera when backpedaling', () => {
- const back = moveFacing(0, { forward: -1, strafe: 0 });
- expect(Math.abs(back)).toBeCloseTo(Math.PI, 6);
- });
-
- it('keeps the current yaw with no input', () => {
- expect(moveFacing(0.7, { forward: 0, strafe: 0 })).toBe(0.7);
- });
-});
-
-describe('avatarState', () => {
- it('classifies the four states with airborne taking priority', () => {
- expect(avatarState({ moving: false })).toBe('idle');
- expect(avatarState({ moving: true })).toBe('walk');
- expect(avatarState({ moving: true, sprinting: true })).toBe('run');
- expect(avatarState({ moving: true, sprinting: true, airborne: true })).toBe('hover');
- });
-});
-
-describe('bankAngle', () => {
- it('leans opposite the yaw rate and clamps', () => {
- expect(bankAngle(1)).toBeLessThan(0);
- expect(bankAngle(-1)).toBeGreaterThan(0);
- expect(bankAngle(100)).toBe(-0.25);
- expect(bankAngle(-100)).toBe(0.25);
- expect(bankAngle(0)).toBeCloseTo(0, 10);
- });
-});
-// @vitest-environment node
diff --git a/client/src/utils/openWorldProductivity.js b/client/src/utils/openWorldProductivity.js
deleted file mode 100644
index 18acc15564..0000000000
--- a/client/src/utils/openWorldProductivity.js
+++ /dev/null
@@ -1,92 +0,0 @@
-// Pure, deterministic helpers for OpenWorld's productivity district: a glowing
-// obelisk whose height reflects today's completed agent tasks and whose color
-// reflects recent velocity. No three.js / React imports.
-
-import { PARCELS } from './openWorldPlan';
-
-export const MONUMENT = {
- position: PARCELS.productivity.anchor, // southwest district — anchored by the master plan (openWorldPlan.js)
- baseWidth: 5, // footprint of the obelisk base
- minHeight: 3,
- maxHeight: 26,
- taskCap: 20,
-};
-
-// Velocity tiers drive the monument color so the district speaks recent throughput at a
-// glance. `velocity.percentage` from the quick-summary payload is "today vs. historical
-// average", where 100 ≈ on pace. Colors reuse the PortOS Tailwind design tokens.
-const TIERS = [
- { min: 120, key: 'surging', color: '#22c55e', label: 'SURGING' }, // port-success — well above pace
- { min: 80, key: 'steady', color: '#3b82f6', label: 'STEADY' }, // port-accent — roughly on pace
- { min: 40, key: 'slowing', color: '#f59e0b', label: 'SLOWING' }, // port-warning — below pace
- { min: 0, key: 'idle', color: '#ef4444', label: 'IDLE' }, // port-error — little/no recent throughput
-];
-
-const ABSENT_COLOR = '#64748b'; // slate — no productivity data at all
-
-const clamp01 = (n) => Math.max(0, Math.min(1, n));
-
-// Coerce a value to a finite number or return null (the "absent" sentinel) so callers can
-// distinguish a missing/garbage field from a legitimate 0.
-function finiteOrNull(value) {
- return typeof value === 'number' && Number.isFinite(value) ? value : null;
-}
-
-// Map today's completed task count to a 0..1 fill against the cap.
-export function throughputLevel(tasks, cap = MONUMENT.taskCap) {
- const count = finiteOrNull(tasks);
- if (count === null) return null;
- if (typeof cap !== 'number' || !Number.isFinite(cap) || cap <= 0) return null;
- return clamp01(count / cap);
-}
-
-// Classify recent velocity into a color tier. A non-numeric velocity (absent) falls through
-// to the lowest tier's color via the caller; here we only resolve a present number.
-export function velocityTier(velocity) {
- const v = finiteOrNull(velocity);
- if (v === null) return null;
- return TIERS.find((t) => v >= t.min) || TIERS[TIERS.length - 1];
-}
-
-// Full derived view-model for the component. `productivityData` is the quick-summary payload
-// (`{ today: { completed, ... }, velocity: { percentage, ... } }`).
-export function computeProductivityMonument(productivityData) {
- const payload = productivityData && typeof productivityData === 'object' ? productivityData : {};
- const todaySrc = payload.today && typeof payload.today === 'object' ? payload.today : {};
- const velocitySrc = payload.velocity && typeof payload.velocity === 'object' ? payload.velocity : {};
-
- const completedToday = finiteOrNull(todaySrc.completed);
- const level = throughputLevel(completedToday) ?? 0;
- const present = completedToday !== null;
-
- const tier = velocityTier(velocitySrc.percentage);
- // Absent productivity data reads slate/dim; a present payload always gets a tier color
- // (idle red when velocity is missing-but-data-exists, so the monument never goes dark on
- // a real-but-quiet day).
- const color = present ? (tier?.color ?? TIERS[TIERS.length - 1].color) : ABSENT_COLOR;
- const tierLabel = present ? (tier?.label ?? TIERS[TIERS.length - 1].label) : 'NO DATA';
-
- const height = MONUMENT.minHeight + level * (MONUMENT.maxHeight - MONUMENT.minHeight);
- // A present-but-quiet day still glows faintly; absent data is nearly dark.
- const intensity = present ? 0.3 + level * 0.7 : 0.1;
-
- let throughputLabel;
- if (!present) throughputLabel = 'NO DATA';
- else if (completedToday === 0) throughputLabel = 'NO TASKS TODAY';
- else throughputLabel = `${completedToday} TASK${completedToday === 1 ? '' : 'S'} TODAY`;
-
- return {
- position: MONUMENT.position,
- baseWidth: MONUMENT.baseWidth,
- height,
- level,
- present,
- completedToday,
- color,
- intensity,
- tierKey: present ? (tier?.key ?? TIERS[TIERS.length - 1].key) : 'absent',
- tierLabel,
- throughputLabel,
- surging: tier?.key === 'surging',
- };
-}
diff --git a/client/src/utils/openWorldProductivity.test.js b/client/src/utils/openWorldProductivity.test.js
deleted file mode 100644
index 34b7cdfc34..0000000000
--- a/client/src/utils/openWorldProductivity.test.js
+++ /dev/null
@@ -1,68 +0,0 @@
-import { describe, expect, it } from 'vitest';
-import {
- MONUMENT,
- computeProductivityMonument,
- throughputLevel,
- velocityTier,
-} from './openWorldProductivity';
-
-describe('throughputLevel', () => {
- it('maps completed tasks into a clamped 0..1 level', () => {
- expect(throughputLevel(10, 20)).toBe(0.5);
- expect(throughputLevel(999, 20)).toBe(1);
- expect(throughputLevel(-5, 20)).toBe(0);
- expect(throughputLevel(0, 20)).toBe(0);
- });
-
- it('returns null for absent values and invalid caps', () => {
- expect(throughputLevel(undefined)).toBeNull();
- expect(throughputLevel('10')).toBeNull();
- expect(throughputLevel(10, 0)).toBeNull();
- });
-});
-
-// @vitest-environment node
-
-describe('velocityTier', () => {
- it('classifies present pace values and preserves absence', () => {
- expect(velocityTier(150)?.key).toBe('surging');
- expect(velocityTier(100)?.key).toBe('steady');
- expect(velocityTier(50)?.key).toBe('slowing');
- expect(velocityTier(0)?.key).toBe('idle');
- expect(velocityTier(undefined)).toBeNull();
- });
-});
-
-describe('computeProductivityMonument', () => {
- it('uses today throughput for height and labeling', () => {
- const vm = computeProductivityMonument({
- today: { completed: 10 },
- velocity: { percentage: 125 },
- });
-
- expect(vm.present).toBe(true);
- expect(vm.completedToday).toBe(10);
- expect(vm.level).toBe(0.5);
- expect(vm.height).toBe(MONUMENT.minHeight + 0.5 * (MONUMENT.maxHeight - MONUMENT.minHeight));
- expect(vm.throughputLabel).toBe('10 TASKS TODAY');
- expect(vm.tierKey).toBe('surging');
- });
-
- it('distinguishes zero throughput from absent data', () => {
- const zero = computeProductivityMonument({ today: { completed: 0 }, velocity: { percentage: 50 } });
- expect(zero.present).toBe(true);
- expect(zero.throughputLabel).toBe('NO TASKS TODAY');
- expect(zero.height).toBe(MONUMENT.minHeight);
-
- const absent = computeProductivityMonument({});
- expect(absent.present).toBe(false);
- expect(absent.throughputLabel).toBe('NO DATA');
- expect(absent.height).toBe(MONUMENT.minHeight);
- });
-
- it('tolerates malformed payloads and clamps unusually high throughput', () => {
- expect(computeProductivityMonument(null).throughputLabel).toBe('NO DATA');
- expect(computeProductivityMonument({ today: 5 }).throughputLabel).toBe('NO DATA');
- expect(computeProductivityMonument({ today: { completed: 9999 } }).level).toBe(1);
- });
-});
diff --git a/client/src/utils/openWorldProximity.js b/client/src/utils/openWorldProximity.js
deleted file mode 100644
index e1df78c7ad..0000000000
--- a/client/src/utils/openWorldProximity.js
+++ /dev/null
@@ -1,159 +0,0 @@
-// Pure deterministic proximity detection for OpenWorld exploration mode.
-// Unifies detection across buildings, warp pads, landmarks, and easter eggs.
-// No three.js / React imports — pure, testable in node.
-
-import { PARCELS } from './openWorldPlan';
-import { isOpenWorldEntryVisible } from './openWorldRegions';
-
-export const PROXIMITY_DISTANCES = {
- warpPad: 3.6,
- building: 6.0,
- landmark: 8.5,
- easterEgg: 4.5,
- shard: 3.0,
-};
-
-// Curated list of recognizable world landmarks derived from the master town plan
-export const WORLD_LANDMARKS = [
- { id: 'ai-core', regionId: 'ai-core', parcel: 'aiCore', label: 'PortOS Common', eyebrow: 'VILLAGE COMMON', action: 'VISIT THE AI CORE' },
- { id: 'backup-vault', regionId: 'backup-vault', parcel: 'backupVault', label: 'Backup Cottage', eyebrow: 'VILLAGE HOUSE', action: 'CHECK THE BACKUPS' },
- { id: 'task-queue', regionId: 'task-queue', parcel: 'taskQueue', label: 'Task Workshop', eyebrow: 'VILLAGE HOUSE', action: 'VISIT THE TASKS' },
- { id: 'archive', regionId: 'archive', parcel: 'warehouse', label: 'Archive Lodge', eyebrow: 'VILLAGE HOUSE', action: 'VISIT THE ARCHIVE' },
- { id: 'wellness', regionId: 'wellness', parcel: 'health', label: 'Wellness Greenhouse', eyebrow: 'VILLAGE GARDEN', action: 'CHECK THE VITALS' },
- { id: 'memory', regionId: 'memory', parcel: 'memory', label: 'Memory House', eyebrow: 'VILLAGE HOUSE', action: 'VISIT MEMORY' },
- { id: 'sprint-yard', regionId: 'sprint-yard', parcel: 'jira', feature: 'jira', label: 'Sprint Yard', eyebrow: 'VILLAGE YARD', action: 'VISIT THE SPRINT' },
- { id: 'quiet-corner', regionId: 'quiet-corner', parcel: 'easterEggs', label: 'Quiet Cottage', eyebrow: 'VILLAGE SECRET', action: 'VISIT THE QUIET CORNER' },
- { id: 'goals', regionId: 'goals', parcel: 'goals', label: 'Goals Lodge', eyebrow: 'VILLAGE HOUSE', action: 'VISIT THE GOALS' },
- { id: 'artifacts', regionId: 'artifacts', parcel: 'artifacts', label: 'Trophy House', eyebrow: 'VILLAGE HOUSE', action: 'SEE THE TROPHIES' },
- { id: 'voice', regionId: 'voice', parcel: 'voice', label: 'Voice Radio', eyebrow: 'VILLAGE RADIO', action: 'VISIT VOICE' },
- { id: 'data-harbor', regionId: 'data-harbor', parcel: 'dataHarbor', label: 'Data Pier', eyebrow: 'VILLAGE HARBOR', action: 'VISIT THE PIER' },
-];
-
-export function getResolvedLandmarks(isFeatureEnabled) {
- return WORLD_LANDMARKS
- .filter((landmark) => isOpenWorldEntryVisible(landmark, isFeatureEnabled))
- .map((lm) => {
- const parcel = PARCELS[lm.parcel];
- if (!parcel) return null;
- return {
- ...lm,
- x: parcel.anchor[0],
- y: 0,
- z: parcel.anchor[2],
- };
- })
- .filter(Boolean);
-}
-
-// Compute the closest interactable target to the player
-export function detectProximity({
- playerPos,
- apps = [],
- positions = null,
- warpPads = [],
- easterEggs = [],
- landmarks = getResolvedLandmarks(),
-} = {}) {
- if (!playerPos || typeof playerPos.x !== 'number' || typeof playerPos.z !== 'number') {
- return null;
- }
-
- // Validate collection-shaped inputs before iterating: this runs inside the r3f frame
- // loop, where a thrown TypeError kills rendering for the whole scene (the canvas would
- // show only its clear color). A wrong-shaped payload degrades to "no targets" instead.
- const pads = Array.isArray(warpPads) ? warpPads : [];
- const eggs = Array.isArray(easterEggs) ? easterEggs : [];
-
- const px = playerPos.x;
- const pz = playerPos.z;
-
- let closestTarget = null;
- let closestDist = Infinity;
-
- // 1. Warp pads (highest priority for fast travel nodes)
- for (const pad of pads) {
- if (!pad) continue;
- const pos = pad.position || (pad.region ? [pad.region.anchor[0], 0, pad.region.anchor[2]] : null);
- if (!pos) continue;
- const dx = px - pos[0];
- const dz = pz - pos[2];
- const dist = Math.hypot(dx, dz);
- if (dist < PROXIMITY_DISTANCES.warpPad && dist < closestDist) {
- closestDist = dist;
- const region = pad.region || pad;
- closestTarget = {
- type: 'warpPad',
- id: region.id,
- label: region.label || 'WARP GATE',
- eyebrow: 'WARP GATE',
- action: 'WARP TO',
- raw: region,
- };
- }
- }
-
- // 2. Apps / Buildings
- if (positions && typeof positions.forEach === 'function') {
- positions.forEach((pos, appId) => {
- const dx = px - pos.x;
- const dz = pz - pos.z;
- const dist = Math.hypot(dx, dz);
- if (dist < PROXIMITY_DISTANCES.building && dist < closestDist) {
- closestDist = dist;
- const app = apps.find((a) => a.id === appId);
- const name = app?.name || appId;
- closestTarget = {
- type: 'building',
- id: appId,
- label: name,
- eyebrow: 'NEARBY BUILDING',
- action: 'OPEN APP STATUS',
- raw: app || { id: appId, name },
- };
- }
- });
- }
-
- // 3. Easter eggs
- for (const egg of eggs) {
- if (!egg || !egg.position) continue;
- const dx = px - egg.position[0];
- const dz = pz - egg.position[2];
- const dist = Math.hypot(dx, dz);
- if (dist < PROXIMITY_DISTANCES.easterEgg && dist < closestDist) {
- closestDist = dist;
- closestTarget = {
- type: 'easterEgg',
- id: egg.id,
- label: `${egg.label || '?!'} — ${egg.hint || 'SECRET'}`,
- eyebrow: 'EASTER EGG FOUND',
- action: 'DISCOVER',
- raw: egg,
- };
- }
- }
-
- // 4. District landmarks (only if no closer specific target was matched)
- if (!closestTarget) {
- for (const lm of landmarks) {
- if (!lm) continue;
- const dx = px - lm.x;
- const dz = pz - lm.z;
- const dist = Math.hypot(dx, dz);
- if (dist < PROXIMITY_DISTANCES.landmark && dist < closestDist) {
- closestDist = dist;
- closestTarget = {
- type: 'landmark',
- id: lm.id,
- regionId: lm.regionId,
- label: lm.label,
- eyebrow: lm.eyebrow,
- action: lm.action,
- raw: lm,
- };
- }
- }
- }
-
- return closestTarget;
-}
diff --git a/client/src/utils/openWorldProximity.test.js b/client/src/utils/openWorldProximity.test.js
deleted file mode 100644
index 06b9299e56..0000000000
--- a/client/src/utils/openWorldProximity.test.js
+++ /dev/null
@@ -1,106 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import {
- detectProximity,
- getResolvedLandmarks,
- WORLD_LANDMARKS,
-} from './openWorldProximity';
-
-describe('openWorldProximity', () => {
- it('resolves all world landmarks against plan parcels', () => {
- const list = getResolvedLandmarks();
- expect(list.length).toBe(WORLD_LANDMARKS.length);
- list.forEach((lm) => {
- expect(typeof lm.x).toBe('number');
- expect(typeof lm.z).toBe('number');
- expect(typeof lm.label).toBe('string');
- expect(typeof lm.eyebrow).toBe('string');
- expect(typeof lm.action).toBe('string');
- });
- });
-
- it('hides a disabled feature landmark from proximity browse surfaces', () => {
- const jiraOff = (featureId) => featureId !== 'jira';
- expect(getResolvedLandmarks(jiraOff).map((landmark) => landmark.id)).not.toContain('sprint-yard');
- expect(getResolvedLandmarks(() => true).map((landmark) => landmark.id)).toContain('sprint-yard');
- });
-
- it('detects landmark when player is close', () => {
- const aiCore = getResolvedLandmarks().find((l) => l.id === 'ai-core');
- expect(aiCore).toBeDefined();
-
- const target = detectProximity({
- playerPos: { x: aiCore.x + 1, y: 1.6, z: aiCore.z + 1 },
- landmarks: [aiCore],
- });
-
- expect(target).toBeDefined();
- expect(target?.type).toBe('landmark');
- expect(target?.id).toBe('ai-core');
- expect(target?.action).toBe('VISIT THE AI CORE');
- });
-
- it('detects warp pad with higher priority when close to warp pad', () => {
- const warpPad = {
- id: 'downtown',
- region: { id: 'downtown', label: 'Downtown' },
- position: [10, 0, 10],
- };
- const landmark = {
- id: 'downtown-landmark',
- x: 10,
- z: 10,
- label: 'Downtown Hub',
- eyebrow: 'LANDMARK',
- action: 'VIEW',
- };
-
- const target = detectProximity({
- playerPos: { x: 10.5, y: 1.6, z: 10.5 },
- warpPads: [warpPad],
- landmarks: [landmark],
- });
-
- expect(target?.type).toBe('warpPad');
- expect(target?.id).toBe('downtown');
- });
-
- it('detects easter egg when near its position', () => {
- const egg = {
- id: 'leet',
- label: '1337',
- hint: 'LEET',
- position: [-45, 1.2, 40],
- };
-
- const target = detectProximity({
- playerPos: { x: -45.5, y: 1.6, z: 40.5 },
- easterEggs: [egg],
- });
-
- expect(target?.type).toBe('easterEgg');
- expect(target?.id).toBe('leet');
- expect(target?.eyebrow).toBe('EASTER EGG FOUND');
- });
-
- it('returns null when player is far from all targets', () => {
- const target = detectProximity({
- playerPos: { x: 999, y: 1.6, z: 999 },
- landmarks: getResolvedLandmarks(),
- });
- expect(target).toBeNull();
- });
-
- it('degrades non-iterable collection payloads to "no targets" instead of throwing', () => {
- // Regression: OpenWorldScene once passed computeEasterEggs' wrapper object
- // ({ base, eggs, total, hasData }) as easterEggs. detectProximity runs inside
- // the r3f frame loop, so the resulting TypeError killed rendering every frame —
- // the world never painted and no interaction worked. A bad shape must degrade.
- const target = detectProximity({
- playerPos: { x: -45.5, y: 1.6, z: 40.5 },
- easterEggs: { eggs: [], total: 0, hasData: false },
- warpPads: { warpPadList: [] },
- landmarks: [],
- });
- expect(target).toBeNull();
- });
-});
diff --git a/client/src/utils/openWorldRegions.js b/client/src/utils/openWorldRegions.js
deleted file mode 100644
index ab1f6bd953..0000000000
--- a/client/src/utils/openWorldRegions.js
+++ /dev/null
@@ -1,138 +0,0 @@
-// OpenWorld's fast-travel region registry — the list of named places you can warp to and
-// the PortOS page each one stands for. This is what turns the world from "one city you pan
-// around" into an open world you teleport across (Breath-of-the-Wild style): every region is
-// a real destination with a name, a one-line pitch, and a door back into the 2D app.
-//
-// Geography is NOT re-declared here. Each region names a parcel in the master town plan
-// (`openWorldPlan.js` PARCELS) and reads its anchor/footprint from there, so moving a district on
-// the plan moves its fast-travel marker too — the same no-drift rule the mini-map follows.
-//
-// No React / three.js imports so the registry stays unit-testable in node (mirrors
-// openWorldMiniMap.js / openWorldFocusCamera.js). `OPEN_WORLD_REGIONS` is ALSO the source of truth for
-// the `/openworld/region/:regionId` nav-manifest entries — `server/lib/navManifest.test.js`
-// scrapes the `id:` values out of this array and fails if a region has no ⌘K / voice command
-// (or vice versa), so a new region can't ship unreachable.
-
-import { ARCHIPELAGO_ISLANDS, PARCELS, isWalkable } from './openWorldPlan';
-
-// Ordered for the fast-travel list: the two places you look at most first, then a clockwise
-// sweep of the outer districts, then the far shore. `parcel` keys into PARCELS for geography;
-// `appPath` is semantic metadata for the PortOS area the region visualizes (null for pure set
-// dressing). OpenWorld uses the region id for in-world travel and never follows this path.
-//
-// `district` marks a region whose real extent is DATA-DRIVEN — the downtown and archive grids
-// grow with the install's app count, so their PARCELS footprint is only a nominal size. It
-// names the district key in `computeOpenWorldLayout`'s output, which the camera uses to measure the
-// buildings actually on the ground instead of framing a fixed rectangle and clipping the
-// outer towers.
-export const OPEN_WORLD_REGIONS = [
- { id: 'downtown', parcel: 'downtown', district: 'downtown', label: 'Village Green', blurb: 'The sunny crossroads where every PortOS lane meets.', appPath: '/apps', aliases: ['downtown', 'apps district', 'app towers', 'village green'] },
- { id: 'ai-core', parcel: 'aiCore', label: 'PortOS Common', blurb: 'A gathering circle around the bright AI pavilion.', appPath: '/ai', aliases: ['ai core', 'core plaza', 'the core', 'reactor', 'common'] },
- { id: 'task-queue', parcel: 'taskQueue', label: 'Task Workshop', blurb: 'Chief of Staff work stacked, sorted, and ready to go.', appPath: '/cos/tasks', aliases: ['task queue', 'queue', 'cos queue', 'task workshop'] },
- { id: 'wellness', parcel: 'health', label: 'Wellness Greenhouse', blurb: 'A glass garden for CPU, memory, disk, and personal vitals.', appPath: '/system-resources/overview', aliases: ['wellness tower', 'health tower', 'vitals tower', 'greenhouse'] },
- { id: 'archive', parcel: 'warehouse', district: 'warehouse', label: 'Archive Lodge', blurb: 'A quiet lodge for archived apps and older work.', appPath: '/apps', aliases: ['archive district', 'warehouse', 'cold storage', 'archive lodge'] },
- { id: 'quiet-corner', parcel: 'easterEggs', label: 'Quiet Corner', blurb: 'The odd little things the world keeps to itself.', appPath: null, aliases: ['quiet corner', 'easter eggs'] },
- { id: 'productivity', parcel: 'productivity', label: 'Focus Farm', blurb: 'Crops, throughput, pace, and the activity calendar.', appPath: '/insights/overview', aliases: ['productivity terrace', 'productivity', 'throughput district', 'streak district', 'focus farm'] },
- { id: 'backup-vault', parcel: 'backupVault', label: 'Backup Cottage', blurb: 'The snug house that watches over the latest backup.', appPath: '/settings/backup', aliases: ['backup vault', 'the vault', 'backup cottage'] },
- { id: 'memory', parcel: 'memory', label: 'Memory House', blurb: 'A wooded home for long-term memory and the inbox well.', appPath: '/brain/inbox', aliases: ['memory quarter', 'memory district', 'knowledge district', 'memory house'] },
- { id: 'sprint-yard', parcel: 'jira', label: 'Sprint Studio', blurb: 'The current sprint laid out as a little maker yard.', appPath: '/devtools/jira', feature: 'jira', aliases: ['sprint yard', 'jira yard', 'sprint district', 'sprint studio'] },
- { id: 'voice', parcel: 'voice', label: 'Voice Radio', blurb: 'A tiny radio house that wakes when the voice agent listens.', appPath: '/digital-twin/voice', aliases: ['voice beacon', 'the beacon', 'voice radio'] },
- { id: 'goals', parcel: 'goals', label: 'Goals Lodge', blurb: 'A lodge for life goals and the paths toward them.', appPath: '/goals/tree', aliases: ['goal monuments', 'monuments', 'goals district', 'goals lodge'] },
- { id: 'artifacts', parcel: 'artifacts', label: 'Trophy House', blurb: 'Earned artifacts on cheerful display.', appPath: '/character', aliases: ['hall of achievements', 'artifact hall', 'achievements hall', 'trophy house'] },
- { id: 'data-harbor', parcel: 'dataHarbor', label: 'Data Pier', blurb: 'A cottage over the bay for tables and data domains.', appPath: '/data', aliases: ['data harbor', 'the harbor', 'piers', 'data pier'] },
-];
-
-// Route prefix the fast-travel deep links live under. Exported so callers build the URL from
-// one constant instead of re-typing it (and so the nav-manifest guard can name the prefix).
-export const OPEN_WORLD_REGION_PREFIX = '/openworld/region';
-
-export const regionPath = (id) => `${OPEN_WORLD_REGION_PREFIX}/${id}`;
-
-const REGIONS_BY_ID = new Map(OPEN_WORLD_REGIONS.map((r) => [r.id, r]));
-
-// Optional feature tags follow the caller's shared navigation gate. Untagged entries
-// and callers without a gate remain visible; tagged entries follow the gate's answer.
-export const isOpenWorldEntryVisible = (entry, isFeatureEnabled) => (
- !entry?.feature
- || typeof isFeatureEnabled !== 'function'
- || isFeatureEnabled(entry.feature)
-);
-
-// A region with its geography resolved from the master town plan: `anchor` is the ground
-// center [x, y, z], `w`/`d` the parcel footprint. The registry's `label` is what the UI
-// shows; the plan's own label rides along as `planLabel` for anything that wants the
-// in-world signage wording. Returns null for an unknown id — and for a region naming a
-// parcel that no longer exists, which the tests fail on rather than rendering at the origin.
-export function getRegion(id) {
- const region = REGIONS_BY_ID.get(id);
- if (!region) return null;
- const parcel = PARCELS[region.parcel];
- if (!parcel) return null;
- return {
- ...region,
- anchor: parcel.anchor,
- w: parcel.w,
- d: parcel.d,
- planLabel: parcel.label,
- };
-}
-
-// Every region, geography resolved, in fast-travel order. Regions whose parcel has vanished
-// from the plan are dropped rather than rendered at [0,0,0].
-export function listRegions(isFeatureEnabled) {
- return OPEN_WORLD_REGIONS
- .filter((region) => isOpenWorldEntryVisible(region, isFeatureEnabled))
- .map((r) => getRegion(r.id))
- .filter(Boolean);
-}
-
-// Case/punctuation-insensitive lookup over labels + aliases, for the fast-travel filter box.
-// Returns regions in registry order so the list never jumps around as you type.
-export function searchRegions(query, isFeatureEnabled) {
- const q = (query || '').trim().toLowerCase();
- const all = listRegions(isFeatureEnabled);
- if (!q) return all;
- return all.filter((r) =>
- r.label.toLowerCase().includes(q)
- || r.id.includes(q)
- || (r.blurb || '').toLowerCase().includes(q)
- || (r.aliases || []).some((a) => a.includes(q)));
-}
-
-// Where a walking player lands when they warp in. Start from the parcel's near (+Z)
-// edge, pulled back far enough to see the district rather than spawning inside a monument.
-// If that point falls beyond its island's authored shoreline, pull it toward the nearest
-// island interior. This keeps every gate on visible ground without teaching the region
-// registry a second, drifting set of hard-coded arrival coordinates.
-export const REGION_ARRIVAL_SETBACK = 6;
-
-export function regionArrivalPoint(region) {
- if (!region) return null;
- const [x, , z] = region.anchor;
- const proposed = { x, z: z + region.d / 2 + REGION_ARRIVAL_SETBACK };
- if (isWalkable(proposed.x, proposed.z)) return proposed;
-
- const island = ARCHIPELAGO_ISLANDS.reduce((nearest, candidate) => {
- const normalized = ((x - candidate.center[0]) / candidate.radiusX) ** 2
- + ((z - candidate.center[1]) / candidate.radiusZ) ** 2;
- return !nearest || normalized < nearest.normalized ? { island: candidate, normalized } : nearest;
- }, null)?.island;
- if (!island) return proposed;
-
- const dx = proposed.x - island.center[0];
- const dz = proposed.z - island.center[1];
- const normalizedRadius = Math.hypot(dx / island.radiusX, dz / island.radiusZ);
- const scale = normalizedRadius > 0.72 ? 0.72 / normalizedRadius : 1;
- return {
- x: island.center[0] + dx * scale,
- z: island.center[1] + dz * scale,
- };
-}
-
-// The arrival point is also the physical location of a region's warp pad. Keeping the
-// pad and the walking spawn on one projection prevents a diegetic warp from landing the
-// player somewhere different from the marker they walked to.
-export function regionWarpPadPosition(region) {
- const arrival = regionArrivalPoint(region);
- return arrival ? [arrival.x, 0.12, arrival.z] : null;
-}
diff --git a/client/src/utils/openWorldRegions.test.js b/client/src/utils/openWorldRegions.test.js
deleted file mode 100644
index 1ddfc7e645..0000000000
--- a/client/src/utils/openWorldRegions.test.js
+++ /dev/null
@@ -1,137 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import {
- OPEN_WORLD_REGIONS,
- OPEN_WORLD_REGION_PREFIX,
- getRegion,
- listRegions,
- regionArrivalPoint,
- regionWarpPadPosition,
- regionPath,
- searchRegions,
- REGION_ARRIVAL_SETBACK,
-} from './openWorldRegions';
-import { PARCELS, isWalkable } from './openWorldPlan';
-
-describe('OPEN_WORLD_REGIONS registry', () => {
- it('has unique, URL-safe ids', () => {
- const ids = OPEN_WORLD_REGIONS.map((r) => r.id);
- expect(new Set(ids).size).toBe(ids.length);
- for (const id of ids) expect(id).toMatch(/^[a-z0-9-]+$/);
- });
-
- it('every region names a parcel that exists in the master town plan', () => {
- const missing = OPEN_WORLD_REGIONS.filter((r) => !PARCELS[r.parcel]);
- expect(missing.map((r) => `${r.id}→${r.parcel}`)).toEqual([]);
- });
-
- it('every region carries a label and a blurb', () => {
- for (const region of OPEN_WORLD_REGIONS) {
- expect(region.label).toBeTruthy();
- expect(region.blurb).toBeTruthy();
- }
- });
-
- it('app paths are absolute PortOS routes (or an explicit null for set dressing)', () => {
- for (const region of OPEN_WORLD_REGIONS) {
- if (region.appPath === null) continue;
- expect(region.appPath).toMatch(/^\//);
- }
- });
-
- it('builds deep links under the region prefix', () => {
- expect(regionPath('memory')).toBe(`${OPEN_WORLD_REGION_PREFIX}/memory`);
- });
-});
-
-describe('getRegion', () => {
- it('resolves geography from the plan rather than re-declaring it', () => {
- const region = getRegion('memory');
- expect(region.anchor).toBe(PARCELS.memory.anchor);
- expect(region.w).toBe(PARCELS.memory.w);
- expect(region.d).toBe(PARCELS.memory.d);
- expect(region.planLabel).toBe(PARCELS.memory.label);
- });
-
- it('returns null for an absent or unknown id', () => {
- // This IS the route contract: /openworld/region/:regionId hands the raw param
- // straight to getRegion, and a null means "stay on the overview".
- expect(getRegion('atlantis')).toBeNull();
- expect(getRegion('')).toBeNull();
- expect(getRegion(undefined)).toBeNull();
- expect(getRegion(null)).toBeNull();
- });
-});
-
-describe('listRegions', () => {
- it('returns every region, geography resolved, in registry order', () => {
- const list = listRegions();
- expect(list).toHaveLength(OPEN_WORLD_REGIONS.length);
- expect(list.map((r) => r.id)).toEqual(OPEN_WORLD_REGIONS.map((r) => r.id));
- for (const region of list) expect(Array.isArray(region.anchor)).toBe(true);
- });
-
- it('hides a disabled feature region from browse lists while leaving untagged regions visible', () => {
- const jiraOff = (featureId) => featureId !== 'jira';
- const list = listRegions(jiraOff);
-
- expect(list.map((region) => region.id)).not.toContain('sprint-yard');
- expect(list.map((region) => region.id)).toContain('memory');
- expect(searchRegions('jira', jiraOff)).toEqual([]);
- expect(searchRegions('jira', () => true).map((region) => region.id)).toContain('sprint-yard');
- });
-});
-
-describe('searchRegions', () => {
- it('returns everything for an empty query', () => {
- expect(searchRegions('')).toHaveLength(OPEN_WORLD_REGIONS.length);
- expect(searchRegions(' ')).toHaveLength(OPEN_WORLD_REGIONS.length);
- expect(searchRegions(undefined)).toHaveLength(OPEN_WORLD_REGIONS.length);
- });
-
- it('matches labels case-insensitively', () => {
- expect(searchRegions('MEMORY').map((r) => r.id)).toContain('memory');
- });
-
- it('matches aliases the label does not contain', () => {
- // "jira yard" is an alias of the Sprint Yard — the label never says JIRA.
- expect(searchRegions('jira').map((r) => r.id)).toContain('sprint-yard');
- });
-
- it('preserves registry order so the list does not reshuffle as you type', () => {
- const order = OPEN_WORLD_REGIONS.map((r) => r.id);
- const hits = searchRegions('t').map((r) => r.id);
- expect(hits).toEqual(order.filter((id) => hits.includes(id)));
- });
-
- it('returns an empty list for a query nothing matches', () => {
- expect(searchRegions('zzzzz-nothing')).toEqual([]);
- });
-});
-
-describe('regionArrivalPoint', () => {
- it('lands on the near (+Z) edge of the parcel, set back so you can see it', () => {
- const region = getRegion('goals');
- const point = regionArrivalPoint(region);
- expect(point.x).toBe(PARCELS.goals.anchor[0]);
- expect(point.z).toBe(PARCELS.goals.anchor[2] + PARCELS.goals.d / 2 + REGION_ARRIVAL_SETBACK);
- });
-
- it('never drops a walking player into the bay', () => {
- for (const region of listRegions()) {
- const { x, z } = regionArrivalPoint(region);
- expect(isWalkable(x, z)).toBe(true);
- }
- });
-
- it('is null-safe', () => {
- expect(regionArrivalPoint(null)).toBeNull();
- expect(regionArrivalPoint(undefined)).toBeNull();
- });
-
- it('puts the warp pad at the same ground point as the walking arrival', () => {
- const region = getRegion('memory');
- const arrival = regionArrivalPoint(region);
- expect(regionWarpPadPosition(region)).toEqual([arrival.x, 0.12, arrival.z]);
- expect(regionWarpPadPosition(null)).toBeNull();
- });
-});
diff --git a/client/src/utils/openWorldRooftops.js b/client/src/utils/openWorldRooftops.js
deleted file mode 100644
index 925b1e5e21..0000000000
--- a/client/src/utils/openWorldRooftops.js
+++ /dev/null
@@ -1,49 +0,0 @@
-// Pure, deterministic helpers for OpenWorld's rooftop fixtures: small antennas, water
-// tanks, AC units, and dish arrays scattered on app-building roofs so the skyline reads
-// as a lived-in game city instead of bare boxes. Fixtures are seeded from the app name
-// (same determinism as the procedural window textures in Building.jsx) so a building
-// keeps its roof across refetches, theme switches, and installs. No three.js / React
-// imports so the kit is unit-testable (mirrors openWorldTaskQueue.js etc.).
-
-import { hashString } from './hashString';
-
-export const ROOFTOP_TYPES = ['antenna', 'tank', 'ac', 'dish'];
-
-const MAX_FIXTURES = 3;
-const EDGE_MARGIN = 0.45; // keep fixtures off the roof edge (relative to a width-2 roof)
-
-// Successive bit-slices of the name hash drive each pick — cheap, allocation-free
-// determinism without a generator.
-const pick = (hash, shift, mod) => Math.abs(hash >> shift) % mod;
-
-// 0–3 fixtures for a roof of `width`×`width` (buildings are square). Positions are
-// offsets from the roof center; `scale` tracks the building width so kits read
-// proportionate on the rare wider structure.
-export function computeRooftopKit(name, width = 2) {
- const hash = hashString(String(name || ''));
- const count = pick(hash, 0, MAX_FIXTURES + 1); // 0..3 — some roofs stay bare
- const half = Math.max(0.2, width / 2 - EDGE_MARGIN);
- const fixtures = [];
- for (let i = 0; i < count; i++) {
- const shift = 3 + i * 7;
- const type = ROOFTOP_TYPES[pick(hash, shift, ROOFTOP_TYPES.length)];
- // Two more slices position the fixture on a 5×5 roof grid (deterministic, clamped).
- const gx = pick(hash, shift + 2, 5) / 4 - 0.5; // -0.5..0.5
- const gz = pick(hash, shift + 4, 5) / 4 - 0.5;
- fixtures.push({
- type,
- x: gx * 2 * half,
- z: gz * 2 * half,
- scale: 0.8 + pick(hash, shift + 5, 3) * 0.2, // 0.8 / 1.0 / 1.2
- rotation: pick(hash, shift + 6, 8) * (Math.PI / 4),
- });
- }
- // Two fixtures on the same grid cell read as one mangled prop — drop duplicates.
- const seen = new Set();
- return fixtures.filter((f) => {
- const key = `${Math.round(f.x * 10)},${Math.round(f.z * 10)}`;
- if (seen.has(key)) return false;
- seen.add(key);
- return true;
- });
-}
diff --git a/client/src/utils/openWorldRooftops.test.js b/client/src/utils/openWorldRooftops.test.js
deleted file mode 100644
index 35ed80b708..0000000000
--- a/client/src/utils/openWorldRooftops.test.js
+++ /dev/null
@@ -1,51 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { computeRooftopKit, ROOFTOP_TYPES } from './openWorldRooftops';
-
-describe('computeRooftopKit', () => {
- it('is deterministic per name', () => {
- expect(computeRooftopKit('port-os')).toEqual(computeRooftopKit('port-os'));
- expect(computeRooftopKit('another-app', 2)).toEqual(computeRooftopKit('another-app', 2));
- });
-
- it('returns 0–3 fixtures of known types', () => {
- for (const name of ['a', 'bb', 'ccc', 'port-os', 'my-app', 'svc-12', 'x9', 'zzz']) {
- const kit = computeRooftopKit(name);
- expect(kit.length).toBeLessThanOrEqual(3);
- for (const f of kit) {
- expect(ROOFTOP_TYPES).toContain(f.type);
- expect(f.scale).toBeGreaterThan(0);
- }
- }
- });
-
- it('keeps every fixture inside the roof bounds', () => {
- for (const width of [2, 3.5]) {
- for (const name of ['port-os', 'another-app', 'svc-12', 'big-roof-app']) {
- for (const f of computeRooftopKit(name, width)) {
- expect(Math.abs(f.x), name).toBeLessThanOrEqual(width / 2);
- expect(Math.abs(f.z), name).toBeLessThanOrEqual(width / 2);
- }
- }
- }
- });
-
- it('varies across names (not every roof identical)', () => {
- const kits = ['app-one', 'app-two', 'app-three', 'app-four', 'app-five', 'gamma', 'delta']
- .map((n) => JSON.stringify(computeRooftopKit(n)));
- expect(new Set(kits).size).toBeGreaterThan(1);
- });
-
- it('never stacks two fixtures on the same spot', () => {
- for (const name of ['port-os', 'another-app', 'svc-12', 'abcdefg', 'q']) {
- const kit = computeRooftopKit(name);
- const keys = kit.map((f) => `${Math.round(f.x * 10)},${Math.round(f.z * 10)}`);
- expect(new Set(keys).size).toBe(keys.length);
- }
- });
-
- it('tolerates a missing name', () => {
- expect(() => computeRooftopKit(undefined)).not.toThrow();
- expect(() => computeRooftopKit('')).not.toThrow();
- });
-});
-// @vitest-environment node
diff --git a/client/src/utils/openWorldSoundscape.js b/client/src/utils/openWorldSoundscape.js
deleted file mode 100644
index 46d0c09105..0000000000
--- a/client/src/utils/openWorldSoundscape.js
+++ /dev/null
@@ -1,101 +0,0 @@
-// Pure, deterministic mapping from live system state → ambient soundscape parameters
-// (OpenWorld v2 roadmap 3.4). The synth-music layer reads these to shift its mood, brightness,
-// and "energy" so the city's drone reflects what's actually happening: a healthy, quiet system
-// sounds calm and bright; a stressed system (high CPU/mem, health warnings) darkens and tenses;
-// active agents add rhythmic energy (a louder arp voice). No Web Audio / React imports so the
-// mapping is unit-testable — openWorldSynthMusic.js applies the result to the live graph.
-
-// Two chord tables the music engine switches between. BRIGHT is the existing major-leaning
-// progression (calm/healthy); TENSE is a darker, more dissonant set used when the system is
-// under stress. Frequencies are bass roots + chord tones in Hz, matching openWorldSynthMusic's shape.
-export const CHORD_SETS = {
- bright: [
- [110, 130.81, 164.81], // Am
- [82.41, 123.47, 164.81], // Em
- [87.31, 110, 130.81], // F
- [65.41, 98.0, 130.81], // C
- ],
- tense: [
- [110, 138.59, 164.81], // A diminished-ish (A, C#dim color)
- [77.78, 116.54, 155.56], // Eb — tritone-ish tension
- [92.50, 116.54, 138.59], // F#m color
- [69.30, 103.83, 138.59], // C# — unresolved
- ],
-};
-
-// The moods the soundscape can be in, in brightening→darkening order. Also the set of values a
-// manual override (#3395) may pin the music to — anything outside this list is not a mood.
-export const SOUNDSCAPE_MOODS = ['bright', 'neutral', 'tense'];
-
-// Validate an override value. `null`/`undefined` is the "auto" sentinel (follow live system
-// state); an unknown string (a stale or hand-edited stored setting) is also treated as auto
-// rather than silently pinning the music to a mood that no longer exists.
-export function isSoundscapeMood(mood) {
- return SOUNDSCAPE_MOODS.includes(mood);
-}
-
-// Classify overall system stress into a mood. Driven primarily by the health verdict, with a
-// CPU/memory fallback so a system that's hammered but not yet "warning" still tenses up.
-// `health` is the /system/health/details payload (or null); `agentCount` is live agents.
-export function classifyMood(health) {
- const verdict = health?.overallHealth;
- if (verdict === 'critical' || verdict === 'unhealthy') return 'tense';
- const cpu = health?.system?.cpu?.usagePercent ?? 0;
- const mem = health?.system?.memory?.usagePercent ?? 0;
- if (cpu >= 85 || mem >= 90) return 'tense';
- if (verdict === 'degraded' || cpu >= 65 || mem >= 75) return 'neutral';
- return 'bright';
-}
-
-// Energy 0..1 — how much rhythmic "life" the music has. Rises with the number of active agents
-// (the city is busy → the music gets busier), saturating so a swarm of agents doesn't blow it
-// out. A quiet, agent-less city sits at a low ambient floor, never fully silent.
-export function computeActivityEnergy(agentCount) {
- const n = Math.max(0, agentCount || 0);
- // Diminishing returns: 0 agents → 0.15 floor, ~5 agents → ~0.85, asymptotic to 1.
- return Math.min(1, 0.15 + (1 - Math.exp(-n / 2.5)) * 0.85);
-}
-
-// Full soundscape view-model the music engine applies. `mood` picks the chord table and a base
-// filter brightness; `energy` scales the arp/lead voice gain and a subtle tempo feel; `detune`
-// widens the pads as tension rises for an uneasy shimmer. Deterministic for a given snapshot.
-export function computeSoundscape(snapshot = {}) {
- const { systemHealth, agentCount } = snapshot;
- return soundscapeForMood(classifyMood(systemHealth), computeActivityEnergy(agentCount));
-}
-
-// Build the view-model for an explicit (mood, energy) pair. Split out of computeSoundscape so a
-// manual mood override re-derives every mood-driven field — chord table, filter, pad detune —
-// instead of swapping the chord set alone and leaving a bright filter over a tense progression.
-export function soundscapeForMood(mood, energy) {
- // Brighter (higher) filter cutoff when healthy; clamped low when tense for a muffled, anxious
- // tone. Energy nudges it up a touch so a busy-but-healthy city sparkles.
- const filterBase = (mood === 'bright' ? 320 : mood === 'neutral' ? 220 : 150) + energy * 80;
-
- return {
- mood,
- energy,
- chordSet: mood === 'tense' ? 'tense' : 'bright', // neutral still uses the bright table, just darker filter
- filterBase,
- arpGain: 0.02 + energy * 0.08, // near-silent at rest, prominent when many agents run
- padDetune: mood === 'tense' ? 14 : mood === 'neutral' ? 10 : 8, // wider = more unease
- // A gentle tempo feel: the arp envelope opens a little faster with energy. Expressed as a
- // 0..1 scalar the engine maps onto its arp attack, NOT a literal BPM change (rescheduling
- // the running intervals mid-stream would race; modulating the voice is equivalent + safe).
- pulse: energy,
- };
-}
-
-// Pin a computed soundscape to a manually chosen mood (#3395). `mood` is the persisted override:
-// `null`/`undefined` (or any non-mood value) means auto, in which case the live soundscape passes
-// through untouched. The activity-driven half stays live even under an override — the listener is
-// choosing a mood, not freezing the city's energy — so `energy`/`arpGain`/`pulse` still follow the
-// running agent count. With no live soundscape yet, the override still yields a playable
-// view-model at the resting energy floor so the forced mood applies immediately.
-export function applyMoodOverride(soundscape, mood) {
- if (!isSoundscapeMood(mood)) return soundscape;
- const energy = typeof soundscape?.energy === 'number' && Number.isFinite(soundscape.energy)
- ? soundscape.energy
- : computeActivityEnergy(0);
- return soundscapeForMood(mood, energy);
-}
diff --git a/client/src/utils/openWorldSoundscape.test.js b/client/src/utils/openWorldSoundscape.test.js
deleted file mode 100644
index 4f34779bb6..0000000000
--- a/client/src/utils/openWorldSoundscape.test.js
+++ /dev/null
@@ -1,145 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import {
- CHORD_SETS,
- SOUNDSCAPE_MOODS,
- applyMoodOverride,
- classifyMood,
- computeActivityEnergy,
- computeSoundscape,
- isSoundscapeMood,
-} from './openWorldSoundscape';
-
-describe('CHORD_SETS', () => {
- it('has bright and tense tables of equal length', () => {
- expect(CHORD_SETS.bright).toHaveLength(CHORD_SETS.tense.length);
- for (const set of [...CHORD_SETS.bright, ...CHORD_SETS.tense]) {
- expect(set).toHaveLength(3);
- }
- });
-});
-
-describe('classifyMood', () => {
- it('is bright for a healthy, quiet system', () => {
- expect(classifyMood({ overallHealth: 'healthy', system: { cpu: { usagePercent: 10 }, memory: { usagePercent: 20 } } })).toBe('bright');
- });
- it('is tense for a critical/unhealthy verdict', () => {
- expect(classifyMood({ overallHealth: 'critical' })).toBe('tense');
- expect(classifyMood({ overallHealth: 'unhealthy' })).toBe('tense');
- });
- it('tenses on very high CPU/memory even without a warning verdict', () => {
- expect(classifyMood({ system: { cpu: { usagePercent: 90 } } })).toBe('tense');
- expect(classifyMood({ system: { memory: { usagePercent: 95 } } })).toBe('tense');
- });
- it('is neutral when degraded or moderately loaded', () => {
- expect(classifyMood({ overallHealth: 'degraded' })).toBe('neutral');
- expect(classifyMood({ system: { cpu: { usagePercent: 70 } } })).toBe('neutral');
- });
- it('defaults to bright with no health data', () => {
- expect(classifyMood(null)).toBe('bright');
- expect(classifyMood(undefined)).toBe('bright');
- expect(classifyMood({})).toBe('bright');
- });
-});
-
-describe('computeActivityEnergy', () => {
- it('has a non-zero floor with no agents', () => {
- expect(computeActivityEnergy(0)).toBeCloseTo(0.15, 5);
- expect(computeActivityEnergy(undefined)).toBeCloseTo(0.15, 5);
- });
- it('rises monotonically with agent count and saturates below 1', () => {
- const e1 = computeActivityEnergy(1);
- const e3 = computeActivityEnergy(3);
- const e10 = computeActivityEnergy(10);
- expect(e3).toBeGreaterThan(e1);
- expect(e10).toBeGreaterThan(e3);
- expect(e10).toBeLessThanOrEqual(1);
- });
- it('clamps negatives to the floor', () => {
- expect(computeActivityEnergy(-5)).toBeCloseTo(0.15, 5);
- });
-});
-
-describe('computeSoundscape', () => {
- it('maps a healthy quiet city to a bright, low-energy soundscape', () => {
- const s = computeSoundscape({ systemHealth: { overallHealth: 'healthy' }, agentCount: 0 });
- expect(s.mood).toBe('bright');
- expect(s.chordSet).toBe('bright');
- expect(s.arpGain).toBeCloseTo(0.02 + 0.15 * 0.08, 4);
- expect(s.padDetune).toBe(8);
- });
- it('maps a stressed city to a tense chord set and muffled filter', () => {
- const calm = computeSoundscape({ systemHealth: { overallHealth: 'healthy' }, agentCount: 0 });
- const tense = computeSoundscape({ systemHealth: { overallHealth: 'critical' }, agentCount: 0 });
- expect(tense.chordSet).toBe('tense');
- expect(tense.filterBase).toBeLessThan(calm.filterBase);
- expect(tense.padDetune).toBeGreaterThan(calm.padDetune);
- });
- it('raises arp gain and energy as agents spin up', () => {
- const idle = computeSoundscape({ agentCount: 0 });
- const busy = computeSoundscape({ agentCount: 6 });
- expect(busy.arpGain).toBeGreaterThan(idle.arpGain);
- expect(busy.energy).toBeGreaterThan(idle.energy);
- expect(busy.pulse).toBe(busy.energy);
- });
- it('keeps the bright chord table for neutral mood (only filter darkens)', () => {
- const s = computeSoundscape({ systemHealth: { overallHealth: 'degraded' }, agentCount: 1 });
- expect(s.mood).toBe('neutral');
- expect(s.chordSet).toBe('bright');
- });
- it('handles an empty snapshot', () => {
- const s = computeSoundscape();
- expect(s.mood).toBe('bright');
- expect(Number.isFinite(s.filterBase)).toBe(true);
- });
-});
-
-describe('isSoundscapeMood', () => {
- it('accepts every listed mood and rejects the auto sentinel or junk', () => {
- SOUNDSCAPE_MOODS.forEach(mood => expect(isSoundscapeMood(mood)).toBe(true));
- [null, undefined, '', 'auto', 'BRIGHT', 0, {}].forEach(value => {
- expect(isSoundscapeMood(value)).toBe(false);
- });
- });
-});
-
-describe('applyMoodOverride', () => {
- const live = computeSoundscape({ systemHealth: { overallHealth: 'healthy' }, agentCount: 4 });
-
- it('pins the forced mood, re-deriving chord set, filter and detune', () => {
- expect(live.mood).toBe('bright');
- const forced = applyMoodOverride(live, 'tense');
- expect(forced.mood).toBe('tense');
- expect(forced.chordSet).toBe('tense');
- expect(forced.filterBase).toBeLessThan(live.filterBase);
- expect(forced.padDetune).toBeGreaterThan(live.padDetune);
- });
-
- it('keeps the activity-driven half live under an override', () => {
- const forced = applyMoodOverride(live, 'tense');
- expect(forced.energy).toBe(live.energy);
- expect(forced.arpGain).toBe(live.arpGain);
- expect(forced.pulse).toBe(live.pulse);
- });
-
- it('forces the neutral mood onto the bright table with a darker filter', () => {
- const forced = applyMoodOverride(live, 'neutral');
- expect(forced.chordSet).toBe('bright');
- expect(forced.filterBase).toBeLessThan(live.filterBase);
- });
-
- it('passes the live soundscape through untouched for auto or an unknown value', () => {
- expect(applyMoodOverride(live, null)).toBe(live);
- expect(applyMoodOverride(live, undefined)).toBe(live);
- expect(applyMoodOverride(live, '')).toBe(live);
- expect(applyMoodOverride(live, 'stale-mood')).toBe(live);
- });
-
- it('still yields a playable view-model when there is no live soundscape yet', () => {
- const forced = applyMoodOverride(null, 'tense');
- expect(forced.chordSet).toBe('tense');
- expect(forced.energy).toBeCloseTo(computeActivityEnergy(0), 5);
- expect(Number.isFinite(forced.filterBase)).toBe(true);
- expect(applyMoodOverride(null, null)).toBe(null);
- });
-});
-// @vitest-environment node
diff --git a/client/src/utils/openWorldSpeedPads.js b/client/src/utils/openWorldSpeedPads.js
deleted file mode 100644
index 45da9e287a..0000000000
--- a/client/src/utils/openWorldSpeedPads.js
+++ /dev/null
@@ -1,88 +0,0 @@
-// Pure deterministic helpers for OpenWorld boost markings.
-// They are painted lane flourishes in the village rather than sci-fi metal plates.
-// Driving onto a boost pad gives an instant surge of acceleration and plays a turbo SFX.
-// No three.js / React imports — pure, testable in node.
-
-export const DEFAULT_PAD_BOOST_SPEED = 48; // Peak surge velocity in units/s
-export const PAD_TRIGGER_RADIUS = 2.4;
-
-export const SPEED_PADS = [
- {
- id: 'pad-harbor-lane',
- label: 'Harbor Lane',
- x: 0,
- z: -41,
- angle: -Math.PI / 2,
- width: 3.7,
- length: 5.2,
- boostSpeed: DEFAULT_PAD_BOOST_SPEED,
- color: '#7cc9be',
- },
- {
- id: 'pad-arrival-lane',
- label: 'Village Welcome',
- x: 0,
- z: 34,
- angle: -Math.PI / 2,
- width: 3.7,
- length: 5.2,
- boostSpeed: DEFAULT_PAD_BOOST_SPEED,
- color: '#f3b856',
- },
- {
- id: 'pad-west-loop',
- label: 'Orchard Bend',
- x: -28,
- z: 8,
- angle: -Math.PI / 2,
- width: 3.6,
- length: 4.8,
- boostSpeed: DEFAULT_PAD_BOOST_SPEED,
- color: '#ec8265',
- },
- {
- id: 'pad-east-loop',
- label: 'Pond Bend',
- x: 28,
- z: 6,
- angle: Math.PI / 2,
- width: 3.6,
- length: 4.8,
- boostSpeed: DEFAULT_PAD_BOOST_SPEED,
- color: '#8ccf9e',
- },
-];
-
-// Return list of speed boost pads with geometric bounding envelopes
-export function getSpeedPadsList() {
- return SPEED_PADS.map((pad) => ({
- ...pad,
- halfWidth: pad.width / 2,
- halfLength: pad.length / 2,
- }));
-}
-
-// Check if player position is currently overlapping a speed boost pad using oriented box bounds
-export function checkSpeedPadOverlap(playerPos, pads = SPEED_PADS, padding = 0.4) {
- if (!playerPos || typeof playerPos.x !== 'number' || typeof playerPos.z !== 'number') {
- return null;
- }
-
- for (const pad of pads) {
- const dx = playerPos.x - pad.x;
- const dz = playerPos.z - pad.z;
- const cos = Math.cos(-pad.angle);
- const sin = Math.sin(-pad.angle);
- const localX = cos * dx - sin * dz;
- const localZ = sin * dx + cos * dz;
-
- const halfL = (pad.length / 2) + padding;
- const halfW = (pad.width / 2) + padding;
-
- if (Math.abs(localX) <= halfL && Math.abs(localZ) <= halfW) {
- return pad;
- }
- }
-
- return null;
-}
diff --git a/client/src/utils/openWorldSpeedPads.test.js b/client/src/utils/openWorldSpeedPads.test.js
deleted file mode 100644
index 5b1681a9f7..0000000000
--- a/client/src/utils/openWorldSpeedPads.test.js
+++ /dev/null
@@ -1,56 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import {
- SPEED_PADS,
- getSpeedPadsList,
- checkSpeedPadOverlap,
- PAD_TRIGGER_RADIUS,
-} from './openWorldSpeedPads';
-
-describe('openWorldSpeedPads', () => {
- it('defines a valid set of speed pads with positions and angles', () => {
- expect(SPEED_PADS.length).toBeGreaterThanOrEqual(4);
- const ids = new Set();
- SPEED_PADS.forEach((pad) => {
- expect(typeof pad.id).toBe('string');
- expect(typeof pad.x).toBe('number');
- expect(typeof pad.z).toBe('number');
- expect(typeof pad.angle).toBe('number');
- expect(typeof pad.width).toBe('number');
- expect(typeof pad.length).toBe('number');
- expect(pad.boostSpeed).toBeGreaterThan(30);
- expect(ids.has(pad.id)).toBe(false);
- ids.add(pad.id);
- });
- });
-
- it('getSpeedPadsList returns pads with half dimensions', () => {
- const list = getSpeedPadsList();
- list.forEach((pad) => {
- expect(pad.halfWidth).toBe(pad.width / 2);
- expect(pad.halfLength).toBe(pad.length / 2);
- });
- });
-
- it('checkSpeedPadOverlap returns pad when player is directly on it', () => {
- const pad = SPEED_PADS[0];
- const playerPos = { x: pad.x + 0.2, z: pad.z + 0.2 };
- const matched = checkSpeedPadOverlap(playerPos, SPEED_PADS, PAD_TRIGGER_RADIUS);
- expect(matched).toBeDefined();
- expect(matched?.id).toBe(pad.id);
- });
-
- it('checkSpeedPadOverlap returns pad when player is on the rectangular edge', () => {
- // Pad 0 has length 5.5 (half 2.75). Point at pad.z - 2.5 along the facing direction (-Z)
- const pad = SPEED_PADS[0]; // angle: -Math.PI / 2 (length extends along -Z / +Z in world space)
- const playerPos = { x: pad.x, z: pad.z - 2.5 };
- const matched = checkSpeedPadOverlap(playerPos, SPEED_PADS);
- expect(matched).toBeDefined();
- expect(matched?.id).toBe(pad.id);
- });
-
- it('checkSpeedPadOverlap returns null when player is far away or invalid', () => {
- expect(checkSpeedPadOverlap(null)).toBeNull();
- expect(checkSpeedPadOverlap({ x: 999, z: 999 })).toBeNull();
- expect(checkSpeedPadOverlap({ x: 'invalid', z: 0 })).toBeNull();
- });
-});
diff --git a/client/src/utils/openWorldTaskFlowRiver.js b/client/src/utils/openWorldTaskFlowRiver.js
deleted file mode 100644
index a2abc1943b..0000000000
--- a/client/src/utils/openWorldTaskFlowRiver.js
+++ /dev/null
@@ -1,153 +0,0 @@
-// Pure, deterministic helpers for OpenWorld's task-flow river (roadmap 2.6 follow-up, issue
-// #817): an animated flow that runs from the task-queue warehouse to the productivity
-// district, visually tying queued work to completed throughput. The river's WIDTH tracks the
-// live backlog (more pending/in-progress work → a broader channel) and its SPEED tracks
-// recent throughput / queue drain (more recently-completed tasks relative to the backlog → a
-// faster current). No three.js / React imports so the topology is unit-testable (mirrors
-// openWorldProductivity.js / openWorldTaskQueue.js).
-
-import { MONUMENT } from './openWorldProductivity';
-import { TASK_QUEUE } from './openWorldTaskQueue';
-
-export const RIVER = {
- // The channel runs warehouse → monument. Endpoints mirror the two districts it links.
- from: TASK_QUEUE.position, // task-queue warehouse (east)
- to: MONUMENT.position, // productivity-district monument (southwest)
- minWidth: 1.4, // a thin trickle when the queue is empty
- maxWidth: 7, // a broad channel at/above the backlog cap
- backlogCap: 12, // backlog count mapped to full width; beyond this stays capped
- minSpeed: 0.15, // a barely-moving current when nothing is draining
- maxSpeed: 1.6, // full-tilt flow when throughput is high
- throughputCap: 10, // recently-completed count mapped to full speed
- particleSpacing: 3.2, // world units between flow particles along the channel
-};
-
-const ACTIVE_COLOR = '#22c55e'; // port-success — work is draining (completing)
-const QUEUED_COLOR = '#3b82f6'; // port-accent — backlog present, little draining
-const BLOCKED_COLOR = '#f59e0b'; // port-warning — blocked work gumming up the flow
-const IDLE_COLOR = '#475569'; // slate — empty channel, nothing moving
-
-const clamp01 = (n) => Math.max(0, Math.min(1, n));
-
-// Coerce to a finite, non-negative number or 0 — counts and throughput are never negative.
-function nonNegOrZero(value) {
- return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0;
-}
-
-// Map a backlog count to a 0..1 width level against the cap, clamped.
-export function widthLevel(backlog, cap = RIVER.backlogCap) {
- const b = nonNegOrZero(backlog);
- const c = nonNegOrZero(cap) || 1;
- return clamp01(b / c);
-}
-
-// Map a recent-throughput count to a 0..1 speed level against the cap, clamped.
-export function speedLevel(throughput, cap = RIVER.throughputCap) {
- const t = nonNegOrZero(throughput);
- const c = nonNegOrZero(cap) || 1;
- return clamp01(t / c);
-}
-
-// Sum completed tasks over the most recent `days` calendar days (excluding future days), as a
-// bounded "recent drain" signal. The activity calendar's `summary.totalTasks` spans the whole
-// 12-week window, so it would pin the river at full speed forever after a few historical
-// completions; a trailing window keeps the current honest about *recent* throughput. Returns
-// null when there's no usable calendar so the caller can fall back to today's count.
-export function recentCalendarThroughput(calendarData, days = 7) {
- const weeks = Array.isArray(calendarData?.weeks) ? calendarData.weeks : null;
- if (!weeks) return null;
- const flat = [];
- for (const week of weeks) {
- if (!Array.isArray(week)) continue;
- for (const day of week) {
- if (day && typeof day === 'object' && !day.isFuture) flat.push(day);
- }
- }
- if (flat.length === 0) return null;
- const window = days > 0 ? flat.slice(-days) : flat;
- return window.reduce((sum, day) => sum + nonNegOrZero(day.tasks), 0);
-}
-
-// Euclidean distance on the ground plane (x/z) between the two endpoints.
-function groundLength(from, to) {
- const dx = to[0] - from[0];
- const dz = to[2] - from[2];
- return Math.sqrt(dx * dx + dz * dz);
-}
-
-// Full derived view-model for the component. Inputs:
-// - `taskQueue`: the `computeTaskQueue` view-model (`{ pending, inProgress, blocked, ... }`)
-// — the live backlog that sets the channel width.
-// - `recentThroughput`: a count of recently-completed tasks (e.g. the calendar window's
-// `summary.totalTasks` or today's completed) that sets the current speed.
-// Tolerates missing/garbage inputs by reading them as zero, yielding a thin, near-still,
-// idle-slate channel that still renders as a quiet seam between the districts.
-export function computeTaskFlowRiver(taskQueue, recentThroughput) {
- const queue = taskQueue && typeof taskQueue === 'object' ? taskQueue : {};
- const pending = nonNegOrZero(queue.pending);
- const inProgress = nonNegOrZero(queue.inProgress);
- const blocked = nonNegOrZero(queue.blocked);
- const backlog = pending + inProgress + blocked;
- const throughput = nonNegOrZero(recentThroughput);
-
- const wLevel = widthLevel(backlog);
- const sLevel = speedLevel(throughput);
-
- const width = RIVER.minWidth + wLevel * (RIVER.maxWidth - RIVER.minWidth);
- const speed = RIVER.minSpeed + sLevel * (RIVER.maxSpeed - RIVER.minSpeed);
-
- // Color reads the queue's dominant state, mirroring the warehouse: blocked work warns,
- // active draining flows green, a non-empty backlog sits accent-blue, an empty channel idles.
- let state;
- if (blocked > 0) state = 'blocked';
- else if (inProgress > 0) state = 'active';
- else if (pending > 0) state = 'queued';
- else state = 'idle';
- const color = state === 'blocked' ? BLOCKED_COLOR
- : state === 'active' ? ACTIVE_COLOR
- : state === 'queued' ? QUEUED_COLOR
- : IDLE_COLOR;
-
- const length = groundLength(RIVER.from, RIVER.to);
- // Rotation about +Y so the channel's local +x (its length axis) points from `from` toward
- // `to`. A +Y rotation by θ maps local +x (1,0,0) to world (cosθ, 0, -sinθ); aligning that
- // with the ground-plane direction (dx, dz) gives θ = atan2(-dz, dx).
- const dx = RIVER.to[0] - RIVER.from[0];
- const dz = RIVER.to[2] - RIVER.from[2];
- const angle = Math.atan2(-dz, dx);
- const center = [
- (RIVER.from[0] + RIVER.to[0]) / 2,
- 0,
- (RIVER.from[2] + RIVER.to[2]) / 2,
- ];
-
- // Evenly-spaced flow particles along the channel; their count scales with length so the
- // current reads continuously regardless of how far apart the districts sit. Each carries a
- // 0..1 phase offset so the component can animate them flowing along the channel.
- const particleCount = Math.max(2, Math.round(length / RIVER.particleSpacing));
- const particles = [];
- for (let i = 0; i < particleCount; i++) {
- particles.push({ index: i, phase: i / particleCount });
- }
-
- return {
- from: RIVER.from,
- to: RIVER.to,
- center,
- angle,
- length,
- width,
- widthLevel: wLevel,
- speed,
- speedLevel: sLevel,
- backlog,
- pending,
- inProgress,
- blocked,
- throughput,
- state,
- color,
- flowing: state !== 'idle' && sLevel > 0,
- particles,
- };
-}
diff --git a/client/src/utils/openWorldTaskFlowRiver.test.js b/client/src/utils/openWorldTaskFlowRiver.test.js
deleted file mode 100644
index df694f3245..0000000000
--- a/client/src/utils/openWorldTaskFlowRiver.test.js
+++ /dev/null
@@ -1,162 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { MONUMENT } from './openWorldProductivity';
-import { TASK_QUEUE } from './openWorldTaskQueue';
-import {
- RIVER,
- widthLevel,
- speedLevel,
- recentCalendarThroughput,
- computeTaskFlowRiver,
-} from './openWorldTaskFlowRiver';
-
-describe('widthLevel', () => {
- it('maps backlog/cap into 0..1', () => {
- expect(widthLevel(6, 12)).toBe(0.5);
- expect(widthLevel(12, 12)).toBe(1);
- });
- it('clamps above the cap and reads garbage as 0', () => {
- expect(widthLevel(99, 12)).toBe(1);
- expect(widthLevel(0, 12)).toBe(0);
- expect(widthLevel(undefined, 12)).toBe(0);
- expect(widthLevel(-3, 12)).toBe(0);
- });
-});
-
-describe('speedLevel', () => {
- it('maps throughput/cap into 0..1', () => {
- expect(speedLevel(5, 10)).toBe(0.5);
- expect(speedLevel(10, 10)).toBe(1);
- });
- it('clamps above the cap and reads garbage as 0', () => {
- expect(speedLevel(50, 10)).toBe(1);
- expect(speedLevel(undefined, 10)).toBe(0);
- expect(speedLevel('5', 10)).toBe(0);
- });
-});
-
-describe('computeTaskFlowRiver', () => {
- it('connects the warehouse to the monument', () => {
- const vm = computeTaskFlowRiver({ pending: 0 }, 0);
- expect(vm.from).toEqual(TASK_QUEUE.position);
- expect(vm.to).toEqual(MONUMENT.position);
- // Center is the midpoint of the two endpoints.
- expect(vm.center[0]).toBeCloseTo((TASK_QUEUE.position[0] + MONUMENT.position[0]) / 2);
- expect(vm.center[2]).toBeCloseTo((TASK_QUEUE.position[2] + MONUMENT.position[2]) / 2);
- // Length is the ground-plane distance between them.
- const dx = MONUMENT.position[0] - TASK_QUEUE.position[0];
- const dz = MONUMENT.position[2] - TASK_QUEUE.position[2];
- expect(vm.length).toBeCloseTo(Math.sqrt(dx * dx + dz * dz));
- });
-
- it('yaws so the channel local +x points from warehouse to monument', () => {
- const vm = computeTaskFlowRiver({ pending: 0 }, 0);
- // A +Y rotation by `angle` maps local +x (1,0,0) to world (cos, 0, -sin); that vector,
- // scaled by length, must reconstruct the warehouse→monument displacement.
- const dirX = Math.cos(vm.angle) * vm.length;
- const dirZ = -Math.sin(vm.angle) * vm.length;
- expect(dirX).toBeCloseTo(MONUMENT.position[0] - TASK_QUEUE.position[0]);
- expect(dirZ).toBeCloseTo(MONUMENT.position[2] - TASK_QUEUE.position[2]);
- });
-
- it('widens with backlog and quickens with throughput', () => {
- const empty = computeTaskFlowRiver({ pending: 0, inProgress: 0, blocked: 0 }, 0);
- const busy = computeTaskFlowRiver({ pending: 8, inProgress: 4, blocked: 0 }, 10);
- expect(busy.width).toBeGreaterThan(empty.width);
- expect(busy.speed).toBeGreaterThan(empty.speed);
- expect(busy.backlog).toBe(12);
- expect(busy.widthLevel).toBe(1); // 12 >= backlogCap
- expect(busy.speedLevel).toBe(1); // 10 >= throughputCap
- expect(busy.width).toBeCloseTo(RIVER.maxWidth);
- expect(busy.speed).toBeCloseTo(RIVER.maxSpeed);
- });
-
- it('floors width/speed for an empty, still channel', () => {
- const vm = computeTaskFlowRiver({ pending: 0 }, 0);
- expect(vm.width).toBeCloseTo(RIVER.minWidth);
- expect(vm.speed).toBeCloseTo(RIVER.minSpeed);
- expect(vm.flowing).toBe(false);
- expect(vm.state).toBe('idle');
- expect(vm.color).toBe('#475569');
- });
-
- it('colors by dominant queue state', () => {
- expect(computeTaskFlowRiver({ blocked: 1 }, 1).state).toBe('blocked');
- expect(computeTaskFlowRiver({ blocked: 1 }, 1).color).toBe('#f59e0b');
- expect(computeTaskFlowRiver({ inProgress: 2 }, 3).state).toBe('active');
- expect(computeTaskFlowRiver({ inProgress: 2 }, 3).color).toBe('#22c55e');
- expect(computeTaskFlowRiver({ pending: 2 }, 0).state).toBe('queued');
- expect(computeTaskFlowRiver({ pending: 2 }, 0).color).toBe('#3b82f6');
- });
-
- it('flows only when there is throughput and a non-idle state', () => {
- expect(computeTaskFlowRiver({ inProgress: 1 }, 4).flowing).toBe(true);
- // backlog present but nothing draining → not flowing
- expect(computeTaskFlowRiver({ pending: 5 }, 0).flowing).toBe(false);
- });
-
- it('emits evenly-phased particles scaled to channel length', () => {
- const vm = computeTaskFlowRiver({ pending: 3 }, 2);
- expect(vm.particles.length).toBeGreaterThanOrEqual(2);
- expect(vm.particles[0].phase).toBe(0);
- expect(vm.particles.every((p, i) => p.index === i)).toBe(true);
- expect(vm.particles.every((p) => p.phase >= 0 && p.phase < 1)).toBe(true);
- });
-
-});
-
-describe('recentCalendarThroughput', () => {
- const cal = (taskRows) => ({
- weeks: taskRows.map((row) =>
- row.map((tasks, dow) => ({ date: `d${dow}`, dayOfWeek: dow, tasks, isFuture: false }))
- ),
- summary: { totalTasks: taskRows.flat().reduce((s, t) => s + t, 0) },
- });
-
- it('sums only the most recent N days, excluding future days', () => {
- // 14 days total (two weeks); last 7 days carry 1+2+3 = 6 tasks.
- const data = cal([
- [5, 5, 5, 5, 5, 5, 5],
- [0, 0, 0, 0, 1, 2, 3],
- ]);
- expect(recentCalendarThroughput(data, 7)).toBe(6);
- });
-
- it('windows away the 12-week-total saturation problem', () => {
- // A big historical total but a quiet recent week reads as low, not pinned high.
- const busyWeeks = Array.from({ length: 12 }, () => [9, 9, 9, 9, 9, 9, 9]);
- busyWeeks.push([0, 0, 0, 0, 0, 0, 0]); // quiet current week
- const data = cal(busyWeeks);
- expect(data.summary.totalTasks).toBe(12 * 7 * 9); // huge historical total
- expect(recentCalendarThroughput(data, 7)).toBe(0); // but the recent window is quiet
- });
-
- it('skips future days in the trailing window', () => {
- const data = cal([[1, 1, 1, 1, 1, 1, 1]]);
- data.weeks[0][5].isFuture = true;
- data.weeks[0][6].isFuture = true;
- expect(recentCalendarThroughput(data, 7)).toBe(5);
- });
-
- it('returns null for a missing/empty calendar so the caller can fall back', () => {
- expect(recentCalendarThroughput(null)).toBeNull();
- expect(recentCalendarThroughput({})).toBeNull();
- expect(recentCalendarThroughput({ weeks: [] })).toBeNull();
- expect(recentCalendarThroughput({ weeks: 'oops' })).toBeNull();
- });
-});
-
-describe('computeTaskFlowRiver — idle resilience', () => {
- it('handles missing / non-object inputs as an idle trickle without crashing', () => {
- for (const badQueue of [null, undefined, 'nope', 42, []]) {
- for (const badThroughput of [null, undefined, 'nope', -5, NaN]) {
- const vm = computeTaskFlowRiver(badQueue, badThroughput);
- expect(vm.state).toBe('idle');
- expect(vm.backlog).toBe(0);
- expect(vm.throughput).toBe(0);
- expect(vm.width).toBeCloseTo(RIVER.minWidth);
- expect(vm.from).toEqual(TASK_QUEUE.position);
- }
- }
- });
-});
-// @vitest-environment node
diff --git a/client/src/utils/openWorldTaskQueue.js b/client/src/utils/openWorldTaskQueue.js
deleted file mode 100644
index 1f696228ac..0000000000
--- a/client/src/utils/openWorldTaskQueue.js
+++ /dev/null
@@ -1,78 +0,0 @@
-// Pure, deterministic helpers for OpenWorld's CoS task-queue silhouette (roadmap 2.2):
-// a warehouse east of downtown whose stacked crates track the depth of the Chief-of-
-// Staff task queue. Crates accumulate as tasks queue up and clear as they complete; an
-// in-progress task lights the warehouse, a blocked task tints it amber. No three.js /
-// React imports so the topology is unit-testable (mirrors openWorldBackupVault.js).
-
-import { tallyByKey } from './openWorldDistrictLayout';
-import { PARCELS } from './openWorldPlan';
-
-export const TASK_QUEUE = {
- position: PARCELS.taskQueue.anchor, // east of the building grid, mirroring the backup vault to the west
- maxCrates: 8, // visible-crate cap; beyond this the warehouse reads as "overflow"
- crateSize: 1.5,
- crateGap: 0.18,
- warehouseWidth: 6,
- warehouseHeight: 4,
-};
-
-// Warehouse lighting color per queue "mood". Reuses the PortOS Tailwind tokens so the
-// silhouette speaks the same visual language as the rest of the UI.
-const STATE_COLORS = {
- idle: '#475569', // slate — empty queue, nothing waiting
- queued: '#3b82f6', // port-accent — pending work piling up
- active: '#22c55e', // port-success — an agent is working a task
- blocked: '#f59e0b', // port-warning — a task needs attention
-};
-
-// Tally CoS tasks by status. Tolerates a missing/non-array input (returns all-zero) and
-// buckets unrecognized statuses under `other` so the counts always sum to the input length.
-const KNOWN_TASK_STATUSES = ['pending', 'in_progress', 'blocked', 'completed'];
-const taskStatusKey = (t) => (KNOWN_TASK_STATUSES.includes(t?.status) ? t.status : 'other');
-export function countByStatus(tasks) {
- return tallyByKey(tasks, taskStatusKey, [...KNOWN_TASK_STATUSES, 'other']);
-}
-
-// Overall queue mood for the warehouse lighting, in priority order: a blocked task is the
-// loudest signal, then active work, then a non-empty backlog, then idle.
-export function queueState(counts) {
- if (counts?.blocked > 0) return 'blocked';
- if (counts?.in_progress > 0) return 'active';
- if (counts?.pending > 0) return 'queued';
- return 'idle';
-}
-
-export function queueColor(state) {
- return STATE_COLORS[state] || STATE_COLORS.idle;
-}
-
-// Full derived view-model for the component: counts + warehouse state/color + a crate
-// layout. The crate stack height tracks pending (queued) work — the depth of the queue —
-// capped at maxCrates with an `overflow` flag when more is waiting than crates shown.
-export function computeTaskQueue(tasks, opts = {}) {
- const maxCrates = opts.maxCrates ?? TASK_QUEUE.maxCrates;
- const counts = countByStatus(tasks);
- const state = queueState(counts);
- const crateCount = Math.min(counts.pending, maxCrates);
- const crates = [];
- for (let i = 0; i < crateCount; i++) {
- crates.push({
- index: i,
- y: TASK_QUEUE.crateSize / 2 + i * (TASK_QUEUE.crateSize + TASK_QUEUE.crateGap),
- });
- }
- return {
- position: TASK_QUEUE.position,
- pending: counts.pending,
- inProgress: counts.in_progress,
- blocked: counts.blocked,
- total: counts.pending + counts.in_progress + counts.blocked,
- state,
- color: queueColor(state),
- crateCount,
- crates,
- overflow: counts.pending > maxCrates,
- active: counts.in_progress > 0,
- hasBlocked: counts.blocked > 0,
- };
-}
diff --git a/client/src/utils/openWorldTaskQueue.test.js b/client/src/utils/openWorldTaskQueue.test.js
deleted file mode 100644
index fbef367485..0000000000
--- a/client/src/utils/openWorldTaskQueue.test.js
+++ /dev/null
@@ -1,128 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import {
- TASK_QUEUE,
- countByStatus,
- queueState,
- queueColor,
- computeTaskQueue,
-} from './openWorldTaskQueue';
-
-const tasks = (...statuses) => statuses.map((status, i) => ({ id: `t${i}`, status }));
-
-describe('countByStatus', () => {
- it('tallies each status and buckets unknowns under other', () => {
- const c = countByStatus(tasks('pending', 'pending', 'in_progress', 'blocked', 'completed', 'weird'));
- expect(c).toEqual({ pending: 2, in_progress: 1, blocked: 1, completed: 1, other: 1 });
- });
-
- it('returns all-zero for missing / non-array input', () => {
- const zero = { pending: 0, in_progress: 0, blocked: 0, completed: 0, other: 0 };
- expect(countByStatus(undefined)).toEqual(zero);
- expect(countByStatus(null)).toEqual(zero);
- expect(countByStatus('nope')).toEqual(zero);
- expect(countByStatus([])).toEqual(zero);
- });
-
- it('counts always sum to the input length', () => {
- const list = tasks('pending', 'in_progress', 'blocked', 'completed', 'other', 'pending');
- const c = countByStatus(list);
- const sum = c.pending + c.in_progress + c.blocked + c.completed + c.other;
- expect(sum).toBe(list.length);
- });
-});
-
-describe('queueState', () => {
- it('prioritizes blocked > active > queued > idle', () => {
- expect(queueState({ blocked: 1, in_progress: 3, pending: 5 })).toBe('blocked');
- expect(queueState({ blocked: 0, in_progress: 2, pending: 5 })).toBe('active');
- expect(queueState({ blocked: 0, in_progress: 0, pending: 5 })).toBe('queued');
- expect(queueState({ blocked: 0, in_progress: 0, pending: 0 })).toBe('idle');
- });
-
- it('treats missing counts as idle', () => {
- expect(queueState(undefined)).toBe('idle');
- expect(queueState({})).toBe('idle');
- });
-});
-
-describe('queueColor', () => {
- it('maps each state to a distinct token', () => {
- expect(queueColor('idle')).toBe('#475569');
- expect(queueColor('queued')).toBe('#3b82f6');
- expect(queueColor('active')).toBe('#22c55e');
- expect(queueColor('blocked')).toBe('#f59e0b');
- });
-
- it('falls back to idle for an unknown state', () => {
- expect(queueColor('bogus')).toBe(queueColor('idle'));
- });
-});
-
-describe('computeTaskQueue', () => {
- it('carries the fixed position through unchanged', () => {
- expect(computeTaskQueue([]).position).toEqual(TASK_QUEUE.position);
- });
-
- it('an empty queue is idle with no crates', () => {
- const vm = computeTaskQueue([]);
- expect(vm.state).toBe('idle');
- expect(vm.crateCount).toBe(0);
- expect(vm.crates).toEqual([]);
- expect(vm.overflow).toBe(false);
- expect(vm.total).toBe(0);
- });
-
- it('stacks one crate per pending task', () => {
- const vm = computeTaskQueue(tasks('pending', 'pending', 'pending'));
- expect(vm.pending).toBe(3);
- expect(vm.crateCount).toBe(3);
- expect(vm.crates).toHaveLength(3);
- expect(vm.state).toBe('queued');
- expect(vm.overflow).toBe(false);
- });
-
- it('stacks crates upward with increasing y', () => {
- const vm = computeTaskQueue(tasks('pending', 'pending'));
- expect(vm.crates[0].y).toBeLessThan(vm.crates[1].y);
- expect(vm.crates[0].y).toBeCloseTo(TASK_QUEUE.crateSize / 2);
- });
-
- it('caps crate count at maxCrates and flags overflow', () => {
- const many = tasks(...Array(TASK_QUEUE.maxCrates + 4).fill('pending'));
- const vm = computeTaskQueue(many);
- expect(vm.pending).toBe(TASK_QUEUE.maxCrates + 4);
- expect(vm.crateCount).toBe(TASK_QUEUE.maxCrates);
- expect(vm.overflow).toBe(true);
- });
-
- it('honors a custom maxCrates', () => {
- const vm = computeTaskQueue(tasks('pending', 'pending', 'pending'), { maxCrates: 2 });
- expect(vm.crateCount).toBe(2);
- expect(vm.overflow).toBe(true);
- });
-
- it('lights active when an agent is working, even with pending backlog', () => {
- const vm = computeTaskQueue(tasks('pending', 'pending', 'in_progress'));
- expect(vm.state).toBe('active');
- expect(vm.active).toBe(true);
- expect(vm.color).toBe('#22c55e');
- // crate stack still reflects the pending backlog, independent of the lighting
- expect(vm.crateCount).toBe(2);
- });
-
- it('tints blocked when any task needs attention, overriding active', () => {
- const vm = computeTaskQueue(tasks('pending', 'in_progress', 'blocked'));
- expect(vm.state).toBe('blocked');
- expect(vm.hasBlocked).toBe(true);
- expect(vm.color).toBe('#f59e0b');
- expect(vm.total).toBe(3);
- });
-
- it('ignores completed tasks for stacking and totals', () => {
- const vm = computeTaskQueue(tasks('completed', 'completed', 'pending'));
- expect(vm.crateCount).toBe(1);
- expect(vm.total).toBe(1);
- expect(vm.state).toBe('queued');
- });
-});
-// @vitest-environment node
diff --git a/client/src/utils/openWorldTimeline.js b/client/src/utils/openWorldTimeline.js
deleted file mode 100644
index 7c9fc65b25..0000000000
--- a/client/src/utils/openWorldTimeline.js
+++ /dev/null
@@ -1,113 +0,0 @@
-// Pure helpers for OpenWorld's recent-action timeline overlay.
-//
-// The Intel pane's ACTIVITY tab shows a flat, newest-at-bottom log; the
-// TIMELINE tab instead conveys *cadence* — when bursts of activity happened
-// versus quiet stretches — by binning recent events into a density sparkbar
-// and grouping them into relative-age buckets. Both transforms are pure so
-// they can be unit-tested without a DOM (mirrors openWorldFilter.js).
-
-// Default cap on events the timeline considers, so a runaway log can't blow
-// up the render. `now` is injectable into both transforms for deterministic tests.
-const MAX_TIMELINE_EVENTS = 40;
-
-// Append a socket burst to the HUD's bounded activity buffer in one immutable
-// update. The data hook batches raw cos:log frames before calling this helper so
-// a chatty agent costs one React render per short window, without dropping any
-// entries from the burst.
-export function appendEventLogBatch(previous, incoming, max = 50) {
- const combined = [...(previous || []), ...(incoming || [])];
- return combined.length > max ? combined.slice(-max) : combined;
-}
-
-// Lower-case so lookups against the lower-cased color maps in OpenWorldIntelPane
-// stay robust; the cos:log stream only emits info/warn/error/success/debug, so
-// unknown values just fall through to the maps' info default (same as the
-// sibling ACTIVITY tab).
-const normalizeLevel = (level) => (level || 'info').toLowerCase();
-
-const eventTime = (log) => {
- const t = new Date(log?.timestamp ?? NaN).getTime();
- return Number.isFinite(t) ? t : null;
-};
-
-/**
- * Bin recent events into evenly-spaced time slots for a density sparkbar.
- * Returns one entry per bin, oldest-first, each `{ count, level }` where
- * `level` is the highest-severity event in that bin (drives the bar color).
- *
- * @param {Array} logs - raw event log entries (`{ timestamp, level }`)
- * @param {object} opts
- * @param {number} opts.now - reference "now" epoch ms (injectable for tests)
- * @param {number} [opts.windowMs] - how far back the bar spans (default 10m)
- * @param {number} [opts.bins] - number of bars (default 24)
- * @returns {Array<{count:number, level:string|null}>}
- */
-export function computeActivityDensity(logs, { now, windowMs = 10 * 60 * 1000, bins = 24 } = {}) {
- const slotMs = windowMs / bins;
- const slots = Array.from({ length: bins }, () => ({ count: 0, level: null }));
-
- const severityRank = { error: 3, warn: 2, success: 1, info: 0, debug: 0 };
-
- (logs || []).forEach(log => {
- const t = eventTime(log);
- if (t == null) return;
- const ageMs = now - t;
- if (ageMs < 0 || ageMs >= windowMs) return; // outside the visible window
- // Oldest events land in bin 0, newest in the last bin.
- const idx = Math.min(bins - 1, Math.floor((windowMs - ageMs) / slotMs));
- const slot = slots[idx];
- slot.count += 1;
- const lvl = normalizeLevel(log.level);
- if (slot.level == null || (severityRank[lvl] ?? 0) > (severityRank[slot.level] ?? 0)) {
- slot.level = lvl;
- }
- });
-
- return slots;
-}
-
-// Relative-age buckets, newest first. Each event falls into the first bucket
-// whose `maxAgeMs` it does not exceed; the final bucket is open-ended.
-const BUCKET_DEFS = [
- { id: 'now', label: 'JUST NOW', maxAgeMs: 60 * 1000 },
- { id: 'recent', label: 'LAST 5 MIN', maxAgeMs: 5 * 60 * 1000 },
- { id: 'quarter', label: 'LAST 15 MIN', maxAgeMs: 15 * 60 * 1000 },
- { id: 'older', label: 'EARLIER', maxAgeMs: Infinity },
-];
-
-/**
- * Group recent events into relative-age buckets, newest event first within
- * each bucket. Empty buckets are dropped. Each event carries its normalized
- * level and ms-age so the renderer can show "2m ago" without re-parsing.
- *
- * @param {Array} logs - raw event log entries
- * @param {object} opts
- * @param {number} opts.now - reference "now" epoch ms (injectable for tests)
- * @param {number} [opts.max] - cap on total events considered (default 40)
- * @returns {Array<{id:string, label:string, events:Array}>}
- */
-export function buildTimelineBuckets(logs, { now, max = MAX_TIMELINE_EVENTS } = {}) {
- const dated = (logs || [])
- .map(log => {
- const t = eventTime(log);
- if (t == null) return null;
- return {
- id: log._localId ?? `${log.timestamp}-${log.message || log.event || ''}`,
- ageMs: now - t,
- timestamp: t,
- level: normalizeLevel(log.level),
- message: log.message || log.event || '',
- };
- })
- .filter(e => e && e.ageMs >= 0)
- .sort((a, b) => b.timestamp - a.timestamp) // newest first
- .slice(0, max);
-
- const buckets = BUCKET_DEFS.map(def => ({ id: def.id, label: def.label, events: [] }));
- dated.forEach(event => {
- const idx = BUCKET_DEFS.findIndex(def => event.ageMs < def.maxAgeMs);
- buckets[idx === -1 ? buckets.length - 1 : idx].events.push(event);
- });
-
- return buckets.filter(b => b.events.length > 0);
-}
diff --git a/client/src/utils/openWorldTimeline.test.js b/client/src/utils/openWorldTimeline.test.js
deleted file mode 100644
index 0282f6fefa..0000000000
--- a/client/src/utils/openWorldTimeline.test.js
+++ /dev/null
@@ -1,132 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { appendEventLogBatch, computeActivityDensity, buildTimelineBuckets } from './openWorldTimeline';
-
-// Fixed reference "now" so the relative-age math is deterministic.
-const NOW = 1_700_000_000_000;
-const minsAgo = (m) => NOW - m * 60 * 1000;
-const secsAgo = (s) => NOW - s * 1000;
-
-describe('appendEventLogBatch', () => {
- it('preserves every event in a socket burst and caps the oldest entries', () => {
- const previous = [{ _localId: 1 }, { _localId: 2 }];
- const incoming = [{ _localId: 3 }, { _localId: 4 }, { _localId: 5 }];
-
- expect(appendEventLogBatch(previous, incoming, 4).map((event) => event._localId))
- .toEqual([2, 3, 4, 5]);
- expect(previous).toHaveLength(2);
- expect(incoming).toHaveLength(3);
- });
-});
-
-describe('computeActivityDensity', () => {
- it('returns one empty slot per bin', () => {
- const slots = computeActivityDensity([], { now: NOW, bins: 24 });
- expect(slots).toHaveLength(24);
- expect(slots.every(s => s.count === 0 && s.level === null)).toBe(true);
- });
-
- it('bins recent events into the correct slot and counts them', () => {
- const logs = [
- { timestamp: secsAgo(5), level: 'info' }, // newest → last bin
- { timestamp: secsAgo(6), level: 'info' },
- { timestamp: minsAgo(9.9), level: 'info' }, // oldest → bin 0
- ];
- const slots = computeActivityDensity(logs, { now: NOW, windowMs: 10 * 60 * 1000, bins: 24 });
- expect(slots[slots.length - 1].count).toBe(2);
- expect(slots[0].count).toBe(1);
- });
-
- it('drops events outside the window', () => {
- const logs = [
- { timestamp: minsAgo(20), level: 'info' }, // older than 10m window
- { timestamp: NOW + 5000, level: 'info' }, // future
- ];
- const slots = computeActivityDensity(logs, { now: NOW, windowMs: 10 * 60 * 1000, bins: 24 });
- expect(slots.reduce((n, s) => n + s.count, 0)).toBe(0);
- });
-
- it('keeps the highest-severity level per bin', () => {
- const logs = [
- { timestamp: secsAgo(5), level: 'info' },
- { timestamp: secsAgo(6), level: 'error' },
- { timestamp: secsAgo(7), level: 'warn' },
- ];
- const slots = computeActivityDensity(logs, { now: NOW, windowMs: 60 * 1000, bins: 1 });
- expect(slots[0].count).toBe(3);
- expect(slots[0].level).toBe('error');
- });
-
- it('ignores entries with an unparseable timestamp', () => {
- const slots = computeActivityDensity(
- [{ timestamp: 'not-a-date', level: 'info' }, { level: 'info' }],
- { now: NOW },
- );
- expect(slots.reduce((n, s) => n + s.count, 0)).toBe(0);
- });
-});
-
-describe('buildTimelineBuckets', () => {
- it('groups events into relative-age buckets, newest first', () => {
- const logs = [
- { _localId: 1, timestamp: secsAgo(10), level: 'info', message: 'now-ish' },
- { _localId: 2, timestamp: minsAgo(3), level: 'warn', message: 'recent' },
- { _localId: 3, timestamp: minsAgo(12), level: 'error', message: 'quarter' },
- { _localId: 4, timestamp: minsAgo(40), level: 'info', message: 'older' },
- ];
- const buckets = buildTimelineBuckets(logs, { now: NOW });
- expect(buckets.map(b => b.id)).toEqual(['now', 'recent', 'quarter', 'older']);
- expect(buckets[0].events[0].message).toBe('now-ish');
- expect(buckets[2].events[0].level).toBe('error');
- });
-
- it('drops empty buckets', () => {
- const logs = [{ _localId: 1, timestamp: secsAgo(5), level: 'info', message: 'just happened' }];
- const buckets = buildTimelineBuckets(logs, { now: NOW });
- expect(buckets).toHaveLength(1);
- expect(buckets[0].id).toBe('now');
- });
-
- it('orders events newest-first within a bucket', () => {
- const logs = [
- { _localId: 1, timestamp: minsAgo(4), level: 'info', message: 'first' },
- { _localId: 2, timestamp: minsAgo(2), level: 'info', message: 'second' },
- ];
- const buckets = buildTimelineBuckets(logs, { now: NOW });
- expect(buckets[0].events.map(e => e.message)).toEqual(['second', 'first']);
- });
-
- it('caps total events at `max`', () => {
- const logs = Array.from({ length: 100 }, (_, i) => ({
- _localId: i,
- timestamp: secsAgo(i),
- level: 'info',
- message: `e${i}`,
- }));
- const buckets = buildTimelineBuckets(logs, { now: NOW, max: 10 });
- const total = buckets.reduce((n, b) => n + b.events.length, 0);
- expect(total).toBe(10);
- });
-
- it('lower-cases the level and defaults a missing one to info', () => {
- const logs = [
- { _localId: 1, timestamp: secsAgo(1), level: 'WARN', message: 'w' },
- { _localId: 2, timestamp: secsAgo(2), message: 'no-level' },
- ];
- const buckets = buildTimelineBuckets(logs, { now: NOW });
- const levels = buckets.flatMap(b => b.events.map(e => e.level));
- expect(levels).toContain('warn');
- expect(levels).toContain('info');
- });
-
- it('skips events with future or invalid timestamps', () => {
- const logs = [
- { _localId: 1, timestamp: NOW + 60000, level: 'info', message: 'future' },
- { _localId: 2, timestamp: 'nope', level: 'info', message: 'bad' },
- { _localId: 3, timestamp: secsAgo(5), level: 'info', message: 'good' },
- ];
- const buckets = buildTimelineBuckets(logs, { now: NOW });
- const msgs = buckets.flatMap(b => b.events.map(e => e.message));
- expect(msgs).toEqual(['good']);
- });
-});
-// @vitest-environment node
diff --git a/client/src/utils/openWorldVoiceMarker.js b/client/src/utils/openWorldVoiceMarker.js
deleted file mode 100644
index a92abe92ae..0000000000
--- a/client/src/utils/openWorldVoiceMarker.js
+++ /dev/null
@@ -1,95 +0,0 @@
-// Pure, deterministic helpers for OpenWorld's voice-agent district marker (roadmap 2.4):
-// a small ground-level beacon north of downtown whose lighting mirrors the voice agent's
-// live state — calm slate when idle, accent blue while listening, green while dictating,
-// red on error, and dimmed when voice mode is disabled. The live state is socket-driven
-// (`voice:idle` / `voice:dictation` / `voice:error`); the `enabled` flag comes from the
-// persisted /voice/status payload. No three.js / React imports so the topology is
-// unit-testable (mirrors openWorldBackupVault.js).
-
-import { PARCELS } from './openWorldPlan';
-
-export const MARKER = {
- position: PARCELS.voice.anchor, // north of downtown, stepped just off the harbor avenue's
- // centerline so the plaza→harbor walkway runs clear past the beacon (see openWorldPlan.js).
- baseRadius: 2.2,
- poleHeight: 6,
- beaconRadius: 0.9,
-};
-
-// Beacon color per voice state — reuses the PortOS Tailwind design tokens so the marker
-// speaks the same visual language as the rest of the UI.
-const STATE_COLORS = {
- idle: '#475569', // slate — voice mode on, nothing happening
- listening: '#3b82f6', // port-accent — capturing a turn
- dictating: '#22c55e', // port-success — dictation appending to the daily log
- error: '#ef4444', // port-error — a turn failed
- disabled: '#1e293b', // dim slate — voice mode off
-};
-
-const STATE_LABELS = {
- idle: 'STANDBY',
- listening: 'LISTENING',
- dictating: 'DICTATING',
- error: 'VOICE ERROR',
- disabled: 'VOICE OFF',
-};
-
-// Emissive intensity per state — the marker glows brightest while actively working
-// (listening / dictating), throbs on error, sits calm when idle, and barely lights when
-// disabled so it reads as "asleep" without disappearing.
-const STATE_INTENSITY = {
- idle: 0.45,
- listening: 1,
- dictating: 1,
- error: 0.9,
- disabled: 0.12,
-};
-
-const VALID_STATES = new Set(['idle', 'listening', 'dictating', 'error', 'disabled']);
-
-// Derive the marker's live state from the voice view payload. `enabled` is the persisted
-// flag; `live` is the latest socket-driven sub-state (idle | listening | dictating | error).
-// Voice mode is treated as off unless `enabled === true` — an absent flag (status fetch
-// failed / voice never configured) reads as `disabled`, never as a live "on" state. A stale
-// live value is ignored while disabled. An unrecognized live value falls back to `idle`.
-export function markerState(voice) {
- if (!voice || voice.enabled !== true) return 'disabled';
- const live = voice.live;
- if (live === 'error') return 'error';
- if (live === 'dictating') return 'dictating';
- if (live === 'listening') return 'listening';
- return 'idle';
-}
-
-export function markerColor(state) {
- return STATE_COLORS[VALID_STATES.has(state) ? state : 'idle'] || STATE_COLORS.idle;
-}
-
-export function markerLabel(state) {
- return STATE_LABELS[VALID_STATES.has(state) ? state : 'idle'] || STATE_LABELS.idle;
-}
-
-// Should the beacon pulse urgently (error) vs. calmly? Listening/dictating get an active
-// pulse; idle breathes slowly; disabled doesn't animate.
-export function markerIsActive(state) {
- return state === 'listening' || state === 'dictating';
-}
-
-// Full derived view-model for the component: position + state + color + label + flags +
-// emissive intensity. Mirrors computeBackupVault's shape so the component layer stays thin.
-export function computeVoiceMarker(voice) {
- const state = markerState(voice);
- return {
- position: MARKER.position,
- baseRadius: MARKER.baseRadius,
- poleHeight: MARKER.poleHeight,
- beaconRadius: MARKER.beaconRadius,
- state,
- color: markerColor(state),
- label: markerLabel(state),
- active: markerIsActive(state),
- alerting: state === 'error',
- disabled: state === 'disabled',
- intensity: STATE_INTENSITY[state] ?? STATE_INTENSITY.idle,
- };
-}
diff --git a/client/src/utils/openWorldVoiceMarker.test.js b/client/src/utils/openWorldVoiceMarker.test.js
deleted file mode 100644
index 42dbcb6409..0000000000
--- a/client/src/utils/openWorldVoiceMarker.test.js
+++ /dev/null
@@ -1,120 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import {
- MARKER,
- markerState,
- markerColor,
- markerLabel,
- markerIsActive,
- computeVoiceMarker,
-} from './openWorldVoiceMarker';
-
-describe('markerState', () => {
- it('is disabled when voice mode is off, regardless of a stale live value', () => {
- expect(markerState({ enabled: false })).toBe('disabled');
- expect(markerState({ enabled: false, live: 'listening' })).toBe('disabled');
- });
-
- it('is disabled for missing / empty input', () => {
- expect(markerState(undefined)).toBe('disabled');
- expect(markerState(null)).toBe('disabled');
- expect(markerState({})).toBe('disabled'); // enabled absent → treated as off
- });
-
- it('maps each live sub-state when voice mode is enabled', () => {
- expect(markerState({ enabled: true, live: 'error' })).toBe('error');
- expect(markerState({ enabled: true, live: 'dictating' })).toBe('dictating');
- expect(markerState({ enabled: true, live: 'listening' })).toBe('listening');
- expect(markerState({ enabled: true, live: 'idle' })).toBe('idle');
- });
-
- it('falls back to idle for an absent / unrecognized live value when enabled', () => {
- expect(markerState({ enabled: true })).toBe('idle');
- expect(markerState({ enabled: true, live: 'bogus' })).toBe('idle');
- });
-
- it('prioritizes error over other live states', () => {
- // The live field carries one sub-state, but guard the precedence contract anyway.
- expect(markerState({ enabled: true, live: 'error' })).toBe('error');
- });
-});
-
-describe('markerColor', () => {
- it('maps each state to a distinct token', () => {
- expect(markerColor('idle')).toBe('#475569');
- expect(markerColor('listening')).toBe('#3b82f6');
- expect(markerColor('dictating')).toBe('#22c55e');
- expect(markerColor('error')).toBe('#ef4444');
- expect(markerColor('disabled')).toBe('#1e293b');
- });
-
- it('falls back to idle for an unknown state', () => {
- expect(markerColor('bogus')).toBe(markerColor('idle'));
- });
-});
-
-describe('markerLabel', () => {
- it('gives a distinct uppercase label per state', () => {
- expect(markerLabel('idle')).toBe('STANDBY');
- expect(markerLabel('listening')).toBe('LISTENING');
- expect(markerLabel('dictating')).toBe('DICTATING');
- expect(markerLabel('error')).toBe('VOICE ERROR');
- expect(markerLabel('disabled')).toBe('VOICE OFF');
- });
-
- it('falls back to the idle label for an unknown state', () => {
- expect(markerLabel('bogus')).toBe(markerLabel('idle'));
- });
-});
-
-describe('markerIsActive', () => {
- it('is active only while listening or dictating', () => {
- expect(markerIsActive('listening')).toBe(true);
- expect(markerIsActive('dictating')).toBe(true);
- expect(markerIsActive('idle')).toBe(false);
- expect(markerIsActive('error')).toBe(false);
- expect(markerIsActive('disabled')).toBe(false);
- });
-});
-
-describe('computeVoiceMarker', () => {
- it('carries the fixed position/geometry through unchanged', () => {
- const vm = computeVoiceMarker({ enabled: true });
- expect(vm.position).toEqual(MARKER.position);
- expect(vm.baseRadius).toBe(MARKER.baseRadius);
- expect(vm.poleHeight).toBe(MARKER.poleHeight);
- expect(vm.beaconRadius).toBe(MARKER.beaconRadius);
- });
-
- it('derives color/label/flags/intensity for each state', () => {
- const idle = computeVoiceMarker({ enabled: true, live: 'idle' });
- expect(idle).toMatchObject({ state: 'idle', color: '#475569', label: 'STANDBY', active: false, alerting: false, disabled: false });
- expect(idle.intensity).toBeGreaterThan(0);
-
- const listening = computeVoiceMarker({ enabled: true, live: 'listening' });
- expect(listening).toMatchObject({ state: 'listening', color: '#3b82f6', active: true, alerting: false });
-
- const dictating = computeVoiceMarker({ enabled: true, live: 'dictating' });
- expect(dictating).toMatchObject({ state: 'dictating', color: '#22c55e', active: true });
-
- const error = computeVoiceMarker({ enabled: true, live: 'error' });
- expect(error).toMatchObject({ state: 'error', color: '#ef4444', alerting: true, active: false });
-
- const disabled = computeVoiceMarker({ enabled: false });
- expect(disabled).toMatchObject({ state: 'disabled', color: '#1e293b', disabled: true, active: false });
- expect(disabled.intensity).toBeLessThan(idle.intensity);
- });
-
- it('treats missing / empty input as disabled', () => {
- expect(computeVoiceMarker(undefined).state).toBe('disabled');
- expect(computeVoiceMarker(null).state).toBe('disabled');
- expect(computeVoiceMarker({}).state).toBe('disabled');
- });
-
- it('always returns a finite emissive intensity', () => {
- for (const live of ['idle', 'listening', 'dictating', 'error', undefined, 'bogus']) {
- const vm = computeVoiceMarker({ enabled: true, live });
- expect(Number.isFinite(vm.intensity)).toBe(true);
- }
- });
-});
-// @vitest-environment node
diff --git a/client/src/utils/providers.js b/client/src/utils/providers.js
index 16f64e2bc6..ec966195cc 100644
--- a/client/src/utils/providers.js
+++ b/client/src/utils/providers.js
@@ -289,6 +289,18 @@ export const supportsPublicReviewPosture = (provider, posture) => {
: provider?.publicReviewSupported === true;
};
+/**
+ * Whether the SERVER runs `posture` on this provider through a vendor-enforced
+ * recipe (an OS sandbox for the actions stage), as opposed to merely allowing
+ * it. `publicReviewEnforcedPostures` is the server's subset; an older server
+ * that does not publish it only ever offered enforced providers, so its
+ * eligible set is taken as enforced.
+ */
+export const enforcesPublicReviewPosture = (provider, posture) => {
+ if (Array.isArray(provider?.publicReviewEnforcedPostures)) return provider.publicReviewEnforcedPostures.includes(posture);
+ return supportsPublicReviewPosture(provider, posture);
+};
+
/**
* Selection policy for a pr-reviewer stage.
*
@@ -343,6 +355,20 @@ export const filterSelectableModels = (models) =>
export const isProviderHardwareCompatible = (provider) =>
isHardwareCompatible(provider?.hardwareCompatibility);
+/**
+ * The providers a picker may offer: enabled, runnable on this hardware, and
+ * allowed by the caller's policy — plus the currently-selected id whatever its
+ * state, so a saved pin still renders instead of silently blanking. This is
+ * the single rule `ProviderModelSelector` renders from; a caller that lists
+ * "eligible" providers beside such a picker must derive the list from here so
+ * the note and the dropdown cannot disagree.
+ */
+export const selectableProviders = (providers, { selectedId = '', allowed = null } = {}) =>
+ (Array.isArray(providers) ? providers : []).filter((provider) => (
+ provider?.id === selectedId
+ || (provider?.enabled !== false && isProviderHardwareCompatible(provider) && (!allowed || allowed(provider)))
+ ));
+
export const isProviderModelHardwareCompatible = (provider, model) =>
isProviderHardwareCompatible(provider)
&& isHardwareCompatible(provider?.modelHardwareCompatibility?.[model]);
diff --git a/client/src/utils/providers.test.js b/client/src/utils/providers.test.js
index c438375088..91f3f47952 100644
--- a/client/src/utils/providers.test.js
+++ b/client/src/utils/providers.test.js
@@ -1132,6 +1132,12 @@ describe('supportsModelRefresh', () => {
'opencode-orcarouter', 'opencode-orcarouter-tui',
'opencode-sglang', 'opencode-sglang-tui',
'opencode-vllm', 'opencode-vllm-tui',
+ // The Zen API record is an ordinary OpenAI-compatible endpoint. Its CLI/TUI
+ // wrappers are deliberately ABSENT: they carry no namespace marker, which
+ // is what makes OpenCode resolve `opencode/*` through its own built-in
+ // provider — nothing here can enumerate that, and Models → Harnesses
+ // ("Refresh models") is where their catalog comes from instead.
+ 'opencode-zen',
'openrouter', 'orcarouter',
]);
});
diff --git a/client/src/utils/openWorldRenderBudget.js b/client/src/utils/renderBudget.js
similarity index 98%
rename from client/src/utils/openWorldRenderBudget.js
rename to client/src/utils/renderBudget.js
index 14201d99bd..edc2187c56 100644
--- a/client/src/utils/openWorldRenderBudget.js
+++ b/client/src/utils/renderBudget.js
@@ -1,4 +1,4 @@
-// Pure render-budget state machine for OpenWorld's Auto quality mode (issue #2592).
+// Pure render-budget state machine for adaptive quality (issue #2592).
//
// It observes per-frame delta times and decides when the *effective* detail tier
// should step down (frame pressure) or up (headroom), with hysteresis + a cooldown
@@ -20,7 +20,7 @@ export const QUALITY_TIERS = ['low', 'medium', 'high', 'ultra'];
// before Auto quality could react. Unknown desktop capabilities remain High; a coarse
// pointer or sub-8-core/sub-8GB device starts at Medium, while clearly constrained
// hardware starts at Low. The live budget can still climb when it measures headroom.
-export function recommendOpenWorldStartTier({
+export function recommendStartTier({
coarsePointer = false,
hardwareConcurrency = null,
deviceMemory = null,
@@ -104,7 +104,7 @@ export function createRenderBudget(startTier = 'high', now = 0) {
};
}
-// Re-arm the warm-up without touching the current tier — used when the OpenWorld frameloop
+// Re-arm the warm-up without touching the current tier — used when a frameloop
// resumes after the tab was hidden, so the first sluggish post-resume frames (and any
// pre-hide pressure/headroom streaks) don't drive a bogus decision.
export function restartWarmup(state, now) {
diff --git a/client/src/utils/openWorldRenderBudget.test.js b/client/src/utils/renderBudget.test.js
similarity index 88%
rename from client/src/utils/openWorldRenderBudget.test.js
rename to client/src/utils/renderBudget.test.js
index 1061f98329..709df60728 100644
--- a/client/src/utils/openWorldRenderBudget.test.js
+++ b/client/src/utils/renderBudget.test.js
@@ -8,9 +8,9 @@ import {
resetRenderBudget,
getEffectiveTier,
percentile,
- recommendOpenWorldStartTier,
+ recommendStartTier,
tierToIndex,
-} from './openWorldRenderBudget.js';
+} from './renderBudget.js';
const CFG = DEFAULT_RENDER_BUDGET_CONFIG;
@@ -39,7 +39,7 @@ function makeDriver(startTier) {
};
}
-describe('openWorldRenderBudget — helpers', () => {
+describe('renderBudget — helpers', () => {
it('percentile uses nearest-rank and handles empty input', () => {
expect(percentile([], 0.75)).toBeNull();
expect(percentile([10, 20, 30, 40], 0.75)).toBe(30);
@@ -53,21 +53,21 @@ describe('openWorldRenderBudget — helpers', () => {
});
it('starts conservatively on touch and lower-capability devices', () => {
- expect(recommendOpenWorldStartTier({ coarsePointer: true, hardwareConcurrency: 12 })).toBe('medium');
- expect(recommendOpenWorldStartTier({ hardwareConcurrency: 6 })).toBe('medium');
- expect(recommendOpenWorldStartTier({ deviceMemory: 6 })).toBe('medium');
- expect(recommendOpenWorldStartTier({ hardwareConcurrency: 2 })).toBe('low');
- expect(recommendOpenWorldStartTier({ deviceMemory: 4 })).toBe('low');
+ expect(recommendStartTier({ coarsePointer: true, hardwareConcurrency: 12 })).toBe('medium');
+ expect(recommendStartTier({ hardwareConcurrency: 6 })).toBe('medium');
+ expect(recommendStartTier({ deviceMemory: 6 })).toBe('medium');
+ expect(recommendStartTier({ hardwareConcurrency: 2 })).toBe('low');
+ expect(recommendStartTier({ deviceMemory: 4 })).toBe('low');
});
it('keeps capable and unknown desktops at high', () => {
- expect(recommendOpenWorldStartTier()).toBe('high');
- expect(recommendOpenWorldStartTier({ hardwareConcurrency: 12, deviceMemory: 16 })).toBe('high');
- expect(recommendOpenWorldStartTier({ hardwareConcurrency: 0, deviceMemory: Number.NaN })).toBe('high');
+ expect(recommendStartTier()).toBe('high');
+ expect(recommendStartTier({ hardwareConcurrency: 12, deviceMemory: 16 })).toBe('high');
+ expect(recommendStartTier({ hardwareConcurrency: 0, deviceMemory: Number.NaN })).toBe('high');
});
});
-describe('openWorldRenderBudget — warm-up & gap rejection (ignored samples)', () => {
+describe('renderBudget — warm-up & gap rejection (ignored samples)', () => {
it('ignores frames inside the warm-up window but records after it', () => {
let s = createRenderBudget('high', 0);
// Frames before warmupMs never accumulate (dt=40 would otherwise be pressure).
@@ -139,7 +139,7 @@ describe('openWorldRenderBudget — warm-up & gap rejection (ignored samples)',
});
});
-describe('openWorldRenderBudget — downshift', () => {
+describe('renderBudget — downshift', () => {
it('steps down exactly one tier after two consecutive pressure windows', () => {
const d = makeDriver('high');
d.window(30); // window 1: p75 30ms > 25ms
@@ -159,7 +159,7 @@ describe('openWorldRenderBudget — downshift', () => {
});
});
-describe('openWorldRenderBudget — recovery (upshift)', () => {
+describe('renderBudget — recovery (upshift)', () => {
it('steps up one tier after five consecutive headroom windows', () => {
const d = makeDriver('medium');
for (let w = 0; w < 4; w += 1) d.window(10); // four fast windows: not enough
@@ -178,7 +178,7 @@ describe('openWorldRenderBudget — recovery (upshift)', () => {
});
});
-describe('openWorldRenderBudget — cooldown', () => {
+describe('renderBudget — cooldown', () => {
it('blocks a second tier change until the cooldown elapses', () => {
const d = makeDriver('high');
d.window(30);
@@ -208,7 +208,7 @@ describe('openWorldRenderBudget — cooldown', () => {
});
});
-describe('openWorldRenderBudget — visibility transitions', () => {
+describe('renderBudget — visibility transitions', () => {
it('restartWarmup re-arms warm-up and clears streaks/samples without changing tier', () => {
const d = makeDriver('high');
d.window(30); // pressure streak = 1
@@ -230,7 +230,7 @@ describe('openWorldRenderBudget — visibility transitions', () => {
});
});
-describe('openWorldRenderBudget — purity', () => {
+describe('renderBudget — purity', () => {
it('recordFrame does not mutate the input state', () => {
const s = createRenderBudget('high', 0);
const before = JSON.stringify(s);
diff --git a/client/src/utils/staleChunkReload.js b/client/src/utils/staleChunkReload.js
index 61747f3adf..0c18e1c854 100644
--- a/client/src/utils/staleChunkReload.js
+++ b/client/src/utils/staleChunkReload.js
@@ -1,3 +1,4 @@
+import { safeReadSession, safeWriteSession } from '../lib/safeStorage.js';
import { sleep } from './sleep.js';
// Cross-browser detection for stale dynamic-import chunk errors that happen
@@ -98,8 +99,19 @@ export const fetchServerBuildId = async () => {
export const reloadOnceForStaleChunk = () => {
const buildId = getCurrentBuildId();
const flag = buildId ? `${buildId}` : '1';
- if (sessionStorage.getItem(RELOAD_FLAG) === flag) return false;
- sessionStorage.setItem(RELOAD_FLAG, flag);
+ // The stored flag IS the anti-loop guard, so it gets the sentinel treatment
+ // (root AGENTS.md): a storage that cannot persist must not read back as
+ // `no reload attempted yet`, which would reload on every stale-chunk error
+ // forever. Write, then read back — a mismatch means storage is unavailable
+ // (Safari private mode, blocked storage, disabled cookies) and the page stays
+ // put. The guarded helpers also keep the throw itself from taking out the very
+ // recovery path a stale bundle needs (#5689).
+ if (safeReadSession(RELOAD_FLAG) === flag) return false;
+ safeWriteSession(RELOAD_FLAG, flag);
+ if (safeReadSession(RELOAD_FLAG) !== flag) {
+ console.warn('🔄 Stale chunk detected but sessionStorage is unavailable — skipping reload to avoid a reload loop');
+ return false;
+ }
console.warn(`🔄 Stale chunk detected (build ${buildId || 'unknown'}) — reloading to pick up new bundle`);
// Purge the offline caches BEFORE reloading so the reload can't be handed the
// stale shell/chunks back — but only when the server confirms a different
diff --git a/client/src/utils/staleChunkReload.test.js b/client/src/utils/staleChunkReload.test.js
index 1c1355d396..ad85513bad 100644
--- a/client/src/utils/staleChunkReload.test.js
+++ b/client/src/utils/staleChunkReload.test.js
@@ -212,6 +212,23 @@ describe('reloadOnceForStaleChunk', () => {
expect(reload).toHaveBeenCalledTimes(1);
});
+ it('does not reload at all when sessionStorage is unavailable (no reload loop)', async () => {
+ // Safari private mode / blocked storage: the accessor itself throws. The
+ // stored flag IS the anti-loop guard, so a storage that cannot persist it
+ // must not read back as "no reload attempted yet" — that would reload on
+ // every stale-chunk error, forever (#5689).
+ const boom = () => { throw new DOMException('The operation is insecure.', 'SecurityError'); };
+ vi.stubGlobal('sessionStorage', { getItem: boom, setItem: boom, removeItem: boom });
+ stubFetch();
+ const reload = stubReload();
+ vi.stubGlobal('caches', undefined);
+
+ expect(reloadOnceForStaleChunk()).toBe(false);
+ expect(reloadOnceForStaleChunk()).toBe(false);
+ await Promise.resolve();
+ expect(reload).not.toHaveBeenCalled();
+ });
+
it('reloads again after a new build ships (guard is build-scoped)', async () => {
stubSessionStorage();
stubFetch();
diff --git a/client/vite.chunkGroups.js b/client/vite.chunkGroups.js
new file mode 100644
index 0000000000..23c830cbdc
--- /dev/null
+++ b/client/vite.chunkGroups.js
@@ -0,0 +1,50 @@
+// Vendor chunk groups for rolldown's `output.codeSplitting.groups` (Vite 8).
+//
+// Declared as package NAMES rather than as hand-written regexes so the grouping
+// can be checked against what is actually installed. A group regex naming a
+// package nobody installs matches nothing and quietly stops guaranteeing
+// anything: `vendor-three` listed `three-fenestra` — removed from the tree when
+// `openworld/InteriorMappingMaterial.js` ported the material in — while the two
+// three-* packages that DO ship (`three-stdlib` and `three-mesh-bvh`, both pulled
+// in by @react-three/drei) fell outside the pattern, because `three` had to be
+// followed immediately by a path separator (#5725).
+//
+// Conventions for a `packages` entry:
+// 'three' an exact package name
+// '@xterm' a scope — every package published under it
+// 'd3-*' a family prefix — every `d3-` package
+// `buildGroupTest` renders them back into the module-id regex rolldown matches
+// with. Use `[\\/]` (not `/`) for the separator so the regexes match on Windows.
+
+const PATH_SEP = '[\\\\/]';
+
+const escapeRegex = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+
+const packagePattern = (name) =>
+ name.endsWith('*')
+ ? `${escapeRegex(name.slice(0, -1))}[^\\\\/]+`
+ : escapeRegex(name);
+
+/** Module-id regex capturing every module published by one of `packages`. */
+const buildGroupTest = (packages) =>
+ new RegExp(`${PATH_SEP}node_modules${PATH_SEP}(${packages.map(packagePattern).join('|')})${PATH_SEP}`);
+
+export const CHUNK_GROUPS = [
+ // Core React dependencies
+ { name: 'vendor-react', packages: ['react', 'react-dom', 'react-router'] },
+ // Socket dependencies
+ { name: 'vendor-realtime', packages: ['socket.io-client'] },
+ // Drag and drop library (only used in CoS)
+ { name: 'vendor-dnd', packages: ['@dnd-kit'] },
+ // Icon library (largest dependency)
+ { name: 'vendor-icons', packages: ['lucide-react'] },
+ // 3D stack — only pulled into lazy 3D pages (CyberCity, avatars, BrainGraph).
+ // Naming it gives the ~1 MB chunk a stable identity instead of an opaque
+ // `OrbitControls-*.js`, and listing drei's own three-* dependencies pins them
+ // to that chunk instead of leaving their placement to the bundler's derivation.
+ { name: 'vendor-three', packages: ['three', 'three-stdlib', 'three-mesh-bvh', '@react-three'] },
+ // Charting (recharts) — lazy chart pages only
+ { name: 'vendor-charts', packages: ['recharts', 'd3-*', 'victory-*'] },
+ // Terminal emulator (xterm) — Shell page only
+ { name: 'vendor-term', packages: ['@xterm'] },
+].map((group) => ({ ...group, test: buildGroupTest(group.packages) }));
diff --git a/client/vite.config.js b/client/vite.config.js
index 8b2d11a8b4..8c3044bdb0 100644
--- a/client/vite.config.js
+++ b/client/vite.config.js
@@ -6,6 +6,7 @@ import { execFileSync } from 'child_process';
import { resolve } from 'path';
import { resolveBundleNodeEnv } from './vite.buildEnv.js';
+import { CHUNK_GROUPS } from './vite.chunkGroups.js';
const ANALYZE_BUNDLE = process.env.ANALYZE === 'true';
const CONFIG_DIR = import.meta.dirname;
@@ -171,29 +172,11 @@ export default defineConfig(({ command, mode }) => {
// id matches `test` into a named chunk. This replaces the legacy
// `rollupOptions.output.manualChunks` function (still accepted via
// rolldown's compat layer, but slated to drop in a future Vite). The
- // groups below reproduce the same four vendor chunks as before.
- // Note: use `[\\/]` (not `/`) for the path separator so the regexes
- // also match on Windows.
+ // groups themselves are declared as package names in
+ // `vite.chunkGroups.js`, so a group naming an uninstalled package fails
+ // a test instead of silently grouping nothing.
codeSplitting: {
- groups: [
- // Core React dependencies
- { name: 'vendor-react', test: /[\\/]node_modules[\\/](react|react-dom|react-router)[\\/]/ },
- // Socket dependencies
- { name: 'vendor-realtime', test: /[\\/]node_modules[\\/]socket\.io-client[\\/]/ },
- // Drag and drop library (only used in CoS)
- { name: 'vendor-dnd', test: /[\\/]node_modules[\\/]@dnd-kit[\\/]/ },
- // Icon library (largest dependency)
- { name: 'vendor-icons', test: /[\\/]node_modules[\\/]lucide-react[\\/]/ },
- // 3D stack — only pulled into lazy 3D pages (CyberCity, avatars,
- // BrainGraph). Naming it gives the ~1 MB chunk a stable identity
- // instead of an opaque `OrbitControls-*.js` and guarantees a single
- // shared chunk across all 3D consumers.
- { name: 'vendor-three', test: /[\\/]node_modules[\\/](three|@react-three|three-fenestra)[\\/]/ },
- // Charting (recharts) — lazy chart pages only
- { name: 'vendor-charts', test: /[\\/]node_modules[\\/](recharts|d3-[^\\/]+|victory-[^\\/]+)[\\/]/ },
- // Terminal emulator (xterm) — Shell page only
- { name: 'vendor-term', test: /[\\/]node_modules[\\/]@xterm[\\/]/ },
- ]
+ groups: CHUNK_GROUPS.map(({ name, test }) => ({ name, test }))
}
}
},
diff --git a/data.reference/media-models.json b/data.reference/media-models.json
index 75203a3b7d..4fb9002c37 100644
--- a/data.reference/media-models.json
+++ b/data.reference/media-models.json
@@ -588,7 +588,7 @@
},
{
"id": "fastmetal_1_3b_qad",
- "name": "FastMetal 1.3B QAD (~3.5 GB download, 8+ GB RAM, 3-step)",
+ "name": "FastMetal 1.3B QAD (~13.4 GB download, 8+ GB RAM, 3-step)",
"repo": "FastVideo/FastMetal-1.3B-QAD",
"runtime": "fastvideo",
"supportedModes": [
@@ -618,7 +618,7 @@
},
{
"id": "fastmetal_5b_qad",
- "name": "FastMetal 5B QAD (~10 GB download, 16+ GB RAM, 3-step)",
+ "name": "FastMetal 5B QAD (~19.5 GB download, 16+ GB RAM, 3-step)",
"repo": "FastVideo/FastMetal-5B-QAD",
"runtime": "fastvideo",
"supportedModes": [
@@ -648,8 +648,27 @@
},
{
"id": "fastmetal_14b_qad",
- "name": "FastMetal 14B QAD (~25 GB download, 36+ GB RAM, 3-step)",
+ "name": "FastMetal 14B QAD (~27.1 GB download, 36+ GB RAM, 3-step)",
"repo": "FastVideo/FastMetal-14B-QAD",
+ "repoFiles": [
+ "model_index.json",
+ "mlx_dit.json",
+ "mlx_dit.safetensors",
+ "scheduler/scheduler_config.json",
+ "text_encoder/config.json",
+ "text_encoder/model.safetensors.index.json",
+ "text_encoder/model-00001-of-00005.safetensors",
+ "text_encoder/model-00002-of-00005.safetensors",
+ "text_encoder/model-00003-of-00005.safetensors",
+ "text_encoder/model-00004-of-00005.safetensors",
+ "text_encoder/model-00005-of-00005.safetensors",
+ "tokenizer/special_tokens_map.json",
+ "tokenizer/spiece.model",
+ "tokenizer/tokenizer.json",
+ "tokenizer/tokenizer_config.json",
+ "vae/config.json",
+ "vae/diffusion_pytorch_model.safetensors"
+ ],
"runtime": "fastvideo",
"supportedModes": [
"text"
@@ -672,8 +691,251 @@
"name": "Apache-2.0",
"url": "https://github.com/hao-ai-lab/FastVideo/blob/main/LICENSE"
},
- "estimatedDownloadGb": 42.3,
+ "estimatedDownloadGb": 27.1,
+ "reviewedAt": "2026-09-02"
+ }
+ },
+ {
+ "id": "fasth3_dense_datafree_int8",
+ "name": "FastH3 Preview v1 Dense Data-Free — MLX INT8 (highest fidelity) (video + audio, ~144 GB download, 48+ GB RAM, 4-step)",
+ "repo": "FastVideo/FastVideo-FastH3-4-step-Preview-v1-Dense-DataFree",
+ "revision": "f624f08c6c279ab43534c003e556fc5b295b6558",
+ "runtime": "fastvideo",
+ "fastvideoFamily": "fasth3",
+ "fastvideoMlxFormat": "int8",
+ "supportedModes": [
+ "text"
+ ],
+ "defaultWidth": 832,
+ "defaultHeight": 480,
+ "defaultFrames": 124,
+ "frameOptions": [
+ 124,
+ 141,
+ 158,
+ 175,
+ 192,
+ 209,
+ 226,
+ 243,
+ 260,
+ 277,
+ 294,
+ 311,
+ 328,
+ 345
+ ],
+ "fpsOptions": [
+ 24
+ ],
+ "resolutionStep": 32,
+ "resolutionOptions": [
+ {
+ "label": "832x480 (16:9 FastH3 default)",
+ "w": 832,
+ "h": 480
+ },
+ {
+ "label": "1280x720 (16:9 HD)",
+ "w": 1280,
+ "h": 720
+ }
+ ],
+ "memoryGb": 48,
+ "steps": 4,
+ "guidance": 1,
+ "samplerLocked": true,
+ "samplerNote": "FastH3 Preview v1 is a 4-step DMD2 model. This is FastVideo's own dense-attention checkpoint — it does not support VSA, whose routing weights the MLX runtime drops. The first render converts its transformer to an MLX INT8 DiT (a few minutes, once), after which the 66 GB bf16 transformer can be deleted. Renders video with audio at a fixed 24 fps.",
+ "supportsNegativePrompt": false,
+ "supportsTiling": false,
+ "supportsDisableAudio": false,
+ "disclosure": {
+ "modelCardUrl": "https://huggingface.co/FastVideo/FastVideo-FastH3-4-step-Preview-v1-Dense-DataFree",
+ "weightsLicense": {
+ "name": "MiniMax H3 Community License",
+ "url": "https://huggingface.co/MiniMaxAI/MiniMax-H3/blob/6818f6c32d12b210915e44ad56a4228c2608f160/LICENSE"
+ },
+ "runtimeLicense": {
+ "name": "Apache-2.0",
+ "url": "https://github.com/hao-ai-lab/FastVideo/blob/main/LICENSE"
+ },
+ "estimatedDownloadGb": 144,
+ "reviewedAt": "2026-09-02"
+ },
+ "termsGate": {
+ "id": "minimax-h3-community-license-2026-08-02",
+ "title": "MiniMax H3 eligibility and terms",
+ "summary": "MiniMax grants use only in its Applicable Territory. The license excludes the European Union, United Kingdom, Republic of Korea, and United States of America.",
+ "acknowledgement": "I confirm I am in the Applicable Territory and agree to the MiniMax H3 Community License and its Acceptable Use Policy.",
+ "licenseUrl": "https://huggingface.co/MiniMaxAI/MiniMax-H3/blob/6818f6c32d12b210915e44ad56a4228c2608f160/LICENSE",
+ "excludedTerritories": [
+ "European Union",
+ "United Kingdom",
+ "Republic of Korea",
+ "United States of America"
+ ]
+ }
+ },
+ {
+ "id": "fasth3_dense_datafree_int6",
+ "name": "FastH3 Preview v1 Dense Data-Free — MLX INT6 (upstream default) (video + audio, ~144 GB download, 42+ GB RAM, 4-step)",
+ "repo": "FastVideo/FastVideo-FastH3-4-step-Preview-v1-Dense-DataFree",
+ "revision": "f624f08c6c279ab43534c003e556fc5b295b6558",
+ "runtime": "fastvideo",
+ "fastvideoFamily": "fasth3",
+ "fastvideoMlxFormat": "int6",
+ "supportedModes": [
+ "text"
+ ],
+ "defaultWidth": 832,
+ "defaultHeight": 480,
+ "defaultFrames": 124,
+ "frameOptions": [
+ 124,
+ 141,
+ 158,
+ 175,
+ 192,
+ 209,
+ 226,
+ 243,
+ 260,
+ 277,
+ 294,
+ 311,
+ 328,
+ 345
+ ],
+ "fpsOptions": [
+ 24
+ ],
+ "resolutionStep": 32,
+ "resolutionOptions": [
+ {
+ "label": "832x480 (16:9 FastH3 default)",
+ "w": 832,
+ "h": 480
+ },
+ {
+ "label": "1280x720 (16:9 HD)",
+ "w": 1280,
+ "h": 720
+ }
+ ],
+ "memoryGb": 42,
+ "steps": 4,
+ "guidance": 1,
+ "samplerLocked": true,
+ "samplerNote": "FastH3 Preview v1 is a 4-step DMD2 model. This is FastVideo's own dense-attention checkpoint — it does not support VSA, whose routing weights the MLX runtime drops. The first render converts its transformer to an MLX INT6 DiT (a few minutes, once), after which the 66 GB bf16 transformer can be deleted. Renders video with audio at a fixed 24 fps.",
+ "supportsNegativePrompt": false,
+ "supportsTiling": false,
+ "supportsDisableAudio": false,
+ "disclosure": {
+ "modelCardUrl": "https://huggingface.co/FastVideo/FastVideo-FastH3-4-step-Preview-v1-Dense-DataFree",
+ "weightsLicense": {
+ "name": "MiniMax H3 Community License",
+ "url": "https://huggingface.co/MiniMaxAI/MiniMax-H3/blob/6818f6c32d12b210915e44ad56a4228c2608f160/LICENSE"
+ },
+ "runtimeLicense": {
+ "name": "Apache-2.0",
+ "url": "https://github.com/hao-ai-lab/FastVideo/blob/main/LICENSE"
+ },
+ "estimatedDownloadGb": 144,
"reviewedAt": "2026-09-02"
+ },
+ "termsGate": {
+ "id": "minimax-h3-community-license-2026-08-02",
+ "title": "MiniMax H3 eligibility and terms",
+ "summary": "MiniMax grants use only in its Applicable Territory. The license excludes the European Union, United Kingdom, Republic of Korea, and United States of America.",
+ "acknowledgement": "I confirm I am in the Applicable Territory and agree to the MiniMax H3 Community License and its Acceptable Use Policy.",
+ "licenseUrl": "https://huggingface.co/MiniMaxAI/MiniMax-H3/blob/6818f6c32d12b210915e44ad56a4228c2608f160/LICENSE",
+ "excludedTerritories": [
+ "European Union",
+ "United Kingdom",
+ "Republic of Korea",
+ "United States of America"
+ ]
+ }
+ },
+ {
+ "id": "fasth3_dense_datafree_int4",
+ "name": "FastH3 Preview v1 Dense Data-Free — MLX INT4 (smallest) (video + audio, ~144 GB download, 36+ GB RAM, 4-step)",
+ "repo": "FastVideo/FastVideo-FastH3-4-step-Preview-v1-Dense-DataFree",
+ "revision": "f624f08c6c279ab43534c003e556fc5b295b6558",
+ "runtime": "fastvideo",
+ "fastvideoFamily": "fasth3",
+ "fastvideoMlxFormat": "int4",
+ "supportedModes": [
+ "text"
+ ],
+ "defaultWidth": 832,
+ "defaultHeight": 480,
+ "defaultFrames": 124,
+ "frameOptions": [
+ 124,
+ 141,
+ 158,
+ 175,
+ 192,
+ 209,
+ 226,
+ 243,
+ 260,
+ 277,
+ 294,
+ 311,
+ 328,
+ 345
+ ],
+ "fpsOptions": [
+ 24
+ ],
+ "resolutionStep": 32,
+ "resolutionOptions": [
+ {
+ "label": "832x480 (16:9 FastH3 default)",
+ "w": 832,
+ "h": 480
+ },
+ {
+ "label": "1280x720 (16:9 HD)",
+ "w": 1280,
+ "h": 720
+ }
+ ],
+ "memoryGb": 36,
+ "steps": 4,
+ "guidance": 1,
+ "samplerLocked": true,
+ "samplerNote": "FastH3 Preview v1 is a 4-step DMD2 model. This is FastVideo's own dense-attention checkpoint — it does not support VSA, whose routing weights the MLX runtime drops. The first render converts its transformer to an MLX INT4 DiT (a few minutes, once), after which the 66 GB bf16 transformer can be deleted. Renders video with audio at a fixed 24 fps.",
+ "supportsNegativePrompt": false,
+ "supportsTiling": false,
+ "supportsDisableAudio": false,
+ "disclosure": {
+ "modelCardUrl": "https://huggingface.co/FastVideo/FastVideo-FastH3-4-step-Preview-v1-Dense-DataFree",
+ "weightsLicense": {
+ "name": "MiniMax H3 Community License",
+ "url": "https://huggingface.co/MiniMaxAI/MiniMax-H3/blob/6818f6c32d12b210915e44ad56a4228c2608f160/LICENSE"
+ },
+ "runtimeLicense": {
+ "name": "Apache-2.0",
+ "url": "https://github.com/hao-ai-lab/FastVideo/blob/main/LICENSE"
+ },
+ "estimatedDownloadGb": 144,
+ "reviewedAt": "2026-09-02"
+ },
+ "termsGate": {
+ "id": "minimax-h3-community-license-2026-08-02",
+ "title": "MiniMax H3 eligibility and terms",
+ "summary": "MiniMax grants use only in its Applicable Territory. The license excludes the European Union, United Kingdom, Republic of Korea, and United States of America.",
+ "acknowledgement": "I confirm I am in the Applicable Territory and agree to the MiniMax H3 Community License and its Acceptable Use Policy.",
+ "licenseUrl": "https://huggingface.co/MiniMaxAI/MiniMax-H3/blob/6818f6c32d12b210915e44ad56a4228c2608f160/LICENSE",
+ "excludedTerritories": [
+ "European Union",
+ "United Kingdom",
+ "Republic of Korea",
+ "United States of America"
+ ]
}
},
{
@@ -690,7 +952,6 @@
"defaultHeight": 480,
"defaultFrames": 124,
"frameOptions": [
- 107,
124,
141,
158,
@@ -704,8 +965,7 @@
294,
311,
328,
- 345,
- 362
+ 345
],
"fpsOptions": [
24
diff --git a/data.reference/providers.json b/data.reference/providers.json
index a41a8b7f75..dbfa649d8a 100644
--- a/data.reference/providers.json
+++ b/data.reference/providers.json
@@ -78,7 +78,7 @@
"type": "cli",
"command": "agy",
"args": ["--print", "--dangerously-skip-permissions"],
- "models": ["antigravity-configured-default","gemini-3.7-flash-high","gemini-3.7-flash-medium","gemini-3.7-flash-low","gemini-3.6-flash-high","gemini-3.6-flash-medium","gemini-3.6-flash-low","gemini-3.5-flash-high","gemini-3.5-flash-medium","gemini-3.5-flash-low","gemini-3.1-pro-high","gemini-3.1-pro-low","claude-sonnet-4-6","claude-opus-4-6-thinking","gpt-oss-120b-medium"],
+ "models": ["antigravity-configured-default","gemini-3.8-flash-high","gemini-3.8-flash-medium","gemini-3.8-flash-low","gemini-3.7-flash-high","gemini-3.7-flash-medium","gemini-3.7-flash-low","gemini-3.6-flash-high","gemini-3.6-flash-medium","gemini-3.6-flash-low","gemini-3.1-pro-high","gemini-3.1-pro-low","claude-sonnet-4-6","claude-opus-4-6-thinking","gpt-oss-120b-medium"],
"defaultModel": "antigravity-configured-default",
"lightModel": "antigravity-configured-default",
"mediumModel": "antigravity-configured-default",
@@ -490,13 +490,62 @@
"tuiPromptDelayMs": 2500,
"tuiIdleTimeoutMs": 180000
},
+ "opencode-zen": {
+ "id": "opencode-zen",
+ "name": "OpenCode Zen",
+ "type": "api",
+ "endpoint": "https://opencode.ai/zen/v1",
+ "apiKey": "",
+ "models": ["claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5", "gpt-5.6-sol", "gpt-5.5", "grok-4.6", "kimi-k3", "qwen3.6-plus", "big-pickle", "deepseek-v4-flash-free"],
+ "defaultModel": "claude-sonnet-5",
+ "lightModel": "claude-haiku-4-5",
+ "mediumModel": "claude-sonnet-5",
+ "heavyModel": "claude-opus-5",
+ "timeout": 300000,
+ "enabled": false,
+ "envVars": {},
+ "secretEnvVars": []
+ },
+ "opencode-zen-cli": {
+ "id": "opencode-zen-cli",
+ "name": "OpenCode Zen CLI",
+ "type": "cli",
+ "command": "opencode",
+ "args": ["run"],
+ "models": ["opencode/big-pickle", "opencode/ling-3.0-flash-fin-free", "opencode/mimo-v2.5-free", "opencode/muse-spark-1.2-contributor-free", "opencode/muse-spark-1.3-contributor-free", "opencode/nemotron-3-ultra-free", "opencode/nemotron-3.5-lightning-free"],
+ "defaultModel": "opencode/big-pickle",
+ "timeout": 600000,
+ "enabled": false,
+ "envVars": {
+ "OPENCODE_CONFIG_CONTENT": "{\"permission\":\"allow\"}"
+ },
+ "secretEnvVars": ["OPENCODE_API_KEY"],
+ "headlessArgs": []
+ },
+ "opencode-zen-tui": {
+ "id": "opencode-zen-tui",
+ "name": "OpenCode Zen TUI",
+ "type": "tui",
+ "command": "opencode",
+ "args": [],
+ "models": ["opencode/big-pickle", "opencode/ling-3.0-flash-fin-free", "opencode/mimo-v2.5-free", "opencode/muse-spark-1.2-contributor-free", "opencode/muse-spark-1.3-contributor-free", "opencode/nemotron-3-ultra-free", "opencode/nemotron-3.5-lightning-free"],
+ "defaultModel": "opencode/big-pickle",
+ "timeout": 600000,
+ "enabled": false,
+ "envVars": {
+ "OPENCODE_CONFIG_CONTENT": "{\"permission\":\"allow\"}"
+ },
+ "secretEnvVars": ["OPENCODE_API_KEY"],
+ "tuiPromptDelayMs": 2500,
+ "tuiIdleTimeoutMs": 180000
+ },
"antigravity-tui": {
"id": "antigravity-tui",
"name": "Antigravity TUI",
"type": "tui",
"command": "agy",
"args": ["--dangerously-skip-permissions"],
- "models": ["antigravity-configured-default","gemini-3.7-flash-high","gemini-3.7-flash-medium","gemini-3.7-flash-low","gemini-3.6-flash-high","gemini-3.6-flash-medium","gemini-3.6-flash-low","gemini-3.5-flash-high","gemini-3.5-flash-medium","gemini-3.5-flash-low","gemini-3.1-pro-high","gemini-3.1-pro-low","claude-sonnet-4-6","claude-opus-4-6-thinking","gpt-oss-120b-medium"],
+ "models": ["antigravity-configured-default","gemini-3.8-flash-high","gemini-3.8-flash-medium","gemini-3.8-flash-low","gemini-3.7-flash-high","gemini-3.7-flash-medium","gemini-3.7-flash-low","gemini-3.6-flash-high","gemini-3.6-flash-medium","gemini-3.6-flash-low","gemini-3.1-pro-high","gemini-3.1-pro-low","claude-sonnet-4-6","claude-opus-4-6-thinking","gpt-oss-120b-medium"],
"defaultModel": "antigravity-configured-default",
"lightModel": "antigravity-configured-default",
"mediumModel": "antigravity-configured-default",
diff --git a/data.reference/rigging/README.md b/data.reference/rigging/README.md
new file mode 100644
index 0000000000..fe747731ea
--- /dev/null
+++ b/data.reference/rigging/README.md
@@ -0,0 +1,15 @@
+# Rigging clip library
+
+`data/rigging/clips/` is a **file-primary** library of animation-bearing GLB
+files that you own or are licensed to use. PortOS creates this empty directory
+from `data.reference/` during `npm run setup:data`; it does not bundle clip
+assets here.
+
+Drop a `.glb` file containing animation clips into this directory. A later
+retarget job will inspect its skeleton and refuse a partial or unrecognized
+mapping rather than producing a broken animation.
+
+Only use assets whose license permits your intended use. Good places to find
+your own compatible CC0 source material include the [Kenney asset library]
+(https://kenney.nl/assets) and other sources that expressly label the specific
+asset CC0. Verify the license for each file before adding it.
diff --git a/data.reference/rigging/clips/.gitkeep b/data.reference/rigging/clips/.gitkeep
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/data.reference/settings.json b/data.reference/settings.json
index 9db31b01f0..c524f769d1 100644
--- a/data.reference/settings.json
+++ b/data.reference/settings.json
@@ -46,11 +46,6 @@
},
"vad": { "endOfSpeechMs": 700, "minUtteranceMs": 250 }
},
- "citySnapshots": {
- "enabled": true,
- "intervalMinutes": 5,
- "maxSnapshots": 1000
- },
"apiAccess": {
"voice": { "exposed": false, "requireAuth": false },
"sdapi": { "exposed": false, "requireAuth": false }
diff --git a/docs/API.md b/docs/API.md
index 9ee52528cf..119123f06d 100644
--- a/docs/API.md
+++ b/docs/API.md
@@ -125,6 +125,16 @@ own wire contract in [FEDERATED_MEDIA_PROVIDERS.md](./FEDERATED_MEDIA_PROVIDERS.
| GET | `/providers/opencode/installation` | **Legacy alias**, kept so a stale client bundle still renders: `{ installed, npmAvailable }` for the `opencode` runtime. New code uses `/providers/runtimes`. |
| POST | `/providers/opencode/install` | **Legacy alias** for `/providers/runtimes/install?runtime=opencode`. |
+### Harnesses
+
+The coding-agent CLIs/TUIs PortOS drives (`opencode`, `claude`, `codex`, `agy`, `grok`, `kimi`, `cursor-agent`), managed as things in their own right rather than as a footnote on a provider card. Backs **Models → Harnesses**. Every response carries booleans, versions, and labels — never a resolved filesystem path, which would disclose the host account name.
+
+| Method | Endpoint | Description |
+|--------|----------|-------------|
+| GET | `/harnesses?fresh=1` | Every harness with its installed version, the latest published version (npm-backed rows only), whether an update is available, which lifecycle actions it supports, and the provider records riding on it. `version`/`latestVersion` are `null` for NOT KNOWN, never `0.0.0` — `updateAvailable` is set only on a definite "installed < latest", so an offline host shows no false staleness badge. `fresh=1` bypasses both the 60s runtime-status TTL and the 6h registry cache. |
+| POST | `/harnesses/action?runtime=&action=install\|update\|uninstall` | Run one lifecycle action, streaming the child's output as SSE. `action` defaults to `install`. Update prefers the vendor's OWN updater (`opencode upgrade`, `claude update`) — the only path that refreshes the copy actually on PATH when the user installed it from Homebrew or a vendor script. Remove is offered only for npm-installed rows, and reports an error rather than success if the binary is still runnable afterwards. One action at a time process-wide (npm's global prefix is one directory); closing the stream cancels the child. Both values are table keys — nothing from the request reaches a shell word. |
+| POST | `/harnesses/models/refresh?runtime=` | Re-read a harness's own model catalog (`opencode models`, `agy models`, `grok models`) and write it to every provider that draws from that catalog — i.e. a wrapper with no local-runtime marker and no `gatewayBacked`, since a gateway- or Ollama-backed wrapper serves ids the harness never lists. Answers `{ ok, models, updated }`. A probe that runs but parses to nothing is a **409**, not an empty write: a signed-out CLI must not blank every picker. |
+
### AI Runs
| Method | Endpoint | Description |
@@ -252,11 +262,11 @@ Context tools remain read-only. Semantic reads and writes are independent, defau
|--------|----------|-------------|
| GET | `/cos/schedule` | Get full task schedule status |
| GET | `/cos/upcoming` | Get upcoming scheduled tasks preview |
-| GET | `/cos/schedule/interval-types` | Get available interval types and descriptions |
+| GET | `/cos/schedule/interval-types` | Get the two cadence types (`on-demand`, `cron`) and their descriptions, plus the `perpetual` flag description |
| GET | `/cos/schedule/due` | List all tasks due to run |
| GET | `/cos/schedule/due/:appId` | List tasks due for specific app |
| GET | `/cos/schedule/task/:taskType` | Get interval and schedule settings for a task type |
-| PUT | `/cos/schedule/task/:taskType` | Update schedule settings for a task type |
+| PUT | `/cos/schedule/task/:taskType` | Update schedule settings for a task type (`type`: `on-demand` \| `cron`; `cronExpression`: 5-field or null; `perpetual`: boolean drain flag, orthogonal to `type`) |
| POST | `/cos/schedule/trigger` | Trigger an on-demand task run |
| GET | `/cos/schedule/on-demand` | List pending on-demand task requests |
| DELETE | `/cos/schedule/on-demand/:requestId` | Clear a pending on-demand request |
@@ -709,6 +719,7 @@ Every mounted API prefix (see `server/index.js` for the authoritative list). Dom
| `/api/browser` | Managed Chromium |
| `/api/creative-commission` | Creative commissions |
| `/api/midi-runtime` | MIDI runtime |
+| `/api/harnesses` | Coding-agent CLI/TUI harness lifecycle |
## WebSocket Events
diff --git a/docs/DEPS.md b/docs/DEPS.md
index 4cbd93a85a..66f96b35e0 100644
--- a/docs/DEPS.md
+++ b/docs/DEPS.md
@@ -2,7 +2,9 @@
Living reference of every third-party dependency in PortOS, why it's kept, and what the current verdict is. Updated by `/do:depfree` runs.
-**Last audited:** 2026-09-02 (scoped: `pdf-lib` → `@cantoo/pdf-lib`, issue #5672); prior 2026-08-04 (scoped audit of the `keyv`/`cacheable` supply-chain compromise); prior follow-up 2026-07-14 (issue #2547), prior full audit 2026-04-28 (default mode), tables corrected 2026-07-01 during a docs audit.
+That "every dependency" claim is enforced by `docs/deps-doc.test.js` (run by `cd server && npm test`): adding a dependency to any workspace manifest without a row here fails the suite, and a Quick Reference row naming a package no manifest declares fails too unless its verdict records the removal (`REMOVED` / `REPLACED`).
+
+**Last audited:** 2026-09-02 (scoped: `pdf-lib` → `@cantoo/pdf-lib`, issue #5672; parity sweep that added the missing `playwright-core` row and put this document under test — `docs/deps-doc.test.js`, issue #5708); prior 2026-08-04 (scoped audit of the `keyv`/`cacheable` supply-chain compromise); prior follow-up 2026-07-14 (issue #2547), prior full audit 2026-04-28 (default mode), tables corrected 2026-07-01 during a docs audit.
**Verdict:** All dependencies justified. The 2026-08-04 audit replaced the entire `eslint` stack with `@biomejs/biome`, dropping 110 net client packages including the `file-entry-cache → flat-cache → keyv` chain named in the August 2026 Shai-Hulud npm compromise (PortOS held safe versions throughout — see the detailed finding below). The same pass closed a latent hole where `ignore-scripts=true` was only active for repo-root installs, not for any workspace install or CI. Since the last full audit: `sax` was removed (replaced with an owned parser, issue #1824), `portos-ai-toolkit` was vendored in-tree (`server/lib/aiToolkit/`), and monolithic `googleapis` was replaced with scoped `@googleapis/*` packages. The 2026-07-14 follow-up bumped `kokoro-js` to its latest patch `1.2.1` (still on maintenance watch — no publish since 2025-05) and aligned the dual `pm2` pins (root + server both `7.0.4`). The 2026-09-02 follow-up replaced abandoned `pdf-lib` (no publish since 2022-05) with the maintained MIT fork `@cantoo/pdf-lib@2.9.1` — a same-public-API swap across the four export paths, done while `npm audit` was still clean rather than under advisory pressure.
## Audit Methodology
@@ -33,6 +35,7 @@ Before removing a Tier 3 candidate, run a transitive-dep check (`npm ls `).
| `kokoro-js` | 2 | KEEP | `server/services/voice/tts-kokoro.js` | Only pure-JS in-process TTS; replacement = Python subprocess + pooling |
| `node-pty` | 1 | KEEP | shell/terminal services | Native PTY binding (N-API) |
| `pg` | 1 | KEEP | Postgres access | Official `pg` driver |
+| `playwright-core` | 1 | KEEP | `server/services/fableLoom/falVideoAutomation.js` — fal.ai scene video automation | Drives the PortOS-managed browser over CDP (`chromium.connectOverCDP`). `-core` rather than the full `playwright` package deliberately: the browser is provisioned separately by `npm run setup:browser`, so the bundled browser download would be dead weight |
| `pm2` | 1 | KEEP | app lifecycle | Process manager. Pinned `7.0.4` (aligned with root pin) |
| `sharp` | 1 | KEEP | image processing | Native, widely-audited |
| `socket.io` | 1 | KEEP | realtime | Foundational |
@@ -59,7 +62,7 @@ Before removing a Tier 3 candidate, run a transitive-dep check (`npm ls `).
| `recharts` | 1 | KEEP | charts | |
| `socket.io-client` | 1 | KEEP | realtime client | |
| `three` | 1 | KEEP | 3D | |
-| `three-stdlib` | 1 | KEEP | CyberCity 3D | Community-maintained Three.js utilities used by the client renderer |
+| `three-stdlib` | — | REMOVED (direct) | 3D avatars | 2026-09-02 → `three/examples/jsm/utils/SkeletonUtils.js` (issue #5683). The sole direct import was `SkeletonUtils.clone`, which `three` itself ships. Still in the tree transitively via `@react-three/drei`, so this is a manifest cleanup, not a supply-chain reduction — the win is one fewer pin to Dependabot-bump and the removal of a pinned-vs-`^` drift surface against drei's own request |
| **Client devDeps** | | | | |
| `@biomejs/biome` | 1 | KEEP | linting | Replaced the whole eslint stack 2026-08-04; native binary, 0 regular deps + 8 platform optionals (1 installed) |
| `eslint` | — | REMOVED | linting | 2026-08-04 → `@biomejs/biome`. 53 packages were reachable only via `eslint` itself (incl. the `file-entry-cache → flat-cache → keyv` chain); 110 net once the plugin subtrees and orphaned `typescript` go too. `minimatch` and `brace-expansion` left the tree with it (both now 0 occurrences in every lockfile), so neither needs an override pin — do not re-add one |
@@ -124,7 +127,7 @@ Before removing a Tier 3 candidate, run a transitive-dep check (`npm ls `).
- **Trigger**: the August 2026 Shai-Hulud npm compromise hit `keyv`, `flat-cache`, and `file-entry-cache` (among ~430 packages). PortOS was **never exposed** — it held `keyv@4.5.4`, `flat-cache@4.0.1`, `file-entry-cache@8.0.0`, while the malicious releases were the *next major* in each line (`6.0.0` / `6.1.24` / `11.1.6`), unreachable from the `^4` / `^4.0.0` / `^8.0.0` ranges. npm has since unpublished all three, and no install hook ever existed in the tree.
- **Why they were here at all**: not direct dependencies. A single chain under one root — `eslint → file-entry-cache → flat-cache → keyv` — client workspace, devDependencies only. `eslint@10.8.0` (latest) hard-requires `file-entry-cache@^8.0.0`; no eslint release drops it. So the only way to shed them was to stop using eslint.
- **Semver headroom was already zero**: the highest published `file-entry-cache@8.x` *is* `8.0.0`, `flat-cache@4.x` *is* `4.0.1`, `keyv@4.x` *is* `4.5.4`. An `overrides` pin would have been a no-op — there was nowhere to float. Removal was therefore a **maintenance** win, not a security fix.
-- **Resolution**: replaced eslint with `@biomejs/biome@2.5.7`. Client lockfile **447 → 337 packages** (−110; the eslint tree out, 9 Biome entries in, only 1 platform binary installed). All four eslint-chain packages now report 0 occurrences in `client/package-lock.json`.
+- **Resolution**: replaced eslint with `@biomejs/biome` (the Quick Reference row and `client/package.json` are the version's home — naming a pin here only drifts). Client lockfile **447 → 337 packages** (−110; the eslint tree out, 9 Biome entries in, only 1 platform binary installed). All four eslint-chain packages now report 0 occurrences in `client/package-lock.json`.
- **`typescript` went with it.** It was a client devDependency solely to satisfy `@eslint-react/eslint-plugin`'s peer dep — the client has no `tsconfig`, zero `.ts`/`.tsx` sources, no `tsc` script, and nothing peer-depends on it. Removing it also retires the `.github/dependabot.yml` ignore rule that pinned it below TS7 (and issue #3351, which tracked waiting on typescript-eslint for TS7 support — now moot).
- **Rule parity was proven with fixtures, not inferred from a clean run** — a linter with no rules also reports "0 problems". Every rule the old config enforced fires under Biome; `npm run lint` covers the same **1859 files** with 0 problems. `exhaustive-deps` was already `off` (documented, deliberate), which is what made this a half-day swap instead of a risky one.
- **The `crypto.randomUUID` ban survives as a GritQL plugin** (`client/lint-no-random-uuid.grit`). This rule is load-bearing: `crypto.randomUUID` is undefined on insecure origins, and PortOS is routinely reached over plain HTTP via Tailscale. It matches on the CST node kind rather than code snippets, so it catches `crypto.`, `globalThis.crypto.`, `window.`, `self.`, optional chaining, bare `typeof` references, and assignment targets — and, matching the old ESLint rule exactly, *not* `crypto['randomUUID']`. Exemptions for `src/lib/uuid.js` and `src/**/*.test.{js,jsx}` are expressed as **negated globs**, because a Biome override's `plugins` list is *additive*: `plugins: []` does NOT disable an inherited plugin. Do not "simplify" that back.
@@ -167,6 +170,12 @@ These exist purely to force-bump transitive deps; revisit if `npm audit` flags n
**Not every compromised package warrants a pin.** A pin only helps when the installed version is *below* the top of its permitted range — otherwise there is nothing to force. The August 2026 `keyv` / `flat-cache` / `file-entry-cache` compromise deliberately got **no** pin: each range was already at its ceiling (highest published `keyv@4.x` *is* `4.5.4`, etc.), so a pin would have been a no-op, and the packages were removed outright instead. Check headroom (`npm view @ version`) before adding an entry here.
+## Direct Dependency Pinning
+
+**Every direct dependency and devDependency, in every manifest, is an exact version — no `^`, `~`, `>=`, or `*`.** A caret range lets a fresh `npm install` (or any tree re-resolution: a Dependabot bump to a sibling package, `npm run setup`'s `npm install --no-save --prefix server`, `scripts/ensure-deps.js`'s clean-reinstall path) float past a version nobody reviewed — the same argument that already makes an override pin exact, applied to the packages this repo depends on directly. Upgrades arrive as reviewable Dependabot PRs instead.
+
+`server/dependency-overrides.test.js` enforces this across all four manifests, so a dependency added with a caret fails the suite rather than shipping.
+
## Install-Script Policy
`ignore-scripts=true` is pinned in **every** workspace's own `.npmrc` (root, `client/`, `server/`, `autofixer/`, `browser/`) — not just the repo root. The list is not maintained by hand: `discoverWorkspaces()` in `scripts/trusted-rebuilds.js` globs every top-level directory carrying a `package.json`, and the test asserts each discovered one has the setting — so a workspace added later is caught rather than silently unguarded. npm resolves the project `.npmrc` from the *local prefix* and never walks up the directory tree, so a root-only setting does not cover `cd client && npm install` or `npm ci --prefix server` (what CI runs). Deleting any workspace `.npmrc` silently re-grants every dependency in that workspace an install-time code-execution slot — the vector the Shai-Hulud worm used.
diff --git a/docs/GITHUB_ACTIONS.md b/docs/GITHUB_ACTIONS.md
index c0394f26fa..83d57f88d1 100644
--- a/docs/GITHUB_ACTIONS.md
+++ b/docs/GITHUB_ACTIONS.md
@@ -213,10 +213,16 @@ The selected work is split across parallel jobs:
the server on the same job when server source changed (the smoke path uses the
file backend under `NODE_ENV=test` and does not need Postgres). The install
and the native-addon rebuild are skipped when a `server/node_modules` cache is
- restored and its trusted-rebuild mark checks out.
+ restored and its trusted-rebuild mark checks out. This job also runs
+ `npm ci --prefix autofixer` — uncached and never skipped, because resolving
+ that workspace's tracked lockfile *is* the check. It is the only CI step that
+ installs `autofixer/`, which `npm run setup` and `scripts/ensure-deps.js`
+ install on every user's machine; without it a lockfile that stopped resolving
+ shipped green and failed at setup time. (`browser/` gets no such step: zero
+ dependencies, and its lockfile is deliberately gitignored.)
- **Client tests and build** — affected client tests; production build whenever
client source changed; client lint on the same install so Biome does not pay a
- second `npm ci`.
+ second `npm ci`. Lint, build, and the bundle budget run on shard 1 only.
- **DB tests** — provisions only the isolated `portos_test` database and runs
the serial DB suite when database-sensitive files changed.
- **Windows server tests** — the same server selection, but only on full CI
@@ -231,6 +237,47 @@ The selected work is split across parallel jobs:
mirrors `CI Gate`'s result. This is the check the release workflow looks for;
see "Reusing the release PR's CI run" below.
+### Full-suite sharding
+
+A full plan is the slow case: on the 4-vCPU public runners the client suite
+alone took ~10 minutes (jsdom setup per file dominates — the 868-file run
+spent 489 s in `environment` against 384 s in `tests` — and its worker cap is
+two, see `scripts/vitestCiPool.js`), the Windows server suite ~10 minutes
+(module import is several times slower there), and the Linux server suite ~6
+minutes. Roughly half of all PR runs went full, so most PRs waited on the
+longest of those.
+
+The three test-runner jobs are therefore matrices. The impact job emits
+`server_shards`, `client_shards`, and `windows_shards` — `[1]` for a scoped
+plan and `[1..n]` for a full one, sized by `FULL_SUITE_SHARDS` in
+`scripts/ci-test-plan.js` (client 3, Windows 3, server 2) — and each job builds
+its matrix from that output. The decision has to be made in the planner: a
+job-level `if` cannot read the `matrix` context, so a job cannot skip its own
+extra shards. `scripts/run-ci-tests.js` turns `CI_SHARD=/` into
+Vitest's `--shard`, which slices the file list by path hash — every shard is a
+fixed, disjoint subset, and their union is the complete suite. A scoped plan
+never shards (its handful of files would trip Vitest's shard-count guard) and
+passes no flag at all, so its invocation stays identical to a local
+`npm run test:ci`. Once-only steps — smoke boot, lint, the client build, the
+bundle budget — pin themselves to shard 1. `CI Gate` sees a matrix job as one
+`needs` result, so nothing downstream changes; public-repo runner minutes are
+free, so the fan-out costs only concurrency.
+
+`scripts/run-ci-tests.test.js` pins the wiring: every runner job builds its
+matrix from the planner, hands `CI_SHARD` to the runner, and gates its
+once-only steps on shard 1.
+
+### Python sidecar scripts
+
+`scripts/*.py` (the LTX-2, MiniMax, FastVideo, and download sidecars) used to
+be "unclassified changed files" and forced the complete matrix on every edit.
+Vitest's import graph cannot reach into them, but ~45 suites pin their
+contracts by reading the `.py` source as text (argparse flags, MLX pins, model
+paths). The planner now resolves the suites naming each changed script with
+`git grep` (`pythonReferencePattern`) and runs exactly them in `files` mode,
+failing closed to the full suite for a script nothing names. A `.py` outside
+`scripts/` is still unclassified.
+
Targeted `files` plans run the planner's exact test files once. `related` plans
run `vitest related` once with changed behavioral source paths and the cheap
structural/repository contract files as inputs. Vitest treats a test-file input
@@ -332,6 +379,14 @@ aggregate diagnostics and cache post-steps can complete normally.
- Barrel/catalog guards are added when reusable `lib`, `hooks`, or `utils`
directories change, and catalog-only barrels are excluded from import-graph
expansion. JSX changes include the global accessibility convention guard.
+- Any server source change adds the two generated-manifest drift tests
+ (`generate-api-route-catalog`, `generate-prompt-stage-call-sites`). They
+ regenerate from the tree rather than importing what they scan, so no import
+ edge reaches them; before this rule a route added on a scoped plan could
+ merge with a stale catalog and turn every later full-plan PR red.
+- A `scripts/*.py` sidecar selects every test that names a python script
+ (`git grep`), in `files` mode, and falls back to the full suite when none do
+ — see "Python sidecar scripts" above.
- A deleted executable source cannot be handed to `vitest related`, so that
case fails closed to the complete suite.
- Database adapters, DB scripts, and relevant migrations add the complete
diff --git a/docs/MANAGED_APP_UPDATES.md b/docs/MANAGED_APP_UPDATES.md
new file mode 100644
index 0000000000..7e4fdde0ed
--- /dev/null
+++ b/docs/MANAGED_APP_UPDATES.md
@@ -0,0 +1,43 @@
+# Managed app update contract
+
+PortOS updates itself through `update.sh` or `update.ps1`. That lifecycle is
+specific to PortOS and is never assumed for a separately managed app.
+
+When a managed app is updated, PortOS first fetches `origin`, checks out that
+repository's default branch, fast-forwards it, and restarts the app's configured
+PM2 processes. It does not automatically run `npm install`, `setup`, migrations,
+or a production build. If the checkout has local changes, the update stops
+without stashing or discarding them.
+
+An app can opt into its own lifecycle in either of these ways:
+
+1. Add an executable `update.sh` (or `update.ps1` on Windows) at the repository
+ root. PortOS recognizes these conventional scripts automatically.
+2. Set **Update Command** in Apps → Edit → Commands (for example,
+ `npm run update`). Commands use PortOS's normal command allowlist and run
+ from the app repository root.
+3. For Node or Bun apps, define a dedicated package script named
+ `portos:update`. PortOS runs it as `npm run portos:update` or
+ `bun run portos:update` for Bun-managed apps.
+
+The command/script is responsible for the app's own dependency installation,
+database migrations, generated assets, and build. Use the dedicated
+`portos:update` name rather than a generic lifecycle hook so an app's normal
+package-manager behavior is never invoked merely because PortOS updated it.
+When more than one is present, the configured **Update Command** wins, then
+`portos:update`, then the conventional script.
+
+## PortOS is itself a managed app
+
+The PortOS record appears in App Management like any other app, so **Update**
+there runs the same `appUpdater` flow described above. It is the one app whose
+update routine deletes the process running that flow, so it takes a different
+launcher: the conventional-script branch delegates to `executeUpdate()` in
+`server/services/updateExecutor.js`, whose double-fork keeps `update.sh` alive
+through its own `pm2 delete` step, and the trailing PM2 restart is skipped
+because the script starts the ecosystem itself. See
+[Self-Update Flow](SELF_UPDATE.md#every-portos-update-goes-through-the-detached-launcher).
+
+A custom **Update Command** on the PortOS record keeps the ordinary attached
+path, since delegating would silently run `update.sh` instead of the configured
+command.
diff --git a/docs/PORTS.md b/docs/PORTS.md
index 9599440bc7..4a3d0da99a 100644
--- a/docs/PORTS.md
+++ b/docs/PORTS.md
@@ -7,7 +7,7 @@ PortOS uses a contiguous port allocation scheme to make it easy to understand wh
### Convention
1. **Contiguous Ranges**: Each app should use a contiguous block of ports
-2. **Labeled Ports**: Define all ports in the top-level `PORTS` object in `ecosystem.config.cjs` (mirrored — manually kept in sync — in `server/lib/ports.js`, since the ESM server can't `require()` the CommonJS config); the per-process label map for PM2 processes lives in `server/services/apps.js`. Infrastructure dependencies (such as PostgreSQL on 5561) are provisioned via `scripts/setup-db.js` / Docker Compose rather than registered as PM2 processes in `apps.js`. The mirror carries every port literal, including both PostgreSQL ports; the config's mode-dependent `POSTGRES` (resolved from `PGMODE` at load time) is exposed in the mirror as `resolvePostgresPort(pgMode)` over the `POSTGRES_NATIVE` / `POSTGRES_DOCKER` literals, so `server/lib/ports.js` stays free of filesystem reads. `server/lib/ports.test.js` fails if the two drift apart
+2. **Labeled Ports**: Define all ports in the top-level `PORTS` object in `ecosystem.config.cjs` (mirrored — manually kept in sync — in `server/lib/ports.js`, since the ESM server can't `require()` the CommonJS config); the per-process label map for PM2 processes lives in `server/services/apps.js`. Infrastructure dependencies (such as PostgreSQL on 5561) are provisioned via `scripts/setup-db.js` / Docker Compose rather than registered as PM2 processes in `apps.js`. The mirror carries every port literal, including both PostgreSQL ports; the config's mode-dependent `POSTGRES` (resolved from `PGMODE` at load time) is exposed in the mirror as `resolvePostgresPort(pgMode)` over the `POSTGRES_NATIVE` / `POSTGRES_DOCKER` literals, so `server/lib/ports.js` stays free of filesystem reads. `server/lib/ports.test.js` fails if the two drift apart. The browser bundle can't import either file, so `client/src/lib/ports.js` carries a third, deliberately minimal mirror of just the UI-facing subset (`API`, `API_LOCAL`, `UI`) plus `DEFAULT_PEER_PORT` — use it instead of re-hardcoding a port literal in client code; `client/src/lib/ports.parity.test.js` fails if it drifts
3. **No Gaps**: Avoid leaving gaps between port allocations within an app
### Port Labels
diff --git a/docs/README.md b/docs/README.md
index 83f472bb03..28bdc68dc5 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -26,6 +26,7 @@ Index of everything under `docs/`. Start with the [root README](../README.md) fo
| [GITHUB_ACTIONS.md](./GITHUB_ACTIONS.md) | CI and release workflows |
| [VERSIONING.md](./VERSIONING.md) | SemVer + release process (`/do:release`) |
| [SELF_UPDATE.md](./SELF_UPDATE.md) | Fork-aware self-update flow — release polling, `FORK_SYNC_REQUIRED`, fork sync |
+| [MANAGED_APP_UPDATES.md](./MANAGED_APP_UPDATES.md) | Safe managed-app update default and the opt-in app lifecycle contract |
| [DEPS.md](./DEPS.md) | Dependency audit — every third-party package and its verdict |
| [TROUBLESHOOTING.md](./TROUBLESHOOTING.md) | Common runtime issues, known issues |
| [WINDOWS_CONSOLE.md](./WINDOWS_CONSOLE.md) | Why console windows flash and steal focus on Windows, and the two fixes |
diff --git a/docs/SELF_UPDATE.md b/docs/SELF_UPDATE.md
index 4ea67094a9..0998abfd2b 100644
--- a/docs/SELF_UPDATE.md
+++ b/docs/SELF_UPDATE.md
@@ -2,7 +2,7 @@
How PortOS notices a new release and updates itself. PortOS is distributed software — many people run it, and a large share run it from a **personal fork**, so every step here is fork-aware. Breaking that assumption produces silent no-op updates.
-Code: `server/services/updateChecker.js`, `server/routes/update.js`, `server/lib/gitRemote.js`, `update.sh` / `update.ps1`, `client/src/components/apps/tabs/UpdateTab.jsx`.
+Code: `server/services/updateChecker.js`, `server/services/updateExecutor.js`, `server/services/appUpdater.js`, `server/routes/update.js`, `server/lib/gitRemote.js`, `server/lib/detachedSpawn.js`, `update.sh` / `update.ps1`, `scripts/verify-server-health.js`, `client/src/components/apps/tabs/UpdateTab.jsx`.
## Release polling always targets upstream
@@ -58,11 +58,31 @@ 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.
+For the point-in-time analysis of macOS Finder metadata false positives and the separate managed-App build omission, see [macOS “Install out of sync” research](research/2026-09-03-macos-install-out-of-sync.md).
+
To prevent that confusion, `POST /api/update/execute` rejects fork runs with **412 `FORK_SYNC_REQUIRED`** unless either:
- the request body sets `acknowledgeFork: true`, or
- `lastForkSync.fullName` matches `remoteInfo.fullName` (compared case-insensitively — GitHub owner/repo names are) and is less than 10 minutes old. The service computes this once as `status.forkSyncFresh` from `FORK_SYNC_FRESHNESS_MS`; the route and the UI both read that flag rather than re-implementing the time math.
+## Every PortOS update goes through the detached launcher
+
+`update.sh` deletes and restarts every PortOS PM2 entry. PM2's TreeKill walks **PPID**, so a script left attached to `portos-server` is killed by its own `pm2 delete` step — mid-list, before it can run the closing `pm2 start` — and the install is left headless. `spawnDetached`'s double-fork (`server/lib/detachedSpawn.js`) is what reparents the script to init so it survives; `executeUpdate()` in `server/services/updateExecutor.js` is the single launcher that applies it, along with the `STEP:` progress parsing, the still-running-script guard, and `recordUpdateResult()`.
+
+**PortOS is also a managed app**, so an update started from **App Management** reaches `update.sh` through `appUpdater.js` rather than `routes/update.js`. That path delegates to `executeUpdate()` for the PortOS record instead of spawning the script itself — a second detached-spawn implementation would be one more thing to keep in sync, and the attached one it replaced produced exactly the headless failure above (#5976). `appUpdater` also **skips its own `restart` step** for that case: the script runs `pm2 start ecosystem.config.cjs` itself, so restarting on top of it would be redundant and would race the script.
+
+Both entry points take the same atomic `setUpdateInProgress(true)` lock before launching, so they cannot run `update.sh` concurrently — and because that flag is what `subAgentSpawner`, `agentLifecycle` and `persistentMindSupervisor` gate on, holding it also stops a CoS agent from being spawned into a process the script is about to `pm2 delete` (#4124).
+
+A PortOS record carrying a custom `updateCommand`, or a `repoPath` that is not this checkout, keeps the ordinary attached path — delegating there would silently run `update.sh` instead of the configured command. That decision is logged rather than silent, since the attached path is the one that failed. The `repoPath` comparison resolves symlinks and case-folds on macOS/Windows: `repoPath` is user-editable and not force-synced, so a trailing slash or a different spelling must not be mistaken for a different checkout. Non-PortOS managed apps are unaffected and still get their own PM2 restart from `appUpdater`. The dashboard handoff it starts before that restart is PortOS-only — it opens the PortOS dashboard, so it would be meaningless after another app's update.
+
+## Post-update health verification
+
+`pm2 start` exiting 0 is not proof the server came back, and the process that would notice is the one that did not. Both platform scripts therefore close with a `verify` step that polls `/api/system/health` (`scripts/verify-server-health.js`) until it reports `ok` or the budget — `PORTOS_HEALTH_WAIT_MS`, default 120s — runs out. On failure they spend one more `pm2 start ecosystem.config.cjs` and then log the outcome loudly, with the manual recovery command.
+
+The probe tries the loopback HTTP mirror (`:5553`) first, then the API port over HTTP and HTTPS, because the listening scheme depends on whether a cert is provisioned; `/api/system/health` is in the always-public set, so it works with the optional instance password on. The recovery only fires when the probe fails, so it cannot make a healthy update worse.
+
+**When the probe still fails after the recovery, the scripts say so and exit non-zero** — the closing banner reads "Update applied, but PortOS is DOWN" instead of "Update Complete". The script outlives the server it restarts, so its exit status and the tail of `data/update.log` are the only signals a wrapper, a CI job, or an operator still has; printing a success banner over a confirmed-headless install is how the failure went unnoticed for hours in the first place.
+
## Syncing a fork
`POST /api/update/sync-fork` shells out to:
diff --git a/docs/STORAGE.md b/docs/STORAGE.md
index 663f996e09..5e720c7c85 100644
--- a/docs/STORAGE.md
+++ b/docs/STORAGE.md
@@ -32,7 +32,7 @@ PostgreSQL is a **required** install/runtime dependency (see [Backup & Restore](
- `catalog_ingredient_relations` — directed ingredient→ingredient graph edges (the strongest argument for Postgres as the catalog graph store).
- `memories` / `memory_links` — long-term memory + pgvector similarity.
- `post_runs` / `post_attempts` — normalized MeatSpace POST test, benchmark, and training history. A run owns its planned composition and lifecycle timestamps; attempts carry queryable module/drill, difficulty/config version, correctness/score, latency/completion, hint/confidence, input mode, and scorer provenance, with the compatibility payload retained in JSONB. The complete attempt set is replaced in one transaction and stable client ids make retries idempotent. Migrated from `data/meatspace/post-sessions.json` and `post-training-log.json` by `server/scripts/migratePostRunsToDB.js`; the sources are parked as `.imported` recovery copies. **Intentionally machine-local — never federated** because cognitive-performance history is personal activity data. Adapter: `server/services/postRunStore.js` (legacy JSON only under the dev/test file escape hatch).
-- `user_action_events` — the operator-action ledger (#5594): one row per action the HUMAN took in PortOS (queued a CoS task, edited/deleted/approved/force-spawned one, rated an agent run, hit Run Now on a scheduled task, saved settings). `db-primary` because the value is in querying it — by type, actor, target, and time window — which is exactly what a JSONL append log cannot do. Columns carry the queryable axes (`type`/`actor`/`happened_at`/`target`/`success`) with structured detail in `payload` JSONB and the hook site in `source` JSONB; a unique `(type, dedupe_key)` index plus `ON CONFLICT DO NOTHING` makes a retried request idempotent. Bounded inline after each insert by BOTH a 20,000-row cap and a 90-day age cap — no cron. Credential-shaped payload keys are dropped at write time and their paths listed under `payload.redactedKeys`. **Intentionally machine-local — never federated**: it records what one operator did on one machine, and PII must not ride the federation layer (ADR [privacy records machine-local](./decisions/2026-08-08-privacy-records-machine-local.md)); guarded in `server/services/sharing/peerSync.test.js`. Deliberately NOT in `auditedTables` — auditing an audit log doubles every row, same rationale as `post_runs`. Adapter: `server/services/userActions.js` (file backend only under the dev/test escape hatch).
+- `user_action_events` — the operator-action ledger (#5594, epic #5593): one row per action the HUMAN took in PortOS (queued a CoS task, edited/deleted/approved/force-spawned one, rated an agent run, hit Run Now on a scheduled task, saved settings; phase 3 / #5596 also records event-only creative/Brain pointers, instance-feature toggles, and CoS schedule updates that skip `PUT /api/settings`). The leftover-branch idle detector is a **consumer** of this ledger (plus live git state), not a store of its own. `db-primary` because the value is in querying it — by type, actor, target, and time window — which is exactly what a JSONL append log cannot do. Columns carry the queryable axes (`type`/`actor`/`happened_at`/`target`/`success`) with structured detail in `payload` JSONB and the hook site in `source` JSONB; a unique `(type, dedupe_key)` index plus `ON CONFLICT DO NOTHING` makes a retried request idempotent. Bounded inline after each insert by BOTH a 20,000-row cap and a 90-day age cap — no cron. Credential-shaped payload keys are dropped at write time and their paths listed under `payload.redactedKeys`. **Intentionally machine-local — never federated**: it records what one operator did on one machine, and PII must not ride the federation layer (ADR [privacy records machine-local](./decisions/2026-08-08-privacy-records-machine-local.md)); guarded in `server/services/sharing/peerSync.test.js`. Deliberately NOT in `auditedTables` — auditing an audit log doubles every row, same rationale as `post_runs`. Adapter: `server/services/userActions.js` (file backend only under the dev/test escape hatch).
- `creative_director_projects` — Creative Director project/treatment/scene/run state, one row per project (`id`/`status`/timestamps as columns, the full record in `data` JSONB). Migrated from the monolithic `data/creative-director-projects.json` in Phase 3 (#997); CD is local-only, so the row carries no sync cursor/tombstone. Adapter: `server/services/creativeDirector/projectsDB.js`.
- `catalog_user_types` — user-defined ingredient types (the registry that defines catalog row semantics), one row per type (`id` PK, the definition in `data` JSONB, `updated_at`/`deleted_at` mirroring the federation LWW clock + tombstone). Migrated from the `data/settings.json` `catalogUserTypes` slice in Phase 4 lead-in (#1001) so type evolution versions/syncs alongside the catalog data it governs. Federates via the catalog sync `catalogTypes` envelope block (wire shape unchanged by the move). Adapter: `server/services/catalogUserTypes/db.js`, dispatched via `store.js`.
- `universes` / `universe_runs` — Universe Builder records (canon bibles, categories, composite sheets, locks, influences, and portable character production packages) one row per universe with the full sanitized record in `data` JSONB and `name`/`schema_version`/`ephemeral`/`updated_at`/`deleted`/`deleted_at` mirrored into columns; render-run history one row per run (local-only, capped 200, never federated). Character production packages carry only versioned voice direction and approved managed-image roles; local profiles, recordings, provider ids, and training artifacts are excluded from the federated wire. Migrated from `data/universes/{id}/index.json` (collectionStore) in Phase 3 Create slice 1 (#1014). **NO `sync_sequence`** — universes federate via the EXISTING `dataSync` snapshot/push model (LWW on the body's `updatedAt`), so the storage swap is invisible to peers (no schema-version bump). The store bumps an in-process mutation epoch on every write that `dataSync` folds into its checksum fingerprint, since a DB edit no longer changes the `data/universes/` directory the fingerprint used to watch. **`universe_runs` is intentionally never federated** — a regenerable render cache under a 200-row *global* cap that two producers would mutually evict, while the durable universe record already syncs (ADR [tribe + universe-runs local](./decisions/2026-06-26-tribe-and-universe-runs-local.md), #1724). Adapter: `server/services/universeBuilder/db.js`, dispatched via `store.js`.
@@ -87,6 +87,8 @@ PostgreSQL is a **required** install/runtime dependency (see [Backup & Restore](
**Media asset index (`media_assets`, #1000).** One row per generated image/video: `media_key` (`:[`) PK, `kind`/`ref`/`created_at` mirror columns for queries, the full metadata record in `data` JSONB. It is a **derived index** — the on-disk sidecars + `video-history.json` stay authoritative — reconciled from disk at boot (upsert every asset, prune rows whose file is gone) and kept warm by a generation-`completed` hook. Local-only (rebuilt from disk), so no sync cursor/tombstone. Adapter: `server/services/mediaAssetIndex/{logic,db,index}.js`.
+**Asset license provenance (#5638).** Every finished image and video stamps `data.provenance` at finalize time: the renderer/model id, every LoRA applied, and each one's license string and source URL *as known when the pixels were made*. Unknown stays `null` (displayed as "unknown") — never a permissive default. A license re-read months later can differ from the one in force at render, so the stamp is written into the authoritative sidecar / video-history row (the derived `media_assets.data` JSONB mirrors it). LoRA installs persist `license` on the `.metadata.json` sidecar so it is available at render rather than re-fetched. Collection and export surfaces roll the distinct sources up into an Attribution & licenses section.
+
**Standalone media-library federation (`mediaLibrary`, #1566).** For **full-sync peers**, the standalone media-library *bytes* (generated images + sidecars, videos, pipeline audio, uploaded music) mirror across the pair — not just bytes referenced by a synced creative record. The sender advertises a library-level manifest at `GET /api/peer-sync/library-manifest` (`{ schemaVersion, manifestHash, assets:[{kind,filename,sha256,sidecarSha256?}] }`); the receiver's periodic sweep (`syncMediaLibraryFromPeer`, driven from `initSharing`) diffs it against local disk, receiver-pulls missing bytes through the SAME `diffAssetManifestAgainstLocal` + `pullOneAsset` machinery as the per-record path, then rebuilds the derived `media_assets` index. Video thumbnails are regenerated locally on video pull (not byte-federated); `video-history.json` metadata already union-merges via the `videoHistory` dataSync category; the generic `data/history.jsonl` action log is machine-local and never federated. Byte replication is gated to `peer.fullSync` and honors backup `DEFAULT_EXCLUDES` (a media dir excluded from backup isn't federated). Manifest envelope versioned by `PORTOS_SCHEMA_VERSIONS.mediaLibrary` (a non-record category — see `NON_RECORD_SCHEMA_CATEGORIES`); the receiver gently skips a sender ahead of its version.
**Postgres-First target (remaining).** `data/history.jsonl` (action log) and the durable portions of `data/media-jobs.json` (job history / lineage) are still file-backed — follow-up slices. Do **not** move generated image/video/audio bytes into PostgreSQL.
@@ -169,6 +171,8 @@ The escape hatch is **guarded from bitrot by the test suite** (tests boot with `
`ensureSchema()` in `server/lib/db.js` applies **idempotent** schema upgrades on every boot (`CREATE TABLE IF NOT EXISTS`, `ADD COLUMN IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`). Every index it creates — including the HNSW vector index and the GIN full-text index on `catalog_scraps` — is a plain, **non-`CONCURRENT`** build.
+There is one Postgres per install, shared by every process that opens it (the server, `portos-cos`, and every CoS agent worktree), so two processes calling `ensureSchema()` at once is routine — most visibly when an update restart overlaps an outgoing server still shutting down with the incoming one. The DDL block is idempotent **within one session** but not atomic **across sessions**: the per-table audit triggers are installed as a `DROP TRIGGER IF EXISTS` / `CREATE TRIGGER` pair, and two interleaved sessions can both pass the `DROP` before either reaches `CREATE`, so the second `CREATE` throws "already exists" (#5977). `ensureSchemaImpl()` takes a dedicated client and holds a session-level `pg_advisory_lock` around the entire DDL block (upgrades + catalog) to serialize this cluster-wide; the lock is released in `finally` on both the success and throw path, and a dropped connection (a hard kill mid-boot) releases it automatically so a later boot is never blocked by a stale lock.
+
The **first** time a given index materializes on a table that **already holds many rows** (an existing install upgrading into a newly-added index), Postgres takes a `SHARE` lock that **blocks writes** (INSERT/UPDATE/DELETE) to that table until the build completes. So an existing install can see a one-time write stall at boot proportional to the table's row count — HNSW builds are the slowest. Fresh installs never see this: they build every index on an **empty** table, so the lock is effectively instant.
This is left as-is deliberately rather than switched to `CREATE INDEX CONCURRENTLY`, because `CONCURRENTLY` cannot run inside a transaction block, needs its own retry/cleanup path (a failed concurrent build leaves an `INVALID` index that must be dropped by hand), and roughly doubles build time — too fragile to run unattended on every boot for a stall that only bites large-table upgrades.
diff --git a/docs/decisions/2026-09-01-federated-usage-metrics.md b/docs/decisions/2026-09-01-federated-usage-metrics.md
index b6471f7025..e354ff9239 100644
--- a/docs/decisions/2026-09-01-federated-usage-metrics.md
+++ b/docs/decisions/2026-09-01-federated-usage-metrics.md
@@ -121,11 +121,20 @@ excluded on both privacy and payload grounds.
- Peer rows are as fresh as the last 60s sync cycle, so each row states when its
digest was captured rather than implying it is live.
- `usage` is the first snapshot category that is **always dirty** — `saveUsage`
- rewrites `usage.json` on every AI run — so it transfers the whole digest map
- where every other category rests on a rarely-moving checksum. The payload is
- bounded here (120-day wire rollup, all-time `byProvider`/`byModel` dropped,
- no per-provider rows on fleet output); replacing the whole-payload transfer
- with a `capturedAt` manifest + per-slot fetch is filed as **#5759**.
+ rewrites `usage.json` on every AI run — where every other category rests on a
+ rarely-moving checksum. The payload is bounded (120-day wire rollup, all-time
+ `byProvider`/`byModel` dropped, no per-provider rows on fleet output), and
+ since **#5759** the transfer is per-slot rather than whole-map: the category
+ serves a **manifest** at `/api/sync/usage/manifest` — `{ instances: {
+ ]: capturedAt }, tombstones }` — and that manifest, not the
+ payload, is what the category's checksum hashes. A puller diffs the remote
+ manifest against what it already holds and fetches only the advanced slots
+ via `/api/sync/usage/snapshot?slots=…`, so one machine burning tokens moves
+ one digest instead of N. Both legs degrade: a source peer too old to serve a
+ manifest 404s it and the puller falls back to the whole snapshot, and a
+ snapshot request with no `slots` serves everything — receivers merge per slot
+ under LWW either way, so a full payload is always applied idempotently. See
+ `server/lib/syncManifest.js` for the wire contract.
- **The per-peer toggle governs the INBOUND direction.** Snapshot sync is
pull-only, and `/api/sync/:category/snapshot` carries no per-peer category
authorization for *any* category — the receiver-side gap that per-record pulls
diff --git a/docs/deps-doc.test.js b/docs/deps-doc.test.js
new file mode 100644
index 0000000000..840fed7359
--- /dev/null
+++ b/docs/deps-doc.test.js
@@ -0,0 +1,96 @@
+/**
+ * Parity guard for docs/DEPS.md.
+ *
+ * DEPS.md calls itself a "living reference of every third-party dependency in
+ * PortOS" and is the artifact a reviewer consults to answer "why is this package
+ * here, and is it still justified?". Nothing enforced that claim, and it drifted:
+ * `playwright-core` landed as a server runtime dependency 27 days after the
+ * document's stated last-audit date and had no row at all, so the newest
+ * dependency in the tree was the one with no recorded justification.
+ *
+ * These assertions move that failure from "the next audit, months later" to the
+ * commit that introduces it. They deliberately check *names*, not versions —
+ * DEPS.md carries no version for most rows, and asserting on the few that do
+ * would turn every Dependabot bump into a doc-edit chore.
+ *
+ * The test is colocated with the document it guards; `server/vitest.config.js`
+ * globs `../docs/**` so `cd server && npm test` picks it up.
+ */
+import { readFileSync } from 'fs';
+import { dirname, join } from 'path';
+import { fileURLToPath } from 'url';
+import { describe, expect, it } from 'vitest';
+import { discoverWorkspaces, workspaceDir } from '../scripts/trusted-rebuilds.js';
+
+const DOC = readFileSync(join(dirname(fileURLToPath(import.meta.url)), 'DEPS.md'), 'utf8');
+
+/**
+ * Every direct dependency name in every workspace manifest, mapped to the
+ * workspaces declaring it. Workspaces are discovered rather than hardcoded
+ * (`discoverWorkspaces()` is the repo's existing single source for "what is a
+ * workspace"), so a fifth workspace is covered without editing a list here.
+ *
+ * `optionalDependencies` counts too — an optional package is still installed
+ * third-party code with a supply-chain surface. `peerDependencies` does not: a
+ * peer is declared for a consumer to install, and no PortOS workspace is a
+ * published library.
+ *
+ * Manifests are read and JSON-parsed as files rather than resolved as modules:
+ * CI never installs root `node_modules`, so anything reached through an
+ * installed package would be green locally and red in CI.
+ */
+const WORKSPACES = discoverWorkspaces();
+const DECLARED = new Map();
+for (const label of WORKSPACES) {
+ const pkg = JSON.parse(readFileSync(join(workspaceDir(label), 'package.json'), 'utf8'));
+ const declared = [pkg.dependencies, pkg.devDependencies, pkg.optionalDependencies];
+ for (const name of declared.flatMap((group) => Object.keys(group ?? {}))) {
+ DECLARED.set(name, [...(DECLARED.get(name) ?? []), label]);
+ }
+}
+
+/**
+ * Rows of the Quick Reference table, as `{ name, verdict }`. Scoped to that one
+ * section so a table added to the prose below can't be mistaken for the roster.
+ * Cells are split rather than pattern-matched so padding and any decoration
+ * around the name (a link, a footnote marker) leave the row readable;
+ * section headings (`**Server deps**`) and the browser workspace's `_(none)_`
+ * placeholder carry no backticked package and drop out.
+ */
+function quickReferenceRows() {
+ const afterHeading = DOC.slice(DOC.indexOf('## Quick Reference Table') + 1);
+ const end = afterHeading.indexOf('\n## ');
+ const section = end === -1 ? afterHeading : afterHeading.slice(0, end);
+ return section
+ .split('\n')
+ .map((line) => line.split('|').map((cell) => cell.trim()))
+ .map(([, name, , verdict]) => ({ name: /`([^`]+)`/.exec(name ?? '')?.[1], verdict: verdict ?? '' }))
+ .filter(({ name }) => name);
+}
+
+describe('docs/DEPS.md', () => {
+ it('documents every dependency declared in every workspace manifest', () => {
+ // A Quick Reference row, not merely a backticked mention anywhere in the
+ // file: prose elsewhere (a "Last audited" note, a detailed finding) names
+ // packages in passing, and accepting that would let a deleted row pass.
+ const documented = new Set(quickReferenceRows().map(({ name }) => name));
+ const undocumented = [...DECLARED.entries()]
+ .filter(([name]) => !documented.has(name))
+ .map(([name, labels]) => `${name} (${labels.join(', ')})`);
+ expect(undocumented).toEqual([]);
+ });
+
+ it('names no package that no manifest declares, unless the row records a removal', () => {
+ const stale = quickReferenceRows()
+ .filter(({ name, verdict }) => !DECLARED.has(name) && !/REMOVED|REPLACED/.test(verdict))
+ .map(({ name }) => name);
+ expect(stale).toEqual([]);
+ });
+
+ it('scans a non-empty set of manifests and table rows', () => {
+ // Without this, a broken discovery path or a table-format change lets both
+ // assertions above pass over nothing at all.
+ expect(WORKSPACES.length).toBeGreaterThanOrEqual(4);
+ expect(quickReferenceRows().length).toBeGreaterThanOrEqual(40);
+ });
+});
diff --git a/docs/features/openworld.md b/docs/features/openworld.md
index 45bd70c7b0..db0708626a 100644
--- a/docs/features/openworld.md
+++ b/docs/features/openworld.md
@@ -1,198 +1,7 @@
# OpenWorld
-> **Retired in favor of Eidoverse.** The implementation remains in the checkout
-> for compatibility and reference, but `/openworld` and `/city` now redirect to
-> the private, persistent Eidoverse PortOS world. New world content and PortOS
-> resource projection belong in [Eidoverse Worlds](./eidoverse.md).
+> **Retired in favor of Eidoverse.** The retired OpenWorld implementation was removed. Persistent `/openworld`, `/openworld/*`, `/city`, and `/city/*` URLs redirect to `/eidoverse` without retaining deep-path suffixes.
-The historical snapshot scheduler is no longer started at PortOS boot. Existing
-snapshot files and compatibility endpoints remain readable for older clients;
-new automatic world synchronization is owned by Eidoverse's page-open refresh
-and optional install-local projection job.
+Existing `data/city-snapshots.jsonl` files remain untouched and inert. Eidoverse is the canonical optional 3D surface.
-> **Historical rename (2026-08-19).** This surface shipped as *CyberCity* at `/city`.
-> It was renamed **OpenWorld** at `/openworld`; the `/city` routes redirected so existing bookmarks,
-> pinned rows, palette history, and peer deep links keep working. Persisted nav-command
-> ids remain unchanged.
-
-## Vision
-
-OpenWorld is a playable, spatial interpretation of PortOS: a small world whose places
-are shaped by the systems, memories, goals, apps, and agents inside an install. It is
-not a 3D dashboard with ornamental streets. The world should be enjoyable to cross even
-before the player reads a number.
-
-The design has four priorities, in this order:
-
-1. **A memorable place.** Water, silhouettes, terrain color, and landmarks make each
- part of the world recognizable before labels do.
-2. **A satisfying traversal loop.** Arrive through the village gate, follow curving
- lanes, recover Echo Shards, and stop at cottages that open real PortOS places.
-3. **PortOS made physical.** Live app and system state changes structures and motion,
- but never replaces the authored geography.
-4. **A useful route back into the product.** Buildings, destinations, search, and URL
- deep links remain direct paths into canonical PortOS surfaces.
-
-## PortOS Village
-
-Street-level OpenWorld is a compact, continuous valley rather than a systems diagram spread
-across a flat plane. Its named neighborhoods remain useful for the map and deep links, but
-the player experiences them as parts of one village:
-
-- **The Common** — a circular gathering place around the kinetic AI Core.
-- **Memory Wilds** — the Memory House and Backup Cottage among denser trees.
-- **Maker Reach** — the Task Workshop, Goals Lodge, and Trophy House.
-- **Data Pier** — the waterfront cottage for database tables and data domains.
-- **Focus Gardens** — crops, productivity, quiet discoveries, and activity terrain.
-- **Wellness Grove** — the Wellness Greenhouse and personal-health destinations.
-
-`client/src/utils/openWorldPlan.js` is the geography contract. Its curved lanes, terrain
-height function, cottage footprints, region anchors, and walkability rules are shared by
-rendering, player grounding, collision, camera avoidance, suspension, and map views.
-
-The valley uses a deterministic irregular outline, rolling low-poly terrain, dense instanced
-broadleaf trees, grass, flowers, rocks, crops, benches, lanterns, a pond, and a harbor. The
-sea remains one inexpensive procedural surface outside the village shelf.
-
-Live PortOS state is woven into this authored setting rather than displayed as a detached
-dashboard. Active managed apps occupy small status-lit market kiosks around the Common;
-nearby app interaction opens the same focused app route used elsewhere in PortOS. Cottage
-plaques show their place name and a compact live metric, while memories grow a grove, tasks
-stack as workshop cargo, goals raise flags, and backup, health, voice, trophy, and data state
-alter their corresponding landmarks. Archived apps remain summarized at Archive Lodge so
-the active market stays legible.
-
-## The Village Run
-
-Street-level exploration is the game layer:
-
-1. The utility rover enters through the authored PortOS Village gate. App count never
- moves the starting line or blocks the arrival view.
-2. A broad heart loop and short destination lanes keep a cottage, garden, pond, or landmark
- entering the frame every few seconds without floating horizon labels.
-3. Echo Shards reward exploring the whole valley. Collection has audiovisual feedback and
- session persistence.
-4. Landmark proximity offers one clear action with `F`: visit the visible nearby cottage
- or place. Invisible street-level warp triggers are not used.
-5. `M` opens the Village Map. A destination warp is shareable via
- `/openworld/region/:regionId`, and dropping back to street level lands at that region.
-
-The exploration HUD deliberately removes dashboard noise and takes over the whole viewport.
-It shows only the current place, Echo progress, speed, nearby interaction, and four compact
-tools. Operational vitals, filters, agent bars, and attention panes return in orbital view.
-
-Controls:
-
-| Input | Action |
-|---|---|
-| `W` / `S` | accelerate / reverse |
-| `A` / `D` | steer |
-| `Shift` | boost |
-| `Ctrl` or `X` | brake |
-| `Space` | jump |
-| `F` | use the nearby building, landmark, or gate |
-| `V` | switch rover and first-person camera |
-| `M` | open the Village Map |
-| `Tab` | switch street-level and orbital view |
-| `R` | return to the latest arrival point |
-
-Touch uses a joystick plus only three verbs: boost, hop, and action.
-
-## PortOS Places
-
-The cozy places are authored; their destinations still tell the truth about the install.
-
-- App state → Common market kiosks, status lamps, active-agent markers, and focused app routes.
-- AI activity → the suspended seed and orbital rings at the AI Core, with targeted beams.
-- CoS tasks → crates outside the Task Workshop.
-- Backup state → the status lamp outside Backup Cottage.
-- Memory graph and inbox → blossom clusters and the memory well at Memory House.
-- Goals and artifacts → flags and earned displays around their cottages.
-- Productivity and calendar history → terrain heat and task-flow motion.
-- Health → the Wellness landmark.
-- Database introspection → Data Harbor structures.
-- Federated peers → distant silhouettes beyond the local islands.
-
-These mappings are symbolic and read-only. OpenWorld may navigate to an existing action,
-but it must not invent an implicit write path or trigger an automation merely because the
-player approached something.
-
-## Historical Views and URL Contracts
-
-- `/openworld` and `/city` redirect to `/eidoverse`.
-- Historical `/openworld/apps/:appId`, `/openworld/region/:regionId`, and
- `/openworld/settings` paths are caught by the compatibility redirect.
-
-Selection remains in the URL. Building focus and region travel are bookmarkable,
-back/forward safe, and reachable from the command palette and voice navigation.
-
-Orbital view is an establishing shot of the complete archipelago, with pan/orbit/zoom,
-search, operational filters, attention, history, and photo tools. Street-level view is
-for movement and discovery. The two modes intentionally have different information
-hierarchies.
-
-## Art Direction
-
-`settings.worldStyle` selects one of two material languages over the same world:
-
-| Style | Look |
-|---|---|
-| `vibes` (default) | colorful low-poly valley, cottage destinations, dense trees, rolling lanes, drifting clouds |
-| `cyber` | nocturnal orbital dashboard, galaxy and weather layers, emissive live-state accents |
-
-The new direction follows current Three.js practice without making experimental renderer
-features a runtime requirement:
-
-- Procedural, data-driven terrain is authored as deterministic buffer geometry, inspired
- by Three.js’s
- [procedural terrain example](https://threejs.org/examples/webgpu_tsl_procedural_terrain).
-- Repeated world detail is instanced and geometry is reused, following
- [React Three Fiber’s scaling guidance](https://r3f.docs.pmnd.rs/advanced/scaling-performance).
-- Dense procedural city generation informed the decision to keep live structures modular,
- while making geography a stronger authored composition; see Three.js’s
- [city generator example](https://threejs.org/examples/webgpu_generator_city.html).
-
-WebGPU/TSL is not required for OpenWorld. The current WebGL path supports the app’s browsers
-and quality tiers, while the geometry/data separation leaves room for a future renderer
-upgrade.
-
-## Performance Contract
-
-- No per-frame React state for camera, player, water, pulses, or instanced dressing.
-- Geometry derived from static geography is memoized and explicitly disposed.
-- Repeated terrain detail uses instancing; the village terrain and curved lane ribbons are memoized.
-- Expensive atmosphere layers mount only for the appropriate style/tier.
-- Adaptive quality remains internal. Player-facing settings describe mood, sound, and
- controls rather than renderer implementation.
-- Photo-only postprocessing stays out of the always-on live canvas.
-- Hidden tabs stop the live frame loop.
-
-## Critical Files
-
-- `client/src/utils/openWorldPlan.js` — terrain height, lanes, cottage collision, parcels, and walkability
-- `client/src/components/openworld/OpenWorldArchipelago.jsx` — village terrain, cottages, routes, and dressing
-- `client/src/components/openworld/OpenWorldWater.jsx` — world sea
-- `client/src/pages/OpenWorld.jsx` — route state and mode/game orchestration
-- `client/src/components/openworld/OpenWorldScene.jsx` — Canvas and scene composition
-- `client/src/components/openworld/PlayerController.jsx` — rover movement and interaction
-- `client/src/utils/openWorldPlayerRig.js` — camera and vehicle math
-- `client/src/utils/openWorldCollectibles.js` — Echo Shard placement and progress
-- `client/src/utils/openWorldRegions.js` — region registry and arrival projection
-- `client/src/utils/openWorldMiniMap.js` — shared world-map projection
-- `client/src/components/openworld/OpenWorldFastTravel.jsx` — searchable Village Map
-- `client/src/components/openworld/OpenWorldHud.jsx` — desktop mode hierarchy
-- `client/src/components/openworld/OpenWorldHudCompact.jsx` — compact/touch hierarchy
-
-## Verification
-
-Before shipping a geography or traversal change:
-
-1. Run the focused OpenWorld utility and component tests.
-2. Run the client production build.
-3. Inspect orbital view and confirm the full PortOS world remains readable and useful.
-4. Drop into street level and confirm the rover enters at the village gate, the next bend and
- destination are visible, cottages are solid, suspension follows terrain, shards collect,
- and `F` exposes only a visible nearby place.
-5. Open the Village Map and confirm its neighborhoods, player, cottages, and destinations
- match the 3D world.
-6. Check at least one compact viewport and one desktop viewport.
+> **Historical rename (2026-08-19).** This surface originally shipped as CyberCity at `/city`, then as OpenWorld at `/openworld`. The permanent redirects preserve bookmarks, pinned rows, and command-palette history.
diff --git a/docs/features/product-surfaces.md b/docs/features/product-surfaces.md
index 1eddc70bd5..19e8d5fc9a 100644
--- a/docs/features/product-surfaces.md
+++ b/docs/features/product-surfaces.md
@@ -72,7 +72,7 @@ Submit tasks, manage durable autonomous agents, schedule recurring automations,
| **AI Providers & Model Runner** | `/ai` | Multi-provider configuration supporting CLI agents (Claude Code, Codex, Antigravity, OpenCode), cloud APIs (OpenAI, Anthropic, Gemini, Grok), and local endpoints (Ollama, LM Studio, vLLM, SGLang). | [Claude on Ollama](./claude-ollama.md) |
| **Prompt Manager** | `/prompts` | Reusable prompt template library, variable substitution engine, prompt versioning, and auto-upgrade migrations. | [Prompt Manager](./prompt-manager.md) |
| **Runs & Run Events Ledger** | `/cos/runs`, `/cos/run-events` | Comprehensive ledger of past and in-flight AI runs, lifecycle event replay, and orphaned process recovery. | — |
-| **Code Reviewers** | `/settings/code-reviewers` | Configurable multi-reviewer chain (Codex, Claude, Copilot, Ollama) with stop conditions, max rounds, and dispute workflows. | — |
+| **Code Reviewers** | `/settings/code-reviewers` | Configurable multi-reviewer chain (Copilot, Claude, Antigravity, Codex, Grok, Cursor, OpenCode, Kimi, LM Studio, Ollama, MTPLX) with stop conditions, max rounds, per-reviewer model/effort pins, and dispute workflows. | — |
---
diff --git a/docs/features/sglang-qwen38.md b/docs/features/sglang-qwen38.md
index 4405d25e69..15adbd6775 100644
--- a/docs/features/sglang-qwen38.md
+++ b/docs/features/sglang-qwen38.md
@@ -37,7 +37,8 @@ unmeasured number. If you bring one up on real hardware, write a dated note in
1. Docker with the NVIDIA Container Toolkit. PortOS does not install either —
they are host decisions with driver requirements it cannot judge.
2. Create a project directory. PortOS looks in `~/sglang-qwen38` by default;
- `SGLANG_QWEN_PROJECT_DIR` overrides it.
+ `SGLANG_QWEN_PROJECT_DIR` overrides it. **On Windows, do this inside your WSL2
+ distro** and let PortOS find it — see [Windows](#windows-the-project-lives-in-wsl2).
3. Save the compose file below into it as `docker-compose.yml`.
4. Pull the image and the weights **once, yourself** — roughly 20 GB:
@@ -50,6 +51,40 @@ unmeasured number. If you bring one up on real hardware, write a dated note in
somewhere else entirely? Point `SGLANG_QWEN_WEIGHTS_DIR` at them, so the
readiness check can see them.
+### Windows: the project lives in WSL2
+
+Docker Desktop's engine **is** a WSL2 VM. Prepare this project inside the distro
+— run the commands above there, not in PowerShell — so the compose file and the
+~20 GB of weights sit on the distro's own filesystem. A project on `C:\` is
+reached from inside that VM over a 9p share, which is slow in a way that is
+invisible until it has already cost the download.
+
+**You do not have to tell PortOS where that is.** A native-Win32 PortOS resolves
+the default `~/sglang-qwen38` to a *Windows* home, where the project is not — so
+before the Start button inspects anything, it asks WSL for the default distro's
+name and home (`wsl.exe -e sh -c 'echo "$WSL_DISTRO_NAME"; echo "$HOME"'`),
+checks that `\\wsl.localhost\\home\` is readable from Windows, and
+records the resulting path in its own `.env`. Node reads that path and `docker
+compose` accepts it as a working directory, so the readiness poll, the Start
+button and the next server restart all resolve the same directory.
+
+It refuses only where it genuinely cannot answer the question — no WSL on the
+host, a default distro that belongs to a container engine (`docker-desktop`,
+recreated on a reset), or a `\\wsl.localhost` share Windows cannot read — and each
+refusal names that host's fix rather than a path template to fill in. Because
+there is no 1-click provisioning path for this stack, those refusals point back
+at this document: PortOS finds a project you prepared, it never creates one.
+
+Set `SGLANG_QWEN_PROJECT_DIR` only to overrule the detected placement, or when
+you prepared the project somewhere other than the default distro's home:
+
+```
+SGLANG_QWEN_PROJECT_DIR=\\wsl.localhost\\home\\sglang-qwen38
+```
+
+An exported value wins over anything PortOS detected on an earlier run. On Linux
+the default is already correct.
+
## The compose file
Generated from `buildSglangQwenRecipe({ hw: 'h200' })` — that function is the
@@ -147,7 +182,9 @@ button on the provider readiness checklist. That button only ever brings up an
already-prepared project: it refuses on an unsupported card before it reaches
docker, refuses when the compose file is missing, and refuses when it cannot
confirm the weights are on disk. It never pulls the image and never downloads
-weights.
+weights. On Windows it first resolves the WSL2 placement described
+[above](#windows-the-project-lives-in-wsl2), so a project you prepared by hand
+inside the distro is found without any environment variable.
## Use it from an agent (OpenCode)
diff --git a/docs/research/2026-08-28-fasth3-vsa-datafree.md b/docs/research/2026-08-28-fasth3-vsa-datafree.md
index 318791e142..e7fb55bcac 100644
--- a/docs/research/2026-08-28-fasth3-vsa-datafree.md
+++ b/docs/research/2026-08-28-fasth3-vsa-datafree.md
@@ -47,3 +47,57 @@ the CUDA kernel through MPS.
- [FastVideo VSA backend documentation](https://github.com/hao-ai-lab/FastVideo/tree/main/docs/attention/vsa)
Issue: [#5351](https://github.com/atomantic/PortOS/issues/5351)
+
+## Addendum, 2026-09-02: the weights are there; the runtime is still dense
+
+The VSA-DataFree repo does publish full weights — the DiT is under
+`transformer/`, in the diffusers shard layout, beside the `vae/`, `audio_vae/`,
+`text_encoder/` and `tokenizer/` the pipeline loads. An earlier read that looked
+for a top-level checkpoint file found none and concluded the repo was a stub.
+That conclusion was wrong about the weights, and it does not change the verdict:
+FastVideo's MLX H3 runtime is dense-only, and its converter *drops* the VSA
+routing projections by name — `_IGNORED_DENSE_KEY_PARTS = ("attn.to_gate_compress",)`
+in `fastvideo/mlx_runtime/minimax_h3.py`, on the stated grounds that "the MLX
+path is dense, so retaining these weights wastes about 3.6 GiB without affecting
+a single output value." Converting the VSA student would therefore load it with
+the mechanism it was distilled around deleted. VSA-DataFree stays a CUDA target.
+
+**Dense-DataFree is the Apple Silicon checkpoint.**
+`FastVideo/FastVideo-FastH3-4-step-Preview-v1-Dense-DataFree` @ `f624f08c` is the
+one FastVideo's own MPS guide converts, and PortOS now ships it at all three MLX
+DiT formats. FastVideo's three pre-converted MLX repos (`…-MLX-INT4/INT6/INT8`)
+are still weightless as of this date — `usedStorage: 0`, five small files each —
+so the local conversion is not an optimization, it is the only path to them.
+
+### Measured on an M5 Max, 128 GB
+
+832x480, 124 frames, 4 steps, INT4 DiT, full H3 VAE, dense attention:
+
+| phase | seconds | peak GiB |
+| --- | --- | --- |
+| conditioning (streamed bf16 Qwen3-VL) | 307.0 | 1.8 |
+| denoise (4 steps) | 229.3 | 14.9 |
+| video decode (tiled) | 83.0 | 11.2 |
+| audio decode | 2.8 | 2.4 |
+| mux | 1.5 | — |
+
+Output: 5.17 s of 832x480 H.264 at 24 fps with synchronized 32 kHz stereo AAC,
+subjectively coherent across the clip. Peak memory is ~15 GiB — the published
+36 GB floor is conservative, and 128 GB is not the binding constraint. **Disk
+is**: the bf16 snapshot is 144 GB and the converted DiT another 11–22 GB.
+
+Conditioning was half the wall clock and recomputes identical embeddings every
+run, so PortOS now passes `--prompt-cache-dir`; upstream digests each entry over
+its cache version, the model root and the prompt, so one shared directory is
+safe across models. Re-running the same prompt on a warm cache took conditioning
+from **307.0s to 0.003s** (621s to 336s end to end) and produced a **byte-identical
+MP4** — so the saving costs nothing in output.
+
+The conversion path was verified against the upstream snapshot directly: its
+bf16 `transformer/` converted to an MLX INT6 DiT in **21.9s**, peak 19.4 GiB,
+1464 arrays — the tensor count the repack's own conversion manifest records —
+and rendered from that checkpoint. Denoise peaks at 19.6 GiB on INT6 against
+14.9 GiB on INT4.
+
+Renders are deterministic at a fixed seed: two runs at seed 2026 produced
+byte-identical files.
diff --git a/docs/research/2026-09-03-macos-install-out-of-sync.md b/docs/research/2026-09-03-macos-install-out-of-sync.md
new file mode 100644
index 0000000000..3c0e9c2386
--- /dev/null
+++ b/docs/research/2026-09-03-macos-install-out-of-sync.md
@@ -0,0 +1,97 @@
+# macOS “Install out of sync” research
+
+Point-in-time primary-source review on 2026-09-03. This note distinguishes the
+documented install/update path from two separate stale-build defects.
+
+## Bottom line
+
+- A fresh, minimal supported install is `npm run setup` followed by `npm start`.
+ `npm start` builds the client before starting PM2. `./setup.sh` is the optional
+ guided alternative, not an additional required step
+ ([README.md:291-309](../../README.md#L291-L309)).
+- A managed reconcile is supposed to run the full platform update script. That
+ script installs all workspaces, runs setup and migrations, builds the client,
+ and restarts PortOS ([update.sh:185-219](../../update.sh#L185-L219),
+ [update.sh:293-317](../../update.sh#L293-L317),
+ [update.sh:335-340](../../update.sh#L335-L340)).
+- There was a real, platform-independent bug in **Apps → PortOS → Git → Update
+ app**: it restarted without rebuilding `client/dist`. It was fixed upstream on
+ 2026-09-02 by [PR #5823](https://github.com/atomantic/PortOS/pull/5823).
+- `.DS_Store` was already recognized elsewhere as macOS metadata: it is ignored
+ by Git ([.gitignore:39-41](../../.gitignore#L39-L41)) and explicitly excluded
+ from backup snapshot enumeration
+ ([server/services/backup.js:522-532](../../server/services/backup.js#L522-L532)).
+ However, upstream `installState` freshness detection does not consult Git and
+ has no `.DS_Store` exclusion as of upstream commit `878468a9c`.
+
+## What the freshness detector actually measures
+
+Issue [#1779](https://github.com/atomantic/PortOS/issues/1779) introduced the
+warning on 2026-06-29 to detect a half-update after `git pull` without
+`./update.sh`. Its five signals are running-code drift, dependency receipts,
+client build time, pending migrations, and submodule drift
+([server/services/installState.js:1-28](../../server/services/installState.js#L1-L28)).
+
+The root-file scan was deliberately widened in commit
+[`4147b077f`](https://github.com/atomantic/PortOS/commit/4147b077f13b95f0ad2d68a989c05678ff2d18c8)
+from three named files to **every file directly under `client/`**, while also
+walking `src/` and `public/`. The current upstream implementation uses raw
+`readdir`/`stat`; it excludes only the `node_modules`, `dist`, and `.git`
+directories. Consequently `.gitignore` has no effect on this calculation
+([upstream source at `878468a9c`](https://github.com/atomantic/PortOS/blob/878468a9c7d70d360f44c6ce044dfd68c224cda6/server/services/installState.js#L109-L149)).
+
+**Conclusion:** freshness should inspect actual client build inputs, not merely
+Git-tracked files. Untracked files under `src/`/`public/` can affect a Vite build,
+and Git-ignored `.env*` files are also Vite inputs
+([official Vite env documentation](https://vite.dev/guide/env-and-mode.html#env-files)).
+A blanket “ignore every ignored/untracked file” change would therefore create
+false negatives. The sound boundary is to exclude known non-input metadata such
+as `.DS_Store`, while continuing to inspect real build inputs.
+
+## What most likely caused the reported warning
+
+There are three evidence-backed possibilities, in descending operational
+priority:
+
+1. **Known updater defect.** If the source was updated through the managed App
+ Git tab before PR #5823 was present, that path omitted the production build.
+ The PR description names the exact result: `client/dist` stayed older and
+ PortOS immediately reported “Install out of sync.” The fix is already merged
+ into upstream and is present in the currently fetched fork `main` history as
+ commit [`ff7d35682`](https://github.com/atomantic/PortOS/commit/ff7d35682f4359a0cbb9a0939c329a0aa5b7f306).
+2. **Feature-branch reconciliation.** The documented updater always switches to
+ `main` before pulling, building, and restarting; dirty feature-branch work is
+ stashed and deliberately not restored
+ ([docs/SELF_UPDATE.md:17-34](../SELF_UPDATE.md#L17-L34)). Therefore a detector
+ patch that exists only on a feature branch cannot fix a warning by clicking
+ Reconcile: the reconciliation boots `main`, where that patch is absent.
+3. **Finder metadata newer than the build.** If `/api/update/status` identifies
+ `staleBuild` as the only active signal and `.DS_Store` is the only file newer
+ than `client/dist/index.html`, the upstream raw filesystem scan necessarily
+ reports stale even though the build is valid. No upstream issue or merged PR
+ found since 2026-08-31 adds an install-freshness `.DS_Store` exclusion; the
+ relevant official search result is instead the separate managed-updater fix,
+ PR #5823.
+
+A fourth, display-only edge exists: the global banner hook sets out-of-sync state
+when its one mount-time status request returns true, but does not clear that
+state from a later in-sync response
+([client/src/hooks/useUpdateChecker.jsx:38-64](../../client/src/hooks/useUpdateChecker.jsx#L38-L64)).
+The normal successful reconcile path reloads the page, so this explains a stale
+banner only when that reload did not happen or the browser kept the old session.
+
+## Operational reading
+
+Use the detailed reasons on **Apps → PortOS → Update**, or the
+`installState` object from `GET /api/update/status`, before changing code:
+
+- `staleBuild` after a managed App update on old code: update to a revision that
+ contains PR #5823, then run the documented reconcile/update path once.
+- only `staleBuild`, with the full reconcile completing successfully: identify
+ the newest filesystem entry; `.DS_Store` is a detector false positive, while
+ `src/`, `public/`, config, or `.env*` is a real rebuild input.
+- any other signal: follow that signal (dependencies, migration, boot commit, or
+ submodule) rather than treating it as a macOS metadata problem.
+
+For post-install configuration, the maintained walkthrough is also available at
+**Settings → Setup** ([README.md:311-324](../../README.md#L311-L324)).
diff --git a/lib/slashdo b/lib/slashdo
index 3352fb6848..bce844b6dc 160000
--- a/lib/slashdo
+++ b/lib/slashdo
@@ -1 +1 @@
-Subproject commit 3352fb68483e558d3357aa9c98148e033b37e7bf
+Subproject commit bce844b6dc172f24e7b12fa68994068060280514
diff --git a/package-lock.json b/package-lock.json
index 6baf5649ca..31c3425fb3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "portos",
- "version": "2.56.0",
+ "version": "2.57.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "portos",
- "version": "2.56.0",
+ "version": "2.57.0",
"license": "MIT",
"dependencies": {
"pm2": "7.0.4"
@@ -520,9 +520,9 @@
}
},
"node_modules/ip-address": {
- "version": "10.5.0",
- "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz",
- "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==",
+ "version": "10.7.0",
+ "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz",
+ "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==",
"license": "MIT",
"engines": {
"node": ">= 12"
diff --git a/package.json b/package.json
index 2085e4e267..e091b61699 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "portos",
- "version": "2.56.0",
+ "version": "2.57.0",
"private": true,
"description": "Local dev machine App OS portal",
"author": "Adam Eivy (@antic|@atomantic)",
@@ -54,6 +54,6 @@
"follow-redirects": "1.16.0",
"js-yaml": "4.3.1",
"ws": "8.21.3",
- "ip-address": "10.5.0"
+ "ip-address": "10.7.0"
}
}
diff --git a/scripts/_runner_common.py b/scripts/_runner_common.py
index 92e25c6ff3..569ab68fdd 100644
--- a/scripts/_runner_common.py
+++ b/scripts/_runner_common.py
@@ -59,7 +59,7 @@ def register_source_namespace(package_name: str, package_dir: "str | Path"):
@contextmanager
-def heartbeat(stage: str, interval: float = 20.0):
+def heartbeat(stage: "str | Callable[[], str]", interval: float = 20.0):
"""Emit a periodic STAGE::heartbeat:Ns marker so the JS idle
watchdog (default 5min) doesn't kill silent long pipeline loads.
@@ -69,14 +69,27 @@ def heartbeat(stage: str, interval: float = 20.0):
the heartbeat line and resets lastActivityAt. True hangs (GIL-pinned
C extension, no I/O) still trip the watchdog because the heartbeat
thread can't print either.
+
+ `stage` may be a callable resolved per beat, for a runner that wraps a
+ whole child process rather than one load step: generate_fastvideo.py scrapes
+ the phase out of the child's own log lines, so the phase the heartbeat is
+ reporting changes underneath it. The server stamps that phase onto the
+ status frame, which is what lets the UI name the step during the many
+ minutes a large checkpoint spends streaming in silence.
"""
stop = threading.Event()
+ resolve_stage = stage if callable(stage) else (lambda: stage)
def beat():
elapsed = 0
while not stop.wait(interval):
elapsed += int(interval)
- print(f"STAGE:{stage}:heartbeat:{elapsed}s", file=sys.stderr, flush=True)
+ # One write, not print()'s two — a caller that also writes to stderr
+ # from the main thread (generate_fastvideo.py streams its child's
+ # output there) would otherwise see the beat land between a line's
+ # text and its newline, gluing two protocol lines together.
+ sys.stderr.write(f"STAGE:{resolve_stage()}:heartbeat:{elapsed}s\n")
+ sys.stderr.flush()
t = threading.Thread(target=beat, daemon=True)
t.start()
diff --git a/scripts/catalog-merge-union.test.js b/scripts/catalog-merge-union.test.js
new file mode 100644
index 0000000000..90df622df0
--- /dev/null
+++ b/scripts/catalog-merge-union.test.js
@@ -0,0 +1,107 @@
+/**
+ * Guard for the catalogs and barrels `.gitattributes` merges with `union`
+ * (rationale: AGENTS.md "Module Organization"). Union keeps both sides of a
+ * conflicting hunk — right for an insertion, wrong for an edit or deletion
+ * beside one — so this catches the doubled or resurrected line nothing else
+ * would, and pins the precondition: every `.js` listed is a pure re-export
+ * barrel, where two edits to one line cannot both be right silently.
+ *
+ * Always-run: a README is documentation to the impact planner, so nothing
+ * else would run this on a docs-only rebase.
+ */
+import { describe, it, expect } from 'vitest';
+import { readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { STRUCTURAL_BARRELS } from './ci-test-plan.js';
+
+const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+
+const CONFLICT_MARKER_RE = /^(?:<{7}|={7}|>{7})(?:\s|$)/m;
+const BARREL_LINE_RE = /^export (?:\*|\* as \w+|\{[^}]+\}) from '(\.\/[^']+)';$/;
+const CATALOG_ROW_RE = /^\|\s*`([^`]+)`/;
+
+/** Paths `.gitattributes` assigns `merge=union`, repo-relative. */
+export const unionMergedPaths = (gitattributes) => gitattributes
+ .split('\n')
+ .map((line) => line.trim())
+ .filter((line) => line && !line.startsWith('#') && /\smerge=union(?:\s|$)/.test(line))
+ .map((line) => line.split(/\s+/)[0]);
+
+const duplicates = (values) => {
+ const seen = new Set();
+ const doubled = new Set();
+ for (const value of values) (seen.has(value) ? doubled : seen).add(value);
+ return [...doubled];
+};
+
+/** Non-blank, non-comment barrel lines that are not a single `export … from './x'`. */
+export const nonBarrelLines = (lines) => lines
+ .filter((line) => line.trim() && !line.trim().startsWith('//') && !BARREL_LINE_RE.test(line));
+
+/**
+ * Re-export lines a barrel repeats verbatim. Keyed on the whole line: a hooks
+ * barrel legitimately re-exports one module twice (`default as useX`, then a
+ * named helper), and only an identical line is the doubled-insertion shape.
+ */
+export const duplicateBarrelLines = (lines) => duplicates(lines.filter((line) => BARREL_LINE_RE.test(line)));
+
+/** Backtick-named table rows a catalog README lists more than once. */
+export const duplicateCatalogRows = (lines) => duplicates(
+ lines.map((line) => CATALOG_ROW_RE.exec(line)?.[1]).filter(Boolean),
+);
+
+describe('union-merged catalogs and barrels', () => {
+ const paths = unionMergedPaths(readFileSync(join(REPO_ROOT, '.gitattributes'), 'utf8'));
+ const sources = new Map(paths.map((path) => [path, readFileSync(join(REPO_ROOT, path), 'utf8')]));
+ const lines = (path) => sources.get(path).split('\n');
+ const barrels = paths.filter((path) => path.endsWith('.js'));
+ const catalogs = paths.filter((path) => path.endsWith('.md'));
+
+ it('unions exactly the structural barrels and the catalog beside each', () => {
+ // One list in .gitattributes, one in the planner: a barrel missing from
+ // either loses union merging (daily conflicts return) or import-graph
+ // exclusion, and nothing else would notice.
+ expect(barrels.sort()).toEqual([...STRUCTURAL_BARRELS].sort());
+ for (const barrel of barrels) {
+ expect(catalogs, barrel).toContain(barrel.replace(/index\.js$/, 'README.md'));
+ }
+ });
+
+ it('detects the shapes it guards against', () => {
+ // Bypass probes: each detector must bite on a minimal bad input.
+ expect(unionMergedPaths('# c\nfoo.md merge=union\nbar.js text\nbaz.js merge=union'))
+ .toEqual(['foo.md', 'baz.js']);
+ expect(duplicateBarrelLines(["export * from './a.js';", "export * as b from './b.js';", "export * from './a.js';"]))
+ .toEqual(["export * from './a.js';"]);
+ expect(duplicateBarrelLines(["export { default as useA } from './useA.js';", "export { helper } from './useA.js';"]))
+ .toEqual([]);
+ expect(nonBarrelLines(['// note', "export * from './a.js';", 'const leaked = 1;'])).toEqual(['const leaked = 1;']);
+ expect(duplicateCatalogRows(['| `a.js` | one |', '| `b.js` | two |', '| `a.js` | one again |'])).toEqual(['a.js']);
+ });
+
+ it('leaves no conflict markers behind', () => {
+ for (const [path, source] of sources) expect(source, path).not.toMatch(CONFLICT_MARKER_RE);
+ });
+
+ it('only ever unions pure re-export barrels, never a file with code paths', () => {
+ for (const path of barrels) {
+ expect(nonBarrelLines(lines(path)), `${path} carries lines a union merge cannot arbitrate — drop it from .gitattributes`)
+ .toEqual([]);
+ }
+ });
+
+ it('has no doubled barrel re-export after a union merge', () => {
+ for (const path of barrels) {
+ expect(duplicateBarrelLines(lines(path)), `${path} repeats a re-export line — a union merge kept a line both branches touched; keep one`)
+ .toEqual([]);
+ }
+ });
+
+ it('has no doubled catalog row after a union merge', () => {
+ for (const path of catalogs) {
+ expect(duplicateCatalogRows(lines(path)), `${path} lists a module twice — a union merge kept a row both branches touched; keep one`)
+ .toEqual([]);
+ }
+ });
+});
diff --git a/scripts/ci-base-sha.test.js b/scripts/ci-base-sha.test.js
index 43f0b613fc..15b579344e 100644
--- a/scripts/ci-base-sha.test.js
+++ b/scripts/ci-base-sha.test.js
@@ -5,6 +5,7 @@ import { fileURLToPath } from 'url';
import { describe, expect, it } from 'vitest';
import { resolveBaseSha } from './ci-base-sha.js';
+import { workflowJobs } from './lib/workflowJobs.js';
const WORKFLOW = readFileSync(
join(dirname(fileURLToPath(import.meta.url)), '..', '.github', 'workflows', 'ci.yml'),
@@ -17,23 +18,6 @@ const HEAD = 'b'.repeat(40);
/** A merge-ref checkout: both parents resolve. */
const mergeRefRevParse = (rev) => ({ 'HEAD^1': BASE, 'HEAD^2': HEAD }[rev] ?? null);
-/** Split the workflow into `jobs:` entries keyed by job id. */
-function workflowJobs(yaml) {
- const body = yaml.slice(yaml.indexOf('\njobs:\n'));
- const jobs = {};
- let current = null;
- for (const line of body.split('\n')) {
- const header = line.match(/^ {2}([a-z][a-z0-9-]*):\s*$/);
- if (header) {
- current = header[1];
- jobs[current] = [];
- continue;
- }
- if (current) jobs[current].push(line);
- }
- return Object.fromEntries(Object.entries(jobs).map(([id, lines]) => [id, lines.join('\n')]));
-}
-
describe('resolveBaseSha', () => {
it('reads the base branch off the pull-request merge ref', () => {
expect(resolveBaseSha({ eventName: 'pull_request', revParse: mergeRefRevParse })).toBe(BASE);
@@ -231,3 +215,39 @@ describe('ci.yml server node_modules cache', () => {
}
});
});
+
+describe('ci.yml autofixer workspace install', () => {
+ const jobs = workflowJobs(WORKFLOW);
+
+ /** Every job that resolves the autofixer lockfile. */
+ const installers = Object.entries(jobs)
+ .filter(([, body]) => body.includes('npm ci --prefix autofixer'));
+
+ it('resolves the autofixer lockfile on the job that runs the server suite', () => {
+ // autofixer/ carries its own package.json, its own tracked lockfile, and
+ // its own .npmrc, and `npm run setup` / scripts/ensure-deps.js install it
+ // on every user's machine. When no CI job ran `npm ci` against it, a
+ // lockfile that stopped resolving shipped green and failed at setup time
+ // instead — the static parity checks in dependency-overrides.test.js only
+ // parse the JSON. The server job is the one that already globs
+ // autofixer/*.test.js, so it is where the tree belongs.
+ // Named, so the loop below is never vacuously green on a deleted step.
+ expect(jobs.server).toMatch(/- name: Install autofixer dependencies\n {8}run: npm ci --prefix autofixer/);
+ });
+
+ it('never lets a cache hit skip the resolution it exists to prove', () => {
+ // The server tree is cached and its `npm ci` is skipped on a hit, which is
+ // fine because that job's job is to run tests. This step's whole purpose is
+ // the install, so caching it (or gating it on a cache outcome) would put
+ // the hole straight back. The job-level `server_mode != 'skip'` is what
+ // keeps a docs-only plan from paying for it.
+ for (const [id, body] of installers) {
+ const step = body.match(
+ /- name: Install autofixer dependencies\n(?(?: {8}.*\n)*?) {8}run: npm ci --prefix autofixer/,
+ );
+ expect(step, id).not.toBeNull();
+ expect(step.groups.between, id).not.toMatch(/^ {8}if:/m);
+ expect(body, id).not.toMatch(/path: autofixer\/node_modules/);
+ }
+ });
+});
diff --git a/scripts/ci-fail-fast.test.js b/scripts/ci-fail-fast.test.js
index d1279cdd62..f6fe2b05ee 100644
--- a/scripts/ci-fail-fast.test.js
+++ b/scripts/ci-fail-fast.test.js
@@ -4,6 +4,8 @@ import { fileURLToPath } from 'url';
import { describe, expect, it } from 'vitest';
+import { workflowJobs } from './lib/workflowJobs.js';
+
const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
const WORKFLOW = readFileSync(join(REPO_ROOT, '.github/workflows/ci.yml'), 'utf8');
const NON_LEAF_JOBS = new Set(['impact', 'gate', 'full-gate']);
@@ -27,23 +29,6 @@ const FAIL_FAST_STEP = [
' run: node scripts/cancel-current-ci-run.js',
].join('\n');
-function workflowJobs(yaml) {
- const jobsStart = yaml.indexOf('\njobs:\n');
- const body = yaml.slice(jobsStart);
- const jobs = {};
- let current = null;
- for (const line of body.split('\n')) {
- const header = line.match(/^ {2}([a-z][a-z0-9-]*):\s*$/);
- if (header) {
- current = header[1];
- jobs[current] = [];
- continue;
- }
- if (current) jobs[current].push(line);
- }
- return Object.fromEntries(Object.entries(jobs).map(([id, lines]) => [id, lines.join('\n')]));
-}
-
describe('ci.yml fail-fast cancellation contract', () => {
const jobs = workflowJobs(WORKFLOW);
const leafJobs = Object.keys(jobs).filter((id) => (
diff --git a/scripts/ci-test-plan.js b/scripts/ci-test-plan.js
index a816ecae62..67ab3a7deb 100644
--- a/scripts/ci-test-plan.js
+++ b/scripts/ci-test-plan.js
@@ -10,6 +10,28 @@ const EXECUTABLE_RE = /\.(?:cjs|css|html|js|jsx|json|mjs|sql|ts|tsx|ya?ml)$/i;
const MAX_CHANGED_CODE_FILES = 30;
const MAX_TARGETED_TEST_FILES = 120;
+// Python sidecar scripts (`scripts/generate_ltx2.py`, …). Vitest's import graph
+// cannot reach into them, but their contracts are pinned by suites that read
+// the .py source as text, so main() finds the suites naming each changed script
+// with `git grep` and the plan runs exactly those — failing closed to the full
+// suite for a script nothing names.
+const PYTHON_SCRIPT_RE = /^scripts\/[^/]+\.py$/;
+/** `git grep -E` pattern for a test that names this one script. */
+export const pythonReferencePattern = (scriptPath) => (
+ `(^|[^A-Za-z0-9_])${scriptPath.slice(scriptPath.lastIndexOf('/') + 1).replace(/[.]/g, '\\.')}([^A-Za-z0-9_]|$)`
+);
+
+// Parallel runners per test job on a full plan. Decided here rather than in
+// ci.yml because a job-level `if` cannot read the matrix context, so the
+// planner must emit `[1]` for a scoped plan itself. Sizing rationale:
+// docs/GITHUB_ACTIONS.md "Full-suite sharding".
+export const FULL_SUITE_SHARDS = { server: 2, client: 3, windows: 3 };
+
+/** Matrix values for one runner: every shard on a full run, one otherwise. */
+export const shardIndexes = (mode, count) => (
+ mode === 'full' ? Array.from({ length: count }, (_, index) => index + 1) : [1]
+);
+
const FULL_TRIGGER_RULES = [
{ re: /^\.github\/workflows\//, reason: 'workflow definition changed' },
{ re: /^(?:package|server\/package|client\/package|autofixer\/package)(?:-lock)?\.json$/, reason: 'dependency manifest changed' },
@@ -47,6 +69,17 @@ const WINDOWS_RISK_RULES = [
// — green everywhere CI looked. These modules decide containment and worktree
// identity, so they need a real Windows run.
/^server\/lib\/(?:pathSafety|worktreeOwnership)(?:\.test)?\.js$/,
+ // Same class, different mechanism: staticImportGraph keys its module map by
+ // string-concatenating entry names with "/" but resolves edge targets with
+ // path.relative, which answers with "\\" on Windows. Every edge into a
+ // subdirectory module then missed the lookup and vanished — reddening the
+ // cluster guards' positive assertions while their acyclicity assertions
+ // passed VACUOUSLY. Nothing here was Windows-risk-tagged, so the Windows job
+ // skipped the PR that introduced it and main went red for every later PR
+ // (#5909). The graph's consumers ride along: their expectations are spelled
+ // in "/" and only a real Windows run can tell.
+ /^server\/lib\/staticImportGraph(?:\.test)?\.js$/,
+ /^server\/services\/(?:agent|twin)ImportCycles\.test\.js$/,
/^server\/services\/worktree(?:Manager|Reap)\b/,
/^server\/lib\/shell(?:Cd|Exit|LivenessProbe|ReadinessProbe)(?:\.test)?\.js$/,
/^server\/lib\/agentGuard\//,
@@ -85,6 +118,7 @@ export const WINDOWS_CONTRACT_TESTS = [
'server/lib/processEnv.spawnOptions.test.js',
'server/lib/processEnv.test.js',
'server/lib/spawnCwd.test.js',
+ 'server/lib/staticImportGraph.test.js',
'server/lib/shellCd.test.js',
'server/routes/apps/crud.test.js',
'server/routes/apps/icons.test.js',
@@ -102,6 +136,8 @@ export const WINDOWS_CONTRACT_TESTS = [
'server/services/shell.test.js',
'server/services/shellImageDrop.test.js',
'server/services/agentTuiSpawning.test.js',
+ 'server/services/agentImportCycles.test.js',
+ 'server/services/twinImportCycles.test.js',
];
// Contract guards that run on EVERY plan, whatever the impact scope selects.
@@ -134,11 +170,18 @@ export const WINDOWS_CONTRACT_TESTS = [
// what can reach it.
export const ALWAYS_RUN_TESTS = [
'scripts/agent-instructions-files.test.js',
+ // The union-merged catalogs are `.md` to the planner — documentation-only —
+ // so a rebase that doubled a row would otherwise never be re-checked.
+ 'scripts/catalog-merge-union.test.js',
'scripts/direct-invocation-drift.test.js',
+ 'scripts/ensure-deps.test.js',
'scripts/node-version-drift.test.js',
'scripts/repo-scan-guards.test.js',
'scripts/tailnet-identity-leak.test.js',
'server/dependency-overrides.test.js',
+ // Whole-tree scanner: any server file can add a `process.env` read, and
+ // `.env.example` itself is not a scope the selector routes to a runner.
+ 'server/envExampleDrift.test.js',
'server/lib/generatedManifests.test.js',
'server/lib/qwenAgentParsers.test.js',
'server/lib/testDataIsolation.guards.test.js',
@@ -207,7 +250,8 @@ const pathMatchesFeature = (path, feature) => {
const isTestFile = (path) => TEST_FILE_RE.test(path);
const isDocumentationOnly = (path) => DOCUMENTATION_RULES.some((rule) => rule.test(path));
-const isExecutable = (path) => EXECUTABLE_RE.test(path);
+const isPythonScript = (path) => PYTHON_SCRIPT_RE.test(path);
+const isExecutable = (path) => EXECUTABLE_RE.test(path) || isPythonScript(path);
const isServerRunnerFile = (path) => RUNNER_ROOTS.server.some((root) => path.startsWith(root));
const isClientRunnerFile = (path) => RUNNER_ROOTS.client.some((root) => path.startsWith(root));
@@ -235,6 +279,15 @@ const structuralTestsFor = (changedFiles, trackedSet) => {
if (changedFiles.some((path) => /^server\/lib\//.test(path))) {
add('server/lib/index.test.js');
}
+ // The generated-manifest drift tests regenerate from the tree and compare;
+ // they import neither the route modules nor the stage call sites they scan,
+ // so no import edge selects them. Without this rule a route added on a
+ // scoped plan merged with a stale catalog (#5898), and every later full-plan
+ // PR inherited the red drift test until someone committed a regeneration.
+ if (changedFiles.some((path) => /^server\/.*\.js$/.test(path) || /^server\/lib\/.*\.generated\.json$/.test(path))) {
+ add('scripts/generate-api-route-catalog.test.js');
+ add('scripts/generate-prompt-stage-call-sites.test.js');
+ }
// The socket guard readdir-scans server/sockets/ rather than importing it, so
// no import edge reaches it — a handler added there would otherwise only be
// checked on a full suite.
@@ -256,12 +309,20 @@ const structuralTestsFor = (changedFiles, trackedSet) => {
// Both `.js` and `.jsx`: the StrictMode mounted-ref bug the first guard covers
// reached its widest blast radius through a plain-`.js` hook (`useAsyncAction`),
// so a `.jsx`-only trigger would miss the case that matters most, and the
- // responsive-grid guard reads class strings out of both extensions. Neither
- // file has a source sibling or imports an app module, so nothing else selects
- // them — without this entry they only ever run on a full suite.
+ // responsive-grid, popover-clamp, pre-wrap/break, safe-storage,
+ // heading-truncation, and global-shadow guards read class strings, storage
+ // accesses, declarations, and JSX markup out of both extensions.
+ // None of these files has a source sibling or imports an app module, so nothing
+ // else selects them — without this entry they only ever run on a full suite.
if (changedFiles.some((path) => /^client\/src\/.*\.jsx?$/.test(path))) {
+ add('client/src/globalShadowConventions.test.js');
+ add('client/src/headingTruncationConventions.test.js');
add('client/src/hooks/mountedRefConventions.test.js');
+ add('client/src/pollingConventions.test.js');
+ add('client/src/popoverClampConventions.test.js');
+ add('client/src/preWrapClasses.test.js');
add('client/src/responsiveGridConventions.test.js');
+ add('client/src/storageConventions.test.js');
}
return selected;
@@ -295,12 +356,16 @@ const skippedRunner = () => ({ mode: 'skip', files: [], sources: [] });
// contains no behavior; PR #5296 changed server/lib/index.js and consequently
// selected 1,244 files / 27,491 tests. Behavioral source files in the same diff
// still drive related-test selection normally.
-const isStructuralBarrel = (path) => [
+//
+// Also the set `.gitattributes` merges with `union` — scripts/catalog-merge-union.test.js
+// pins the two lists to each other.
+export const STRUCTURAL_BARRELS = [
'server/lib/index.js',
'client/src/lib/index.js',
'client/src/hooks/index.js',
'client/src/utils/index.js',
-].includes(path);
+];
+const isStructuralBarrel = (path) => STRUCTURAL_BARRELS.includes(path);
/**
* A route declaration is a safe, client-only subset of the App composition
@@ -350,6 +415,8 @@ export function buildCiTestPlan(changedFiles, {
forceFull = false,
forceFullReason = 'full CI requested',
appRouteOnly = false,
+ // Changed python script → tracked test files naming it (see PYTHON_SCRIPT_RE).
+ pythonContractTests = {},
} = {}) {
const changed = uniqueSorted(changedFiles.filter(Boolean));
const trackedSet = new Set(trackedFiles);
@@ -370,7 +437,7 @@ export function buildCiTestPlan(changedFiles, {
windowsFiles: [],
windowsSources: [],
};
- return { ...plan, suiteReasons: suiteReasonsFor(plan, { appRouteOnly }) };
+ return finishPlan(plan, { appRouteOnly });
}
const appCompositionChanged = changed.includes('client/src/App.jsx');
@@ -408,7 +475,7 @@ export function buildCiTestPlan(changedFiles, {
windowsFiles: [],
windowsSources: [],
};
- return { ...plan, suiteReasons: suiteReasonsFor(plan, { appRouteOnly }) };
+ return finishPlan(plan, { appRouteOnly });
}
const executable = relevant.filter(isExecutable);
@@ -438,14 +505,28 @@ export function buildCiTestPlan(changedFiles, {
// the graph.
return fullPlan(changed, `deleted executable source: ${deletedSources[0]}`, { appRouteOnly });
}
- const features = uniqueSorted(sourceFiles.map(featureDirectory).filter(Boolean));
- const unscopedSources = sourceFiles.filter((path) => !featureDirectory(path));
+ // Python scripts never enter the import graph or a feature directory: their
+ // only selector is the per-script contract list, so they leave the JS-only
+ // scoping below.
+ const pythonSources = sourceFiles.filter(isPythonScript);
+ const jsSources = sourceFiles.filter((path) => !isPythonScript(path));
+ const features = uniqueSorted(jsSources.map(featureDirectory).filter(Boolean));
+ const unscopedSources = jsSources.filter((path) => !featureDirectory(path));
const selectedTests = [
...directTests,
...structuralTestsFor(changed, trackedSet),
...alwaysRun,
];
+ for (const script of pythonSources) {
+ const pythonTests = (pythonContractTests[script] || [])
+ .filter((path) => trackedSet.has(path) && runnerForTest(path));
+ if (pythonTests.length === 0) {
+ return fullPlan(changed, `python script with no parsing contract: ${script}`, { appRouteOnly });
+ }
+ selectedTests.push(...pythonTests);
+ }
+
for (const testFile of trackedFiles.filter(isTestFile)) {
if (features.some((feature) => pathMatchesFeature(testFile, feature))) {
selectedTests.push(testFile);
@@ -459,15 +540,15 @@ export function buildCiTestPlan(changedFiles, {
return fullPlan(changed, 'targeted test set exceeded safety cap', { appRouteOnly });
}
- const hasServerSource = sourceFiles.some(isServerRunnerFile);
- const hasClientSource = sourceFiles.some((path) => path.startsWith('client/'));
+ const hasServerSource = jsSources.some(isServerRunnerFile);
+ const hasClientSource = jsSources.some((path) => path.startsWith('client/'));
const hasUnscopedServer = unscopedSources.some(isServerRunnerFile);
const hasUnscopedClient = unscopedSources.some((path) => path.startsWith('client/'));
- const serverSources = sourceFiles
+ const serverSources = jsSources
.filter(isServerRunnerFile)
.filter((path) => !isStructuralBarrel(path));
- const clientSources = sourceFiles
+ const clientSources = jsSources
.filter((path) => path.startsWith('client/'))
.filter((path) => !isStructuralBarrel(path));
@@ -493,11 +574,13 @@ export function buildCiTestPlan(changedFiles, {
? (serverSources.length > 0 ? 'related' : 'files')
: 'skip';
- const plan = {
+ let reason = 'Vitest related-test fallback';
+ if (features.length) reason = `targeted features: ${features.join(', ')}`;
+ else if (jsSources.length === 0 && pythonSources.length > 0) reason = 'python script parsing contracts';
+
+ return finishPlan({
full: false,
- reason: features.length
- ? `targeted features: ${features.join(', ')}`
- : 'Vitest related-test fallback',
+ reason,
changedFiles: changed,
server,
client,
@@ -514,12 +597,22 @@ export function buildCiTestPlan(changedFiles, {
windowsMode,
windowsFiles: windows ? windowsContractTests(trackedSet) : [],
windowsSources: windowsMode === 'related' ? serverSources : [],
- };
- return { ...plan, suiteReasons: suiteReasonsFor(plan, { appRouteOnly }) };
+ }, { appRouteOnly });
}
+/** The derived fields every plan carries: per-suite reasons and shard matrices. */
+const finishPlan = (plan, options) => ({
+ ...plan,
+ suiteReasons: suiteReasonsFor(plan, options),
+ shards: {
+ server: shardIndexes(plan.server.mode, FULL_SUITE_SHARDS.server),
+ client: shardIndexes(plan.client.mode, FULL_SUITE_SHARDS.client),
+ windows: shardIndexes(plan.windowsMode, FULL_SUITE_SHARDS.windows),
+ },
+});
+
function fullPlan(changedFiles, reason, options) {
- const plan = {
+ return finishPlan({
full: true,
reason,
changedFiles,
@@ -533,8 +626,7 @@ function fullPlan(changedFiles, reason, options) {
windowsMode: 'full',
windowsFiles: [],
windowsSources: [],
- };
- return { ...plan, suiteReasons: suiteReasonsFor(plan, options) };
+ }, options);
}
const gitLines = (args) => execFileSync('git', args, { encoding: 'utf8' })
@@ -542,6 +634,16 @@ const gitLines = (args) => execFileSync('git', args, { encoding: 'utf8' })
.map((line) => line.trim())
.filter(Boolean);
+/** Tracked test files whose text matches `pattern`; `git grep` exit 1 is "none". */
+const gitGrepFiles = (pattern, pathspecs) => {
+ try {
+ return gitLines(['grep', '-l', '-E', pattern, '--', ...pathspecs]);
+ } catch (err) {
+ if (err.status === 1) return [];
+ throw err;
+ }
+};
+
export function emitGitHubPlan(plan) {
const outputs = {
full: plan.full,
@@ -561,6 +663,9 @@ export function emitGitHubPlan(plan) {
windows_mode: plan.windowsMode,
windows_files: JSON.stringify(plan.windowsFiles),
windows_sources: JSON.stringify(plan.windowsSources),
+ server_shards: JSON.stringify(plan.shards.server),
+ client_shards: JSON.stringify(plan.shards.client),
+ windows_shards: JSON.stringify(plan.shards.windows),
suite_reasons: JSON.stringify(plan.suiteReasons),
};
@@ -602,11 +707,16 @@ function main() {
const appDiff = forceFull || !changedFiles.includes('client/src/App.jsx')
? null
: execFileSync('git', ['diff', '--unified=0', `${base}...HEAD`, '--', 'client/src/App.jsx'], { encoding: 'utf8' });
+ const pythonContractTests = Object.fromEntries(changedFiles.filter(isPythonScript).map((script) => [
+ script,
+ gitGrepFiles(pythonReferencePattern(script), ['*.test.js', '*.test.jsx']),
+ ]));
emitGitHubPlan(buildCiTestPlan(changedFiles, {
trackedFiles,
forceFull,
forceFullReason,
appRouteOnly: isRouteOnlyAppDiff(appDiff),
+ pythonContractTests,
}));
}
diff --git a/scripts/ci-test-plan.test.js b/scripts/ci-test-plan.test.js
index 40d1414259..35b8790981 100644
--- a/scripts/ci-test-plan.test.js
+++ b/scripts/ci-test-plan.test.js
@@ -4,7 +4,10 @@ import {
ALWAYS_RUN_TESTS,
buildCiTestPlan,
forceFullReasonFor,
+ FULL_SUITE_SHARDS,
isRouteOnlyAppDiff,
+ pythonReferencePattern,
+ shardIndexes,
splitByRunner,
WINDOWS_CONTRACT_TESTS,
} from './ci-test-plan.js';
@@ -382,6 +385,121 @@ describe('CI test impact planner', () => {
expect(plan.reason).toMatch(/deleted executable source/);
});
+ it('runs the parsing contracts for a python sidecar script instead of the full suite', () => {
+ const tracked = [
+ ...TRACKED,
+ 'scripts/generate_ltx2.py',
+ 'scripts/_runner_common.py',
+ 'scripts/generate_ltx2.test.js',
+ 'server/services/videoGen/runtimes.test.js',
+ 'client/src/lib/videoRenderPhase.test.js',
+ ];
+ const pythonContractTests = {
+ 'scripts/_runner_common.py': [
+ 'scripts/generate_ltx2.test.js',
+ 'server/services/videoGen/runtimes.test.js',
+ 'client/src/lib/videoRenderPhase.test.js',
+ 'server/lib/deleted.test.js',
+ ],
+ };
+
+ const plan = buildCiTestPlan(['scripts/_runner_common.py'], { trackedFiles: tracked, pythonContractTests });
+
+ expect(plan).toMatchObject({
+ full: false,
+ reason: 'python script parsing contracts',
+ server: { mode: 'files', sources: [] },
+ client: { mode: 'files', sources: [] },
+ windows: false,
+ smoke: false,
+ db: false,
+ });
+ expect(plan.server.files).toEqual(expect.arrayContaining([
+ 'scripts/generate_ltx2.test.js',
+ 'server/services/videoGen/runtimes.test.js',
+ 'server/services/taskPromptDefaults.test.js',
+ ]));
+ expect(plan.server.files).not.toContain('server/lib/deleted.test.js');
+ expect(plan.client.files).toEqual(['client/src/lib/videoRenderPhase.test.js']);
+
+ // A JS source in the same diff keeps its own import-graph selection; the
+ // python contracts ride along as explicit files.
+ const mixed = buildCiTestPlan(['scripts/_runner_common.py', 'server/services/auth.js'], {
+ trackedFiles: tracked,
+ pythonContractTests,
+ });
+ expect(mixed.reason).toBe('Vitest related-test fallback');
+ expect(mixed.server).toMatchObject({ mode: 'related', sources: ['server/services/auth.js'] });
+ expect(mixed.server.files).toContain('scripts/generate_ltx2.test.js');
+ expect(mixed.smoke).toBe(true);
+ });
+
+ it('runs the generated-manifest drift tests whenever a server source changes', () => {
+ const tracked = [
+ ...TRACKED,
+ 'server/routes/settings.js',
+ 'scripts/generate-api-route-catalog.test.js',
+ 'scripts/generate-prompt-stage-call-sites.test.js',
+ ];
+ const drift = ['scripts/generate-api-route-catalog.test.js', 'scripts/generate-prompt-stage-call-sites.test.js'];
+
+ // A new route on a scoped plan is exactly the case that shipped a stale catalog.
+ const route = buildCiTestPlan(['server/routes/settings.js'], { trackedFiles: tracked });
+ expect(route.full).toBe(false);
+ expect(route.server.files).toEqual(expect.arrayContaining(drift));
+ // Any server module can add a literal stage-key call site.
+ const service = buildCiTestPlan(['server/services/auth.js'], { trackedFiles: tracked });
+ expect(service.server.files).toEqual(expect.arrayContaining(drift));
+ // A client-only change has nothing to regenerate.
+ const client = buildCiTestPlan(['client/src/lib/catalogLinks.js'], { trackedFiles: tracked });
+ expect(client.server.files).not.toEqual(expect.arrayContaining(drift));
+ });
+
+ it('fails closed to the full suite for a python script nothing pins', () => {
+ // Per script: a pinned sibling in the same diff does not vouch for the orphan.
+ const plan = buildCiTestPlan(['scripts/generate_ltx2.py', 'scripts/orphan.py'], {
+ trackedFiles: [...TRACKED, 'scripts/generate_ltx2.py', 'scripts/orphan.py', 'scripts/generate_ltx2.test.js'],
+ pythonContractTests: { 'scripts/generate_ltx2.py': ['scripts/generate_ltx2.test.js'] },
+ });
+ expect(plan.full).toBe(true);
+ expect(plan.reason).toMatch(/python script with no parsing contract: scripts\/orphan\.py/);
+ // A python file outside scripts/ is still an unclassified artifact.
+ const nested = buildCiTestPlan(['server/tools/helper.py'], { trackedFiles: TRACKED });
+ expect(nested.reason).toMatch(/unclassified/);
+ });
+
+ it('matches the way tests name one python script, not every one', () => {
+ const re = new RegExp(pythonReferencePattern('scripts/generate_ltx2.py'));
+ expect(re.test("join(SCRIPTS, 'generate_ltx2.py')")).toBe(true);
+ expect(re.test('readFileSync("scripts/generate_ltx2.py", "utf8")')).toBe(true);
+ expect(re.test('"generate_ltx2.py"')).toBe(true);
+ expect(re.test("'generate_ltx2_cuda.py'")).toBe(false);
+ expect(re.test("'my_generate_ltx2.py'")).toBe(false);
+ expect(re.test("'generate_ltx2.pyc'")).toBe(false);
+ expect(re.test("'generate_ltx2xpy'")).toBe(false);
+ });
+
+ it('fans a full plan out across the configured shards and leaves a scoped plan on one', () => {
+ expect(shardIndexes('full', 3)).toEqual([1, 2, 3]);
+ expect(shardIndexes('related', 3)).toEqual([1]);
+ expect(shardIndexes('files', 3)).toEqual([1]);
+ expect(shardIndexes('skip', 3)).toEqual([1]);
+ for (const [runner, count] of Object.entries(FULL_SUITE_SHARDS)) {
+ expect(count, runner).toBeGreaterThan(1);
+ }
+
+ const full = buildCiTestPlan(['server/vitest.config.js'], { trackedFiles: TRACKED });
+ expect(full.shards).toEqual({
+ server: shardIndexes('full', FULL_SUITE_SHARDS.server),
+ client: shardIndexes('full', FULL_SUITE_SHARDS.client),
+ windows: shardIndexes('full', FULL_SUITE_SHARDS.windows),
+ });
+ const scoped = buildCiTestPlan(['server/services/auth.js'], { trackedFiles: TRACKED });
+ expect(scoped.shards).toEqual({ server: [1], client: [1], windows: [1] });
+ const docsOnly = buildCiTestPlan(['docs/README.md'], { trackedFiles: TRACKED });
+ expect(docsOnly.shards).toEqual({ server: [1], client: [1], windows: [1] });
+ });
+
it('falls back to full CI for unknown artifacts and wide changes', () => {
const unknown = buildCiTestPlan(['data.reference/bootstrap.bin'], {
trackedFiles: TRACKED,
diff --git a/scripts/ensure-deps.js b/scripts/ensure-deps.js
index 3bd5a5ac04..37168924a4 100644
--- a/scripts/ensure-deps.js
+++ b/scripts/ensure-deps.js
@@ -9,6 +9,7 @@ import { createHash } from 'crypto';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
import { rebuildTrusted } from './trusted-rebuilds.js';
+import { isDirectlyInvoked } from './lib/directInvocation.js';
// Node refuses to spawn npm's `.cmd` shim under `shell:false` (CVE-2024-27980),
// so every npm spawn goes through this wrap. Safe to import before `npm install`
// has ever run — bufferedSpawn's whole import graph is Node builtins only.
@@ -31,7 +32,7 @@ const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
// `git pull` + `npm start` path, which has no pull context to diff against.
const HASH_FILE = join(ROOT, 'data', 'deps-hashes.json');
-const WORKSPACES = [
+export const WORKSPACES = [
{ dir: ROOT, label: 'root' },
{ dir: join(ROOT, 'client'), label: 'client' },
{ dir: join(ROOT, 'server'), label: 'server' },
@@ -62,21 +63,6 @@ function saveHashes(hashes) {
}
}
-// True only when the lockfile is gitignored (the per-install client/server
-// locks). A tracked root lockfile is kept — it's consistent with package.json.
-function lockfileIsGitignored(dir) {
- try {
- execFileSync('git', ['check-ignore', '-q', join(dir, 'package-lock.json')], {
- cwd: ROOT,
- stdio: 'ignore',
- windowsHide: true
- });
- return true;
- } catch {
- return false;
- }
-}
-
// Filesystem fallback for the no-baseline case (first run after this feature
// lands, or a fresh manual checkout): npm writes node_modules/.package-lock.json
// at the end of every install, so its mtime is the last-install time. If
@@ -95,15 +81,14 @@ function manifestNewerThanInstall(dir) {
}
}
-function cleanWorkspaceDeps(dir) {
+// Wipe `node_modules` ONLY. Every workspace lockfile ensure-deps touches (root,
+// client, server, autofixer) is tracked, so `npm install` re-resolves from the
+// committed lock — which is the state we want. Deleting it would let a clean
+// reinstall silently float transitive versions past the `overrides` pins.
+export function cleanWorkspaceDeps(dir) {
try {
rmSync(join(dir, 'node_modules'), { recursive: true, force: true });
} catch { /* best effort */ }
- if (lockfileIsGitignored(dir)) {
- try {
- rmSync(join(dir, 'package-lock.json'), { force: true });
- } catch { /* best effort */ }
- }
}
// The trusted install-script allowlist lives in scripts/trusted-rebuilds.js —
@@ -140,73 +125,79 @@ function install(dir, label) {
}
}
-const storedHashes = loadHashes();
-let hashesDirty = false;
-let needed = false;
-
-for (const { dir, label } of WORKSPACES) {
- const currentHash = pkgHash(dir);
- const nodeModulesMissing = !existsSync(join(dir, 'node_modules'));
- const storedHash = storedHashes[label];
- // With a stored baseline, a differing hash means the manifest moved since the
- // last install. Without one (first run after this feature lands, or a fresh
- // manual checkout), fall back to the install-marker mtime so a `git pull` +
- // `npm start` that changed package.json over a present node_modules is still
- // caught — instead of silently seeding the stale tree.
- const depsChanged = storedHash != null
- ? currentHash != null && storedHash !== currentHash
- : !nodeModulesMissing && manifestNewerThanInstall(dir);
-
- if (nodeModulesMissing || depsChanged) {
- if (depsChanged) {
- // Clean whenever the manifest changed — even if node_modules is already
- // gone — so the stale gitignored lockfile is removed and npm resolves the
- // tree from scratch instead of reusing the old per-install lock.
- console.log(`🧹 ${label} package.json changed since last install — clean reinstall...`);
- cleanWorkspaceDeps(dir);
- } else {
- console.log(`📦 Missing node_modules for ${label} — installing...`);
+// Import-safe driver: the module exposes its helpers to scripts/ensure-deps.test.js,
+// and only performs installs when run as `node scripts/ensure-deps.js`.
+function main() {
+ const storedHashes = loadHashes();
+ let hashesDirty = false;
+ let needed = false;
+
+ for (const { dir, label } of WORKSPACES) {
+ const currentHash = pkgHash(dir);
+ const nodeModulesMissing = !existsSync(join(dir, 'node_modules'));
+ const storedHash = storedHashes[label];
+ // With a stored baseline, a differing hash means the manifest moved since the
+ // last install. Without one (first run after this feature lands, or a fresh
+ // manual checkout), fall back to the install-marker mtime so a `git pull` +
+ // `npm start` that changed package.json over a present node_modules is still
+ // caught — instead of silently seeding the stale tree.
+ const depsChanged = storedHash != null
+ ? currentHash != null && storedHash !== currentHash
+ : !nodeModulesMissing && manifestNewerThanInstall(dir);
+
+ if (nodeModulesMissing || depsChanged) {
+ if (depsChanged) {
+ // Clean whenever the manifest changed — even if node_modules is already
+ // gone — so npm rebuilds the tree from the committed lockfile instead of
+ // layering onto a tree resolved against the previous manifest.
+ console.log(`🧹 ${label} package.json changed since last install — clean reinstall...`);
+ cleanWorkspaceDeps(dir);
+ } else {
+ console.log(`📦 Missing node_modules for ${label} — installing...`);
+ }
+ if (!install(dir, label)) process.exit(1);
+ needed = true;
+ }
+
+ if (currentHash != null && storedHashes[label] !== currentHash) {
+ storedHashes[label] = currentHash;
+ hashesDirty = true;
}
- if (!install(dir, label)) process.exit(1);
- needed = true;
}
- if (currentHash != null && storedHashes[label] !== currentHash) {
- storedHashes[label] = currentHash;
- hashesDirty = true;
+ // Verify critical packages exist even if node_modules dirs were present
+ // Grouped by workspace to avoid redundant installs
+ const criticalPackages = [
+ { dir: ROOT, label: 'root', pkg: 'pm2/package.json' },
+ { dir: join(ROOT, 'client'), label: 'client', pkg: 'vite/bin/vite.js' },
+ { dir: join(ROOT, 'server'), label: 'server', pkg: 'express/package.json' },
+ { dir: join(ROOT, 'server'), label: 'server', pkg: 'pg/package.json' },
+ ];
+
+ const criticalByDir = new Map();
+ for (const { dir, label, pkg } of criticalPackages) {
+ if (!criticalByDir.has(dir)) criticalByDir.set(dir, { label, pkgs: [] });
+ criticalByDir.get(dir).pkgs.push(pkg);
}
-}
-// Verify critical packages exist even if node_modules dirs were present
-// Grouped by workspace to avoid redundant installs
-const criticalPackages = [
- { dir: ROOT, label: 'root', pkg: 'pm2/package.json' },
- { dir: join(ROOT, 'client'), label: 'client', pkg: 'vite/bin/vite.js' },
- { dir: join(ROOT, 'server'), label: 'server', pkg: 'express/package.json' },
- { dir: join(ROOT, 'server'), label: 'server', pkg: 'pg/package.json' },
-];
+ for (const [dir, { label, pkgs }] of criticalByDir) {
+ const missing = pkgs.filter(pkg => !existsSync(join(dir, 'node_modules', ...pkg.split('/'))));
+ if (!missing.length) continue;
-const criticalByDir = new Map();
-for (const { dir, label, pkg } of criticalPackages) {
- if (!criticalByDir.has(dir)) criticalByDir.set(dir, { label, pkgs: [] });
- criticalByDir.get(dir).pkgs.push(pkg);
-}
+ console.log(`📦 Missing ${missing.map(p => p.split('/')[0]).join(', ')} in ${label} — reinstalling deps...`);
+ if (!install(dir, label)) process.exit(1);
+ needed = true;
-for (const [dir, { label, pkgs }] of criticalByDir) {
- const missing = pkgs.filter(pkg => !existsSync(join(dir, 'node_modules', ...pkg.split('/'))));
- if (!missing.length) continue;
+ const stillMissing = pkgs.filter(pkg => !existsSync(join(dir, 'node_modules', ...pkg.split('/'))));
+ if (stillMissing.length) {
+ console.error(`❌ Still missing in ${label} after reinstall: ${stillMissing.map(p => p.split('/')[0]).join(', ')}`);
+ process.exit(1);
+ }
+ }
- console.log(`📦 Missing ${missing.map(p => p.split('/')[0]).join(', ')} in ${label} — reinstalling deps...`);
- if (!install(dir, label)) process.exit(1);
- needed = true;
+ if (hashesDirty) saveHashes(storedHashes);
- const stillMissing = pkgs.filter(pkg => !existsSync(join(dir, 'node_modules', ...pkg.split('/'))));
- if (stillMissing.length) {
- console.error(`❌ Still missing in ${label} after reinstall: ${stillMissing.map(p => p.split('/')[0]).join(', ')}`);
- process.exit(1);
- }
+ if (needed) console.log('✅ Dependencies verified');
}
-if (hashesDirty) saveHashes(storedHashes);
-
-if (needed) console.log('✅ Dependencies verified');
+if (isDirectlyInvoked(import.meta.url)) main();
diff --git a/scripts/ensure-deps.test.js b/scripts/ensure-deps.test.js
new file mode 100644
index 0000000000..a01610fd0d
--- /dev/null
+++ b/scripts/ensure-deps.test.js
@@ -0,0 +1,86 @@
+/**
+ * Destructive-action guard for the clean-reinstall path (issue #5691).
+ *
+ * `cleanWorkspaceDeps` (and its `update.sh` / `update.ps1` twins) used to delete
+ * `package-lock.json` whenever `git check-ignore` said the lockfile was ignored
+ * — correct while the client and server locks were gitignored, dead since all
+ * four workspace lockfiles became tracked. Restoring that delete would be silent
+ * and dangerous: a `git pull` that changes a `package.json` would wipe the
+ * committed lock and let `npm install` re-resolve transitive versions past the
+ * `overrides` pins (which is how the node-tar / engine.io advisories are held
+ * down), with no other detector in the suite.
+ *
+ * The behavioural test alone cannot catch that regression — the old code ran
+ * `git check-ignore` with `cwd` pinned to the repo root, so it always answered
+ * "not ignored" for a temp directory outside the checkout. So the premise (every
+ * workspace lockfile is tracked) and the absence of the delete are asserted too.
+ */
+import { describe, expect, it } from 'vitest';
+import { execFileSync } from 'child_process';
+import { mkdtempSync, mkdirSync, writeFileSync, existsSync, rmSync, readFileSync } from 'fs';
+import { tmpdir } from 'os';
+import { dirname, join, relative } from 'path';
+import { fileURLToPath } from 'url';
+import { cleanWorkspaceDeps, WORKSPACES } from './ensure-deps.js';
+
+const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+
+/** Every helper that wipes a workspace's installed deps before a reinstall. */
+const REINSTALL_HELPERS = ['scripts/ensure-deps.js', 'update.sh', 'update.ps1'];
+
+/** True when `source` can delete a workspace lockfile, or asks git whether it may. */
+const deletesLockfile = (source) => (
+ /check-ignore/.test(source)
+ || /(rmSync|rm -f|rm -rf|Remove-Item)[^\n]*package-lock\.json/.test(source)
+);
+
+describe('clean reinstall keeps the committed lockfile (#5691)', () => {
+ it('wipes node_modules and keeps the lockfile', () => {
+ const dir = mkdtempSync(join(tmpdir(), 'portos-ensure-deps-'));
+ try {
+ mkdirSync(join(dir, 'node_modules', 'left-over'), { recursive: true });
+ writeFileSync(join(dir, 'package-lock.json'), '{"lockfileVersion":3}');
+
+ cleanWorkspaceDeps(dir);
+
+ expect(existsSync(join(dir, 'node_modules'))).toBe(false);
+ expect(existsSync(join(dir, 'package-lock.json'))).toBe(true);
+ } finally {
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+ // The premise the deletion removal rests on. If a workspace lockfile is ever
+ // untracked again, this fails first and says so — rather than the reinstall
+ // path quietly reverting to a per-install lock nobody can reproduce.
+ it('tracks a lockfile for every workspace ensure-deps cleans', () => {
+ const tracked = new Set(
+ execFileSync('git', ['ls-files', '*package-lock.json'], { cwd: REPO_ROOT, encoding: 'utf8' })
+ .split('\n')
+ .filter(Boolean)
+ );
+
+ expect(WORKSPACES.length).toBeGreaterThan(0);
+ for (const { dir, label } of WORKSPACES) {
+ const lockPath = [relative(REPO_ROOT, dir), 'package-lock.json'].filter(Boolean).join('/');
+ expect(tracked, `${label} lockfile must stay tracked`).toContain(lockPath);
+ }
+ });
+
+ // The detector decides whether the scan below means anything, so it is
+ // verified against both spellings of the removed path rather than trusted.
+ it('deletesLockfile flags the removed delete path in every helper language', () => {
+ expect(deletesLockfile("if (lockfileIsGitignored(dir)) rmSync(join(dir, 'package-lock.json'), { force: true });")).toBe(true);
+ expect(deletesLockfile('if git check-ignore -q "$dir/package-lock.json"; then rm -f "$dir/package-lock.json"; fi')).toBe(true);
+ expect(deletesLockfile('Remove-Item -Force "$Dir/package-lock.json" -ErrorAction SilentlyContinue')).toBe(true);
+ expect(deletesLockfile("rmSync(join(dir, 'node_modules'), { recursive: true, force: true });")).toBe(false);
+ });
+
+ it('no reinstall helper deletes a workspace lockfile', () => {
+ const offenders = REINSTALL_HELPERS.filter(
+ (relativePath) => deletesLockfile(readFileSync(join(REPO_ROOT, relativePath), 'utf8'))
+ );
+
+ expect(offenders).toEqual([]);
+ });
+});
diff --git a/scripts/generate-api-route-catalog.test.js b/scripts/generate-api-route-catalog.test.js
index bf39635b9b..77ad759714 100644
--- a/scripts/generate-api-route-catalog.test.js
+++ b/scripts/generate-api-route-catalog.test.js
@@ -255,8 +255,6 @@ describe('generated API route catalog', () => {
const operations = new Set(readApiRouteCatalog().routes.map((route) => `${route.method} ${route.path}`));
for (const operation of [
'POST /api/brain/songbook/import/url',
- 'GET /api/city/introspection',
- 'GET /api/openworld/introspection',
'GET /api/providers/readiness',
'DELETE /api/providers/:id',
'POST /api/providers/:id/test',
diff --git a/scripts/generate_fastvideo.py b/scripts/generate_fastvideo.py
index 20a696203a..d6f58afdb8 100755
--- a/scripts/generate_fastvideo.py
+++ b/scripts/generate_fastvideo.py
@@ -18,7 +18,10 @@
entry script rejects instead of silently rendering through the wrong pipeline.
"""
+from __future__ import annotations
+
import argparse
+import hashlib
import os
import re
import subprocess
@@ -27,7 +30,7 @@
# Same-dir sibling import. _runner_common is stdlib-only at import time.
sys.path.insert(0, str(Path(__file__).resolve().parent))
-from _runner_common import emit_runtime_fingerprint, establish_process_group # noqa: E402
+from _runner_common import emit_runtime_fingerprint, establish_process_group, heartbeat # noqa: E402
_DENOISE_STEP_PATTERN = re.compile(
@@ -35,6 +38,86 @@
)
_PERCENT_PATTERN = re.compile(r'\d+%')
+# ---------------------------------------------------------------------------
+# Phase reporting (#5872)
+#
+# FastH3 is silent for MINUTES at a time. Its MLX pipeline
+# (fastvideo/mlx_runtime/minimax_h3_pipeline.py) logs one milestone line per
+# phase and emits no per-step denoise progress at all, so a render that is
+# streaming an ~89 GB INT4 DiT off disk looks identical to a hung one: 0%, no
+# text. PortOS therefore derives the phase itself, by scraping the milestone
+# lines upstream DOES log into a small monotonic state machine.
+#
+# Liveness is the shared `_runner_common.heartbeat`, handed a callable so each
+# beat reports whichever phase the scraper is currently in. The server stamps
+# that phase onto the status frame it broadcasts, so the UI can name the step
+# even while nothing else is being said.
+# ---------------------------------------------------------------------------
+
+# The phase progression, earliest first. Ordering is load-bearing — it is what
+# keeps `advance_phase` monotonic — so it is declared here rather than inferred
+# from the key order of the label table below.
+# NOT "encode-prompt": that exact marker is generate_ltx2.py's Gemma
+# prompt-encode BEGIN sentinel (PROMPT_ENCODE_BEGIN_MARKER in
+# generateVideoHelpers.js), and the server arms an ltx2-specific relaunch on it.
+# Emitting it here would open a phase this runner never closes, so a FastVideo
+# render killed by the Metal watchdog would be "retried" with ltx2's
+# --gemma-max-length flag, which this script's argparse rejects — replacing the
+# real watchdog error with an exit-2 after a wasted full model re-spawn.
+_PHASE_ORDER = ("load-pipeline", "conditioning", "sampling", "mux")
+
+# Phase id -> the sentence the user reads, emitted once per transition. Ids are
+# drawn from the STAGE: vocabulary the server already parses
+# (generateVideoHelpers.js), so the client's phase→step mapping needs no
+# FastVideo-specific entries.
+PHASE_LABELS = {
+ "load-pipeline": "Loading the FastVideo pipeline",
+ "conditioning": "Encoding the prompt and streaming model weights",
+ "sampling": "Rendering (denoising)",
+ "mux": "Decoding and muxing video + audio",
+}
+
+# The phase a render is in from spawn until upstream says otherwise.
+INITIAL_PHASE = _PHASE_ORDER[0]
+
+# (lowercase substring, phase it moves us to). First match wins. Each marker is
+# a line upstream logs at the END of the work it names, so it opens the NEXT
+# phase rather than the one it describes.
+_PHASE_MARKERS = (
+ # `Geometry: output=...` is the first line generate() logs, i.e. the
+ # pipeline object is constructed and the long conditioning leg starts here.
+ ("geometry:", "conditioning"),
+ # Text conditioning is done — either freshly encoded or read from cache.
+ ("loaded prompt embeddings", "sampling"),
+ # The DiT finished streaming in; denoising is the only thing left before
+ # decode. Both orderings are covered because a prompt-cache hit skips the
+ # encode entirely.
+ ("loaded mlx h3 dit", "sampling"),
+ ("recomputing adaln cache", "sampling"),
+ # Denoising is over; everything after it is VAE decode + ffmpeg mux.
+ ("generation complete", "mux"),
+)
+
+
+def advance_phase(line: str, current: str) -> str:
+ """The phase `line` moves us into, or `current` when it names none.
+
+ Never moves backwards: upstream repeats some milestone lines (a chained or
+ retried leg re-logs `Geometry:`), and a phase that regressed to
+ "Encoding the prompt" halfway through denoising would read as a stall.
+ """
+ # A denoising-step line is proof of the sampler running whatever the
+ # milestone wording said. It is the only marker the fastmetal family emits,
+ # so without this its heartbeat would keep claiming "Loading the FastVideo
+ # pipeline" while the step counter climbed.
+ phase = "sampling" if _DENOISE_STEP_PATTERN.search(line) else None
+ if phase is None:
+ lowered = line.lower()
+ phase = next((p for marker, p in _PHASE_MARKERS if marker in lowered), None)
+ if phase is None or _PHASE_ORDER.index(phase) <= _PHASE_ORDER.index(current):
+ return current
+ return phase
+
def translate_line(line: str) -> str:
"""Translate one upstream output line into PortOS's progress protocol."""
@@ -56,6 +139,12 @@ def parse_args() -> argparse.Namespace:
help="Which FastVideo entry script and argv shape to use")
p.add_argument("--model-root", required=True, help="HF model snapshot path")
p.add_argument("--mlx-checkpoint", default=None, help="MLX checkpoint path (defaults to model-root)")
+ p.add_argument("--mlx-format", choices=MLX_DIT_FORMATS, default=None,
+ help="convert this snapshot's transformer/ to an MLX DiT of this format on first use")
+ p.add_argument("--mlx-checkpoint-cache-dir", default=None,
+ help="root for converted MLX DiTs (default: a shared dir under ~/.portos/fastvideo)")
+ p.add_argument("--prompt-cache-dir", default=None,
+ help="reusable FastH3 prompt-embedding cache (default: a shared dir under ~/.portos/fastvideo)")
p.add_argument("--prompt", required=True)
p.add_argument("--negative-prompt", default="")
p.add_argument("--width", type=int, required=True)
@@ -106,6 +195,125 @@ def find_entry_script(repo_dir: Path, family: str = "fastmetal") -> Path:
raise FileNotFoundError(f"Could not find {glob_name} under {repo_dir}")
+# FastVideo publishes FastH3 as a bf16 diffusers snapshot: the DiT lives under
+# `transformer/` beside the vae / audio_vae / text_encoder / tokenizer the
+# pipeline loads. mlx_fasth3.py does not read that DiT -- it wants a
+# pre-quantized `mlx_h3_dit` directory. `--mlx-format` bridges the two by
+# running FastVideo's own converter once, so a row can point at the upstream
+# checkpoint instead of depending on a third party having published a repack.
+MLX_DIT_FORMATS = ("int8", "int6", "int4")
+# PortOS declares both of these roots in server/services/videoGen/runtimes.js and
+# passes them as flags. These defaults exist only so the script runs standalone,
+# the same role --repo-dir's fallback plays.
+MLX_CHECKPOINT_CACHE = Path.home() / ".portos" / "fastvideo" / "mlx-checkpoints"
+PROMPT_CACHE = Path.home() / ".portos" / "fastvideo" / "prompt-cache"
+_CONVERTER_SCRIPT = ("scripts", "checkpoint_conversion", "convert_minimax_h3_mlx.py")
+_MLX_CHECKPOINT_FILES = ("mlx_h3_dit.safetensors", "mlx_h3_dit.json")
+
+
+def mlx_checkpoint_root(model_root: Path, base: Path | None = None) -> Path:
+ """Where DiTs converted from `model_root` are cached.
+
+ Keyed by the SNAPSHOT, not by the repo id -- an HF cache path ends in the
+ commit sha, so two revisions of one repo cannot collide on a converted
+ checkpoint. Kept outside the HF cache because `hf` prunes by blob and has no
+ idea these files belong to that snapshot.
+ """
+ parts = [part for part in (model_root.parent.parent.name, model_root.name) if part]
+ label = re.sub(r"[^A-Za-z0-9._-]+", "-", "-".join(parts)).strip("-") or "snapshot"
+ # The readable half is for whoever reads the directory listing; the digest is
+ # what carries identity. A --model-root outside the HF cache can share both
+ # its own name and its grandparent's with an unrelated snapshot, and two
+ # snapshots resolving to ONE converted DiT would render the wrong weights
+ # without any error to notice.
+ digest = hashlib.sha256(str(model_root).encode("utf-8")).hexdigest()[:12]
+ return (base or MLX_CHECKPOINT_CACHE) / f"{label}-{digest}"
+
+
+def is_converted(checkpoint_dir: Path) -> bool:
+ return all((checkpoint_dir / name).is_file() for name in _MLX_CHECKPOINT_FILES)
+
+
+def ensure_mlx_checkpoint(repo_dir: Path, model_root: Path, fmt: str, env: dict,
+ base: Path | None = None) -> Path:
+ """Return the converted MLX DiT for `fmt`, converting it if it is missing."""
+ out_base = mlx_checkpoint_root(model_root, base)
+ out_dir = out_base / fmt
+ if is_converted(out_dir):
+ return out_dir
+ transformer = model_root / "transformer"
+ if not transformer.is_dir():
+ raise FileNotFoundError(
+ f"{model_root} has no transformer/ to convert to MLX {fmt}. Download the "
+ f"full FastH3 snapshot, or point --mlx-checkpoint at a converted DiT.")
+ converter = repo_dir.joinpath(*_CONVERTER_SCRIPT)
+ if not converter.is_file():
+ raise FileNotFoundError(f"Could not find {converter}")
+ print(f"STATUS:converting the FastH3 DiT to MLX {fmt} — one time, into {out_dir}",
+ file=sys.stderr, flush=True)
+ out_base.mkdir(parents=True, exist_ok=True)
+ code = run_child(
+ [sys.executable, str(converter),
+ "--model-root", str(transformer),
+ "--out", str(out_base),
+ "--formats", fmt],
+ env, repo_dir, lambda line: f"STATUS:{line}",
+ )
+ if code != 0:
+ raise RuntimeError(f"FastH3 MLX conversion exited with code {code}")
+ if not is_converted(out_dir):
+ raise RuntimeError(f"FastH3 MLX conversion finished but {out_dir} is incomplete")
+ # The bf16 transformer is dead weight for rendering once this exists, but it
+ # is the user's download: name it, do not delete it.
+ print(f"STATUS:MLX {fmt} DiT ready — {transformer} is no longer needed to render and can be deleted",
+ file=sys.stderr, flush=True)
+ return out_dir
+
+
+def resolve_prompt_cache_dir(args) -> Path:
+ """Where FastH3 caches its conditioning embeddings.
+
+ Streaming the bf16 Qwen3-VL conditioner is HALF the wall clock of a 124-frame
+ render (307s of 621s measured on an M5 Max), and it produces the same
+ embeddings every time. Upstream digests the cache entry over its own cache
+ version, the model root AND the prompt, so one shared directory cannot serve
+ a stale entry across models or across a conditioner change.
+ """
+ return Path(args.prompt_cache_dir) if args.prompt_cache_dir else PROMPT_CACHE
+
+
+def run_child(cmd: list, env: dict, cwd: Path, transform) -> int:
+ """Spawn a child, stream its merged output through `transform`, return its code.
+
+ For the CONVERSION child only. The inference child in main() runs its own
+ loop because it also drives a heartbeat thread and a phase state machine,
+ and shares stderr with that thread — it needs one write per line, which
+ print() does not give. Conversion finishes before the heartbeat starts, so
+ there is no concurrent writer here.
+ """
+ proc = subprocess.Popen(
+ cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
+ env=env, cwd=str(cwd),
+ )
+ assert proc.stdout is not None
+ for raw in proc.stdout:
+ line = raw.rstrip()
+ if line:
+ print(transform(line), file=sys.stderr, flush=True)
+ return proc.wait()
+
+
+def build_child_env(repo_dir: Path) -> dict:
+ """Environment shared by the conversion child and the inference child."""
+ env = os.environ.copy()
+ env["PYTHONPATH"] = f"{str(repo_dir)}:{env.get('PYTHONPATH', '')}".rstrip(":")
+ # Mirrors train_mflux_lora.py's M5 Metal-watchdog mitigation. Preserve an
+ # explicit caller override, but make the validated safe value the default
+ # for the sustained denoise child of either family.
+ env.setdefault("AGX_RELAX_CDM_CTXSTORE_TIMEOUT", "1")
+ return env
+
+
# mlx_fasth3.py always muxes H.264 at 24 fps with 32 kHz stereo AAC and exposes
# no --fps flag, so a differing request is reported rather than silently ignored.
FASTH3_NATIVE_FPS = 24
@@ -166,7 +374,8 @@ def build_command(args, entry_script: Path, model_root: Path, mlx_checkpoint: Pa
if args.fps and args.fps != FASTH3_NATIVE_FPS:
print(f"STATUS:FastH3 always writes {FASTH3_NATIVE_FPS} fps — ignoring the requested {args.fps} fps",
file=sys.stderr, flush=True)
- cmd = common + ["--steps", str(args.steps)] + tail
+ cmd = common + ["--steps", str(args.steps),
+ "--prompt-cache-dir", str(resolve_prompt_cache_dir(args))] + tail
if args.fast:
cmd.append("--fast")
return cmd
@@ -193,19 +402,31 @@ def main() -> int:
return 1
model_root = Path(args.model_root).resolve()
- mlx_checkpoint = Path(args.mlx_checkpoint).resolve() if args.mlx_checkpoint else model_root
+ env = build_child_env(repo_dir)
+ # Precedence: an explicit path always wins, so a row that ships a
+ # pre-quantized DiT never triggers a conversion it does not need.
+ if args.mlx_checkpoint:
+ mlx_checkpoint = Path(args.mlx_checkpoint).resolve()
+ elif args.mlx_format:
+ try:
+ mlx_checkpoint = ensure_mlx_checkpoint(
+ repo_dir, model_root, args.mlx_format, env,
+ Path(args.mlx_checkpoint_cache_dir) if args.mlx_checkpoint_cache_dir else None)
+ except (FileNotFoundError, RuntimeError) as err:
+ print(f"❌ {err}", file=sys.stderr)
+ return 1
+ else:
+ mlx_checkpoint = model_root
cmd = build_command(args, entry_script, model_root, mlx_checkpoint)
+ # The child writes into this directory but will not create it. Keyed off the
+ # argv rather than a second family test, so it cannot disagree with the one
+ # branch in build_command that decides the flag is passed at all.
+ if "--prompt-cache-dir" in cmd:
+ Path(cmd[cmd.index("--prompt-cache-dir") + 1]).mkdir(parents=True, exist_ok=True)
- print("STAGE:inference", file=sys.stderr, flush=True)
print(f"🎬 fastvideo {args.family} generate {args.width}x{args.height} frames={args.num_frames} steps={args.steps} seed={args.seed}", file=sys.stderr, flush=True)
- env = os.environ.copy()
- env["PYTHONPATH"] = f"{str(repo_dir)}:{env.get('PYTHONPATH', '')}".rstrip(":")
- # Mirrors train_mflux_lora.py's M5 Metal-watchdog mitigation. Preserve an
- # explicit caller override, but make the validated safe value the default
- # for the sustained denoise child of either family.
- env.setdefault("AGX_RELAX_CDM_CTXSTORE_TIMEOUT", "1")
print(
f"STATUS:watchdog mitigation · AGX_RELAX_CDM_CTXSTORE_TIMEOUT={env['AGX_RELAX_CDM_CTXSTORE_TIMEOUT']}",
file=sys.stderr,
@@ -221,17 +442,33 @@ def main() -> int:
cwd=str(repo_dir),
)
+ # One write per line, not print()'s two (text, then newline): the heartbeat
+ # thread writes to this same stream, and an interleave between the two halves
+ # would glue two protocol lines together and lose a marker.
+ def emit(text: str) -> None:
+ sys.stderr.write(f"{text}\n")
+ sys.stderr.flush()
+
+ phase = INITIAL_PHASE
+ emit(f"STAGE:{phase}")
+
assert proc.stdout is not None
# Parse output lines and map to STAGE: / STATUS: protocols. Only the
# upstream denoising-step message represents render progress. Startup
# model-loading bars also contain percentages (often ending at 100%) and
# must remain status output or the generic server parser will report them
# as completed rendering.
- for raw in proc.stdout:
- line = raw.rstrip()
- if not line:
- continue
- print(translate_line(line), file=sys.stderr, flush=True)
+ with heartbeat(lambda: phase):
+ for raw in proc.stdout:
+ line = raw.rstrip()
+ if not line:
+ continue
+ next_phase = advance_phase(line, phase)
+ if next_phase != phase:
+ phase = next_phase
+ emit(f"STAGE:{phase}")
+ emit(f"STATUS:{PHASE_LABELS[phase]}")
+ emit(translate_line(line))
return_code = proc.wait()
if return_code != 0:
diff --git a/scripts/generate_fastvideo.test.js b/scripts/generate_fastvideo.test.js
index d7e9a80eea..2f69289767 100644
--- a/scripts/generate_fastvideo.test.js
+++ b/scripts/generate_fastvideo.test.js
@@ -45,3 +45,55 @@ describe.skipIf(!pyBin)('generate_fastvideo.py', () => {
]);
});
});
+
+// Phase reporting (#5872). FastH3's MLX pipeline logs one milestone line per
+// phase and NO per-step denoise progress, so these markers plus the heartbeat
+// are the only thing standing between the user and a 20-minute blank 0%.
+describe.skipIf(!pyBin)('generate_fastvideo.py phase reporting', () => {
+ it('advances the phase on each upstream milestone line', () => {
+ const output = runPython(`${importRunner}\n${[
+ 'phase = runner.INITIAL_PHASE',
+ 'for line in [',
+ ' "INFO Geometry: output=832x480x124 model=832x480x124 audio_frames=124 fast=None",',
+ ' "INFO Loaded prompt embeddings from cache abc123",',
+ ' "INFO Loaded MLX H3 DiT from /models/int4 in 412.7s",',
+ ' "INFO Generation complete: /out/render.mp4 | timings={} peaks={}",',
+ ']:',
+ ' phase = runner.advance_phase(line, phase)',
+ ' print(phase)',
+ ].join('\n')}`);
+
+ // 'conditioning', deliberately NOT 'encode-prompt' — that exact marker is
+ // generate_ltx2.py's prompt-encode BEGIN sentinel, and emitting it here
+ // would arm an ltx2-only relaunch against a FastVideo render.
+ expect(lines(output)).toEqual(['conditioning', 'sampling', 'sampling', 'mux']);
+ });
+
+ it('never moves the phase backwards when a milestone line repeats', () => {
+ const output = runPython(`${importRunner}\n${[
+ 'print(runner.advance_phase("INFO Geometry: output=832x480x124", "sampling"))',
+ 'print(runner.advance_phase("nothing to see here", "sampling"))',
+ ].join('\n')}`);
+
+ expect(lines(output)).toEqual(['sampling', 'sampling']);
+ });
+
+ // fastmetal reports denoise steps but none of FastH3's milestone wording, so
+ // without this its heartbeat would keep claiming "Loading the FastVideo
+ // pipeline" while the step counter climbed.
+ it('treats a denoising step as proof the sampler is running', () => {
+ const output = runPython(`${importRunner}\n${[
+ 'print(runner.advance_phase("denoising step 2/4", runner.INITIAL_PHASE))',
+ ].join('\n')}`);
+
+ expect(lines(output)).toEqual(['sampling']);
+ });
+
+ it('labels every phase it can advance into', () => {
+ const output = runPython(`${importRunner}\n${[
+ 'print(sorted(runner._PHASE_ORDER) == sorted(runner.PHASE_LABELS))',
+ ].join('\n')}`);
+
+ expect(lines(output)).toEqual(['True']);
+ });
+});
diff --git a/scripts/generate_fastvideo_test.py b/scripts/generate_fastvideo_test.py
index 5ce36144d7..4a0fb8d566 100644
--- a/scripts/generate_fastvideo_test.py
+++ b/scripts/generate_fastvideo_test.py
@@ -11,6 +11,7 @@
import importlib.util
import io
import sys
+import tempfile
import unittest
from contextlib import redirect_stderr
from pathlib import Path
@@ -47,6 +48,8 @@ def make_args(**overrides):
fast=False,
enhance_prompt=False,
refine=False,
+ prompt_cache_dir="/fixture/prompt-cache",
+ mlx_checkpoint_cache_dir=None,
)
for key, value in overrides.items():
setattr(args, key, value)
@@ -126,6 +129,22 @@ def test_fasth3_reports_a_non_native_fps_and_stays_quiet_at_24(self):
_, quiet = self.build(family="fasth3", fps=self.helper.FASTH3_NATIVE_FPS)
self.assertNotIn("fps", quiet)
+ def test_fasth3_reuses_conditioning_through_a_prompt_cache(self):
+ # Streaming the bf16 Qwen3-VL conditioner is half the wall clock of a
+ # 124-frame render, and it recomputes the same embeddings every time.
+ cmd, _ = self.build(family="fasth3")
+ self.assertEqual(self.flag(cmd, "--prompt-cache-dir"), "/fixture/prompt-cache")
+
+ def test_fasth3_falls_back_to_a_shared_cache_dir(self):
+ cmd, _ = self.build(family="fasth3", prompt_cache_dir=None)
+ self.assertTrue(self.flag(cmd, "--prompt-cache-dir").endswith("prompt-cache"))
+
+ def test_fastmetal_is_not_handed_a_prompt_cache_flag(self):
+ # mlx_wan_prompt_to_video.py has no such flag; passing it would turn
+ # every FastMetal render into an argparse error.
+ cmd, _ = self.build(family="fastmetal")
+ self.assertNotIn("--prompt-cache-dir", cmd)
+
def test_fasth3_still_forwards_fast_mode(self):
cmd, _ = self.build(family="fasth3", fast=True)
self.assertIn("--fast", cmd)
@@ -164,5 +183,68 @@ def test_a_checkout_without_the_fasth3_entry_script_fails_loudly(self):
self.assertIn("mlx_fasth3.py", str(ctx.exception))
+class MlxCheckpointTest(unittest.TestCase):
+ """The convert-on-first-use path for FastVideo's own bf16 FastH3 snapshot."""
+
+ def setUp(self):
+ self.helper = load_helper()
+
+ def test_the_cache_key_is_the_snapshot_not_the_repo(self):
+ # Two revisions of one repo must not collide on a converted DiT, so the
+ # key carries the commit sha an HF cache path ends in.
+ root = self.helper.mlx_checkpoint_root
+ older = root(Path("/hfcache/models--Org--Repo/snapshots/aaaa1111"))
+ newer = root(Path("/hfcache/models--Org--Repo/snapshots/bbbb2222"))
+ self.assertNotEqual(older, newer)
+ self.assertIn("models--Org--Repo", str(older))
+ self.assertIn("aaaa1111", str(older))
+ # Outside the HF cache: hf prunes by blob and would not know these
+ # converted files belong to that snapshot.
+ self.assertNotIn("/hfcache/models--Org--Repo", str(older))
+
+ def test_two_roots_sharing_a_readable_name_still_get_their_own_checkpoint(self):
+ # A hand-placed --model-root can share both its own name and its
+ # grandparent's with an unrelated snapshot. Collapsing those onto one
+ # converted DiT would render the wrong weights with nothing to notice.
+ first = self.helper.mlx_checkpoint_root(Path("/srv/a/models/snapshot"))
+ second = self.helper.mlx_checkpoint_root(Path("/srv/b/models/snapshot"))
+ self.assertNotEqual(first, second)
+ self.assertIn("snapshot", first.name)
+ # Stable across calls, so a second render reuses the first conversion.
+ self.assertEqual(first, self.helper.mlx_checkpoint_root(Path("/srv/a/models/snapshot")))
+
+ def test_a_directory_missing_either_file_is_not_converted(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ out = Path(tmp) / "int4"
+ out.mkdir()
+ self.assertFalse(self.helper.is_converted(out))
+ (out / "mlx_h3_dit.safetensors").write_text("")
+ self.assertFalse(self.helper.is_converted(out))
+ (out / "mlx_h3_dit.json").write_text("")
+ self.assertTrue(self.helper.is_converted(out))
+
+ def test_an_already_converted_format_is_reused_without_a_transformer(self):
+ # The steady state after the user deletes the 66 GB bf16 transformer:
+ # rendering must keep working off the converted DiT alone.
+ with tempfile.TemporaryDirectory() as tmp:
+ base = Path(tmp) / "mlx-checkpoints"
+ model_root = Path(tmp) / "models--Org--Repo" / "snapshots" / "abc123"
+ model_root.mkdir(parents=True)
+ out = self.helper.mlx_checkpoint_root(model_root, base) / "int6"
+ out.mkdir(parents=True)
+ for name in ("mlx_h3_dit.safetensors", "mlx_h3_dit.json"):
+ (out / name).write_text("")
+ resolved = self.helper.ensure_mlx_checkpoint(Path(tmp), model_root, "int6", {}, base)
+ self.assertEqual(resolved, out)
+
+ def test_a_snapshot_without_a_transformer_fails_loudly(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ model_root = Path(tmp) / "models--Org--Repo" / "snapshots" / "def456"
+ model_root.mkdir(parents=True)
+ with self.assertRaises(FileNotFoundError) as ctx:
+ self.helper.ensure_mlx_checkpoint(Path(tmp), model_root, "int4", {}, Path(tmp) / "cache")
+ self.assertIn("transformer", str(ctx.exception))
+
+
if __name__ == "__main__":
unittest.main()
diff --git a/scripts/lib/workflowJobs.js b/scripts/lib/workflowJobs.js
new file mode 100644
index 0000000000..942f2e4981
--- /dev/null
+++ b/scripts/lib/workflowJobs.js
@@ -0,0 +1,27 @@
+/**
+ * Split a GitHub Actions workflow file into its `jobs:` entries, keyed by job
+ * id, each as the raw YAML text of that job. Shared by the workflow-contract
+ * tests so the indentation-based slicing lives in one place.
+ *
+ * ZERO external dependencies — see githubOutput.js.
+ */
+
+/**
+ * @param {string} yaml - workflow source
+ * @returns {Record} job id → job body
+ */
+export function workflowJobs(yaml) {
+ const body = yaml.slice(yaml.indexOf('\njobs:\n'));
+ const jobs = {};
+ let current = null;
+ for (const line of body.split('\n')) {
+ const header = line.match(/^ {2}([a-z][a-z0-9-]*):\s*$/);
+ if (header) {
+ current = header[1];
+ jobs[current] = [];
+ continue;
+ }
+ if (current) jobs[current].push(line);
+ }
+ return Object.fromEntries(Object.entries(jobs).map(([id, lines]) => [id, lines.join('\n')]));
+}
diff --git a/scripts/migrations/334-fasth3-upstream-source-mlx.js b/scripts/migrations/334-fasth3-upstream-source-mlx.js
new file mode 100644
index 0000000000..3bb67339f0
--- /dev/null
+++ b/scripts/migrations/334-fasth3-upstream-source-mlx.js
@@ -0,0 +1,107 @@
+/**
+ * Add FastVideo's own FastH3 Dense / Data-Free snapshot to existing macOS video
+ * registries, at all three MLX DiT formats. Fresh installs receive them from
+ * data.reference/media-models.json.
+ *
+ * Migration 333 shipped a third-party repack because FastVideo's MLX repos
+ * publish no weights (they still do not). These rows point at the upstream bf16
+ * diffusers snapshot instead and declare `fastvideoMlxFormat`, which is what
+ * makes the helper run FastVideo's own converter on the snapshot's transformer/
+ * before the first render. One download serves all three rows.
+ */
+
+import { VIDEO_BUCKET_MLX, readVideoBucket } from '../../server/lib/mediaModelBuckets.js';
+import { readMediaRegistry, writeMediaRegistry } from './_lib.js';
+
+// H3's video VAE decodes only 17n+5 frame counts, and the MLX pipeline refuses
+// anything outside 5-15 s at 24 fps, so the grid is 124..345. Inlined rather
+// than imported from lib/mediaModels.js: a migration must keep writing the
+// values it shipped with, not whatever the live constant becomes three releases
+// later.
+const FRAME_OPTIONS = [124, 141, 158, 175, 192, 209, 226, 243, 260, 277, 294, 311, 328, 345];
+
+// What migration 333 wrote. Its two extra ends — 107 (4.46 s) and 362 (15.08 s)
+// — sit outside the pipeline's window and raise instead of rendering, so an
+// install that already ran 333 carries a picker with a broken value at each end.
+const MIGRATION_333_FRAME_OPTIONS = [107, 124, 141, 158, 175, 192, 209, 226, 243, 260, 277, 294, 311, 328, 345, 362];
+const REPACK_ID = 'fasth3_dense_datafree_mlx_int4';
+const RESOLUTION_OPTIONS = [
+ { label: '832x480 (16:9 FastH3 default)', w: 832, h: 480 },
+ { label: '1280x720 (16:9 HD)', w: 1280, h: 720 },
+];
+
+const NEW_ENTRIES = [
+ { format: 'int8', memoryGb: 48, label: 'INT8 (highest fidelity)' },
+ { format: 'int6', memoryGb: 42, label: 'INT6 (upstream default)' },
+ { format: 'int4', memoryGb: 36, label: 'INT4 (smallest)' },
+].map(({ format, memoryGb, label }) => ({
+ id: `fasth3_dense_datafree_${format}`,
+ name: `FastH3 Preview v1 Dense Data-Free — MLX ${label} (video + audio, ~144 GB download, ${memoryGb}+ GB RAM, 4-step)`,
+ repo: 'FastVideo/FastVideo-FastH3-4-step-Preview-v1-Dense-DataFree',
+ revision: 'f624f08c6c279ab43534c003e556fc5b295b6558',
+ runtime: 'fastvideo',
+ fastvideoFamily: 'fasth3',
+ fastvideoMlxFormat: format,
+ supportedModes: ['text'],
+ defaultWidth: 832,
+ defaultHeight: 480,
+ defaultFrames: 124,
+ frameOptions: FRAME_OPTIONS,
+ fpsOptions: [24],
+ resolutionStep: 32,
+ resolutionOptions: RESOLUTION_OPTIONS,
+ memoryGb,
+ steps: 4,
+ guidance: 1,
+ samplerLocked: true,
+ samplerNote: `FastH3 Preview v1 is a 4-step DMD2 model. This is FastVideo's own dense-attention checkpoint — it does not support VSA, whose routing weights the MLX runtime drops. The first render converts its transformer to an MLX ${format.toUpperCase()} DiT (a few minutes, once), after which the 66 GB bf16 transformer can be deleted. Renders video with audio at a fixed 24 fps.`,
+ supportsNegativePrompt: false,
+ supportsTiling: false,
+ supportsDisableAudio: false,
+}));
+
+export default {
+ async up({ rootDir }) {
+ const { ok, config, entries: mlxEntries, path } = await readMediaRegistry({ rootDir, bucket: VIDEO_BUCKET_MLX });
+ if (!ok) return;
+
+ let added = 0;
+ let repaired = false;
+ const present = new Set(mlxEntries.map((entry) => entry?.id));
+ for (const entry of NEW_ENTRIES) {
+ if (present.has(entry.id)) continue;
+ mlxEntries.push(structuredClone(entry));
+ present.add(entry.id);
+ added += 1;
+ }
+ // Repair the row 333 shipped, but only while it still holds 333's exact
+ // list — a user who edited their own frame options keeps them.
+ const repack = mlxEntries.find((entry) => entry?.id === REPACK_ID);
+ if (repack && Array.isArray(repack.frameOptions)
+ && repack.frameOptions.length === MIGRATION_333_FRAME_OPTIONS.length
+ && repack.frameOptions.every((frames, i) => frames === MIGRATION_333_FRAME_OPTIONS[i])) {
+ repack.frameOptions = [...FRAME_OPTIONS];
+ repaired = true;
+ }
+
+ const shipped = readVideoBucket(config?._shippedDefaults?.video, VIDEO_BUCKET_MLX);
+ if (Array.isArray(shipped)) {
+ for (const entry of NEW_ENTRIES) {
+ if (!shipped.includes(entry.id)) {
+ shipped.push(entry.id);
+ added = Math.max(added, 1);
+ }
+ }
+ }
+ if (added > 0 || repaired) {
+ await writeMediaRegistry(path, config);
+ // Say which of the two things actually happened — an upgrade that only
+ // repaired the frame grid did not add anything.
+ const what = [
+ added > 0 ? `added ${added} upstream FastH3 Dense Data-Free MLX model(s)` : null,
+ repaired ? 'repaired the FastH3 repack frame options' : null,
+ ].filter(Boolean).join('; ');
+ console.log(`📝 data/media-models.json: ${what}`);
+ }
+ },
+};
diff --git a/scripts/migrations/334-fasth3-upstream-source-mlx.test.js b/scripts/migrations/334-fasth3-upstream-source-mlx.test.js
new file mode 100644
index 0000000000..c0e5a85043
--- /dev/null
+++ b/scripts/migrations/334-fasth3-upstream-source-mlx.test.js
@@ -0,0 +1,133 @@
+import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
+import { tmpdir } from 'os';
+import { join } from 'path';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import migration from './334-fasth3-upstream-source-mlx.js';
+
+const NEW_IDS = ['fasth3_dense_datafree_int8', 'fasth3_dense_datafree_int6', 'fasth3_dense_datafree_int4'];
+const SOURCE_REPO = 'FastVideo/FastVideo-FastH3-4-step-Preview-v1-Dense-DataFree';
+
+describe('334-fasth3-upstream-source-mlx migration', () => {
+ let rootDir;
+ let registryFile;
+
+ beforeEach(() => {
+ rootDir = join(tmpdir(), `portos-test-334-${Date.now()}-${Math.random().toString(36).slice(2)}`);
+ mkdirSync(join(rootDir, 'data'), { recursive: true });
+ registryFile = join(rootDir, 'data', 'media-models.json');
+ });
+
+ afterEach(() => {
+ rmSync(rootDir, { recursive: true, force: true });
+ });
+
+ const write = (config) => writeFileSync(registryFile, JSON.stringify(config, null, 2));
+ const read = () => JSON.parse(readFileSync(registryFile, 'utf-8'));
+
+ it('skips gracefully when media-models.json does not exist', async () => {
+ await expect(migration.up({ rootDir })).resolves.toBeUndefined();
+ });
+
+ it('adds all three format rows to an existing MLX registry and its shipped list', async () => {
+ write({
+ video: { mlx: [{ id: 'fastmetal_1_3b_qad', name: 'FastMetal 1.3B' }], cuda: [] },
+ _shippedDefaults: { video: { mlx: ['fastmetal_1_3b_qad'] } },
+ });
+
+ await migration.up({ rootDir });
+
+ const updated = read();
+ for (const id of NEW_IDS) {
+ const entry = updated.video.mlx.find((m) => m.id === id);
+ expect(entry).toBeDefined();
+ // All three rows are ONE download differing only by a local conversion,
+ // so they must name the same pinned snapshot.
+ expect(entry.repo).toBe(SOURCE_REPO);
+ expect(entry.revision).toMatch(/^[0-9a-f]{40}$/);
+ // The row rides the EXISTING fastvideo runtime; `fastvideoFamily` routes
+ // it to the FastH3 entry script and `fastvideoMlxFormat` is what makes
+ // the helper convert the snapshot's bf16 transformer before rendering.
+ expect(entry.runtime).toBe('fastvideo');
+ expect(entry.fastvideoFamily).toBe('fasth3');
+ expect(entry.fastvideoMlxFormat).toBe(id.split('_').pop());
+ // The UI reads its frame/fps/capability limits straight off the entry, so
+ // an upgraded install must receive them too — not just a fresh seed.
+ expect(entry.frameOptions.every((f) => (f - 5) % 17 === 0)).toBe(true);
+ expect(entry.frameOptions).toContain(entry.defaultFrames);
+ expect(entry.fpsOptions).toEqual([24]);
+ expect(entry.supportsNegativePrompt).toBe(false);
+ expect(entry.supportsTiling).toBe(false);
+ expect(entry.supportsDisableAudio).toBe(false);
+ expect(updated._shippedDefaults.video.mlx).toContain(id);
+ }
+ });
+
+ it('leaves the pre-converted repack row from migration 333 otherwise in place', async () => {
+ write({ video: { mlx: [{ id: 'fasth3_dense_datafree_mlx_int4', repo: 'MrMofer/x' }] } });
+
+ await migration.up({ rootDir });
+
+ const packed = read().video.mlx.find((m) => m.id === 'fasth3_dense_datafree_mlx_int4');
+ expect(packed).toEqual({ id: 'fasth3_dense_datafree_mlx_int4', repo: 'MrMofer/x' });
+ });
+
+ it('drops the two out-of-window frame counts migration 333 shipped', async () => {
+ // 107 is 4.46 s and 362 is 15.08 s at FastH3's fixed 24 fps; the pipeline
+ // refuses both, so they were a broken value at each end of the picker.
+ write({
+ video: {
+ mlx: [{
+ id: 'fasth3_dense_datafree_mlx_int4',
+ frameOptions: [107, 124, 141, 158, 175, 192, 209, 226, 243, 260, 277, 294, 311, 328, 345, 362],
+ }],
+ },
+ });
+
+ await migration.up({ rootDir });
+
+ const packed = read().video.mlx.find((m) => m.id === 'fasth3_dense_datafree_mlx_int4');
+ expect(packed.frameOptions).not.toContain(107);
+ expect(packed.frameOptions).not.toContain(362);
+ expect(packed.frameOptions.every((f) => f / 24 >= 5 && f / 24 <= 15)).toBe(true);
+ });
+
+ it('keeps a user own frame options rather than overwriting them', async () => {
+ write({ video: { mlx: [{ id: 'fasth3_dense_datafree_mlx_int4', frameOptions: [124, 141] }] } });
+
+ await migration.up({ rootDir });
+
+ expect(read().video.mlx.find((m) => m.id === 'fasth3_dense_datafree_mlx_int4').frameOptions)
+ .toEqual([124, 141]);
+ });
+
+ it('leaves the CUDA bucket untouched', async () => {
+ write({ video: { mlx: [], cuda: [{ id: 'ltx_video' }] } });
+
+ await migration.up({ rootDir });
+
+ expect(read().video.cuda.map((m) => m.id)).toEqual(['ltx_video']);
+ });
+
+ it('is idempotent when run multiple times', async () => {
+ write({ video: { mlx: [{ id: 'fastmetal_1_3b_qad' }] } });
+
+ await migration.up({ rootDir });
+ const firstPass = readFileSync(registryFile, 'utf-8');
+ await migration.up({ rootDir });
+
+ expect(readFileSync(registryFile, 'utf-8')).toBe(firstPass);
+ });
+
+ it('does not re-add a row the user deleted from the shipped list only', async () => {
+ write({
+ video: { mlx: [{ id: NEW_IDS[0], name: 'edited by user', runtime: 'fastvideo' }] },
+ _shippedDefaults: { video: { mlx: NEW_IDS } },
+ });
+
+ await migration.up({ rootDir });
+
+ expect(read().video.mlx.filter((m) => m.id === NEW_IDS[0])).toEqual([
+ { id: NEW_IDS[0], name: 'edited by user', runtime: 'fastvideo' },
+ ]);
+ });
+});
diff --git a/scripts/migrations/335-antigravity-gemini-3-8-models.js b/scripts/migrations/335-antigravity-gemini-3-8-models.js
new file mode 100644
index 0000000000..5ae9a3ab58
--- /dev/null
+++ b/scripts/migrations/335-antigravity-gemini-3-8-models.js
@@ -0,0 +1,100 @@
+/**
+ * Update Antigravity (agy) CLI and TUI model catalog to include Gemini 3.8 and
+ * drop the retired Gemini 3.5 tier.
+ *
+ * Adds gemini-3.8-flash-high, gemini-3.8-flash-medium, gemini-3.8-flash-low and
+ * removes gemini-3.5-flash-high, gemini-3.5-flash-medium, gemini-3.5-flash-low
+ * (no longer returned by `agy models`) from the shipped Antigravity model
+ * catalog.
+ *
+ * Existing installs whose models list matches a prior seeded catalog or is
+ * sentinel-only are updated to the new catalog. User-customized model lists are
+ * left alone.
+ *
+ * Kept in lockstep with data.reference/providers.json and
+ * server/lib/aiToolkit/defaults/providers.sample.json.
+ */
+
+import { readProvidersDoc, writeJsonAtomic } from './_lib.js';
+
+const PROVIDERS_REL_PATH = 'data/providers.json';
+const TARGET_IDS = ['antigravity-cli', 'antigravity-tui'];
+const SENTINEL = 'antigravity-configured-default';
+
+const OLD_MODELS = [
+ SENTINEL,
+ 'gemini-3.7-flash-high',
+ 'gemini-3.7-flash-medium',
+ 'gemini-3.7-flash-low',
+ 'gemini-3.6-flash-high',
+ 'gemini-3.6-flash-medium',
+ 'gemini-3.6-flash-low',
+ 'gemini-3.5-flash-high',
+ 'gemini-3.5-flash-medium',
+ 'gemini-3.5-flash-low',
+ 'gemini-3.1-pro-high',
+ 'gemini-3.1-pro-low',
+ 'claude-sonnet-4-6',
+ 'claude-opus-4-6-thinking',
+ 'gpt-oss-120b-medium',
+];
+
+const NEW_MODELS = [
+ SENTINEL,
+ 'gemini-3.8-flash-high',
+ 'gemini-3.8-flash-medium',
+ 'gemini-3.8-flash-low',
+ 'gemini-3.7-flash-high',
+ 'gemini-3.7-flash-medium',
+ 'gemini-3.7-flash-low',
+ 'gemini-3.6-flash-high',
+ 'gemini-3.6-flash-medium',
+ 'gemini-3.6-flash-low',
+ 'gemini-3.1-pro-high',
+ 'gemini-3.1-pro-low',
+ 'claude-sonnet-4-6',
+ 'claude-opus-4-6-thinking',
+ 'gpt-oss-120b-medium',
+];
+
+const sameArray = (a, b) =>
+ Array.isArray(a) && Array.isArray(b) && a.length === b.length && a.every((v, i) => v === b[i]);
+
+export default {
+ async up({ rootDir }) {
+ const doc = await readProvidersDoc({ rootDir });
+ if (!doc.ok) {
+ if (doc.reason === 'no-file') console.log(`📄 ${PROVIDERS_REL_PATH} not present — skipping (fresh install seeds from data.reference with the new defaults)`);
+ else if (doc.reason === 'unreadable') console.log(`⚠️ ${PROVIDERS_REL_PATH}: invalid JSON, skipping (${doc.err.message})`);
+ else console.log(`⚠️ ${PROVIDERS_REL_PATH}: no providers map — skipping`);
+ return;
+ }
+
+ const { config, providers, path: providersPath } = doc;
+ let changed = false;
+
+ for (const id of TARGET_IDS) {
+ if (!Object.hasOwn(providers, id)) continue;
+ const provider = providers[id];
+ if (!provider || typeof provider !== 'object') continue;
+
+ const isSentinelOnly = Array.isArray(provider.models)
+ && provider.models.length === 1
+ && provider.models[0] === SENTINEL;
+
+ if (sameArray(provider.models, OLD_MODELS) || isSentinelOnly) {
+ provider.models = [...NEW_MODELS];
+ changed = true;
+ console.log(`📝 ${PROVIDERS_REL_PATH}: updated ${id} models with Gemini 3.8 catalog`);
+ } else if (sameArray(provider.models, NEW_MODELS)) {
+ console.log(`✅ ${PROVIDERS_REL_PATH}: ${id} already has Gemini 3.8 models`);
+ } else {
+ console.log(`ℹ️ ${PROVIDERS_REL_PATH}: ${id} has custom models list — leaving intact`);
+ }
+ }
+
+ if (changed) {
+ await writeJsonAtomic(providersPath, config);
+ }
+ },
+};
diff --git a/scripts/migrations/335-antigravity-gemini-3-8-models.test.js b/scripts/migrations/335-antigravity-gemini-3-8-models.test.js
new file mode 100644
index 0000000000..3e711744d1
--- /dev/null
+++ b/scripts/migrations/335-antigravity-gemini-3-8-models.test.js
@@ -0,0 +1,160 @@
+/**
+ * Test for migration 335 — update Antigravity CLI and TUI model catalog to include Gemini 3.8.
+ */
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { mkdtempSync, rmSync, writeFileSync, readFileSync, mkdirSync } from 'fs';
+import { tmpdir } from 'os';
+import { join } from 'path';
+
+import migration from './335-antigravity-gemini-3-8-models.js';
+
+const writeJson = (path, value) => writeFileSync(path, JSON.stringify(value, null, 2) + '\n');
+const readJson = (path) => JSON.parse(readFileSync(path, 'utf-8'));
+
+const SENTINEL = 'antigravity-configured-default';
+
+const OLD_MODELS = [
+ SENTINEL,
+ 'gemini-3.7-flash-high',
+ 'gemini-3.7-flash-medium',
+ 'gemini-3.7-flash-low',
+ 'gemini-3.6-flash-high',
+ 'gemini-3.6-flash-medium',
+ 'gemini-3.6-flash-low',
+ 'gemini-3.5-flash-high',
+ 'gemini-3.5-flash-medium',
+ 'gemini-3.5-flash-low',
+ 'gemini-3.1-pro-high',
+ 'gemini-3.1-pro-low',
+ 'claude-sonnet-4-6',
+ 'claude-opus-4-6-thinking',
+ 'gpt-oss-120b-medium',
+];
+
+const NEW_MODELS = [
+ SENTINEL,
+ 'gemini-3.8-flash-high',
+ 'gemini-3.8-flash-medium',
+ 'gemini-3.8-flash-low',
+ 'gemini-3.7-flash-high',
+ 'gemini-3.7-flash-medium',
+ 'gemini-3.7-flash-low',
+ 'gemini-3.6-flash-high',
+ 'gemini-3.6-flash-medium',
+ 'gemini-3.6-flash-low',
+ 'gemini-3.1-pro-high',
+ 'gemini-3.1-pro-low',
+ 'claude-sonnet-4-6',
+ 'claude-opus-4-6-thinking',
+ 'gpt-oss-120b-medium',
+];
+
+describe('migration 335 — Antigravity Gemini 3.8 model catalog', () => {
+ let rootDir;
+ let providersPath;
+
+ beforeEach(() => {
+ rootDir = mkdtempSync(join(tmpdir(), 'migration-335-'));
+ mkdirSync(join(rootDir, 'data'), { recursive: true });
+ providersPath = join(rootDir, 'data/providers.json');
+ });
+
+ afterEach(() => {
+ rmSync(rootDir, { recursive: true, force: true });
+ });
+
+ it('updates prior seeded models list on antigravity-cli and antigravity-tui', async () => {
+ writeJson(providersPath, {
+ providers: {
+ 'antigravity-cli': {
+ id: 'antigravity-cli',
+ type: 'cli',
+ command: 'agy',
+ models: [...OLD_MODELS],
+ defaultModel: SENTINEL,
+ },
+ 'antigravity-tui': {
+ id: 'antigravity-tui',
+ type: 'tui',
+ command: 'agy',
+ models: [...OLD_MODELS],
+ defaultModel: SENTINEL,
+ },
+ },
+ });
+
+ await migration.up({ rootDir });
+
+ const out = readJson(providersPath);
+ expect(out.providers['antigravity-cli'].models).toEqual(NEW_MODELS);
+ expect(out.providers['antigravity-tui'].models).toEqual(NEW_MODELS);
+ });
+
+ it('updates sentinel-only antigravity providers', async () => {
+ writeJson(providersPath, {
+ providers: {
+ 'antigravity-cli': {
+ id: 'antigravity-cli',
+ type: 'cli',
+ command: 'agy',
+ models: [SENTINEL],
+ defaultModel: SENTINEL,
+ },
+ },
+ });
+
+ await migration.up({ rootDir });
+
+ const out = readJson(providersPath);
+ expect(out.providers['antigravity-cli'].models).toEqual(NEW_MODELS);
+ });
+
+ it('leaves already-current model lists alone', async () => {
+ writeJson(providersPath, {
+ providers: {
+ 'antigravity-cli': {
+ id: 'antigravity-cli',
+ type: 'cli',
+ command: 'agy',
+ models: [...NEW_MODELS],
+ defaultModel: SENTINEL,
+ },
+ },
+ });
+
+ await migration.up({ rootDir });
+
+ const out = readJson(providersPath);
+ expect(out.providers['antigravity-cli'].models).toEqual(NEW_MODELS);
+ });
+
+ it('leaves customized model lists alone', async () => {
+ const customModels = [SENTINEL, 'my-custom-model', 'gemini-3.6-flash-high'];
+ writeJson(providersPath, {
+ providers: {
+ 'antigravity-cli': {
+ id: 'antigravity-cli',
+ type: 'cli',
+ command: 'agy',
+ models: [...customModels],
+ defaultModel: 'my-custom-model',
+ },
+ },
+ });
+
+ await migration.up({ rootDir });
+
+ const out = readJson(providersPath);
+ expect(out.providers['antigravity-cli'].models).toEqual(customModels);
+ });
+
+ it('is a no-op when data/providers.json does not exist (fresh install)', async () => {
+ rmSync(providersPath, { force: true });
+ await expect(migration.up({ rootDir })).resolves.not.toThrow();
+ });
+
+ it('skips gracefully when providers.json is invalid JSON', async () => {
+ writeFileSync(providersPath, 'not-json');
+ await expect(migration.up({ rootDir })).resolves.not.toThrow();
+ });
+});
diff --git a/scripts/migrations/335-schedule-interval-types.js b/scripts/migrations/335-schedule-interval-types.js
new file mode 100644
index 0000000000..2c92b16073
--- /dev/null
+++ b/scripts/migrations/335-schedule-interval-types.js
@@ -0,0 +1,120 @@
+/**
+ * Collapse the seven scheduled-task interval types onto the two-variant cadence
+ * model, with `perpetual` as an orthogonal boolean flag.
+ *
+ * once → on-demand (manual trigger only; no auto-run, no reset loop)
+ * rotation → cron '0 7 * * *' (un-shipped in defaults; a conservative
+ * daily slot, never an unbounded drain)
+ * daily → cron '0 7 * * *'
+ * weekly → cron '0 7 * * 1' (Monday, so work never lands on a weekend)
+ * custom → cron derived from intervalMs
+ * perpetual → on-demand + perpetual: true (recheck cadence retained)
+ * on-demand → on-demand (branch-reconcile / issue-reconcile gain perpetual)
+ *
+ * Applies to both schedule-file locations and to the per-app cadence overrides
+ * in apps.json. Idempotent: a record already on the new model is left alone,
+ * and a raw 5-field cron override passes through untouched. `enabled`,
+ * `weekdaysOnly`, and every other field are preserved.
+ */
+
+import { readFile, writeFile } from 'fs/promises';
+import { join } from 'path';
+import {
+ INTERVAL_TYPES,
+ decodeIntervalType,
+ isCronExpression,
+ isReconcileDrainTaskType,
+ normalizeIntervalConfig,
+} from '../../server/services/taskScheduleConstants.js';
+
+const SCHEDULE_PATHS = [
+ join('data', 'cos', 'task-schedule.json'),
+ join('data', 'task-schedule.json'),
+];
+const APPS_PATH = join('data', 'apps.json');
+
+async function readJson(path) {
+ const raw = await readFile(path, 'utf-8').catch((err) => {
+ if (err.code === 'ENOENT') return null;
+ throw err;
+ });
+ if (raw == null) return null;
+ try {
+ return JSON.parse(raw);
+ } catch {
+ return null;
+ }
+}
+
+const writeJson = (path, value) => writeFile(path, `${JSON.stringify(value, null, 2)}\n`);
+
+/** Migrate one task config in place. Returns true when it changed. */
+export function migrateTaskConfig(taskType, config) {
+ if (!config || typeof config !== 'object') return false;
+ let changed = normalizeIntervalConfig(config);
+ // The reconcile drains shipped as on-demand while their drain behavior was
+ // hardcoded by task name. That special case is gone, so the flag has to be
+ // written onto the record for them to keep draining.
+ if (isReconcileDrainTaskType(taskType) && config.perpetual !== true) {
+ config.perpetual = true;
+ changed = true;
+ }
+ return changed;
+}
+
+/**
+ * Migrate one per-app override in place. Per-app rows carry the cadence as a
+ * bare string (`interval`), so a retired name becomes either 'on-demand' or a
+ * cron expression. Returns true when it changed.
+ */
+export function migrateAppOverride(override) {
+ if (!override || typeof override !== 'object') return false;
+ const { interval } = override;
+ if (typeof interval !== 'string' || isCronExpression(interval)) return false;
+ const decoded = decodeIntervalType(interval, { intervalMs: override.intervalMs });
+ const next = decoded.type === INTERVAL_TYPES.CRON ? decoded.cronExpression : INTERVAL_TYPES.ON_DEMAND;
+ if (next === interval) return false;
+ override.interval = next;
+ return true;
+}
+
+export default {
+ async up({ rootDir }) {
+ let updated = 0;
+
+ for (const relPath of SCHEDULE_PATHS) {
+ const fullPath = join(rootDir, relPath);
+ const schedule = await readJson(fullPath);
+ const tasks = schedule?.tasks;
+ if (!tasks || typeof tasks !== 'object') continue;
+
+ let migrated = 0;
+ for (const [taskType, config] of Object.entries(tasks)) {
+ if (migrateTaskConfig(taskType, config)) migrated += 1;
+ }
+ if (!migrated) continue;
+ await writeJson(fullPath, schedule);
+ updated += migrated;
+ console.log(`📅 ${relPath}: migrated ${migrated} task cadence(s) to on-demand/cron + perpetual flag`);
+ }
+
+ // apps.json stores `apps` as an object keyed by app id.
+ const appsPath = join(rootDir, APPS_PATH);
+ const apps = await readJson(appsPath);
+ if (apps?.apps && typeof apps.apps === 'object') {
+ let migrated = 0;
+ for (const app of Object.values(apps.apps)) {
+ for (const override of Object.values(app?.taskTypeOverrides || {})) {
+ if (migrateAppOverride(override)) migrated += 1;
+ }
+ }
+ if (migrated) {
+ await writeJson(appsPath, apps);
+ updated += migrated;
+ console.log(`📅 ${APPS_PATH}: migrated ${migrated} per-app cadence override(s)`);
+ }
+ }
+
+ return { updated };
+ },
+};
diff --git a/scripts/migrations/335-schedule-interval-types.test.js b/scripts/migrations/335-schedule-interval-types.test.js
new file mode 100644
index 0000000000..80f501b313
--- /dev/null
+++ b/scripts/migrations/335-schedule-interval-types.test.js
@@ -0,0 +1,124 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { mkdtempSync, rmSync, writeFileSync, readFileSync, mkdirSync } from 'fs';
+import { tmpdir } from 'os';
+import { join } from 'path';
+import migration from './335-schedule-interval-types.js';
+
+describe('migration 335 — collapse interval types to on-demand/cron + perpetual flag', () => {
+ let rootDir;
+ let schedulePath;
+ let appsPath;
+
+ const writeSchedule = (tasks) => {
+ mkdirSync(join(rootDir, 'data', 'cos'), { recursive: true });
+ writeFileSync(schedulePath, `${JSON.stringify({ version: 2, tasks, executions: {} }, null, 2)}\n`);
+ };
+ const readTasks = () => JSON.parse(readFileSync(schedulePath, 'utf8')).tasks;
+
+ const writeApps = (apps) => {
+ mkdirSync(join(rootDir, 'data'), { recursive: true });
+ writeFileSync(appsPath, `${JSON.stringify({ apps }, null, 2)}\n`);
+ };
+ const readApps = () => JSON.parse(readFileSync(appsPath, 'utf8')).apps;
+
+ beforeEach(() => {
+ rootDir = mkdtempSync(join(tmpdir(), 'migration-335-'));
+ schedulePath = join(rootDir, 'data', 'cos', 'task-schedule.json');
+ appsPath = join(rootDir, 'data', 'apps.json');
+ });
+
+ afterEach(() => rmSync(rootDir, { recursive: true, force: true }));
+
+ it('is a no-op on a fresh install with no schedule or apps file', async () => {
+ await expect(migration.up({ rootDir })).resolves.toEqual({ updated: 0 });
+ });
+
+ it('maps every retired cadence onto the two-variant model', async () => {
+ writeSchedule({
+ 'a-once': { type: 'once', enabled: true },
+ 'a-rotation': { type: 'rotation', enabled: true },
+ 'a-daily': { type: 'daily', enabled: true },
+ 'a-weekly': { type: 'weekly', enabled: true },
+ 'a-custom': { type: 'custom', intervalMs: 1_800_000, enabled: true },
+ 'a-perpetual': { type: 'perpetual', recheckCron: '0 3 * * *', enabled: true },
+ });
+
+ await migration.up({ rootDir });
+ const tasks = readTasks();
+
+ expect(tasks['a-once']).toMatchObject({ type: 'on-demand', perpetual: false, enabled: true });
+ expect(tasks['a-rotation']).toMatchObject({ type: 'cron', cronExpression: '0 7 * * *' });
+ expect(tasks['a-daily']).toMatchObject({ type: 'cron', cronExpression: '0 7 * * *' });
+ expect(tasks['a-weekly']).toMatchObject({ type: 'cron', cronExpression: '0 7 * * 1' });
+ expect(tasks['a-custom']).toMatchObject({ type: 'cron', cronExpression: '*/30 * * * *' });
+ // The recheck cadence survives the type collapse.
+ expect(tasks['a-perpetual']).toMatchObject({ type: 'on-demand', perpetual: true, recheckCron: '0 3 * * *' });
+ });
+
+ it('preserves enabled/weekdaysOnly and an already-cron expression', async () => {
+ writeSchedule({
+ paused: { type: 'weekly', enabled: false, weekdaysOnly: true },
+ pinned: { type: 'cron', cronExpression: '30 8 * * 2', enabled: true, weekdaysOnly: true },
+ });
+
+ await migration.up({ rootDir });
+ const tasks = readTasks();
+
+ expect(tasks.paused).toMatchObject({ enabled: false, weekdaysOnly: true, type: 'cron', cronExpression: '0 7 * * 1' });
+ expect(tasks.pinned).toMatchObject({ enabled: true, weekdaysOnly: true, type: 'cron', cronExpression: '30 8 * * 2' });
+ });
+
+ it('gives the reconcile drains the perpetual flag their hardcoded behavior relied on', async () => {
+ writeSchedule({
+ 'branch-reconcile': { type: 'on-demand', enabled: true, recheckCron: '0 3 * * *' },
+ 'issue-reconcile': { type: 'on-demand', enabled: true },
+ 'claim-issue': { type: 'on-demand', enabled: true },
+ });
+
+ await migration.up({ rootDir });
+ const tasks = readTasks();
+
+ expect(tasks['branch-reconcile']).toMatchObject({ type: 'on-demand', perpetual: true });
+ expect(tasks['issue-reconcile']).toMatchObject({ type: 'on-demand', perpetual: true });
+ // Every OTHER on-demand task stays a single-run manual action.
+ expect(tasks['claim-issue']).toMatchObject({ type: 'on-demand', perpetual: false });
+ });
+
+ it('rewrites per-app cadence overrides and leaves a raw cron string alone', async () => {
+ writeApps({
+ 'app-1': {
+ id: 'app-1',
+ taskTypeOverrides: {
+ security: { enabled: true, interval: 'weekly' },
+ 'layered-intelligence': { enabled: true, interval: 'custom', intervalMs: 900_000 },
+ 'ui-bugs': { enabled: true, interval: 'once' },
+ typing: { enabled: true, interval: '15 6 * * 3' },
+ inherited: { enabled: true, interval: null },
+ },
+ },
+ });
+
+ await migration.up({ rootDir });
+ const overrides = readApps()['app-1'].taskTypeOverrides;
+
+ expect(overrides.security.interval).toBe('0 7 * * 1');
+ expect(overrides['layered-intelligence'].interval).toBe('*/15 * * * *');
+ expect(overrides['ui-bugs'].interval).toBe('on-demand');
+ expect(overrides.typing.interval).toBe('15 6 * * 3');
+ expect(overrides.inherited.interval).toBeNull();
+ });
+
+ it('is idempotent — a second run changes nothing', async () => {
+ writeSchedule({ 'a-weekly': { type: 'weekly', enabled: true }, 'branch-reconcile': { type: 'on-demand', enabled: true } });
+ writeApps({ 'app-1': { id: 'app-1', taskTypeOverrides: { security: { enabled: true, interval: 'daily' } } } });
+
+ const first = await migration.up({ rootDir });
+ expect(first.updated).toBeGreaterThan(0);
+ const afterFirst = readFileSync(schedulePath, 'utf8');
+ const appsAfterFirst = readFileSync(appsPath, 'utf8');
+
+ await expect(migration.up({ rootDir })).resolves.toEqual({ updated: 0 });
+ expect(readFileSync(schedulePath, 'utf8')).toBe(afterFirst);
+ expect(readFileSync(appsPath, 'utf8')).toBe(appsAfterFirst);
+ });
+});
diff --git a/scripts/migrations/336-fastmetal-download-size-names.js b/scripts/migrations/336-fastmetal-download-size-names.js
new file mode 100644
index 0000000000..61dc5c87c3
--- /dev/null
+++ b/scripts/migrations/336-fastmetal-download-size-names.js
@@ -0,0 +1,54 @@
+/**
+ * Correct the FastMetal rows' download size where the user actually reads it.
+ *
+ * All three shipped names quoted the MLX DiT alone (~3.5 / ~10 / ~25 GB) while
+ * the entries pull a whole-repo snapshot that also carries a bundled T5 text
+ * encoder and VAE — 13.4 / 19.5 / 42.3 GB. #5860 fixed the disclosure panel's
+ * `estimatedDownloadGb` but shipped no migration, and left the NAME — the
+ * number shown in the picker before that panel is ever opened — untouched.
+ *
+ * This also narrows the 14B row with `repoFiles`, dropping the `ema/` copy of
+ * its DiT (14.14 GB the entry script never loads), which is why its corrected
+ * figure is 27.1 GB rather than the full 42.3 GB snapshot.
+ *
+ * Conservative customization rules (see `upgradeFastMetalDownloadSizes`):
+ * - only a row still pointing at the shipped repo is eligible;
+ * - the name changes only when it is byte-for-byte the prior shipped string;
+ * - `repoFiles` is added only when the row declares none;
+ * - a persisted `estimatedDownloadGb` changes only when it equals a value
+ * PortOS itself shipped.
+ *
+ * Fresh installs receive all of this from data.reference/media-models.json.
+ */
+
+import { readMediaRegistry, writeMediaRegistry } from './_lib.js';
+import {
+ FASTMETAL_DOWNLOAD_SIZE_PROFILES,
+ upgradeFastMetalDownloadSizes,
+} from '../../server/lib/mediaModels.js';
+
+const REL_PATH = 'data/media-models.json';
+
+export default {
+ async up({ rootDir }) {
+ const { ok, config, entries, path } = await readMediaRegistry({ rootDir });
+ if (!ok) return;
+
+ const changedIds = [];
+ for (const profile of FASTMETAL_DOWNLOAD_SIZE_PROFILES) {
+ const entry = entries.find((model) => model?.id === profile.id);
+ if (!entry) continue;
+ const [upgraded] = upgradeFastMetalDownloadSizes([entry]);
+ if (upgraded === entry) continue;
+ Object.assign(entry, upgraded);
+ changedIds.push(profile.id);
+ }
+
+ if (changedIds.length === 0) {
+ console.log(`✅ ${REL_PATH}: FastMetal download sizes already current or customized`);
+ return;
+ }
+ await writeMediaRegistry(path, config);
+ console.log(`📝 ${REL_PATH}: corrected FastMetal download sizes on ${changedIds.length} row(s) — ${changedIds.join(', ')}`);
+ },
+};
diff --git a/scripts/migrations/336-fastmetal-download-size-names.test.js b/scripts/migrations/336-fastmetal-download-size-names.test.js
new file mode 100644
index 0000000000..fe2fc8d20d
--- /dev/null
+++ b/scripts/migrations/336-fastmetal-download-size-names.test.js
@@ -0,0 +1,148 @@
+import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
+import { tmpdir } from 'os';
+import { join } from 'path';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+import migration from './336-fastmetal-download-size-names.js';
+
+// The exact strings/values migration 314 and #5860 shipped. Spelled out rather
+// than imported so the test would fail if a profile's `oldName` ever drifted off
+// what installs actually carry — the whole guard rests on a byte-for-byte match.
+const OLD_1_3B = 'FastMetal 1.3B QAD (~3.5 GB download, 8+ GB RAM, 3-step)';
+const OLD_5B = 'FastMetal 5B QAD (~10 GB download, 16+ GB RAM, 3-step)';
+const OLD_14B = 'FastMetal 14B QAD (~25 GB download, 36+ GB RAM, 3-step)';
+
+const shippedRow = (id, name, repo, extra = {}) => ({ id, name, repo, runtime: 'fastvideo', ...extra });
+
+const shippedRegistry = () => ({
+ video: {
+ mlx: [
+ shippedRow('fastmetal_1_3b_qad', OLD_1_3B, 'FastVideo/FastMetal-1.3B-QAD'),
+ shippedRow('fastmetal_5b_qad', OLD_5B, 'FastVideo/FastMetal-5B-QAD'),
+ shippedRow('fastmetal_14b_qad', OLD_14B, 'FastVideo/FastMetal-14B-QAD'),
+ ],
+ cuda: [],
+ },
+});
+
+describe('336-fastmetal-download-size-names migration', () => {
+ let rootDir;
+ let registryFile;
+
+ beforeEach(() => {
+ rootDir = join(tmpdir(), `portos-test-336-${Date.now()}-${Math.random().toString(36).slice(2)}`);
+ mkdirSync(join(rootDir, 'data'), { recursive: true });
+ registryFile = join(rootDir, 'data', 'media-models.json');
+ });
+
+ afterEach(() => {
+ rmSync(rootDir, { recursive: true, force: true });
+ });
+
+ const write = (config) => writeFileSync(registryFile, JSON.stringify(config, null, 2));
+ const read = () => JSON.parse(readFileSync(registryFile, 'utf-8'));
+ const rowsById = () => Object.fromEntries(read().video.mlx.map((m) => [m.id, m]));
+
+ it('skips gracefully when media-models.json does not exist', async () => {
+ await expect(migration.up({ rootDir })).resolves.toBeUndefined();
+ });
+
+ it('rewrites all three shipped names onto the size the download actually pulls', async () => {
+ write(shippedRegistry());
+
+ await migration.up({ rootDir });
+
+ const rows = rowsById();
+ expect(rows.fastmetal_1_3b_qad.name).toBe('FastMetal 1.3B QAD (~13.4 GB download, 8+ GB RAM, 3-step)');
+ expect(rows.fastmetal_5b_qad.name).toBe('FastMetal 5B QAD (~19.5 GB download, 16+ GB RAM, 3-step)');
+ expect(rows.fastmetal_14b_qad.name).toBe('FastMetal 14B QAD (~27.1 GB download, 36+ GB RAM, 3-step)');
+ });
+
+ it('narrows only the 14B row off its duplicated ema/ DiT', async () => {
+ write(shippedRegistry());
+
+ await migration.up({ rootDir });
+
+ const rows = rowsById();
+ expect(rows.fastmetal_1_3b_qad.repoFiles).toBeUndefined();
+ expect(rows.fastmetal_5b_qad.repoFiles).toBeUndefined();
+ // The root DiT is in; the `ema/` copy of it — 14.14 GB the entry script
+ // never loads — is what the narrowing exists to drop.
+ expect(rows.fastmetal_14b_qad.repoFiles).toContain('mlx_dit.safetensors');
+ expect(rows.fastmetal_14b_qad.repoFiles.some((f) => f.startsWith('ema/'))).toBe(false);
+ // The pipeline's other components must survive the narrowing, or the
+ // download completes and the render fails on a missing text encoder.
+ expect(rows.fastmetal_14b_qad.repoFiles).toEqual(expect.arrayContaining([
+ 'model_index.json',
+ 'text_encoder/model-00005-of-00005.safetensors',
+ 'vae/diffusion_pytorch_model.safetensors',
+ ]));
+ });
+
+ it('repairs a stale persisted disclosure size from either shipped generation', async () => {
+ const config = shippedRegistry();
+ // Pre-#5860: the DiT-only figure. Post-#5860: the whole-snapshot figure the
+ // 14B row no longer pulls now that `ema/` is excluded.
+ config.video.mlx[0].disclosure = { estimatedDownloadGb: 3.5, reviewedAt: '2026-08-01' };
+ config.video.mlx[2].disclosure = { estimatedDownloadGb: 42.3, reviewedAt: '2026-09-02' };
+ write(config);
+
+ await migration.up({ rootDir });
+
+ const rows = rowsById();
+ expect(rows.fastmetal_1_3b_qad.disclosure.estimatedDownloadGb).toBe(13.4);
+ expect(rows.fastmetal_14b_qad.disclosure.estimatedDownloadGb).toBe(27.1);
+ // Everything else in the block is the user's/shipped provenance — untouched.
+ expect(rows.fastmetal_1_3b_qad.disclosure.reviewedAt).toBe('2026-08-01');
+ });
+
+ it('leaves a renamed, re-pointed, pre-narrowed or hand-estimated row alone', async () => {
+ write({
+ video: {
+ mlx: [
+ shippedRow('fastmetal_1_3b_qad', 'My tiny video model', 'FastVideo/FastMetal-1.3B-QAD'),
+ shippedRow('fastmetal_5b_qad', OLD_5B, 'example/FastMetal-5B-fork'),
+ shippedRow('fastmetal_14b_qad', OLD_14B, 'FastVideo/FastMetal-14B-QAD', {
+ repoFiles: ['mlx_dit.safetensors'],
+ disclosure: { estimatedDownloadGb: 31.2 },
+ }),
+ ],
+ cuda: [],
+ },
+ });
+
+ await migration.up({ rootDir });
+
+ const rows = rowsById();
+ expect(rows.fastmetal_1_3b_qad.name).toBe('My tiny video model');
+ // A forked repo gets nothing: PortOS has not measured what it pulls.
+ expect(rows.fastmetal_5b_qad.name).toBe(OLD_5B);
+ expect(rows.fastmetal_5b_qad.repoFiles).toBeUndefined();
+ // The name is still the untouched shipped string, so it IS corrected — but
+ // the owner's own narrowing and estimate are preserved.
+ expect(rows.fastmetal_14b_qad.name).toBe('FastMetal 14B QAD (~27.1 GB download, 36+ GB RAM, 3-step)');
+ expect(rows.fastmetal_14b_qad.repoFiles).toEqual(['mlx_dit.safetensors']);
+ expect(rows.fastmetal_14b_qad.disclosure.estimatedDownloadGb).toBe(31.2);
+ });
+
+ it('is idempotent — a second run rewrites nothing', async () => {
+ write(shippedRegistry());
+ await migration.up({ rootDir });
+ const afterFirst = readFileSync(registryFile, 'utf-8');
+
+ await migration.up({ rootDir });
+
+ expect(readFileSync(registryFile, 'utf-8')).toBe(afterFirst);
+ });
+
+ it('skips a row the user deleted without touching its siblings', async () => {
+ const config = shippedRegistry();
+ config.video.mlx = config.video.mlx.filter((m) => m.id !== 'fastmetal_5b_qad');
+ write(config);
+
+ await migration.up({ rootDir });
+
+ const rows = rowsById();
+ expect(rows.fastmetal_5b_qad).toBeUndefined();
+ expect(rows.fastmetal_14b_qad.name).toBe('FastMetal 14B QAD (~27.1 GB download, 36+ GB RAM, 3-step)');
+ });
+});
diff --git a/scripts/migrations/336-opencode-zen-providers.js b/scripts/migrations/336-opencode-zen-providers.js
new file mode 100644
index 0000000000..50f395005c
--- /dev/null
+++ b/scripts/migrations/336-opencode-zen-providers.js
@@ -0,0 +1,130 @@
+/**
+ * Ship disabled OpenCode Zen presets to existing installs.
+ *
+ * Every shipped OpenCode preset until now fronted something ELSE — a local
+ * Ollama/vLLM/SGLang daemon, or a hosted gateway (OrcaRouter, OpenRouter). None
+ * of them ran OpenCode on the models OpenCode itself ships with, so an install
+ * that had the CLI on PATH still had no provider that used it out of the box.
+ *
+ * These three close that gap:
+ * - `opencode-zen` — the OpenCode Zen HTTP API (an OpenAI-compatible
+ * endpoint), for direct PortOS calls.
+ * - `opencode-zen-cli` — headless `opencode run` on the harness's own catalog.
+ * - `opencode-zen-tui` — the same, driven through the TUI.
+ *
+ * The two wrappers deliberately carry NO `gatewayBacked` / `*Backed` marker:
+ * that absence is what tells `getOpencodeLocalProviderNamespace` there is no
+ * custom provider entry to declare, so OpenCode resolves `opencode/*` models
+ * through its own built-in provider and its own stored credential. It is also
+ * what makes them the targets of the Harnesses page's model refresh — see
+ * `usesHarnessCatalog` in `server/services/harnesses.js`.
+ *
+ * The seeded model list is OpenCode Zen's free tier, so a fresh install has
+ * something runnable before any key is stored; Models → Harnesses → Refresh
+ * models replaces it with whatever `opencode models` reports for the account
+ * actually signed in.
+ *
+ * Kept in lockstep with data.reference/providers.json and
+ * server/lib/aiToolkit/defaults/providers.sample.json. Later default changes
+ * require a new migration.
+ */
+
+import { makeProviderSeedMigration } from './_lib.js';
+
+// The API record's wire address. The CLI/TUI wrappers deliberately carry NO
+// `endpoint`, matching every other harness-native record (`grok-cli`, `codex`,
+// `cursor-cli`, `claude-code`): they declare no OpenCode provider entry, so
+// nothing would read one, and a field that looks load-bearing and isn't is
+// worse than an absent one. Only a gateway-backed wrapper mirrors its
+// gateway's `baseURL` there.
+const ZEN_ENDPOINT = 'https://opencode.ai/zen/v1';
+
+// The unattended posture every seeded OpenCode record declares. It is the WHOLE
+// config for these two: with no namespace there is no provider entry to declare
+// and no key to inject, and `buildOpencodeEnvVars` preserves this as the base
+// while pinning `small_model` to the model the run was dispatched with.
+const OPENCODE_CONFIG_CONTENT = '{"permission":"allow"}';
+
+// The harness's own namespaced spelling — exactly what `opencode models` prints
+// and what `opencode --model` takes. These records declare no OpenCode provider
+// entry, so nothing prefixes the id at spawn time and it must be stored whole.
+const CLI_MODELS = [
+ 'opencode/big-pickle',
+ 'opencode/ling-3.0-flash-fin-free',
+ 'opencode/mimo-v2.5-free',
+ 'opencode/muse-spark-1.2-contributor-free',
+ 'opencode/muse-spark-1.3-contributor-free',
+ 'opencode/nemotron-3-ultra-free',
+ 'opencode/nemotron-3.5-lightning-free',
+];
+const CLI_DEFAULT = 'opencode/big-pickle';
+
+// The HTTP API takes BARE ids — there is no OpenCode namespace on the wire.
+const API_MODELS = [
+ 'claude-opus-5',
+ 'claude-sonnet-5',
+ 'claude-haiku-4-5',
+ 'gpt-5.6-sol',
+ 'gpt-5.5',
+ 'grok-4.6',
+ 'kimi-k3',
+ 'qwen3.6-plus',
+ 'big-pickle',
+ 'deepseek-v4-flash-free',
+];
+
+const OPENCODE_ZEN_API = {
+ id: 'opencode-zen',
+ name: 'OpenCode Zen',
+ type: 'api',
+ endpoint: ZEN_ENDPOINT,
+ apiKey: '',
+ models: API_MODELS,
+ defaultModel: 'claude-sonnet-5',
+ lightModel: 'claude-haiku-4-5',
+ mediumModel: 'claude-sonnet-5',
+ heavyModel: 'claude-opus-5',
+ timeout: 300000,
+ enabled: false,
+ envVars: {},
+ secretEnvVars: [],
+};
+
+const OPENCODE_ZEN_CLI = {
+ id: 'opencode-zen-cli',
+ name: 'OpenCode Zen CLI',
+ type: 'cli',
+ command: 'opencode',
+ args: ['run'],
+ models: CLI_MODELS,
+ defaultModel: CLI_DEFAULT,
+ timeout: 600000,
+ enabled: false,
+ envVars: { OPENCODE_CONFIG_CONTENT },
+ // Named, not stored: OpenCode holds its own Zen credential after
+ // `opencode auth login`, and an install that would rather hand it over
+ // explicitly fills this in from the provider editor.
+ secretEnvVars: ['OPENCODE_API_KEY'],
+ headlessArgs: [],
+};
+
+const OPENCODE_ZEN_TUI = {
+ id: 'opencode-zen-tui',
+ name: 'OpenCode Zen TUI',
+ type: 'tui',
+ command: 'opencode',
+ args: [],
+ models: CLI_MODELS,
+ defaultModel: CLI_DEFAULT,
+ timeout: 600000,
+ enabled: false,
+ envVars: { OPENCODE_CONFIG_CONTENT },
+ secretEnvVars: ['OPENCODE_API_KEY'],
+ tuiPromptDelayMs: 2500,
+ tuiIdleTimeoutMs: 180000,
+};
+
+export default makeProviderSeedMigration({
+ label: 'OpenCode Zen',
+ defs: [OPENCODE_ZEN_API, OPENCODE_ZEN_CLI, OPENCODE_ZEN_TUI],
+});
diff --git a/scripts/migrations/336-opencode-zen-providers.test.js b/scripts/migrations/336-opencode-zen-providers.test.js
new file mode 100644
index 0000000000..c4e4e50de6
--- /dev/null
+++ b/scripts/migrations/336-opencode-zen-providers.test.js
@@ -0,0 +1,95 @@
+import { describe, it, expect, beforeEach, afterEach } from 'vitest';
+import { mkdtempSync, rmSync, writeFileSync, readFileSync, mkdirSync } from 'fs';
+import { tmpdir } from 'os';
+import { join } from 'path';
+
+import migration from './336-opencode-zen-providers.js';
+
+const writeJson = (path, value) => writeFileSync(path, JSON.stringify(value, null, 2) + '\n');
+const readJson = (path) => JSON.parse(readFileSync(path, 'utf-8'));
+
+describe('migration 336 — OpenCode Zen providers', () => {
+ let rootDir;
+ let providersPath;
+
+ beforeEach(() => {
+ rootDir = mkdtempSync(join(tmpdir(), 'migration-336-'));
+ mkdirSync(join(rootDir, 'data'), { recursive: true });
+ providersPath = join(rootDir, 'data/providers.json');
+ });
+
+ afterEach(() => rmSync(rootDir, { recursive: true, force: true }));
+
+ it('adds the three disabled presets without changing the active provider', async () => {
+ writeJson(providersPath, {
+ activeProvider: 'claude-code',
+ providers: { 'claude-code': { id: 'claude-code', type: 'cli', command: 'claude' } },
+ });
+
+ await migration.up({ rootDir });
+ const out = readJson(providersPath);
+
+ expect(out.providers['opencode-zen']).toMatchObject({
+ type: 'api',
+ endpoint: 'https://opencode.ai/zen/v1',
+ apiKey: '',
+ enabled: false,
+ });
+ expect(out.providers['opencode-zen-cli']).toMatchObject({ type: 'cli', command: 'opencode', enabled: false });
+ expect(out.providers['opencode-zen-tui']).toMatchObject({ type: 'tui', command: 'opencode', enabled: false });
+ expect(out.activeProvider).toBe('claude-code');
+ });
+
+ it('carries no backend marker, so the harness catalog owns these records', async () => {
+ // The absence is load-bearing: a `*Backed` / `gatewayBacked` marker would
+ // make OpenCode declare a custom provider entry and would exclude the
+ // record from the Harnesses page's model refresh.
+ writeJson(providersPath, { providers: {} });
+ await migration.up({ rootDir });
+ const out = readJson(providersPath);
+
+ for (const id of ['opencode-zen-cli', 'opencode-zen-tui']) {
+ const record = out.providers[id];
+ expect(record.gatewayBacked).toBeUndefined();
+ expect(record.ollamaBacked).toBeUndefined();
+ expect(record.orcarouterBacked).toBeUndefined();
+ // No `endpoint` either: these declare no OpenCode provider entry, so
+ // nothing would read one — matching every other harness-native record.
+ expect(record.endpoint).toBeUndefined();
+ // The only thing the config declares is the unattended posture — no
+ // provider entry, no key.
+ expect(JSON.parse(record.envVars.OPENCODE_CONFIG_CONTENT)).toEqual({ permission: 'allow' });
+ }
+ });
+
+ it('stores CLI models namespaced and API models bare', async () => {
+ writeJson(providersPath, { providers: {} });
+ await migration.up({ rootDir });
+ const out = readJson(providersPath);
+
+ // Nothing prefixes the id at spawn time for a namespace-less record, so the
+ // stored spelling has to be the one `opencode --model` accepts.
+ for (const id of out.providers['opencode-zen-cli'].models) expect(id.startsWith('opencode/')).toBe(true);
+ expect(out.providers['opencode-zen-cli'].defaultModel).toBe('opencode/big-pickle');
+ // The HTTP endpoint has no namespace on the wire.
+ for (const id of out.providers['opencode-zen'].models) expect(id).not.toContain('/');
+ expect(out.providers['opencode-zen'].models).toContain(out.providers['opencode-zen'].defaultModel);
+ });
+
+ it('preserves an existing OpenCode Zen record and its key', async () => {
+ const existing = { id: 'opencode-zen', name: 'My Zen', type: 'api', apiKey: 'sk-zen-example', enabled: true };
+ writeJson(providersPath, { providers: { 'opencode-zen': existing } });
+
+ await migration.up({ rootDir });
+ expect(readJson(providersPath).providers['opencode-zen']).toEqual(existing);
+ });
+
+ it('is a no-op on re-run', async () => {
+ writeJson(providersPath, { activeProvider: 'claude-code', providers: {} });
+
+ await migration.up({ rootDir });
+ const first = readFileSync(providersPath, 'utf-8');
+ await migration.up({ rootDir });
+ expect(readFileSync(providersPath, 'utf-8')).toBe(first);
+ });
+});
diff --git a/scripts/repo-scan-guards.test.js b/scripts/repo-scan-guards.test.js
index 30f70375ad..6e7d9c7e17 100644
--- a/scripts/repo-scan-guards.test.js
+++ b/scripts/repo-scan-guards.test.js
@@ -38,8 +38,14 @@ const STRUCTURALLY_SELECTED = new Map([
// client/src/**.jsx (a11y) or .js/.jsx (mounted-ref) file changes, which is
// the only way either can start failing.
['client/src/a11yConventions.test.js', 'structuralTestsFor: client/src/**.jsx'],
+ ['client/src/globalShadowConventions.test.js', 'structuralTestsFor: client/src/**.js(x)'],
+ ['client/src/headingTruncationConventions.test.js', 'structuralTestsFor: client/src/**.js(x)'],
['client/src/hooks/mountedRefConventions.test.js', 'structuralTestsFor: client/src/**.js(x)'],
+ ['client/src/pollingConventions.test.js', 'structuralTestsFor: client/src/**.js(x)'],
+ ['client/src/popoverClampConventions.test.js', 'structuralTestsFor: client/src/**.js(x)'],
+ ['client/src/preWrapClasses.test.js', 'structuralTestsFor: client/src/**.js(x)'],
['client/src/responsiveGridConventions.test.js', 'structuralTestsFor: client/src/**.js(x)'],
+ ['client/src/storageConventions.test.js', 'structuralTestsFor: client/src/**.js(x)'],
// `.ps1` is not in EXECUTABLE_RE, so touching one is an "unclassified changed
// file" and forces the complete suite. The guard also rides the Windows
// contract list.
diff --git a/scripts/run-ci-tests.js b/scripts/run-ci-tests.js
index 9fb4c03921..2a47fb19fe 100644
--- a/scripts/run-ci-tests.js
+++ b/scripts/run-ci-tests.js
@@ -25,6 +25,18 @@ export function relatedInputs(sourceFiles, selectedFiles) {
return [...new Set([...sourceFiles, ...selectedFiles])];
}
+/**
+ * Vitest selector flags for this runner's slice of a full suite. `CI_SHARD` is
+ * `/` from the job matrix (ci.yml). A single shard passes nothing,
+ * so the one-runner invocation stays identical to a local `npm run test:ci`.
+ */
+export function shardArgs(shard) {
+ if (!shard) return [];
+ const match = /^(\d+)\/(\d+)$/.exec(shard);
+ if (!match) throw new Error(`CI_SHARD must look like /, got "${shard}"`);
+ return match[2] === '1' ? [] : [`--shard=${shard}`];
+}
+
export function recordVitestDuration(scope, label, startedAt) {
const seconds = ((Date.now() - startedAt) / 1000).toFixed(1);
const line = `⏱ ${scope} ${label}: ${seconds}s`;
@@ -80,7 +92,9 @@ function main() {
}
if (mode === 'full') {
- process.exit(spawnNpm(scope, 'test:ci', [], 'full suite'));
+ const shard = shardArgs(process.env.CI_SHARD);
+ const label = shard.length ? `full suite shard ${process.env.CI_SHARD}` : 'full suite';
+ process.exit(spawnNpm(scope, 'test:ci', shard, label));
}
if (mode === 'files') {
diff --git a/scripts/run-ci-tests.test.js b/scripts/run-ci-tests.test.js
index 7aea3d3bf1..0524cce753 100644
--- a/scripts/run-ci-tests.test.js
+++ b/scripts/run-ci-tests.test.js
@@ -7,8 +7,51 @@ import {
recordVitestDuration,
relatedInputs,
requiresSourceFiles,
+ shardArgs,
toRunnerPath,
} from './run-ci-tests.js';
+import { workflowJobs } from './lib/workflowJobs.js';
+
+const WORKFLOW = readFileSync(join(import.meta.dirname, '..', '.github', 'workflows', 'ci.yml'), 'utf8');
+
+describe('shardArgs', () => {
+ it('passes a slice selector only when the matrix actually split the suite', () => {
+ expect(shardArgs(undefined)).toEqual([]);
+ expect(shardArgs('')).toEqual([]);
+ expect(shardArgs('1/1')).toEqual([]);
+ expect(shardArgs('2/3')).toEqual(['--shard=2/3']);
+ expect(() => shardArgs('2')).toThrow(/\//);
+ });
+});
+
+describe('ci.yml shard wiring', () => {
+ const runners = Object.entries(workflowJobs(WORKFLOW)).filter(([, body]) => body.includes('run-ci-tests.js'));
+
+ it('builds every test runner matrix from the planner and hands each slice to the runner', () => {
+ expect(runners.map(([id]) => id).sort()).toEqual(['client', 'server', 'windows-server']);
+ for (const [id, body] of runners) {
+ // The fan-out is decided by the impact job, never hardcoded here: a
+ // scoped plan must collapse to one runner, and a job-level `if` cannot
+ // read `matrix` to skip the extra shards itself.
+ expect(body, id).toMatch(/shard: \$\{\{ fromJSON\(needs\.impact\.outputs\.\w+_shards\) \}\}/);
+ expect(body, id).toContain('CI_SHARD: ${{ matrix.shard }}/${{ strategy.job-total }}');
+ // Shards race to save one immutable cache entry; without the shard in
+ // the key the winner's 1/n of the transform artifacts is all that persists.
+ expect(body, id).toMatch(/key: vitest-\w+-\$\{\{ runner\.os \}\}-\$\{\{ hashFiles\([^)]*\) \}\}-\$\{\{ matrix\.shard \}\}of\$\{\{ strategy\.job-total \}\}/);
+ }
+ });
+
+ it('runs once-only steps on the first shard alone', () => {
+ // Smoke boot, lint, the production build, and the bundle budget are not
+ // sharded work; on every shard they would triple the cost for no coverage.
+ for (const step of ['Smoke-boot server', 'Lint client', 'Build client', 'Check API Explorer bundle budget']) {
+ const start = WORKFLOW.indexOf(`- name: ${step}\n`);
+ expect(start, step).toBeGreaterThan(0);
+ const condition = WORKFLOW.slice(start).match(/\n {8}if: (.*)\n/)[1];
+ expect(condition, step).toMatch(/&& matrix\.shard == 1$/);
+ }
+ });
+});
describe('toRunnerPath', () => {
it('maps repo paths onto each workspace runner root', () => {
diff --git a/scripts/run_prompt_guard.py b/scripts/run_prompt_guard.py
index 04b7185111..14d6f6f6e1 100644
--- a/scripts/run_prompt_guard.py
+++ b/scripts/run_prompt_guard.py
@@ -76,8 +76,10 @@ def main() -> int:
return_attention_mask=True,
return_tensors="pt",
)
+ # prepare_for_model returns unbatched [seq] tensors; the encoder
+ # indexes input_shape[1], so add the batch axis it expects.
model_inputs = {
- key: value
+ key: value.unsqueeze(0) if value.dim() == 1 else value
for key, value in encoded.items()
if key in {"input_ids", "attention_mask", "token_type_ids"}
}
diff --git a/scripts/update-headless-recovery.test.js b/scripts/update-headless-recovery.test.js
new file mode 100644
index 0000000000..e8dfdd69fc
--- /dev/null
+++ b/scripts/update-headless-recovery.test.js
@@ -0,0 +1,302 @@
+import { describe, it, expect, beforeAll, afterAll } from 'vitest';
+import { spawn } from 'child_process';
+import { mkdirSync, writeFileSync, copyFileSync, readFileSync, existsSync, chmodSync, rmSync } from 'fs';
+import { join, dirname } from 'path';
+import { fileURLToPath } from 'url';
+import { makeGitSandbox, destroyGitSandbox, SKIP_HEAVY_INTEGRATION } from '../server/lib/gitTestRepo.js';
+
+const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
+const UPDATE_SH = join(REPO_ROOT, 'update.sh');
+
+// Everything update.sh shells out to between the pm2 delete and the pm2 start.
+// Stubbing them lets the success path run end to end offline, which is the only
+// way to prove the exit trap does NOT start the apps a second time.
+const STUB_SCRIPTS = [
+ 'setup-data.js', 'setup-db.js', 'setup-browser.js', 'setup-ghostty.js',
+ 'setup-cert.js', 'setup-guide.js', 'run-migrations.js',
+ 'verify-server-health.js', 'print-access-url.js', 'open-ui-in-browser.js'
+];
+
+/**
+ * A throwaway checkout of update.sh with `npm`, `npx` and `pm2` shimmed, so the
+ * assertions below are what the script actually does to PM2 rather than a grep
+ * for the guard's source text. The rationale for the guard itself lives with it
+ * in update.sh.
+ *
+ * @param {{origin?: boolean, failAfterDelete?: boolean, npmShim?: string,
+ * forceClean?: boolean, pm2OnPath?: boolean, healthy?: boolean}} options
+ * origin:false leaves the checkout with no upstream, so the git-pull step
+ * fails before anything is deleted. failAfterDelete fails the first step AFTER
+ * the pm2 delete that does not also wipe node_modules. npmShim picks whether
+ * `npm`/`npx` succeed ('ok'), fail ('fail' — which makes safe_install wipe
+ * node_modules first) or stall ('slow', so a signal can arrive mid-window).
+ * forceClean makes safe_install wipe root node_modules regardless of the diff,
+ * pm2OnPath supplies a fallback pm2 for when that wipe removes the local one,
+ * and healthy:false makes the health probe report the server never came back.
+ */
+async function makeSandbox({
+ origin = true, failAfterDelete = true, npmShim = 'ok',
+ forceClean = false, pm2OnPath = false, healthy = true, stallDelete = false
+} = {}) {
+ const { scratch, repo } = await makeGitSandbox({ origin, prefix: 'portos-update-guard-' });
+ const bin = join(scratch, 'bin');
+ const calls = join(scratch, 'pm2-calls.log');
+ const releaseFile = join(scratch, 'pm2-delete-stalled');
+
+ mkdirSync(bin, { recursive: true });
+ mkdirSync(join(repo, 'scripts'), { recursive: true });
+ mkdirSync(join(repo, 'node_modules', 'pm2', 'bin'), { recursive: true });
+
+ copyFileSync(UPDATE_SH, join(repo, 'update.sh'));
+ chmodSync(join(repo, 'update.sh'), 0o755);
+ writeFileSync(join(repo, 'package.json'), JSON.stringify({ name: 'sandbox', version: '0.0.0' }));
+ writeFileSync(join(repo, 'ecosystem.config.cjs'), 'module.exports = { apps: [] };\n');
+
+ // Records every pm2 invocation the script makes, in order. When stallDelete is
+ // set the `delete` call logs and then BLOCKS until the test deletes the release
+ // file, so a signal can be delivered while the delete is still running — the
+ // window a latch armed after the delete would miss.
+ writeFileSync(join(repo, 'node_modules', 'pm2', 'package.json'), JSON.stringify({ name: 'pm2', version: '0.0.0' }));
+ writeFileSync(
+ join(repo, 'node_modules', 'pm2', 'bin', 'pm2'),
+ `const fs = require('fs');
+const args = process.argv.slice(2).join(' ');
+fs.appendFileSync(${JSON.stringify(calls)}, args + '\\n');
+if (${stallDelete} && args.startsWith('delete')) {
+ const release = ${JSON.stringify(releaseFile)};
+ fs.writeFileSync(release, 'stalled');
+ while (fs.existsSync(release)) { try { require('child_process').execFileSync('sleep', ['0.05']); } catch { break; } }
+}
+`
+ );
+
+ writeFileSync(join(repo, 'scripts', 'trusted-rebuilds.js'), `process.exit(${failAfterDelete ? 1 : 0});\n`);
+ for (const stub of STUB_SCRIPTS) {
+ writeFileSync(join(repo, 'scripts', stub), 'process.exit(0);\n');
+ }
+ writeFileSync(join(repo, 'scripts', 'verify-server-health.js'), `process.exit(${healthy ? 0 : 1});\n`);
+ // Non-zero means "the daemon is already ours", which skips the co-located
+ // `pm2 update` restart — the branch a healthy install takes.
+ writeFileSync(join(repo, 'scripts', 'pm2-daemon-refresh.js'), 'process.exit(1);\n');
+
+ // The workspaces safe_install cd's into, and the dependency it sanity-checks.
+ for (const ws of ['client', 'server', 'autofixer']) {
+ mkdirSync(join(repo, ws), { recursive: true });
+ writeFileSync(join(repo, ws, 'package.json'), JSON.stringify({ name: ws, version: '0.0.0' }));
+ }
+ mkdirSync(join(repo, 'client', 'node_modules', 'vite', 'bin'), { recursive: true });
+ writeFileSync(join(repo, 'client', 'node_modules', 'vite', 'bin', 'vite.js'), '');
+
+ // update.sh only ever calls these as bare commands, so a PATH shim covers
+ // every install and the slash-do refresh without touching the network.
+ const npmBody = { ok: 'exit 0', fail: 'exit 1', slow: 'sleep 2\nexit 0' }[npmShim];
+ for (const shim of ['npm', 'npx']) {
+ writeFileSync(join(bin, shim), `#!/bin/sh\n${npmBody}\n`);
+ chmodSync(join(bin, shim), 0o755);
+ }
+ // The success case is the only one that runs past trusted-rebuilds, so it
+ // reaches update.sh's ffmpeg step — which on a machine without ffmpeg would
+ // run a real `brew install`, or on Linux CI a passwordless `sudo apt-get
+ // install` that mutates the runner. update.sh only probes `command -v`.
+ writeFileSync(join(bin, 'ffmpeg'), '#!/bin/sh\nexit 0\n');
+ chmodSync(join(bin, 'ffmpeg'), 0o755);
+ // A pm2 the recovery can still reach after safe_install wipes the local one.
+ if (pm2OnPath) {
+ writeFileSync(join(bin, 'pm2'), `#!/bin/sh\nprintf '%s\\n' "$*" >> ${JSON.stringify(calls)}\n`);
+ chmodSync(join(bin, 'pm2'), 0o755);
+ }
+
+ return { scratch, repo, bin, calls, forceClean, releaseFile };
+}
+
+// The three runs share no state, so they go out concurrently rather than
+// serializing three full update scripts.
+function runUpdate(sandbox, { onStart } = {}) {
+ return new Promise((resolve) => {
+ const env = { ...process.env, PATH: `${sandbox.bin}:${process.env.PATH}` };
+ if (sandbox.forceClean) env.PORTOS_FORCE_CLEAN_WORKSPACES = '.';
+ const child = spawn('bash', [join(sandbox.repo, 'update.sh')], { cwd: sandbox.repo, env });
+ let stdout = '';
+ child.stdout.on('data', (d) => { stdout += d; });
+ child.stderr.on('data', () => {});
+ child.on('close', (status) => resolve({ status, stdout }));
+ onStart?.(child);
+ });
+}
+
+// Resolves once a sentinel appears, so a test can act at a precise point in the
+// window instead of guessing at a delay.
+function waitForFile(path, timeoutMs = 30000) {
+ const deadline = Date.now() + timeoutMs;
+ return new Promise((resolve, reject) => {
+ const poll = () => {
+ if (existsSync(path)) return resolve();
+ if (Date.now() > deadline) return reject(new Error(`timed out waiting for ${path}`));
+ setTimeout(poll, 25);
+ };
+ poll();
+ });
+}
+
+const pm2Calls = (sandbox) =>
+ (existsSync(sandbox.calls) ? readFileSync(sandbox.calls, 'utf8') : '').split('\n').filter(Boolean);
+
+describe.skipIf(process.platform === 'win32' || SKIP_HEAVY_INTEGRATION)('update.sh headless-install guard', () => {
+ const sandboxes = {};
+ const results = {};
+
+ beforeAll(async () => {
+ const cases = {
+ failed: {},
+ clean: { failAfterDelete: false },
+ preDelete: { origin: false },
+ unhealthy: { healthy: false },
+ // The failure the guard's own comment names: both npm installs fail, and
+ // safe_install wiped root node_modules — pm2 included — on the way.
+ pm2Wiped: { npmShim: 'fail', forceClean: true, pm2OnPath: true }
+ };
+ await Promise.all(Object.entries(cases).map(async ([name, options]) => {
+ sandboxes[name] = await makeSandbox(options);
+ }));
+ await Promise.all(Object.keys(cases).map(async (name) => {
+ results[name] = await runUpdate(sandboxes[name]);
+ }));
+ }, 180000);
+
+ afterAll(async () => {
+ await Promise.all(Object.values(sandboxes).map(box => destroyGitSandbox(box.scratch)));
+ });
+
+ it('restarts the PM2 apps it deleted when a later step aborts the update', () => {
+ const calls = pm2Calls(sandboxes.failed);
+ const deleteAt = calls.findIndex(c => c.startsWith('delete ecosystem.config.cjs'));
+ const startAt = calls.findIndex(c => c.startsWith('start ecosystem.config.cjs'));
+ expect(deleteAt, `pm2 calls were: ${JSON.stringify(calls)}`).toBeGreaterThan(-1);
+ expect(startAt, `pm2 calls were: ${JSON.stringify(calls)}`).toBeGreaterThan(deleteAt);
+ });
+
+ it('still reports the update as failed after recovering', () => {
+ expect(results.failed.status).not.toBe(0);
+ expect(results.failed.stdout).toContain('STEP:restart:warning:');
+ });
+
+ it('starts the apps exactly once on an update that succeeds', () => {
+ expect(results.clean.status, results.clean.stdout).toBe(0);
+ expect(pm2Calls(sandboxes.clean).filter(c => c.startsWith('start ecosystem.config.cjs'))).toHaveLength(1);
+ expect(results.clean.stdout).toContain('STEP:restart:done:');
+ expect(results.clean.stdout).not.toContain('STEP:restart:warning:');
+ });
+
+ it('does not claim a recovery the health probe never confirmed', () => {
+ expect(results.unhealthy.status).not.toBe(0);
+ expect(results.unhealthy.stdout).toContain('STEP:restart:error:');
+ expect(results.unhealthy.stdout).not.toContain('STEP:restart:warning:');
+ });
+
+ it('restarts through a pm2 the failed install did not delete', () => {
+ // safe_install wipes root node_modules (pm2 is a ROOT dependency) before it
+ // retries, so a recovery hardcoded to ./node_modules/pm2/bin/pm2 would be a
+ // no-op on the most likely failure of all.
+ const calls = pm2Calls(sandboxes.pm2Wiped);
+ const deleteAt = calls.findIndex(c => c.startsWith('delete ecosystem.config.cjs'));
+ const startAt = calls.findIndex(c => c.startsWith('start ecosystem.config.cjs'));
+ expect(deleteAt, `pm2 calls were: ${JSON.stringify(calls)}`).toBeGreaterThan(-1);
+ expect(startAt, `pm2 calls were: ${JSON.stringify(calls)}`).toBeGreaterThan(deleteAt);
+ expect(results.pm2Wiped.status).not.toBe(0);
+ });
+
+ it('restarts the apps when the update is killed during the delete itself', async () => {
+ // Two guards at once: without the TERM/INT/HUP traps bash runs NO exit trap
+ // for a fatal signal, and with the latch armed AFTER the delete the trap
+ // would see it unset — bash only runs a pending trap between statements, so
+ // a signal delivered while `pm2 delete` is still running lands here.
+ const box = await makeSandbox({ stallDelete: true });
+ try {
+ const result = await runUpdate(box, {
+ onStart: (child) => {
+ waitForFile(box.releaseFile)
+ .then(() => {
+ child.kill('SIGTERM');
+ rmSync(box.releaseFile, { force: true });
+ })
+ .catch(() => child.kill('SIGKILL'));
+ }
+ });
+ const calls = pm2Calls(box);
+ const deleteAt = calls.findIndex(c => c.startsWith('delete ecosystem.config.cjs'));
+ const startAt = calls.findIndex(c => c.startsWith('start ecosystem.config.cjs'));
+ expect(startAt, `pm2 calls were: ${JSON.stringify(calls)}`).toBeGreaterThan(deleteAt);
+ expect(result.status).toBe(143);
+ } finally {
+ await destroyGitSandbox(box.scratch);
+ }
+ }, 120000);
+
+ it('does not touch PM2 when the update aborts before the delete', () => {
+ expect(results.preDelete.status).not.toBe(0);
+ expect(pm2Calls(sandboxes.preDelete)).toEqual([]);
+ });
+});
+
+/**
+ * update.ps1 is the Windows half of the same bracket and cannot be executed
+ * here, so guard the invariant that makes its recovery reachable: PowerShell's
+ * `exit` inside a function terminates the whole script WITHOUT raising a
+ * terminating error, bypassing both Stop-UpdateScript and the script-scope trap.
+ * So every fatal exit in the file must route through Stop-UpdateScript. Scanning
+ * the whole file rather than the delete→start line window is the point: the
+ * exit that actually caused this bug lives in Safe-Install, which is DEFINED
+ * above the delete and CALLED below it.
+ */
+describe('update.ps1 headless-install guard', () => {
+ const ps1 = readFileSync(join(REPO_ROOT, 'update.ps1'), 'utf8').split('\n');
+ const lineOf = (needle) => ps1.findIndex(line => line.includes(needle));
+
+ // The only two script-terminating exits that may bypass the recovery: the one
+ // Stop-UpdateScript itself performs (after running it), and the final status.
+ const SANCTIONED_EXITS = ['exit $Code', 'exit $verifyFailed'];
+
+ it('routes every fatal exit through the recovery', () => {
+ const exits = ps1
+ .map((line, i) => ({ line: line.trim(), number: i + 1 }))
+ .filter(({ line }) => /(^|[{;]\s*)exit\b/i.test(line) || /\[Environment\]::Exit\(/i.test(line));
+
+ const offenders = exits.filter(({ line }) => !SANCTIONED_EXITS.includes(line));
+ expect(offenders, `exit(s) bypassing Restore-Pm2Apps: ${JSON.stringify(offenders)}`).toEqual([]);
+ // ...and both sanctioned exits must still be there, so the guard can't be
+ // "satisfied" by deleting the exit inside Stop-UpdateScript.
+ expect([...new Set(exits.map(e => e.line))].sort()).toEqual([...SANCTIONED_EXITS].sort());
+ });
+
+ it('keeps every Resolve-Pm2Command branch a plain array collected by @()', () => {
+ // `return` ENUMERATES an array into the output stream, so a single-element
+ // branch arrives as a bare string and `$pm2 + @('start', …)` concatenates
+ // into one garbage token — the PATH fallback would never start pm2. The
+ // call-site @() fixes that, but only if every branch returns a PLAIN array:
+ // a `,@(…)` wrapper emits the array as ONE stream item and @() nests rather
+ // than flattens it, so $CmdArgs[0] is an object[] instead of the executable.
+ expect(ps1.some(line => line.includes('$pm2 = @(Resolve-Pm2Command)'))).toBe(true);
+
+ const body = ps1.slice(lineOf('function Resolve-Pm2Command'), lineOf('function Restore-Pm2Apps'));
+ const returns = body.map(line => line.trim()).filter(line => line.startsWith('return'));
+ expect(returns.length).toBeGreaterThan(2);
+ expect(returns.filter(line => /return\s*,/.test(line)), 'a ,@() return nests instead of flattening').toEqual([]);
+ expect(returns.every(line => /^return\s+@\(/.test(line))).toBe(true);
+ });
+
+ it('arms the latch before the delete, not after', () => {
+ const armed = lineOf('$script:Pm2AppsDown = $true');
+ const deleted = lineOf('pm2 delete ecosystem.config.cjs --silent');
+ expect(armed).toBeGreaterThan(-1);
+ expect(armed).toBeLessThan(deleted);
+ });
+
+ it('defines the recovery before every call site that depends on it', () => {
+ const definedAt = lineOf('function Stop-UpdateScript');
+ expect(definedAt).toBeGreaterThan(-1);
+ const firstCall = ps1.findIndex(line => line.includes('Stop-UpdateScript ') && !line.includes('function '));
+ expect(firstCall).toBeGreaterThan(definedAt);
+ expect(lineOf('function Restore-Pm2Apps')).toBeLessThan(lineOf('$script:Pm2AppsDown = $true'));
+ expect(lineOf('trap {')).toBeLessThan(lineOf('$script:Pm2AppsDown = $true'));
+ });
+});
diff --git a/scripts/verify-server-health.js b/scripts/verify-server-health.js
new file mode 100644
index 0000000000..f9c2b79c84
--- /dev/null
+++ b/scripts/verify-server-health.js
@@ -0,0 +1,167 @@
+#!/usr/bin/env node
+/**
+ * "Did portos-server actually come back after the update restarted it?"
+ *
+ * `update.sh` / `update.ps1` delete every PortOS PM2 entry and start it again.
+ * When that bracket half-fails, the install is left headless — and nothing else
+ * on the machine notices, because the thing that would have noticed is the
+ * server that did not come back (#5976: a no-op update left the install down
+ * for hours). The update script is the last PortOS process still running at
+ * that point, so the check has to live here.
+ *
+ * Usage as a CLI (what update.sh and update.ps1 call):
+ * node scripts/verify-server-health.js
+ *
+ * Exit 0 → the server answered /api/system/health with status "ok".
+ * Exit 1 → it did not, within the budget. The caller re-runs `pm2 start`.
+ *
+ * Fails CLOSED, unlike `pm2-daemon-refresh.js`: an unreachable server is
+ * exactly the condition being detected, so anything short of a positive "ok"
+ * is reported as unhealthy. The recovery it triggers is one extra `pm2 start`,
+ * which cannot make an already-healthy install worse.
+ *
+ * `/api/system/health` is in the always-public set (`PUBLIC_API_PATHS`), so
+ * this works with the optional instance password on. All three candidate URLs
+ * are probed because the listening scheme/port depends on whether a cert is
+ * provisioned: HTTPS on :5555 plus the loopback HTTP mirror on :5553, or plain
+ * HTTP on :5555. Probing beats re-deriving the cert state — the answer we want
+ * is "is something serving", not "which URL should we advertise".
+ */
+
+import http from 'node:http';
+import https from 'node:https';
+import { PORTS } from '../server/lib/ports.js';
+import { isDirectlyInvoked } from './lib/directInvocation.js';
+
+const HEALTH_PATH = '/api/system/health';
+const DEFAULT_TIMEOUT_MS = 120_000;
+const DEFAULT_INTERVAL_MS = 2_000;
+const PROBE_TIMEOUT_MS = 5_000;
+
+/**
+ * Read a non-negative millisecond budget from the environment. `|| DEFAULT`
+ * would be wrong here: it collapses "unset" and "not a number" together with a
+ * deliberate `0` (fail fast, one pass and out), which `waitForHealthy`
+ * explicitly supports.
+ *
+ * @param {string|undefined} raw
+ * @param {number} fallback
+ * @returns {number}
+ */
+export function parseTimeoutMs(raw, fallback = DEFAULT_TIMEOUT_MS) {
+ if (raw === undefined || raw === null || String(raw).trim() === '') return fallback;
+ const parsed = Number(raw);
+ return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
+}
+
+/**
+ * The loopback URLs a healthy PortOS could be answering on, in the order worth
+ * trying: the plain-HTTP mirror first (always cert-free), then the API port
+ * over each scheme. Deduped so a plain-HTTP install (mirror port unbound,
+ * API port serving HTTP) does not probe the same URL twice.
+ *
+ * @param {{apiPort: number, mirrorPort: number}} ports
+ * @returns {string[]}
+ */
+export function healthProbeUrls({ apiPort, mirrorPort }) {
+ const urls = [
+ `http://127.0.0.1:${mirrorPort}${HEALTH_PATH}`,
+ `http://127.0.0.1:${apiPort}${HEALTH_PATH}`,
+ `https://127.0.0.1:${apiPort}${HEALTH_PATH}`,
+ ];
+ return [...new Set(urls)];
+}
+
+/**
+ * One request. Resolves true only on a 200 whose JSON body says status "ok" —
+ * a 502 from something else on the port, a hung socket, or a half-booted
+ * server that answers but not with "ok" all count as not-yet-healthy.
+ *
+ * `rejectUnauthorized: false` matches the rest of PortOS's loopback probing:
+ * the cert is issued for the Tailscale hostname, so 127.0.0.1 never validates,
+ * and there is no trust boundary to cross on loopback.
+ *
+ * @param {string} url
+ * @param {number} timeoutMs
+ * @returns {Promise}
+ */
+export function probeHealth(url, timeoutMs = PROBE_TIMEOUT_MS) {
+ return new Promise((resolve) => {
+ const transport = url.startsWith('https:') ? https : http;
+ const req = transport.get(url, { timeout: timeoutMs, rejectUnauthorized: false }, (res) => {
+ if (res.statusCode !== 200) {
+ res.resume();
+ resolve(false);
+ return;
+ }
+ let body = '';
+ res.setEncoding('utf8');
+ res.on('data', (chunk) => { body += chunk; });
+ res.on('end', () => {
+ // A response that is not the health payload — a proxy error page, a
+ // truncated body — is not a healthy server.
+ try {
+ resolve(JSON.parse(body)?.status === 'ok');
+ } catch {
+ resolve(false);
+ }
+ });
+ res.on('error', () => resolve(false));
+ });
+ req.on('timeout', () => { req.destroy(); resolve(false); });
+ req.on('error', () => resolve(false));
+ });
+}
+
+/**
+ * Poll the candidate URLs until one reports healthy or the budget runs out.
+ * Clock and probe are injected so the timeout contract is testable without
+ * real sleeps or a real server.
+ *
+ * @param {object} options
+ * @param {string[]} options.urls
+ * @param {number} [options.timeoutMs] - total budget across all attempts
+ * @param {number} [options.intervalMs] - pause between full passes
+ * @param {(url: string) => Promise} [options.probe]
+ * @param {() => number} [options.now]
+ * @param {(ms: number) => Promise} [options.sleep]
+ * @returns {Promise<{healthy: boolean, url: string|null, attempts: number}>}
+ */
+export async function waitForHealthy({
+ urls,
+ timeoutMs = DEFAULT_TIMEOUT_MS,
+ intervalMs = DEFAULT_INTERVAL_MS,
+ probe = probeHealth,
+ now = Date.now,
+ sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
+}) {
+ const deadline = now() + timeoutMs;
+ let attempts = 0;
+ // Always make one full pass, even with a zero/expired budget — the check is
+ // worthless if it can report "unhealthy" without having asked.
+ for (;;) {
+ for (const url of urls) {
+ attempts += 1;
+ if (await probe(url)) return { healthy: true, url, attempts };
+ }
+ if (now() >= deadline) return { healthy: false, url: null, attempts };
+ await sleep(intervalMs);
+ }
+}
+
+async function runCli() {
+ const apiPort = Number(process.env.PORT) || PORTS.API;
+ const mirrorPort = Number(process.env.PORTOS_HTTP_PORT) || PORTS.API_LOCAL;
+ const timeoutMs = parseTimeoutMs(process.env.PORTOS_HEALTH_WAIT_MS);
+ const urls = healthProbeUrls({ apiPort, mirrorPort });
+
+ const result = await waitForHealthy({ urls, timeoutMs });
+ if (result.healthy) {
+ console.log(`✅ PortOS is serving ${HEALTH_PATH} (${result.url})`);
+ return 0;
+ }
+ console.error(`❌ PortOS did not answer ${HEALTH_PATH} within ${Math.round(timeoutMs / 1000)}s (${result.attempts} attempts)`);
+ return 1;
+}
+
+if (isDirectlyInvoked(import.meta.url)) process.exit(await runCli());
diff --git a/scripts/verify-server-health.test.js b/scripts/verify-server-health.test.js
new file mode 100644
index 0000000000..d9699f61a9
--- /dev/null
+++ b/scripts/verify-server-health.test.js
@@ -0,0 +1,132 @@
+import { describe, expect, it, vi } from 'vitest';
+import { createServer } from 'node:http';
+import { healthProbeUrls, parseTimeoutMs, probeHealth, waitForHealthy } from './verify-server-health.js';
+
+/** Start a loopback server that answers one canned response, and return its URL. */
+async function withServer(handler, run) {
+ const server = createServer(handler);
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ try {
+ return await run(`http://127.0.0.1:${server.address().port}/api/system/health`);
+ } finally {
+ await new Promise((resolve) => server.close(resolve));
+ }
+}
+
+describe('post-update server health verification', () => {
+ it('probes the loopback mirror before the API port, and dedupes a plain-HTTP install', () => {
+ expect(healthProbeUrls({ apiPort: 5555, mirrorPort: 5553 })).toEqual([
+ 'http://127.0.0.1:5553/api/system/health',
+ 'http://127.0.0.1:5555/api/system/health',
+ 'https://127.0.0.1:5555/api/system/health',
+ ]);
+ // No cert provisioned: the mirror never binds and the API port serves HTTP,
+ // so the two http candidates collapse into one.
+ expect(healthProbeUrls({ apiPort: 5555, mirrorPort: 5555 })).toEqual([
+ 'http://127.0.0.1:5555/api/system/health',
+ 'https://127.0.0.1:5555/api/system/health',
+ ]);
+ });
+
+ it('accepts only a 200 that actually reports status "ok"', async () => {
+ const ok = await withServer((_req, res) => {
+ res.writeHead(200, { 'content-type': 'application/json' });
+ res.end(JSON.stringify({ status: 'ok', version: '0.0.0-test' }));
+ }, (url) => probeHealth(url));
+ expect(ok).toBe(true);
+
+ // A half-booted server, or something else squatting the port, answers —
+ // treating that as healthy would skip the recovery the caller exists for.
+ const degraded = await withServer((_req, res) => {
+ res.writeHead(200, { 'content-type': 'application/json' });
+ res.end(JSON.stringify({ status: 'degraded' }));
+ }, (url) => probeHealth(url));
+ expect(degraded).toBe(false);
+
+ const notJson = await withServer((_req, res) => {
+ res.writeHead(200, { 'content-type': 'text/html' });
+ res.end('proxy error');
+ }, (url) => probeHealth(url));
+ expect(notJson).toBe(false);
+
+ const serverError = await withServer((_req, res) => {
+ res.writeHead(503);
+ res.end('');
+ }, (url) => probeHealth(url));
+ expect(serverError).toBe(false);
+ });
+
+ it('reports unhealthy for a port nothing is listening on', async () => {
+ // Bind then release so the port is known-free rather than guessed.
+ const port = await withServer(() => {}, (url) => Number(new URL(url).port));
+ expect(await probeHealth(`http://127.0.0.1:${port}/api/system/health`, 1_000)).toBe(false);
+ });
+
+ it('keeps polling a booting server until it answers, without spending the whole budget', async () => {
+ let clock = 0;
+ const probe = vi.fn()
+ .mockResolvedValueOnce(false)
+ .mockResolvedValueOnce(false)
+ .mockResolvedValueOnce(true);
+
+ const result = await waitForHealthy({
+ urls: ['http://127.0.0.1:5553/api/system/health'],
+ timeoutMs: 120_000,
+ intervalMs: 2_000,
+ probe,
+ now: () => clock,
+ sleep: async (ms) => { clock += ms; },
+ });
+
+ expect(result).toEqual({ healthy: true, url: 'http://127.0.0.1:5553/api/system/health', attempts: 3 });
+ expect(clock).toBe(4_000);
+ });
+
+ it('gives up once the budget is spent, after asking at least once', async () => {
+ let clock = 0;
+ const probe = vi.fn().mockResolvedValue(false);
+
+ const result = await waitForHealthy({
+ urls: ['http://a/health', 'http://b/health'],
+ timeoutMs: 5_000,
+ intervalMs: 2_000,
+ probe,
+ now: () => clock,
+ sleep: async (ms) => { clock += ms; },
+ });
+
+ expect(result.healthy).toBe(false);
+ expect(result.url).toBe(null);
+ // The contract is "kept asking for the whole budget, then stopped", not a
+ // particular pass schedule: every candidate is asked on every pass, at
+ // least one full pass happened, and it only gave up past the deadline.
+ expect(probe.mock.calls.length % 2).toBe(0);
+ expect(probe.mock.calls.length).toBeGreaterThanOrEqual(2);
+ expect(clock).toBeGreaterThanOrEqual(5_000);
+ });
+
+ it('reads a deliberate zero budget from the environment instead of falling back', () => {
+ // `|| DEFAULT` would turn a fail-fast 0 — and a typo — into a silent 120s.
+ expect(parseTimeoutMs('0')).toBe(0);
+ expect(parseTimeoutMs('30000')).toBe(30_000);
+ expect(parseTimeoutMs(undefined)).toBe(120_000);
+ expect(parseTimeoutMs('')).toBe(120_000);
+ expect(parseTimeoutMs('12O')).toBe(120_000);
+ expect(parseTimeoutMs('-1')).toBe(120_000);
+ });
+
+ it('still makes one full pass when the budget is already exhausted', async () => {
+ const probe = vi.fn().mockResolvedValue(false);
+
+ const result = await waitForHealthy({
+ urls: ['http://a/health'],
+ timeoutMs: 0,
+ probe,
+ now: () => 0,
+ sleep: async () => {},
+ });
+
+ expect(result.healthy).toBe(false);
+ expect(probe).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/server/dependency-overrides.test.js b/server/dependency-overrides.test.js
index 8663d5a755..1b73914e17 100644
--- a/server/dependency-overrides.test.js
+++ b/server/dependency-overrides.test.js
@@ -270,3 +270,50 @@ describe('dependency override parity across manifests (#2848)', () => {
expect(drift).toEqual([]);
});
});
+
+// An exact npm version: `8.21.3`, or a prerelease like `node-pty@1.2.0-beta.15`.
+// Anything else — `^16.0.0`, `~1.17.1`, `>=5`, `*`, a git/file/npm-alias specifier —
+// lets a fresh install resolve somewhere nobody reviewed.
+const EXACT_VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
+
+const readDirectDeps = (rel) => {
+ const pkg = JSON.parse(readFileSync(join(REPO_ROOT, rel), 'utf8'));
+ return ['dependencies', 'devDependencies'].flatMap((block) =>
+ Object.entries(pkg[block] ?? {}).map(([name, range]) => ({ block, name, range }))
+ );
+};
+
+// The `overrides` blocks have always required an exact pin (see the comment on
+// MINIMUM_SAFE above: a range "lets npm resolve anywhere in the range on a fresh
+// install, which defeats the point of pinning"). The same argument applies to the
+// packages this repo depends on DIRECTLY, and until #5699 nothing enforced it —
+// six server dependencies had drifted to caret ranges. Any tree re-resolution
+// (`npm run setup`'s `npm install --no-save --prefix server`, a Dependabot bump to
+// a sibling, `scripts/ensure-deps.js`'s clean reinstall) would float them to a
+// newer release no human reviewed. Upgrades arrive as reviewable PRs instead.
+describe('direct dependency pinning (#5699)', () => {
+ it.each(MANIFESTS)('%s: pins every direct dependency to an exact version', (rel) => {
+ const ranged = readDirectDeps(rel)
+ .filter(({ range }) => !EXACT_VERSION.test(range))
+ .map(({ block, name, range }) => `${block}.${name}=${range}`);
+
+ expect(
+ ranged,
+ `${rel}: direct dependencies must be exact versions, not ranges (see docs/DEPS.md "Direct Dependency Pinning"): ${ranged.join(', ')}`
+ ).toEqual([]);
+ });
+
+ it('scans every manifest, so a clean result is not vacuous', () => {
+ const scanned = MANIFESTS.flatMap(readDirectDeps);
+ // ~49 entries across the four manifests today. A path or shape change that
+ // makes the loop iterate nothing would otherwise report a clean sweep.
+ expect(scanned.length).toBeGreaterThanOrEqual(40);
+ // And the matcher must actually reject the shapes this guard exists to catch,
+ // so a regex that accidentally accepts everything fails here rather than
+ // passing every manifest.
+ for (const range of ['^16.0.0', '~1.17.1', '>=8.10.0', '8.x', '*', 'latest']) {
+ expect(EXACT_VERSION.test(range), `${range} should not read as an exact pin`).toBe(false);
+ }
+ expect(EXACT_VERSION.test('1.2.0-beta.15')).toBe(true);
+ });
+});
diff --git a/server/envExampleDrift.test.js b/server/envExampleDrift.test.js
new file mode 100644
index 0000000000..cb631baf86
--- /dev/null
+++ b/server/envExampleDrift.test.js
@@ -0,0 +1,231 @@
+/**
+ * Repo-wide guard: `.env.example` and the variables the server actually reads
+ * must not drift apart (#5706).
+ *
+ * ## Why this exists
+ *
+ * `.env.example` is the only discovery surface a fresh install has for "what can
+ * I configure". Every one of these variables is read lazily at first use with a
+ * silent fallback, so an undocumented one is invisible: the feature just quietly
+ * uses its default and nothing ever says otherwise. That made the file rot in
+ * both directions before this guard existed —
+ *
+ * - FORWARD drift: `signalSync.js` reads `SIGNAL_DIR`, `SIGNAL_CONFIG_PATH` and
+ * `SIGNAL_DB_PATH` in one three-line block, and only the middle one was
+ * documented — so a user relocating a Signal install configured half of it.
+ * - REVERSE drift: `.env.example` still advertised `PORTOS_UI_MAX_MEMORY` long
+ * after #5322 made the Vite ceiling a fixed constant. Setting it did nothing.
+ *
+ * Both directions are checked below.
+ *
+ * ## The rule
+ *
+ * 1. Every `process.env.NAME` read in tracked, non-test server runtime source
+ * appears as a `NAME=` line in `.env.example` — commented out is fine, that is
+ * how the whole file documents an optional override.
+ * 2. Every `NAME=` documented in `.env.example` is mentioned somewhere in the
+ * tracked code PortOS actually runs — any language, anywhere in the repo.
+ *
+ * ## Allowlist
+ *
+ * `INHERITED_ENV` below is the one escape hatch, and it is a *category* list with
+ * a reason per entry rather than a snapshot of today's diff — so it keeps meaning
+ * something as the tree grows. An entry belongs there only if a user would never
+ * put it in `.env`: the OS provides it, a toolchain (npm, PM2, Hugging Face, XDG)
+ * injects it, or it exists purely for the test harness. A new *product* setting
+ * is not an allowlist entry; document it in `.env.example` instead.
+ *
+ * ## What this guard CANNOT see
+ *
+ * - `process.env['NAME']` / `process.env[dynamicKey]` — only the dotted form is
+ * matched. Write `process.env.NAME` and this guard covers you.
+ * - A destructured read (`const { FOO } = process.env`, or a helper taking an
+ * `env` object, as `interactiveShellResolver.js` does with `PORTOS_SHELL`).
+ * Forward drift on those shapes goes unnoticed; the reverse check still
+ * covers them because it matches the bare name anywhere in the source.
+ * - Anything outside the server RUNTIME tree in the forward direction. Both
+ * `scripts/` and `server/scripts/` are standalone CLIs — CI plumbing
+ * (`CI_SHARD`, `GITHUB_SHA`, …) and one-off dev explorers (`MAZE_SEED`, …)
+ * that document their own env in their file headers and would be noise in a
+ * user-facing example file. They stay a reverse-only surface.
+ * - A reference to a name in prose. The reverse direction matches bare names,
+ * so a variable nothing reads any more still counts as alive while a code
+ * COMMENT mentions it. `.md` files are excluded from that surface for the
+ * same reason, but an in-code comment cannot be told from a real read here.
+ *
+ * String and comment CONTENT is blanked before the forward scan (`blankLiterals`
+ * from `lib/sourceScan.js`), so a `process.env.X` written inside a code sample
+ * PortOS *generates* for someone else — `routes/apps/viteTls.js` emits exactly
+ * that for a user's vite config — is correctly not treated as a PortOS setting.
+ */
+
+import { describe, it, expect } from 'vitest';
+import { execFileSync } from 'child_process';
+import { readFileSync } from 'fs';
+import { dirname, join } from 'path';
+import { fileURLToPath } from 'url';
+import { blankLiterals } from './lib/sourceScan.js';
+
+const SERVER_ROOT = dirname(fileURLToPath(import.meta.url));
+const REPO_ROOT = dirname(SERVER_ROOT);
+const ENV_EXAMPLE = join(REPO_ROOT, '.env.example');
+
+/**
+ * Names a user never sets in `.env`, with the reason each is exempt. Grouped by
+ * category on purpose — see the Allowlist note in the header before adding one.
+ */
+const INHERITED_ENV = {
+ // Provided by the operating system / shell.
+ HOME: 'OS-provided home directory',
+ PATH: 'OS-provided executable search path',
+ PWD: 'OS-provided working directory',
+ USER: 'OS-provided account name',
+ TZ: 'OS/pm2-provided timezone (ecosystem.config.cjs pins it to UTC)',
+ LOCALAPPDATA: 'Windows-provided per-user app data root',
+ ProgramFiles: 'Windows-provided install root',
+ SystemDrive: 'Windows-provided system drive letter',
+ DYLD_LIBRARY_PATH: 'macOS dynamic-loader path, passed through to child processes',
+ LD_LIBRARY_PATH: 'Linux dynamic-loader path, passed through to child processes',
+ XDG_CACHE_HOME: 'XDG base-directory spec, set by the desktop environment',
+
+ // Injected by a toolchain PortOS runs under.
+ NODE_ENV: 'set by the launcher (pm2) and forced to "test" by vitest.config.js',
+ npm_config_cache: 'injected by npm when it runs a script',
+ npm_lifecycle_event: 'injected by npm when it runs a script',
+ PM2_HOME: 'injected by pm2',
+ PM2_ID: 'injected by pm2',
+ pm_exec_path: 'injected by pm2',
+ pm_id: 'injected by pm2',
+ BUN_INSTALL: 'set by the Bun installer; PortOS only reads it to find the binary',
+ HF_HOME: 'Hugging Face toolchain convention, shared with the Python side',
+ HF_HUB_CACHE: 'Hugging Face toolchain convention, shared with the Python side',
+
+ // Test-harness only — setting any of these on a real install is meaningless.
+ VITEST: 'set by the vitest runner',
+ VITEST_FAST: 'opt-in fast-suite selector, CI/local test runs only',
+ TEST_DB_OK: 'test-harness flag for the DB-backed suites',
+ PGTESTDATABASE: 'test-harness override naming portos_test',
+ PORTOS_REQUIRE_DB: 'CI flag that turns a skipped DB suite into a failure',
+ PORTOS_TEST_PYTHON: 'test-harness interpreter override',
+ PORTOS_TEST_QUIET: 'test-harness log silencer',
+};
+
+/**
+ * `git ls-files --stage`, minus gitlinks. `--stage` so the mode is visible:
+ * `lib/slashdo` is a submodule, and git lists that gitlink as one path that is a
+ * DIRECTORY on disk (mode 160000), which would blow the reader up. Its contents
+ * are not tracked in this repo anyway.
+ */
+const trackedFiles = (...args) => execFileSync('git', ['ls-files', '--stage', ...args], {
+ cwd: REPO_ROOT,
+ encoding: 'utf8',
+ maxBuffer: 64 * 1024 * 1024,
+}).split('\n').filter(Boolean).flatMap((line) => {
+ const [meta, path] = line.split('\t');
+ return meta.startsWith('160000 ') ? [] : [path];
+});
+
+/** Tracked, non-test server RUNTIME modules — the forward-scan surface. */
+const serverRuntimeSources = () => trackedFiles('server').filter((f) => (
+ /\.(?:js|mjs|cjs)$/.test(f) && !f.includes('.test.') && !f.startsWith('server/scripts/')
+));
+
+// Everything a variable can reach the running system through. Deliberately wide
+// — a false "this is dead" would block CI over a real setting — but `.md` and
+// other prose is left out so a doc mention alone cannot keep a dead key alive.
+const CODE_EXT = /\.(?:js|mjs|cjs|ts|tsx|jsx|sh|ps1|bat|cmd|py|rb|swift|yml|yaml|json|toml|sql|html|npmrc)$/;
+
+/**
+ * Tracked code PortOS actually runs — the reverse-scan surface. Extensionless
+ * files are kept (an executable shim like `server/lib/agentGuard/bin/pm2` is
+ * exactly the kind of place a variable is consumed). Two things are not: tests,
+ * which exercise the code rather than being it — this very file names
+ * `PORTOS_UI_MAX_MEMORY` in its header and would otherwise vouch for the dead
+ * key it was written to catch — and `.env.example`, which would document itself.
+ */
+const runtimeCodeSources = () => trackedFiles().filter((f) => (
+ f !== '.env.example' && !f.includes('.test.') && (CODE_EXT.test(f) || !f.includes('.'))
+));
+
+const ENV_READ = /process\.env\.([A-Za-z_$][A-Za-z0-9_$]*)/g;
+
+/**
+ * Every `process.env.NAME` read in one file's source, literals blanked first.
+ * Most of the tree never touches `process.env`, and blanking is the expensive
+ * step, so skip it entirely for a file with nothing to find — this guard is on
+ * the always-run CI list and pays that cost on every PR.
+ */
+export function envReadsIn(src) {
+ if (!src.includes('process.env.')) return [];
+ return [...blankLiterals(src).matchAll(ENV_READ)].map((m) => m[1]);
+}
+
+// A key line is `NAME=`, optionally commented and optionally behind a platform
+// label — `# Windows: PORTOS_SHELL=…` documents PORTOS_SHELL just as well.
+const ENV_KEY_LINE = /^[ \t]*(?:#[ \t]*)?(?:[A-Za-z][A-Za-z0-9 ]*:[ \t]*)?([A-Za-z_][A-Za-z0-9_]*)=/gm;
+
+/** Every variable `.env.example` documents, commented or not. */
+export function documentedKeys(text) {
+ return [...text.matchAll(ENV_KEY_LINE)].map((m) => m[1]);
+}
+
+describe('.env.example stays in sync with the environment the server reads (#5706)', () => {
+ it('scans a real tree', () => {
+ // A broken `git ls-files` (wrong cwd, detached checkout) would otherwise let
+ // both directions below pass by comparing two empty sets.
+ expect(serverRuntimeSources().length).toBeGreaterThan(500);
+ expect(runtimeCodeSources().length).toBeGreaterThan(500);
+ expect(documentedKeys(readFileSync(ENV_EXAMPLE, 'utf8')).length).toBeGreaterThan(50);
+ });
+
+ it('extracts reads and documented keys from the shapes that matter', () => {
+ expect(envReadsIn('const a = process.env.FOO || 1;')).toEqual(['FOO']);
+ // Bracket access is a known blind spot — pinned so the limitation in the
+ // header stays true rather than quietly becoming wrong.
+ expect(envReadsIn("const a = process.env['FOO'];")).toEqual([]);
+ // A sample PortOS generates for someone ELSE's config is not a PortOS setting.
+ expect(envReadsIn('const line = `const D = process.env.FOO;`;')).toEqual([]);
+ expect(envReadsIn('// legacy: process.env.FOO\nconst a = process.env.BAR;')).toEqual(['BAR']);
+
+ expect(documentedKeys('# FOO=bar\n')).toEqual(['FOO']);
+ expect(documentedKeys('FOO=bar\n')).toEqual(['FOO']);
+ expect(documentedKeys('# Windows: FOO=C:\\bar\n')).toEqual(['FOO']);
+ expect(documentedKeys('# Prose about FOO=bar in a sentence\n')).toEqual([]);
+ });
+
+ it('documents every environment variable the server reads', () => {
+ const documented = new Set(documentedKeys(readFileSync(ENV_EXAMPLE, 'utf8')));
+ const undocumented = new Map();
+
+ for (const file of serverRuntimeSources()) {
+ for (const name of envReadsIn(readFileSync(join(REPO_ROOT, file), 'utf8'))) {
+ // hasOwn, not `in`: `process.env.constructor` would otherwise hit
+ // Object.prototype and silently skip enforcement.
+ if (documented.has(name) || Object.hasOwn(INHERITED_ENV, name)) continue;
+ if (!undocumented.has(name)) undocumented.set(name, file);
+ }
+ }
+
+ // Sorted `NAME (first read here)` lines so a failure says what to add and where.
+ expect([...undocumented].sort().map(([n, f]) => `${n} (${f})`)).toEqual([]);
+ });
+
+ it('documents nothing the code has stopped reading', () => {
+ // Bare-name match, not `process.env.NAME`: a variable can legitimately reach
+ // the code destructured, through an `env` object, or via a shell script, and
+ // this direction only needs to know that SOMETHING still knows the name.
+ // One alternation over the documented names per file — `\b` makes the longer
+ // name win, so PORTOS_HOST never counts as a sighting of PORT — and the scan
+ // stops as soon as every name has been accounted for.
+ const dead = new Set(documentedKeys(readFileSync(ENV_EXAMPLE, 'utf8')));
+ const anyName = new RegExp(`\\b(?:${[...dead].join('|')})\\b`, 'g');
+
+ for (const file of runtimeCodeSources()) {
+ if (dead.size === 0) break;
+ const src = readFileSync(join(REPO_ROOT, file), 'utf8');
+ for (const [name] of src.matchAll(anyName)) dead.delete(name);
+ }
+
+ expect([...dead].sort()).toEqual([]);
+ });
+});
diff --git a/server/index.js b/server/index.js
index 58ec6ffb7b..7c5a6fc073 100644
--- a/server/index.js
+++ b/server/index.js
@@ -87,7 +87,6 @@ import jiraRoutes from './routes/jira.js';
import autobiographyRoutes from './routes/autobiography.js';
import backupRoutes from './routes/backup.js';
import legacyExportRoutes from './routes/legacyExport.js';
-import openWorldRoutes from './routes/openWorldRoutes.js';
import eidoverseWorldRoutes from './routes/eidoverseWorldRoutes.js';
import databaseRoutes from './routes/database.js';
import localLlmRoutes from './routes/localLlm.js';
@@ -131,6 +130,7 @@ import spriteRoutes from './routes/sprites.js';
import moodBoardRoutes from './routes/moodBoard.js';
import threejsModelsRoutes from './routes/threejsModels.js';
import imageTo3dRoutes from './routes/imageTo3d.js';
+import riggingRoutes from './routes/rigging.js';
import privacyRoutes from './routes/privacy.js';
import writersRoomRoutes from './routes/writersRoom.js';
import universeBuilderRoutes from './routes/universeBuilder/index.js';
@@ -152,6 +152,7 @@ import openclawRoutes from './routes/openclaw.js';
import sharingRoutes from './routes/sharing.js';
import roundsRoutes from './routes/rounds.js';
import midiRuntimeRoutes from './routes/midiRuntime.js';
+import harnessRoutes from './routes/harnesses.js';
import peerSyncRoutes from './routes/peerSync.js';
import askRoutes from './routes/ask.js';
import remoteDesktopRoutes from './routes/remoteDesktop.js';
@@ -299,9 +300,6 @@ app.use('/api/autofix', autoFixMetricsRoutes);
app.use('/api/backup', backupRoutes);
app.use('/api/legacy-export', legacyExportRoutes);
app.use('/api/eidoverse/world', eidoverseWorldRoutes);
-app.use('/api/openworld', openWorldRoutes);
-// Keep the pre-rename API prefix available to older clients and installed voice tools.
-app.use('/api/city', openWorldRoutes);
app.use('/api/database', databaseRoutes);
app.use('/api/uploads', uploadsRoutes);
app.use('/api/image-clean', imageCleanRoutes);
@@ -389,6 +387,7 @@ app.use('/api/sprites', spriteRoutes);
app.use('/api/mood-boards', moodBoardRoutes);
app.use('/api/threejs-models', threejsModelsRoutes);
app.use('/api/image-to-3d', imageTo3dRoutes);
+app.use('/api/rigging', riggingRoutes);
app.use('/api/privacy', privacyRoutes);
app.use('/api/writers-room', writersRoomRoutes);
app.use('/api/universe-builder', universeBuilderRoutes);
@@ -412,6 +411,7 @@ app.use('/api/openclaw', openclawRoutes);
app.use('/api/sharing', sharingRoutes);
app.use('/api/rounds', roundsRoutes);
app.use('/api/midi-runtime', midiRuntimeRoutes);
+app.use('/api/harnesses', harnessRoutes);
app.use('/api/peer-sync', peerSyncRoutes);
app.use('/api/ask', askRoutes);
diff --git a/server/lib/README.md b/server/lib/README.md
index 68911cfb33..4516759b07 100644
--- a/server/lib/README.md
+++ b/server/lib/README.md
@@ -69,15 +69,16 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub
| `appleHealthValidation.js` | Apple Health import payloads. |
| `brainValidation.js` | Brain/memory route schemas (search, ingest, edit). |
| `catalogValidation.js` | Creative ingredients catalog route schemas (scraps, ingredients, links, relations, tags, revisions, sync envelope). |
-| `cosValidation.js` | Chief-of-Staff task/job/loop/learning schemas, the Review-Loop reviewer vocabulary + helpers (`normalizeReviewers`/`buildReviewWithArgs`), the Code-Review settings slice, and the task-metadata sanitizer. |
+| `cosValidation.js` | Chief-of-Staff task/job/loop/learning schemas, the Code-Review settings slice, and the task-metadata sanitizer. The Review-Loop reviewer vocabulary lives in `reviewerConfig.js` (#5702) and is re-exported flat from here. |
| `creativeCommissionValidation.js` | Creative Commission (Autonomous Creation Engine) create/update + brief/schedule/generation schemas. Brief field caps are mirrored by the commission form in `client/src/components/creative-commission/commissionForm.js` (parity: `creativeCommissionValidation.mirror.test.js`). |
| `creativeDirectorValidation.js` | Creative Director project/treatment/scene + Create-Suite importer schemas. `CREATIVE_DIRECTOR_GOAL_MAX` is mirrored in `client/src/lib/creativeDirectorPlan.js` (parity: `creativeDirectorValidation.mirror.test.js`). |
| `digitalTwinValidation.js` | Digital twin document/category schemas. |
+| `eidoverseValidation.js` | Eidoverse world-projection schemas — the `EIDOVERSE_PROJECTION_SOURCE_KEYS` allowlist, the V1/V2 projection recipe union (includes/assets/terrain/scale/districts/environment, with the `..`-escape guard on every asset path), and the world augment/say/config-patch route bodies (incl. the 8KB augment-argument cap). Split out of `validation.js` (#5698), which re-exports it. |
| `fableLoomValidation.js` | FableLoom branching-narrative route schemas (loom/episode/node/transition CRUD, weave/branch/review, play turns). |
| `genomeValidation.js` | Genome upload + search schemas. |
| `identityValidation.js` | Identity section + chronotype + scheduling schemas. |
| `meatspaceValidation.js` | Meatspace (location/health log) schemas. |
-| `mediaValidation.js` | Media-generation & local-model infra schemas (LoRA training, local-LLM/Ollama/LM Studio management, OpenWorld snapshots, media-collection bulk ops). |
+| `mediaValidation.js` | Media-generation & local-model infra schemas (LoRA training, local-LLM/Ollama/LM Studio management, media-collection bulk ops). |
| `memoryValidation.js` | Memory record + retrieval schemas. |
| `modelPersonalityValidation.js` | LLM personality self-profile test schemas: trait taxonomy (versioned), self-eval + twin-alignment response schemas, run/settings route inputs. |
| `moodBoardValidation.js` | Mood board + board-item create/update schemas. |
@@ -141,6 +142,7 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub
| `antigravity.js` | Antigravity (`agy`) CLI provider helpers — id/sentinel constants (`ANTIGRAVITY_CLI_ID`, `ANTIGRAVITY_CONFIGURED_DEFAULT`, `LEGACY_GEMINI_*`), `isAntigravityCommand`/`isAntigravityCliProvider` predicates, and `ensureAntigravityPrintArgs(args, {model, effort})`/`ensureAntigravityTuiArgs(args, {model, effort})`/`stripAntigravityUnsupportedArgs` argv normalizers. `parseAntigravityModelList(stdout)` parses `agy models` rows — accepts both the modern `\t` shape and the older bare-id-per-line one, deduped, sentinel dropped (mirrored in the vendored toolkit's `internal/antigravity.js`; used by both the provider-catalog refresh and Image Gen's agy model picker). `isAntigravityModelId(id)` is the same id shape as a bare predicate, for spawn sites building an `agy --model` argv from a value no route schema bounded. The strip drops legacy Gemini `--yolo`/`-m`/`--output-format` but PRESERVES the long `--model` (agy accepts it as a per-session flag, so a user-baked pin is a real selection and suppresses the injected one); the two builders inject `--model`/`--effort` from the per-run overrides, always ahead of the trailing `--print` marker whose value is the prompt. |
| `llmText.js` | Pure LLM-output text helpers — `stripCodeFences` (unfence a model reply) and `parseLLMJSON` (unfence then JSON.parse with a descriptive throw). Below the provider layer, so lib can clean model output without importing provider orchestration. |
| `providerCooldown.js` | Provider bench policy — `resolveProviderBench(analysis)` → `null` (don't bench) / a `usage-limit` marker / an `unavailable` marker with the per-category cooldown from `COOLDOWN_MS_BY_CATEGORY`. Declines to bench request/response-specific categories (`isRequestSpecificCategory` / `isSchemaTypeCategory`) so one bad model id or off-shape response can't take a healthy provider offline. Shared by `services/promptRunner.js` (prompt cascade) and `services/agentFinalization.js` (finished CoS agent run) so both bench a category for the same window. Pure. |
+| `reviewerConfig.js` | Review-Loop reviewer vocabulary (split out of `cosValidation.js`, #5702; Zod-free): the reviewer roster + aliases (`REVIEWER_VALUES` / `REVIEWER_ALIASES` / `DEFAULT_REVIEWER`), the local-LLM / PortOS-only / model-capable / effort-selectable subsets, the slug→CLI-binary map (`REVIEWER_CLI_BINARIES`, `isCliReviewer`, `reviewerCliBinary`), the keyed model/effort/max-rounds pin normalizers + resolvers (`normalizeReviewers`, `normalizeReviewerModels`, `normalizeReviewerEfforts`, `resolveReviewerConfig`, `resolveClaimReviewerConfig`, `KEYED_REVIEWER_PINS`), and the emitters that render a reviewer set into slashdo argv (`buildReviewWithArgs`, `buildReviewersCsv`) and into agent-prompt notes (`buildReviewerPinNote`, `buildReviewerEffortNote`). Re-exported flat by `cosValidation.js`, so existing `validation.js` imports keep resolving. |
| `providerPrerequisites.js` | Can a provider run on this host AT ALL — `providerPrerequisites(provider, { runtime, gatewayKeySet })` → `{ met, missing: [{ code, label }] }` (CLI binary on PATH, API key stored for a public endpoint, the sibling key a gateway-backed wrapper inherits from the API record of its own gateway id unless it carries its own), plus `providerRuntimeKey` (`null` for an API provider, for a command carrying an explicit path, and for a provider with its own `PATH` in `envVars` — the runtime table only answers "does the BARE binary resolve on PortOS's PATH?", which is not those providers' question), `isPrivateNetworkEndpoint` (mirror of the client copy: loopback/RFC1918/tailnet CGNAT + ULA/`.ts.net`/single-label hosts need no key) and `describeMissingPrerequisites`. `runtime: null` = NOT PROBED and never counts as missing. Feeds BOTH `GET /api/providers` and the fallback chain in `aiToolkit/providerStatus.js` (via `services/providerPrerequisites.js`), so a `NEEDS SETUP` card and the router read one computation (#4611). `ROUTING_BLOCKING_CODES`/`blocksRouting(missing)` narrow what ROUTING may act on to the missing binary. Stored, inherited, and environment-backed credential findings stay presentation-only here because the server cannot assume the eventual process environment; the client card classifies sanitized env metadata through tri-state lookups (#4612). Pure. |
| `tuiShellLaunch.js` | `buildTuiShellLaunch(provider)` → `{ commandLine, env }` for launching a TUI provider by hand in a Shell session (the AI Providers card's "Launch in Shell" button). Resolves the command via `tuiHandshake.js#buildTuiInvocation` (so the vendor posture flags and `--model`/`--effort` injection match a real TUI spawn) and the env via `cliChildEnv.js#composeProviderEnv`. **The env is why this is server-side and the deep link carries only a provider ID**: a TUI provider's backend lives in `envVars` (`ANTHROPIC_BASE_URL` for an Ollama-backed or Bedrock `claude`, `OPENCODE_CONFIG_CONTENT` for an OpenCode wrapper), so a shell handed only the command line would run the right binary against the vendor cloud instead of the local daemon the user configured — and those values are secret, so they can't ride a URL. Returns null for a non-TUI provider. Must be given a RAW provider, never a client-sanitized one (redacted `'***'` env reads truthy). |
| `tuiHandshake.js` | Shared TUI invocation + paste-handshake constants. Also owns `SUBMIT_KEY` — the single Enter byte every PTY writer sends, whether it's submitting a pasted TUI prompt or a command PortOS injected into a shell session. It is CR, never LF: a POSIX pty's ICRNL hides the difference, but cmd.exe under Windows ConPTY accepts only CR and leaves an LF-terminated line typed-but-unexecuted at the prompt. |
@@ -169,21 +171,24 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub
| `modelPricing.js` | Per-model API billing rates for the /devtools/usage cost estimates — `resolveModelRates(providerId, model)` (exact → family regex → provider default → blended fallback, with a `matched` tier; also derives `cacheReadPer1M`/`cacheWritePer1M` from the input rate via per-family multipliers), `isFreeProvider` (ollama/lmstudio/`ollamaBacked`/localhost = free), `estimateCostUsd(tokensIn, tokensOut, rates, cache?)` — `tokensIn` is UNCACHED input; cache tiers are priced separately via the optional 4th arg — and `PRICING_AS_OF`. Informational only (PortOS runs on subscriptions); still excludes batch/long-context tiers. |
| `usageRange.js` | `resolveUsageRange({ period, from, to })` — pure period→inclusive-YYYY-MM-DD range resolution for the usage cost report (explicit dates win; `all` unbounded; default 7d). |
| `subscriptionSavings.js` | Subscription-vs-API savings math for the usage page — `resolveSavingsWindow` (clamps an open-ended report range to today / first activity day), `prorateMonthlyCost` (monthly plan price → this window's share, `DAYS_PER_MONTH`, capped by `MAX_MONTHLY_COST`), `savingsPercent` / `costMultiplier` (null, never 0, when the comparison is undefined), `attributeReportCostToFamilies` (groups report rows by their stamped `family`), `roundCents` (the one money rounder), and `buildSubscriptionSavings({ entries, range, unmatchedApiCost })` → per-family rows + totals. Pure. |
+| `credentialRegistry.js` | Pure catalog of PortOS credentials (`CREDENTIALS`, `CREDENTIAL_IDS`, `CREDENTIAL_TIERS`) — one entry per key/token an install can use (`id`, `label`, `unlocks`, `tier`, `getUrl`, `envVars`, `settingsPath`, `configurePath`, optional `feature`). Sits beside `instanceFeatureRegistry.js` so the two lists stay greppable together. Runtime resolution (settings / repo `.env` / inherited `process.env` / CLI / instance config) lives in `services/credentialInventory.js`. The Settings > Credentials page never receives a value or masked prefix. |
| `instanceFeatureRegistry.js` | The registry of optional per-install features (`INSTANCE_FEATURES`, `INSTANCE_FEATURE_IDS`, `APP_FEATURE_IDS`) — pure data, so `validation.js` derives its feature schemas from it and `navManifest.js` can be checked against it without a service→lib inversion. Runtime resolution (stored override → auto-detection → `defaultEnabled`) lives in `services/instanceFeatures.js`. A feature id tagged on a nav entry hides that page from ⌘K and the sidebar when the feature is off. |
| `providerFamilies.js` | Subscription-quota FAMILY identity — `PROVIDER_FAMILIES` (`{ id, label, matches }` for claude/codex/agy/grok), `PROVIDER_FAMILY_IDS`, `familyLabel`, `familyForProvider(config)` → family id or null (local-runtime wrappers and API-only providers belong to none). The pure half of the registry `services/providerUsage.js` attaches quota `fetch`ers to, so cost attribution and route validation can ask "which plan is this provider on?" without importing the PTY-scrape graph. Distinct from `providerVendors.js`, which is argv-shaped and includes vendors with no subscription quota. |
+| `harnessOutput.js` | Parsers for what a coding-agent HARNESS prints about itself: `parseHarnessVersion(stdout)` (the one semver run in a `--version` banner, `null` when unparseable), `compareHarnessVersions(a, b)` (the null-guarding wrapper around `versionUtils.js#compareSemver` — `null` when either side is unparseable, so a version that did not parse never reads as "out of date"), `parseHarnessModels(harnessId, stdout)` + `HARNESS_MODEL_PARSER_IDS` (OpenCode's `provider/model` lines and Grok's bulleted list are parsed here; Antigravity and Cursor DELEGATE to `antigravity.js#parseAntigravityModelList` / `aiToolkit/internal/cursor.js#parseCursorModelList`, which the provider-card refresh has used for far longer), `MAX_MODELS`, and `parseNpmLatestVersion`. Pure: the service layer runs the child and hands the captured stdout here, so the vendor output shapes are pinned by table-driven tests instead of by running six real binaries in CI. Model ids come back in the exact spelling `--model` takes — namespaces kept where the vendor keeps them. Consumed by `services/providerRuntimeInstaller.js` and `services/harnesses.js`. |
| `providerGateways.js` | `PROVIDER_GATEWAYS` — one row per hosted OpenAI-compatible gateway an OpenCode CLI/TUI wrapper can front-end (`orcarouter`, `openrouter`), plus `PROVIDER_GATEWAY_IDS`, `gatewayById`, `isGatewayNamespace(ns)` and `gatewayForProvider(config)` → row or null. Each row's `id` is simultaneously the OpenCode provider namespace, the `gatewayBacked` marker value, and the id of the sibling `api` record that owns the key — so the sibling lookup is `providers[gateway.id]` and an OrcaRouter key can never satisfy an OpenRouter wrapper. Replaces the `orcarouterBacked` boolean + literal `'orcarouter'` that had been hand-copied across ~15 server and client files (namespace resolution, the OpenCode config builder, both zod schemas, the model-fetcher table, the sibling-key attach, the prerequisite check, and the two "not a local runtime" carve-outs in `cliChildEnv.js`/`localProviderRuntime.js`). Reads the legacy per-gateway boolean FOREVER, so stored records are never rewritten. Distinct from a local runtime (`ollamaBacked`, `vllmBacked`, …): remote, always authenticating, and no thinking toggle. Deliberately mirrored in `aiToolkit/internal/gateways.js` (the vendored toolkit may not import out) and `client/src/utils/providers.js` (the browser cannot import server code) — `providerGateways.parity.test.js` fails when the first two drift. Dependency-light: imports nothing. |
-| `providerTranscriptUsage.js` | Parsers for the real per-message token counts the coding CLIs write to disk (0 tokens to read) — `parseClaudeTranscript` (`~/.claude/projects//*.jsonl`), `parseCodexRollout` (`~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`), `claudeProjectSlug`, `totalTranscriptTokens`. Both de-duplicate a format hazard that otherwise inflates counts badly: Claude repeats one response across several lines sharing a `message.id`, and Codex's `total_token_usage` is cumulative and repeated. Each parser returns per-model buckets (`byModel`) plus the message keys it counted (`countedKeys`), and accepts an `exclude` set — that is what stops two overlapping PortOS runs from both billing the same messages. Tolerant of truncated (mid-write) files; consumed by `services/usageReconciler.js`. |
+| `providerTranscriptUsage.js` | Parsers for the session files the coding CLIs write to disk (0 tokens to read) — `parseClaudeTranscript` (`~/.claude/projects//*.jsonl`), `parseCodexRollout` (`~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl`), `parseGrokTurns`/`parseGrokChatHistory`/`decodeGrokSessionDir` (`~/.grok/sessions///`), `parseAgyTranscript`/`parseAgyHistory` (`~/.gemini/antigravity-cli/`), `claudeProjectSlug`, `totalTranscriptTokens`. Each de-duplicates a format hazard that otherwise inflates counts badly: Claude repeats one response across several lines sharing a `message.id`, Codex's `total_token_usage` is cumulative and repeated, grok's `turn_completed.usage` has shipped in both per-prompt and cumulative shapes (detected and delta'd, never summed raw) while its `_meta.totalTokens` is context occupancy and never billed. Antigravity writes no token fields at all, so its parser returns chars for the caller to estimate from. Each parser returns per-model buckets (`byModel`) plus the message keys it counted (`countedKeys`), and accepts an `exclude` set — that is what stops two overlapping PortOS runs from both billing the same messages. Tolerant of truncated (mid-write) files; consumed by `services/usageReconciler.js`. |
| `opencodeConfig.js` | OpenCode config builder — `buildOpencodeEnvVars(provider, model)` builds dynamic `OPENCODE_CONFIG_CONTENT` declaring model ids under the namespace the provider's marker selects: a local runtime (`ollama` / `mtplx` / `llama` / `vllm` / `sglang`, bare ids) or a hosted gateway from `providerGateways.js` (`vendor/model` ids kept whole). Fixes --model rejection. Also attaches the key for a key-bearing namespace, and pins `small_model` to the run model for a gateway so OpenCode's own side calls (titles, summarization) can't land on its built-in default — a billed model the operator never chose. |
| `localProviderRuntime.js` | Which LOCAL daemon a provider talks to, and where — `LOCAL_RUNTIMES` (llama.cpp / Ollama / LM Studio / MTPLX / vLLM: label, binary, canonical base URL read from `opencodeConfig.js` rather than re-typed, manage/docs links, model-download hint), `localBackendForProvider` + `localEndpointPort` + `isLocalInstanceHost` (moved here from `services/localModelHealing.js`, which re-exports them, so the healing path and the readiness checklist classify a provider identically — loopback/bind-all only, so a LAN/Tailscale peer on port 11434 is NOT claimed as a local daemon), `localRuntimeKind(provider)` (the `*Backed` markers first, then that classifier; `orcarouter` excluded as a remote API), `localRuntimeForProvider(provider)` → the row with the endpoint the provider ITSELF configures (`OPENCODE_CONFIG_CONTENT`'s `baseURL`, `ANTHROPIC_BASE_URL`, or `endpoint`), then the `OLLAMA_URL`/`OLLAMA_HOST`/`LM_STUDIO_URL` override the backend managers read, then the canonical default — and `null` when that resolved endpoint fails `isLocalInstanceEndpoint` (an API provider on another machine has no local daemon to check, whatever its name says) — plus `normalizeOpenAiBaseUrl`. Pure; the probing half is `services/providerReadiness.js`. Optional `setupStateDetail` overrides `providerReadiness`'s per-state prose for a runtime whose local setup is not a model cache (vLLM's is a compose project); `standbyWhenStopped` marks an installed runtime such as llama.cpp whose stopped state is intentional standby rather than incomplete setup. |
| `managedDaemon.js` | Shared mechanism for the local daemons PortOS runs as optional PM2 processes (`services/llamaServerManager.js` → `portos-llama-server`, `services/mtplxServerManager.js` → `portos-mtplx`, `services/slotstreamServerManager.js` → `portos-slotstream`). Owns their PM2 process names — `LLAMA_APP`, `MTPLX_APP`, `SLOTSTREAM_APP`, and the `isModelServerProcess(name)` predicate over them — so a caller like the CoS health monitor can recognize a model server without importing a manager; the managers re-export those names. `createDaemonWatcher({...})` supplies the common PM2 launch-line re-adoption, endpoint probe, status skeleton, bounded log view, and port-release wait while managers retain daemon-specific parsing and lifecycle policy. `createDaemonLogBuffer({maxLines?})` is the bounded timestamped ring buffer of what PortOS logged around a launch, plus `withPm2Logs(output)` → that buffer followed by anything `pm2 logs` has which it doesn't already hold, deduped and re-capped (PM2's lines are a VIEW, never folded into the buffer — PM2 owns them and re-reads them every status call). `pm2ArgValue(args, flag)` reads one value back out of a PM2 process's recorded argv so a manager can recover a still-online daemon's launch config after a PortOS restart; `null` means the flag was absent, which a relaunch must leave off rather than defaulting. Also the shared **idle reaper**, for a daemon that cannot release its weights any other way: `registerIdleDaemon({name, getIdleMs, stop})` (seeds `lastUsedAt` to NOW, so a hand-started daemon gets a full window), `markDaemonUsed(name)` — call on real traffic, NEVER on a status poll — `daemonLastUsedAt(name)`, `idleWindowMs(minutes)` (minutes → ms; `0` = never, `null` = not configured, kept distinct), `reapIdleDaemons(now?)` → the names stopped, and `startIdleReaper({intervalMs?})` / `stopIdleReaper()` (ONE interval for all registrants, `unref`'d, idempotent). `mtplxServerManager` and `slotstreamServerManager` register: llama.cpp releases its checkpoint in place via `--sleep-idle-seconds` and must NOT be stopped for it. Deliberately mechanism only — what a launch line means and when a daemon may start is exactly what differs between the two. |
| `mtplxModels.js` | `listMtplxCachedModels({command?})` → `{models, error}` from `mtplx models --json` (walks local directories — pulls no weights, loads no model, but see `mtplxRuntime.js`: on an un-warmed Homebrew wrapper the spawn ITSELF is a several-hundred-megabyte runtime download, so poll callers must gate on `describeMtplxRuntime().ready` first) and `pickMtplxCachedModel(models)` → the repo id to hand `mtplx serve --model`. `models: null` means the cache could not be READ (no binary, command failed, unparseable) and is deliberately distinct from `[]` (read, and empty), because `services/localRuntimeSetup.js` starts MTPLX on its own default in the first case and refuses with the `mtplx pull` command in the second. Exists because `mtplx serve` defaults `--model` to one hard-coded checkpoint and exits 1 before binding when that repo is not cached — even on a host holding a different MTP model that serves fine. Picks only entries MTPLX itself calls complete (`validation.ok !== false`, so a half-finished pull is not served), preferring one with a recorded `mtplx_runtime.json` exactness contract. `describeMtplxCache(cache)` → `{state: 'unknown'\|'empty'\|'partial'\|'ready', model, count, error}` folds both into the one value `services/providerReadiness.js` puts on the checklist and `describeRuntimeSetup` picks a button from — so an empty cache is named up front instead of only inside the failure of a Start that could never work. |
| `mtplxRuntime.js` | `describeMtplxRuntime(binaryPath, {env?})` → `{ready, wrapper, venvPath}` — is MTPLX's own Python runtime on disk, decided by READING the binary rather than running it. Homebrew's `mtplx` is a shell wrapper that lazily bootstraps a version-keyed Python venv (a multi-hundred-megabyte pip install) on its first invocation, and `brew upgrade` re-arms it, so a status poll or an 8s-judged PM2 start is really a package download. This parses the wrapper's own `VENV=` assignment, honours `$MTPLX_BREW_VENV` the way `${MTPLX_BREW_VENV:-…}` does, and tests `/bin/mtplx` for executability — the wrapper's own `[ ! -x … ]` guard, so the two cannot disagree. Anything unrecognisable (a pip install, a compiled binary, an unparseable script) reports `ready: true, wrapper: false`, i.e. the status quo — never a block over a parse failure. |
| `slotstreamModels.js` | `listSlotstreamCachedModels({cacheDir?})` → `{models, error}` from a local directory walk of the SSD-streaming MoE cache (no network, no model load), `pickSlotstreamCachedModel(models, requested?)` → the checkpoint id to hand `slotstream serve --model`, and `planSlotstreamMemory({totalBytes?, overrideGb?})` → `{targetGb, expectedPeakGb, expectedWarmDecodeToks, auto}` so the LLMs row can show the memory plan instead of hiding it. `models: null` means the cache could not be READ and is deliberately distinct from `[]` (read, and empty), because a start never fetches weights. |
-| `vllmQwenProject.js` | `resolveVllmProjectDir()` / `inspectVllmQwenProject()` → `{dir, hasProject, composeFile, hasWeights, weightsRoot}` for the syv-ai/qwen38-27b-rtx3090 compose project, plus `vllmStartBlockedReason(project)` → the prose refusal (or `null`), and `vllmProjectSetupState(project)` → `ready`/`empty`/`unknown` for the readiness checklist. `hasWeights` is tri-state: `true` found / `false` caches read and empty / `null` no cache readable (a docker-volume cache is invisible from a native-Win32 PortOS), and `services/localRuntimeSetup.js` refuses to `docker compose up` on anything but `true` so the start button can never kick off the ~20 GB prepare. Also owns WHERE the project lives: `readRecordedVllmProjectDir(envPath?)` / `recordVllmProjectDir(dir, envPath?)` keep PortOS's auto-detected directory as one line in the INSTALL's `.env` (`PATHS.installRoot`, so a worktree-booted server writes where the real install reads), and `vllmProjectDirIsSettled()` answers "is detection still worth a subprocess?" so the manager never re-lists the precedence. That order is `VLLM_QWEN_PROJECT_DIR` (this run's decision) → the record → `~/qwen-serving`; `envPath` is a parameter so a test's sandbox answers instead of the developer's install. Directory reads plus that one config line — never docker, never a registry. Other override: `VLLM_QWEN_WEIGHTS_DIR`. |
+| `recordedProjectDir.js` | Where PortOS remembers a container project it had to go and FIND — one `.env` line per stack in the INSTALL's root (`PATHS.installRoot`, so a worktree-booted server writes where the real install reads). `readRecordedProjectDir(envVar, envPath?)` / `recordProjectDir(envVar, dir, envPath?)` / `projectDirIsSettled(envVar, env?, envPath?)` / `resolveRecordedProjectDir(envVar, defaultDir, env?, envPath?)` are all keyed on the env-var NAME, so the vLLM and SGLang Qwen3.8-27B stacks share one implementation instead of drifting on the precedence rule — exported value (this run's decision) → the record (an earlier run's) → the stack's documented default. Read from the file on every call, never cached: a placement run writes it and the readiness poll in the same process must see it without a restart, and PortOS has no dotenv so `.env` reaches `process.env` for nobody. Writes are atomic and replace the key's line rather than appending (`upsertEnvLine`) — that file also holds the database password. `envPath` is a parameter so a test's sandbox answers instead of the developer's install. Written by `services/wslProjectPlacement.js`. |
+| `vllmQwenProject.js` | `resolveVllmProjectDir()` / `inspectVllmQwenProject()` → `{dir, hasProject, composeFile, hasWeights, weightsRoot}` for the syv-ai/qwen38-27b-rtx3090 compose project, plus `vllmStartBlockedReason(project)` → the prose refusal (or `null`), and `vllmProjectSetupState(project)` → `ready`/`empty`/`unknown` for the readiness checklist. `hasWeights` is tri-state: `true` found / `false` caches read and empty / `null` no cache readable (a docker-volume cache is invisible from a native-Win32 PortOS), and `services/localRuntimeSetup.js` refuses to `docker compose up` on anything but `true` so the start button can never kick off the ~20 GB prepare. Also owns WHERE the project lives, as this stack's keyed view of `recordedProjectDir.js`: `readRecordedVllmProjectDir(envPath?)` / `recordVllmProjectDir(dir, envPath?)` / `vllmProjectDirIsSettled()`, resolving `VLLM_QWEN_PROJECT_DIR` (this run's decision) → the record → `~/qwen-serving`; `envPath` is a parameter so a test's sandbox answers instead of the developer's install. Directory reads plus that one config line — never docker, never a registry. Other override: `VLLM_QWEN_WEIGHTS_DIR`. |
| `wslDistro.js` | Which WSL2 distro a native-Win32 PortOS should put Linux-side work in. `detectWslProjectDir(leaf)` → `{dir, distro, home}` or `{dir: null, reason}` (`no-wsl` / `no-distro` / `internal-distro` / `unreadable-share`), built from `wsl.exe -e sh -c 'echo "$WSL_DISTRO_NAME"; echo "$HOME"'` — the distro's OWN shell, because `wsl --list` prints UTF-16LE that a UTF-8 reader mangles while an executed program's stdout passes through byte for byte. Verifies the derived `\\wsl.localhost\…` path is readable from Windows before returning it (WSL running and its share answering are separate facts) and refuses a container engine's own distro (`docker-desktop` and friends are recreated on a reset). `parseWslProbe` / `parseWslDistroList` (NUL-stripping the UTF-16 bytes, for an error message only) are exported for their own tests; `WSL_UNC_PREFIX` names the share root for callers writing refusal prose. Exists so the vLLM stack places its ~20 GB of weights on the distro filesystem instead of asking a human to fill in a UNC template — every read from a `C:\` checkout would cross a 9p share. |
| `qwenAgentParsers.js` | The tool-call / reasoning parser flags a local runtime MUST carry to serve a Qwen3-family model to a coding agent — one table PortOS owns instead of three docs. `QWEN_AGENT_PARSERS` maps runtime → `{toolCallParser, reasoningParser, enableAutoToolChoice}` (vLLM `qwen3_xml` + `--enable-auto-tool-choice`, SGLang `qwen3_coder` + `--reasoning-parser qwen3`, llama both `null` — a positive "no such flag today", not a placeholder). `parserFlagsFor(runtime)` returns the argv fragment (always an array, so no caller type-checks) and `vllmExtraArgs()` its string form for the compose project's `.env` `EXTRA_ARGS`; an unknown runtime THROWS, because the failure it prevents is silent — a parser-less server answers fluently and returns tool markup as ordinary text with `tool_calls: null`, so the agent never touches a file. Spellings are empirical (`docs/research/2026-08-21-qwen38-rtx3090-vllm.md`, `…-sglang-qwen38-27b.md`); never auto-detect one from the chat template — that is how `hermes` got picked. Pure. |
| `vllmQwenProvision.js` | The `.env` half of provisioning that same project: `generateVllmApiKey()`, `isWsl2Engine()` (win32 counts — Docker Desktop's engine IS a WSL2 VM), `vllmEnvDefaults({apiKey, wsl2})` → the load-bearing tool-parser/pin-memory/alloc-conf settings, `parseEnvContents(contents)` → a key→value Map (keyed on *mention*, so a commented-out key reads as absent and `KEY=` as an intentional empty), and the two writers over one shared newline guard: `mergeEnvFileContents(existing, defaults)` → `{contents, added, kept, effective}` is **additive only** — an operator's existing key or tuning is never overwritten, and `effective` reports what the container will actually read — while `upsertEnvLine(contents, key, value)` REPLACES one key's line, for a value PortOS owns and re-derives (`vllmQwenProject.js`'s recorded project directory); its replacement is a function, not a string, so a `$`-sequence in the value is written literally. Plus `WSL2_PREPARE_MIN_BYTES` / `WSL2_PREPARE_CONFIG_HINT` for the ceiling `prepare` needs (detected and warned about, never raised). |
-| `sglangQwenProject.js` | `resolveSglangProjectDir()` / `inspectSglangQwenProject()` → `{dir, hasProject, composeFile, hasWeights, weightsRoot}` for the operator's SGLang Qwen3.8-27B project, plus `sglangStartBlockedReason(project)` → the prose refusal (or `null`). Sibling of `vllmQwenProject.js` with the same tri-state `hasWeights` contract (`true` found / `false` caches read and empty / `null` no cache readable) and the same directory-reads-only rule — never docker, never a registry. Differs in that PortOS OWNS this launch line (SGLang publishes an image but no compose project), so the refusals point at the compose file in `docs/features/sglang-qwen38.md` rather than at a `git clone`. Overrides: `SGLANG_QWEN_PROJECT_DIR`, `SGLANG_QWEN_WEIGHTS_DIR`. |
+| `sglangQwenProject.js` | `resolveSglangProjectDir()` / `inspectSglangQwenProject()` → `{dir, hasProject, composeFile, hasWeights, weightsRoot}` for the operator's SGLang Qwen3.8-27B project, plus `sglangStartBlockedReason(project)` → the prose refusal (or `null`). Sibling of `vllmQwenProject.js` with the same tri-state `hasWeights` contract (`true` found / `false` caches read and empty / `null` no cache readable) and the same directory-reads-only rule — never docker, never a registry. Differs in that PortOS OWNS this launch line (SGLang publishes an image but no compose project), so the refusals point at the compose file in `docs/features/sglang-qwen38.md` rather than at a `git clone`. Also this stack's keyed view of `recordedProjectDir.js` — `readRecordedSglangProjectDir(envPath?)` / `recordSglangProjectDir(dir, envPath?)` / `sglangProjectDirIsSettled()` plus `SGLANG_PROJECT_LEAF` — so `resolveSglangProjectDir()` reads `SGLANG_QWEN_PROJECT_DIR` → the UNC path `services/sglangQwenManager.js` detected and recorded on Windows → `~/sglang-qwen38`. Overrides: `SGLANG_QWEN_PROJECT_DIR`, `SGLANG_QWEN_WEIGHTS_DIR`. |
| `sglangQwenRecipe.js` | The `sglang serve` launch line PortOS owns, per NVIDIA card class. `buildSglangQwenRecipe({hw, contextLength, ssmDtype, spec, radixStrategy, host, port})` → `{image, modelName, modelPath, cell, mambaRatio, stateSlots, flags, env}`, and `sglangComposeYaml(recipe)` renders the `docker-compose.yml` from it (one source of truth; a test pins the doc against it). `mambaFullMemoryRatio(...)` derives `--mamba-full-memory-ratio` from the cookbook formula `(S + D) × state_bytes / (L × kv_bytes_per_token)` — load-bearing, because the cookbook default `0.9` under-sizes the GDN state pool at CoS prompt lengths and silently clamps `max_running_requests`. Both Qwen parsers (`--reasoning-parser qwen3`, `--tool-call-parser qwen3_coder` — NOT vLLM's `qwen3_xml`) are baked into every cell: getting them wrong fails silently, with the model emitting raw markup and the agent never calling a tool. `sglangCellForGpu(gpu)` maps a `cudaCapability.js` compute-cap row to a cell (SM 9.x → `h200`, SM ≥10 → `rtx6000`/`rtx5090` by VRAM, Ampere → `null`, which is a refusal: 24 GB stays on vLLM), and `sglangUnsupportedReason({platform, status, gpus})` is the prose for every no — never collapsing a probe `'unknown'` into "no GPU". Pure: no filesystem, no docker, no network. |
| `opencodeStream.js` | OpenCode's `--format json` event stream — ONE parser, shared by `services/localModelAgentBenchmark.js` (which wants chars/tokens) and `services/modelCapabilityTests.js` (which wants a readable transcript). `eventPart` (accepts the flat `{part}` and nested `{properties.part}` envelopes OpenCode has both used), `isToolEvent` / `eventText`, `parseAgentLine` / `parseAgentEvents` (blank and unparsable lines yield nothing rather than throwing), `formatAgentEvent` (one frame → a transcript line a person can read, with the path or command the tool acted on), and `summarizeOpenCodeEvents` (assistant chars, tool calls, and output tokens — `null`, never 0, when OpenCode reported no usage; tool ARGUMENTS are deliberately not counted as answer text). Pure. The runner that produces the stream is `services/opencodeTask.js`. |
| `openAiModelsProbe.js` | `probeOpenAiModels(baseUrl, { timeoutMs, apiKey })` → `{ reachable, models, error }` — the one `GET {base}/models` probe for the local OpenAI-compatible daemons, shared by `services/providerReadiness.js` and `services/llamaServerManager.js`. Distinguishes unreachable from reachable-but-unlistable (`models: null`) from up-with-nothing-loaded (`[]`), names the real transport failure via `describeFetchError` (undici reports every one as a bare `fetch failed`), and cancels an unread body on a non-OK response. `apiKey` attaches a Bearer header for a key-gated daemon (vLLM's compose stack), and a 401/403 answers `reachable: true` with `error: 'authentication required'` — a server that refused the request is definitively running, and calling it unreachable would send the user to start it again. Consolidated after the two copies drifted — one passed its timeout as a `timeout` key inside the fetch init object, where it is not an option, silently running a 500ms poll loop on the 15s default. |
@@ -225,6 +230,7 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub
| `agentInstructionsFile.js` | The `AGENTS.md` + bridge `CLAUDE.md` pair a repo carries (#4852). `writeAgentInstructions(repoPath, content)` writes the body to `AGENTS.md` and the one-line `@AGENTS.md` import beside it — use it in scaffolders instead of a bare `writeFile(join(repoPath, 'CLAUDE.md'), …)`, since a generated repo carrying only one name is unreadable to half the CLIs PortOS can point at it. Constants: `AGENT_INSTRUCTIONS_FILENAME`, `CLAUDE_BRIDGE_FILENAME`, `AGENT_INSTRUCTIONS_IMPORT`. |
| `fileCore.js` | Cross-cutting filesystem primitives (`atomicWrite`, directory helpers, bounded tail reads/watchers), time/format helpers, directory sizing, and SHA-256 helpers. |
| `fileUtils.js` | Backward-compatible facade re-exporting the focused file utility modules so existing deep imports need no caller changes. |
+| `portosEnv.js` | Single server-side helper for PortOS's own `.env` (`PORTOS_ENV_PATH` anchored to `installRoot` per #1947, `parseEnvContents`, `readPortosEnvValue`, `upsertPortosEnvLine` + `upsertEnvLine`), with replacer-function guard for `$`-patterns and `process.env` wins precedence; `scripts/lib/envFile.js` stays as the zero-dependency boundary copy. |
| `secretText.js` | `scrubSecretTokens(text)` — replace credential-SHAPED substrings (prefixed API keys, GitHub/Slack tokens, JWTs, AWS key ids, pasted Bearer headers, 48+-char hex) with `[REDACTED]` in free text bound for an LLM provider or a world-readable artifact; `scrubSecretTokensDeep(value)` walks arrays/plain objects and scrubs every string value. Value-side counterpart to the operator-action ledger's key-based `redactPayload` and `commandSecurity.js#redactOutput`'s JSON patterns; conservative so prose, 40-hex git SHAs, and short ids survive. |
| `homePath.js` | `scrubHomePath(value)` — replace the running user's home-directory prefix with `~` anywhere in a string, so `/Users//…` never embeds the OS username in anything a user pastes into a bug report. Non-strings pass through; a root-user container reporting `/` as home is left alone (substituting on it would rewrite every separator in every path). Zero-dependency by design: `agentRunEvents.js` re-exports it for the CoS ledger, and `scripts/doctor.js` imports it statically because it must load from a bare checkout with no `node_modules` (`scripts/pre-install-entrypoints.test.js` enforces that). |
| `jsonIo.js` | JSON/JSONL parsing and file IO, strict read sentinels, append/read/write helpers, and `createCachedStore`. |
@@ -272,9 +278,9 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub
| `childProcess.js` | Drop-in `child_process` replacement that defaults every spawn to `windowsHide: true` — `spawn` / `spawnSync` / `fork` / `exec` / `execSync` / `execFile` / `execFileSync`, plus a `ChildProcess` re-export. **Server runtime code must import from here, never from `child_process` directly** (enforced by `childProcess.guards.test.js`): a console-less PM2 fork spawning a console child without `CREATE_NO_WINDOW` makes Windows hand the new console to Windows Terminal, which flashes a focus-stealing window. `exec`/`execFile` carry `util.promisify.custom` so `promisify(execFile)` still resolves to `{ stdout, stderr }`. An explicit `windowsHide: false` is respected. Background: `docs/WINDOWS_CONSOLE.md`. |
| `bufferedSpawn.js` | `bufferedSpawn(cmd, args, opts)` (structured non-throwing result) + `bufferedSpawnOrThrow` (throwing adapter), plus `killProcessTree`, `resolveWindowsExecutable`, `prepareWindowsSafeSpawn`, `prepareCliSpawn(command, args, env)` (composed resolve+wrap for a `spawn()`-safe pair), `needsShell`, `IS_WIN32`, `WIN_CMD_SHIMS`, `MAX_OUTPUT_BYTES`, `guardChildStdin(child)` (attach the no-op `stdin` `'error'` listener BEFORE any `stdin.write`/`stdin.end` — a child that dies before reading stdin makes the pipe emit `EPIPE`, and an unlistened stream `'error'` outside the Express request lifecycle takes the whole server process down; call it at every spawn that writes to a child's stdin) + `deliverChildStdin(child, payload, label)` (its other half: write-then-`end()` with a synchronously-throwing `write` caught, the pipe destroyed so a reading child sees EOF rather than hanging, and the failure logged so an empty-prompt run that exits 0 isn't filed as clean), `spawnFailureDetail(result, fallback)` (the most useful sentence from a failed result — prefers the spawn error, then a stderr line, then a stdout line that isn't bare JSON punctuation, because a `--json` CLI prints its payload to stdout even when it exits non-zero) — shared buffered-spawn machinery with capped stdout/stderr, timeout SIGTERM plus fire-and-forget SIGKILL escalation (`killGraceMs`, default 8s), Windows `.cmd`/`.bat` shim resolution, and `taskkill /T /F` tree-kill. `killProcessTree(child, signal, { processGroup })` takes the POSIX group down too when the child was spawned `detached` (Windows always tree-kills). A killable that is NOT a `ChildProcess` — a node-pty `IPty` TUI session — is killed through its own `.kill()`, with **no signal on Windows**: node-pty throws `Signals not supported on windows.` for any signal argument, so a signalled kill there killed nothing and threw past the caller. Used by `appBuilder.js`, `appUpdater.js`, the CoS agent spawners, and `setupScriptRunner.js`. |
| `setupScriptRunner.js` | `spawnSetupScript(envVars)` / `stopSetupScript(child)` / `SETUP_IMAGE_VIDEO_SCRIPT` — the one way to run `scripts/setup-image-video.sh` (shared by the Video Gen BYOV runtimes, the music engines and the MuScriptor venv). Runs it under `resolveBashBinary()` with a `toBashPath` script path, and on Windows presets the `PYTHON_BIN` the script would otherwise default to `python3` for. Cancel via `stopSetupScript`, which tree-kills so uv / pip / git die with bash. |
-| `commandExists.js` | `commandExists(cmd, args = ['--version'], { timeoutMs = 5_000, env, cwd })` — does running `cmd args` succeed? A capability probe (`execFile`-based), not a PATH lookup like `processEnv.js`'s `whichFirst`; `env`/`cwd` let a caller check the exact child process configuration. Consolidates the two previously-private copies in `localLlm.js`/`ollamaManager.js`; callers probing a heavier CLI (e.g. `codeReview.js`'s reviewer-binary probe) pass a longer `timeoutMs`. |
+| `commandExists.js` | `commandExists(cmd, args = ['--version'], { timeoutMs = 5_000, env, cwd })` — does running `cmd args` succeed? A capability probe (`execFile`-based), not a PATH lookup like `processEnv.js`'s `whichFirst`; `env`/`cwd` let a caller check the exact child process configuration. Consolidates the two previously-private copies in `localLlm.js`/`ollamaManager.js`; callers probing a heavier CLI (e.g. `codeReview.js`'s reviewer-binary probe) pass a longer `timeoutMs`. Sibling `commandOutput(cmd, args, opts)` runs the same probe but returns the trimmed stdout (or `null` when it could not run / exited non-zero), so a caller can read a `--version` banner or a `models` listing without spawning the same child twice. |
| `spawnCwd.js` | `resolveSpawnCwd(workspacePath, fallbackRoot, label)` — resolves and **logs** the working directory a run/agent spawns into (expanding `~`), and throws when a workspace was requested but is missing / not a directory. Behind `services/runner.js#resolveRunCwd`, which turns that throw into a normal failed-run record for the two spawning runners. Stops a bad app `repoPath` from silently spawning in the PortOS checkout (#3180). `usesCreativeDirectorScratchCwd(task)` / `creativeDirectorScratchCwd(agentId)` / `removeCreativeDirectorScratchCwd(agentId)` / `resolveAgentCliCwd({ workspacePath, fallbackRoot, task, agentId })` — Creative Director no-worktree tasks get a per-agent scratch cwd under `os.tmpdir()/portos-cd-cwd/` (outside the PortOS git tree) instead of the PortOS root, so native CLI AGENTS.md / CLAUDE.md discovery cannot walk up into the repo (#4650). `removeCreativeDirectorScratchCwd` is the matching finalize cleanup. `withSpawnCwdEnv(env, cwd)` — returns a copy of `env` with `PWD` pinned to `cwd` (dropping stale case-variant keys), because `spawn({ cwd })` doesn't rewrite the inherited `PWD` and OpenCode resolves its project root as `process.env.PWD ?? process.cwd()` (#3193). Apply it at every spawn that names its own cwd — the shared wrappers (`bufferedSpawn`, `spawnDetached`) already do, so their callers inherit it. `spawnCwd.test.js` discovers cwd-passing spawns across `server/` and fails on any that neither pins nor is listed exempt.
-| `commandSecurity.js` | Two allowlists, one parser. `validateCommand(cmd)` gates the OPERATOR-driven runner against `ALLOWED_COMMANDS` (+ `validatePm2Command(args)`, which rejects daemon-wide `pm2 kill`/`startup`/`unstartup` and ` all`). `validateUnattendedCommand(cmd)` gates the UNATTENDED lane (Layered Intelligence `cmd` sources) against the far narrower `UNATTENDED_READONLY_COMMANDS` — read-only inspection binaries only, no `npx`/`node`/`python`/`pip`/`curl`/`wget`/`brew`, since those execute network code with no shell metacharacter. Both share one parse + `DANGEROUS_SHELL_CHARS` body. Mirrored by the `agentGuard/` PATH shim for agentic paths. |
+| `commandSecurity.js` | Two allowlists, one parser. `validateCommand(cmd)` gates the OPERATOR-driven runner against `ALLOWED_COMMANDS` (+ `validatePm2Command(args)`, which rejects daemon-wide `pm2 kill`/`startup`/`unstartup` and ` all`). `validateUnattendedCommand(cmd)` gates the UNATTENDED lane (Layered Intelligence `cmd` sources) against the far narrower `UNATTENDED_READONLY_COMMANDS` — read-only inspection binaries only, no `npx`/`node`/`python`/`pip`/`curl`/`wget`/`brew`, since those execute network code with no shell metacharacter — then applies a per-binary SUBCOMMAND gate to the multi-purpose survivors, so `git reset --hard` / `git commit`, `find -delete` / `-exec`, and `gh`/`glab` writes (`api -X POST`, `pr merge`) are rejected while `git log`, `find -name` and `gh pr list` pass. Both share one parse + `DANGEROUS_SHELL_CHARS` body. Mirrored by the `agentGuard/` PATH shim for agentic paths. |
| `detachedSpawn.js` | `spawnDetached(bin, args, {controlDir,env,cwd,killProcessGroup?})` → ChildProcess-like handle for a long media job that SURVIVES `pm2 restart portos-server`. A pure-`sh` double-fork reparents the job to init (escaping pm2's PPID-based TreeKill — `detached:true` alone doesn't, since it only changes the process group); the server tails on-disk log files for `stdout`/`stderr`/`close`. Group-kill mode persists a marker so cancel, reattach, and orphan reaping terminate a group-leader wrapper plus every runtime child together. Windows has no double-fork (plain-spawn fallback), so its handle's `kill` delegates to `killProcessTree` (`taskkill /T /F`) — a cancel there takes the runner's children with it. Used by loraTraining + videoGen. Also exports `reattachDetached(controlDir)` / `isReattachable(controlDir)` to RE-ATTACH a survivor after a restart, `isDetachedRunning(controlDir, expectedProcess?)` with optional executable/argument validation for fixed-command control dirs, and `reapDetached` to checkpoint-kill one when re-attach isn't possible. |
| `hostShutdown.js` | Tells "PortOS was restarted out from under a running agent" apart from "the agent failed" (#3202). `markHostShuttingDown()` / `isHostShuttingDown()` are the in-process latch the SIGTERM/SIGINT handler sets first thing; `shouldAbandonForHostShutdown({sentinelPresent,terminatedByUser,paused})` keeps every spawn path on the same preserve-vs-finalize policy. `writeHostShutdownMarker({agentIds,signal})` / `readHostShutdownMarker()` / `clearHostShutdownMarker()` persist that verdict to `data/cos/host-shutdown.json` so the NEXT boot's orphan sweep can requeue those agents as *interrupted* — no orphan-retry charge, no 30-minute cooldown. All non-throwing: a missing marker degrades to the ordinary orphan path. |
| `execGit.js` | `execGit(args, cwd, options)` utility imported by `git.js` + worktree manager. `cwd` is REQUIRED — it rejects on a missing/blank one rather than letting `spawn` fall back to `process.cwd()` and run git against PortOS's own checkout (#4554). |
@@ -340,6 +346,7 @@ pm` default, `NPM_CONFIG_PREFIX`, nvm/Volta) installed `codex` successfully and
| Module | Purpose |
|---|---|
+| `clientApiPaths.js` | Static scan of the paths `client/src/services/api*.js` requests, for the client↔server route-parity guard (`apiRouteParity.test.js`). `scanClientApiPaths({ repoRoot | sources })` constant-folds the first argument of every `request(…)` — and of every `fetch(`${API_BASE}…`)` the streaming/blob wrappers use instead — from string and template literals, module-local path helpers, ternaries and `...helper(path, …)` spreads into normalized `/api/...` paths (dynamic segments → `:p`), and REPORTS what it cannot fold instead of dropping it. `findUnmountedClientPaths(clientPaths, serverPaths)` diffs those against `scripts/generate-api-route-catalog.js` output, honoring Express 5 `*wildcard` segments. Pure. |
| `htmlToText.js` | Shared HTML → plain-text converter. `htmlToText(html, { extraEntities?, paragraphBreak?, collapseSpaces? })` — strips script/style/head/noscript blocks, converts ` `/block closes to newlines, strips remaining tags, decodes entities via `decodeXmlEntities`, collapses 3+ newlines, trims. Options preserve caller-specific output (Gmail: `paragraphBreak: '\n\n'` + `collapseSpaces`; SongBook import: defaults — space runs preserved for tab alignment). |
| `jsonExtract.js` | Pull JSON blocks out of LLM responses. `findBalancedBlocks` walks top-level blocks and stops at the first unbalanced one; `findAllBalancedBlocks` is the stack-based variant that scans past stray braces and returns nested blocks in closing order (reverse it for outermost-newest-first). |
| `taskParser.js` | Parse `TASKS.md` format. |
@@ -387,11 +394,13 @@ pm` default, `NPM_CONFIG_PREFIX`, nvm/Volta) installed `codex` successfully and
| `learningVerdict.js` | The three-way success-criteria verdict a completed agent run carries on `result.validationPassed` (#4107). `true`/`false` = a declared criterion was met/missed; `null` = none declared (record the run, fall back to the exit code); `SKIP_LEARNING_VERDICT` = nothing ever evaluated the run, so task-learning must not record it at all (a programmatic-I/O output hook that bailed on `no-app`/`app-not-found`). `isSkipLearningVerdict(v)` gates the skip; `toValidationVerdict(v)` narrows anything non-boolean — including the sentinel — back to `null` for downstream telemetry. JSON-safe string (not a Symbol) because the verdict is persisted and read back by the learning backfill, and older readers already narrow on `typeof v === 'boolean'`. Pure. |
| `investigationTasks.js` | Investigation-task identity, approval, auto-retry, and PR-backed delivery policy — shared by the producers (`services/investigationTaskProducer.js`), the reaper (`cosTaskStore.js`, which used to hand-copy the predicate to dodge an import cycle) and the retry (`services/investigationRetry.js`). `isInvestigationTask(task)` (durable `isInvestigation` marker, falling back to the `INVESTIGATION_HEADLINE_PREFIX` headline for pre-#2615 / peer-synced tasks); `INVESTIGATION_TASK_DELIVERY` keeps unattended investigations in an isolated worktree and routes them through a PR merged on green; `buildInvestigationFingerprint(task, analysis)` / `investigationFingerprint({category, kind, scope})` → the `category:kind:scope` dedup key; `resolveInvestigationApproval({fingerprint, tasks, recentCreations})` → unattended by default (#3714), held only on a `repeat-fingerprint` or `failure-storm` loop, with `approvalReason` + `loopProse` for the queue UI; `couldReleaseBlockedTasks(investigation)` is the pure pre-read gate for `resolveInvestigationRetryTargets({investigation, tasksById})`, which returns the failure-blocked tasks a just-completed investigation releases plus every skip and its `RETRY_SKIP_REASONS` reason (an `auto-expired` completion, a non-`blocked` task, a `NON_AUTO_RETRY_BLOCK_CATEGORIES` block, or a task past `MAX_AUTO_RETRIES_PER_TASK`); `autoRetryMetadata(task, investigationId, now)` stamps the budget that survives the revive's own `failureCount` reset. Pure. |
| `mediaItemKey.js` | `:[` key vocabulary for media items. |
+| `assetProvenance.js` | Stamp-time model/LoRA license provenance (`buildProvenance` / `provenanceForRender` / `rollupProvenance`). Unknown stays `null` (displayed as "unknown") — never a permissive default. Mirrored byte-for-byte to `client/src/lib/assetProvenance.js`. |
| `migrationMarker.js` | Shared marker-file helpers for one-time migration/repair/reconcile scripts — `markerExists(filename)` (boolean gate), `readMarker(filename)` (parsed payload or null), `writeMarker(filename, payload)` (atomic write). All anchor `filename` under `PATHS.data` and use `tryReadFile`/`atomicWrite` so a crash can't leave a truncated marker. |
| `goalFeatureMap.js` | Deterministic goal `category` → PortOS feature-area map (deep-links sourced from `NAV_COMMANDS`). `getGoalFeatureAreas(goal)` honors the per-goal `featureAreas` override, else the category default. Mirrored byte-for-byte to `client/src/lib/`. |
| `navManifest.js` | Single source of truth for nav (`⌘K` palette + voice). Add an entry when you add a page. |
+| `noReplaceMove.js` | `moveWithoutReplace(from, to)` — publish a staged file into its final name WITHOUT ever clobbering an existing one. `fs.rename` silently replaces its destination, which is the wrong default for a derived artifact; this uses `link(2)` + `unlink(2)`, so an existing destination fails atomically with `MOVE_DEST_EXISTS` and both files survive. Refuses rather than degrading when the filesystem cannot express it (`MOVE_CROSS_DEVICE`, `MOVE_NO_REPLACE_UNSUPPORTED`) — a `stat`-then-`rename` fallback would be a race. Used by the rigging publication contract (`services/rigging/autoSkin.js`). |
| `personaTraitBlend.js` | Digital-twin persona trait-blending (M34 P7). Blends a persona's `traitAdjustments` against the base twin's communication profile + Big-Five into a "Communication Calibration" directive. Mirrored to `client/src/lib/`. |
-| `textUtils.js` | Pure dependency-free prose helpers. `countWords(text)` is the canonical whitespace-token count (`\S+`); `trimTo(value, max)` trims and bounds strings without coercing non-strings, and is safe for shared modules consumed by the browser; `escapeRegExp(value)` is the one to import instead of re-inlining the escape (a guard in `textUtils.test.js` fails the suite when a copy reappears in any non-test source under `server/`); `kebabCase(text)` is the canonical ASCII slug transform (PLAN.md `[slug]` ids and `planner:]` labels). |
+| `textUtils.js` | Pure dependency-free prose helpers. `countWords(text)` is the canonical whitespace-token count (`\S+`); `trimTo(value, max)` trims and bounds strings without coercing non-strings, and is safe for shared modules consumed by the browser; `escapeRegExp(value)` is the one to import instead of re-inlining the escape (a guard in `textUtils.test.js` fails the suite when a copy reappears in any non-test source under `server/`, or in ANY source under `client/src/`, tests and `.jsx` included — the escape half is mirrored on the client at `client/src/lib/textUtils.js`, which the browser imports since it cannot reach `server/lib`); `kebabCase(text)` is the canonical ASCII slug transform (PLAN.md `[slug]` ids and `planner:` labels). |
| `pipelineIssueOrder.js` | Pure renumber algorithm for pipeline issues. |
| `postAdaptive.js` | Pure POST adaptive-difficulty policy — nudges a math drill's primary knob (`steps`/`maxDigits`/`maxExponent`/`tolerancePct`) up/down within clamped bounds from recent scored performance. Opt-in via the config Adaptive toggle. |
| `postAppliedNumeracy.js` | Pure seeded Applied Numeracy pack — everyday percentage, ratio, unit, rate, and estimation scenarios plus server-authoritative numeric/fraction/unit scoring with explicit tolerance handling. |
@@ -408,7 +417,10 @@ pm` default, `NPM_CONFIG_PREFIX`, nvm/Volta) installed `codex` successfully and
| `markdownText.js` | `stripMarkdownEmphasis(text)` — unwrap `**bold**` / `~~struck~~` / `` `code` `` / `[text](url)` and drop HTML comments, keeping the words. For strings handed to something that can't read markdown (a model's text encoder, a slug builder, TTS); leftover lone `*_~` become spaces so an unbalanced marker can't fuse two words. Does not collapse whitespace — callers that need that normalize themselves. |
| `renderSlot.js` | Render-slot helpers for `(proof\|final)Image` per stage. |
| `renderTargets.js` | Render-target alphabet (#3231): `RENDER_TARGET`/`RENDER_TARGETS` name every creative surface that enqueues renders (universe-bible, sprite-reference, music-video, …) — the keys into `settings.renderDefaults` — plus the `RENDER_TARGET_BACKEND_AUTO` fall-through sentinel. Dependency-free leaf; resolved per surface by `services/imageGen/cloudProviderConfig.js#resolveRenderTargetConfig`. |
+| `renderTiming.js` | `renderTimingFields(startedAtMs, nowMs?)` → the `{ renderMs, renderStartedAt, renderCompletedAt }` spread every image/video backend stamps on the record it persists, measuring ingestion → finished artifact (not queue wait). Returns `{}` when no start instant was observed, so absence stays the explicit unknown sentinel for the history cards and `services/videoGen/eta.js`. `omitRenderTiming(record)` is the inverse, for a DERIVED record built by spreading its source (an image variant) that must not inherit a duration it never took. |
| `telegramClient.js` | Telegram bot client. |
+| `telegramMessage.js` | Pure builder for the Telegram notification wire message, shared by both transports (`services/telegram.js` and `services/telegramBridge.js`, which drifted apart while each kept a copy — #5688). `buildNotificationMessage(notification, { approvalBody })` → `{ text, options }`: emoji + escaped title, the body truncated to `TELEGRAM_MAX_RAW_CHARS` (2800) BEFORE escaping so the escaped result stays under Telegram's 4096 cap and a slice can't split an entity, the priority line, and the memory approve/reject `reply_markup` as a plain OBJECT (both transports post JSON, so a pre-serialized string would arrive as a literal string). `approvalBody` is resolved by the caller because the memory lookup is I/O. Also exports `escapeHtml`, `truncateForTelegram`, `isMemoryApprovalNotification`, the `NOTIFICATION_EMOJI`/`PRIORITY_EMOJI` maps (keyed by literal `NOTIFICATION_TYPES` values to stay a dependency-free leaf; pinned to the real enum by a parity test), and the `CALLBACK_APPROVE`/`CALLBACK_REJECT` prefixes the bot's `callback_query` handler parses back out. |
+| `telegramRateLimit.js` | `createTokenBucket({ max = 30, refillMs = 60_000 })` → `{ consume() }` — the coarse full-refill-per-window bucket both Telegram transports use against Telegram's ~30 msg/min throttle. Each transport creates its OWN instance: they are never both active, and one shared bucket would let a transport inherit the other's drained budget. Pure (reads `Date.now()`, owns no timer). |
| `tempPathGuard.js` | Throwaway-path guard for destructive test fixtures. `isTempPath(target)` — true only for an absolute path that is a STRICT descendant of `os.tmpdir()` **after symlinks are resolved** (`os.tmpdir()` itself is refused, or `destroyGitSandbox(tmpdir())` would wipe every process's scratch; a `..` segment is refused rather than lexically collapsed; macOS `/var/folders/...` and `/private/var/folders/...` both pass). `assertTempPath(target, operation)` throws otherwise and returns `target` so it can wrap an argument inline. Use it before any fixture `git init` / `git config` / `rm -rf`: `spawn` silently substitutes `process.cwd()` for a missing cwd, which is how a test run once set `core.bare = true` on a real checkout (#4554). |
| `vaultCrypto.js` | Privacy Center PII Vault field-level encryption (issue #2140). AES-256-GCM `encryptValue`/`decryptValue` (`v1:::` format, per-value 12-byte IV), `ensureVaultKey()` self-heal (generates `PRIVACY_VAULT_KEY` into the install root's `.env` on first write, replacing any invalid line; never logs the value), key resolution that falls back to reading `.env` so decrypt/status survive a server restart, `isVaultKeyConfigured()`, and the per-type `maskValue(type, plaintext)` display masking (last-4 / domain-visible / street-masked). Plaintext must never be logged by callers. |
@@ -441,7 +453,7 @@ pm` default, `NPM_CONFIG_PREFIX`, nvm/Volta) installed `codex` successfully and
| `apiAccessPolicy.js` | Shared always-public path and gated non-`/api` prefix policy consumed by both `authGate` and API discovery. |
| `apiCatalog.js` | Searchable projection of the generated Express route manifest: domain, access, side-effect, contract coverage, summaries, and Express-to-OpenAPI path conversion. |
| `socketEventCatalog.js` | Searchable projection of the cached Socket.IO inventory: direction, domain, and runtime-schema coverage. |
-| `sourceScan.js` | Lexer-assisted primitives shared by the whole-tree source-scan guard suites, so the timer rule and the socket rule cannot drift on what "owns its rejection" means. `blankLiterals(src)` blanks comment/string/template/regex CONTENT to spaces while preserving length, so a brace inside a literal cannot skew a bracket walk and a caller can still read a literal (a socket event name) out of the original string at the same offset; `matchBracket(src, open)` returns the index past the matching `)`/`]`/`}`; `parseCallbackAt(blanked, from, limit)` parses the function expression at `from` (`async`/`function`/either arrow spelling, skipping the parameter list as a unit so a `= {}` default is not mistaken for the body) and returns `{isAsync, start, text}`; `unguardedAwaits(body)` returns the awaited chains that neither sit inside a `try`/`catch` nor END in `.catch(…)`. `blankComments(src)` is the weaker LINE-based stripper the per-line `child_process` rule needs. Callers: `childProcess.guards.test.js`, `server/timerCallbackConventions.test.js`, `server/sockets/asyncHandlerGuard.test.js`. |
+| `sourceScan.js` | Lexer-assisted primitives shared by the whole-tree source-scan guard suites, so the timer rule and the socket rule cannot drift on what "owns its rejection" means. `blankLiterals(src)` blanks comment/string/template/regex CONTENT to spaces while preserving length, so a brace inside a literal cannot skew a bracket walk and a caller can still read a literal (a socket event name) out of the original string at the same offset; `matchBracket(src, open)` returns the index past the matching `)`/`]`/`}`; `parseCallbackAt(blanked, from, limit)` parses the function expression at `from` (`async`/`function`/either arrow spelling, skipping the parameter list as a unit so a `= {}` default is not mistaken for the body) and returns `{isAsync, start, text}`; `unguardedAwaits(body)` returns the awaited chains that neither sit inside a `try`/`catch` nor END in `.catch(…)`. `blankComments(src)` is the weaker LINE-based stripper the per-line `child_process` rule needs. Callers: `childProcess.guards.test.js`, `server/timerCallbackConventions.test.js`, `server/sockets/asyncHandlerGuard.test.js`, `server/process-safety-net.test.js`. |
| `apiOperationContracts.js` | Detailed operation metadata for intentionally public APIs. It consumes the canonical route Zod contracts and feeds both public and internal OpenAPI documents. |
| `apiRegistry.js` | Single source of truth for which PortOS services are externally-callable HTTP APIs (`voice`, `sdapi`). `API_REGISTRY` declares each API's `publicPrefixes` (read/compute-safe surface only) + defaults; `isRegistryPublic(settings, path)` tells `authGate` when an `exposed && !requireAuth` API re-opens its prefix; `resolveApiAccess(settings)` merges persisted `apiAccess` flags for the Settings UI + OpenAPI docs. |
| `arrayUtils.js` | `shuffle(arr)` — Fisher-Yates shuffle (new array, never mutates). The canonical uniform shuffle — never `arr.sort(() => Math.random() - 0.5)`, which is biased. Shared by `meatspacePostCognitive.js` (Schulte table / mental rotation) and `meatspacePostMemory.js` (memory drill generators). `dedupeByKey(items, keyOf, pick?)` — one survivor per key, first-seen order. **Required before any multi-row `INSERT … ON CONFLICT (key) DO UPDATE`**: Postgres refuses the whole statement ("ON CONFLICT DO UPDATE command cannot affect row a second time") when its VALUES list names one conflict key twice, and the rows a batcher joins usually come from something that promises no uniqueness (a disk scan, a peer payload). `DO NOTHING` upserts are exempt. `pick(held, candidate)` defaults to last-seen-wins (what a sequential one-row upsert loop leaves); pass a comparator when the table's conflict rule isn't "latest write" — `memorySync.applyRemoteChanges` keeps the newest `updatedAt` so a peer's payload ordering can't flip a last-writer-wins outcome. Used by `services/mediaAssetIndex/db.js` and `services/memorySync.js`. |
@@ -458,6 +470,7 @@ pm` default, `NPM_CONFIG_PREFIX`, nvm/Volta) installed `codex` successfully and
| `isoWeek.js` | ISO-8601 week identity — the single source of the `YYYY-Www` week id. `getWeekId(date)` keys on the ISO week-numbering **year** (the calendar year of that week's Thursday), not `date.getFullYear()`, so one ISO week is never split across two ids and two weeks never collide on one (#3465). Also `isoWeekParts`, `getIsoWeekNumber`, `getIsoWeekYear`, `parseWeekId` (null on garbage), and `isoWeeksInYear(year)` (52, or 53 in a leap-week year). Shared by productivity week aggregates and weekly digest filenames. |
| `snapshotChecksum.js` | The two snapshot-checksum flavours a sync category can want. `snapshotChecksum(data)` hashes `JSON.stringify` — insertion-order SENSITIVE, correct only where the getter already canonicalizes its own ordering (`dataSync.js`, `digital-twin-sync.js`). `canonicalSnapshotChecksum(data)` hashes `canonicalStringify`, so two converged machines hash identically regardless of the order they learned the data (`peerUsage.js`, whose payload is a map keyed by wire-supplied instance ids). Picking the wrong one is not a crash — it is two synced peers whose checksums never match, which the sync UI reads as "behind" forever. |
| `lwwTimestamp.js` | Last-writer-wins timestamp comparison for cross-instance sync merges. `parseTsMs(s)` (Date.parse → epoch ms or null), `compareNewerWins(candidate, incumbent)` (true iff candidate strictly newer; unparseable-loses, tie → incumbent — used to decide remote-overwrites-local), `compareEarlierWins(a, b)` (−1/0/1 earliest-wins tiebreak; unparseable-loses). Single source of the LWW polarity shared by `mergeMediaCollectionsFromSync` / `mergeAuthorsFromSync` etc. |
+| `syncManifest.js` | Wire contract for the snapshot-sync MANIFEST leg — the fix for a category that is always dirty AND a map of per-instance slots (`usage`), where one instance advancing dragged every other instance's digest across the wire. `isManifestEnvelope(value)` validates a `{ data: { instances }, checksum }` response (a legacy peer that 404s the endpoint fails it, and the puller falls back to the whole snapshot); `diffManifestSlots(remoteInstances, localInstances)` returns the sorted slot ids whose REMOTE LWW stamp is strictly newer than ours — exactly the slots worth fetching. Comparison goes through `lwwTimestamp.js`, so a tie breaks to what we already hold. Consumed by `syncOrchestrator.syncDataCategoryFromPeer`; served by `dataSync.getManifest`. |
| `mapWithConcurrency.js` | Generic bounded-concurrency async mapper that preserves input order while capping in-flight work. |
| `markedSection.js` | Marker-delimited section replacement (pure). `buildMarkers(id)` → `{ start, end }` HTML-comment marker pair; `replaceMarkedSection(content, body, markers)` splices/replaces/removes an auto-generated region without touching surrounding user content (idempotent); `extractMarkedSection` / `hasMarkedSection` read it back. Powers the daily-log activity-digest auto-drafts (#2155) via `brainJournal.upsertAutoSection()`. |
| `objects.js` | Object utilities — `deepMerge` (recursive merge w/ array replacement), `isPlainObject` (non-null, non-array `object` guard for JSON / LLM payloads), `POLLUTING_KEYS` (shared `__proto__`/`constructor`/`prototype` denylist for sanitizers), `canonicalStringify` (recursive sorted-key JSON serialization for cross-machine content hashing), `isEmptyScalar` (true for null/undefined/whitespace-string/empty-array — merge gap-fill gate). |
@@ -465,7 +478,10 @@ pm` default, `NPM_CONFIG_PREFIX`, nvm/Volta) installed `codex` successfully and
| `openapiDowngrade.js` | `OPENAPI_VERSION` plus `toOpenApi30Schema` / `toOpenApi30Operation`, which rewrite Zod's draft-2020-12 output (null unions, numeric exclusive bounds, `const`, tuples, `propertyNames`) into the OpenAPI 3.0.3 dialect. Apply ONLY at the OpenAPI document boundary — the AsyncAPI payloads, CoS provider tool definitions, and tool resource read the same schemas as plain JSON Schema, where these rewrites silently widen bounds and drop null branches. |
| `apiToolResource.js` | Builds the minimized semantic tool resource served at `/api/api-docs/tools.min.json` — only `x-portos-tool`-annotated operations, flattened to provider-neutral tool records with an HTTP binding and a shared error vocabulary. |
| `asyncApiSpec.js` | Builds the AsyncAPI 3 Socket.IO document from the generated event catalog with direction-aware operations and explicit modeled/generated payload status. |
+| `mergeGateContract.js` | `mergeGateOwed({taskOpenPR, ownsPrWorkflow, leaveOpen})` decides whether a completing run actually owed its own PR merge; `resolveMergeGateVerdict({prProbe, summary, alreadyReprompted})` classifies a run that did (`merged` / `unreadable` / `leave-open-stated` / `needs-reprompt` / `reprompt-exhausted`) from the PR's live state and the agent's own sentinel text; `summaryStatesLeaveOpen` and `buildMergeGateReprompt` are its text-matching and corrective-prompt helpers. Pure — the PR lookup lives in `../services/prProbe.js`, the re-prompt delivery in `agentTuiSpawning.js`. |
| `prDisposition.js` | `resolvePrCompletion(metadata)` resolves the explicit `review-then-merge` / `merge-on-green` / `leave-open` policy, with legacy `reviewLoop` fallback; `leavesPrForHuman(task)` + `PR_STAYS_OPEN_TASK_TYPES` keep JIRA hand-offs open. Shared by the agent prompt builder and `agentWorktreeCleanup` so both halves agree. `resolvePrCreation({taskOpenPR, agentOwnsPr, prClaimVerified})` → a `PR_CREATION` tri-state (`never` / `if-missing` / `always`) naming who opens the change request for a completing worktree agent, so the runner, TUI, and direct-CLI completion paths cannot drift into double-firing `gh pr create`. |
+| `prHandbackPolicy.js` | Whose turn it is on a public PR the pr-reviewer coordinator reviewed but did not merge. `resolvePullRequestWriteAccess(pr)` reads `isCrossRepository`/`maintainerCanModify` into a `PR_WRITE_ACCESS` reason, failing closed on an unknown head-repository relationship; `resolveHandbackDisposition({requestedChanges, notMergeReady, downgraded, deferred, canEdit, remediationExhausted})` returns a `PR_HANDBACK` verdict — `remediate` (PortOS pushes the fixes and lands it), `assign-opener` (the PR goes to its author queue), or `none`. A deferred or unanchorable review never becomes an agent work order. Pure. |
+| `prReviewReport.js` | Owns the structured PR-decision contract end to end: `PR_REVIEW_DECISION_CONTRACT` is the envelope both review producers (pr-reviewer stage 3, issue-watcher reasoning pass) interpolate into their prompts, `normalizeReviewReport` bounds what comes back, `reviewReportText` hands every model-authored string to the abuse scan, and `renderReviewBody`/`renderFinding` turn it into the markdown a human reads on the PR page — verdict banner, scope line, blocking/non-blocking index anchored to `path:line`, test-evidence bullets (`TEST_EVIDENCE_STATUSES` keeps `fail` for "the change is broken", with `expected-fail` and `blocked` for the non-zero exits that say nothing about it), notes, collapsed verified-claims list, and inline comments with an optional GitHub suggestion block. Bounded by `MAX_REVIEW_BODY_CHARS`, dropping whole low-priority sections instead of truncating mid-sentence. Pure. |
| `repoStateExpectations.js` | Post-completion repo-state audit, pure half. `resolveRepoStateExpectation({...})` answers whether one finished worktree agent should be audited — naming every not-audited path via `REPO_STATE_SKIPS` (a failed run is preserved for its retry; a review-loop follow-up or pr-watcher pending merge still owns the branch) — and returns just `staysOpen` + `prExpected`, from which `classifyRepoStateIssues(expectation, observed)` derives every check into `REPO_STATE_ISSUES` codes. Observations are tri-state: `null` ("could not ask") never produces an issue. `repoStateVerificationEnabled(app)` reads the per-app `verifyRepoStateOnCompletion` switch (unset = on). Probing + remediation live in `services/agentRepoStateVerification.js`. |
| `shellCd.js` | `buildCdCommand(path, shell)` + `formatShellCommandLine(command, args, shell)` + `detectShellFlavor(shell, platform?)` + `quoteForShell(value, flavor, position?)` — build `cd` and arbitrary command-token lines for the shell a PTY session is ACTUALLY running (`cmd.exe` → Windows-escaped double quotes, PowerShell → doubled single quotes with `&` for a quoted command token, everything else → POSIX `shellQuote`). The Shell page's "cd to app" picker used to hard-code the POSIX form, which on Windows both mis-quoted the path and silently refused to cross drives (a bare `cd` does not switch drive). Flavor comes from the shell binary name, not the platform, so git-bash on Windows still gets POSIX quoting. `formatShellCommandLine` joins a command + argv into one quoted line and is shared by `agentTuiSpawning.js#buildTuiSpawnConfig` and the AI Providers page's "Launch in Shell" deep link, so a hand-launched TUI provider is quoted exactly the way the CoS runner would quote it. Renders the LINE only; the Enter byte that submits it is `SUBMIT_KEY` in `tuiHandshake.js`. |
| `shellExit.js` | `buildRunThenExitCommand(commandLine, shell)` — "run this CLI, then close the shell with its status", in the dialect the session speaks. An agent TUI shell exists only to host one CLI and must die with it. The POSIX `cmd; exit $?` was applied everywhere and is actively wrong off-POSIX: in PowerShell `$?` is a BOOLEAN, so `exit $?` reports success as 1 and failure as 0 (inverted) — use `$LASTEXITCODE`, pre-seeded to 1 so a command that never ran still exits non-zero; in cmd.exe `;` is an argument separator, so the CLI is handed `;`/`exit`/`$?` as arguments — use `& exit`. Verified against node-pty on Windows 11 for pwsh 7, PowerShell 5.1 and cmd.exe. |
@@ -479,7 +495,7 @@ pm` default, `NPM_CONFIG_PREFIX`, nvm/Volta) installed `codex` successfully and
| `slashdoLoader.js` | Loads bundled slashdo command/lib markdown (`lib/slashdo/` submodule) and resolves it the way slashdo's own per-environment installer would, since PortOS inlines these bodies into CoS-agent prompts WITHOUT running that installer. `loadSlashdoFile(cmd, {stripFrontmatter, skipIncludes})` / `loadSlashdoLib(name, {teams})` resolve the `` !`cat ~/.claude/lib/…` `` includes + `if:teams` conditionals, with `skipIncludes` pruning lib includes the run can never reach (each leaves a one-line "not applicable" marker — #3110); `writeResolvedSlashdoBody(cmd, body, {skipIncludes})` → absolute path, writing the already-loaded body under `PATHS.slashdoResolved` (`data/cos/slashdo-resolved/`, gitignored derived cache) at most once per (command, body) per process, so a file-tool host can be handed a path instead of a 250KB paste. Split out of `fileUtils.js` (a generic file-utilities module) as its own feature module — pairs with `slashdoInvocation.js`, which resolves how a workflow is TYPED per host CLI rather than how its body is loaded. |
| `singleFlight.js` | `createSingleFlight()` → `run(key, fn)` — keyed in-flight coalescer: concurrent calls for the same key share one `fn()` execution and result; the slot auto-clears on settle. Minimal by design (no TTL/result cache layered on top, doesn't reject concurrent callers). Used by `services/promptRunner.js`'s fallback mark-and-pick. |
| `staleWhileRevalidate.js` | `createStaleWhileRevalidate({ ttlMs, failureBackoffMs?, isComplete?, partialTtlMs? })` → `read(key, produce, { wait })` / `clear(key?)` — TTL cache that serves a STALE value immediately and revalidates behind the caller, for readings too slow to block on (10-20s CLI/PTY spawns). `wait`: `'fresh'` bypasses and blocks, `'cached'` (default) blocks only on a cold cache, `'never'` returns the `PENDING` symbol on a cold cache for UIs that render "still reading" and poll. Failures keep the last good value and back off; a cold cache whose producer failed throws rather than promising a reading that isn't coming. Used by `providerUsage.js` (TUI scrapes) and `claudeCodeUsage.js`. |
-| `staticImportGraph.js` | Static ES-module import scanning for structural test guards. `staticImportSpecifiers(file)` → every specifier a file statically imports (verbatim, source order); `staticImportClosure(entry)` → `{files, packages}` for the whole reachable graph; `specifierMatchesPackage(spec, pkg)` matches a package or any subpath. Static imports only — `await import()` is deferred and can't create a load-time cycle or drag a native dep into an init graph. Shared by `agentImportCycles.test.js` (acyclicity) and `sprites/animationTracks.test.js` (the request-validation graph reaches no sharp/ffmpeg). |
+| `staticImportGraph.js` | Static ES-module import scanning for structural test guards. `staticImportSpecifiers(file)` → every specifier a file statically imports (verbatim, source order); `staticImportClosure(entry)` → `{files, packages}` for the whole reachable graph; `buildStaticImportGraph(rootDir)` → `Map` of one directory's internal static edges (recurses into subdirectories, skips `.test.js`); `listModuleFiles(rootDir)` → every non-test `.js` under a directory, keyed by `/`-separated relative path (the same walk the graph builder uses, shared so a layering guard cannot drift from it); `findImportCycles(graph)` → each cycle rendered as `a.js -> b.js -> a.js` (depth-first, so WHICH rings it names depends on where the walk enters a component — fine for an "is this empty?" assertion, unusable as a baseline); `findImportCycleComponents(graph)` → the cyclic strongly-connected components as sorted member lists, traversal-order invariant and therefore baselineable (#5693); `specifierMatchesPackage(spec, pkg)` matches a package or any subpath; `toModuleKey(relPath)` is the one graph-key mint — always `/`-separated, because the keys are built two ways (entry-name concatenation vs `path.relative`) and Windows spells the second one with `\`, which silently dropped every edge into a subdirectory module and made the acyclicity guards pass vacuously there (#5909). Static imports only — `await import()` is deferred and can't create a load-time cycle or drag a native dep into an init graph. Shared by `serviceImportCycles.test.js` (the tree-wide cycle ratchet), `agentImportCycles.test.js` and `twinImportCycles.test.js` (per-cluster acyclicity) and `spriteAnimationTracks.test.js` (the request-validation graph reaches no sharp/ffmpeg). |
| `sseUtils.js` | Per-job SSE stream helpers (imageGen + others) plus `createSseRunner` — the shared batch-runner lifecycle (runs map, terminal-frame replay, cancel, fire-and-forget coordinator) used by the pipeline completeness/analysis/checks runners. |
| `streamAttachment.js` | `streamAttachment(res,stream,{filename,contentType,failure,label})` — pipe a readable to a response as a file download. Sets the attachment headers plus `X-Content-Type-Options:nosniff`, and owns the teardown every attachment route needs: a pre-stream failure drops the download headers and returns `failure` via `sendErrorResponse`, a mid-stream failure destroys the socket (the envelope no-ops once headers are sent), and a client disconnect calls `stream.abort?.()` so an upstream child process is torn down. Shared by `routes/imageTo3d.js` (GLB, full-mesh) and `routes/backup.js` (snapshot tarball). |
| `streamBackpressure.js` | `awaitWritableDrain(res)` — park a streaming-response producer on the socket's next `drain` (or `close`) when `res.write()` returned false, so SSE/NDJSON writes stay bounded for a slow reader. Shared by `routes/ask.js` (SSE) and `routes/localLlm.js` (NDJSON). |
@@ -487,7 +503,7 @@ pm` default, `NPM_CONFIG_PREFIX`, nvm/Volta) installed `codex` successfully and
| `repoIntakeActions.js` | `REPO_INTAKE_KEYS` + `normalizeRepoIntake(input)` — the opt-in post-clone agent actions a Brain capture can request for a GitHub repo URL (`malwareScan` → `/do:scan`, `learn` → a `repo-study` review). Pure half of `services/repoIntake.js` (which pulls the CoS task graph), so the link write path and the Zod schemas can import it freely. Normalizes to null when nothing was ticked, so "no intake" is never persisted onto a link. |
| `tombstones.js` | Generic timestamped tombstones (`{ , deletedAt }`) that let an otherwise add-only peer merge represent a DELETE, so a record removed on one machine is not resurrected by a peer that still has it (#3530). `normalizeTombstones` / `recordTombstone` / `clearTombstone` / `tombstoneTimestamp` maintain the list; `mergeTombstones` unions it in both directions (newest deletion per key wins) so a delete propagates rather than only defending locally; `isTombstoned(list, key, createdAt)` suppresses a record unless its own creation stamp is strictly NEWER than the deletion; `pruneTombstones(list, records)` drops tombstones a re-created record has superseded (otherwise a stale peer copy keeps reaping it); `supersedingTimestamp(deletedAt)` stamps a re-create that lands in the same millisecond (or behind a skewed peer clock). Comparison goes through `lwwTimestamp.js`, so polarity matches every other sync merge. Key on a field that means the same thing on every machine — locally-minted ids usually do not. `DEFAULT_TOMBSTONE_LIMIT` (200) caps growth. |
| `uploadLimits.js` | Single source of truth for upload size caps — `JSON_BODY_LIMIT`/`JSON_BODY_LIMIT_BYTES` (the express.json limit applied in `index.js`), `MAX_BASE64_UPLOAD_BYTES` derived from it (base64 ×4/3, so a bigger per-route cap is unreachable), `MAX_SCREENSHOT_BYTES`. Mirrored client-side as `JSON_UPLOAD_MAX_FILE_SIZE`. |
-| `userActionTypes.js` | Closed vocabulary for the operator-action ledger (`user_action_events`, #5594): `USER_ACTION_TYPES` (the phase-1 action list), `USER_ACTION_ACTORS` (`user` / `mind` / `schedule` / `system`), and the `isUserActionType` / `isUserActionActor` predicates. `recordUserAction` (`services/userActions.js`) throws on a type absent from the list, so a typo fails a test instead of writing a row nothing can filter on. |
+| `userActionTypes.js` | Closed vocabulary for the operator-action ledger (`user_action_events`, #5594 / #5596): `USER_ACTION_TYPES` (CoS task/feedback/schedule + settings + instance-feature toggles + event-only creative/Brain pointers), `USER_ACTION_ACTORS` (`user` / `mind` / `schedule` / `system`), and the `isUserActionType` / `isUserActionActor` predicates. `recordUserAction` (`services/userActions.js`) throws on a type absent from the list, so a typo fails a test instead of writing a row nothing can filter on. |
| `uuid.js` | `v4()` thin wrapper over `crypto.randomUUID()`. |
| `versionUtils.js` | `compareSemver(a, b)` — semver ordering (-1/0/1) with pre-release precedence and build-metadata stripping. Shared by the self-update checker (`updateChecker.js`) and the local-LLM Ollama update detector (`localLlm.js`). Inputs must be `v`-stripped. |
| `workTracker.js` | `WORK_TRACKERS`/`CONCRETE_WORK_TRACKERS`/`DEFAULT_WORK_TRACKER`, `workTrackerLabel`, `hostToWorkTracker`, `isGithubHost` (GitHub-family host test — github.com + enterprise github.*; enterprise-aware replacement for the github.com-only `isGithub` gate), `githubRepoSpec(origin)` (host-qualified `HOST/OWNER/REPO` selector for `gh --repo`, or null for a non-GitHub origin — pairs the isGithubHost gate with the selector so prWatcher/branchReconcile/issueReconcile share one "resolvable GitHub repo" definition), `forgeCliForTracker`, `isFileTracker` (true when the tracker records work as repo files — PLAN.md — so an agent's proposal necessarily dirties the worktree; false for github/gitlab/jira), `trackerToClaimTaskType`, `hostFromOriginUrl` (subgroup-tolerant host parse), pure `resolveWorkTracker({configured,host})`, async `resolveAppWorkTracker(app)` — resolves a managed app's autonomous work source (PLAN.md / GitHub / GitLab / JIRA), defaulting `'auto'` to the git origin host. Async `resolveRepoForgeTarget(repoPath)` — the ONE definition of "which forge can we query for this checkout", returning `{ forge, fullName, repoSpec, apiHost }` (enterprise-aware `gh --repo` selector for GitHub; `repoSpec: null` for GitLab, which `glab` resolves from its cwd) or null for a non-forge origin; shared by `issueReconcile.js` and `appIssues.js`. Async `resolveAppForgeTarget(app, {repoPath})` — the composed `resolveAppWorkTracker` + `resolveRepoForgeTarget` for callers holding the managed-app record, returning `{ tracker, target }` with the app's github/gitlab pin threaded in as `preferredForge` (so a self-hosted forge on a hostname matching neither pattern still resolves); use this instead of re-threading the pin by hand. Also owns the `{trackerInstructions}` prompt block shared by the TRACKER-FILING task types (types that read the app read-only and deliver findings as tracker items, not a commit): `TRACKER_FILING_PRESETS` (per-task-type slug prefix / label / body requirements — `reference-watch`, `ux`, `repo-study`), `TRACKER_FILING_TASK_TYPES` (derived from the presets, so a gated type always has wording), and `formatTrackerInstructions(tracker, options)` which renders the plan/github/gitlab/jira block (reference-watch is the default option set, so a bare call stays byte-identical for it). Consumed by the `claim-work` router in `cosTaskGenerator.js`, `referenceRepos.js`, and `routes/apps.js`. |
@@ -499,8 +515,8 @@ pm` default, `NPM_CONFIG_PREFIX`, nvm/Volta) installed `codex` successfully and
| Module | Purpose |
|---|---|
| `dbTestGate.js` | `requireDbOrSkip(label, dbReady, reason)` keeps a missing local test database as a visible skipped suite, but throws when `PORTOS_REQUIRE_DB` is set so CI cannot pass after DB-backed suites disappear. |
-| `gitTestRepo.js` | Shared real-git sandbox for integration tests (#4394): one initialized template (working tree + bare origin) per worker, then `fs.cp` into a fresh temp dir. `makeGitSandbox({ origin })`, `attachBareOrigin(scratch, repo)`, `materializeGitRepo(dest)`, `destroyGitSandbox`, plus `SKIP_HEAVY_INTEGRATION` (`VITEST_FAST=1`). Every entry point runs `assertTempPath` first, so a path outside `os.tmpdir()` throws instead of `git init`-ing or `rm -rf`-ing a real checkout (#4554). Still real git — just not rebuilt from `init`+`commit`+`push` in every `beforeEach`. |
+| `gitTestRepo.js` | Shared real-git sandbox for integration tests (#4394): one initialized template (working tree + bare origin) per worker, then `fs.cp` into a fresh temp dir. `makeGitSandbox({ origin })`, `attachBareOrigin(scratch, repo)`, `materializeGitRepo(dest)`, `destroyGitSandbox`, plus `SKIP_HEAVY_INTEGRATION` (`VITEST_FAST=1`). `resetGitSandbox({ scratch, repo, initialHead })` / `resetGitWorktreeSandbox(repo, initialHead)` restore a sandbox in place (branches, worktrees, remote) so a `describe` can build one sandbox in `beforeAll` and reset between tests instead of paying the fs.cp/rm cycle per test (#5902). Every entry point runs `assertTempPath` first, so a path outside `os.tmpdir()` throws instead of `git init`-ing or `rm -rf`-ing a real checkout (#4554). Still real git — just not rebuilt from `init`+`commit`+`push` in every `beforeEach`. |
| `mirrorParity.js` | Source-comparison primitives for the `*.mirror.test.js` server↔client parity tests: `stripCommentsAndNormalize` (so per-side commentary may diverge but logic may not), `extractDeclaration(src, name)` (balanced `{}`/`()`/`[]` walk over `function` / `async function` / `const`), `compareDeclaration(serverSrc, clientSrc, name)`, and `compareRegexDeclaration(serverSrc, clientSrc, serverName, clientName?)` / `regexAlternationSource(declText)` (for a regex spelled as a `new RegExp([…].join('|'), 'i')` array on one side and an inline `/…/i` literal on the other — compares what it matches, not how it is typeset, and returns `null` rather than a partial read on any shape it can't decode). Use these instead of hand-rolling a brace-walker per mirror. Pure — no `vitest` import — so callers own the assertions. |
| `mockPathsDataRoot.js` | Shared Vitest helpers for `PATHS.data → temp dir` and no-peer record creation guards. |
| `settingsTestUtil.js` | `bindSettingsFile(dataRoot)` → `writeSettingsFile`/`mergeSettingsFile`: direct settings.json disk writes that also drop the `getSettings()` read cache (dynamic-import reset) so a stale cache can't survive a bypass-`save()` write. |
-| `testHelper.js` | Test helpers: `request()` (supertest-style HTTP) + `mockJsonResponse`/`mockTextResponse` (fetch `Response` mocks with `.text()`, `.json()`, and a `headers.get` content-type), `startLoopbackServer(app)`/`closeLoopbackServer(server)`/`waitForAbort(signal)` for tests that need a real socket (raw disconnects, SSE streaming) that `request()`'s run-to-completion fetch harness can't model, plus the source-scan pair `collectServerSources()` / `readServerSource(rel)` (and `SERVER_DIR`) used by the whole-tree guard suites — `spawnCwd.test.js` (#3193) and `cliChildEnv.test.js` (#3194). Those guards overlap deliberately, so they share one definition of "a source file"; change the ignore rules here and both move together. Cross-platform trio: `posixPath(v)` normalizes a RECEIVED path before comparing it to a POSIX-spelled literal (no-op on POSIX — never normalize the expectation, which would hide a genuinely wrong path), and `resolveTestPython()` returns an interpreter that actually runs, probing by execution because Windows ships a `python` Store-alias stub that exists but fails; `null` when there is none, for `describe.skipIf`; `pinPlatform(value)` pins `process.platform` and returns a restore that reinstates the ORIGINAL descriptor (deleting the pin when there was none) — it carries the one hazard every hand-rolled pin had to rediscover: never pin above an import that loads a native addon, which picks its prebuilt binary off the platform at load time (#4085). |
+| `testHelper.js` | Test helpers: `request()` (supertest-style HTTP) + `mockJsonResponse`/`mockTextResponse` (fetch `Response` mocks with `.text()`, `.json()`, and a `headers.get` content-type), `startLoopbackServer(app)`/`closeLoopbackServer(server)`/`waitForAbort(signal)` for tests that need a real socket (raw disconnects, SSE streaming) that `request()`'s run-to-completion fetch harness can't model, plus the source-scan pair `collectServerSources()` / `readServerSource(rel)` (and `SERVER_DIR`) used by the whole-tree guard suites — `spawnCwd.test.js` (#3193) and `cliChildEnv.test.js` (#3194). Those guards overlap deliberately, so they share one definition of "a source file"; change the ignore rules here and both move together. Cross-platform trio: `posixPath(v)` normalizes a RECEIVED path before comparing it to a POSIX-spelled literal (no-op on POSIX — never normalize the expectation, which would hide a genuinely wrong path), and `resolveTestPython()` returns an interpreter that actually runs, probing by execution because Windows ships a `python` Store-alias stub that exists but fails; `null` when there is none, for `describe.skipIf`; `pinPlatform(value)` pins `process.platform` and returns a restore that reinstates the ORIGINAL descriptor (deleting the pin when there was none) — it carries the one hazard every hand-rolled pin had to rediscover: never pin above an import that loads a native addon, which picks its prebuilt binary off the platform at load time (#4085). Python-shelling suites also take their two nested budgets from here: `PY_TEST_TIMEOUT_MS` (vitest per-test, passed as `it()`'s third argument — a real interpreter's wall time tracks machine load, not the assertion, so a ~4s case crosses the tight global 10s `testTimeout` on a contended full-suite worker) and the strictly smaller `PY_SUBPROCESS_TIMEOUT_MS` (every `execFileSync` spawn's own `timeout`, so a hung interpreter trips the spawn guard first and names the command instead of producing a bare vitest timeout; a subprocess allowance ABOVE the vitest budget is dead intent — vitest always wins). |
diff --git a/server/lib/agentValidation.js b/server/lib/agentValidation.js
index 89cdc6bc4c..91875ffdf5 100644
--- a/server/lib/agentValidation.js
+++ b/server/lib/agentValidation.js
@@ -97,8 +97,6 @@ export const platformAccountSchema = z.object({
platformData: z.record(z.unknown()).optional().default({})
});
-export const platformAccountUpdateSchema = partialWithoutDefaults(platformAccountSchema);
-
// Account registration (when creating new Moltbook account)
export const accountRegistrationSchema = z.object({
agentId: z.string().min(1),
@@ -317,7 +315,9 @@ export const moltworldQueueAddSchema = z.object({
// FEATURE AGENT SCHEMAS
// =============================================================================
-export const featureAgentStatusSchema = z.enum(['draft', 'active', 'paused', 'completed', 'error']);
+// A feature agent's `status` is server-owned — stamped by the start/pause/
+// complete routes, never accepted from a request body — so it has no request
+// schema (#5730).
export const featureAgentScheduleModeSchema = z.enum(['continuous', 'interval']);
export const featureAgentAutonomySchema = z.enum(['standby', 'assistant', 'manager', 'yolo']);
export const featureAgentPrioritySchema = z.enum(['LOW', 'MEDIUM', 'HIGH', 'CRITICAL']);
diff --git a/server/lib/aiToolkit/defaults/providers.sample.json b/server/lib/aiToolkit/defaults/providers.sample.json
index 2492b00bdf..5e95d5ab39 100644
--- a/server/lib/aiToolkit/defaults/providers.sample.json
+++ b/server/lib/aiToolkit/defaults/providers.sample.json
@@ -408,6 +408,55 @@
"tuiPromptDelayMs": 2500,
"tuiIdleTimeoutMs": 180000
},
+ "opencode-zen": {
+ "id": "opencode-zen",
+ "name": "OpenCode Zen",
+ "type": "api",
+ "endpoint": "https://opencode.ai/zen/v1",
+ "apiKey": "",
+ "models": ["claude-opus-5", "claude-sonnet-5", "claude-haiku-4-5", "gpt-5.6-sol", "gpt-5.5", "grok-4.6", "kimi-k3", "qwen3.6-plus", "big-pickle", "deepseek-v4-flash-free"],
+ "defaultModel": "claude-sonnet-5",
+ "lightModel": "claude-haiku-4-5",
+ "mediumModel": "claude-sonnet-5",
+ "heavyModel": "claude-opus-5",
+ "timeout": 300000,
+ "enabled": false,
+ "envVars": {},
+ "secretEnvVars": []
+ },
+ "opencode-zen-cli": {
+ "id": "opencode-zen-cli",
+ "name": "OpenCode Zen CLI",
+ "type": "cli",
+ "command": "opencode",
+ "args": ["run"],
+ "models": ["opencode/big-pickle", "opencode/ling-3.0-flash-fin-free", "opencode/mimo-v2.5-free", "opencode/muse-spark-1.2-contributor-free", "opencode/muse-spark-1.3-contributor-free", "opencode/nemotron-3-ultra-free", "opencode/nemotron-3.5-lightning-free"],
+ "defaultModel": "opencode/big-pickle",
+ "timeout": 600000,
+ "enabled": false,
+ "envVars": {
+ "OPENCODE_CONFIG_CONTENT": "{\"permission\":\"allow\"}"
+ },
+ "secretEnvVars": ["OPENCODE_API_KEY"],
+ "headlessArgs": []
+ },
+ "opencode-zen-tui": {
+ "id": "opencode-zen-tui",
+ "name": "OpenCode Zen TUI",
+ "type": "tui",
+ "command": "opencode",
+ "args": [],
+ "models": ["opencode/big-pickle", "opencode/ling-3.0-flash-fin-free", "opencode/mimo-v2.5-free", "opencode/muse-spark-1.2-contributor-free", "opencode/muse-spark-1.3-contributor-free", "opencode/nemotron-3-ultra-free", "opencode/nemotron-3.5-lightning-free"],
+ "defaultModel": "opencode/big-pickle",
+ "timeout": 600000,
+ "enabled": false,
+ "envVars": {
+ "OPENCODE_CONFIG_CONTENT": "{\"permission\":\"allow\"}"
+ },
+ "secretEnvVars": ["OPENCODE_API_KEY"],
+ "tuiPromptDelayMs": 2500,
+ "tuiIdleTimeoutMs": 180000
+ },
"codex": {
"id": "codex",
"name": "Codex CLI",
@@ -485,7 +534,7 @@
"type": "tui",
"command": "agy",
"args": ["--dangerously-skip-permissions"],
- "models": ["antigravity-configured-default","gemini-3.7-flash-high","gemini-3.7-flash-medium","gemini-3.7-flash-low","gemini-3.6-flash-high","gemini-3.6-flash-medium","gemini-3.6-flash-low","gemini-3.5-flash-high","gemini-3.5-flash-medium","gemini-3.5-flash-low","gemini-3.1-pro-high","gemini-3.1-pro-low","claude-sonnet-4-6","claude-opus-4-6-thinking","gpt-oss-120b-medium"],
+ "models": ["antigravity-configured-default","gemini-3.8-flash-high","gemini-3.8-flash-medium","gemini-3.8-flash-low","gemini-3.7-flash-high","gemini-3.7-flash-medium","gemini-3.7-flash-low","gemini-3.6-flash-high","gemini-3.6-flash-medium","gemini-3.6-flash-low","gemini-3.1-pro-high","gemini-3.1-pro-low","claude-sonnet-4-6","claude-opus-4-6-thinking","gpt-oss-120b-medium"],
"defaultModel": "antigravity-configured-default",
"lightModel": "antigravity-configured-default",
"mediumModel": "antigravity-configured-default",
@@ -503,7 +552,7 @@
"type": "cli",
"command": "agy",
"args": ["--print", "--dangerously-skip-permissions"],
- "models": ["antigravity-configured-default","gemini-3.7-flash-high","gemini-3.7-flash-medium","gemini-3.7-flash-low","gemini-3.6-flash-high","gemini-3.6-flash-medium","gemini-3.6-flash-low","gemini-3.5-flash-high","gemini-3.5-flash-medium","gemini-3.5-flash-low","gemini-3.1-pro-high","gemini-3.1-pro-low","claude-sonnet-4-6","claude-opus-4-6-thinking","gpt-oss-120b-medium"],
+ "models": ["antigravity-configured-default","gemini-3.8-flash-high","gemini-3.8-flash-medium","gemini-3.8-flash-low","gemini-3.7-flash-high","gemini-3.7-flash-medium","gemini-3.7-flash-low","gemini-3.6-flash-high","gemini-3.6-flash-medium","gemini-3.6-flash-low","gemini-3.1-pro-high","gemini-3.1-pro-low","claude-sonnet-4-6","claude-opus-4-6-thinking","gpt-oss-120b-medium"],
"defaultModel": "antigravity-configured-default",
"lightModel": "antigravity-configured-default",
"mediumModel": "antigravity-configured-default",
diff --git a/server/lib/aiToolkit/internal/modelFetchers.test.js b/server/lib/aiToolkit/internal/modelFetchers.test.js
index 60a7fe7e26..4775c658e9 100644
--- a/server/lib/aiToolkit/internal/modelFetchers.test.js
+++ b/server/lib/aiToolkit/internal/modelFetchers.test.js
@@ -24,6 +24,12 @@ const SHIPPED_REFRESHABLE = [
// Every hosted gateway refreshes through the same sibling `/models` probe —
// one MODEL_FETCHERS row covers all of them (internal/gateways.js).
'opencode-openrouter', 'opencode-openrouter-tui', 'openrouter',
+ // OpenCode Zen's API record is an ordinary OpenAI-compatible endpoint, so it
+ // refreshes through the same `/models` probe. Its CLI/TUI wrappers do NOT:
+ // they carry no namespace marker at all, which is what makes OpenCode resolve
+ // `opencode/*` through its own built-in provider — nothing here can enumerate
+ // that, and Models → Harnesses ("Refresh models") is where it comes from.
+ 'opencode-zen',
'opencode-vllm', 'opencode-vllm-tui',
// SGLang publishes its served catalog through the same OpenAI-compatible
// `/v1/models` contract as the vLLM pair, so its wrappers refresh too.
@@ -36,6 +42,7 @@ const SHIPPED_REFRESHABLE = [
const SHIPPED_NOT_REFRESHABLE = [
'claude-code-tui', 'claude-code-tui-bedrock', 'codex', 'codex-tui',
'grok-cli', 'grok-tui', 'kimi-cli', 'kimi-tui',
+ 'opencode-zen-cli', 'opencode-zen-tui',
];
describe('MODEL_FETCHERS — shipped catalog visibility is unchanged', () => {
diff --git a/server/lib/aiToolkit/providerStatus.test.js b/server/lib/aiToolkit/providerStatus.test.js
index b4271c514c..68c880fd22 100644
--- a/server/lib/aiToolkit/providerStatus.test.js
+++ b/server/lib/aiToolkit/providerStatus.test.js
@@ -860,14 +860,22 @@ describe('Provider Status Service', () => {
});
describe('init', () => {
+ // Fake time, not a real sleep: the recovery window under test is 1000ms and
+ // a real 1100ms sleep left only 100ms of slack on a loaded runner. Same
+ // pattern as the `stale recovery on read` describe below.
+ beforeEach(() => vi.useFakeTimers());
+ afterEach(() => vi.useRealTimers());
+
it('should clean up expired statuses on init', async () => {
// Mark provider unavailable with very short wait time
await statusService.markUsageLimit('test-provider', {
message: 'Test'
});
- // Wait for recovery time to pass
- await new Promise(resolve => setTimeout(resolve, 1100));
+ // Advance past the 1000ms defaultUsageLimitWait recovery window. 1100 (not
+ // 1001) keeps the intent — "past the window" — legible; with fake time the
+ // extra 100ms is free.
+ await vi.advanceTimersByTimeAsync(1100);
// Create new service and init (should clean up expired status)
const newService = createProviderStatusService({
@@ -876,7 +884,16 @@ describe('Provider Status Service', () => {
defaultUsageLimitWait: 1000
});
- await newService.init();
+ const loaded = await newService.init();
+
+ // init() itself must reset the expired entry in the cache it returns.
+ // Asserting only isAvailable() would pass even with the init cleanup
+ // deleted, because every reader re-applies the same recovery check on
+ // read (see server/lib/aiToolkit/AGENTS.md).
+ expect(loaded.providers['test-provider']).toMatchObject({
+ available: true,
+ reason: 'ok'
+ });
// Provider should now be available
expect(newService.isAvailable('test-provider')).toBe(true);
diff --git a/server/lib/aiToolkit/providers.js b/server/lib/aiToolkit/providers.js
index c313689740..b207c6133a 100644
--- a/server/lib/aiToolkit/providers.js
+++ b/server/lib/aiToolkit/providers.js
@@ -203,20 +203,20 @@ const PRIOR_CODEX_MODEL_CATALOGS = [
];
const ANTIGRAVITY_MODEL_KEYS = ['defaultModel', 'lightModel', 'mediumModel', 'heavyModel'];
// agy exposes a per-session `--model` flag and lists its catalog via
-// `agy models`. This is the shipped fallback list (agy 2026-08) used to seed a
+// `agy models`. This is the shipped fallback list (agy 2026-09) used to seed a
// fresh install and when the live `agy models` probe can't run; the AI Providers
// "Refresh models" button replaces it with whatever the installed binary
// reports, which is the authoritative list for that user's plan.
const ANTIGRAVITY_MODELS = [
+ 'gemini-3.8-flash-high',
+ 'gemini-3.8-flash-medium',
+ 'gemini-3.8-flash-low',
'gemini-3.7-flash-high',
'gemini-3.7-flash-medium',
'gemini-3.7-flash-low',
'gemini-3.6-flash-high',
'gemini-3.6-flash-medium',
'gemini-3.6-flash-low',
- 'gemini-3.5-flash-high',
- 'gemini-3.5-flash-medium',
- 'gemini-3.5-flash-low',
'gemini-3.1-pro-high',
'gemini-3.1-pro-low',
'claude-sonnet-4-6',
@@ -245,6 +245,24 @@ const PRIOR_ANTIGRAVITY_MODEL_CATALOGS = [
'claude-opus-4-6-thinking',
'gpt-oss-120b-medium',
],
+ // Prior 2026-08 catalog with gemini-3.5, without gemini-3.8
+ [
+ ANTIGRAVITY_CONFIGURED_DEFAULT,
+ 'gemini-3.7-flash-high',
+ 'gemini-3.7-flash-medium',
+ 'gemini-3.7-flash-low',
+ 'gemini-3.6-flash-high',
+ 'gemini-3.6-flash-medium',
+ 'gemini-3.6-flash-low',
+ 'gemini-3.5-flash-high',
+ 'gemini-3.5-flash-medium',
+ 'gemini-3.5-flash-low',
+ 'gemini-3.1-pro-high',
+ 'gemini-3.1-pro-low',
+ 'claude-sonnet-4-6',
+ 'claude-opus-4-6-thinking',
+ 'gpt-oss-120b-medium',
+ ],
];
const CODEX_CONTEXT_WINDOW = 1_000_000;
const GEMINI_CONTEXT_WINDOW = 1_048_576;
diff --git a/server/lib/aiToolkit/providers.test.js b/server/lib/aiToolkit/providers.test.js
index 16faa2d639..9fa6f7efae 100644
--- a/server/lib/aiToolkit/providers.test.js
+++ b/server/lib/aiToolkit/providers.test.js
@@ -681,6 +681,48 @@ describe('Provider Service', () => {
expect(antigravity.defaultModel).toBe('antigravity-configured-default');
});
+ it('upgrades a prior-seeded Antigravity model list to include Gemini 3.8 models', async () => {
+ const priorModels = [
+ 'antigravity-configured-default',
+ 'gemini-3.7-flash-high',
+ 'gemini-3.7-flash-medium',
+ 'gemini-3.7-flash-low',
+ 'gemini-3.6-flash-high',
+ 'gemini-3.6-flash-medium',
+ 'gemini-3.6-flash-low',
+ 'gemini-3.5-flash-high',
+ 'gemini-3.5-flash-medium',
+ 'gemini-3.5-flash-low',
+ 'gemini-3.1-pro-high',
+ 'gemini-3.1-pro-low',
+ 'claude-sonnet-4-6',
+ 'claude-opus-4-6-thinking',
+ 'gpt-oss-120b-medium',
+ ];
+ await writeProvidersFile({
+ activeProvider: 'antigravity-cli',
+ providers: {
+ 'antigravity-cli': {
+ id: 'antigravity-cli',
+ name: 'Antigravity CLI',
+ type: 'cli',
+ command: 'agy',
+ contextWindow: 1048576,
+ models: [...priorModels],
+ defaultModel: 'antigravity-configured-default',
+ lightModel: 'antigravity-configured-default'
+ }
+ }
+ });
+
+ const antigravity = await providerService.getProviderById('antigravity-cli');
+ expect(antigravity.models).toContain('gemini-3.8-flash-high');
+ expect(antigravity.models).toContain('gemini-3.8-flash-medium');
+ expect(antigravity.models).toContain('gemini-3.8-flash-low');
+ expect(antigravity.models).toContain('gemini-3.7-flash-high');
+ expect(antigravity.defaultModel).toBe('antigravity-configured-default');
+ });
+
// A failed `agy models` probe must be distinguishable from a real fetch.
// Returning the shipped catalog here would persist it and toast "Models
// refreshed", so a user whose service PATH can't resolve `agy` would pick a
diff --git a/server/lib/apiRouteCatalog.generated.json b/server/lib/apiRouteCatalog.generated.json
index c363efd736..9227c19c57 100644
--- a/server/lib/apiRouteCatalog.generated.json
+++ b/server/lib/apiRouteCatalog.generated.json
@@ -30,7 +30,6 @@
"/api/capabilities",
"/api/catalog",
"/api/character",
- "/api/city",
"/api/client-errors",
"/api/code-review",
"/api/commands",
@@ -60,6 +59,7 @@
"/api/games",
"/api/git",
"/api/github",
+ "/api/harnesses",
"/api/health",
"/api/history",
"/api/image-clean",
@@ -98,7 +98,6 @@
"/api/notes",
"/api/notifications",
"/api/openclaw",
- "/api/openworld",
"/api/palette",
"/api/peer-sync",
"/api/pipeline",
@@ -110,6 +109,7 @@
"/api/rapid-reader",
"/api/remote-desktop",
"/api/review",
+ "/api/rigging",
"/api/rounds",
"/api/runs",
"/api/scaffold",
@@ -3318,38 +3318,6 @@
"server/routes/character.js"
]
},
- {
- "method": "GET",
- "path": "/api/city/introspection",
- "mountPath": "/api/city",
- "sources": [
- "server/routes/openWorldRoutes.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/city/snapshots",
- "mountPath": "/api/city",
- "sources": [
- "server/routes/openWorldRoutes.js"
- ]
- },
- {
- "method": "POST",
- "path": "/api/city/snapshots/capture",
- "mountPath": "/api/city",
- "sources": [
- "server/routes/openWorldRoutes.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/city/snapshots/config",
- "mountPath": "/api/city",
- "sources": [
- "server/routes/openWorldRoutes.js"
- ]
- },
{
"method": "POST",
"path": "/api/client-errors",
@@ -7510,6 +7478,30 @@
"server/routes/github.js"
]
},
+ {
+ "method": "GET",
+ "path": "/api/harnesses",
+ "mountPath": "/api/harnesses",
+ "sources": [
+ "server/routes/harnesses.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/harnesses/action",
+ "mountPath": "/api/harnesses",
+ "sources": [
+ "server/routes/harnesses.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/harnesses/models/refresh",
+ "mountPath": "/api/harnesses",
+ "sources": [
+ "server/routes/harnesses.js"
+ ]
+ },
{
"method": "GET",
"path": "/api/health/correlation",
@@ -7950,6 +7942,22 @@
"server/routes/imageTo3d.js"
]
},
+ {
+ "method": "GET",
+ "path": "/api/image-to-3d/models/:id/usdz",
+ "mountPath": "/api/image-to-3d",
+ "sources": [
+ "server/routes/imageTo3d.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/image-to-3d/models/:id/usdz",
+ "mountPath": "/api/image-to-3d",
+ "sources": [
+ "server/routes/imageTo3d.js"
+ ]
+ },
{
"method": "GET",
"path": "/api/image-to-3d/targets",
@@ -11702,38 +11710,6 @@
"server/routes/openclaw.js"
]
},
- {
- "method": "GET",
- "path": "/api/openworld/introspection",
- "mountPath": "/api/openworld",
- "sources": [
- "server/routes/openWorldRoutes.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/openworld/snapshots",
- "mountPath": "/api/openworld",
- "sources": [
- "server/routes/openWorldRoutes.js"
- ]
- },
- {
- "method": "POST",
- "path": "/api/openworld/snapshots/capture",
- "mountPath": "/api/openworld",
- "sources": [
- "server/routes/openWorldRoutes.js"
- ]
- },
- {
- "method": "GET",
- "path": "/api/openworld/snapshots/config",
- "mountPath": "/api/openworld",
- "sources": [
- "server/routes/openWorldRoutes.js"
- ]
- },
{
"method": "POST",
"path": "/api/palette/action/:id",
@@ -14054,6 +14030,38 @@
"server/routes/review.js"
]
},
+ {
+ "method": "GET",
+ "path": "/api/rigging/clips",
+ "mountPath": "/api/rigging",
+ "sources": [
+ "server/routes/rigging.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/rigging/models/:id",
+ "mountPath": "/api/rigging",
+ "sources": [
+ "server/routes/rigging.js"
+ ]
+ },
+ {
+ "method": "POST",
+ "path": "/api/rigging/models/:id/retarget",
+ "mountPath": "/api/rigging",
+ "sources": [
+ "server/routes/rigging.js"
+ ]
+ },
+ {
+ "method": "GET",
+ "path": "/api/rigging/readiness",
+ "mountPath": "/api/rigging",
+ "sources": [
+ "server/routes/rigging.js"
+ ]
+ },
{
"method": "GET",
"path": "/api/rounds",
@@ -14334,6 +14342,14 @@
"server/routes/settings.js"
]
},
+ {
+ "method": "GET",
+ "path": "/api/settings/credentials",
+ "mountPath": "/api/settings",
+ "sources": [
+ "server/routes/settings.js"
+ ]
+ },
{
"method": "GET",
"path": "/api/settings/features",
@@ -15238,6 +15254,14 @@
"server/routes/dataSync.js"
]
},
+ {
+ "method": "GET",
+ "path": "/api/sync/:category/manifest",
+ "mountPath": "/api/sync",
+ "sources": [
+ "server/routes/dataSync.js"
+ ]
+ },
{
"method": "GET",
"path": "/api/sync/:category/snapshot",
@@ -17473,8 +17497,8 @@
],
"stats": {
"mounts": 147,
- "operations": 2164,
- "declarations": 2168,
- "sourceFiles": 229
+ "operations": 2167,
+ "declarations": 2175,
+ "sourceFiles": 230
}
}
diff --git a/server/lib/apiRouteParity.test.js b/server/lib/apiRouteParity.test.js
new file mode 100644
index 0000000000..77b19332b0
--- /dev/null
+++ b/server/lib/apiRouteParity.test.js
@@ -0,0 +1,113 @@
+/**
+ * Client ↔ server API route parity.
+ *
+ * `client/src/services/api*.js` is where the browser learns which URL a feature
+ * lives at, and each wrapper's co-located test asserts the wrapper produced the
+ * string the wrapper produces. Nothing compared those strings to the routes
+ * `server/index.js` actually mounts, so renaming an `app.use('/api/', …)`
+ * prefix — or moving a handler between routers — left the whole client suite
+ * green while the feature 404'd in the browser (#5716).
+ *
+ * This closes that boundary by diffing two static scans of the real tree:
+ * `scripts/generate-api-route-catalog.js` for the mounted server routes (the
+ * inventory the API Explorer already ships) and `clientApiPaths.js` for the
+ * paths the client wrappers request. The server side is REGENERATED here rather
+ * than read from `apiRouteCatalog.generated.json`, so a rename that skipped the
+ * regeneration step still fails this test.
+ */
+
+import { describe, it, expect } from 'vitest';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { generateApiRouteCatalog } from '../../scripts/generate-api-route-catalog.js';
+import { findUnmountedClientPaths, scanClientApiPaths } from './clientApiPaths.js';
+
+const REPO_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..', '..');
+
+/**
+ * Call sites whose path this scanner cannot fold to a literal, each with the
+ * reason. An entry is a review signal, not a silent skip: the scan's unresolved
+ * set must equal this list EXACTLY, so a wrapper shape the scanner stops
+ * understanding fails the suite instead of quietly shrinking the guard.
+ *
+ * Keyed by file plus the expression text — never by line number, so an edit
+ * above a call site does not churn this list (root AGENTS.md, "Generated
+ * manifests are addressed by content, never by position").
+ */
+const CALLER_SUPPLIED_PATH_SITES = [
+ // `fetchByIds(path, ids)` is the shared id-batching wrapper; its path comes
+ // from callers across client/src, outside the scanned services directory.
+ { file: 'client/src/services/apiBatch.js', expression: '`${path}?${params}`' },
+ // `importFile(path, file, …)` is a module-local multipart-upload helper with a
+ // block body, so its `path` parameter is only bound at its own call sites.
+ { file: 'client/src/services/apiTimeline.js', expression: 'path' },
+];
+
+const describeSite = (site) => `${site.file}: ${site.expression}`;
+
+const catalog = generateApiRouteCatalog(REPO_ROOT);
+const serverPaths = catalog.routes.map((route) => route.path);
+const { paths: clientPaths, unresolved } = scanClientApiPaths({ repoRoot: REPO_ROOT });
+
+describe('client ↔ server API route parity', () => {
+ it('resolves every client API path to a mounted server route', () => {
+ // Guards against a vacuously green run: a scan that stopped finding call
+ // sites would report zero mismatches below.
+ expect(clientPaths.length).toBeGreaterThan(1000);
+ expect(clientPaths.map((entry) => entry.path)).toContain('/api/settings/features/:p');
+
+ const unmounted = findUnmountedClientPaths(clientPaths, serverPaths);
+ expect(unmounted.map((site) => `${describeSite(site)} → ${site.path}`)).toEqual([]);
+ });
+
+ it('declares every call site whose path it cannot resolve', () => {
+ expect(unresolved.map(describeSite).sort())
+ .toEqual(CALLER_SUPPLIED_PATH_SITES.map(describeSite).sort());
+ });
+
+ it('reports the client wrappers stranded by a renamed server mount prefix', () => {
+ const renamed = serverPaths.map((route) =>
+ route.replace(/^\/api\/games(?=\/|$)/, '/api/games-renamed'));
+ expect(renamed).not.toEqual(serverPaths);
+
+ const stranded = findUnmountedClientPaths(clientPaths, renamed);
+ expect(stranded.length).toBeGreaterThan(0);
+ // The failure has to name BOTH sides for a human to act on it: the client
+ // module still pointing at the old prefix, and the path it now 404s on.
+ expect([...new Set(stranded.map((site) => site.file))]).toContain('client/src/services/apiGames.js');
+ expect(stranded.every((site) => site.path.startsWith('/api/games'))).toBe(true);
+ });
+
+ // ── bypass probes ─────────────────────────────────────────────────────────
+ // Each proves the matcher still reports a real mismatch. Stub either scanner
+ // to return nothing and its probe goes red, so neither extractor can degrade
+ // into a permanently-passing parity assertion.
+
+ it('reports an unmounted client path (client-extractor bypass probe)', () => {
+ const scanned = scanClientApiPaths({
+ sources: {
+ 'apiProbe.js': [
+ "import { request } from './apiCore.js';",
+ "const thing = (id, rest = '') => `/definitely-not-mounted/${encodeURIComponent(id)}${rest}`;",
+ 'export const getProbe = (id) => request(thing(id));',
+ "export const listProbe = () => request('/sync/checksum-probe');",
+ 'export const streamProbe = () => fetch(`${API_BASE}/sync/stream-probe`);',
+ ].join('\n'),
+ },
+ });
+
+ expect(scanned.paths.map((entry) => entry.path))
+ .toEqual(['/api/definitely-not-mounted/:p', '/api/sync/checksum-probe', '/api/sync/stream-probe']);
+ const mounted = ['/api/sync/checksum-probe', '/api/sync/stream-probe'];
+ expect(findUnmountedClientPaths(scanned.paths, mounted).map((site) => site.path))
+ .toEqual(['/api/definitely-not-mounted/:p']);
+ });
+
+ it('builds a populated server route table covering a known route (server-extractor bypass probe)', () => {
+ expect(catalog.routes.length).toBeGreaterThan(1000);
+ expect(catalog.mounts).toContain('/api/sync');
+ expect(serverPaths).toContain('/api/sync/:category/checksum');
+ // An empty table must strand every client path rather than pass silently.
+ expect(findUnmountedClientPaths(clientPaths, []).length).toBe(clientPaths.length);
+ });
+});
diff --git a/server/lib/assetProvenance.js b/server/lib/assetProvenance.js
new file mode 100644
index 0000000000..98c7fa3cba
--- /dev/null
+++ b/server/lib/assetProvenance.js
@@ -0,0 +1,198 @@
+/**
+ * Asset license provenance — stamp at finalize time, never re-read later.
+ *
+ * PortOS already resolves a model's license when it downloads one
+ * (`licenseOf` in huggingFaceCatalog, Civitai/HF LoRA cards) and then drops
+ * it on the floor. Create-suite outputs leave the machine (collections,
+ * pipeline export, albums), so the terms that applied WHEN THE PIXELS WERE
+ * MADE have to travel with the asset. A license re-read months later can
+ * differ from the one in force at render; unknown stays unknown (`null`),
+ * displayed as "unknown" — never a permissive default.
+ *
+ * Shape (schemaVersion 1):
+ * {
+ * schemaVersion: 1,
+ * capturedAt: ISO-8601 | null,
+ * sources: [{ kind: 'model'|'lora', id, name, license, sourceUrl }]
+ * }
+ *
+ * Pure — no I/O. Server and client share this module byte-for-byte.
+ */
+
+export const PROVENANCE_SCHEMA_VERSION = 1;
+export const PROVENANCE_SOURCE_KINDS = Object.freeze(['model', 'lora']);
+export const UNKNOWN_LICENSE_LABEL = 'unknown';
+
+export function normalizeLicense(value) {
+ if (typeof value !== 'string') return null;
+ const trimmed = value.trim();
+ return trimmed || null;
+}
+
+export function licenseLabel(license) {
+ const normalized = normalizeLicense(license);
+ return normalized || UNKNOWN_LICENSE_LABEL;
+}
+
+export function huggingfaceUrl(repo) {
+ if (typeof repo !== 'string') return null;
+ const id = repo.trim();
+ return id ? `https://huggingface.co/${id}` : null;
+}
+
+export function licenseFromHuggingFaceModel(model) {
+ const card = normalizeLicense(model?.cardData?.license || model?.license);
+ if (card) return card;
+ const tags = Array.isArray(model?.tags) ? model.tags : [];
+ const tag = tags.find((t) => typeof t === 'string' && /^license:/i.test(t));
+ return tag ? normalizeLicense(tag.slice(tag.indexOf(':') + 1)) : null;
+}
+
+export function licenseFromCivitaiModel(model) {
+ // Civitai's `allowCommercialUse` is a policy flag, not a license string —
+ // never promote it into one. Only a real `license` field counts.
+ return normalizeLicense(model?.license);
+}
+
+export function buildProvenanceSource({ kind, id, name = null, license = null, sourceUrl = null } = {}) {
+ if (!PROVENANCE_SOURCE_KINDS.includes(kind)) return null;
+ if (typeof id !== 'string' || !id.trim()) return null;
+ const url = typeof sourceUrl === 'string' && sourceUrl.trim() ? sourceUrl.trim() : null;
+ const display = typeof name === 'string' && name.trim() ? name.trim() : null;
+ return {
+ kind,
+ id: id.trim(),
+ name: display,
+ license: normalizeLicense(license),
+ sourceUrl: url,
+ };
+}
+
+const sourceKey = (src) => `${src.kind}:${src.id}`;
+
+export function buildProvenance({ sources = [], capturedAt = null } = {}) {
+ const captured = typeof capturedAt === 'string' && capturedAt.trim() ? capturedAt.trim() : null;
+ const byKey = new Map();
+ for (const raw of Array.isArray(sources) ? sources : []) {
+ const src = buildProvenanceSource(raw);
+ if (!src) continue;
+ const key = sourceKey(src);
+ const existing = byKey.get(key);
+ if (!existing) {
+ byKey.set(key, src);
+ continue;
+ }
+ // Prefer a known license over unknown when the same source appears twice
+ // in one stamp (model + LoRA list shouldn't collide, but a rollup can).
+ if (existing.license == null && src.license != null) {
+ byKey.set(key, {
+ ...existing,
+ license: src.license,
+ name: existing.name || src.name,
+ sourceUrl: existing.sourceUrl || src.sourceUrl,
+ });
+ continue;
+ }
+ byKey.set(key, {
+ ...existing,
+ name: existing.name || src.name,
+ sourceUrl: existing.sourceUrl || src.sourceUrl,
+ });
+ }
+ return {
+ schemaVersion: PROVENANCE_SCHEMA_VERSION,
+ capturedAt: captured,
+ sources: [...byKey.values()],
+ };
+}
+
+export function readProvenance(record) {
+ const raw = record?.provenance;
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
+ const built = buildProvenance({
+ sources: Array.isArray(raw.sources) ? raw.sources : [],
+ capturedAt: raw.capturedAt,
+ });
+ return built.sources.length ? built : null;
+}
+
+function pickLoraFilenames(record) {
+ if (Array.isArray(record?.loraFilenames)) return record.loraFilenames;
+ if (Array.isArray(record?.lora_filenames)) return record.lora_filenames;
+ return [];
+}
+
+export function resolveAssetProvenance(record) {
+ const stamped = readProvenance(record);
+ if (stamped) return { ...stamped, reconstructed: false };
+ if (!record || typeof record !== 'object') return null;
+ const modelId = record.modelId || record.model;
+ const loras = pickLoraFilenames(record).filter((f) => typeof f === 'string' && f);
+ if (!modelId && !loras.length) return null;
+ const capturedAt = typeof record.createdAt === 'string' ? record.createdAt : null;
+ return {
+ ...buildProvenance({
+ sources: [
+ ...(modelId ? [{ kind: 'model', id: String(modelId), license: null }] : []),
+ ...loras.map((id) => ({ kind: 'lora', id, license: null })),
+ ],
+ capturedAt,
+ }),
+ reconstructed: true,
+ };
+}
+
+export function licenseFromRegistryModel(model) {
+ // Weights terms only. `disclosure.runtimeLicense` is the inference stack
+ // (often MIT) and must never be promoted into the asset's model license.
+ return normalizeLicense(model?.license)
+ || normalizeLicense(model?.disclosure?.weightsLicense?.name);
+}
+
+export function provenanceForRender({ model = null, loras = [], capturedAt = null } = {}) {
+ const sources = [];
+ if (model && (model.id || model.name)) {
+ const id = String(model.id || model.name);
+ const disclosureUrl = typeof model.disclosure?.modelCardUrl === 'string'
+ ? model.disclosure.modelCardUrl
+ : null;
+ const weightsUrl = typeof model.disclosure?.weightsLicense?.url === 'string'
+ ? model.disclosure.weightsLicense.url
+ : null;
+ sources.push({
+ kind: 'model',
+ id,
+ name: model.name || null,
+ license: licenseFromRegistryModel(model),
+ sourceUrl: model.sourceUrl || huggingfaceUrl(model.repo) || disclosureUrl || weightsUrl,
+ });
+ }
+ for (const lora of Array.isArray(loras) ? loras : []) {
+ const filename = typeof lora === 'string' ? lora : lora?.filename;
+ if (typeof filename !== 'string' || !filename) continue;
+ sources.push({
+ kind: 'lora',
+ id: filename,
+ name: typeof lora === 'object' ? (lora.name || null) : null,
+ license: typeof lora === 'object' ? lora.license : null,
+ sourceUrl: typeof lora === 'object' ? lora.sourceUrl : null,
+ });
+ }
+ return buildProvenance({ sources, capturedAt });
+}
+
+export function rollupProvenance(records) {
+ const sources = [];
+ for (const record of Array.isArray(records) ? records : []) {
+ const resolved = resolveAssetProvenance(record);
+ if (!resolved) continue;
+ sources.push(...resolved.sources);
+ }
+ return buildProvenance({ sources, capturedAt: null });
+}
+
+export function formatProvenanceSource(src) {
+ const built = buildProvenanceSource(src);
+ if (!built) return null;
+ return { ...built, licenseLabel: licenseLabel(built.license) };
+}
diff --git a/server/lib/assetProvenance.test.js b/server/lib/assetProvenance.test.js
new file mode 100644
index 0000000000..ac3beed894
--- /dev/null
+++ b/server/lib/assetProvenance.test.js
@@ -0,0 +1,189 @@
+import { describe, it, expect } from 'vitest';
+import { readFileSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { fileURLToPath } from 'node:url';
+import {
+ UNKNOWN_LICENSE_LABEL,
+ buildProvenance,
+ buildProvenanceSource,
+ huggingfaceUrl,
+ licenseFromCivitaiModel,
+ licenseFromHuggingFaceModel,
+ licenseLabel,
+ normalizeLicense,
+ provenanceForRender,
+ readProvenance,
+ resolveAssetProvenance,
+ rollupProvenance,
+} from './assetProvenance.js';
+
+describe('normalizeLicense / licenseLabel', () => {
+ it('treats blank, whitespace, and non-strings as unknown (null)', () => {
+ expect(normalizeLicense('')).toBeNull();
+ expect(normalizeLicense(' ')).toBeNull();
+ expect(normalizeLicense(null)).toBeNull();
+ expect(normalizeLicense(undefined)).toBeNull();
+ expect(normalizeLicense(42)).toBeNull();
+ expect(licenseLabel(null)).toBe(UNKNOWN_LICENSE_LABEL);
+ expect(licenseLabel(' ')).toBe(UNKNOWN_LICENSE_LABEL);
+ });
+ it('never infers a permissive default from absence', () => {
+ expect(licenseLabel(undefined)).toBe('unknown');
+ expect(licenseLabel('mit')).toBe('mit');
+ });
+});
+
+describe('licenseFromHuggingFaceModel', () => {
+ it('prefers cardData.license, then license, then a license: tag', () => {
+ expect(licenseFromHuggingFaceModel({ cardData: { license: 'apache-2.0' } })).toBe('apache-2.0');
+ expect(licenseFromHuggingFaceModel({ license: 'mit' })).toBe('mit');
+ expect(licenseFromHuggingFaceModel({ tags: ['text-to-image', 'license:openrail'] })).toBe('openrail');
+ });
+ it('returns null when nothing is observable', () => {
+ expect(licenseFromHuggingFaceModel({})).toBeNull();
+ expect(licenseFromHuggingFaceModel({ tags: ['flux'] })).toBeNull();
+ });
+});
+
+describe('licenseFromCivitaiModel', () => {
+ it('reads the license string and ignores allowCommercialUse', () => {
+ expect(licenseFromCivitaiModel({ license: 'CreativeML Open RAIL-M' })).toBe('CreativeML Open RAIL-M');
+ expect(licenseFromCivitaiModel({ allowCommercialUse: 'Sell' })).toBeNull();
+ expect(licenseFromCivitaiModel({ license: '', allowCommercialUse: 'Sell' })).toBeNull();
+ });
+});
+
+describe('buildProvenance / provenanceForRender', () => {
+ it('stamps model + LoRA sources with unknown licenses as null', () => {
+ const p = provenanceForRender({
+ model: { id: 'flux2-klein-9b', name: 'FLUX.2 Klein 9B', repo: 'org/flux2' },
+ loras: [{ filename: 'lora-style.safetensors', license: 'openrail', sourceUrl: 'https://civitai.com/models/1' }],
+ capturedAt: '2026-09-02T12:00:00.000Z',
+ });
+ expect(p.schemaVersion).toBe(1);
+ expect(p.capturedAt).toBe('2026-09-02T12:00:00.000Z');
+ expect(p.sources).toEqual([
+ {
+ kind: 'model',
+ id: 'flux2-klein-9b',
+ name: 'FLUX.2 Klein 9B',
+ license: null,
+ sourceUrl: 'https://huggingface.co/org/flux2',
+ },
+ {
+ kind: 'lora',
+ id: 'lora-style.safetensors',
+ name: null,
+ license: 'openrail',
+ sourceUrl: 'https://civitai.com/models/1',
+ },
+ ]);
+ });
+ it('uses disclosure.weightsLicense and never runtimeLicense', () => {
+ const p = provenanceForRender({
+ model: {
+ id: 'ltx-2.3',
+ name: 'LTX 2.3',
+ disclosure: {
+ modelCardUrl: 'https://huggingface.co/Lightricks/LTX-2.3',
+ weightsLicense: { name: 'Apache-2.0', url: 'https://huggingface.co/Lightricks/LTX-2.3' },
+ runtimeLicense: { name: 'MIT', url: 'https://opensource.org/licenses/MIT' },
+ },
+ },
+ });
+ expect(p.sources[0].license).toBe('Apache-2.0');
+ expect(p.sources[0].sourceUrl).toBe('https://huggingface.co/Lightricks/LTX-2.3');
+ });
+
+ it('drops unusable sources and dedupes by kind+id, preferring a known license', () => {
+ const p = buildProvenance({
+ sources: [
+ { kind: 'model', id: 'm', license: null },
+ { kind: 'model', id: 'm', license: 'mit', name: 'M' },
+ { kind: 'weird', id: 'x' },
+ { kind: 'lora', id: '' },
+ ],
+ });
+ expect(p.sources).toEqual([
+ { kind: 'model', id: 'm', name: 'M', license: 'mit', sourceUrl: null },
+ ]);
+ });
+});
+
+describe('readProvenance / resolveAssetProvenance', () => {
+ it('returns stamped provenance as-is and does not re-derive licenses', () => {
+ const record = {
+ modelId: 'flux2-klein-9b',
+ provenance: {
+ schemaVersion: 1,
+ capturedAt: '2026-01-01T00:00:00.000Z',
+ sources: [{ kind: 'model', id: 'flux2-klein-9b', license: null, name: 'Klein', sourceUrl: null }],
+ },
+ };
+ const resolved = resolveAssetProvenance(record);
+ expect(resolved.reconstructed).toBe(false);
+ expect(resolved.sources[0].license).toBeNull();
+ });
+ it('reconstructs unknown-license sources from sidecar fields when provenance is missing', () => {
+ const resolved = resolveAssetProvenance({
+ modelId: 'flux2-klein-9b',
+ loraFilenames: ['lora-style.safetensors'],
+ createdAt: '2026-01-02T00:00:00.000Z',
+ });
+ expect(resolved.reconstructed).toBe(true);
+ expect(resolved.sources.map((s) => s.id)).toEqual(['flux2-klein-9b', 'lora-style.safetensors']);
+ expect(resolved.sources.every((s) => s.license == null)).toBe(true);
+ });
+ it('returns null when there is nothing to attribute', () => {
+ expect(resolveAssetProvenance({})).toBeNull();
+ expect(readProvenance({ provenance: { sources: [] } })).toBeNull();
+ });
+});
+
+describe('rollupProvenance', () => {
+ it('unions distinct sources across a collection of assets', () => {
+ const rollup = rollupProvenance([
+ {
+ provenance: {
+ sources: [
+ { kind: 'model', id: 'a', license: 'mit' },
+ { kind: 'lora', id: 'x.safetensors', license: null },
+ ],
+ },
+ },
+ {
+ provenance: {
+ sources: [
+ { kind: 'model', id: 'a', license: 'mit' },
+ { kind: 'model', id: 'b', license: 'apache-2.0' },
+ ],
+ },
+ },
+ ]);
+ expect(rollup.sources.map((s) => s.id)).toEqual(['a', 'x.safetensors', 'b']);
+ });
+});
+
+describe('huggingfaceUrl', () => {
+ it('builds a Hub URL from a repo id and rejects blanks', () => {
+ expect(huggingfaceUrl('org/name')).toBe('https://huggingface.co/org/name');
+ expect(huggingfaceUrl(' ')).toBeNull();
+ expect(huggingfaceUrl(null)).toBeNull();
+ });
+});
+
+describe('buildProvenanceSource', () => {
+ it('rejects unknown kinds and empty ids', () => {
+ expect(buildProvenanceSource({ kind: 'runtime', id: 'x' })).toBeNull();
+ expect(buildProvenanceSource({ kind: 'model', id: '' })).toBeNull();
+ });
+});
+
+describe('client mirror', () => {
+ it('stays byte-for-byte with client/src/lib/assetProvenance.js', () => {
+ const here = dirname(fileURLToPath(import.meta.url));
+ const server = readFileSync(join(here, 'assetProvenance.js'), 'utf8');
+ const client = readFileSync(join(here, '../../client/src/lib/assetProvenance.js'), 'utf8');
+ expect(client).toBe(server);
+ });
+});
diff --git a/server/lib/brainValidation.js b/server/lib/brainValidation.js
index 68206d216c..e45fa01cbb 100644
--- a/server/lib/brainValidation.js
+++ b/server/lib/brainValidation.js
@@ -140,17 +140,6 @@ export const adminRecordSchema = z.object({
updatedAt: z.string().datetime()
});
-// Memory Record schema (journal entries, daily notes, personal memories)
-export const memoryRecordSchema = z.object({
- id: z.string().guid(),
- title: z.string().min(1).max(200),
- content: z.string().max(10000).optional().default(''),
- mood: z.string().max(50).optional(),
- tags: z.array(z.string().max(50)).optional().default([]),
- createdAt: z.string().datetime(),
- updatedAt: z.string().datetime()
-});
-
// Meta/Settings schema
export const brainSettingsSchema = z.object({
version: z.number().int().positive().default(1),
@@ -164,29 +153,6 @@ export const brainSettingsSchema = z.object({
lastWeeklyReview: z.string().datetime().optional()
});
-// Digest Record schema
-export const digestRecordSchema = z.object({
- id: z.string().guid(),
- generatedAt: z.string().datetime(),
- digestText: z.string().max(2000),
- topActions: z.array(z.string().max(200)).max(3),
- stuckThing: z.string().max(200),
- smallWin: z.string().max(200),
- ai: aiConfigSchema.optional()
-});
-
-// Weekly Review Record schema
-export const reviewRecordSchema = z.object({
- id: z.string().guid(),
- generatedAt: z.string().datetime(),
- reviewText: z.string().max(3000),
- whatHappened: z.array(z.string().max(200)).max(5),
- biggestOpenLoops: z.array(z.string().max(200)).max(3),
- suggestedActionsNextWeek: z.array(z.string().max(200)).max(3),
- recurringTheme: z.string().max(500),
- ai: aiConfigSchema.optional()
-});
-
// --- Input schemas for API endpoints ---
// Opt-in post-clone agent actions for a captured repository URL. Keys derive
@@ -557,17 +523,6 @@ export const bucketColorEnum = z.enum([
'accent', 'success', 'warning', 'error', 'purple', 'pink', 'cyan', 'slate'
]);
-// Bucket Record schema
-export const bucketRecordSchema = z.object({
- id: z.string().guid(),
- name: z.string().min(1).max(100),
- color: bucketColorEnum.default('accent'),
- icon: z.string().max(50).optional().default(''),
- order: z.number().int().default(0),
- createdAt: z.string().datetime(),
- updatedAt: z.string().datetime()
-});
-
// Create Bucket input schema
export const bucketInputSchema = z.object({
name: z.string().min(1).max(100),
diff --git a/server/lib/civitai.js b/server/lib/civitai.js
index b63bbd3fb3..b70be05fed 100644
--- a/server/lib/civitai.js
+++ b/server/lib/civitai.js
@@ -18,6 +18,7 @@
import { ServerError } from './errorHandler.js';
import { RUNNER_FAMILIES } from './runners.js';
import { readResponseJson } from './readResponseJson.js';
+import { licenseFromCivitaiModel } from './assetProvenance.js';
const CIVITAI_API = 'https://civitai.com/api/v1';
const CIVITAI_HOSTS = new Set(['civitai.com', 'civitai.red', 'civitai.green', 'www.civitai.com']);
@@ -334,6 +335,9 @@ export const buildSidecar = ({ model, version, file, filename }) => {
downloadUrl: file?.downloadUrl || null,
},
previewImageUrl: normalizeCivitaiImageUrl(previewImage?.url) || null,
+ // License as known at install time (#5638). Unknown stays null — never
+ // inferred from allowCommercialUse. Re-read at render would lie.
+ license: licenseFromCivitaiModel(model),
installedAt: new Date().toISOString(),
};
};
diff --git a/server/lib/civitai.test.js b/server/lib/civitai.test.js
index 182eba6348..c95354152d 100644
--- a/server/lib/civitai.test.js
+++ b/server/lib/civitai.test.js
@@ -394,6 +394,23 @@ describe('buildSidecar', () => {
expect(sc.previewImageUrl).toBe('p.jpg');
expect(sc.file.sizeKB).toBe(102400);
expect(typeof sc.installedAt).toBe('string');
+ expect(sc.license).toBeNull();
+ });
+ it('persists a Civitai license string and never infers one from allowCommercialUse', () => {
+ const licensed = buildSidecar({
+ model: { id: 1, name: 'Rail', license: 'CreativeML Open RAIL-M', allowCommercialUse: 'Sell' },
+ version: { id: 2, baseModel: 'Flux.1 D' },
+ file: {},
+ filename: 'lora-rail-v2.safetensors',
+ });
+ expect(licensed.license).toBe('CreativeML Open RAIL-M');
+ const commercialOnly = buildSidecar({
+ model: { id: 1, name: 'NoLicense', allowCommercialUse: 'Sell' },
+ version: { id: 2, baseModel: 'Flux.1 D' },
+ file: {},
+ filename: 'lora-nolicense-v2.safetensors',
+ });
+ expect(commercialOnly.license).toBeNull();
});
it('falls back when version has no settings/preview/trainedWords', () => {
const sc = buildSidecar({
diff --git a/server/lib/cliChildEnv.js b/server/lib/cliChildEnv.js
index 80f9ad62b5..bac4b90ba8 100644
--- a/server/lib/cliChildEnv.js
+++ b/server/lib/cliChildEnv.js
@@ -142,10 +142,39 @@ const PUBLIC_REVIEW_ENV_KEYS = new Set([
'CLAUDE_CODE_MAX_OUTPUT_TOKENS', 'MAX_THINKING_TOKENS',
]);
+// A Claude CLI pointed at a LOCAL Anthropic-compatible runtime (the Ollama and
+// SGLang wrappers) authenticates with a placeholder token that means nothing
+// outside that loopback endpoint, and its lean argv passes `--bare`, which
+// disables the keychain — so without the token the CLI exits "Not logged in"
+// before reading the prompt. Keep the credential only for a loopback base URL;
+// against any other host it is a real cloud credential and stays stripped.
+const LOCAL_ANTHROPIC_CREDENTIAL_KEYS = ['ANTHROPIC_AUTH_TOKEN', 'ANTHROPIC_API_KEY'];
+const LOOPBACK_HOSTNAMES = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);
+
+function localAnthropicCredentialEnv(env) {
+ let hostname;
+ try {
+ hostname = new URL(env?.ANTHROPIC_BASE_URL).hostname.toLowerCase();
+ } catch {
+ return {};
+ }
+ if (!LOOPBACK_HOSTNAMES.has(hostname) && !hostname.startsWith('127.')) return {};
+ return Object.fromEntries(LOCAL_ANTHROPIC_CREDENTIAL_KEYS
+ .filter((key) => env[key] != null)
+ .map((key) => [key, env[key]]));
+}
+
+function allowlistEnv(env, keys) {
+ return {
+ ...Object.fromEntries(Object.entries(env || {}).filter(([key, value]) => (
+ value != null && (keys.has(key) || key.startsWith('LC_'))
+ ))),
+ ...localAnthropicCredentialEnv(env),
+ };
+}
+
export function buildPublicReviewCliEnv(env = {}) {
- return Object.fromEntries(Object.entries(env || {}).filter(([key, value]) => (
- value != null && (PUBLIC_REVIEW_ENV_KEYS.has(key) || key.startsWith('LC_'))
- )));
+ return allowlistEnv(env, PUBLIC_REVIEW_ENV_KEYS);
}
// The actions stage is allowed to use its vendor's own workspace sandbox for
@@ -161,12 +190,13 @@ const PUBLIC_REVIEW_ACTIONS_ENV_KEYS = new Set([
'NVM_DIR', 'NVM_BIN',
'SystemRoot', 'SystemDrive', 'ComSpec', 'PATHEXT', 'USERPROFILE', 'APPDATA',
'LOCALAPPDATA', 'ProgramData', 'ProgramFiles', 'HOMEDRIVE', 'HOMEPATH',
+ // The local Claude wrappers are eligible for this stage too; without the
+ // endpoint they would talk to the cloud (or, with `--bare`, to nothing).
+ 'ANTHROPIC_BASE_URL', 'ANTHROPIC_SMALL_FAST_MODEL',
]);
export function buildPublicReviewActionsCliEnv(env = {}) {
- return Object.fromEntries(Object.entries(env || {}).filter(([key, value]) => (
- value != null && (PUBLIC_REVIEW_ACTIONS_ENV_KEYS.has(key) || key.startsWith('LC_'))
- )));
+ return allowlistEnv(env, PUBLIC_REVIEW_ACTIONS_ENV_KEYS);
}
/**
diff --git a/server/lib/cliChildEnv.test.js b/server/lib/cliChildEnv.test.js
index f65120b69c..6f0e82f2a9 100644
--- a/server/lib/cliChildEnv.test.js
+++ b/server/lib/cliChildEnv.test.js
@@ -138,8 +138,11 @@ describe('buildCliChildEnv — public-review profile', () => {
HOME: '/home/example',
LC_ALL: 'C',
ANTHROPIC_BASE_URL: 'http://127.0.0.1:11434',
+ // The wrapper's placeholder token authenticates only to the loopback
+ // runtime; `--bare` disables the keychain, so without it the CLI exits
+ // "Not logged in" (the live Stage 2 failure on the Ollama wrapper).
+ ANTHROPIC_AUTH_TOKEN: 'local-only',
});
- expect(env).not.toHaveProperty('ANTHROPIC_AUTH_TOKEN');
expect(env).not.toHaveProperty('GH_TOKEN');
expect(env).not.toHaveProperty('GITHUB_TOKEN');
expect(env).not.toHaveProperty('AWS_SECRET_ACCESS_KEY');
@@ -166,7 +169,7 @@ describe('buildCliChildEnv — public-review profile', () => {
});
expect(env.ANTHROPIC_BASE_URL).toBe('http://127.0.0.1:11434');
- expect(env).not.toHaveProperty('ANTHROPIC_AUTH_TOKEN');
+ expect(env.ANTHROPIC_AUTH_TOKEN).toBe('local-only');
expect(env.PWD).toBe('/tmp/public-review');
expect(env).not.toHaveProperty('GH_TOKEN');
expect(env).not.toHaveProperty('AWS_PROFILE');
@@ -175,6 +178,22 @@ describe('buildCliChildEnv — public-review profile', () => {
});
});
+describe('buildCliChildEnv — public-review profile, cloud endpoint', () => {
+ it('strips the Anthropic credential when the base URL is not loopback', () => {
+ const env = buildPublicReviewCliEnv({
+ PATH: '/usr/bin',
+ ANTHROPIC_BASE_URL: 'https://api.example.com',
+ ANTHROPIC_AUTH_TOKEN: 'cloud-secret',
+ ANTHROPIC_API_KEY: 'cloud-key',
+ });
+ expect(env.ANTHROPIC_BASE_URL).toBe('https://api.example.com');
+ expect(env).not.toHaveProperty('ANTHROPIC_AUTH_TOKEN');
+ expect(env).not.toHaveProperty('ANTHROPIC_API_KEY');
+ // No base URL at all means the cloud default — the credential stays out.
+ expect(buildPublicReviewCliEnv({ ANTHROPIC_API_KEY: 'cloud-key' })).not.toHaveProperty('ANTHROPIC_API_KEY');
+ });
+});
+
describe('buildCliChildEnv — public-review-actions profile', () => {
it('keeps runtime essentials without inherited credentials or config-path overlays', () => {
const env = buildCliChildEnv({
@@ -213,6 +232,17 @@ describe('buildCliChildEnv — public-review-actions profile', () => {
expect(env).not.toHaveProperty('AWS_PROFILE');
expect(env).not.toHaveProperty('PRIVATE_APP_SETTING');
});
+
+ it('keeps the local Claude wrapper endpoint and its placeholder token', () => {
+ const env = buildCliChildEnv({
+ baseEnv: { PATH: '/usr/bin', GH_TOKEN: 'forge-secret' },
+ provider: { envVars: { ANTHROPIC_BASE_URL: 'http://localhost:11434', ANTHROPIC_AUTH_TOKEN: 'ollama', ANTHROPIC_SMALL_FAST_MODEL: 'small:7b' } },
+ cwd: '/tmp/public-review-actions',
+ safetyProfile: PUBLIC_REVIEW_ACTIONS_EXECUTION_PROFILE,
+ });
+ expect(env).toMatchObject({ ANTHROPIC_BASE_URL: 'http://localhost:11434', ANTHROPIC_AUTH_TOKEN: 'ollama', ANTHROPIC_SMALL_FAST_MODEL: 'small:7b' });
+ expect(env).not.toHaveProperty('GH_TOKEN');
+ });
});
describe('buildCliChildEnv — PWD pin and CLAUDECODE strip', () => {
diff --git a/server/lib/clientApiPaths.js b/server/lib/clientApiPaths.js
new file mode 100644
index 0000000000..27d37cdb18
--- /dev/null
+++ b/server/lib/clientApiPaths.js
@@ -0,0 +1,397 @@
+/**
+ * Static scanner for the URL paths `client/src/services/api*.js` asks the server
+ * for.
+ *
+ * The client's ~87 `apiX.js` wrappers are the only place a browser learns which
+ * server route a feature lives at, and their co-located tests assert the wrapper
+ * produced the string the wrapper produces — nothing compares those strings to
+ * the routes `server/index.js` actually mounts. `apiRouteParity.test.js` closes
+ * that gap by diffing this scanner's output against
+ * `apiRouteCatalog.generated.json` (the existing server-side route inventory
+ * built by `scripts/generate-api-route-catalog.js`); this module is only the
+ * client half.
+ *
+ * Why a source scan rather than importing the modules: `apiCore.request` builds
+ * its URL at call time from arguments the wrapper supplies, so the paths exist
+ * only as expressions until someone calls them, and a server-suite test cannot
+ * import a client module anyway (different workspace, different vitest env).
+ * The scan therefore constant-folds those expressions: string and template
+ * literals, module-local path helpers (`loomPath(id, '/episodes')`), ternaries,
+ * and nested combinations of the three. Anything it cannot fold is REPORTED as
+ * unresolved rather than silently dropped, so a wrapper shape this scanner
+ * stops understanding surfaces as a review signal instead of shrinking the
+ * guard.
+ *
+ * The scan carries its own small lexer rather than reusing `sourceScan.js`:
+ * that module's primitives exist to find CODE constructs, so `blankLiterals`
+ * blanks literal content to spaces — which is exactly the text this scan has to
+ * read — and its `blankComments` is line-based, which shifts the offsets a
+ * balanced expression scan depends on.
+ *
+ * Nothing here records a line number in a checked-in artifact — the results are
+ * computed fresh in the test and used only to name a failing call site.
+ */
+
+import { readdirSync, readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { escapeRegExp } from './textUtils.js';
+
+/** Client wrappers live here; the scan reads `api*.js` minus tests. */
+const CLIENT_SERVICES_DIR = 'client/src/services';
+
+/**
+ * `apiCore.js` is the transport itself — its `request(endpoint, …)` calls take a
+ * caller-supplied path by design, so scanning it would only produce noise.
+ */
+const TRANSPORT_MODULE = 'apiCore.js';
+
+/**
+ * Call shapes that name a server path. `request(endpoint, …)` is the shared
+ * transport; the streaming and blob wrappers bypass it and call `fetch` on
+ * `` `${API_BASE}/…` `` directly, so those are scanned too — but only when the
+ * argument mentions `API_BASE`, since a bare `fetch` may target any origin.
+ */
+const REQUEST_CALL = /\b(request|fetch)\s*\(/g;
+const API_BASE_SCOPE = new Map([['API_BASE', ['']]]);
+
+/**
+ * Stand-in for an interpolated value the scan cannot fold to a literal (an id,
+ * a query string, a `URLSearchParams`). Braces never occur in a route path, so
+ * the marker cannot collide with real path text.
+ */
+const DYNAMIC = '{dyn}';
+
+/** Every `:param` segment normalizes to this, on both sides of the diff. */
+const PARAM_SEGMENT = ':p';
+
+const MAX_DEPTH = 8;
+
+const skipQuoted = (source, start, quote) => {
+ let i = start + 1;
+ while (i < source.length) {
+ if (source[i] === '\\') { i += 2; continue; }
+ if (source[i] === quote) return i + 1;
+ i++;
+ }
+ return i;
+};
+
+/**
+ * Skip a template literal whole, returning the index just past its closing
+ * backtick. `depth` counts open braces INSIDE an interpolation, not just `${`
+ * — an object literal in there (`${new URLSearchParams({ repoPath })}`) closes
+ * a brace the scan never opened, and a counter that only tracked `${` fell out
+ * of the interpolation early and then treated the next backtick as the
+ * literal's end. A backtick reached while inside an interpolation starts a
+ * NESTED template, skipped by recursion.
+ */
+const skipTemplate = (source, start) => {
+ let i = start + 1;
+ let depth = 0;
+ while (i < source.length) {
+ if (source[i] === '\\') { i += 2; continue; }
+ if (source[i] === '$' && source[i + 1] === '{') { depth += 1; i += 2; continue; }
+ if (depth > 0 && source[i] === '{') { depth += 1; i++; continue; }
+ if (depth > 0 && source[i] === '}') { depth -= 1; i++; continue; }
+ if (source[i] === '`') {
+ if (depth === 0) return i + 1;
+ i = skipTemplate(source, i);
+ continue;
+ }
+ if (depth > 0 && (source[i] === "'" || source[i] === '"')) { i = skipQuoted(source, i, source[i]); continue; }
+ i++;
+ }
+ return i;
+};
+
+/**
+ * Replace every comment with spaces, preserving offsets and newlines so line
+ * numbers and the balanced-scan below stay accurate. A `//` inside a string or
+ * template literal (`'https://…'`) must not start a comment, which is why this
+ * walks quote state rather than running a regex.
+ */
+function stripComments(source) {
+ const out = source.split('');
+ let i = 0;
+ while (i < source.length) {
+ const char = source[i];
+ if (char === '/' && source[i + 1] === '/') {
+ let end = i;
+ while (end < source.length && source[end] !== '\n') end++;
+ for (let k = i; k < end; k++) out[k] = ' ';
+ i = end;
+ continue;
+ }
+ if (char === '/' && source[i + 1] === '*') {
+ const found = source.indexOf('*/', i + 2);
+ const end = found === -1 ? source.length : found + 2;
+ for (let k = i; k < end; k++) if (out[k] !== '\n') out[k] = ' ';
+ i = end;
+ continue;
+ }
+ if (char === "'" || char === '"') { i = skipQuoted(source, i, char); continue; }
+ if (char === '`') { i = skipTemplate(source, i); continue; }
+ i++;
+ }
+ return out.join('');
+}
+
+const CLOSERS = { '(': ')', '[': ']', '{': '}' };
+
+/**
+ * Read forward from `start` until a top-level character in `stops`, or the
+ * bracket that closes the enclosing group. Strings, template literals, and
+ * nested brackets are skipped whole, so a comma inside `f(a, b)` or `${x ? 1 : 2}`
+ * does not terminate the scan.
+ */
+function scanTo(source, start, stops) {
+ const stack = [];
+ let i = start;
+ while (i < source.length) {
+ const char = source[i];
+ if (char === "'" || char === '"') { i = skipQuoted(source, i, char); continue; }
+ if (char === '`') { i = skipTemplate(source, i); continue; }
+ if (CLOSERS[char]) { stack.push(CLOSERS[char]); i++; continue; }
+ if (char === ')' || char === ']' || char === '}') {
+ if (stack.length === 0) return { text: source.slice(start, i), end: i };
+ stack.pop();
+ i++;
+ continue;
+ }
+ if (stack.length === 0 && stops.includes(char)) return { text: source.slice(start, i), end: i };
+ i++;
+ }
+ return { text: source.slice(start), end: source.length };
+}
+
+function splitArguments(text) {
+ const args = [];
+ let i = 0;
+ while (i <= text.length) {
+ const { text: arg, end } = scanTo(text, i, [',']);
+ args.push(arg.trim());
+ if (end >= text.length) break;
+ i = end + 1;
+ }
+ return args;
+}
+
+const parseParameter = (text) => {
+ const { text: before, end } = scanTo(text, 0, ['=']);
+ const name = before.trim();
+ return {
+ name: /^[A-Za-z_$][\w$]*$/.test(name) ? name : null,
+ fallback: end < text.length ? text.slice(end + 1).trim() : null,
+ };
+};
+
+/**
+ * Collect module-level `const name = (args) => ;` path helpers.
+ * Block-bodied arrows are skipped: their result is a statement sequence, not an
+ * expression this scanner can fold, and the call sites that use one land in
+ * `unresolved` where they are visible.
+ */
+function parsePathHelpers(source) {
+ const helpers = new Map();
+ const declaration = /(?:^|\n)[ \t]*(?:export\s+)?const\s+([A-Za-z_$][\w$]*)\s*=\s*\(/g;
+ for (const match of source.matchAll(declaration)) {
+ const parenStart = match.index + match[0].length;
+ const { text: parameterText, end } = scanTo(source, parenStart, []);
+ const arrow = source.slice(end + 1).match(/^\s*=>\s*/);
+ if (!arrow) continue;
+ const bodyStart = end + 1 + arrow[0].length;
+ if (source[bodyStart] === '{') continue;
+ // Bounded by the declaration's own semicolon, NOT by the newline: a concise
+ // body wrapped across lines would otherwise be truncated to its first line
+ // and fold to a WRONG path instead of failing loudly.
+ const { text: body } = scanTo(source, bodyStart, [';']);
+ helpers.set(match[1], {
+ parameters: splitArguments(parameterText).map(parseParameter),
+ body: body.trim(),
+ });
+ }
+ return helpers;
+}
+
+const stringLiteralValue = (expression) => {
+ const match = expression.match(/^(['"])((?:\\.|[^\\])*)\1$/s);
+ return match ? match[2].replace(/\\(.)/g, '$1') : null;
+};
+
+/**
+ * Fold a path expression to the set of strings it can produce, or `null` when
+ * the shape is not one this scanner understands. A ternary contributes both
+ * branches, so `seriesId ? '/x?s=1' : '/x'` yields both.
+ */
+function resolvePathExpression(expression, { helpers, scope = new Map(), depth = 0 } = {}) {
+ const text = (expression ?? '').trim();
+ if (!text || depth > MAX_DEPTH) return null;
+
+ const literal = stringLiteralValue(text);
+ if (literal !== null) return [literal];
+
+ if (text.startsWith('`') && skipTemplate(text, 0) === text.length) {
+ return resolveTemplate(text, { helpers, scope, depth });
+ }
+
+ if (/^[A-Za-z_$][\w$]*$/.test(text)) return scope.get(text) ?? null;
+
+ const condition = scanTo(text, 0, ['?']);
+ if (condition.end < text.length && text[condition.end + 1] !== '.') {
+ const rest = text.slice(condition.end + 1);
+ const branch = scanTo(rest, 0, [':']);
+ if (branch.end < rest.length) {
+ const consequent = resolvePathExpression(branch.text, { helpers, scope, depth: depth + 1 });
+ const alternate = resolvePathExpression(rest.slice(branch.end + 1), { helpers, scope, depth: depth + 1 });
+ if (!consequent || !alternate) return null;
+ return [...consequent, ...alternate];
+ }
+ }
+
+ // `request(...scoped('/privacy/vault', options))` — the helper returns the
+ // whole `[path, options]` ARGUMENT PAIR, so the request path is the helper's
+ // own first argument. (It may append a query string; normalization drops it.)
+ if (text.startsWith('...')) {
+ const spread = text.slice(3).trimStart();
+ const spreadCall = spread.match(/^([A-Za-z_$][\w$]*)\s*\(/);
+ if (!spreadCall) return null;
+ const [first] = splitArguments(scanTo(spread, spreadCall[0].length, []).text);
+ return resolvePathExpression(first, { helpers, scope, depth: depth + 1 });
+ }
+
+ const call = text.match(/^([A-Za-z_$][\w$]*)\s*\(/);
+ const helper = call && helpers?.get(call[1]);
+ if (helper) {
+ const args = splitArguments(scanTo(text, call[0].length, []).text);
+ const inner = new Map();
+ for (const [index, parameter] of helper.parameters.entries()) {
+ if (!parameter.name) continue;
+ // splitArguments yields '' for a position the call omitted, so `||` (not `??`)
+ // is what falls through to the parameter's default expression.
+ const argument = args[index] || parameter.fallback;
+ inner.set(
+ parameter.name,
+ argument == null ? [DYNAMIC] : (resolvePathExpression(argument, { helpers, scope, depth: depth + 1 }) ?? [DYNAMIC]),
+ );
+ }
+ return resolvePathExpression(helper.body, { helpers, scope: inner, depth: depth + 1 });
+ }
+
+ return null;
+}
+
+function resolveTemplate(text, context) {
+ const body = text.slice(1, -1);
+ let prefixes = [''];
+ let chunk = '';
+ let i = 0;
+ while (i < body.length) {
+ if (body[i] === '\\') { chunk += body[i + 1] ?? ''; i += 2; continue; }
+ if (body[i] === '$' && body[i + 1] === '{') {
+ const { text: inner, end } = scanTo(body, i + 2, []);
+ const values = resolvePathExpression(inner, { ...context, depth: context.depth + 1 }) ?? [DYNAMIC];
+ prefixes = prefixes.flatMap((prefix) => values.map((value) => prefix + chunk + value));
+ chunk = '';
+ i = end + 1;
+ continue;
+ }
+ chunk += body[i];
+ i++;
+ }
+ return prefixes.map((prefix) => prefix + chunk);
+}
+
+/**
+ * Turn one folded string into the `/api/...` path shape the server catalog uses,
+ * or `null` when it is not a request path at all.
+ *
+ * A segment that is entirely dynamic becomes `:p`. A dynamic tail fused onto the
+ * END of the last static segment is a query-string builder — `…/projections${qs(f)}`
+ * where `qs` returns `'?a=b'` or `''` — so the marker is dropped rather than
+ * swallowing the segment. A dynamic fused anywhere else is a genuinely variable
+ * segment and normalizes to `:p`.
+ */
+function normalizeClientPath(raw) {
+ const withoutQuery = raw.split('?')[0].split('#')[0];
+ if (!withoutQuery.startsWith('/')) return null;
+ const segments = withoutQuery.split('/').filter(Boolean);
+ const normalized = segments.map((segment, index) => {
+ if (!segment.includes(DYNAMIC)) return segment;
+ if (segment === DYNAMIC) return PARAM_SEGMENT;
+ const isTrailingSuffix = index === segments.length - 1
+ && segment.endsWith(DYNAMIC)
+ && !segment.slice(0, -DYNAMIC.length).includes(DYNAMIC);
+ return isTrailingSuffix ? segment.slice(0, -DYNAMIC.length) : PARAM_SEGMENT;
+ });
+ return `/api${normalized.map((segment) => `/${segment}`).join('')}`;
+}
+
+/** Collapse a server catalog path's named params so both sides compare equal. */
+const normalizeServerPath = (path) =>
+ path.split('/').map((segment) => (segment.startsWith(':') ? PARAM_SEGMENT : segment)).join('/');
+
+const isWildcard = (segment) => segment.startsWith('*');
+
+/**
+ * Express 5 wildcard segments (`/apps/:id/documents/*docPath`) swallow one or
+ * more path segments, so the client's `…/documents/:p/:p` is a legitimate match
+ * for them and plain string equality is not. Only wildcard routes need the
+ * regex; everything else stays in an O(1) Set.
+ */
+const wildcardMatcher = (path) => new RegExp(`^${
+ path.split('/').filter(Boolean)
+ .map((segment) => (isWildcard(segment) ? '[^/]+(?:/[^/]+)*' : escapeRegExp(segment)))
+ .map((pattern) => `/${pattern}`)
+ .join('')
+}$`);
+
+const lineOf = (source, index) => source.slice(0, index).split('\n').length;
+
+/**
+ * Scan the client service modules and return every `/api/...` path they request.
+ *
+ * `sources` injects `{ 'apiThing.js': '' }` instead of reading the tree —
+ * the parity test's bypass probes use it to prove the matcher still reports a
+ * path the server does not mount.
+ */
+export function scanClientApiPaths({ repoRoot, sources } = {}) {
+ const modules = sources
+ ? Object.entries(sources)
+ : readdirSync(join(repoRoot, CLIENT_SERVICES_DIR))
+ .filter((file) => /^api.*\.js$/.test(file) && !file.endsWith('.test.js') && file !== TRANSPORT_MODULE)
+ .sort()
+ .map((file) => [file, readFileSync(join(repoRoot, CLIENT_SERVICES_DIR, file), 'utf8')]);
+
+ const paths = [];
+ const unresolved = [];
+
+ for (const [file, rawSource] of modules) {
+ const source = stripComments(rawSource);
+ const helpers = parsePathHelpers(source);
+ for (const match of source.matchAll(REQUEST_CALL)) {
+ const { text } = scanTo(source, match.index + match[0].length, [',']);
+ const expression = text.trim().replace(/\s+/g, ' ');
+ if (match[1] === 'fetch' && !/\bAPI_BASE\b/.test(expression)) continue;
+ const site = { file: `${CLIENT_SERVICES_DIR}/${file}`, line: lineOf(source, match.index), expression };
+ const values = resolvePathExpression(text.trim(), { helpers, scope: API_BASE_SCOPE });
+ if (!values) { unresolved.push(site); continue; }
+ for (const value of values) {
+ const path = normalizeClientPath(value);
+ if (path) paths.push({ ...site, path });
+ else unresolved.push(site);
+ }
+ }
+ }
+
+ return { paths, unresolved };
+}
+
+/** Client call sites whose path no mounted server route can serve. */
+export const findUnmountedClientPaths = (clientPaths, serverPaths) => {
+ const normalized = [...serverPaths].map(normalizeServerPath);
+ const exact = new Set(normalized.filter((path) => !path.split('/').some(isWildcard)));
+ const wildcards = normalized.filter((path) => path.split('/').some(isWildcard)).map(wildcardMatcher);
+ return clientPaths.filter(
+ (entry) => !exact.has(entry.path) && !wildcards.some((matcher) => matcher.test(entry.path)),
+ );
+};
diff --git a/server/lib/commandExists.js b/server/lib/commandExists.js
index 0ba6bb7d43..e5dc49c959 100644
--- a/server/lib/commandExists.js
+++ b/server/lib/commandExists.js
@@ -3,6 +3,39 @@ import { promisify } from 'util';
const execFileAsync = promisify(execFile);
+/**
+ * Run a bounded capability probe and return its trimmed stdout, or `null` when
+ * the command could not run or exited non-zero.
+ *
+ * The child's stdin is closed immediately. `agy models` blocks on an open stdin
+ * and prints NOTHING until it closes — with execFile's default pipe that is a
+ * full timeout's hang ending in SIGTERM and empty output. (execFile ignores an
+ * `stdio` option, so ending the stream is the way to do it.) Not every vendor
+ * needs it, but it costs one FD close and makes every probe immune. Same
+ * reasoning, and the same incident, as `_execCliModelList` in
+ * `lib/aiToolkit/providers.js`.
+ */
+function probe(cmd, args, { timeoutMs, env, cwd, maxBuffer }) {
+ const options = {
+ timeout: timeoutMs,
+ ...(env === undefined ? {} : { env }),
+ ...(cwd === undefined ? {} : { cwd }),
+ ...(maxBuffer === undefined ? {} : { maxBuffer }),
+ };
+ // Some spawn failures (notably ENOEXEC for a broken text shim on macOS)
+ // are thrown synchronously by the child-process wrapper before it can
+ // return a promise. Start the call in a microtask so those failures follow
+ // the same null result as an ordinary rejected execFile promise.
+ return Promise.resolve()
+ .then(() => {
+ const pending = execFileAsync(cmd, args, options);
+ pending.child?.stdin?.end();
+ return pending;
+ })
+ .then(({ stdout }) => String(stdout ?? '').trim())
+ .catch(() => null);
+}
+
/**
* Does running `cmd args` succeed without error? A capability probe (not a
* PATH lookup like `whichFirst` in processEnv.js) — it actually invokes the
@@ -20,17 +53,25 @@ const execFileAsync = promisify(execFile);
* @returns {Promise}
*/
export async function commandExists(cmd, args = ['--version'], { timeoutMs = 5_000, env, cwd } = {}) {
- const options = {
- timeout: timeoutMs,
- ...(env === undefined ? {} : { env }),
- ...(cwd === undefined ? {} : { cwd }),
- };
- // Some spawn failures (notably ENOEXEC for a broken text shim on macOS)
- // are thrown synchronously by the child-process wrapper before it can
- // return a promise. Start the call in a microtask so those failures follow
- // the same false result as an ordinary rejected execFile promise.
- return Promise.resolve()
- .then(() => execFileAsync(cmd, args, options))
- .then(() => true)
- .catch(() => false);
+ return (await probe(cmd, args, { timeoutMs, env, cwd })) !== null;
+}
+
+/**
+ * The stdout of `cmd args`, trimmed — or `null` when the command could not run
+ * or exited non-zero. The output-returning sibling of {@link commandExists}:
+ * same probe, same bounded timeout, but it hands back what the command SAID so
+ * a caller can read a `--version` banner or a `models` listing instead of
+ * re-spawning the same child twice to learn both.
+ *
+ * `null` is the NOT-KNOWN sentinel, distinct from `''` (ran, said nothing) —
+ * the harness registry reads a null as "no version available", never as "out of
+ * date".
+ *
+ * @param {string} cmd
+ * @param {string[]} [args] - defaults to `['--version']`, the common probe
+ * @param {{timeoutMs?: number, env?: object, cwd?: string, maxBuffer?: number}} [opts]
+ * @returns {Promise}
+ */
+export async function commandOutput(cmd, args = ['--version'], { timeoutMs = 5_000, env, cwd, maxBuffer } = {}) {
+ return probe(cmd, args, { timeoutMs, env, cwd, maxBuffer });
}
diff --git a/server/lib/commandSecurity.js b/server/lib/commandSecurity.js
index 71c8fb6c9d..65b4f78112 100644
--- a/server/lib/commandSecurity.js
+++ b/server/lib/commandSecurity.js
@@ -24,12 +24,14 @@ export const ALLOWED_COMMANDS_SORTED = Array.from(ALLOWED_COMMANDS).sort();
// `npx ` / `pip install ` / `curl -o ` contain no shell
// metacharacter, so the metacharacter filter alone does NOT stop them.
//
-// Scope: this gate is binary-level, not subcommand-level. Every admitted binary
-// is one whose *documented* use here is inspection, but a few are multi-purpose
-// (`git commit`, `find -delete`, `gh api -X POST`) and are NOT rejected. That is
-// a deliberately smaller step than subcommand gating: it removes remote-code
-// fetch/exec from the unattended lane, which is the class that turns hostile
-// config into arbitrary RCE. Subcommand gating is tracked separately.
+// Scope: membership here is necessary but NOT sufficient. Four of these binaries
+// are multi-purpose — `git`, `find`, `gh` and `glab` all accept destructive or
+// mutating verbs (`git reset --hard`, `find . -delete`, `gh api -X POST`) that
+// carry no shell metacharacter. Those are rejected a second time by the
+// per-binary subcommand gate below (`UNATTENDED_SUBCOMMAND_VALIDATORS`, wired
+// into `validateUnattendedCommand`). The remaining entries (`ls`, `cat`, `head`,
+// `tail`, `grep`, `wc`, `pwd`, `echo`) have no write mode at all and stay
+// binary-level.
export const UNATTENDED_READONLY_COMMANDS = new Set([
'git', 'gh', 'glab',
'ls', 'cat', 'head', 'tail', 'grep', 'find', 'wc',
@@ -100,6 +102,132 @@ export function validatePm2Command(args) {
return { valid: true };
}
+// ---------------------------------------------------------------------------
+// Unattended subcommand gate (#5808)
+//
+// `UNATTENDED_READONLY_COMMANDS` admits four multi-purpose binaries whose
+// destructive verbs contain no shell metacharacter, so the allowlist and the
+// metacharacter filter both wave them through. These validators run only on the
+// unattended lane, keyed off the base command exactly the way `validateCommand`
+// keys the pm2 check off its own base command. The operator lane is untouched:
+// a human triggers and watches it, and `git commit` there is legitimate.
+// ---------------------------------------------------------------------------
+
+// Read-only git verbs. Allowlist, not denylist — git grows verbs, and an unknown
+// verb must fail closed rather than inherit a permission nobody reviewed.
+const UNATTENDED_GIT_SUBCOMMANDS = new Set([
+ 'log', 'show', 'status', 'diff', 'blame', 'describe', 'rev-parse',
+ 'branch', 'tag', 'remote', 'config', 'ls-files', 'shortlog',
+]);
+
+// `git config` only reads when asked to read; a bare `git config a.b c` writes.
+const GIT_CONFIG_READ_FLAGS = new Set(['--get', '--get-all', '--get-regexp', '--get-urlmatch', '--list', '-l']);
+
+// The three listing verbs above still expose a small mutating flag surface
+// (`git branch -D`, `git tag -d`, `git remote remove`). Their listing forms stay
+// allowed; these forms do not.
+const GIT_BRANCH_MUTATING_FLAGS = new Set(['-d', '-D', '--delete', '-m', '-M', '--move', '-c', '-C', '--copy', '--set-upstream-to', '-u', '--unset-upstream', '--edit-description']);
+const GIT_TAG_MUTATING_FLAGS = new Set(['-d', '--delete', '-a', '--annotate', '-s', '--sign', '-f', '--force', '-m', '--message', '-F', '--file']);
+// `git branch ` / `git tag ` CREATE a ref; the same verbs only read
+// when the positional is a filter for an explicit list mode. So a positional is
+// admitted only alongside one of these.
+const GIT_LIST_MODE_FLAGS = new Set(['-l', '--list', '--contains', '--no-contains', '--points-at', '--merged', '--no-merged']);
+const GIT_REMOTE_READ_VERBS = new Set(['show', 'get-url']);
+
+// `find` action flags: `-delete` removes files and `-exec`/`-ok` family runs
+// arbitrary binaries, which would defeat the allowlist entirely. `-f*print*`
+// writes attacker-chosen files. Everything else (`-name`, `-type`, `-maxdepth`,
+// `-print`) only inspects.
+const FIND_ACTION_FLAGS = new Set([
+ '-delete', '-exec', '-execdir', '-ok', '-okdir',
+ '-fls', '-fprint', '-fprint0', '-fprintf',
+]);
+
+// gh/glab write verbs are authenticated mutations against the tracker using the
+// operator's own credentials, so only ` list`, ` view` and read-only
+// `api` calls are admitted.
+const GH_READ_VERBS = new Set(['list', 'view']);
+const GH_METHOD_FLAGS = new Set(['-X', '--method']);
+// gh/glab switch `api` to POST implicitly when a field flag is present, so a
+// method check alone is not enough.
+const GH_FIELD_FLAGS = new Set(['-f', '-F', '--field', '--raw-field', '--input']);
+
+const deny = (error) => ({ valid: false, error });
+
+// True when `git branch` / `git tag` args only list refs: no mutating flag, and
+// no bare positional unless an explicit list-mode flag makes it a filter.
+function isGitRefListing(sub, rest) {
+ const mutating = sub === 'branch' ? GIT_BRANCH_MUTATING_FLAGS : GIT_TAG_MUTATING_FLAGS;
+ if (rest.some(a => mutating.has(a))) return false;
+ const hasPositional = rest.some(a => !a.startsWith('-'));
+ return !hasPositional || rest.some(a => GIT_LIST_MODE_FLAGS.has(a));
+}
+
+function validateUnattendedGit(args) {
+ const sub = args[0];
+ if (!sub) return deny("'git' needs a read-only subcommand on the unattended lane.");
+ if (!UNATTENDED_GIT_SUBCOMMANDS.has(sub)) {
+ return deny(`'git ${sub}' is not allowed on the unattended lane — only read-only inspection subcommands are: ${[...UNATTENDED_GIT_SUBCOMMANDS].sort().join(', ')}.`);
+ }
+ const rest = args.slice(1);
+ if (sub === 'config' && !rest.some(a => GIT_CONFIG_READ_FLAGS.has(a))) {
+ return deny("'git config' is only allowed in its read form on the unattended lane (e.g. 'git config --get ').");
+ }
+ if ((sub === 'branch' || sub === 'tag') && !isGitRefListing(sub, rest)) {
+ return deny(`'git ${sub}' is only allowed in its listing form on the unattended lane — naming a ref (or a delete/move flag) writes to the repo.`);
+ }
+ if (sub === 'remote' && rest.length && !GIT_REMOTE_READ_VERBS.has(rest[0]) && !rest[0].startsWith('-')) {
+ return deny("'git remote' is only allowed in its listing form on the unattended lane (e.g. 'git remote -v', 'git remote show ').");
+ }
+ return { valid: true };
+}
+
+function validateUnattendedFind(args) {
+ const action = args.find(a => FIND_ACTION_FLAGS.has(a));
+ if (action) {
+ return deny(`'find ${action}' is not allowed on the unattended lane — it deletes, writes, or executes. Use inspection predicates ('-name', '-type', '-maxdepth', '-print') instead.`);
+ }
+ return { valid: true };
+}
+
+function validateUnattendedGhLike(base, args) {
+ const sub = args[0];
+ if (!sub) return deny(`'${base}' needs a read-only subcommand on the unattended lane.`);
+ if (sub === 'api') {
+ const rest = args.slice(1);
+ for (let i = 0; i < rest.length; i += 1) {
+ const arg = rest[i];
+ // `?? ''` keeps a trailing `-X` with no value from crashing here — and a
+ // methodless `-X` is malformed anyway, so failing closed is correct.
+ const method = GH_METHOD_FLAGS.has(arg) ? (rest[i + 1] ?? '')
+ : arg.startsWith('--method=') ? arg.slice('--method='.length)
+ : null;
+ if (method !== null && method.toUpperCase() !== 'GET') {
+ return deny(`'${base} api' is limited to GET on the unattended lane — '${method}' is an authenticated write.`);
+ }
+ if (GH_FIELD_FLAGS.has(arg) || arg.startsWith('--field=') || arg.startsWith('--raw-field=')) {
+ return deny(`'${base} api ${arg}' is not allowed on the unattended lane — a field flag makes the request a POST.`);
+ }
+ }
+ return { valid: true };
+ }
+ const verb = args[1];
+ if (!GH_READ_VERBS.has(verb)) {
+ return deny(`'${base} ${sub}${verb ? ` ${verb}` : ''}' is not allowed on the unattended lane — only '${base} list', '${base} view' and read-only '${base} api' are.`);
+ }
+ return { valid: true };
+}
+
+// Keyed by base command; a binary with no entry is accepted on membership alone.
+// A Map, not an object literal — the key comes from the parsed command string,
+// and a Map has no prototype chain for `constructor`/`__proto__` to resolve into.
+const UNATTENDED_SUBCOMMAND_VALIDATORS = new Map([
+ ['git', validateUnattendedGit],
+ ['find', validateUnattendedFind],
+ ['gh', (args) => validateUnattendedGhLike('gh', args)],
+ ['glab', (args) => validateUnattendedGhLike('glab', args)],
+]);
+
/**
* Shared shape/metacharacter/allowlist gate. Both public validators route
* through this so the two lanes can never disagree about parsing or about
@@ -143,12 +271,18 @@ export function validateCommand(command) {
* string that lives in persistent, attacker-reachable config with no human in
* the loop. Same parsing and metacharacter rules as `validateCommand`, but only
* `UNATTENDED_READONLY_COMMANDS` are admitted, so a config that lands `npx`,
- * `curl` or `pip install` cannot reach a spawn. No pm2 sub-check is needed —
- * pm2 is not on the list at all.
+ * `curl` or `pip install` cannot reach a spawn. Multi-purpose binaries then get
+ * a per-binary subcommand check (`git`, `find`, `gh`, `glab`), so `git reset
+ * --hard`, `find . -delete` and `gh api -X POST` are rejected too. No pm2
+ * sub-check is needed — pm2 is not on the list at all.
* Returns { valid, error?, baseCommand?, args? }
*/
export function validateUnattendedCommand(command) {
- return validateAgainst(command, UNATTENDED_READONLY_COMMANDS, UNATTENDED_READONLY_COMMANDS_SORTED);
+ const check = validateAgainst(command, UNATTENDED_READONLY_COMMANDS, UNATTENDED_READONLY_COMMANDS_SORTED);
+ if (!check.valid) return check;
+ const subCheck = UNATTENDED_SUBCOMMAND_VALIDATORS.get(check.baseCommand)?.(check.args);
+ if (subCheck && !subCheck.valid) return subCheck;
+ return check;
}
// Patterns matching sensitive env var values in command output
diff --git a/server/lib/commandSecurity.test.js b/server/lib/commandSecurity.test.js
index 7d786224e2..2f926b6cd6 100644
--- a/server/lib/commandSecurity.test.js
+++ b/server/lib/commandSecurity.test.js
@@ -356,4 +356,123 @@ describe('commandSecurity', () => {
expect(validateCommand('pip install requests').valid).toBe(true)
})
})
+
+ describe('validateUnattendedCommand subcommand gate (#5808)', () => {
+ // Allowlist membership is necessary but not sufficient: `git`, `find`, `gh`
+ // and `glab` all accept destructive verbs that carry no shell metacharacter,
+ // so the binary-level list and the metacharacter filter both wave them past.
+ it.each([
+ ['git reset --hard destroys uncommitted work in the app repo', 'git reset --hard'],
+ ['git checkout . discards local edits', 'git checkout .'],
+ ['git clean -fd removes untracked files', 'git clean -fd'],
+ ['git push writes to the remote', 'git push'],
+ ['git commit writes history', 'git commit -m "x"'],
+ ['git stash is not on the read-only verb allowlist', 'git stash'],
+ ['an unknown-to-the-allowlist verb fails closed', 'git rebase --continue'],
+ ['a bare git has no read-only verb at all', 'git'],
+ ])('rejects %s', (_label, cmd) => {
+ expect(validateUnattendedCommand(cmd).valid).toBe(false)
+ })
+
+ it.each([
+ 'git log --oneline -20',
+ 'git status --short',
+ 'git diff --stat', // NB: `git diff HEAD~1` is already rejected upstream — `~` is a DANGEROUS_SHELL_CHAR
+ 'git show --name-only',
+ 'git blame README.md',
+ 'git rev-parse --abbrev-ref HEAD',
+ 'git shortlog -sn',
+ 'git ls-files',
+ 'git describe --tags',
+ 'git branch -a',
+ 'git tag -l v1',
+ 'git remote -v',
+ 'git remote show origin',
+ 'git config --get user.name',
+ ])('accepts read-only git form %s', (cmd) => {
+ expect(validateUnattendedCommand(cmd).valid).toBe(true)
+ })
+
+ it.each([
+ ['git branch -D deletes a branch', 'git branch -D main'],
+ ['git branch creates one', 'git branch newbranch'],
+ ['git tag creates a tag', 'git tag v9.9.9'],
+ ['git tag -d deletes one', 'git tag -d v1'],
+ ['git remote add rewrites remotes', 'git remote add evil https://example.com'],
+ ['git config writes config', 'git config user.email a@example.com'],
+ ])('rejects the mutating form of a listing verb: %s', (_label, cmd) => {
+ expect(validateUnattendedCommand(cmd).valid).toBe(false)
+ })
+
+ it.each([
+ ['-delete removes files', 'find . -delete'],
+ // The `{}` / `;` form is already caught by the metacharacter filter, so this
+ // uses the `+` terminator — it reaches the new check and proves it works.
+ ['-exec runs an arbitrary binary', 'find . -exec rm -f -- +'],
+ ['-execdir runs an arbitrary binary', 'find . -execdir rm -f -- +'],
+ ['-fprint writes an attacker-chosen file', 'find . -fprint /tmp/out'],
+ ])('rejects find action flag: %s', (_label, cmd) => {
+ const result = validateUnattendedCommand(cmd)
+ expect(result.valid).toBe(false)
+ expect(result.error).toMatch(/not allowed on the unattended lane/)
+ })
+
+ it.each([
+ 'find . -name README.md',
+ 'find . -type f -maxdepth 2 -print',
+ ])('accepts find inspection form %s', (cmd) => {
+ expect(validateUnattendedCommand(cmd).valid).toBe(true)
+ })
+
+ it.each([
+ ['gh api -X POST is an authenticated write', 'gh api -X POST /repos/x/y/issues'],
+ ['gh api --method DELETE is an authenticated write', 'gh api --method DELETE /repos/x/y/issues/1'],
+ ['gh api --method=PATCH is an authenticated write', 'gh api --method=PATCH /repos/x/y/issues/1'],
+ ['a field flag makes gh api an implicit POST', 'gh api /repos/x/y/issues -f title=hi'],
+ ['gh pr merge merges with operator credentials', 'gh pr merge 1'],
+ ['gh issue close mutates the tracker', 'gh issue close 1'],
+ ['gh pr create mutates the tracker', 'gh pr create --fill'],
+ ['glab mr merge merges with operator credentials', 'glab mr merge 1'],
+ ['glab api -X POST is an authenticated write', 'glab api -X POST /projects'],
+ ['a bare gh has no read-only verb at all', 'gh'],
+ ['a methodless -X is malformed and fails closed', 'gh api -X'],
+ ])('rejects %s', (_label, cmd) => {
+ expect(validateUnattendedCommand(cmd).valid).toBe(false)
+ })
+
+ it.each([
+ 'gh pr list',
+ 'gh issue list --limit 20',
+ 'gh issue view 1',
+ 'gh api /rate_limit',
+ 'gh api -X GET /rate_limit',
+ 'glab mr list',
+ 'glab issue view 1',
+ ])('accepts read-only tracker command %s', (cmd) => {
+ expect(validateUnattendedCommand(cmd).valid).toBe(true)
+ })
+
+ it('still returns the parsed base command and args on accept', () => {
+ const result = validateUnattendedCommand('git log --oneline -5')
+ expect(result).toEqual({ valid: true, baseCommand: 'git', args: ['log', '--oneline', '-5'] })
+ })
+
+ it.each([
+ 'ls -la',
+ 'cat README.md',
+ 'grep -rn TODO src',
+ 'echo hello',
+ 'wc -l README.md',
+ ])('leaves single-purpose binaries binary-level: %s', (cmd) => {
+ expect(validateUnattendedCommand(cmd).valid).toBe(true)
+ })
+
+ it('does not touch the operator lane', () => {
+ // POST /api/commands/execute stays byte-identical — a human triggers and
+ // watches it, and `git commit` / `gh pr merge` are legitimate there.
+ for (const cmd of ['git commit -m "x"', 'gh pr merge 1', 'git reset --hard', 'find . -delete']) {
+ expect(validateCommand(cmd).valid).toBe(true)
+ }
+ })
+ })
})
diff --git a/server/lib/cosValidation.js b/server/lib/cosValidation.js
index 0efc74d85a..1b2afcebb0 100644
--- a/server/lib/cosValidation.js
+++ b/server/lib/cosValidation.js
@@ -1,1178 +1,53 @@
/**
- * Chief-of-Staff (CoS) Zod schemas + reviewer config (split out of validation.js,
- * issue #1831).
+ * Chief-of-Staff (CoS) Zod schemas (split out of validation.js, issue #1831).
*
- * Covers CoS tasks, the Review-Loop reviewer vocabulary + helpers
- * (`normalizeReviewers` / `buildReviewWithArgs`), the Code-Review settings slice,
- * recurring jobs, loops, learning insights, and the task-metadata sanitizer.
- * validation.js re-exports everything here (flat) so existing deep imports keep
- * working; the barrel surfaces it as the `cosValidation` namespace.
+ * Covers CoS tasks, the Code-Review settings slice, recurring jobs, loops,
+ * learning insights, and the task-metadata sanitizer. The Review-Loop reviewer
+ * vocabulary + helpers (`normalizeReviewers` / `buildReviewWithArgs`) moved to
+ * `reviewerConfig.js` (issue #5702) and are re-exported flat from here, so
+ * existing deep imports keep working. validation.js re-exports everything here
+ * (flat); the barrel surfaces it as the `cosValidation` namespace.
*/
import { z } from 'zod';
import { emptyToUndefined, emptyToNull } from './zodCompat.js';
import { isPlainObject } from './objects.js';
-import { EFFORT_LEVELS, effortLevelsForProvider, buildEffortArgs, foldCursorEffortIntoModel, splitAntigravityModel } from './providerModels.js';
-import { ANTIGRAVITY_COMMAND } from './antigravity.js';
-import { CURSOR_COMMAND } from './cursor.js';
+import { EFFORT_LEVELS } from './providerModels.js';
import { isValidSlashdoCommand } from './slashdoInvocation.js';
import { PR_COMPLETION_VALUES } from './prDisposition.js';
import { PUBLIC_REVIEW_EXECUTION_PROFILES } from './agentExecutionProfiles.js';
import { AGENT_RUN_EVENT_KINDS, RUN_EVENT_READ_LIMITS } from './agentRunEvents.js';
import { recurrenceRuleSchema } from './recurrenceValidation.js';
import { TASK_DATA_INPUT_DEFINITIONS, TASK_DATA_INPUT_IDS } from './taskDataInputCatalog.js';
+import {
+ EFFORT_SELECTABLE_REVIEWERS,
+ KEYED_REVIEWER_PINS,
+ LOCAL_LLM_REVIEWERS,
+ MAX_REVIEWER_MAX_ROUNDS,
+ MAX_REVIEWER_MODEL_LENGTH,
+ MODEL_CAPABLE_CLI_REVIEWERS,
+ MODEL_SELECTABLE_REVIEWERS,
+ REVIEWER_ALIASES,
+ REVIEWER_VALUES,
+ REVIEW_STOP_MODES,
+ normalizeOptionalReviewers,
+ normalizeReviewUsernames,
+ normalizeReviewerEffort,
+ normalizeReviewerEfforts,
+ normalizeReviewerMaxRounds,
+ normalizeReviewerModel,
+ normalizeReviewerModels,
+ resolveReviewUsernames,
+} from './reviewerConfig.js';
export { TASK_DATA_INPUT_DEFINITIONS, TASK_DATA_INPUT_IDS } from './taskDataInputCatalog.js';
+// Transitional shim: the reviewer vocabulary lives in reviewerConfig.js but is
+// still reachable from every existing `cosValidation.js` / `validation.js` import.
+export * from './reviewerConfig.js';
// =============================================================================
// COS TASK SCHEMAS
// =============================================================================
-// Reviewer choices for the Review Loop. `copilot` requests a native GitHub
-// Copilot review; `claude`/`antigravity`/`codex`/`grok`/`cursor` instruct the review-loop
-// follow-up agent to invoke the named CLI to critique the PR diff; `lmstudio`/`ollama`
-// route the diff through PortOS's local code-review endpoint
-// (`POST /api/code-review/local`) which runs the configured local LLM model.
-// Mirrored in client/src/components/cos/constants.js → REVIEWER_OPTIONS.
-export const REVIEWER_VALUES = ['copilot', 'claude', 'antigravity', 'codex', 'grok', 'cursor', 'lmstudio', 'ollama'];
-export const REVIEWER_ALIASES = { gemini: 'antigravity', 'cursor-agent': 'cursor' };
-export const DEFAULT_REVIEWER = 'copilot';
-export const DEFAULT_REVIEWERS = ['copilot'];
-// Reviewers that resolve to a local-LLM backend (rather than a CLI or GitHub
-// bot). Used by the code-review endpoint, settings panel, and prompt builder
-// to gate model-id resolution.
-export const LOCAL_LLM_REVIEWERS = ['lmstudio', 'ollama'];
-// Reviewers PortOS serves ITSELF, with no counterpart in slashdo's reviewer
-// vocabulary: `lmstudio` runs through `POST /api/code-review/local`, which takes
-// its model in the request body. slashdo has no such slug, so it can neither
-// carry a `[]` bracket nor appear in a `--review-with` list (an unknown
-// value aborts the command). One constant so a future addition can't be fixed
-// in one of those two places and missed in the other.
-export const PORTOS_ONLY_REVIEWERS = ['lmstudio'];
-// CLI reviewers whose binary accepts a `--model ` tier the user can pin on
-// the Code Review Defaults panel (stored as a `Model` settings scalar,
-// e.g. `codexModel` / `claudeModel` / `antigravityModel`). The review-loop
-// follow-up threads each as a reviewer-keyed model map
-// (`reviewLoopReviewerModels`) so the prompt emits ` --model ` per
-// configured reviewer. `claude` covers both a normal Claude tier and an
-// Ollama-backed `claude` (see isOllamaClaudeProvider) where `--model` selects the
-// local Ollama model. `antigravity` runs `agy --model `; an effort-suffixed
-// agy id is reconciled with the effort pin by `pairReviewerModelsAndEfforts`.
-// `grok` runs `grok --model ` (slashdo's `grok[]` bracket); it takes a
-// model but NO effort at all, which is why this roster and
-// EFFORT_SELECTABLE_REVIEWERS are genuinely different sets rather than two names
-// for one list. `cursor` runs `cursor-agent --model ` and DOES take an
-// effort — but as a parameter of the model id (`gpt-5[effort=max]`), not a flag,
-// so its pin rides this roster's `--model` rather than an `--effort` argv.
-// Copilot/local-LLM reviewers are excluded — the former has no CLI, the latter
-// get their model injected server-side by `POST /api/code-review/local`. Add a
-// reviewer here when its CLI gains model selection; the `Model`
-// settings scalar is generated from this roster (codeReviewSettingsSchema).
-export const MODEL_CAPABLE_CLI_REVIEWERS = ['codex', 'claude', 'antigravity', 'grok', 'cursor'];
-// Every reviewer whose model the user can PICK in the UI: the model-capable CLIs
-// above (threaded into the follow-up prompt as ` --model `) plus the
-// local-LLM backends (whose id is injected server-side by
-// `POST /api/code-review/local`, or emitted as slashdo's `[]` bracket for a
-// claim flow). `copilot` and `@username` reviewers are excluded — neither is a
-// model-taking backend, matching slashdo rejecting `copilot[…]`/`@login[…]`.
-export const MODEL_SELECTABLE_REVIEWERS = [...MODEL_CAPABLE_CLI_REVIEWERS, ...LOCAL_LLM_REVIEWERS];
-// The executable a CLI reviewer's slug actually resolves to on PATH. Every slug
-// except `antigravity` names its own binary; `antigravity` is the STORED,
-// federated reviewer identity (aliased from the older `gemini`) while the
-// shipped executable is `agy` — there is no `antigravity` command. A prompt that
-// names only the slug sends the follow-up agent looking for a binary that does
-// not exist: one CoS review-loop agent probed `command -v antigravity`, found
-// nothing, declared "no reviewer available", and merged its own PR on a
-// self-review. Prompt builders must resolve the slug through
-// `reviewerCliBinary()` before telling an agent what to invoke.
-// A reviewer absent from this map has no spawnable CLI (`copilot` is a GitHub
-// API review, `lmstudio`/`ollama` go through `POST /api/code-review/local`).
-export const REVIEWER_CLI_BINARIES = {
- claude: 'claude',
- antigravity: ANTIGRAVITY_COMMAND,
- codex: 'codex',
- grok: 'grok',
- cursor: CURSOR_COMMAND,
-};
-
-/**
- * Is this reviewer a CLI the agent spawns itself? Derived by EXCLUSION rather
- * than from REVIEWER_CLI_BINARIES so a newly added CLI reviewer still drives the
- * review loop before anyone remembers to map its binary (the map's coverage is
- * pinned separately by cosValidation.test.js).
- *
- * The one definition of the rule — the prompt builder and the coverage test both
- * call it, so neither can re-implement (and quietly diverge from) it.
- *
- * @param {string} reviewer - reviewer slug
- * @returns {boolean}
- */
-export function isCliReviewer(reviewer) {
- return reviewer !== DEFAULT_REVIEWER && !LOCAL_LLM_REVIEWERS.includes(reviewer);
-}
-
-/**
- * The PATH executable for a CLI reviewer slug, or `null` when the reviewer is
- * not a spawnable CLI. Accepts the `gemini` alias.
- *
- * A null here means "no binary is mapped", NOT "not a CLI" — use isCliReviewer
- * for that question. The two can disagree for exactly one reviewer: a new CLI
- * reviewer added to REVIEWER_VALUES before its REVIEWER_CLI_BINARIES entry.
- * That reviewer still drives the loop (isCliReviewer says yes) and its prompt
- * falls back to naming the slug — the pre-existing behavior — rather than being
- * silently dropped. cosValidation.test.js pins the map's coverage so the window
- * closes at review time.
- *
- * @param {string} reviewer - reviewer slug (`antigravity`, `gemini`, `codex`, …)
- * @returns {string|null}
- */
-export function reviewerCliBinary(reviewer) {
- if (typeof reviewer !== 'string') return null;
- const slug = reviewer.trim().toLowerCase();
- return REVIEWER_CLI_BINARIES[REVIEWER_ALIASES[slug] || slug] || null;
-}
-
-/**
- * Render a reviewer slug for an agent prompt as the command it must actually
- * run, keeping the slug visible so the text still lines up with the configured
- * reviewer list and slashdo's `--review-with` token.
- *
- * `antigravity` → ```agy` (the `antigravity` reviewer)``; every other reviewer,
- * whose binary equals its slug, → ```codex` `` with no redundant restatement.
- *
- * @param {string} reviewer - reviewer slug
- * @returns {string} markdown fragment
- */
-export function describeReviewerCli(reviewer) {
- if (typeof reviewer !== 'string' || !reviewer) return '';
- const binary = reviewerCliBinary(reviewer);
- if (!binary || binary === reviewer) return `\`${reviewer}\``;
- return `\`${binary}\` (the \`${reviewer}\` reviewer)`;
-}
-// Stop-mode for the multi-reviewer loop (slashdo `--review-stop-on-*`).
-export const REVIEW_STOP_MODES = ['all', 'on-findings', 'on-clean'];
-export const DEFAULT_REVIEW_STOP_MODE = 'all';
-
-// Arbitrary GitHub reviewer usernames (e.g. `@CodeReviewbot`) requested as PR
-// reviewers to gate merging — a class distinct from the fixed REVIEWER_VALUES
-// enum (which either invoke a CLI, hit the local-LLM endpoint, or request the
-// native Copilot reviewer). Usernames are appended to slashdo's `--review-with`
-// as `@user` tokens after the keyed reviewers; the review-loop follow-up prompt
-// instructs the agent to request each as a PR reviewer and gate the merge on it.
-//
-// Stored WITHOUT the leading `@` (added back only in the flag string). The
-// charset is deliberately shell-safe — a GitHub username (1–39 chars,
-// alphanumeric + single hyphens, no leading/trailing hyphen) optionally followed
-// by a `/team-slug` for org-team mentions. No shell metacharacters, so the token
-// stays inert wherever it lands in a command string.
-export const MAX_REVIEW_USERNAMES = 20;
-const REVIEW_USERNAME_RE = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})(?:\/[A-Za-z0-9._-]{1,100})?$/;
-
-/**
- * Normalize a raw list of reviewer usernames: strip an optional leading `@`,
- * trim, drop anything that isn't a shell-safe GitHub username/team slug,
- * case-insensitively dedupe (GitHub logins are case-insensitive) while
- * preserving first-occurrence order, and cap at MAX_REVIEW_USERNAMES. Returns
- * a clean array of usernames WITHOUT the `@` prefix. Non-array input → [].
- */
-export function normalizeReviewUsernames(list) {
- if (!Array.isArray(list)) return [];
- const seen = new Set();
- const out = [];
- for (const raw of list) {
- if (typeof raw !== 'string') continue;
- const trimmed = raw.trim().replace(/^@+/, '');
- if (!trimmed || !REVIEW_USERNAME_RE.test(trimmed)) continue;
- const key = trimmed.toLowerCase();
- if (seen.has(key)) continue;
- seen.add(key);
- out.push(trimmed);
- if (out.length >= MAX_REVIEW_USERNAMES) break;
- }
- return out;
-}
-
-/**
- * Resolve reviewer usernames with task-over-default precedence: a task-level
- * list (even explicitly empty) overrides the Code Review Defaults; only fall
- * back to the defaults when the task didn't pin its own. Mirrors how
- * `normalizeReviewers`'s fallback param works for the keyed reviewers.
- */
-export function resolveReviewUsernames(metadataUsernames, defaultUsernames) {
- return Array.isArray(metadataUsernames)
- ? normalizeReviewUsernames(metadataUsernames)
- : normalizeReviewUsernames(defaultUsernames);
-}
-
-/**
- * Normalize ONE raw reviewer identity to the exact token `--review-with` emits:
- * a keyed slug from `REVIEWER_VALUES` (aliasing `gemini` → `antigravity`) or an
- * `@`. Returns null for anything else. Single definition of the token
- * identity shared by `normalizeOptionalReviewers` and
- * `normalizeReviewerMaxRounds`, so the `~opt` set and the `~max=` map can't
- * disagree about what a reviewer is called.
- */
-function normalizeReviewerToken(raw) {
- if (typeof raw !== 'string') return null;
- const trimmed = raw.trim();
- if (!trimmed) return null;
- if (trimmed.startsWith('@')) {
- const [user] = normalizeReviewUsernames([trimmed]);
- return user ? `@${user}` : null;
- }
- const slug = REVIEWER_ALIASES[trimmed] ?? trimmed;
- return REVIEWER_VALUES.includes(slug) ? slug : null;
-}
-
-/**
- * Reviewer identities the user marked OPTIONAL (non-blocking). slashdo's `~opt`
- * suffix is appended to each matching `--review-with` token, so an *inconclusive*
- * verdict from that reviewer (timeout / no-verdict / partial) no longer gates the
- * merge — a hard-error from it still does (slashdo `lib/multi-reviewer-loop.md`).
- * This is the escape hatch for a valuable-but-flaky reviewer (a local Ollama
- * model that often returns nothing) that would otherwise strand every PR on an
- * `inconclusive` aggregate.
- *
- * Each entry mirrors an *emitted* `--review-with` token so the builder's
- * membership test is a plain lookup: a keyed slug from `REVIEWER_VALUES`
- * (`ollama`, `lmstudio`, …) or an `@`. Normalizes like the sibling
- * helpers — drop non-strings/unknown slugs/unsafe usernames, alias `gemini` →
- * `antigravity`, dedupe case-insensitively preserving order. Non-array → undefined
- * (an omitted field isn't persisted as an empty override).
- */
-export function normalizeOptionalReviewers(list) {
- if (!Array.isArray(list)) return undefined;
- const seen = new Set();
- const out = [];
- for (const raw of list) {
- const token = normalizeReviewerToken(raw);
- if (!token) continue;
- const key = token.toLowerCase();
- if (seen.has(key)) continue;
- seen.add(key);
- out.push(token);
- }
- return out;
-}
-
-/**
- * Resolve optional (non-blocking) reviewers with task-over-default precedence:
- * a task-level list (even explicitly empty) overrides the Code Review Defaults;
- * only fall back to the defaults when the task didn't pin its own. Mirrors
- * `resolveReviewUsernames`.
- */
-export function resolveOptionalReviewers(metadataOptional, defaultOptional) {
- return Array.isArray(metadataOptional)
- ? (normalizeOptionalReviewers(metadataOptional) || [])
- : (normalizeOptionalReviewers(defaultOptional) || []);
-}
-
-/**
- * Factory for the token-keyed per-reviewer PIN normalizers (`~max=` caps,
- * model ids, reasoning efforts). All three share one contract and only differ in
- * how they validate a single value, so the contract lives here once:
- *
- * - Non-object input → `undefined`, so an omitted field isn't persisted as an
- * empty override (an explicitly empty `{}` IS kept — it's a real "clear the
- * defaults for this task" choice).
- * - Keys are normalized to the exact token `--review-with` emits
- * (`normalizeReviewerToken`), so the maps can't disagree about what a reviewer
- * is called; unknown tokens are dropped.
- * - First spelling wins for two names of one reviewer (`gemini`/`antigravity`,
- * `@Bot`/`@bot`) — mirrors `normalizeOptionalReviewers`' dedupe.
- * - A value `normalizeOne` rejects is DROPPED, never coerced — for every pin
- * kind, "absent" and "a falsy value" mean different things downstream.
- *
- * `Object.create(null)` while building so a reviewer token can't collide with
- * `Object.prototype` keys; spread on return so callers get a plain object.
- *
- * @param {(value: unknown, token: string) => unknown} normalizeOne - returns the
- * validated value, or a falsy value to drop the entry.
- */
-function keyedReviewerPinNormalizer(normalizeOne) {
- return (map) => {
- if (!isPlainObject(map)) return undefined;
- const out = Object.create(null);
- for (const [rawKey, rawValue] of Object.entries(map)) {
- const token = normalizeReviewerToken(rawKey);
- if (!token) continue;
- const value = normalizeOne(rawValue, token);
- if (!value && value !== 0) continue;
- if (Object.prototype.hasOwnProperty.call(out, token)) continue;
- out[token] = value;
- }
- return { ...out };
- };
-}
-
-/**
- * Factory for the matching task-over-default resolvers: a task-level map — even
- * an explicitly empty one — overrides the Code Review Defaults; only an
- * absent/malformed one falls back. Mirrors `resolveOptionalReviewers`.
- *
- * @param {(map: unknown) => Object|undefined} normalizeMap - the normalizer this
- * pin kind was built with.
- */
-function keyedReviewerPinResolver(normalizeMap) {
- return (metadataMap, defaultMap) => (isPlainObject(metadataMap)
- ? (normalizeMap(metadataMap) || {})
- : (normalizeMap(defaultMap) || {}));
-}
-
-// Ceiling on a per-reviewer `~max=` cap. slashdo's inner loops carry their own
-// 10-iteration safety guardrail, so a budget above it can never be spent —
-// accepting one would just be a lie in the flag string.
-export const MAX_REVIEWER_MAX_ROUNDS = 10;
-
-/**
- * Per-reviewer iteration caps — slashdo's `~max=` suffix (v3.25.0). Caps how
- * many review → fix → re-review cycles ONE reviewer runs before it stops, so a
- * slow local model can be included in a chain without paying for its otherwise
- * hardcoded 3 rounds (`--review-with claude~max=2,ollama~max=1,codex~max=3`).
- * Stored as a token-keyed map (`{ ollama: 1, '@flaky-bot': 0 }`) rather than a
- * list because the cap carries a value; the key is the same *emitted*
- * `--review-with` token `normalizeOptionalReviewers` uses.
- *
- * **Absent ≠ 0.** slashdo reads `~max=0` as "loop until this reviewer is clean"
- * (bounded by its own 10-round guardrail), which is the OPPOSITE of "no cap
- * requested" (that keeps slashdo's built-in default of 3 for CLI/Ollama
- * reviewers). So a missing key and an explicit `0` must never collapse: an entry
- * whose value isn't a usable cap is DROPPED rather than coerced to `0`.
- * Drops unknown tokens, non-integers, negatives, and anything above
- * MAX_REVIEWER_MAX_ROUNDS so a hand-edited settings.json can't smuggle in an
- * unbounded budget. Non-object input → undefined (an omitted field isn't
- * persisted as an empty override).
- *
- * `0` is the one pin value that is falsy AND meaningful, which is why the shared
- * factory keeps it explicitly.
- */
-export const normalizeReviewerMaxRounds = keyedReviewerPinNormalizer((rawValue) => (
- // Only a genuine non-negative integer is a cap. A string "2", null, NaN, or
- // 1.5 is not — and must NOT fall through to 0, which slashdo reads as
- // "unlimited".
- (Number.isInteger(rawValue) && rawValue >= 0 && rawValue <= MAX_REVIEWER_MAX_ROUNDS)
- ? rawValue
- : undefined
-));
-
-/**
- * Resolve per-reviewer iteration caps with task-over-default precedence: a
- * task-level map (even explicitly empty) overrides the Code Review Defaults;
- * only fall back to the defaults when the task didn't pin its own. Mirrors
- * `resolveOptionalReviewers`.
- */
-export const resolveReviewerMaxRounds = keyedReviewerPinResolver(normalizeReviewerMaxRounds);
-
-// Upper bound on a pinned reviewer model id. Generous (Bedrock/Ollama ids get
-// long) but present so a hand-edited settings.json can't smuggle in a blob that
-// then round-trips the TASKS.md store.
-export const MAX_REVIEWER_MODEL_LENGTH = 200;
-
-// Characters a model id may not contain, because they are STRUCTURAL in the
-// emitted `--review-with` token and there is no escape for them:
-// - `]` would close the `[]` selector early (`foo]~opt` → a corrupt entry
-// whose remainder slashdo then parses as suffixes),
-// - `[` would open a nested one,
-// - `,` would split the entry list, turning one reviewer into two bogus ones,
-// - whitespace that breaks lines would split the single-line flag string.
-// Everything else stays legal on purpose: the value is free-form in slashdo's
-// grammar (`agy[Gemini 3.5 Flash (High)]` is valid), and the field has to accept
-// whatever id the user's environment actually needs. A space is fine; a newline is
-// not.
-const REVIEWER_MODEL_FORBIDDEN_RE = /[[\],\r\n\t]/;
-
-/**
- * Validate ONE reviewer model id — the single definition shared by the
- * token-keyed map normalizer, the `