// contract.mjs // Canonical 1:1 mapping: Vivijure docs/CONTRACT.md section 2.1 (68 routes) plus control-panel // supplements -> Slate surfaces. CI asserts zero drift via contract.test.ts. /** @typedef {{ id: number, method: string, path: string, section: string, studioFn: string|string[], actions: string[], commands: string[], notes?: string }} ContractRoute */ /** Routes in vivijure/docs/CONTRACT.md section 2.1. */ export const CONTRACT_MD_ROUTE_COUNT = 68; /** Studio HTTP routes Slate must surface (CONTRACT + control-panel parity). */ export const STUDIO_API_ROUTE_COUNT = 69; export const CONTRACT_ROUTES = [ { id: 1, method: 'GET', path: '/health', section: '2.2', studioFn: 'getHealth', actions: ['health'], commands: ['!health', '!api health'] }, { id: 2, method: 'GET', path: '/api/modules', section: '2.3', studioFn: 'getModules', actions: ['modules'], commands: ['!hooks', '!api modules'] }, { id: 3, method: 'GET', path: '/api/voices', section: '2.4', studioFn: 'listVoices', actions: ['voices'], commands: ['!voices', '/voices'] }, { id: 4, method: 'GET', path: '/api/storyboard/projects', section: '2.5', studioFn: 'listProjects', actions: ['projects-list'], commands: ['!projects', '!api projects-list'] }, { id: 5, method: 'POST', path: '/api/storyboard/projects', section: '2.5', studioFn: 'createProject', actions: ['project-create'], commands: ['!saveproject (create)', '!api project-create'] }, { id: 6, method: 'GET', path: '/api/storyboard/projects/:id', section: '2.5', studioFn: 'getProject', actions: ['project-get'], commands: ['!loadproject', '!api project-get'] }, { id: 7, method: 'PATCH', path: '/api/storyboard/projects/:id', section: '2.5', studioFn: 'updateProject', actions: ['project-patch'], commands: ['!patchproject', '!api project-patch'] }, { id: 8, method: 'POST', path: '/api/storyboard/projects/:id/storyboard', section: '2.5', studioFn: 'saveProjectStoryboard', actions: ['project-save-storyboard'], commands: ['!saveproject (update)', '!api project-save-storyboard'] }, { id: 9, method: 'DELETE', path: '/api/storyboard/projects/:id', section: '2.5', studioFn: 'deleteProject', actions: ['project-delete'], commands: ['!deleteproject', '!api project-delete'] }, { id: 10, method: 'GET', path: '/api/cast', section: '2.6', studioFn: 'listCast', actions: ['cast-list'], commands: ['!cast', '/cast'] }, { id: 11, method: 'POST', path: '/api/cast', section: '2.6', studioFn: 'createCast', actions: ['cast-create'], commands: ['!castcreate', '!api cast-create'] }, { id: 12, method: 'GET', path: '/api/cast/:id', section: '2.6', studioFn: 'getCast', actions: ['cast-get'], commands: ['!castget', '!api cast-get'] }, { id: 13, method: 'PATCH', path: '/api/cast/:id', section: '2.6', studioFn: 'updateCast', actions: ['cast-update'], commands: ['!voice', '!api cast-update'] }, { id: 14, method: 'DELETE', path: '/api/cast/:id', section: '2.6', studioFn: 'deleteCast', actions: ['cast-delete'], commands: ['!castdelete', '!api cast-delete'] }, { id: 15, method: 'POST', path: '/api/cast/:id/portrait', section: '2.7', studioFn: 'setCastPortrait', actions: ['cast-portrait-set'], commands: ['!portrait (sync)', '!api cast-portrait-set'] }, { id: 16, method: 'DELETE', path: '/api/cast/:id/portrait', section: '2.7', studioFn: 'deleteCastPortrait', actions: ['cast-portrait-clear'], commands: ['!clearportrait', '!api cast-portrait-clear'] }, { id: 17, method: 'POST', path: '/api/cast/:id/ref', section: '2.7', studioFn: 'addCastRef', actions: ['cast-ref-add'], commands: ['!addref', '!api cast-ref-add'] }, { id: 18, method: 'DELETE', path: '/api/cast/:id/ref', section: '2.7', studioFn: ['deleteCastRef', 'deleteCastRefByKey'], actions: ['cast-ref-del'], commands: ['!delref', '!api cast-ref-del'] }, { id: 19, method: 'POST', path: '/api/cast/:id/source', section: '2.7', studioFn: 'addCastSource', actions: ['cast-source-add'], commands: ['!addsource', '!api cast-source-add'] }, { id: 20, method: 'DELETE', path: '/api/cast/:id/source', section: '2.7', studioFn: ['deleteCastSource', 'deleteCastSourceByKey'], actions: ['cast-source-del'], commands: ['!delsource', '!api cast-source-del'] }, { id: 21, method: 'POST', path: '/api/cast/:id/generate-refs', section: '2.8', studioFn: 'generateCastRefs', actions: ['cast-generate-refs'], commands: ['!genrefs', '/genrefs'] }, { id: 22, method: 'GET', path: '/api/cast/:id/refs-job/:jobId', section: '2.8', studioFn: 'pollCastRefsJob', actions: ['cast-refs-poll'], commands: ['!genrefs (polls)', '!api cast-refs-poll'] }, { id: 23, method: 'POST', path: '/api/cast/:id/train-lora', section: '2.9', studioFn: 'trainCastLora', actions: ['cast-train-lora'], commands: ['!train', '/train'] }, { id: 24, method: 'GET', path: '/api/cast/:id/lora-status', section: '2.9', studioFn: 'getCastLoraStatus', actions: ['cast-lora-status'], commands: ['!lorastatus', '/lorastatus'] }, { id: 25, method: 'POST', path: '/api/upload', section: '2.10', studioFn: 'uploadImage', actions: [], commands: ['!upload'], notes: 'attachment command' }, { id: 26, method: 'GET', path: '/api/artifact/*key', section: '2.11', studioFn: 'artifactUrl', actions: ['artifact-url'], commands: ['!api artifact-url'] }, { id: 27, method: 'POST', path: '/api/storyboard/preflight', section: '2.12', studioFn: 'preflightStoryboard', actions: ['preflight'], commands: ['!preflight', '/preflight'] }, { id: 28, method: 'POST', path: '/api/storyboard/plan', section: '2.13', studioFn: 'planStoryboard', actions: ['plan'], commands: ['!plan', '!api plan'] }, { id: 29, method: 'POST', path: '/api/storyboard/refine', section: '2.13', studioFn: 'refineStoryboard', actions: ['refine'], commands: ['!refine', '!api refine'] }, { id: 30, method: 'POST', path: '/api/chat', section: '2.13', studioFn: 'studioChat', actions: ['chat'], commands: ['!chat', '!api chat'] }, { id: 31, method: 'POST', path: '/api/storyboard/score-bed', section: '2.14', studioFn: 'startScoreBed', actions: ['score-bed'], commands: ['!score', '/score'] }, { id: 32, method: 'GET', path: '/api/job/:id', section: '2.14', studioFn: 'pollJob', actions: ['job-poll'], commands: ['!poll', '!score (polls)', '!api job-poll'] }, { id: 33, method: 'POST', path: '/api/storyboard/enhance', section: '2.15', studioFn: 'enhanceStoryboard', actions: ['enhance'], commands: ['!autodirect', '/autodirect'] }, { id: 34, method: 'GET', path: '/api/storyboard/models', section: '2.16', studioFn: 'getStoryboardModels', actions: ['models'], commands: ['!models', '!api models'] }, { id: 35, method: 'POST', path: '/api/storyboard/yaml', section: '2.16', studioFn: 'storyboardYaml', actions: ['yaml'], commands: ['!yaml', '!api yaml'] }, { id: 36, method: 'POST', path: '/api/storyboard/markers', section: '2.16', studioFn: 'storyboardMarkers', actions: ['markers'], commands: ['!markers', '!api markers'] }, { id: 37, method: 'POST', path: '/api/storyboard/bundle', section: '2.16', studioFn: 'bundleStoryboard', actions: ['bundle'], commands: ['!bundle', '!render (bundles)', '!api bundle'] }, { id: 38, method: 'POST', path: '/api/storyboard/audio-upload', section: '2.10', studioFn: 'uploadAudio', actions: [], commands: ['!audioupload'], notes: 'attachment command' }, { id: 39, method: 'POST', path: '/api/storyboard/character-ref', section: '2.10', studioFn: 'uploadCharacterRef', actions: [], commands: ['!characterref'], notes: 'attachment command' }, { id: 40, method: 'POST', path: '/api/audio/analyze', section: '2.17', studioFn: 'analyzeAudio', actions: ['audio-analyze'], commands: ['!analyze', '!api audio-analyze'] }, { id: 41, method: 'POST', path: '/api/storyboard/render', section: '2.18', studioFn: 'submitStoryboardRender', actions: ['storyboard-render'], commands: ['!storyboard-render', '!api storyboard-render'] }, { id: 42, method: 'POST', path: '/api/storyboard/render-plan', section: '2.18', studioFn: 'submitRenderPlan', actions: ['render-plan'], commands: ['!render-plan', '!api render-plan'] }, { id: 43, method: 'POST', path: '/api/render/clips', section: '2.19', studioFn: 'submitClips', actions: ['clips-submit'], commands: ['!clips', '!api clips-submit'] }, { id: 44, method: 'GET', path: '/api/render/clips/:id', section: '2.19', studioFn: 'pollClips', actions: ['clips-poll'], commands: ['!clipspoll', '!api clips-poll'] }, { id: 45, method: 'POST', path: '/api/render/film', section: '2.20', studioFn: 'submitFilm', actions: ['film-submit'], commands: ['!render', '!ship', '/render'] }, { id: 46, method: 'GET', path: '/api/render/film/:id', section: '2.21', studioFn: 'pollFilm', actions: ['film-poll'], commands: ['!filmpoll', '!render (polls)', '!api film-poll'] }, { id: 47, method: 'POST', path: '/api/storyboard/renders/:id/regen-shot', section: '2.22', studioFn: 'regenShot', actions: ['render-regen-shot'], commands: ['!regen', '!api render-regen-shot'] }, { id: 48, method: 'POST', path: '/api/storyboard/render/scatter', section: '2.23', studioFn: 'submitScatterRender', actions: ['scatter'], commands: ['!scatter', '!api scatter'] }, { id: 49, method: 'POST', path: '/api/storyboard/render-from-keyframes', section: '2.18', studioFn: 'submitRenderFromKeyframes', actions: ['render-from-keyframes'], commands: ['!render-keyframes', '!api render-from-keyframes'] }, { id: 50, method: 'GET', path: '/api/storyboard/render/:jobId', section: '2.24', studioFn: 'pollStoryboardRender', actions: ['render-poll'], commands: ['!renderpoll', '!api render-poll'] }, { id: 51, method: 'DELETE', path: '/api/storyboard/render/:jobId', section: '2.24', studioFn: 'cancelStoryboardRender', actions: ['render-cancel'], commands: ['!cancel', '!api render-cancel'] }, { id: 52, method: 'GET', path: '/api/storyboard/renders', section: '2.25', studioFn: 'listRenders', actions: ['renders-list'], commands: ['!renders', '/renders'] }, { id: 53, method: 'GET', path: '/api/storyboard/renders/tags', section: '2.25', studioFn: 'listRenderTags', actions: ['renders-tags'], commands: ['!rendertags', '!api renders-tags'] }, { id: 54, method: 'PATCH', path: '/api/storyboard/renders/:id', section: '2.25', studioFn: 'patchRender', actions: ['render-patch'], commands: ['!patch-render', '!api render-patch'] }, { id: 55, method: 'DELETE', path: '/api/storyboard/renders/:id', section: '2.25', studioFn: 'deleteRender', actions: ['render-delete'], commands: ['!delete-render', '!api render-delete'] }, { id: 56, method: 'POST', path: '/api/storyboard/renders/:id/add-audio', section: '2.26', studioFn: 'addRenderAudio', actions: ['render-add-audio'], commands: ['!add-audio', '!api render-add-audio'] }, { id: 57, method: 'POST', path: '/api/storyboard/renders/:id/add-narration', section: '2.26', studioFn: 'addRenderNarration', actions: ['render-add-narration'], commands: ['!add-narration', '!api render-add-narration'] }, { id: 58, method: 'POST', path: '/api/storyboard/renders/:id/finalize', section: '2.27', studioFn: 'finalizeRender', actions: ['render-finalize'], commands: ['!finalize', '!api render-finalize'] }, { id: 59, method: 'POST', path: '/api/storyboard/renders/:id/animate-cloud', section: '2.27', studioFn: 'animateRenderCloud', actions: ['render-animate-cloud'], commands: ['!animate-cloud', '!api render-animate-cloud'] }, { id: 60, method: 'POST', path: '/api/storyboard/renders/:id/animate-hybrid', section: '2.27', studioFn: 'animateRenderHybrid', actions: ['render-animate-hybrid'], commands: ['!animate-hybrid', '!api render-animate-hybrid'] }, { id: 61, method: 'POST', path: '/api/storyboard/renders/adopt', section: '2.28', studioFn: 'adoptRender', actions: ['render-adopt'], commands: ['!adopt', '!api render-adopt'] }, { id: 62, method: 'GET', path: '/api/whoami', section: '2.29', studioFn: 'getWhoami', actions: ['whoami'], commands: ['!whoami', '!api whoami'] }, { id: 63, method: 'GET', path: '/api/prefs', section: '2.29', studioFn: 'getPrefs', actions: ['prefs'], commands: ['!prefs', '!api prefs'] }, { id: 64, method: 'PATCH', path: '/api/prefs', section: '2.29', studioFn: 'patchPrefs', actions: ['prefs-patch'], commands: ['!api prefs-patch'] }, { id: 65, method: 'GET', path: '/api/modules/:name/config', section: '4.1.2', studioFn: 'getModuleInstallConfig', actions: ['module-config-get'], commands: ['!install-config', '!api module-config-get'] }, { id: 66, method: 'PATCH', path: '/api/modules/:name/config', section: '4.1.2', studioFn: 'patchModuleInstallConfig', actions: ['module-config-patch'], commands: ['!install-config', '!api module-config-patch'] }, { id: 67, method: 'GET/POST', path: '/api/cast/export/:id', section: '2.9a', studioFn: 'exportCastUrl', actions: ['cast-export-url'], commands: ['!exportcast', '!api cast-export-url'] }, { id: 68, method: 'POST', path: '/api/cast/import', section: '2.9a', studioFn: 'importCast', actions: [], commands: ['!importcast'], notes: 'attachment command' }, // Control-panel route (planner-history-row.js v0.60.0); studio handler ships separately from CONTRACT.md table. { id: 69, method: 'POST', path: '/api/storyboard/renders/:id/retry', section: '2.25', studioFn: 'retryRender', actions: ['render-retry'], commands: ['!retry', '!api render-retry'], notes: 'control panel retry failed render row' }, ]; export const CONTRACT_ROUTE_COUNT = CONTRACT_ROUTES.length; /** * Bang-command aliases -> !api action. Parsed before the LLM path in bot.mjs. * `args(rest)` builds key:value args; default passes rest through. */ export const STUDIO_COMMAND_ALIASES = { health: { action: 'health' }, whoami: { action: 'whoami' }, prefs: { action: 'prefs' }, models: { action: 'models' }, projects: { action: 'projects-list' }, rendertags: { action: 'renders-tags' }, plan: { action: 'plan', passRest: true }, refine: { action: 'refine', restKey: 'message' }, bundle: { action: 'bundle' }, yaml: { action: 'yaml' }, markers: { action: 'markers', passRest: true }, chat: { action: 'chat', passRest: true }, scatter: { action: 'scatter', passRest: true }, 'storyboard-render': { action: 'storyboard-render', passRest: true }, 'render-keyframes': { action: 'render-from-keyframes', passRest: true }, 'render-plan': { action: 'render-plan', passRest: true }, clips: { action: 'clips-submit', passRest: true }, poll: { action: 'job-poll', args(rest) { const [id, mod] = rest.trim().split(/\s+/); if (!id) return null; return mod ? `id:${id} module:${mod}` : `id:${id}`; }, usage: '!poll [module]', }, renderpoll: { action: 'render-poll', argKey: 'jobId', usage: '!renderpoll ' }, filmpoll: { action: 'film-poll', argKey: 'id', usage: '!filmpoll ' }, clipspoll: { action: 'clips-poll', argKey: 'id', usage: '!clipspoll ' }, cancel: { action: 'render-cancel', argKey: 'jobId', usage: '!cancel ' }, regen: { action: 'render-regen-shot', args(rest) { const [id, shotId] = rest.trim().split(/\s+/); if (!id || !shotId) return null; return `id:${id} shotId:${shotId}`; }, usage: '!regen ', }, retry: { action: 'render-retry', argKey: 'id', usage: '!retry ' }, finalize: { action: 'render-finalize', leadArgKey: 'id', passRest: true, usage: '!finalize [audioKey:...]' }, 'animate-cloud': { action: 'render-animate-cloud', leadArgKey: 'id', passRest: true, usage: '!animate-cloud [model:...]' }, 'animate-hybrid': { action: 'render-animate-hybrid', leadArgKey: 'id', passRest: true, usage: '!animate-hybrid [backends:...]' }, adopt: { action: 'render-adopt', passRest: true, usage: '!adopt ' }, 'add-audio': { action: 'render-add-audio', args(rest) { const [id, ...keyParts] = rest.trim().split(/\s+/); if (!id || !keyParts.length) return null; return `id:${id} audioKey:${keyParts.join(' ')}`; }, usage: '!add-audio ', }, 'add-narration': { action: 'render-add-narration', args(rest) { const [id, ...textParts] = rest.trim().split(/\s+/); if (!id || !textParts.length) return null; return `id:${id} text:${textParts.join(' ')}`; }, usage: '!add-narration ', }, 'delete-render': { action: 'render-delete', argKey: 'id', usage: '!delete-render ' }, 'patch-render': { action: 'render-patch', leadArgKey: 'id', passRest: true, usage: '!patch-render label:...' }, deleteproject: { action: 'project-delete', argKey: 'id', usage: '!deleteproject ' }, patchproject: { action: 'project-patch', leadArgKey: 'id', passRest: true, usage: '!patchproject name:...' }, castget: { action: 'cast-get', argKey: 'id', usage: '!castget ' }, castcreate: { action: 'cast-create', passRest: true, usage: '!castcreate name: bible:' }, castdelete: { action: 'cast-delete', argKey: 'id', usage: '!castdelete ' }, exportcast: { action: 'cast-export-url', argKey: 'id', usage: '!exportcast ' }, analyze: { action: 'audio-analyze', passRest: true, usage: '!analyze audioKey:' }, 'audio-analyze': { action: 'audio-analyze', passRest: true }, }; /** Build !api args from a studio command alias definition. */ export function aliasArgs(alias, rest) { if (alias.args) { const built = alias.args(rest); return built === null ? undefined : built; } if (alias.restKey) { return rest ? `${alias.restKey}:${rest}` : ''; } if (alias.argKey) { const id = rest.trim().split(/\s+/)[0]; return id ? `${alias.argKey}:${id}` : undefined; } if (alias.leadArgKey) { const [id, ...more] = rest.trim().split(/\s+/); if (!id) return undefined; const tail = more.join(' ').trim(); return tail ? `${alias.leadArgKey}:${id} ${tail}` : `${alias.leadArgKey}:${id}`; } if (alias.passRest) return rest; return ''; } export function routeById(id) { return CONTRACT_ROUTES.find((r) => r.id === id); } export function routeForAction(action) { return CONTRACT_ROUTES.find((r) => r.actions.includes(action)); } export function routeForStudioFn(fn) { return CONTRACT_ROUTES.find((r) => { const fns = Array.isArray(r.studioFn) ? r.studioFn : [r.studioFn]; return fns.includes(fn); }); } /** Human-readable conformance matrix for Discord or docs. */ export function formatConformanceReport({ compact = false } = {}) { const lines = [ `**Vivijure studio API conformance** (${CONTRACT_ROUTE_COUNT} routes: ${CONTRACT_MD_ROUTE_COUNT} CONTRACT + supplements)`, 'Source: `vivijure/docs/CONTRACT.md` section 2.1 + control-panel parity\n', ]; for (const r of CONTRACT_ROUTES) { const fns = Array.isArray(r.studioFn) ? r.studioFn.join('|') : r.studioFn; const surfaces = [ ...r.commands, ...r.actions.map((a) => `!api ${a}`), ].filter(Boolean); if (compact) { lines.push(`**#${r.id}** \`${r.method} ${r.path}\` -> ${surfaces[0] || '(none)'}${surfaces.length > 1 ? ` (+${surfaces.length - 1})` : ''}`); } else { lines.push(`### #${r.id} ${r.method} ${r.path}`); lines.push(`studio: \`${fns}\` | actions: ${r.actions.map((a) => `\`${a}\``).join(', ') || '*(attachment)*'}`); lines.push(`commands: ${surfaces.join(', ')}`); if (r.notes) lines.push(`_${r.notes}_`); lines.push(''); } } lines.push('\nRun `npx vitest run contract.test.ts` in CI to assert this matrix stays current.'); return lines.join('\n'); } /** * Validate studio.mjs + studio-api.mjs against CONTRACT_ROUTES. * Returns { ok, errors }. */ export function validateContractConformance(studioExports, studioActions) { const errors = []; const actionKeys = new Set(Object.keys(studioActions || {}).filter((k) => k !== 'help')); if (CONTRACT_ROUTES.length !== STUDIO_API_ROUTE_COUNT) { errors.push(`CONTRACT_ROUTES length is ${CONTRACT_ROUTES.length}, expected ${STUDIO_API_ROUTE_COUNT}`); } const contractMdRoutes = CONTRACT_ROUTES.filter((r) => r.id <= CONTRACT_MD_ROUTE_COUNT); if (contractMdRoutes.length !== CONTRACT_MD_ROUTE_COUNT) { errors.push(`expected ${CONTRACT_MD_ROUTE_COUNT} CONTRACT.md routes, found ${contractMdRoutes.length}`); } for (const route of CONTRACT_ROUTES) { const fns = Array.isArray(route.studioFn) ? route.studioFn : [route.studioFn]; for (const fn of fns) { if (typeof studioExports[fn] !== 'function' && typeof studioExports[fn] !== 'string') { errors.push(`#${route.id} missing studio.mjs export: ${fn}`); } } const hasSurface = route.commands.length > 0 || route.actions.length > 0; if (!hasSurface) { errors.push(`#${route.id} has no Slate command or !api action`); } for (const action of route.actions) { if (!actionKeys.has(action)) { errors.push(`#${route.id} missing STUDIO_ACTIONS.${action}`); } } } for (const action of actionKeys) { if (!routeForAction(action)) { errors.push(`STUDIO_ACTIONS.${action} is not mapped to any studio route`); } } const infra = new Set([ 'studioRequest', 'studioGet', 'studioPost', 'studioPatch', 'studioDelete', 'studioUploadBinary', 'setStudioRequestObserver', // slate#90: traffic-ledger/RAG observer hook, not an HTTP route ]); for (const [name, exp] of Object.entries(studioExports)) { if (infra.has(name)) continue; if (typeof exp !== 'function' && typeof exp !== 'string') continue; if (!routeForStudioFn(name)) { errors.push(`studio.mjs export ${name} is not mapped in CONTRACT_ROUTES`); } } for (const [cmd, alias] of Object.entries(STUDIO_COMMAND_ALIASES)) { if (alias.action && !studioActions[alias.action] && alias.action !== 'help') { errors.push(`STUDIO_COMMAND_ALIASES.${cmd} points to missing action ${alias.action}`); } } return { ok: errors.length === 0, errors }; }