diff --git a/.changelog/NEXT.md b/.changelog/NEXT.md index ad882c450..ca9123852 100644 --- a/.changelog/NEXT.md +++ b/.changelog/NEXT.md @@ -1,6 +1,7 @@ # Unreleased Changes ## Added +- **[codex5-onboarding-capability-map] Capabilities page — every connected system at a glance.** A new Capabilities page (under System) shows the status of each integration on one screen: AI Providers, Calendar, Brain & Memory, Voice, Tailscale & HTTPS, Genome & Health, Telegram, Messages, and Apps & Processes. Every row says whether it's set up and healthy — Ready, Degraded, Error, or Not set up — and links straight to its settings, so the page works as both a first-run setup checklist and a quick runtime health overview. Reachable from the sidebar, ⌘K, and voice. - **Federated media collections + per-category sync integrity.** Media collections are now a first-class peer-sync record kind (`mediaCollection` in `PEER_SUBSCRIBABLE_KINDS`, schema-version-gated at `mediaCollections: 1`): they auto-subscribe on create, push per-record on edit, and propagate deletes via tombstones — collections now reach parity between federated machines the way universes/series already did, instead of only riding along as `linkedCollection` cargo or the opt-in 60s snapshot. Required adding soft-delete to collections (`deleted`/`deletedAt`; `deleteCollection` marks instead of splicing; `listCollections({ includeDeleted })`; tombstone-aware LWW merge; tombstone GC), so a delete on one node can't be resurrected by a reverse-subscribed peer. Synced images now carry their **generation prompts**: the peer asset manifest advertises a canonical gen-params-only `sidecarSha256` (machine-local hash-cache stripped so it converges across peers), and the asset-pull worker fetches each image's `.metadata.json` sidecar alongside the bytes — fixing images that previously landed in "Unsorted" with no prompts. A new per-category **sync integrity** surface (`GET /api/peer-sync/manifest`, `GET /api/peer-sync/integrity`, pure `computeRecordIntegrity` diff) drives a `SyncBadge` on every Universe / Series / Media-Collection row (in-sync / diverged / assets-missing / local-only / on-peer-only / "not syncing — enable?") plus a deep-linkable `SyncDetailDrawer` (`/…/:id/sync`) showing the per-peer breakdown, previews, and manual actions: **Sync to peer** (`POST /sync-record`, force-push bypassing the unchanged-hash short-circuit), **Sync now** (`POST /sync-now`, backfill-subscribe + retry a peer's pending pushes), and **Pull missing prompts** (`POST /pull-metadata` + a button on the Unsorted view that backfills sidecars from online peers). `mediaCollections` sync stays **opt-in** per peer (default off); the badge surfaces a clear "enable?" state rather than flipping the default. - **Manual tombstone GC trigger.** The 60s `syncOrchestrator` sweep buffers tombstones for 24h to give every peer time to ack a deletion before the bytes leave disk — fine for normal use, painful right after a mass-delete. New `POST /api/sync/tombstones/sweep` (Zod-validated optional `{ graceMs }` clamped to `[0, 24h]`) calls `sweepTombstones({ graceMs })` so callers can shrink the buffer; the per-kind null-cutoff refusal (snapshot-mode peer with no per-record subscription) still fires independently of `graceMs` to preserve resurrection safety. Companion `GET /api/sync/tombstones/status` returns the dry-run refusal status without invoking the prune helpers. A new "GC tombstones now" section on the Instances page (between SelfCard/AddPeer and the peers list) POSTs `graceMs: 0` and surfaces per-kind prune counts + any refusals in a toast; the button is disabled only when every kind is refused. `scripts/gc-tombstones-now.js` is the equivalent CLI path (dry-run by default; `--apply` to prune) for headless cleanups — uses the same "stop the server first, single-process write-tail isn't cross-process safe" wording as `cleanup-test-data.js`. `sweepTombstones` now returns `{ universes, series, issues, refused }` so the manual trigger can explain why a kind didn't shrink. The orchestrator path keeps the 24h default behavior unchanged. - `server/lib/collectionStore.js` — per-type, per-record JSON storage helper with explicit type-level `schemaVersion` stamping. `createCollectionStore({ dir, type, schemaVersion, sanitizeRecord })` returns `loadOne` / `saveOne` / `listIds` / `loadAll` / `deleteOne` / `loadTypeIndex` / `saveTypeIndex` / `verifySchemaVersion`. Per-id write queue means writes to different records run in parallel — the win over a single-file write queue. `verifyCollectionVersions([store, ...])` is called at server boot after `runMigrations()` so a missed migration produces a loud, single-line log per collection (does not crash — PortOS is single-user). diff --git a/PLAN.md b/PLAN.md index f267f36ab..e54e9a56d 100644 --- a/PLAN.md +++ b/PLAN.md @@ -4,14 +4,13 @@ For project goals, see [GOALS.md](./GOALS.md). For completed work, see [.changel ## Next Up -- [ ] [codex5-onboarding-capability-map] **[P2][ONBOARDING]** Capability map of connected systems. One page showing each integration's status: Providers (per-provider configured/available/throttled), Calendar, Brain/memory embeddings, Voice (mic + TTS), Tailscale + HTTPS, Genome/health imports, Telegram/messages, App registry/PM2. Each row links to the relevant settings page. Doubles as a setup checklist and a runtime health overview. - [ ] [migrate-taskitem-formatattachmentsize-to-formatbytes] **Migrate `TaskItem.jsx`'s local `formatAttachmentSize` to shared `formatBytes`.** `client/src/components/cos/tabs/TaskItem.jsx` still defines a local `formatAttachmentSize(bytes)` (B/KB/MB with `.toFixed(1)`) that duplicates `formatBytes` in `client/src/utils/formatters.js`. Surfaced by gemini review during the `[migrate-two-remaining-local-formatters]` claim 2026-05-24; deferred as out-of-scope for that item (which named only `formatDateTime` + `formatDurationMin`). Note the edge-case semantics differ: the local helper returns `''` for falsy/0 size (used inside an attachment `title` like `(1.5 KB)`), while `formatBytes(0)` returns `'0 B'` — preserve the empty-on-missing behavior with a guard (`att.size ? formatBytes(att.size) : ''`) so the title doesn't render `(0 B)` for sizeless attachments. - [ ] [optimize-voice-ui-index-text-payload-lazy-only-run] **Optimize `voice:ui:index` text payload.** Lazy: only run `extractVisibleText` when server requests via `voice:ui:read-request`. Keep current behavior as fallback. - [ ] [voice-agent-explicit-long-term-memory-routing-on] **Voice agent — explicit long-term memory routing.** On retrieval-shaped voice turns, inject top-N relevant memories into the system prompt via `brain_search`. - [ ] [flux2-multi-reference-python-runner] **FLUX.2 multi-reference Python runner.** The UI + server contract for multi-reference editing shipped 2026-05-17 (slug `multi-reference-image-editing-for-flux-2-ui`); the Python runner (`scripts/flux2_macos.py`) currently ignores the `--reference-images`/`--reference-strengths` args that `local.js` now passes. Wire diffusers' multi-reference API in the runner and swap `server/lib/mediaModels.js#flux2-klein-9b` `tokenizerRepo` to `FLUX.2-klein-9B-kv` (gated repo — requires the user to accept the license on HF). Validate end-to-end with 2–4 uploaded refs. - [ ] [voice-cos-tool-expansion-calendar-today-calendar] **Voice CoS tool expansion** — `calendar_today` / `calendar_next` (existing Google Calendar MCP), `meatspace_log_workout` (wraps `meatspaceHealth.js`), `weather_now` (pick API: OpenWeather / WeatherKit / NWS), `timer_set` (reuses `agentActionExecutor.js`). - [ ] [voice-agent-vision-fallback-ui-describe-visually] **Voice agent vision fallback** — `ui_describe_visually`: screenshot tab and send to a vision-capable model so "what's on this chart?" works on CyberCity / graph views. -- [ ] [apple-health-integration-live-sync] **Apple Health integration for MeatSpace.** iOS live sync (HealthKit Shortcut → `POST /api/meatspace/apple-health` endpoint) plus a bulk historical import path for an exported `export.xml`. Wire into existing MeatSpace tabs so steps / sleep / heart rate / VO2 max / resting HR show alongside the alcohol / blood / body / epigenetic tracks already shipped. GOALS.md flags this as a documented Secondary Goal ("Apple Health integration planned") but no implementation tracking existed until this entry. +- [ ] [apple-health-integration-live-sync] **Apple Health integration for MeatSpace.** iOS live sync (HealthKit Shortcut → `POST /api/meatspace/apple-health` endpoint) plus a bulk historical import path for an exported `export.xml`. Wire into existing MeatSpace tabs so steps / sleep / heart rate / VO2 max / resting HR show alongside the alcohol / blood / body / epigenetic tracks already shipped. GOALS.md flags this as a documented Secondary Goal ("Apple Health integration planned") but no implementation tracking existed until this entry. When this lands, also fold an Apple-Health-imported signal into the Capabilities page "Genome & Health" row (`server/lib/capabilityMap.js#genomeRow` + the route's `genome` fetch) so a health-only setup no longer reports "Not set up" purely because no genome is uploaded (codex review of `[codex5-onboarding-capability-map]`, 2026-05-24). - [ ] [ref-watch-phosphene-mlx-post-decode-os-exit] **Bypass MLX post-decode deallocator hang via `os._exit(0)`.** From `reference-watch` review of phosphene (commits `adc1cd25` + `94bd6965`, 2026-05-22). At PortOS's current `LTX2_PIN=f8f20c83` the bug isn't reachable, but the moment a user updates `ltx-2-mlx` or PortOS bumps the pin past the May 9 refactor, every Distilled / Extend / two-stage render stalls 5–15 min after the upstream "Decoding done in X.Xs" log while Metal command-buffer completion handlers hold the GIL through frame teardown. Fix: in `scripts/generate_ltx2.py` `main()`, after `print(json.dumps(...))`, call `os._exit(0)` instead of returning normally — mp4 is on disk, stdout was flushed, skipping deallocator teardown saves up to 15 min. Document why in the file's docstring. Subsumes any in-helper watchdog approach. - [ ] [ref-watch-phosphene-broaden-negative-prompt-override] **Broaden `DEFAULT_NEGATIVE_PROMPT` override to every module that defines it.** From `reference-watch` review of phosphene (commit `4e6129b7`, 2026-05-22). `scripts/generate_ltx2.py:55-70` `configure_negative_prompt()` only patches `ti2vid_one_stage.DEFAULT_NEGATIVE_PROMPT`. At PortOS's current pin this happens to cover every pipeline (the constant resolves against `ti2vid_one_stage` for the base class), but post-May-9 the constant moves to `ltx_pipelines_mlx._base` and `utils.constants` — and the PortOS override silently no-ops on Q8 / two-stage / HQ paths with no error for the user to chase. Fix: rewrite `configure_negative_prompt()` to probe `ti2vid_one_stage`, `_base`, and `utils.constants`, monkey-patch every module that has the constant. Keep PortOS's *replace* semantics (don't switch to phosphene's append-with-comma). No contextmanager needed — the helper is short-lived. - [ ] [peer-sync-ephemeralize-then-delete-stalls-tombstone] **A previously-shared universe/series that's marked ephemeral, then deleted, never propagates the tombstone when the peer still has other subscriptions of that kind.** Codex iter-8 in v2.7.0 release review (P2). The ephemeralize step's `unsubscribeAllForRecord` removes the per-record sub for that record; the subsequent delete fires `recordEvents.deleted` but there's no sub to push it. The snapshot path in `syncOrchestrator.categoriesCoveredByPeerSync` then suppresses the whole-category snapshot for that peer (because ANY other per-record sub for that kind exists), so the tombstone has no fallback transport either. The peer keeps the old live copy indefinitely. Same root cause as `[peer-sync-snapshot-skip-too-coarse-for-partial-subscriptions]` — the snapshot-skip fix needs to either send the snapshot with subscribed records excluded (so tombstones for unsubscribed-but-previously-known records can still ride it), or detect the ephemeralize-then-delete sequence and proactively push the tombstone before tearing down the sub. @@ -96,6 +95,7 @@ Items here need a research / design pass, an explicit decision, or a preconditio - [ ] [useasyncaction-post-unmount-setstate-guard-add] **`useAsyncAction` post-unmount setState guard.** Add `mountedRef` to gate `setRunning(false)`. YAGNI today; do at 4th consumer. - [ ] [extract-usecanonpatch-universe-setuniverse] **Extract `useCanonPatch(universe, setUniverse, universeId, mountedRef)`.** `UniverseCanon.jsx` + `NounsStage.jsx` 95% identical optimistic-patch handlers. Extract when a 3rd caller appears. - [ ] [shallow-equal-guard-in-usemediaannotations-socket] **Shallow-equal guard in `useMediaAnnotations` socket handler.** Speculative micro-opt; theoretical until observed. +- [ ] [capabilities-cheap-memory-count] **Capabilities route should count active memories without sorting/paginating.** `GET /api/capabilities` calls `getMemories({ status: 'active' })` (server/routes/capabilities.js) only to read `.total`, but `getMemories` loads the full index, filters, sorts, and paginates before returning `{ total, memories }` — the sort + slice is wasted work on a page polled every ~20s. Add a lightweight `countMemories({ status })` (or a `countOnly` option) to `server/services/memory.js` that returns the filtered count without the sort/paginate, and switch the route to it. Micro-opt; the index is cached via `loadIndex`, so low urgency. Surfaced by copilot review of `[codex5-onboarding-capability-map]` 2026-05-25. - [ ] [low-client-extract-shared-usepopoverposition-hook] **[LOW][CLIENT]** Extract shared `usePopoverPosition` hook — **premise eroded; revisit when a 3rd caller lands.** Original PLAN flagged 4 components but `VisualStylePicker` no longer exists and `AddToCollectionMenu` / `BulkTargetPicker` no longer use portal positioning. Today the pattern lives in exactly two components (`client/src/components/ThemeSwitcher.jsx` and `client/src/components/media/CollectionPickerShell.jsx`) — both implement portal-with-fixed-positioning + `useLayoutEffect` measurement + rAF-coalesced scroll/resize listeners. With only 2 callers the abstraction isn't worth the indirection; extract when a third surface needs the same shape. - [ ] [setup-data-import-drift-tables-from-migrations] **`setup-data.js` should import drift tables from each migration file.** `SHIPPED_PROMPT_OLD_MD5` + `SHIPPED_PROMPT_NEW_MD5` in `scripts/setup-data.js:139-196` manually mirror the `ACCEPTED_OLD_MD5` + `NEW_SHIPPED_MD5` exports each migration file already maintains. The mirror is hand-edited every time a migration ships and is the most likely place to drift. After `[backport-pre-023-migrations-to-_lib]` lands, every prompt-replace migration will export those constants — `setup-data.js` can build `SHIPPED_PROMPT_OLD_MD5` / `SHIPPED_PROMPT_NEW_MD5` by `import * as m025 from './migrations/025-idea-stage-character-detail.js'`-style sweep (or by glob+dynamic import) and removing the mirror entirely. Single source of truth, no hand sync. Surfaced by /simplify during `[pipeline-idea-stage-character-detail-plumbing]`. - [ ] [parallelize-applypromptreplacemigration-files] **Parallelize `applyPromptReplaceMigration` per-file scan.** Today every prompt-replace migration covers exactly one file, so the serial `for (await readFile…)` loop in `scripts/migrations/_lib.js` costs nothing. When the first multi-file migration lands, fold the loop into `Promise.all(Object.keys(accepted).map(async filename => …))` and accumulate the `{ updated, alreadyCurrent, skipped }` counters post-flight. Theoretical until needed. diff --git a/client/src/App.jsx b/client/src/App.jsx index 8ec809d5d..cc9a40ab0 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -64,6 +64,7 @@ const DataManager = lazyWithReload(() => import('./pages/DataManager')); const Insights = lazyWithReload(() => import('./pages/Insights')); const Instances = lazyWithReload(() => import('./pages/Instances')); const SystemHealthPage = lazyWithReload(() => import('./pages/SystemHealthPage')); +const CapabilityMap = lazyWithReload(() => import('./pages/CapabilityMap')); const MeatSpace = lazyWithReload(() => import('./pages/MeatSpace')); const Post = lazyWithReload(() => import('./pages/Post')); const Review = lazyWithReload(() => import('./pages/Review')); @@ -193,6 +194,7 @@ export default function App() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/client/src/components/Layout.jsx b/client/src/components/Layout.jsx index 88771ef0b..42f5a0ad7 100644 --- a/client/src/components/Layout.jsx +++ b/client/src/components/Layout.jsx @@ -297,6 +297,7 @@ const navItems = [ icon: HardDrive, defaultTo: '/data', children: [ + { to: '/capabilities', label: 'Capabilities', icon: Compass }, { to: '/data', label: 'Data', icon: HardDrive }, { to: '/instances', label: 'Instances', icon: Network }, { to: '/loops', label: 'Loops', icon: RefreshCw }, diff --git a/client/src/pages/CapabilityMap.jsx b/client/src/pages/CapabilityMap.jsx new file mode 100644 index 000000000..935d5725b --- /dev/null +++ b/client/src/pages/CapabilityMap.jsx @@ -0,0 +1,102 @@ +import { Link } from 'react-router-dom'; +import { + CheckCircle, AlertTriangle, XCircle, Circle, ChevronRight, LayoutGrid, +} from 'lucide-react'; +import * as api from '../services/api'; +import BrailleSpinner from '../components/BrailleSpinner'; +import { useAutoRefetch } from '../hooks/useAutoRefetch'; + +// Status presentation. Mirrors the server's CAPABILITY_STATUS tiers. +const STATUS_STYLE = { + ok: { color: 'text-port-success', bg: 'bg-port-success/10', border: 'border-port-success/30', icon: CheckCircle, label: 'Ready' }, + warn: { color: 'text-port-warning', bg: 'bg-port-warning/10', border: 'border-port-warning/30', icon: AlertTriangle, label: 'Degraded' }, + error: { color: 'text-port-error', bg: 'bg-port-error/10', border: 'border-port-error/30', icon: XCircle, label: 'Error' }, + unconfigured: { color: 'text-gray-500', bg: 'bg-port-card', border: 'border-port-border', icon: Circle, label: 'Not set up' }, +}; + +const OVERALL_LABEL = { + ok: 'All systems ready', + warn: 'Some systems degraded', + error: 'Action needed', + unconfigured: 'Setup incomplete', +}; + +function CapabilityRow({ cap }) { + const style = STATUS_STYLE[cap.status] || STATUS_STYLE.unconfigured; + const Icon = style.icon; + return ( + + +
+
+ {cap.label} + + {style.label} + +
+

{cap.summary}

+
+ + + ); +} + +export default function CapabilityMap() { + const { data, loading } = useAutoRefetch( + () => api.getCapabilities({ silent: true }), + 20_000, + ); + + if (loading) { + return ( +
+ +
+ ); + } + + if (!data) { + return
Capability map unavailable.
; + } + + const caps = Array.isArray(data.capabilities) ? data.capabilities : []; + const summary = data.summary || { ok: 0, warn: 0, error: 0, unconfigured: 0, overall: 'unconfigured' }; + const overallStyle = STATUS_STYLE[summary.overall] || STATUS_STYLE.unconfigured; + const OverallIcon = overallStyle.icon; + + return ( +
+
+

+ + Capabilities +

+
+ + {OVERALL_LABEL[summary.overall] || 'Status'} +
+
+ +

+ Each connected system's status at a glance — a setup checklist and a runtime health overview. + Select a row to configure it. +

+ +
+ {summary.ok} ready + {summary.warn} degraded + {summary.error} error + {summary.unconfigured} not set up +
+ +
+ {caps.map((cap) => ( + + ))} +
+
+ ); +} diff --git a/client/src/services/apiSystem.js b/client/src/services/apiSystem.js index 7759fac9a..528cfaac6 100644 --- a/client/src/services/apiSystem.js +++ b/client/src/services/apiSystem.js @@ -7,6 +7,7 @@ export const getAlertsSummary = (options) => request('/alerts/summary', options) export const checkHealth = () => request('/system/health'); export const getSystemHealth = (options) => request('/system/health/details', options); export const getNetworkExposure = (options) => request('/network-exposure/status', options); +export const getCapabilities = (options) => request('/capabilities', options); export const updateHealthThresholds = (thresholds) => request('/system/health/thresholds', { method: 'PUT', body: JSON.stringify(thresholds) diff --git a/server/index.js b/server/index.js index 7c4aa5b3d..512edbaff 100644 --- a/server/index.js +++ b/server/index.js @@ -15,6 +15,7 @@ import alertsRoutes from './routes/alerts.js'; import appleHealthRoutes from './routes/appleHealth.js'; import avatarRoutes from './routes/avatar.js'; import systemHealthRoutes from './routes/systemHealth.js'; +import capabilitiesRoutes from './routes/capabilities.js'; import appsRoutes from './routes/apps.js'; import referenceReposRoutes from './routes/referenceRepos.js'; import portsRoutes from './routes/ports.js'; @@ -344,6 +345,7 @@ app.set('io', io); app.use('/api/alerts', alertsRoutes); app.use('/api/avatar', avatarRoutes); app.use('/api/system', systemHealthRoutes); +app.use('/api/capabilities', capabilitiesRoutes); app.use('/api/apps', appsRoutes); app.use('/api/apps/:appId/reference-repos', referenceReposRoutes); app.use('/api/ports', portsRoutes); diff --git a/server/lib/README.md b/server/lib/README.md index 785b87463..1aa502e3e 100644 --- a/server/lib/README.md +++ b/server/lib/README.md @@ -141,6 +141,7 @@ The barrel `server/lib/index.js` is a machine-checkable enumeration of every pub | Module | Purpose | |---|---| +| `capabilityMap.js` | Pure row builders for the Capability Map (per-integration status tiers + rollup); fed by `routes/capabilities.js`. | | `civitai.js` | Civitai URL parsing + API client. | | `issueLength.js` | Per-issue size targets fed into text stages. | | `mediaItemKey.js` | `:` key vocabulary for media items. | diff --git a/server/lib/capabilityMap.js b/server/lib/capabilityMap.js new file mode 100644 index 000000000..851226874 --- /dev/null +++ b/server/lib/capabilityMap.js @@ -0,0 +1,286 @@ +// Pure row builders for the Capability Map (one page that shows every +// connected system's status — doubles as a setup checklist and a runtime +// health overview). The route (server/routes/capabilities.js) gathers each +// integration's raw status in parallel and hands the shapes here; this module +// owns the status-tier derivation so it is unit-testable without any I/O. +// +// Status tiers: +// 'ok' — configured and healthy/reachable (green) +// 'warn' — configured but degraded (amber) +// 'error' — configured but broken (red) +// 'unconfigured' — not set up yet (gray) — the setup-checklist signal + +export const CAPABILITY_STATUS = Object.freeze({ + OK: 'ok', + WARN: 'warn', + ERROR: 'error', + UNCONFIGURED: 'unconfigured', +}); + +const { OK, WARN, ERROR, UNCONFIGURED } = CAPABILITY_STATUS; + +const row = (id, label, settingsPath, { status, configured, summary, detail }) => ({ + id, + label, + settingsPath, + status, + configured, + summary, + detail: detail ?? null, +}); + +const plural = (n, one, many) => (n === 1 ? one : (many ?? `${one}s`)); + +export function providersRow(providers = [], statuses = {}) { + const enabled = (Array.isArray(providers) ? providers : []).filter((p) => p && p.enabled !== false); + if (enabled.length === 0) { + return row('providers', 'AI Providers', '/ai', { + status: UNCONFIGURED, + configured: false, + summary: 'No AI providers configured', + }); + } + // getAllProviderStatuses() returns { ...cache, providers: { [id]: status } }. + // A provider that was never marked unavailable has no entry — treat as available. + const statusMap = statuses?.providers ?? statuses ?? {}; + let available = 0; + let unavailable = 0; + for (const p of enabled) { + const s = statusMap?.[p.id]; + if (!s || s.available) available += 1; + else unavailable += 1; + } + let status = OK; + if (available === 0) status = ERROR; + else if (unavailable > 0) status = WARN; + const parts = [`${enabled.length} configured`, `${available} available`]; + if (unavailable > 0) parts.push(`${unavailable} unavailable`); + return row('providers', 'AI Providers', '/ai', { + status, + configured: true, + summary: parts.join(' · '), + detail: { configured: enabled.length, available, unavailable }, + }); +} + +// Calendar and Messages share the same account-list shape: each account has an +// `enabled` flag and a persisted `lastSyncStatus`. An enabled account whose last +// sync ended in 'error' or 'partial' degrades the row to WARN so the page can't +// claim "Ready" while a sync is actively failing. +function accountListRow(id, label, settingsPath, accounts) { + const list = Array.isArray(accounts) ? accounts : []; + if (list.length === 0) { + return row(id, label, settingsPath, { + status: UNCONFIGURED, + configured: false, + summary: `No ${label} accounts connected`, + }); + } + const enabledAccounts = list.filter((a) => a && a.enabled); + const enabled = enabledAccounts.length; + const failing = enabledAccounts.filter( + (a) => a.lastSyncStatus === 'error' || a.lastSyncStatus === 'partial', + ).length; + let status = OK; + let summary; + if (enabled === 0) { + status = WARN; + summary = `${list.length} ${plural(list.length, 'account')}, none enabled`; + } else if (failing > 0) { + status = WARN; + summary = `${enabled} of ${list.length} syncing · ${failing} failing`; + } else { + summary = `${enabled} of ${list.length} ${plural(list.length, 'account')} syncing`; + } + return row(id, label, settingsPath, { + status, + configured: true, + summary, + detail: { total: list.length, enabled, failing }, + }); +} + +export function calendarRow(accounts = []) { + return accountListRow('calendar', 'Calendar', '/calendar/config', accounts); +} + +export function brainRow({ memoryCount = 0, embeddingProviderConfigured = false } = {}) { + const count = Number(memoryCount) || 0; + if (count === 0 && !embeddingProviderConfigured) { + return row('brain', 'Brain & Memory', '/brain/config', { + status: UNCONFIGURED, + configured: false, + summary: 'No memories stored yet', + }); + } + let status = OK; + if (!embeddingProviderConfigured) status = count > 0 ? WARN : UNCONFIGURED; + const summary = `${count} ${plural(count, 'memory', 'memories')} · ` + + (embeddingProviderConfigured ? 'embeddings configured' : 'no embedding provider'); + return row('brain', 'Brain & Memory', '/brain/config', { + status, + configured: count > 0 || embeddingProviderConfigured, + summary, + detail: { memoryCount: count, embeddingProviderConfigured }, + }); +} + +export function voiceRow(cfg = {}) { + const enabled = !!cfg?.enabled; + if (!enabled) { + return row('voice', 'Voice', '/settings/voice', { + status: UNCONFIGURED, + configured: false, + summary: 'Voice disabled', + }); + } + const tts = cfg?.tts?.engine || 'unknown'; + const stt = cfg?.stt?.engine || 'unknown'; + return row('voice', 'Voice', '/settings/voice', { + status: OK, + configured: true, + summary: `Enabled · TTS ${tts} · STT ${stt}`, + detail: { tts, stt }, + }); +} + +export function networkRow(net = {}) { + const https = !!net?.httpsEnabled; + const tailscaleHost = net?.cert?.tailscaleHost || null; + const tailscale = !!tailscaleHost; + if (!https && !tailscale) { + return row('network', 'Tailscale & HTTPS', '/instances', { + status: UNCONFIGURED, + configured: false, + summary: 'HTTP only · Tailscale not detected', + }); + } + return row('network', 'Tailscale & HTTPS', '/instances', { + status: https && tailscale ? OK : WARN, + configured: true, + summary: [ + https ? 'HTTPS on' : 'HTTP only', + tailscale ? `Tailscale: ${tailscaleHost}` : 'Tailscale not detected', + ].join(' · '), + detail: { https, tailscaleHost }, + }); +} + +export function genomeRow(genome = {}) { + if (!genome?.uploaded) { + return row('genome', 'Genome & Health', '/meatspace/genome', { + status: UNCONFIGURED, + configured: false, + summary: 'No genome uploaded', + }); + } + const markers = Number(genome?.markerCount) || 0; + const counts = genome?.statusCounts || {}; + const flagged = (Number(counts.concern) || 0) + (Number(counts.major_concern) || 0); + return row('genome', 'Genome & Health', '/meatspace/genome', { + status: OK, + configured: true, + summary: `Genome loaded · ${markers} ${plural(markers, 'marker')}` + + (flagged > 0 ? ` · ${flagged} flagged` : ''), + detail: { markerCount: markers, flagged }, + }); +} + +export function telegramRow({ hasToken = false, hasChatId = false, connected = false, method = 'manual' } = {}) { + const configured = !!hasToken && !!hasChatId; + if (!configured) { + return row('telegram', 'Telegram', '/settings/telegram', { + status: UNCONFIGURED, + configured: false, + summary: 'Not configured', + }); + } + return row('telegram', 'Telegram', '/settings/telegram', { + status: connected ? OK : WARN, + configured: true, + summary: `${method} · ${connected ? 'connected' : 'configured (not connected)'}`, + detail: { method, connected }, + }); +} + +export function messagesRow(accounts = []) { + return accountListRow('messages', 'Messages', '/messages/config', accounts); +} + +export function appsRow(summary = {}) { + // getAppStatusSummary().total counts only PM2-runnable apps; native/Xcode + // projects are reported separately under `unmanaged` (no runtime state). + const total = Number(summary?.total) || 0; + const unmanaged = Number(summary?.unmanaged) || 0; + if (total === 0 && unmanaged === 0) { + return row('apps', 'Apps & Processes', '/apps', { + status: UNCONFIGURED, + configured: false, + summary: 'No apps registered', + }); + } + if (total === 0) { + // Only native apps registered — they exist but have no PM2 lifecycle to health-check. + return row('apps', 'Apps & Processes', '/apps', { + status: OK, + configured: true, + summary: `${unmanaged} native ${plural(unmanaged, 'app')} · no runtime status`, + detail: { total: 0, online: 0, stopped: 0, notStarted: 0, unmanaged }, + }); + } + const online = Number(summary?.online) || 0; + const stopped = Number(summary?.stopped) || 0; + // notStarted = registered but never launched / no matching PM2 process — a + // setup gap this page exists to surface, so it degrades the row too. unmanaged + // (Xcode/native projects with no runtime state) is intentionally NOT counted. + const notStarted = Number(summary?.notStarted) || 0; + return row('apps', 'Apps & Processes', '/apps', { + status: stopped > 0 || notStarted > 0 ? WARN : OK, + configured: true, + summary: `${total} ${plural(total, 'app')} · ${online} online` + + (stopped > 0 ? ` · ${stopped} stopped` : '') + + (notStarted > 0 ? ` · ${notStarted} not started` : ''), + detail: { total, online, stopped, notStarted, unmanaged }, + }); +} + +/** + * Build the ordered list of capability rows from already-fetched raw data. + * Every field is optional — a missing/failed source degrades to `unconfigured` + * rather than throwing, so one broken integration never blanks the whole page. + */ +export function buildCapabilityRows(data = {}) { + return [ + providersRow(data.providers, data.providerStatuses), + calendarRow(data.calendarAccounts), + brainRow({ memoryCount: data.memoryCount, embeddingProviderConfigured: data.embeddingProviderConfigured }), + voiceRow(data.voiceConfig), + networkRow(data.network), + genomeRow(data.genome), + telegramRow(data.telegram), + messagesRow(data.messageAccounts), + appsRow(data.appSummary), + ]; +} + +/** + * Roll the rows up into a single posture for a header badge. + * `overall` is worst-wins across error → warn → unconfigured → ok. + */ +export function summarizeCapabilities(rows = []) { + const list = Array.isArray(rows) ? rows : []; + const counts = { ok: 0, warn: 0, error: 0, unconfigured: 0 }; + for (const r of list) { + if (counts[r?.status] !== undefined) counts[r.status] += 1; + } + // Derive `overall` purely from the tallied counts (worst-wins). Empty input + // AND a non-empty list whose rows all carry missing/unknown statuses both fall + // through to UNCONFIGURED — never default to OK when nothing is recognized. + let overall; + if (counts.error > 0) overall = ERROR; + else if (counts.warn > 0) overall = WARN; + else if (counts.unconfigured > 0) overall = UNCONFIGURED; + else if (counts.ok > 0) overall = OK; + else overall = UNCONFIGURED; + return { ...counts, total: list.length, overall }; +} diff --git a/server/lib/capabilityMap.test.js b/server/lib/capabilityMap.test.js new file mode 100644 index 000000000..a5bfd375c --- /dev/null +++ b/server/lib/capabilityMap.test.js @@ -0,0 +1,224 @@ +import { describe, it, expect } from 'vitest'; +import { + CAPABILITY_STATUS, + providersRow, + calendarRow, + brainRow, + voiceRow, + networkRow, + genomeRow, + telegramRow, + messagesRow, + appsRow, + buildCapabilityRows, + summarizeCapabilities, +} from './capabilityMap.js'; + +const { OK, WARN, ERROR, UNCONFIGURED } = CAPABILITY_STATUS; + +describe('providersRow', () => { + it('is unconfigured with no enabled providers', () => { + expect(providersRow([]).status).toBe(UNCONFIGURED); + expect(providersRow([{ id: 'a', enabled: false }]).status).toBe(UNCONFIGURED); + }); + + it('treats never-marked providers as available', () => { + const r = providersRow([{ id: 'a' }, { id: 'b' }], { providers: {} }); + expect(r.status).toBe(OK); + expect(r.detail).toEqual({ configured: 2, available: 2, unavailable: 0 }); + }); + + it('warns when some providers are unavailable but at least one is up', () => { + const r = providersRow( + [{ id: 'a' }, { id: 'b' }], + { providers: { b: { available: false, reason: 'rate-limit' } } }, + ); + expect(r.status).toBe(WARN); + expect(r.detail).toMatchObject({ available: 1, unavailable: 1 }); + }); + + it('errors when no provider is available', () => { + const r = providersRow([{ id: 'a' }], { providers: { a: { available: false } } }); + expect(r.status).toBe(ERROR); + }); + + it('accepts a bare status map (no providers wrapper)', () => { + const r = providersRow([{ id: 'a' }], { a: { available: false } }); + expect(r.status).toBe(ERROR); + }); +}); + +describe('calendarRow / messagesRow', () => { + it('unconfigured with no accounts', () => { + expect(calendarRow([]).status).toBe(UNCONFIGURED); + expect(messagesRow([]).status).toBe(UNCONFIGURED); + }); + + it('ok when at least one account is enabled', () => { + expect(calendarRow([{ enabled: true }, { enabled: false }]).status).toBe(OK); + expect(messagesRow([{ enabled: true }]).status).toBe(OK); + }); + + it('warns when accounts exist but none enabled', () => { + expect(calendarRow([{ enabled: false }]).status).toBe(WARN); + }); + + it('warns when an enabled account last sync failed', () => { + const r = calendarRow([{ enabled: true, lastSyncStatus: 'error' }, { enabled: true, lastSyncStatus: 'success' }]); + expect(r.status).toBe(WARN); + expect(r.detail.failing).toBe(1); + expect(r.summary).toContain('1 failing'); + // 'partial' also counts as failing + expect(messagesRow([{ enabled: true, lastSyncStatus: 'partial' }]).status).toBe(WARN); + // a never-synced (null) account is not a failure + expect(calendarRow([{ enabled: true, lastSyncStatus: null }]).status).toBe(OK); + }); +}); + +describe('brainRow', () => { + it('unconfigured with no memories and no embedding provider', () => { + expect(brainRow({ memoryCount: 0, embeddingProviderConfigured: false }).status).toBe(UNCONFIGURED); + }); + + it('ok with memories and a configured embedding provider', () => { + expect(brainRow({ memoryCount: 5, embeddingProviderConfigured: true }).status).toBe(OK); + }); + + it('warns with memories but no embedding provider', () => { + expect(brainRow({ memoryCount: 5, embeddingProviderConfigured: false }).status).toBe(WARN); + }); + + it('pluralizes the memory count', () => { + expect(brainRow({ memoryCount: 1, embeddingProviderConfigured: true }).summary).toContain('1 memory'); + expect(brainRow({ memoryCount: 2, embeddingProviderConfigured: true }).summary).toContain('2 memories'); + }); +}); + +describe('voiceRow', () => { + it('unconfigured when disabled', () => { + expect(voiceRow({ enabled: false }).status).toBe(UNCONFIGURED); + expect(voiceRow({}).status).toBe(UNCONFIGURED); + }); + + it('ok when enabled, reporting engines', () => { + const r = voiceRow({ enabled: true, tts: { engine: 'kokoro' }, stt: { engine: 'whisper' } }); + expect(r.status).toBe(OK); + expect(r.summary).toContain('kokoro'); + expect(r.summary).toContain('whisper'); + }); +}); + +describe('networkRow', () => { + it('unconfigured on plain HTTP with no tailscale', () => { + expect(networkRow({ httpsEnabled: false, cert: {} }).status).toBe(UNCONFIGURED); + }); + + it('ok with HTTPS and a tailscale host', () => { + expect(networkRow({ httpsEnabled: true, cert: { tailscaleHost: 'host.ts.net' } }).status).toBe(OK); + }); + + it('warns when only one of the two is present', () => { + expect(networkRow({ httpsEnabled: true, cert: {} }).status).toBe(WARN); + expect(networkRow({ httpsEnabled: false, cert: { tailscaleHost: 'h' } }).status).toBe(WARN); + }); +}); + +describe('genomeRow', () => { + it('unconfigured when not uploaded', () => { + expect(genomeRow({ uploaded: false }).status).toBe(UNCONFIGURED); + }); + + it('ok when uploaded, surfacing flagged markers', () => { + const r = genomeRow({ uploaded: true, markerCount: 12, statusCounts: { concern: 2, major_concern: 1 } }); + expect(r.status).toBe(OK); + expect(r.summary).toContain('3 flagged'); + }); +}); + +describe('telegramRow', () => { + it('unconfigured without token + chatId', () => { + expect(telegramRow({ hasToken: true, hasChatId: false }).status).toBe(UNCONFIGURED); + }); + + it('ok when configured and connected', () => { + expect(telegramRow({ hasToken: true, hasChatId: true, connected: true }).status).toBe(OK); + }); + + it('warns when configured but not connected', () => { + expect(telegramRow({ hasToken: true, hasChatId: true, connected: false }).status).toBe(WARN); + }); +}); + +describe('appsRow', () => { + it('unconfigured with no apps', () => { + expect(appsRow({ total: 0 }).status).toBe(UNCONFIGURED); + }); + + it('ok when all online, warns when some stopped', () => { + expect(appsRow({ total: 3, online: 3, stopped: 0 }).status).toBe(OK); + expect(appsRow({ total: 3, online: 2, stopped: 1 }).status).toBe(WARN); + }); + + it('warns when apps are registered but never started', () => { + const r = appsRow({ total: 3, online: 0, stopped: 0, notStarted: 3 }); + expect(r.status).toBe(WARN); + expect(r.summary).toContain('3 not started'); + expect(r.detail.notStarted).toBe(3); + }); + + it('reports native-only (unmanaged) apps as present, not "No apps"', () => { + // getAppStatusSummary().total excludes unmanaged native/Xcode apps. + const r = appsRow({ total: 0, online: 0, unmanaged: 2 }); + expect(r.status).toBe(OK); + expect(r.configured).toBe(true); + expect(r.summary).toContain('2 native'); + }); + + it('unconfigured only when there are no apps at all', () => { + expect(appsRow({ total: 0, unmanaged: 0 }).status).toBe(UNCONFIGURED); + }); +}); + +describe('buildCapabilityRows', () => { + it('returns one row per integration even with empty input', () => { + const rows = buildCapabilityRows({}); + expect(rows).toHaveLength(9); + // Every row degrades to unconfigured rather than throwing. + expect(rows.every((r) => r.status === UNCONFIGURED)).toBe(true); + expect(rows.every((r) => typeof r.settingsPath === 'string' && r.settingsPath.startsWith('/'))).toBe(true); + expect(new Set(rows.map((r) => r.id)).size).toBe(9); + }); +}); + +describe('summarizeCapabilities', () => { + it('counts each tier and rolls up worst-wins', () => { + const rows = [ + { status: OK }, { status: OK }, { status: WARN }, { status: UNCONFIGURED }, + ]; + const s = summarizeCapabilities(rows); + expect(s).toMatchObject({ ok: 2, warn: 1, error: 0, unconfigured: 1, total: 4, overall: WARN }); + }); + + it('reports error overall when any row errors', () => { + expect(summarizeCapabilities([{ status: OK }, { status: ERROR }]).overall).toBe(ERROR); + }); + + it('reports ok overall only when every row is ok', () => { + expect(summarizeCapabilities([{ status: OK }, { status: OK }]).overall).toBe(OK); + }); + + it('reports unconfigured overall when only ok + unconfigured rows', () => { + expect(summarizeCapabilities([{ status: OK }, { status: UNCONFIGURED }]).overall).toBe(UNCONFIGURED); + }); + + it('reports unconfigured (not ok) for empty or garbage input', () => { + expect(summarizeCapabilities([]).overall).toBe(UNCONFIGURED); + expect(summarizeCapabilities(null).overall).toBe(UNCONFIGURED); + expect(summarizeCapabilities(null).total).toBe(0); + }); + + it('does not default to ok when a non-empty list has only unknown statuses', () => { + const s = summarizeCapabilities([{ status: 'bogus' }, {}]); + expect(s).toMatchObject({ ok: 0, warn: 0, error: 0, unconfigured: 0, total: 2, overall: UNCONFIGURED }); + }); +}); diff --git a/server/lib/index.js b/server/lib/index.js index e736538d3..0e28c766a 100644 --- a/server/lib/index.js +++ b/server/lib/index.js @@ -111,6 +111,7 @@ export * from './taskParser.js'; export * from './curatedGenomeMarkers.js'; // === Domain utilities === +export * from './capabilityMap.js'; export * from './civitai.js'; export * from './issueLength.js'; export * from './mediaItemKey.js'; diff --git a/server/lib/navManifest.js b/server/lib/navManifest.js index 7e41e2e9c..3cb224fba 100644 --- a/server/lib/navManifest.js +++ b/server/lib/navManifest.js @@ -133,6 +133,7 @@ export const NAV_COMMANDS = [ { id: 'nav.settings.voice', path: '/settings/voice', label: 'Voice', section: 'Settings', aliases: ['settings-voice', 'voice', 'voice-settings'], keywords: ['mic', 'microphone', 'speech', 'tts', 'whisper', 'kokoro'] }, { id: 'nav.ambient', path: '/ambient', label: 'Ambient', section: 'System', aliases: ['ambient', 'ambient-mode', 'ambient mode'], keywords: ['idle', 'background', 'display', 'screensaver', 'fullscreen'] }, + { id: 'nav.capabilities', path: '/capabilities', label: 'Capabilities', section: 'System', aliases: ['capabilities', 'capability-map', 'integrations'], keywords: ['status', 'setup', 'checklist', 'connected systems', 'integrations', 'providers', 'health overview'] }, { id: 'nav.data', path: '/data', label: 'Data', section: 'System', aliases: ['data'] }, { id: 'nav.instances', path: '/instances', label: 'Instances', section: 'System', aliases: ['instances'] }, { id: 'nav.loops', path: '/loops', label: 'Loops', section: 'System', aliases: ['loops'] }, diff --git a/server/routes/capabilities.js b/server/routes/capabilities.js new file mode 100644 index 000000000..3ae27c863 --- /dev/null +++ b/server/routes/capabilities.js @@ -0,0 +1,102 @@ +import { Router } from 'express'; +import { asyncHandler } from '../lib/errorHandler.js'; +import { buildCapabilityRows, summarizeCapabilities } from '../lib/capabilityMap.js'; +import { getAllProviders, getProviderById } from '../services/providers.js'; +import { getAllProviderStatuses } from '../services/providerStatus.js'; +import { listAccounts as listCalendarAccounts } from '../services/calendarAccounts.js'; +import { listAccounts as listMessageAccounts } from '../services/messageAccounts.js'; +import { getMemories } from '../services/memory.js'; +import { getConfig as getCosConfig } from '../services/cos.js'; +import { getVoiceConfig } from '../services/voice/config.js'; +import { getNetworkExposureStatus } from '../lib/networkExposure.js'; +import { getGenomeSummary } from '../services/genome.js'; +import { getSettings } from '../services/settings.js'; +import * as telegram from '../services/telegram.js'; +import * as telegramBridge from '../services/telegramBridge.js'; +import * as apps from '../services/apps.js'; + +const router = Router(); + +// Resolve whether a memory-embedding provider is actually reachable-by-config +// (mirrors memoryEmbeddings.initConfig) without firing the live LM Studio probe +// — the probe auto-loads a model as a side effect, which a read-only status +// page must not trigger. +async function resolveEmbeddingProviderConfigured() { + const cosConfig = await getCosConfig().catch(() => ({})); + const providerId = cosConfig?.embeddingProviderId || 'lmstudio'; + const provider = await getProviderById(providerId).catch(() => null); + // Mirror memoryEmbeddings.initConfig exactly: it keys off `endpoint` alone and + // does NOT gate on `enabled`, so embeddings still generate from a disabled-but- + // endpoint'd provider. Checking `enabled` here would misreport that as "off". + return !!provider?.endpoint; +} + +async function resolveTelegram() { + const settings = await getSettings().catch(() => ({})); + const method = settings?.telegram?.method || 'manual'; + if (method === 'mcp-bridge') { + const status = telegramBridge.getStatus(); + return { method, hasToken: status.hasBotToken, hasChatId: status.hasChatId, connected: status.connected }; + } + const status = telegram.getStatus(); + return { + method, + hasToken: !!settings?.secrets?.telegram?.token, + hasChatId: !!settings?.telegram?.chatId, + connected: status.connected, + }; +} + +// GET /api/capabilities — capability map of every connected system. +router.get('/', asyncHandler(async (req, res) => { + const [ + providers, + providerStatuses, + calendarAccounts, + messageAccounts, + memories, + embeddingProviderConfigured, + voiceConfig, + genome, + telegramStatus, + appSummary, + network, + ] = await Promise.all([ + getAllProviders().catch(() => []), + Promise.resolve().then(() => getAllProviderStatuses()).catch(() => ({})), + listCalendarAccounts().catch(() => []), + listMessageAccounts().catch(() => []), + getMemories({ status: 'active' }).catch(() => ({ total: 0 })), + resolveEmbeddingProviderConfigured().catch(() => false), + getVoiceConfig().catch(() => ({})), + getGenomeSummary().catch(() => ({ uploaded: false })), + resolveTelegram().catch(() => ({})), + apps.getAppStatusSummary().catch(() => ({ total: 0 })), + // Synchronous in-memory read — wrap so a cert-meta read failure degrades + // to {} instead of 500-ing the whole page (no try/catch in route bodies). + Promise.resolve().then(() => getNetworkExposureStatus()).catch(() => ({})), + ]); + + const rows = buildCapabilityRows({ + providers, + providerStatuses, + calendarAccounts, + messageAccounts, + // getMemories returns { total, memories } — use the count, not the wrapper. + memoryCount: Number(memories?.total) || 0, + embeddingProviderConfigured, + voiceConfig, + network, + genome, + telegram: telegramStatus, + appSummary, + }); + + res.json({ + timestamp: new Date().toISOString(), + summary: summarizeCapabilities(rows), + capabilities: rows, + }); +})); + +export default router; diff --git a/server/routes/capabilities.test.js b/server/routes/capabilities.test.js new file mode 100644 index 000000000..091bce455 --- /dev/null +++ b/server/routes/capabilities.test.js @@ -0,0 +1,105 @@ +import { describe, it, expect, vi } from 'vitest'; +import express from 'express'; +import { request } from '../lib/testHelper.js'; +import { errorMiddleware } from '../lib/errorHandler.js'; + +// Stub every integration the aggregator reads. Defaults describe a fully +// configured, healthy install; individual tests override a single source. +vi.mock('../services/providers.js', () => ({ + getAllProviders: vi.fn(async () => [{ id: 'p1', enabled: true }]), + getProviderById: vi.fn(async () => ({ id: 'lmstudio', endpoint: 'http://x/v1', enabled: true })), +})); +vi.mock('../services/providerStatus.js', () => ({ + getAllProviderStatuses: vi.fn(() => ({ providers: {} })), +})); +vi.mock('../services/calendarAccounts.js', () => ({ + listAccounts: vi.fn(async () => [{ enabled: true, lastSyncStatus: 'success' }]), +})); +vi.mock('../services/messageAccounts.js', () => ({ + listAccounts: vi.fn(async () => [{ enabled: true, lastSyncStatus: 'success' }]), +})); +vi.mock('../services/memory.js', () => ({ + // The real getMemories returns { total, memories } — NOT an array. + getMemories: vi.fn(async () => ({ total: 7, memories: [] })), +})); +vi.mock('../services/cos.js', () => ({ + getConfig: vi.fn(async () => ({ embeddingProviderId: 'lmstudio' })), +})); +vi.mock('../services/voice/config.js', () => ({ + getVoiceConfig: vi.fn(async () => ({ enabled: true, tts: { engine: 'kokoro' }, stt: { engine: 'whisper' } })), +})); +vi.mock('../lib/networkExposure.js', () => ({ + getNetworkExposureStatus: vi.fn(() => ({ httpsEnabled: true, cert: { tailscaleHost: 'host.ts.net' } })), +})); +vi.mock('../services/genome.js', () => ({ + getGenomeSummary: vi.fn(async () => ({ uploaded: true, markerCount: 10, statusCounts: {} })), +})); +vi.mock('../services/settings.js', () => ({ + getSettings: vi.fn(async () => ({ telegram: { method: 'manual', chatId: 'c1' }, secrets: { telegram: { token: 't1' } } })), +})); +vi.mock('../services/telegram.js', () => ({ + getStatus: vi.fn(() => ({ connected: true })), +})); +vi.mock('../services/telegramBridge.js', () => ({ + getStatus: vi.fn(() => ({ connected: false, hasBotToken: false, hasChatId: false })), +})); +vi.mock('../services/apps.js', () => ({ + getAppStatusSummary: vi.fn(async () => ({ total: 2, online: 2, stopped: 0, notStarted: 0, unmanaged: 0 })), +})); + +const { getMemories } = await import('../services/memory.js'); +const { getGenomeSummary } = await import('../services/genome.js'); +const { default: capabilitiesRoutes } = await import('./capabilities.js'); + +const makeApp = () => { + const app = express(); + app.use(express.json()); + app.use('/api/capabilities', capabilitiesRoutes); + app.use(errorMiddleware); + return app; +}; + +const byId = (body, id) => body.capabilities.find((c) => c.id === id); + +describe('GET /api/capabilities', () => { + it('returns one row per integration plus a rollup summary', async () => { + const res = await request(makeApp()).get('/api/capabilities'); + expect(res.status).toBe(200); + expect(Array.isArray(res.body.capabilities)).toBe(true); + expect(res.body.capabilities).toHaveLength(9); + expect(res.body.summary).toMatchObject({ overall: expect.any(String), total: 9 }); + // every row is fully formed + deep-links to settings + for (const c of res.body.capabilities) { + expect(c.id).toBeTruthy(); + expect(typeof c.settingsPath).toBe('string'); + expect(c.settingsPath.startsWith('/')).toBe(true); + } + }); + + it('reads the memory COUNT from getMemories().total, not the wrapper object', async () => { + // Regression guard: getMemories returns { total, memories }; an Array.isArray + // check would report 0 here. The brain row must reflect the real count. + const res = await request(makeApp()).get('/api/capabilities'); + const brain = byId(res.body, 'brain'); + expect(brain.detail.memoryCount).toBe(7); + expect(brain.summary).toContain('7 memories'); + expect(brain.status).toBe('ok'); + }); + + it('degrades to fail-soft (200) when a single source throws', async () => { + getGenomeSummary.mockRejectedValueOnce(new Error('disk gone')); + const res = await request(makeApp()).get('/api/capabilities'); + expect(res.status).toBe(200); + // the failed source falls back to "not set up" rather than 500-ing the page + expect(byId(res.body, 'genome').status).toBe('unconfigured'); + // unrelated rows are unaffected + expect(byId(res.body, 'providers').configured).toBe(true); + }); + + it('handles getMemories rejecting (memory count 0, page still renders)', async () => { + getMemories.mockRejectedValueOnce(new Error('boom')); + const res = await request(makeApp()).get('/api/capabilities'); + expect(res.status).toBe(200); + expect(byId(res.body, 'brain').detail.memoryCount).toBe(0); + }); +});