From 1e69239a555df3284b398d83e64f7dd756bede89 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Thu, 3 Sep 2026 16:30:46 +0000 Subject: [PATCH 1/3] feat: expose rigged animated records to CoS avatars (#5894) Rigged + animated image-to-3D records are selectable through the avatar variant namespace (rigged-, same traversal guard), listed with server-computed clip coverage, offered in the CoS avatar-style selector, and played back with a deterministic present-clip fallback for uncovered states. Eidoverse player-model wiring stays open: no GLB player surface exists on main (human/cos avatars are VRM-typed for the external host). --- .../components/cos/MiniCharacterCoSAvatar.jsx | 28 +++-- .../cos/MiniCharacterCoSAvatar.test.jsx | 11 ++ client/src/components/cos/tabs/ConfigTab.jsx | 41 +++++- .../components/cos/tabs/ConfigTab.test.jsx | 59 +++++++++ client/src/hooks/README.md | 2 + client/src/hooks/index.js | 1 + client/src/hooks/useAvatarCapabilities.js | 111 ++++++++++++++++ .../src/hooks/useAvatarCapabilities.test.jsx | 118 ++++++++++++++++++ client/src/pages/ChiefOfStaff.jsx | 26 +++- client/src/pages/ChiefOfStaff.test.jsx | 54 ++++++++ client/src/services/README.md | 1 + client/src/services/api.js | 1 + client/src/services/apiAvatar.js | 9 ++ server/lib/README.md | 1 + server/lib/avatarVariants.js | 63 ++++++++++ server/lib/avatarVariants.test.js | 42 +++++++ server/lib/index.js | 1 + server/routes/avatar.js | 75 ++++++++++- server/routes/avatar.test.js | 90 ++++++++++++- server/routes/cosStatusRoutes.js | 12 +- server/routes/cosStatusRoutesAvatar.test.js | 25 ++++ 21 files changed, 748 insertions(+), 23 deletions(-) create mode 100644 client/src/hooks/useAvatarCapabilities.js create mode 100644 client/src/hooks/useAvatarCapabilities.test.jsx create mode 100644 client/src/services/apiAvatar.js create mode 100644 server/lib/avatarVariants.js create mode 100644 server/lib/avatarVariants.test.js create mode 100644 server/routes/cosStatusRoutesAvatar.test.js diff --git a/client/src/components/cos/MiniCharacterCoSAvatar.jsx b/client/src/components/cos/MiniCharacterCoSAvatar.jsx index c9ea032a81..f78db6654e 100644 --- a/client/src/components/cos/MiniCharacterCoSAvatar.jsx +++ b/client/src/components/cos/MiniCharacterCoSAvatar.jsx @@ -5,6 +5,7 @@ import CoSAvatarOrbitControls from './CoSAvatarOrbitControls'; import CoSBackgroundCamera from './CoSBackgroundCamera'; import CoSCanvasGuard from './CoSCanvasGuard'; import useClonedGltf, { GltfPrimitive } from '../../hooks/useClonedGltf'; +import { resolvePlaybackClip } from '../../hooks/useAvatarCapabilities'; import { fitModelToHeight } from '../../utils/modelFit'; // Kenney Mini Characters (CC0) ship 32 named clips. We map the CoS agent @@ -39,7 +40,7 @@ function buildModelUrl(variant) { return variant ? `/api/avatar/model.glb?variant=${encodeURIComponent(variant)}` : '/api/avatar/model.glb'; } -function MiniCharacter({ state, speaking, variant }) { +function MiniCharacter({ state, speaking, variant, coverage = null }) { const url = useMemo(() => buildModelUrl(variant), [variant]); const group = useRef(); const { scene, actions, names } = useClonedGltf(url); @@ -52,13 +53,14 @@ function MiniCharacter({ state, speaking, variant }) { fitModelToHeight(scene, { targetHeight: TARGET_HEIGHT, feetOnGround: true, yOffset: GROUND_Y }); }, [scene]); - // Resolve the active clip for this state, falling back gracefully. + // Resolve the active clip for this state, falling back gracefully. With a + // coverage report (a rigged record, #5894) the covered clip wins when the + // GLB carries it, else playback degrades to a clip the character actually + // has — an uncovered state never freezes the frame or pretends coverage. const cfg = STATE_CLIP_MAP[state] || FALLBACK; - const clipName = useMemo(() => { - if (names.includes(cfg.clip)) return cfg.clip; - if (names.includes('idle')) return 'idle'; - return names[0]; - }, [names, cfg.clip]); + const clipName = useMemo(() => ( + resolvePlaybackClip(names, { state, coverage, fallbacks: [cfg.clip, 'idle'] }) + ), [names, state, coverage, cfg.clip]); // Crossfade between clips on state change. const prevClip = useRef(null); @@ -107,14 +109,14 @@ function StageLighting({ color }) { ); } -function Scene({ state, speaking, background, variant }) { +function Scene({ state, speaking, background, variant, coverage = null }) { const stateConfig = AGENT_STATES[state] || AGENT_STATES.sleeping; const color = stateConfig.color; return ( <> - + ); @@ -148,8 +150,10 @@ function LoadingPlaceholder({ background = false }) { ); } -// `variant` is wired by the per-character style wrappers (MiniCharMaleC, etc.). -export default function MiniCharacterCoSAvatar({ state, speaking, background = false, variant = 'mini-male-c' }) { +// `variant` is wired by the per-character style wrappers (MiniCharMaleC, etc.) +// or a `rigged-` spelling for an animated record (ChiefOfStaff passes +// that record's coverage so playback falls back to a present clip). +export default function MiniCharacterCoSAvatar({ state, speaking, background = false, variant = 'mini-male-c', coverage = null }) { const [modelPresent, setModelPresent] = useState(null); const url = useMemo(() => buildModelUrl(variant), [variant]); @@ -177,7 +181,7 @@ export default function MiniCharacterCoSAvatar({ state, speaking, background = f gl={{ alpha: true, antialias: true }} > - + diff --git a/client/src/components/cos/MiniCharacterCoSAvatar.test.jsx b/client/src/components/cos/MiniCharacterCoSAvatar.test.jsx index f20e9343f7..f3e3623d98 100644 --- a/client/src/components/cos/MiniCharacterCoSAvatar.test.jsx +++ b/client/src/components/cos/MiniCharacterCoSAvatar.test.jsx @@ -48,4 +48,15 @@ describe('MiniCharacterCoSAvatar', () => { '/api/avatar/model.glb?variant=mini-female-d', )); }); + + // A rigged record probes through the same variant namespace — the avatar + // route resolves `rigged-` to the record's animated GLB, so the + // stage needs no special case for record-backed characters. + it('keys the canvas guard on the rigged variant url for an animated record', async () => { + render(); + await waitFor(() => expect(screen.getByTestId('canvas-guard')).toHaveAttribute( + 'data-reset-key', + '/api/avatar/model.glb?variant=rigged-image3d-1', + )); + }); }); diff --git a/client/src/components/cos/tabs/ConfigTab.jsx b/client/src/components/cos/tabs/ConfigTab.jsx index aab5c6ce58..8200c38338 100644 --- a/client/src/components/cos/tabs/ConfigTab.jsx +++ b/client/src/components/cos/tabs/ConfigTab.jsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { Link } from 'react-router'; import { Activity, @@ -32,6 +32,7 @@ import { } from '../constants'; import ProviderModelSelector from '../../ProviderModelSelector'; import useProviderModels from '../../../hooks/useProviderModels'; +import { coverageSummary, isRiggedAvatarStyle, riggedRecordForStyle } from '../../../hooks/useAvatarCapabilities'; import { timeAgo } from '../../../utils/formatters'; const DOMAIN_MODE_COLORS = { @@ -222,7 +223,7 @@ function PersistentMindStatus({ mind, loaded, error }) { ); } -export default function ConfigTab({ config, onUpdate, onEvaluate, avatarStyle }) { +export default function ConfigTab({ config, onUpdate, onEvaluate, avatarStyle, riggedAvatars = [] }) { const { providers, availableModels, @@ -273,6 +274,37 @@ export default function ConfigTab({ config, onUpdate, onEvaluate, avatarStyle }) useEffect(() => { void refreshBudgetUsage(); }, [refreshBudgetUsage]); useAutoRefetch(refreshMindStatus, 15_000, { pollOnly: true }); + // Built-in styles plus the install's verified animated records (#5894). A + // record entry carries its state coverage in the label, so what the + // character can and cannot do is visible BEFORE it is picked. + const avatarOptions = useMemo(() => ([ + ...Object.entries(AVATAR_STYLE_LABELS).map(([value, label]) => ({ value, label })), + ...(Array.isArray(riggedAvatars) ? riggedAvatars : []) + .filter((record) => record?.variant) + .map((record) => ({ + value: record.variant, + label: `${record.name} (rigged 3D) — ${coverageSummary(record.coverage)}`, + })), + ]), [riggedAvatars]); + + const avatarLabel = (style) => { + if (AVATAR_STYLE_LABELS[style]) return AVATAR_STYLE_LABELS[style]; + const record = riggedRecordForStyle(riggedAvatars, style); + return record ? `${record.name} (rigged 3D)` : style; + }; + + // Honest coverage note for the staged value: which states the character + // covers, what the rest fall back to — or a warning when the record the + // saved style points at is gone. + const stagedRiggedNote = useMemo(() => { + if (!isRiggedAvatarStyle(formData.avatarStyle)) return null; + const record = riggedRecordForStyle(riggedAvatars, formData.avatarStyle); + if (!record) return 'That animated record is no longer available — pick another avatar.'; + const covered = record.coverage?.coveredStates || []; + const fallback = record.clip ? `Other states play ${record.clip}.` : ''; + return `${coverageSummary(record.coverage)}. Covered: ${covered.join(', ') || 'none'}. ${fallback}`.trim(); + }, [formData.avatarStyle, riggedAvatars]); + const handleCancel = () => { setFormData(getDefaultFormData(config, avatarStyle)); setEditing(false); @@ -434,9 +466,12 @@ export default function ConfigTab({ config, onUpdate, onEvaluate, avatarStyle })
- ({ value, label }))} onChange={(value) => setFormData((current) => ({ ...current, avatarStyle: value }))} /> + setFormData((current) => ({ ...current, avatarStyle: value }))} /> setFormData((current) => ({ ...current, dynamicAvatar: value }))} />
+ {stagedRiggedNote && ( +

{stagedRiggedNote}

+ )}
diff --git a/client/src/components/cos/tabs/ConfigTab.test.jsx b/client/src/components/cos/tabs/ConfigTab.test.jsx index 2bb0ccdcd2..82db9c10a5 100644 --- a/client/src/components/cos/tabs/ConfigTab.test.jsx +++ b/client/src/components/cos/tabs/ConfigTab.test.jsx @@ -267,6 +267,65 @@ describe('Default Avatar Style dropdown', () => { }); }); +describe('Rigged avatar records in the Default Avatar dropdown', () => { + const riggedAvatars = [{ + id: 'image3d-1', + name: 'Example Dancer', + variant: 'rigged-image3d-1', + assetUrl: '/api/avatar/model.glb?variant=rigged-image3d-1', + clip: 'Dance', + coverage: { + availableClips: ['Dance'], + coverageByState: { + thinking: { covered: false, clip: null }, + ideating: { covered: true, clip: 'Dance' }, + }, + coveredStates: ['ideating'], + missingStates: ['thinking'], + complete: false, + }, + }]; + + it('offers verified animated records alongside the built-in styles', async () => { + renderConfig({ config: { ...config, avatarStyle: 'svg' }, riggedAvatars }); + await screen.findByText('Waiting for the next wake'); + + fireEvent.click(screen.getByRole('button', { name: /Edit/i })); + + const select = screen.getByRole('combobox', { name: 'Default avatar' }); + const labels = [...select.options].map((option) => option.text); + expect(labels).toContain('Digital (SVG)'); + expect(labels.some((label) => label.includes('Example Dancer') && label.includes('rigged 3D'))).toBe(true); + }); + + it('shows the coverage note when a rigged record is staged', async () => { + renderConfig({ config: { ...config, avatarStyle: 'svg' }, riggedAvatars }); + await screen.findByText('Waiting for the next wake'); + + fireEvent.click(screen.getByRole('button', { name: /Edit/i })); + + const select = screen.getByRole('combobox', { name: 'Default avatar' }); + fireEvent.change(select, { target: { value: 'rigged-image3d-1' } }); + + expect(await screen.findByText(/Covered: ideating/)).toBeInTheDocument(); + expect(screen.getByText(/Other states play Dance/)).toBeInTheDocument(); + + api.updateCosConfig.mockResolvedValue({ success: true }); + fireEvent.click(screen.getByRole('button', { name: /Save/i })); + await waitFor(() => expect(api.updateCosConfig).toHaveBeenCalledWith( + expect.objectContaining({ avatarStyle: 'rigged-image3d-1' }), + { silent: true }, + )); + }); + + it('warns when the saved rigged record is no longer offered', async () => { + renderConfig({ config: { ...config, avatarStyle: 'rigged-image3d-gone' }, riggedAvatars }); + await screen.findByText('Waiting for the next wake'); + + expect(screen.getByText(/no longer available/)).toBeInTheDocument(); + }); +}); + describe('persistent mind status', () => { it('shows the live supervisor state and links to the full mind workspace', async () => { renderConfig(); diff --git a/client/src/hooks/README.md b/client/src/hooks/README.md index 284fe9240d..441c59f6a7 100644 --- a/client/src/hooks/README.md +++ b/client/src/hooks/README.md @@ -62,6 +62,8 @@ grep -i "what you want to do" client/src/hooks/README.md | Hook | Purpose | Use when | |---|---|---| +| `useAvatarCapabilities` | Verified animated records for the avatar selectors (`GET /avatar/rigged`) plus the honest state→clip resolvers (`resolveStateClip`, `resolvePlaybackClip`, `coverageSummary`): an uncovered CoS state falls back to a clip the character actually has, never a pretended one. | Any surface offering or playing a rigged-record avatar (CoS avatar selector, CoS playback). | +|---|---|---| | `useMediaAnnotations` | Per-entry `own`/`others` annotations with back-compat aliases. | Showing media annotations + ownership. | | `useMediaCompletionRefresh` | Refetch on image/video completion socket events. | A list view that needs to refresh when new media lands. | | `useOpenClawAttachments` | File attachment handling (base64, size-capped). | OpenClaw attachment UI. | diff --git a/client/src/hooks/index.js b/client/src/hooks/index.js index 1130f9b503..d2af021ce0 100644 --- a/client/src/hooks/index.js +++ b/client/src/hooks/index.js @@ -129,6 +129,7 @@ export * from './useStoryStepRuns.jsx'; export * from './useModelDownloadStatus.js'; // === Media (annotations, completion, attachments) === +export * from './useAvatarCapabilities.js'; export * from './useMediaAnnotations.js'; export * from './useSpritePendingRenders.js'; export { default as useSpriteRecordCrud } from './useSpriteRecordCrud.js'; diff --git a/client/src/hooks/useAvatarCapabilities.js b/client/src/hooks/useAvatarCapabilities.js new file mode 100644 index 0000000000..9cd36ce9e5 --- /dev/null +++ b/client/src/hooks/useAvatarCapabilities.js @@ -0,0 +1,111 @@ +import { useCallback, useEffect, useState } from 'react'; +import { getRiggedAvatars } from '../services/api'; + +// Avatar capabilities for rigged + animated records (#5894). +// +// A retargeted character carries whatever clips its ONE retarget produced — +// usually a single clip — while every CoS state wants its own motion. This +// module is the honesty layer between the two: the server reports per-state +// coverage (`GET /avatar/rigged`, computed by +// `server/services/rigging/clipCapabilities.js`), and these resolvers turn +// that report into a playable clip WITHOUT ever pretending an uncovered state +// is covered. A missing state deterministically falls back to a clip the +// character actually has. + +/** `?variant=` namespace prefix for record-backed avatar styles. */ +export const RIGGED_AVATAR_PREFIX = 'rigged-'; + +/** Whether an avatar-style value selects a rigged record vs a built-in style. */ +export const isRiggedAvatarStyle = (style) => typeof style === 'string' && style.startsWith(RIGGED_AVATAR_PREFIX); + +/** The selector entry for a style value, or null when it is not offered. */ +export const riggedRecordForStyle = (records, style) => ( + isRiggedAvatarStyle(style) && Array.isArray(records) + ? records.find((record) => record?.variant === style) || null + : null +); + +/** + * The clip a CoS state maps to under a server coverage report: the covered + * clip when the state is covered, else the first available clip + * (deterministic — same record, same answer), else null when the character + * carries no clip at all. Never invents coverage. + * @param {object|null} coverage The `coverage` half of a `/avatar/rigged` entry. + * @param {string} state A CoS agent state. + * @returns {string|null} + */ +export function resolveStateClip(coverage, state) { + const stateClip = coverage?.coverageByState?.[state]?.clip; + if (typeof stateClip === 'string' && stateClip) return stateClip; + const available = Array.isArray(coverage?.availableClips) ? coverage.availableClips : []; + return available.find((clip) => typeof clip === 'string' && clip) || null; +} + +/** + * The clip to actually PLAY from a loaded GLB's roster. The coverage answer + * wins when the GLB still carries that clip (the record may have been + * re-retargeted since the selector read it, so presence is re-checked — + * never trusted blindly); then the caller's ordered fallbacks; then the + * roster's first clip, so an uncovered state degrades to real motion instead + * of a frozen frame. Null when the GLB carries nothing playable. + * @param {string[]} names Clip names on the loaded GLB. + * @param {{state?: string|null, coverage?: object|null, fallbacks?: string[]}} opts + * @returns {string|null} + */ +export function resolvePlaybackClip(names, { state = null, coverage = null, fallbacks = [] } = {}) { + const roster = Array.isArray(names) ? names.filter((name) => typeof name === 'string' && name) : []; + const candidates = [ + ...(state && coverage ? [resolveStateClip(coverage, state)] : []), + ...(Array.isArray(fallbacks) ? fallbacks : []), + ]; + return candidates.find((clip) => clip && roster.includes(clip)) || roster[0] || null; +} + +/** + * One-line honest summary of a coverage report for selector copy. + * @param {object|null} coverage + * @returns {string} + */ +export function coverageSummary(coverage) { + const states = coverage?.coverageByState ? Object.keys(coverage.coverageByState) : []; + const covered = Array.isArray(coverage?.coveredStates) ? coverage.coveredStates.length : 0; + if (states.length === 0) return 'No animation clips'; + if (covered >= states.length) return `Covers all ${states.length} CoS states`; + if (covered > 0) return `Covers ${covered} of ${states.length} CoS states`; + const fallback = resolveStateClip(coverage); + return fallback ? `No covered CoS state — plays ${fallback} throughout` : 'No animation clips'; +} + +/** + * The install's verified animated records for the avatar selectors. Fetches + * once on mount; `refresh` re-reads (e.g. after a retarget completes + * elsewhere). Failures resolve to an empty list with `error` set — the + * selectors render their built-in styles regardless, so a rigging-lane outage + * must never take down the CoS config screen. + * @returns {{records: object[], loading: boolean, error: Error|null, refresh: Function}} + */ +export function useAvatarCapabilities() { + const [records, setRecords] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const refresh = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await getRiggedAvatars({ silent: true }); + setRecords(Array.isArray(data?.records) ? data.records : []); + } catch (err) { + setRecords([]); + setError(err); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + refresh(); + }, [refresh]); + + return { records, loading, error, refresh }; +} diff --git a/client/src/hooks/useAvatarCapabilities.test.jsx b/client/src/hooks/useAvatarCapabilities.test.jsx new file mode 100644 index 0000000000..fea1b2122a --- /dev/null +++ b/client/src/hooks/useAvatarCapabilities.test.jsx @@ -0,0 +1,118 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const api = vi.hoisted(() => ({ getRiggedAvatars: vi.fn() })); +vi.mock('../services/api', () => api); + +import { + coverageSummary, + isRiggedAvatarStyle, + resolvePlaybackClip, + resolveStateClip, + riggedRecordForStyle, + useAvatarCapabilities, +} from './useAvatarCapabilities'; + +// A single-clip retargeted character: the server maps its one clip against +// the CoS vocabulary, so exactly the matching states read as covered. +const danceCoverage = { + availableClips: ['Dance'], + coverageByState: { + thinking: { covered: false, clip: null }, + coding: { covered: false, clip: null }, + ideating: { covered: true, clip: 'Dance' }, + }, + coveredStates: ['ideating'], + missingStates: ['thinking', 'coding'], + complete: false, +}; + +describe('resolveStateClip', () => { + it('returns the covered clip for a covered state', () => { + expect(resolveStateClip(danceCoverage, 'ideating')).toBe('Dance'); + }); + + it('falls back to the available clip for a missing state', () => { + expect(resolveStateClip(danceCoverage, 'coding')).toBe('Dance'); + }); + + it('returns null when the character carries no clip', () => { + expect(resolveStateClip({ availableClips: [], coverageByState: {} }, 'coding')).toBe(null); + expect(resolveStateClip(null, 'coding')).toBe(null); + }); +}); + +describe('resolvePlaybackClip', () => { + it('plays the covered clip when the GLB carries it', () => { + expect(resolvePlaybackClip(['Dance', 'Idle'], { + state: 'ideating', coverage: danceCoverage, fallbacks: ['idle'], + })).toBe('Dance'); + }); + + it('falls back to a present clip when the covered one is absent from the GLB', () => { + // The record was re-retargeted after the selector read it: coverage names + // a clip the file no longer has, so playback degrades to real motion + // rather than a frozen frame. + expect(resolvePlaybackClip(['Idle'], { + state: 'ideating', coverage: danceCoverage, fallbacks: ['idle'], + })).toBe('Idle'); + }); + + it('keeps the legacy fallback chain when there is no coverage', () => { + expect(resolvePlaybackClip(['walk', 'idle'], { fallbacks: ['walk', 'idle'] })).toBe('walk'); + expect(resolvePlaybackClip(['idle'], { fallbacks: ['walk', 'idle'] })).toBe('idle'); + expect(resolvePlaybackClip(['sprint'], { fallbacks: ['walk', 'idle'] })).toBe('sprint'); + expect(resolvePlaybackClip([], { fallbacks: ['walk', 'idle'] })).toBe(null); + }); +}); + +describe('coverageSummary', () => { + it('names the covered fraction honestly', () => { + expect(coverageSummary(danceCoverage)).toBe('Covers 1 of 3 CoS states'); + }); + + it('celebrates full coverage and admits none', () => { + expect(coverageSummary({ coverageByState: { a: {}, b: {} }, coveredStates: ['a', 'b'] })) + .toBe('Covers all 2 CoS states'); + expect(coverageSummary({ + coverageByState: { a: { covered: false, clip: null } }, + coveredStates: [], + availableClips: ['Dance'], + })).toBe('No covered CoS state — plays Dance throughout'); + expect(coverageSummary(null)).toBe('No animation clips'); + }); +}); + +describe('style helpers', () => { + it('recognizes rigged styles and finds their record', () => { + const records = [{ variant: 'rigged-image3d-1', name: 'Example Dancer' }]; + expect(isRiggedAvatarStyle('rigged-image3d-1')).toBe(true); + expect(isRiggedAvatarStyle('muse')).toBe(false); + expect(riggedRecordForStyle(records, 'rigged-image3d-1')).toEqual(records[0]); + expect(riggedRecordForStyle(records, 'rigged-image3d-gone')).toBe(null); + expect(riggedRecordForStyle(records, 'muse')).toBe(null); + }); +}); + +describe('useAvatarCapabilities', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('resolves with the served records', async () => { + api.getRiggedAvatars.mockResolvedValue({ records: [{ variant: 'rigged-image3d-1' }] }); + const { result } = renderHook(() => useAvatarCapabilities()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.records).toHaveLength(1); + expect(result.current.error).toBe(null); + expect(api.getRiggedAvatars).toHaveBeenCalledWith({ silent: true }); + }); + + it('fails open to an empty list so the built-in styles keep working', async () => { + api.getRiggedAvatars.mockRejectedValue(new Error('rigging lane down')); + const { result } = renderHook(() => useAvatarCapabilities()); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.records).toEqual([]); + expect(result.current.error).not.toBe(null); + }); +}); diff --git a/client/src/pages/ChiefOfStaff.jsx b/client/src/pages/ChiefOfStaff.jsx index aa0d48150a..00475e572f 100644 --- a/client/src/pages/ChiefOfStaff.jsx +++ b/client/src/pages/ChiefOfStaff.jsx @@ -5,6 +5,7 @@ import { useLocalStorageBool } from '../hooks/useLocalStorageBool'; import { useAutoRefetch } from '../hooks/useAutoRefetch'; import { useValidTab } from '../hooks/useValidTab'; import * as api from '../services/api'; +import { isRiggedAvatarStyle, riggedRecordForStyle, useAvatarCapabilities } from '../hooks/useAvatarCapabilities'; import { coalesce } from '../utils/coalesce'; import { sameJsonShape } from '../lib/sameJsonShape'; import { Play, Pause, Square, Clock, CheckCircle, AlertCircle, Cpu, ChevronDown, ChevronUp, ChevronLeft, ChevronRight, Brain, PanelLeftClose, PanelLeftOpen } from 'lucide-react'; @@ -70,6 +71,12 @@ const LAZY_AVATARS = { miniFemaleD: lazy(() => import('../components/cos/MiniCharFemaleD')), }; +// A verified animated record renders through the same mini-character stage — +// the variant URL resolves to the record's GLB via /api/avatar, and playback +// falls back to a present clip per its coverage. Lazy like every other 3D +// avatar so three.js stays out of the main chunk until it is picked. +const LazyRiggedAvatar = lazy(() => import('../components/cos/MiniCharacterCoSAvatar')); + const CANVAS_AVATAR_STYLES = new Set([ 'cyber', 'sigil', 'esoteric', 'nexus', 'muse', 'miniMaleC', 'miniFemaleD', @@ -134,6 +141,9 @@ export default function ChiefOfStaff() { const queueSeqRef = useRef(0); const socket = useSocket(); + // Verified animated records for the avatar selector and rigged playback (#5894). + const { records: riggedAvatars } = useAvatarCapabilities(); + // Derive avatar style from server config, with optional dynamic override const configAvatarStyle = status?.config?.avatarStyle || 'svg'; const dynamicAvatarEnabled = status?.config?.dynamicAvatar || false; @@ -710,7 +720,7 @@ export default function ChiefOfStaff() { el.scrollBy({ left: direction === 'left' ? -scrollAmount : scrollAmount, behavior: 'smooth' }); }, []); - const hasCanvasAvatar = CANVAS_AVATAR_STYLES.has(avatarStyle); + const hasCanvasAvatar = CANVAS_AVATAR_STYLES.has(avatarStyle) || isRiggedAvatarStyle(avatarStyle); // Learning tile behaviour shared by the compact (sidebar/mobile) and mini // (ascii stats bar) renderings — only the icon scale and the empty-state @@ -834,6 +844,18 @@ export default function ChiefOfStaff() { ); const renderAvatar = (background = false) => { + // A rigged record plays on the mini-character stage: the variant URL + // resolves to its animated GLB, and its coverage drives the fallback to + // a present clip. A record deleted after being picked 404s its HEAD + // probe, so the stage shows the missing-model hint instead of a canvas. + if (isRiggedAvatarStyle(avatarStyle)) { + const record = riggedRecordForStyle(riggedAvatars, avatarStyle); + return ( + }> + + + ); + } const LazyAvatar = LAZY_AVATARS[avatarStyle]; if (LazyAvatar) { return ( @@ -1259,7 +1281,7 @@ export default function ChiefOfStaff() { {activeTab === 'config' && (
}> - +
)} diff --git a/client/src/pages/ChiefOfStaff.test.jsx b/client/src/pages/ChiefOfStaff.test.jsx index 885ec0c36e..479ecf8087 100644 --- a/client/src/pages/ChiefOfStaff.test.jsx +++ b/client/src/pages/ChiefOfStaff.test.jsx @@ -30,6 +30,7 @@ const api = vi.hoisted(() => ({ getCosLearningDurations: vi.fn(), getCosPopularTemplates: vi.fn(), getCodeReviewDefaults: vi.fn(), + getRiggedAvatars: vi.fn(), })); const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })); const socketStub = vi.hoisted(() => ({ connected: false, on: vi.fn(), off: vi.fn(), emit: vi.fn() })); @@ -60,6 +61,18 @@ vi.mock('../hooks/useProviderModels', () => ({ selectedModel: '', }), })); +// The rigged avatar stage pulls three.js through a lazy chunk — stub the +// module so this suite asserts the wiring (variant + coverage reach the +// stage), not the 3D render. +vi.mock('../components/cos/MiniCharacterCoSAvatar', () => ({ + default: ({ variant, coverage }) => ( +
+ ), +})); const { default: ChiefOfStaff, SPEAKING_MS } = await import('./ChiefOfStaff'); @@ -100,6 +113,7 @@ beforeEach(() => { api.getCosLearningDurations.mockResolvedValue(null); api.getCosPopularTemplates.mockResolvedValue([]); api.getCodeReviewDefaults.mockResolvedValue({}); + api.getRiggedAvatars.mockResolvedValue({ records: [] }); localLlm.getLocalLlmStatus.mockResolvedValue({ ollama: { models: [] }, lmstudio: { models: [] } }); localLlm.getToolUseModels.mockResolvedValue({ models: [] }); }); @@ -863,3 +877,43 @@ describe('ChiefOfStaff Issues card', () => { } }); }); + +describe('Rigged avatar style', () => { + it('renders a rigged record on the mini-character stage with its coverage', async () => { + api.getCosStatus.mockResolvedValue({ + running: true, + config: { ...config, avatarStyle: 'rigged-image3d-1' }, + stats: {}, + }); + api.getRiggedAvatars.mockResolvedValue({ + records: [{ + id: 'image3d-1', + name: 'Example Dancer', + variant: 'rigged-image3d-1', + clip: 'Dance', + coverage: { coveredStates: ['ideating'], missingStates: ['coding'], complete: false }, + }], + }); + await renderSettledAt('tasks'); + + const avatar = await screen.findByTestId('rigged-avatar'); + expect(avatar).toHaveAttribute('data-variant', 'rigged-image3d-1'); + expect(avatar).toHaveAttribute('data-covered', 'ideating'); + }); + + it('still renders the stage when the rigged record is gone', async () => { + api.getCosStatus.mockResolvedValue({ + running: true, + config: { ...config, avatarStyle: 'rigged-image3d-gone' }, + stats: {}, + }); + api.getRiggedAvatars.mockResolvedValue({ records: [] }); + await renderSettledAt('tasks'); + + // No coverage to hand over (null), but the variant URL still probes — + // a deleted record shows the stage's missing-model hint, not a crash. + const avatar = await screen.findByTestId('rigged-avatar'); + expect(avatar).toHaveAttribute('data-variant', 'rigged-image3d-gone'); + expect(avatar).toHaveAttribute('data-covered', ''); + }); +}); diff --git a/client/src/services/README.md b/client/src/services/README.md index c8a3c87075..41494b1cca 100644 --- a/client/src/services/README.md +++ b/client/src/services/README.md @@ -151,3 +151,4 @@ toasts on throw). **Custom catch ⇒ `silent: true`** — otherwise toasts fire | `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. | +| `apiAvatar.js` | Avatar surfaces. `getRiggedAvatars()` (`GET /avatar/rigged`): the install's verified animated records, each with its `?variant=` spelling, serving URL, retargeted clip name, and server-computed CoS-state coverage. | diff --git a/client/src/services/api.js b/client/src/services/api.js index aa8c274e52..d99caf089e 100644 --- a/client/src/services/api.js +++ b/client/src/services/api.js @@ -62,6 +62,7 @@ export * from './apiUniverseBuilder.js'; export * from './apiAuthors.js'; export * from './apiArtists.js'; export * from './apiAlbums.js'; +export * from './apiAvatar.js'; export * from './apiTracks.js'; export * from './apiVideoDownload.js'; export * from './apiMusic.js'; diff --git a/client/src/services/apiAvatar.js b/client/src/services/apiAvatar.js new file mode 100644 index 0000000000..53935a3926 --- /dev/null +++ b/client/src/services/apiAvatar.js @@ -0,0 +1,9 @@ +import { request } from './apiCore.js'; + +// Selectable rigged + animated records for the avatar surfaces (#5894). +// `GET /avatar/rigged` answers the install's verified animated records, each +// with the `?variant=` spelling that selects it, the serving URL, the +// retargeted clip name, and the server-computed CoS-state coverage — so a +// selector shows what a character covers without re-deriving the vocabulary. +export const getRiggedAvatars = (options) => + request('/avatar/rigged', options); diff --git a/server/lib/README.md b/server/lib/README.md index f5fd005817..1ea10ade91 100644 --- a/server/lib/README.md +++ b/server/lib/README.md @@ -396,6 +396,7 @@ pm` default, `NPM_CONFIG_PREFIX`, nvm/Volta) installed `codex` successfully and | `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`. | +| `avatarVariants.js` | Rigged-record avatar variant spelling (`RIGGED_VARIANT_PREFIX`, `AVATAR_VARIANT_PATTERN`, `parseRiggedVariant`, `riggedVariantForId`, `isAnimatedRecordReady`) — the `rigged-` namespace over `?variant=`, sharing the route's strict traversal guard. | | `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. | diff --git a/server/lib/avatarVariants.js b/server/lib/avatarVariants.js new file mode 100644 index 0000000000..001c32500c --- /dev/null +++ b/server/lib/avatarVariants.js @@ -0,0 +1,63 @@ +/** + * Rigged-record avatar variants (issue #5894). + * + * A completed rigged + animated image-to-3D record is selectable as an avatar + * through the SAME `?variant=` namespace as the file-backed variants in + * `server/routes/avatar.js`, under the `rigged-` spelling. The record + * id charset (`image3d-`) already satisfies the route's strict variant + * guard, so the prefix is a namespace — not a second guard — and traversal + * attempts are still rejected by the one shared pattern. + * + * Pure string/shape helpers only: record lookup and file serving stay in the + * route, and clip coverage stays in `services/rigging/clipCapabilities.js`. + */ + +/** Namespace prefix separating record-backed variants from file-backed ones. */ +export const RIGGED_VARIANT_PREFIX = 'rigged-'; + +/** + * The one charset the avatar variant namespace accepts. Mirrors the guard in + * `server/routes/avatar.js` — both the full file-variant spelling and the + * record id inside a `rigged-` spelling must match it, so a malicious + * `?variant` can never carry a slash, a dot, or an extension into a path join. + */ +export const AVATAR_VARIANT_PATTERN = /^[a-z0-9-]+$/; + +/** + * Extract the record id from a rigged variant spelling, or `null` when the + * value is not a rigged spelling or its id fails the shared charset guard. + * @param {unknown} variant + * @returns {string|null} + */ +export function parseRiggedVariant(variant) { + if (typeof variant !== 'string' || !variant.startsWith(RIGGED_VARIANT_PREFIX)) return null; + const id = variant.slice(RIGGED_VARIANT_PREFIX.length); + return id && AVATAR_VARIANT_PATTERN.test(id) ? id : null; +} + +/** + * The `?variant=` spelling that selects a record, or `null` when the id is + * not variant-safe (never emit an unresolvable spelling into a selector). + * @param {unknown} modelId + * @returns {string|null} + */ +export function riggedVariantForId(modelId) { + if (typeof modelId !== 'string' || !AVATAR_VARIANT_PATTERN.test(modelId)) return null; + return `${RIGGED_VARIANT_PREFIX}${modelId}`; +} + +/** + * Whether an image-to-3D record has a published, verified animated GLB worth + * offering as an avatar. Mirrors the retarget lane's own readiness read + * (`retargetImageTo3dModel` persists `status: 'ready'` plus the run id only + * after the published pair verifies) — absent/`null` (pre-rigging records) + * and non-ready states are all "not selectable", never errors. + * @param {object|null} record + * @returns {boolean} + */ +export function isAnimatedRecordReady(record) { + const retarget = record?.retarget; + return retarget?.status === 'ready' + && typeof retarget?.retargetId === 'string' + && retarget.retargetId.length > 0; +} diff --git a/server/lib/avatarVariants.test.js b/server/lib/avatarVariants.test.js new file mode 100644 index 0000000000..85e56b03d3 --- /dev/null +++ b/server/lib/avatarVariants.test.js @@ -0,0 +1,42 @@ +import { describe, it, expect } from 'vitest'; +import { + AVATAR_VARIANT_PATTERN, + RIGGED_VARIANT_PREFIX, + isAnimatedRecordReady, + parseRiggedVariant, + riggedVariantForId, +} from './avatarVariants.js'; + +describe('avatarVariants', () => { + it('parses rigged spellings and rejects traversal ids', () => { + expect(parseRiggedVariant('rigged-image3d-abc-123')).toBe('image3d-abc-123'); + expect(parseRiggedVariant('mini-male-c')).toBe(null); + expect(parseRiggedVariant('rigged-../secret')).toBe(null); + expect(parseRiggedVariant('rigged-')).toBe(null); + expect(parseRiggedVariant(null)).toBe(null); + }); + + it('emits only variant-safe spellings', () => { + expect(riggedVariantForId('image3d-abc-123')).toBe('rigged-image3d-abc-123'); + expect(riggedVariantForId('../secret')).toBe(null); + expect(riggedVariantForId(null)).toBe(null); + }); + + it('keeps the rigged spelling inside the file-variant guard', () => { + // The route checks the rigged prefix FIRST, so a rigged spelling must also + // satisfy the file charset — otherwise one spelling could pass its own + // parse and fail (or bypass) the shared traversal guard. + expect(AVATAR_VARIANT_PATTERN.test(`${RIGGED_VARIANT_PREFIX}image3d-abc-123`)).toBe(true); + }); + + it('treats only verified retargets as selectable', () => { + expect(isAnimatedRecordReady({ retarget: { status: 'ready', retargetId: 'retarget-1' } })).toBe(true); + // Absent key (pre-rigging record), null, in-flight, and failed are all "not yet". + expect(isAnimatedRecordReady({ rig: null })).toBe(false); + expect(isAnimatedRecordReady({ retarget: null })).toBe(false); + expect(isAnimatedRecordReady({ retarget: { status: 'retargeting' } })).toBe(false); + expect(isAnimatedRecordReady({ retarget: { status: 'failed' } })).toBe(false); + expect(isAnimatedRecordReady({ retarget: { status: 'ready' } })).toBe(false); + expect(isAnimatedRecordReady(null)).toBe(false); + }); +}); diff --git a/server/lib/index.js b/server/lib/index.js index caf7813bc8..b9c6e7320b 100644 --- a/server/lib/index.js +++ b/server/lib/index.js @@ -19,6 +19,7 @@ export * from './appDeployFlags.js'; export * from './apiContractSchemas.js'; export * from './asyncApiSpec.js'; +export * from './avatarVariants.js'; export * as agentValidation from './agentValidation.js'; export * as agentContextValidation from './agentContextValidation.js'; export * as appleHealthValidation from './appleHealthValidation.js'; diff --git a/server/routes/avatar.js b/server/routes/avatar.js index f5402260a4..ba3dc39751 100644 --- a/server/routes/avatar.js +++ b/server/routes/avatar.js @@ -3,7 +3,17 @@ import { createReadStream } from 'fs'; import { stat } from 'fs/promises'; import { join } from 'path'; import { PATHS, pathExists } from '../lib/fileUtils.js'; +import { + AVATAR_VARIANT_PATTERN, + RIGGED_VARIANT_PREFIX, + isAnimatedRecordReady, + parseRiggedVariant, + riggedVariantForId, +} from '../lib/avatarVariants.js'; import { ServerError, getErrorCode } from '../lib/errorHandler.js'; +import { buildClipCoverage } from '../services/rigging/clipCapabilities.js'; +import { retargetRunPaths } from '../services/rigging/retarget.js'; +import { getModel, listModels } from '../services/imageTo3d/db.js'; const router = Router(); const AVATAR_DIR = join(PATHS.data, 'avatar'); @@ -15,12 +25,41 @@ const AVATAR_PATH = join(AVATAR_DIR, 'model.glb'); // can never escape the avatar directory. function resolveVariant(variant) { if (!variant || typeof variant !== 'string') return AVATAR_PATH; - if (!/^[a-z0-9-]+$/.test(variant)) return null; + if (!AVATAR_VARIANT_PATTERN.test(variant)) return null; return join(AVATAR_DIR, `${variant}.glb`); } +// Resolve a `rigged-` spelling to the record's published animated GLB +// (#5894). The id passed the shared charset guard in `parseRiggedVariant`, so +// the joins below cannot escape the record dir; the retarget id comes from the +// stored record (not the request) and is re-checked for the same reason. Only +// a record whose retarget verified at publish time resolves — anything else +// (unknown id, rig-only, failed, in-flight) is a 404, never a half-animated file. +async function resolveRiggedVariant(variant) { + const modelId = parseRiggedVariant(variant); + if (!modelId) return null; + const record = await getModel(modelId); + if (!isAnimatedRecordReady(record)) return null; + const { retargetId } = record.retarget; + if (!AVATAR_VARIANT_PATTERN.test(retargetId)) return null; + return retargetRunPaths({ recordDir: join(PATHS.imageTo3d, modelId), retargetId }).publishedGlb; +} + +// Resolve any `?variant=` to an absolute GLB path, or null when unresolvable. +// Rigged spellings are checked FIRST: `rigged-` also satisfies the file +// charset, so falling through to the file branch would 404 a selectable +// record against a filename that was never meant to exist. +async function resolveAvatarPath(variant) { + if (typeof variant === 'string' && variant.startsWith(RIGGED_VARIANT_PREFIX)) { + return resolveRiggedVariant(variant); + } + return resolveVariant(variant); +} + +const isRiggedSpelling = (variant) => typeof variant === 'string' && variant.startsWith(RIGGED_VARIANT_PREFIX); + router.head('/model.glb', async (req, res) => { - const path = resolveVariant(req.query.variant); + const path = await resolveAvatarPath(req.query.variant); if (!path) return res.status(404).end(); // Single async stat off the event loop, doubling as the existence check — // a missing/removed file (TOCTOU) just resolves null → 404. @@ -33,8 +72,11 @@ router.head('/model.glb', async (req, res) => { }); router.get('/model.glb', async (req, res) => { - const path = resolveVariant(req.query.variant); + const path = await resolveAvatarPath(req.query.variant); if (!path || !(await pathExists(path))) { + if (isRiggedSpelling(req.query.variant)) { + throw new ServerError('That animated character is not available. Rig and animate the record first.', { status: 404 }); + } throw new ServerError('No avatar model configured. Drop a GLB at data/avatar/model.glb', { status: 404 }); } res.set('Content-Type', 'model/gltf-binary'); @@ -62,4 +104,31 @@ router.get('/model.glb', async (req, res) => { stream.pipe(res); }); +// One selector entry for a ready animated record: the `?variant=` spelling +// that selects it, the avatar-route URL that serves it (variant-guarded and +// HEAD-probeable, unlike the raw `/data` mount), the retargeted clip name, and +// the server-computed CoS-state coverage so selectors can show what the +// character covers without re-deriving the vocabulary client-side. +const riggedAvatarEntry = (record) => { + const variant = riggedVariantForId(record.id); + const clip = typeof record?.retarget?.clip === 'string' && record.retarget.clip ? record.retarget.clip : null; + return { + id: record.id, + name: record.name || record.id, + variant, + assetUrl: variant ? `/api/avatar/model.glb?variant=${encodeURIComponent(variant)}` : null, + clip, + coverage: buildClipCoverage(clip ? [clip] : []), + }; +}; + +// The animated records the avatar selectors may offer. Read-only: a DB list +// filtered to verified retargets, each carrying its coverage — an empty list +// (no records, none animated yet) is the normal fresh-install answer, not an error. +router.get('/rigged', async (_req, res) => { + const records = await listModels(); + const ready = records.filter(isAnimatedRecordReady).map(riggedAvatarEntry).filter((entry) => entry.variant); + res.json({ records: ready }); +}); + export default router; diff --git a/server/routes/avatar.test.js b/server/routes/avatar.test.js index 09ab777579..9ffdd2299e 100644 --- a/server/routes/avatar.test.js +++ b/server/routes/avatar.test.js @@ -13,14 +13,21 @@ vi.mock('fs/promises', () => ({ stat: vi.fn() })); -vi.mock('../lib/fileUtils.js', () => ({ - PATHS: { data: '/mock/data' }, +vi.mock('../lib/fileUtils.js', async (importOriginal) => ({ + ...(await importOriginal()), + PATHS: { data: '/mock/data', imageTo3d: '/mock/image-to-3d' }, pathExists: vi.fn() })); +vi.mock('../services/imageTo3d/db.js', () => ({ + getModel: vi.fn(), + listModels: vi.fn(), +})); + import { createReadStream } from 'fs'; import { stat } from 'fs/promises'; import { pathExists } from '../lib/fileUtils.js'; +import { getModel, listModels } from '../services/imageTo3d/db.js'; import avatarRoutes from './avatar.js'; const buildApp = () => { @@ -143,4 +150,83 @@ describe('avatar routes', () => { expect(bad.status).toBe(404); }); }); + + describe('rigged record variants', () => { + const readyRecord = { + id: 'image3d-record-1', + name: 'Example Character', + retarget: { status: 'ready', retargetId: 'retarget-run-1', clip: 'Idle' }, + }; + + it('serves a ready record animated GLB through the rigged spelling', async () => { + getModel.mockResolvedValue(readyRecord); + pathExists.mockResolvedValue(true); + createReadStream.mockReturnValue(Readable.from([Buffer.from('ANIMATED-GLB')])); + const res = await request(buildApp()).get('/api/avatar/model.glb?variant=rigged-image3d-record-1'); + expect(res.status).toBe(200); + expect(res.text).toBe('ANIMATED-GLB'); + // The resolved path stays inside the record's retarget publish dir. + expect(createReadStream).toHaveBeenCalledWith(joinPath( + '/mock/image-to-3d', 'image3d-record-1', 'retarget', 'retarget-run-1', 'character.animated.glb', + )); + }); + + it('HEAD honors a ready rigged spelling', async () => { + getModel.mockResolvedValue(readyRecord); + stat.mockResolvedValue({ size: 7 }); + const res = await request(buildApp()).head('/api/avatar/model.glb?variant=rigged-image3d-record-1'); + expect(res.status).toBe(200); + expect(res.headers['content-type']).toBe('model/gltf-binary'); + }); + + it('404s a rigged spelling for unknown, unready, and traversal ids alike', async () => { + pathExists.mockResolvedValue(false); + // Unknown record. + getModel.mockResolvedValue(null); + const missing = await request(buildApp()).get('/api/avatar/model.glb?variant=rigged-image3d-nope'); + expect(missing.status).toBe(404); + expect(missing.body.error).toMatch(/animated character/i); + // Rig-only record (no verified retarget yet) — never a half-animated file. + getModel.mockResolvedValue({ id: 'image3d-record-2', retarget: { status: 'retargeting' } }); + const unready = await request(buildApp()).get('/api/avatar/model.glb?variant=rigged-image3d-record-2'); + expect(unready.status).toBe(404); + // Traversal inside the rigged namespace never reaches the record lookup. + getModel.mockClear(); + const traversal = await request(buildApp()).get('/api/avatar/model.glb?variant=rigged-../secret'); + expect(traversal.status).toBe(404); + expect(getModel).not.toHaveBeenCalled(); + }); + }); + + describe('GET /rigged', () => { + it('lists only ready records with their clip coverage attached', async () => { + listModels.mockResolvedValue([ + { id: 'image3d-animated-1', name: 'Example Dancer', retarget: { status: 'ready', retargetId: 'retarget-1', clip: 'Dance' } }, + { id: 'image3d-rig-only-1', name: 'Not Yet', rig: { status: 'ready' }, retarget: null }, + { id: 'image3d-failed-1', name: 'Failed', retarget: { status: 'failed' } }, + ]); + const res = await request(buildApp()).get('/api/avatar/rigged'); + expect(res.status).toBe(200); + expect(res.body.records).toHaveLength(1); + expect(res.body.records[0]).toMatchObject({ + id: 'image3d-animated-1', + name: 'Example Dancer', + variant: 'rigged-image3d-animated-1', + assetUrl: '/api/avatar/model.glb?variant=rigged-image3d-animated-1', + clip: 'Dance', + }); + // The single retargeted clip drives the coverage answer: Dance covers + // ideating, everything else honestly reports missing. + expect(res.body.records[0].coverage.coveredStates).toContain('ideating'); + expect(res.body.records[0].coverage.missingStates.length).toBeGreaterThan(0); + expect(res.body.records[0].coverage.complete).toBe(false); + }); + + it('answers an empty list when no record is animated yet', async () => { + listModels.mockResolvedValue([]); + const res = await request(buildApp()).get('/api/avatar/rigged'); + expect(res.status).toBe(200); + expect(res.body).toEqual({ records: [] }); + }); + }); }); diff --git a/server/routes/cosStatusRoutes.js b/server/routes/cosStatusRoutes.js index e7bdd670b1..dd5243035b 100644 --- a/server/routes/cosStatusRoutes.js +++ b/server/routes/cosStatusRoutes.js @@ -11,6 +11,7 @@ import { asyncHandler } from '../lib/errorHandler.js'; import { validateRequest } from '../lib/validation.js'; import { z } from 'zod'; import { DOMAIN_IDS, DOMAIN_MODES } from '../lib/domainAutonomy.js'; +import { AVATAR_VARIANT_PATTERN, RIGGED_VARIANT_PREFIX } from '../lib/avatarVariants.js'; import { BUDGET_LIMIT_FIELDS } from '../lib/domainBudgets.js'; import { persistentMindCapabilitiesSchema } from '../lib/persistentMindCapabilities.js'; import { persistentMindProfileSchema } from '../lib/persistentMindProfile.js'; @@ -40,7 +41,16 @@ export const cosConfigSchema = z.object({ selfImprovementEnabled: z.boolean().optional(), appImprovementEnabled: z.boolean().optional(), improvementEnabled: z.boolean().optional(), - avatarStyle: z.enum(['svg', 'ascii', 'cyber', 'sigil', 'esoteric', 'nexus', 'muse', 'miniMaleC', 'miniFemaleD']).optional(), + // Built-in styles plus selectable rigged records (`rigged-`, #5894). + // The record half reuses the avatar variant charset (no slashes, no dots — + // the same traversal guard `server/routes/avatar.js` enforces), so unknown + // spellings still 400 here instead of persisting a style nothing can render. + avatarStyle: z.union([ + z.enum(['svg', 'ascii', 'cyber', 'sigil', 'esoteric', 'nexus', 'muse', 'miniMaleC', 'miniFemaleD']), + z.string().startsWith(RIGGED_VARIANT_PREFIX).refine( + (value) => AVATAR_VARIANT_PATTERN.test(value.slice(RIGGED_VARIANT_PREFIX.length)), + ), + ]).optional(), dynamicAvatar: z.boolean().optional(), alwaysOn: z.boolean().optional(), appReviewCooldownMs: z.number().int().min(0).optional(), diff --git a/server/routes/cosStatusRoutesAvatar.test.js b/server/routes/cosStatusRoutesAvatar.test.js new file mode 100644 index 0000000000..a658888987 --- /dev/null +++ b/server/routes/cosStatusRoutesAvatar.test.js @@ -0,0 +1,25 @@ +import { describe, it, expect, vi } from 'vitest'; + +// The schema is the save-path gate for avatar styles: pin that a rigged +// record spelling persists while traversal-shaped values still 400. The +// service graph behind the route is stubbed — only the exported schema is +// under test here. +vi.mock('../services/cos.js', () => ({})); +vi.mock('../services/domainUsage.js', () => ({ getAllDomainUsageToday: vi.fn() })); +vi.mock('../services/taskWatcher.js', () => ({})); +vi.mock('../services/memoryEmbeddings.js', () => ({ reinitialize: vi.fn() })); + +import { cosConfigSchema } from './cosStatusRoutes.js'; + +describe('cosConfigSchema avatarStyle', () => { + it('accepts built-in styles and rigged record spellings', () => { + expect(cosConfigSchema.safeParse({ avatarStyle: 'muse' }).success).toBe(true); + expect(cosConfigSchema.safeParse({ avatarStyle: 'rigged-image3d-abc-123' }).success).toBe(true); + }); + + it('rejects unknown and traversal-shaped styles', () => { + expect(cosConfigSchema.safeParse({ avatarStyle: 'not-a-style' }).success).toBe(false); + expect(cosConfigSchema.safeParse({ avatarStyle: 'rigged-../secret' }).success).toBe(false); + expect(cosConfigSchema.safeParse({ avatarStyle: 'rigged-' }).success).toBe(false); + }); +}); From 0bfa29f969b68d1cabb6363f48f520815f3beb49 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Thu, 3 Sep 2026 18:59:35 +0000 Subject: [PATCH 2/3] chore: regenerate API route catalog for the rigged-avatar endpoints --- server/lib/apiRouteCatalog.generated.json | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/server/lib/apiRouteCatalog.generated.json b/server/lib/apiRouteCatalog.generated.json index 02ca6e6af8..0150c5b07f 100644 --- a/server/lib/apiRouteCatalog.generated.json +++ b/server/lib/apiRouteCatalog.generated.json @@ -1502,6 +1502,14 @@ "server/routes/avatar.js" ] }, + { + "method": "GET", + "path": "/api/avatar/rigged", + "mountPath": "/api/avatar", + "sources": [ + "server/routes/avatar.js" + ] + }, { "method": "POST", "path": "/api/backup/restore", @@ -17505,8 +17513,8 @@ ], "stats": { "mounts": 147, - "operations": 2168, - "declarations": 2176, + "operations": 2169, + "declarations": 2177, "sourceFiles": 230 } } From 56cdd4790d9d3151a141ad83d6d0e6063b708d04 Mon Sep 17 00:00:00 2001 From: "[._.]/ Adam Eivy" Date: Thu, 3 Sep 2026 19:13:44 +0000 Subject: [PATCH 3/3] fix practice rating button accessible name after visible hint subtext landed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A recent main commit split the rating button's text into a label span plus a visible hint span, which made the button's accessible name the full label+hint string instead of just the label — breaking SongBookViewer.test.jsx's 'Solid' button lookup and, with it, CI on this branch. Pin the accessible name back to the label via aria-label. --- client/src/components/songbook/PracticeLogger.jsx | 1 + 1 file changed, 1 insertion(+) diff --git a/client/src/components/songbook/PracticeLogger.jsx b/client/src/components/songbook/PracticeLogger.jsx index 38b143b07c..efd4145545 100644 --- a/client/src/components/songbook/PracticeLogger.jsx +++ b/client/src/components/songbook/PracticeLogger.jsx @@ -66,6 +66,7 @@ export default function PracticeLogger({ song, onLogged, className = '' }) { type="button" onClick={() => logPractice(rating.quality)} disabled={logging} + aria-label={rating.label} className="min-h-[48px] px-2.5 py-2 text-left sm:text-center rounded-lg border border-port-border text-gray-300 hover:text-white hover:border-port-accent/50 hover:bg-port-border/50 disabled:opacity-50 flex flex-col justify-center" > {rating.label}