diff --git a/README.md b/README.md index c339a132..2fb3fbe2 100644 --- a/README.md +++ b/README.md @@ -1,73 +1,114 @@ # Codex Studio -> Estudio de imágenes local-first que usa tu sesión autenticada de Codex/ChatGPT — sin `OPENAI_API_KEY` para el flujo principal. +> A local-first image generation studio powered by your authenticated Codex/ChatGPT session — no `OPENAI_API_KEY` required for the main workflow. [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](./LICENSE) [![Bun](https://img.shields.io/badge/runtime-Bun-black?logo=bun)](https://bun.sh) [![TypeScript](https://img.shields.io/badge/TypeScript-5.x-blue?logo=typescript)](https://www.typescriptlang.org/) +[![Status](https://img.shields.io/badge/status-open--source%20preview-7c3aed)](#open-source-readiness-checklist) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-22c55e)](./CONTRIBUTING.md) -**Estado actual: preview open-source temprana.** La base técnica ya funciona bien en local. El foco ahora es dejar onboarding, documentación y DX en nivel “instalable en minutos”. +Codex Studio is an open-source image creation environment built for fast local iteration, reliable job history, and future workflow expansion. Today it is Codex-first. Over time, it is designed to support multiple workflow types and providers behind a consistent studio UX. -## Ruta rápida +## Who this is for -1. Instala dependencias y prepara la librería local: - - `bun install` - - `bun run studio:init` -2. Arranca el entorno: - - `bun run dev` -3. Verifica que todo responde: - - UI: - - API local: +- Creative developers building image pipelines locally +- Technical artists who need repeatable generation/editing workflows +- Product teams prototyping AI-assisted visual tooling +- Open-source contributors interested in workflow-driven studio systems -## ¿Por qué llama la atención este proyecto? +## Quick start -- **No exige API key** para el flujo principal con Codex. -- **Aprovecha tu sesión local** autenticada de Codex/ChatGPT. -- **Cola persistente de jobs** con trazabilidad sobre SQLite. -- **Assets, logs y transcripts fuera del repo**, en una Studio Library configurable. -- **UI creativa completa**: recetas, workspaces, grid visual y herramientas de revisión. -- **Arquitectura extensible** con frontera de proveedores (Codex-first). +1. Install dependencies and initialize your local studio library. + - `bun install` + - `bun run studio:init` +2. Start the development environment. + - `bun run dev` +3. Confirm everything is healthy. + - UI: + - Local API health: -## Cómo funciona +## Screenshots -1. La UI React/Vite recibe prompts, recetas e imágenes de referencia. -2. El backend local Bun/Hono crea y supervisa jobs persistentes. -3. `codex app-server` ejecuta turns reales para generar/editar imágenes. -4. La Studio Library guarda assets, SQLite, transcripts y logs. -5. La UI sincroniza por HTTP + SSE y mantiene compatibilidad visual para seguir operativa. +### Studio workspace -## Requisitos +![Codex Studio workspace screenshot](./docs/assets/screenshots/studio-view.png) -- **Bun** en PATH — [bun.sh](https://bun.sh) -- **Codex CLI** instalado y autenticado con login de ChatGPT en la misma máquina. -- Soporte de `codex app-server` en esa instalación. -- Navegador moderno con IndexedDB. +### Recipes view -Si falta Codex o la sesión local, la UI puede abrir pero no completará generaciones reales. Ver [`docs/TROUBLESHOOTING.md`](./docs/TROUBLESHOOTING.md). +![Codex Studio recipes view screenshot](./docs/assets/screenshots/recipes-view.png) -## Configuración local +## Why this project -El backend toma variables desde `.env.local`. Puedes dejar que `bun run studio:init` lo cree o copiar `.env.example`. +- **No API key required** for the primary Codex flow. +- **Uses your local authenticated Codex/ChatGPT session**. +- **Persistent job queue + traceability** backed by SQLite. +- **Library-backed storage** keeps assets, logs, and transcripts outside the repo. +- **Full creative UI** with recipes, workspaces, visual grid, and review tools. +- **Extensible architecture** designed for multi-workflow evolution. -Variables principales: +## Product direction: image studio + evolving workflows + +Codex Studio starts with a strong image-generation core and a clear path to broader workflow support. + +| Scope | Current status | Direction | +|------|----------------|-----------| +| Image generation/editing | Production-ready locally | Continue hardening and quality improvements | +| Workflow model | Codex-first runtime | Add more workflow types over time | +| Provider boundary | Adapter-based | Expand provider compatibility without UI rewrites | +| Studio UX | Unified queue + review surfaces | Keep one coherent UX across workflow families | + +## Open-source launch positioning + +Codex Studio is being prepared as an open-source platform for image-first creation that can evolve into a multi-workflow studio runtime. + +| Pillar | What this means in practice | +|------|------------------------------| +| Image-first excellence | Prioritize quality, speed, and reliability for image generation/editing | +| Workflow extensibility | Introduce new workflow families without fragmenting UX | +| Local-first reliability | Keep durable history, assets, and logs under your control | +| Contributor-friendly architecture | Clear boundaries, shared contracts, and docs that reduce onboarding time | + +## How it works + +1. React/Vite UI collects prompts, recipes, and reference assets. +2. Bun/Hono local server creates and supervises persistent jobs. +3. `codex app-server` executes real turns for image generation/editing. +4. Studio Library stores assets, SQLite data, transcripts, and logs. +5. UI syncs via HTTP + SSE and maintains compatibility surfaces for continuity. + +## Requirements + +- **Bun** in PATH — [bun.sh](https://bun.sh) +- **Codex CLI** installed and authenticated with ChatGPT on the same machine +- `codex app-server` support in that installation +- A modern browser with IndexedDB support + +If Codex or local session auth is missing, the UI may open but real generations will not complete. See [`docs/TROUBLESHOOTING.md`](./docs/TROUBLESHOOTING.md). + +## Local configuration + +The backend reads variables from `.env.local`. You can let `bun run studio:init` generate it, or copy from `.env.example`. + +Primary variables: - `STUDIO_LIBRARY_DIR` - `STUDIO_SERVER_PORT` - `STUDIO_CODEX_WS_PORT` - `VITE_STUDIO_API_BASE` -Variables opcionales para shell de Electron: +Optional variables for Electron shell: - `STUDIO_ELECTRON_API_BASE` - `STUDIO_ELECTRON_RENDERER_URL` -Ejemplos de ruta de librería: +Example library paths: - Windows: `%USERPROFILE%\AI-Studio-Library` -- macOS: `/Users//AI-Studio-Library` -- Linux: `/home//AI-Studio-Library` +- macOS: `/Users//AI-Studio-Library` +- Linux: `/home//AI-Studio-Library` -## Scripts útiles +## Useful scripts ```bash bun run dev @@ -84,17 +125,17 @@ bun run validate:fast bun run validate:full ``` -## Detalles clave +## Core technical decisions -| Tema | Decisión | +| Topic | Decision | |------|----------| -| Fuente de verdad durable | `SQLite + Image Catalog` | -| Cache visual compatible | `GenerationBatch[]` en IndexedDB (solo compatibilidad) | -| Eventos en vivo | `GET /api/events` (SSE) | -| Sesión local canónica | `/api/codex/session` | -| Filosofía de producto | Codex-first, local-first, library-backed | +| Durable source of truth | `SQLite + Image Catalog` | +| Compatibility visual cache | `GenerationBatch[]` in IndexedDB (compatibility-only) | +| Live events | `GET /api/events` (SSE) | +| Canonical local session endpoint | `/api/codex/session` | +| Product philosophy | Codex-first, local-first, library-backed | -## Estructura del repositorio +## Repository layout ```text . @@ -108,24 +149,29 @@ bun run validate:full └─ services/ ``` -## Documentación principal +## Documentation map + +- [`CONTEXT.md`](./CONTEXT.md) — canonical domain vocabulary +- [`AGENTS.md`](./AGENTS.md) — operational rules for agents +- [`SKILLS.md`](./SKILLS.md) — specialized workflow guides +- [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md) — current architecture +- [`docs/SERVICES.md`](./docs/SERVICES.md) — service and integration map +- [`docs/DEV_GUIDE.md`](./docs/DEV_GUIDE.md) — development conventions +- [`docs/TOOLING.md`](./docs/TOOLING.md) — tooling and quality commands +- [`docs/TROUBLESHOOTING.md`](./docs/TROUBLESHOOTING.md) — quick diagnostics + +## Open-source readiness checklist -- [`CONTEXT.md`](./CONTEXT.md) — vocabulario canónico del dominio. -- [`AGENTS.md`](./AGENTS.md) — reglas operativas para agentes. -- [`SKILLS.md`](./SKILLS.md) — flujos especializados. -- [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md) — arquitectura vigente. -- [`docs/SERVICES.md`](./docs/SERVICES.md) — mapa de servicios e integraciones. -- [`docs/DEV_GUIDE.md`](./docs/DEV_GUIDE.md) — convenciones de desarrollo. -- [`docs/TOOLING.md`](./docs/TOOLING.md) — comandos y calidad. -- [`docs/TROUBLESHOOTING.md`](./docs/TROUBLESHOOTING.md) — diagnóstico rápido. +- [ ] `bun run studio:init` completes successfully +- [ ] `bun run dev` starts UI + backend +- [ ] `GET /api/health` returns healthy response +- [ ] UI opens and shows readiness status +- [ ] `CONTRIBUTING.md`, `SECURITY.md`, and `CODE_OF_CONDUCT.md` are reviewed before publishing -## Checklist de validación rápida +## Contributing -- [ ] `bun run studio:init` completa sin errores. -- [ ] `bun run dev` levanta UI + backend. -- [ ] `GET /api/health` responde correctamente. -- [ ] Puedes abrir la UI y ver el estado de readiness. +Contributions are welcome. Start with [`CONTRIBUTING.md`](./CONTRIBUTING.md), then review [`ROADMAP.md`](./ROADMAP.md) for product priorities. -## Próximo paso +## License -Si quieres contribuir, empieza por [`CONTRIBUTING.md`](./CONTRIBUTING.md). Si quieres entender prioridades de producto, sigue en [`ROADMAP.md`](./ROADMAP.md). +This project is licensed under the [MIT License](./LICENSE). diff --git a/SKILLS.md b/SKILLS.md index ad52cb11..ed9522bb 100644 --- a/SKILLS.md +++ b/SKILLS.md @@ -109,6 +109,22 @@ bun run test -- packages/shared/src/generationContracts.test.ts apps/local-serve bun run check -- packages/shared/src/generationContracts.ts packages/shared/src/generationContracts.test.ts apps/local-server/src/jobRoutes.ts apps/local-server/src/jobRoutes.test.ts ``` +## Mejorar calidad de generación + +1. Keep quality semantics in provider-independent `Generation Task Spec.quality`, not React surfaces. +2. Use compact quality presets (`image_general`, `image_edit`, `style_reference`, `sprite_sheet`, `texture`, `product_or_ui_asset`) to add intent without restoring huge Recipe Context prompts. +3. Providers should compile quality sections with `composeGenerationQualityPromptSections()` before recipe directives, then keep stable output rules in Provider Session Contract. +4. Do not duplicate the base prompt or `negativePrompt` inside quality fields; only add real structured hints such as style, color, constraints, and reference-role instructions. +5. For live evidence, run dry evaluation first and store only job/catalog/transcript refs plus reviewer notes. + +Focused validation: + +```bash +bun run test -- packages/shared/src/generationContracts.test.ts lib/recipeModules.test.ts apps/local-server/src/providers/codexProvider.test.ts apps/local-server/src/providers/externalProviderInputs.test.ts scripts/evaluate-recipe-prompts.test.ts +bun run providers:verify +bun run recipes:verify +``` + ## Agregar UI o configuración de Settings 1. Ask: is this Bootstrap Configuration, Studio Settings, or Provider Secret? diff --git a/apps/local-server/src/appFactory.test.ts b/apps/local-server/src/appFactory.test.ts new file mode 100644 index 00000000..f88ca471 --- /dev/null +++ b/apps/local-server/src/appFactory.test.ts @@ -0,0 +1,422 @@ +import { describe, expect, it, vi } from 'vite-plus/test'; + +import type { + CodexModelCatalogResponse, + LocalCodexSessionResponse, +} from '../../../packages/shared/src'; +import type { StudioCatalogStore } from './catalogStore'; +import type { StudioDbStore } from './dbStore'; +import { createStudioApp } from './appFactory'; +import type { WorkerController } from './worker'; + +vi.mock('./db', () => ({ + getSettingValue: vi.fn(() => null), + setSettingValue: vi.fn(() => null), +})); + +vi.mock('./logger', () => ({ + log: vi.fn(), +})); + +function createFakeDbStore(overrides?: Partial): StudioDbStore { + const defaultProject = { + id: 'project-default', + name: 'Default Studio Project', + description: null, + createdAt: '2026-05-31T00:00:00.000Z', + updatedAt: '2026-05-31T00:00:00.000Z', + }; + + const store: StudioDbStore = { + ensureDefaultProject: vi.fn(() => defaultProject), + createProject: vi.fn((name: string, description?: string | null) => ({ + id: 'project-created', + name, + description: description ?? null, + createdAt: '2026-05-31T00:00:00.000Z', + updatedAt: '2026-05-31T00:00:00.000Z', + })), + listProjects: vi.fn(() => [defaultProject]), + createJob: vi.fn(() => { + throw new Error('not used in appFactory composition test'); + }), + updateJobFinalPrompt: vi.fn(() => null), + getJob: vi.fn(() => null), + listJobs: vi.fn(() => []), + listAssets: vi.fn(() => []), + listLogs: vi.fn(() => []), + }; + + return { ...store, ...overrides }; +} + +function createFakeCatalogStore(overrides?: Partial): StudioCatalogStore { + const image = { + id: 'catalog-image-1', + libraryId: 'library-1', + filePath: 'D:/library/outputs/image.png', + thumbnailPath: null, + publicUrl: '/library/outputs/image.png', + thumbnailUrl: null, + prompt: 'Prompt', + negativePrompt: null, + aspectRatio: '1:1', + imageSize: '1K', + width: null, + height: null, + mimeType: 'image/png', + fileSizeBytes: null, + jobId: null, + workspaceId: 'default', + batchId: 'batch-1', + recipeId: null, + isFavorite: false, + isDeleted: false, + deletedAt: null, + tags: [], + generationConfig: null, + createdAt: '2026-05-31T00:00:00.000Z', + }; + + const store: StudioCatalogStore = { + getCatalogImage: vi.fn((id: string) => (id === image.id ? image : null)), + queryCatalog: vi.fn(() => ({ images: [image], total: 1, hasMore: false })), + registerCatalogImage: vi.fn(() => image), + updateCatalogImage: vi.fn(() => image), + softDeleteCatalogImage: vi.fn((id: string) => (id === image.id ? image : null)), + restoreCatalogImage: vi.fn(() => image), + purgeCatalogImage: vi.fn((id: string) => (id === image.id ? image : null)), + }; + + return { ...store, ...overrides }; +} + +function createWorkerDependency(): Pick< + WorkerController, + 'cancelQueuedOrRunningJob' | 'enqueueJob' | 'getWorkerStatus' | 'resetWorkerState' +> { + return { + cancelQueuedOrRunningJob: vi.fn(() => null), + enqueueJob: vi.fn(), + getWorkerStatus: vi.fn(() => ({ + maxConcurrentJobs: 2, + activeWorkerCount: 0, + queuedJobs: 0, + trackedJobs: 0, + })), + resetWorkerState: vi.fn(async () => {}), + }; +} + +describe('createStudioApp', () => { + it('wires injected codex and project adapters through mounted routes', async () => { + const dbStore = createFakeDbStore(); + const catalogStore = createFakeCatalogStore(); + const worker = createWorkerDependency(); + const logger = vi.fn(); + + const codexCatalogFixture: CodexModelCatalogResponse = { + models: [ + { + id: 'gpt-image-1', + model: 'gpt-image-1', + displayName: 'GPT Image', + description: null, + hidden: false, + defaultReasoningEffort: null, + supportedReasoningEfforts: [], + additionalSpeedTiers: [], + inputModalities: ['text'], + supportsPersonality: false, + isDefault: true, + }, + ], + authMode: 'chatgpt', + planType: 'pro', + recommendedDefaultModel: 'gpt-image-1', + source: 'fallback', + fetchedAt: '2026-05-31T00:00:00.000Z', + error: null, + }; + + const localSessionFixture: LocalCodexSessionResponse = { + authMode: 'chatgpt', + planType: 'pro', + usage: null, + source: 'fallback', + fetchedAt: '2026-05-31T00:00:00.000Z', + error: null, + authLabel: 'ChatGPT', + state: 'ready', + reason: null, + isChatgptLogin: true, + isSupportedAuthMode: true, + canRunLocalJobs: true, + }; + + const readCodexModelCatalog = vi.fn(async () => codexCatalogFixture); + const readLocalCodexSession = vi.fn(async () => localSessionFixture); + + const studio = await createStudioApp({ + runInit: false, + dependencies: { + dbStore, + catalogStore, + worker, + logger, + readCodexModelCatalog, + readLocalCodexSession, + }, + }); + + const modelsResponse = await studio.app.request('/api/codex/models'); + expect(modelsResponse.status).toBe(200); + await expect(modelsResponse.json()).resolves.toEqual(codexCatalogFixture); + expect(readCodexModelCatalog).toHaveBeenCalledTimes(1); + + const sessionResponse = await studio.app.request('/api/codex/session'); + expect(sessionResponse.status).toBe(200); + await expect(sessionResponse.json()).resolves.toEqual(localSessionFixture); + expect(readLocalCodexSession).toHaveBeenCalledTimes(1); + + const listProjectsResponse = await studio.app.request('/api/projects'); + expect(listProjectsResponse.status).toBe(200); + await expect(listProjectsResponse.json()).resolves.toEqual([ + { + id: 'project-default', + name: 'Default Studio Project', + description: null, + createdAt: '2026-05-31T00:00:00.000Z', + updatedAt: '2026-05-31T00:00:00.000Z', + }, + ]); + + const createProjectResponse = await studio.app.request('/api/projects', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: 'Seam Project', description: 'composition test' }), + }); + expect(createProjectResponse.status).toBe(201); + await expect(createProjectResponse.json()).resolves.toEqual({ + id: 'project-created', + name: 'Seam Project', + description: 'composition test', + createdAt: '2026-05-31T00:00:00.000Z', + updatedAt: '2026-05-31T00:00:00.000Z', + }); + expect(logger).toHaveBeenCalledWith('info', 'api', 'Project created: Seam Project'); + }); + + it('wires catalog command routes through the injected Catalog Entry store', async () => { + const softDeleteCatalogImage = vi.fn((id: string) => + id === 'catalog-image-1' + ? { + id: 'catalog-image-1', + libraryId: 'library-1', + filePath: 'D:/library/outputs/image.png', + thumbnailPath: null, + publicUrl: '/library/outputs/image.png', + thumbnailUrl: null, + prompt: 'Prompt', + negativePrompt: null, + aspectRatio: '1:1', + imageSize: '1K', + width: null, + height: null, + mimeType: 'image/png', + fileSizeBytes: null, + jobId: null, + workspaceId: 'default', + batchId: 'batch-1', + recipeId: null, + isFavorite: false, + isDeleted: true, + deletedAt: '2026-05-31T00:00:00.000Z', + tags: [], + generationConfig: null, + createdAt: '2026-05-31T00:00:00.000Z', + } + : null, + ); + + const catalogStore = createFakeCatalogStore({ + softDeleteCatalogImage, + }); + + const studio = await createStudioApp({ + runInit: false, + dependencies: { + dbStore: createFakeDbStore(), + catalogStore, + worker: createWorkerDependency(), + }, + }); + + const response = await studio.app.request('/api/catalog/catalog-image-1', { + method: 'DELETE', + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual( + expect.objectContaining({ id: 'catalog-image-1', isDeleted: true }), + ); + expect(softDeleteCatalogImage).toHaveBeenCalledWith('catalog-image-1'); + }); + + it('surfaces codex route failures through the composition seam', async () => { + const readCodexModelCatalog = vi.fn(async () => { + throw new Error('catalog unavailable'); + }); + + const studio = await createStudioApp({ + runInit: false, + dependencies: { + dbStore: createFakeDbStore(), + catalogStore: createFakeCatalogStore(), + worker: createWorkerDependency(), + readCodexModelCatalog, + }, + }); + + const modelsResponse = await studio.app.request('/api/codex/models'); + + expect(modelsResponse.status).toBeGreaterThanOrEqual(500); + expect(readCodexModelCatalog).toHaveBeenCalledTimes(1); + }); + + it('wires app-server start route to injected runtime dependencies', async () => { + const ensureAppServer = vi.fn(); + const isAppServerRunning = vi.fn(() => true); + const getAppServerDiagnostics = vi.fn(() => ({ + pid: 4242, + lastStartError: null, + lastEnsureAt: null, + lastEnsureReason: null, + lastExitCode: null, + lastExitAt: null, + lastInvocation: null, + lastStartAt: null, + })); + + const studio = await createStudioApp({ + runInit: false, + dependencies: { + dbStore: createFakeDbStore(), + catalogStore: createFakeCatalogStore(), + worker: createWorkerDependency(), + ensureAppServer, + isAppServerRunning, + getAppServerDiagnostics, + }, + }); + + const response = await studio.app.request('/api/app-server/start', { + method: 'POST', + }); + + expect(response.status).toBe(200); + await expect(response.json()).resolves.toEqual({ + running: true, + wsUrl: expect.any(String), + pid: 4242, + lastStartError: null, + }); + expect(ensureAppServer).toHaveBeenCalledWith('user'); + expect(isAppServerRunning).toHaveBeenCalled(); + expect(getAppServerDiagnostics).toHaveBeenCalled(); + }); + + it('wires runtime health worker status through the injected worker dependency', async () => { + const workerStatus = { + maxConcurrentJobs: 9, + activeWorkerCount: 3, + queuedJobs: 4, + trackedJobs: 7, + }; + const worker = createWorkerDependency(); + worker.getWorkerStatus = vi.fn(() => workerStatus); + + const studio = await createStudioApp({ + runInit: false, + dependencies: { + dbStore: createFakeDbStore(), + catalogStore: createFakeCatalogStore(), + worker, + }, + }); + + const response = await studio.app.request('/api/health'); + expect(response.status).toBe(200); + + const payload = (await response.json()) as { worker: typeof workerStatus }; + expect(payload.worker).toEqual(workerStatus); + expect(worker.getWorkerStatus).toHaveBeenCalledTimes(2); + }); + + it('surfaces runtime start failures through the composition seam', async () => { + const ensureAppServer = vi.fn(() => { + throw new Error('unable to start app-server'); + }); + + const studio = await createStudioApp({ + runInit: false, + dependencies: { + dbStore: createFakeDbStore(), + catalogStore: createFakeCatalogStore(), + worker: createWorkerDependency(), + ensureAppServer, + }, + }); + + const response = await studio.app.request('/api/app-server/start', { + method: 'POST', + }); + + expect(response.status).toBeGreaterThanOrEqual(500); + expect(ensureAppServer).toHaveBeenCalledWith('user'); + }); + + it('wires cancel conflict path through injected worker dependency', async () => { + const activeJob = { + id: 'job-active', + projectId: 'project-default', + kind: 'dry_run' as const, + providerId: null, + sourceSpec: null, + status: 'running' as const, + execution: null, + originalPrompt: 'hello', + expandedPrompt: null, + finalPromptUsed: 'hello', + error: null, + createdAt: '2026-05-31T00:00:00.000Z', + updatedAt: '2026-05-31T00:00:00.000Z', + completedAt: null, + }; + + const getJobSpy = vi.fn((id: string) => (id === activeJob.id ? activeJob : null)); + const getJobMock: StudioDbStore['getJob'] = (id: string) => getJobSpy(id); + const dbStore = createFakeDbStore({ getJob: getJobMock }); + const worker = createWorkerDependency(); + const cancelQueuedOrRunningJobMock = vi.fn(() => null); + worker.cancelQueuedOrRunningJob = cancelQueuedOrRunningJobMock; + + const studio = await createStudioApp({ + runInit: false, + dependencies: { + dbStore, + catalogStore: createFakeCatalogStore(), + worker, + }, + }); + + const response = await studio.app.request(`/api/jobs/${activeJob.id}/cancel`, { + method: 'POST', + }); + + expect(response.status).toBe(409); + await expect(response.json()).resolves.toEqual({ error: 'Job cannot be cancelled right now' }); + expect(getJobSpy).toHaveBeenCalledWith(activeJob.id); + expect(cancelQueuedOrRunningJobMock).toHaveBeenCalledWith(activeJob.id); + }); +}); diff --git a/apps/local-server/src/codex/localCodexSession.test.ts b/apps/local-server/src/codex/localCodexSession.test.ts index 71241a28..f409c7e1 100644 --- a/apps/local-server/src/codex/localCodexSession.test.ts +++ b/apps/local-server/src/codex/localCodexSession.test.ts @@ -1,7 +1,26 @@ -import { describe, expect, it } from 'vite-plus/test'; +import { beforeAll, describe, expect, it, vi } from 'vite-plus/test'; import { extractUsageSnapshot, pickRateLimitSnapshot } from './rateLimitUsage'; +vi.mock('./rpcClient', () => ({ + CodexRpcClient: class { + async connect() {} + async request() { + return null; + } + notify() {} + close() {} + }, +})); + +let buildLocalCodexSessionResponse: typeof import('./localCodexSession').buildLocalCodexSessionResponse; +let classifyLocalCodexSessionFallbackReason: typeof import('./localCodexSession').classifyLocalCodexSessionFallbackReason; + +beforeAll(async () => { + ({ buildLocalCodexSessionResponse, classifyLocalCodexSessionFallbackReason } = + await import('./localCodexSession')); +}); + describe('localCodexSession usage parsing', () => { it('picks codex rate limit snapshots from app-server responses', () => { const { snapshot, path } = pickRateLimitSnapshot({ @@ -69,3 +88,37 @@ describe('localCodexSession usage parsing', () => { }); }); }); + +describe('localCodexSession fallback cause taxonomy', () => { + it('maps fallback connection errors to app_server_unavailable', () => { + expect(classifyLocalCodexSessionFallbackReason(new Error('ECONNREFUSED 127.0.0.1'))).toBe( + 'app_server_unavailable', + ); + expect(classifyLocalCodexSessionFallbackReason('websocket timed out while connecting')).toBe( + 'app_server_unavailable', + ); + }); + + it('maps non-network fallback errors to unknown', () => { + expect(classifyLocalCodexSessionFallbackReason(new Error('invalid payload shape'))).toBe( + 'unknown', + ); + expect(classifyLocalCodexSessionFallbackReason(null)).toBe('unknown'); + }); + + it('uses fallbackReason when source is fallback and error is present', () => { + const response = buildLocalCodexSessionResponse({ + authMode: null, + planType: null, + usage: null, + source: 'fallback', + fetchedAt: '2026-05-31T00:00:00.000Z', + error: 'invalid payload shape', + fallbackReason: 'unknown', + }); + + expect(response.state).toBe('unavailable'); + expect(response.reason).toBe('unknown'); + expect(response.canRunLocalJobs).toBe(false); + }); +}); diff --git a/apps/local-server/src/codex/localCodexSession.ts b/apps/local-server/src/codex/localCodexSession.ts index c184ad92..734c375a 100644 --- a/apps/local-server/src/codex/localCodexSession.ts +++ b/apps/local-server/src/codex/localCodexSession.ts @@ -1,6 +1,7 @@ import type { CodexAuthMode, CodexUsageSnapshot, + LocalCodexSessionReason, LocalCodexSessionResponse, } from '../../../../packages/shared/src'; import { CodexRpcClient } from './rpcClient'; @@ -22,6 +23,7 @@ interface LocalCodexSessionBase { source: LocalCodexSessionResponse['source']; fetchedAt: string; error: string | null; + fallbackReason?: Exclude; } const CODEX_CLIENT_INFO = { @@ -38,6 +40,29 @@ function defaultClientFactory(): CodexRpcTransport { return new CodexRpcClient(); } +const APP_SERVER_UNAVAILABLE_PATTERN = + /app-server|connect|connection|econnrefused|socket|websocket|timed out|timeout/i; + +export function normalizeCodexSessionErrorMessage(error: unknown) { + if (error instanceof Error) return error.message; + if (typeof error === 'string') return error; + try { + return JSON.stringify(error); + } catch { + return String(error); + } +} + +export function classifyLocalCodexSessionFallbackReason( + error: unknown, +): Exclude { + const message = normalizeCodexSessionErrorMessage(error); + if (!message || message === 'undefined' || message === 'null') { + return 'unknown'; + } + return APP_SERVER_UNAVAILABLE_PATTERN.test(message) ? 'app_server_unavailable' : 'unknown'; +} + export function resolveCodexAuthMode(account: any): CodexAuthMode { if (!account || typeof account !== 'object') return null; if (account.type === 'apiKey') return 'apikey'; @@ -79,7 +104,8 @@ export function buildLocalCodexSessionResponse( reason = base.error ? 'app_server_unavailable' : null; } else if (base.error) { state = 'unavailable'; - reason = 'app_server_unavailable'; + reason = + base.source === 'fallback' ? (base.fallbackReason ?? 'unknown') : 'app_server_unavailable'; } else { state = 'requires_chatgpt_login'; reason = 'chatgpt_login_required'; @@ -153,13 +179,15 @@ export function createLocalCodexSessionReader({ }); }); } catch (error) { + const errorMessage = normalizeCodexSessionErrorMessage(error); return buildLocalCodexSessionResponse({ authMode: null, planType: null, usage: null, source: 'fallback', fetchedAt: now(), - error: error instanceof Error ? error.message : String(error), + error: errorMessage, + fallbackReason: classifyLocalCodexSessionFallbackReason(error), }); } }; diff --git a/apps/local-server/src/codex/processSupervisor.test.ts b/apps/local-server/src/codex/processSupervisor.test.ts new file mode 100644 index 00000000..17ef6b8e --- /dev/null +++ b/apps/local-server/src/codex/processSupervisor.test.ts @@ -0,0 +1,41 @@ +import { beforeAll, describe, expect, it, vi } from 'vite-plus/test'; + +vi.mock('../config', () => ({ + getCodexWsUrl: () => 'ws://127.0.0.1:4317', +})); + +vi.mock('../codexExecutable', () => ({ + resolveCodexInvocation: () => ['codex', 'app-server'], +})); + +vi.mock('../library', () => ({ + resolveLibraryPath: () => 'D:/studio/logs/app-server.log', +})); + +vi.mock('../logger', () => ({ + log: () => {}, +})); + +let AppServerStartError: typeof import('./processSupervisor').AppServerStartError; +let resolveAppServerProcessStatus: typeof import('./processSupervisor').resolveAppServerProcessStatus; + +beforeAll(async () => { + ({ AppServerStartError, resolveAppServerProcessStatus } = await import('./processSupervisor')); +}); + +describe('processSupervisor', () => { + it('resolves process status from running and last start error fields', () => { + expect(resolveAppServerProcessStatus({ running: true, lastStartError: null })).toBe('running'); + expect(resolveAppServerProcessStatus({ running: false, lastStartError: 'boom' })).toBe('error'); + expect(resolveAppServerProcessStatus({ running: false, lastStartError: null })).toBe('stopped'); + }); + + it('preserves cause on AppServerStartError', () => { + const cause = new Error('spawn failed'); + const error = new AppServerStartError('Failed to start codex app-server', cause); + + expect(error.name).toBe('AppServerStartError'); + expect(error.message).toContain('Failed to start codex app-server'); + expect(error.causeValue).toBe(cause); + }); +}); diff --git a/apps/local-server/src/codex/processSupervisor.ts b/apps/local-server/src/codex/processSupervisor.ts index 816a78ff..fb7f363c 100644 --- a/apps/local-server/src/codex/processSupervisor.ts +++ b/apps/local-server/src/codex/processSupervisor.ts @@ -17,6 +17,18 @@ export interface ProcessInfo { lastStartAt: string | null; } +export type AppServerProcessStatus = ProcessInfo['status']; + +export class AppServerStartError extends Error { + readonly causeValue: unknown; + + constructor(message: string, causeValue: unknown) { + super(message); + this.name = 'AppServerStartError'; + this.causeValue = causeValue; + } +} + export interface ProcessSupervisor { ensureAppServer(reason?: AppServerEnsureReason): Promise; stopAppServer(): Promise; @@ -50,10 +62,25 @@ export function getAppServerDiagnostics() { }; } +export function resolveAppServerProcessStatus({ + running, + lastStartError, +}: { + running: boolean; + lastStartError: string | null; +}): AppServerProcessStatus { + if (running) return 'running'; + if (lastStartError) return 'error'; + return 'stopped'; +} + function currentInfo(): ProcessInfo { return { ...getAppServerDiagnostics(), - status: isAppServerRunning() ? 'running' : diagnostics.lastStartError ? 'error' : 'stopped', + status: resolveAppServerProcessStatus({ + running: isAppServerRunning(), + lastStartError: diagnostics.lastStartError, + }), }; } @@ -90,10 +117,11 @@ export function ensureAppServer(reason: AppServerEnsureReason = 'session') { diagnostics.pid = null; diagnostics.lastStartError = message; log('error', 'app-server', `Failed to start codex app-server: ${message}`); - throw error; + throw new AppServerStartError(`Failed to start codex app-server: ${message}`, error); } - diagnostics.pid = appServerProcess.pid ?? null; + const processHandle = appServerProcess; + diagnostics.pid = processHandle.pid ?? null; const pipeOutput = async (stream: ReadableStream | null) => { if (!stream) return; @@ -104,20 +132,22 @@ export function ensureAppServer(reason: AppServerEnsureReason = 'session') { appendFileSync(logPath, Buffer.from(chunk.value).toString('utf8')); } }; - if (appServerProcess.stdout instanceof ReadableStream) void pipeOutput(appServerProcess.stdout); - if (appServerProcess.stderr instanceof ReadableStream) void pipeOutput(appServerProcess.stderr); + if (processHandle.stdout instanceof ReadableStream) void pipeOutput(processHandle.stdout); + if (processHandle.stderr instanceof ReadableStream) void pipeOutput(processHandle.stderr); log( 'info', 'app-server', - `Started codex app-server on ${getCodexWsUrl()} with ${invocation.join(' ')} (pid ${appServerProcess.pid})`, + `Started codex app-server on ${getCodexWsUrl()} with ${invocation.join(' ')} (pid ${processHandle.pid})`, ); - void appServerProcess.exited.then((code) => { + void processHandle.exited.then((code) => { diagnostics.pid = null; diagnostics.lastExitCode = code; diagnostics.lastExitAt = new Date().toISOString(); log('warn', 'app-server', `codex app-server exited with code ${code}`); - appServerProcess = null; + if (appServerProcess === processHandle) { + appServerProcess = null; + } }); } diff --git a/apps/local-server/src/codex/rpcClient.test.ts b/apps/local-server/src/codex/rpcClient.test.ts new file mode 100644 index 00000000..0781fcc2 --- /dev/null +++ b/apps/local-server/src/codex/rpcClient.test.ts @@ -0,0 +1,57 @@ +import { beforeAll, describe, expect, it, vi } from 'vite-plus/test'; + +vi.mock('../config', () => ({ + getCodexWsUrl: () => 'ws://127.0.0.1:4317', +})); + +vi.mock('./processSupervisor', () => ({ + ensureAppServer: () => {}, +})); + +let CodexRpcClient: typeof import('./rpcClient').CodexRpcClient; + +beforeAll(async () => { + ({ CodexRpcClient } = await import('./rpcClient')); +}); + +describe('CodexRpcClient', () => { + it('resolves waitForNotification from buffered notifications', async () => { + const client = new CodexRpcClient({ ensureAppServer: () => {} }); + (client as any).handleMessage( + JSON.stringify({ method: 'turn/completed', params: { turn: { id: 'turn-1' } } }), + ); + + const notification = await client.waitForNotification( + (message) => message.method === 'turn/completed', + 100, + ); + + expect(notification.method).toBe('turn/completed'); + }); + + it('resolves waitForNotification when a matching message arrives later', async () => { + const client = new CodexRpcClient({ ensureAppServer: () => {} }); + const waiting = client.waitForNotification((message) => message.method === 'job.progress', 500); + + setTimeout(() => { + (client as any).handleMessage( + JSON.stringify({ method: 'job.progress', params: { id: 'job-1' } }), + ); + }, 10); + + await expect(waiting).resolves.toMatchObject({ method: 'job.progress' }); + }); + + it('rejects waitForNotification on timeout', async () => { + const client = new CodexRpcClient({ ensureAppServer: () => {} }); + const waiting = client.waitForNotification((message) => message.method === 'never', 20); + await expect(waiting).rejects.toThrow('Timed out waiting for Codex notification'); + }); + + it('rejects waiters when client closes', async () => { + const client = new CodexRpcClient({ ensureAppServer: () => {} }); + const waiting = client.waitForNotification((message) => message.method === 'never', 2000); + client.close(); + await expect(waiting).rejects.toThrow('Codex app-server socket closed'); + }); +}); diff --git a/apps/local-server/src/codex/rpcClient.ts b/apps/local-server/src/codex/rpcClient.ts index c43d92d1..ca1deee8 100644 --- a/apps/local-server/src/codex/rpcClient.ts +++ b/apps/local-server/src/codex/rpcClient.ts @@ -44,7 +44,12 @@ export class CodexRpcClient { { resolve: (value: any) => void; reject: (error: Error) => void } >(); private notifications: JsonRpcMessage[] = []; - private notificationWaiters = new Set<(error: Error) => void>(); + private notificationListeners = new Set<{ + predicate: (message: JsonRpcMessage) => boolean; + resolve: (message: JsonRpcMessage) => void; + reject: (error: Error) => void; + timeout: ReturnType; + }>(); constructor({ ensureAppServer: ensureAppServerFn = ensureAppServer, @@ -94,10 +99,7 @@ export class CodexRpcClient { pending.reject(error); } this.pending.clear(); - for (const reject of this.notificationWaiters) { - reject(error); - } - this.notificationWaiters.clear(); + this.rejectNotificationListeners(error); }); resolve(); }); @@ -129,27 +131,24 @@ export class CodexRpcClient { if (existing) return Promise.resolve(existing); return new Promise((resolve, reject) => { - const started = Date.now(); - const handleSocketClose = (error: Error) => { - clearInterval(interval); - this.notificationWaiters.delete(handleSocketClose); - reject(error); + const listener = { + predicate, + resolve: (message: JsonRpcMessage) => { + clearTimeout(listener.timeout); + this.notificationListeners.delete(listener); + resolve(message); + }, + reject: (error: Error) => { + clearTimeout(listener.timeout); + this.notificationListeners.delete(listener); + reject(error); + }, + timeout: setTimeout(() => { + listener.reject(new Error('Timed out waiting for Codex notification')); + }, timeoutMs), }; - const interval = setInterval(() => { - const match = this.notifications.find(predicate); - if (match) { - clearInterval(interval); - this.notificationWaiters.delete(handleSocketClose); - resolve(match); - return; - } - if (Date.now() - started > timeoutMs) { - clearInterval(interval); - this.notificationWaiters.delete(handleSocketClose); - reject(new Error('Timed out waiting for Codex notification')); - } - }, 250); - this.notificationWaiters.add(handleSocketClose); + + this.notificationListeners.add(listener); }); } @@ -162,10 +161,18 @@ export class CodexRpcClient { } close() { + this.rejectNotificationListeners(new Error('Codex app-server socket closed')); this.socket?.close(); this.socket = null; } + private rejectNotificationListeners(error: Error) { + for (const listener of this.notificationListeners) { + listener.reject(error); + } + this.notificationListeners.clear(); + } + private handleMessage(raw: string) { let message: JsonRpcMessage; try { @@ -186,6 +193,11 @@ export class CodexRpcClient { } this.notifications.push(message); + for (const listener of [...this.notificationListeners]) { + if (listener.predicate(message)) { + listener.resolve(message); + } + } } } diff --git a/apps/local-server/src/codex/runtimePolicy.test.ts b/apps/local-server/src/codex/runtimePolicy.test.ts new file mode 100644 index 00000000..fac7da80 --- /dev/null +++ b/apps/local-server/src/codex/runtimePolicy.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { isTransientCodexRuntimeErrorMessage, normalizeCodexRetryPolicy } from './runtimePolicy'; + +describe('runtimePolicy', () => { + it('normalizes retry policy values', () => { + expect(normalizeCodexRetryPolicy({})).toEqual({ maxAttempts: 2, retryDelayMs: 1500 }); + expect(normalizeCodexRetryPolicy({ maxAttempts: 0, retryDelayMs: -5 })).toEqual({ + maxAttempts: 1, + retryDelayMs: 0, + }); + expect(normalizeCodexRetryPolicy({ maxAttempts: 3.7, retryDelayMs: 250.8 })).toEqual({ + maxAttempts: 3, + retryDelayMs: 250, + }); + }); + + it('classifies transient Codex runtime messages', () => { + expect(isTransientCodexRuntimeErrorMessage('stream disconnected during turn')).toBe(true); + expect(isTransientCodexRuntimeErrorMessage('Timed out waiting for Codex notification')).toBe( + true, + ); + expect(isTransientCodexRuntimeErrorMessage('socket is not open')).toBe(true); + expect(isTransientCodexRuntimeErrorMessage('validation failed')).toBe(false); + }); +}); diff --git a/apps/local-server/src/codex/runtimePolicy.ts b/apps/local-server/src/codex/runtimePolicy.ts new file mode 100644 index 00000000..d34535d9 --- /dev/null +++ b/apps/local-server/src/codex/runtimePolicy.ts @@ -0,0 +1,28 @@ +export interface CodexRetryPolicy { + maxAttempts: number; + retryDelayMs: number; +} + +export const DEFAULT_CODEX_RETRY_POLICY: CodexRetryPolicy = { + maxAttempts: 2, + retryDelayMs: 1500, +}; + +export function normalizeCodexRetryPolicy(policy: Partial): CodexRetryPolicy { + return { + maxAttempts: Math.max( + 1, + Math.floor(policy.maxAttempts ?? DEFAULT_CODEX_RETRY_POLICY.maxAttempts), + ), + retryDelayMs: Math.max( + 0, + Math.floor(policy.retryDelayMs ?? DEFAULT_CODEX_RETRY_POLICY.retryDelayMs), + ), + }; +} + +export function isTransientCodexRuntimeErrorMessage(message: string) { + return /stream disconnected|Timed out waiting for Codex notification|thread.+not found|unknown thread|invalid thread|socket is not open|socket closed|websocket/i.test( + message, + ); +} diff --git a/apps/local-server/src/codex/turn.ts b/apps/local-server/src/codex/turn.ts index 2ed8366f..771fcd86 100644 --- a/apps/local-server/src/codex/turn.ts +++ b/apps/local-server/src/codex/turn.ts @@ -7,6 +7,11 @@ import { createAssetExtractor, type AssetExtractor } from './assetExtractor'; import { resolveJobExecutionOptions } from './executionOptions'; import { resolveCodexImagegenSessionIdentity } from './sessionIdentity'; import { buildCodexImagegenTurnInput } from './turnInput'; +import { + DEFAULT_CODEX_RETRY_POLICY, + isTransientCodexRuntimeErrorMessage, + normalizeCodexRetryPolicy, +} from './runtimePolicy'; import { closeImagegenSession, getImagegenSession, @@ -50,6 +55,8 @@ export interface CodexTurnDependencies { imagegenSkillPath?: string; logger?: typeof log; sleep?: (durationMs: number) => Promise; + maxAttempts?: number; + retryDelayMs?: number; } function createAbortError() { @@ -124,6 +131,8 @@ interface ResolvedCodexTurnDependencies { imagegenSkillPath: string; logger: typeof log; sleep: (durationMs: number) => Promise; + maxAttempts: number; + retryDelayMs: number; } async function runCodexImagegenTurn( @@ -273,8 +282,12 @@ async function runImagegenJob( }); const { sessionKey, reusable: reusableSession } = sessionIdentity; let lastError: unknown = null; + const retryPolicy = normalizeCodexRetryPolicy({ + maxAttempts: dependencies.maxAttempts, + retryDelayMs: dependencies.retryDelayMs, + }); - for (let attempt = 1; attempt <= 2; attempt += 1) { + for (let attempt = 1; attempt <= retryPolicy.maxAttempts; attempt += 1) { let runResult!: TurnResult; const session = await dependencies.getSession(sessionKey, job.execution); const run = session.queue.then(async () => { @@ -315,18 +328,15 @@ async function runImagegenJob( lastError = error; if (isAbortError(error)) throw error; const message = error instanceof Error ? error.message : String(error); - const retryable = - /stream disconnected|Timed out waiting for Codex notification|thread.+not found|unknown thread|invalid thread|socket is not open|socket closed|websocket/i.test( - message, - ); - if (!retryable || attempt === 2) throw error; + const retryable = isTransientCodexRuntimeErrorMessage(message); + if (!retryable || attempt === retryPolicy.maxAttempts) throw error; dependencies.logger( 'warn', 'codex-session', `Retrying ${job.id} after transient Codex failure on ${sessionKey}: ${message}`, job.id, ); - await dependencies.sleep(1_500); + await dependencies.sleep(retryPolicy.retryDelayMs); } } @@ -349,6 +359,8 @@ export function createCodexTurn({ ), logger = log, sleep = (durationMs: number) => Bun.sleep(durationMs), + maxAttempts = DEFAULT_CODEX_RETRY_POLICY.maxAttempts, + retryDelayMs = DEFAULT_CODEX_RETRY_POLICY.retryDelayMs, }: CodexTurnDependencies = {}): CodexTurn { const dependencies: ResolvedCodexTurnDependencies = { createAssetExtractor: createAssetExtractorFn, @@ -361,6 +373,8 @@ export function createCodexTurn({ imagegenSkillPath, logger, sleep, + maxAttempts, + retryDelayMs, }; return { diff --git a/apps/local-server/src/eventStreamRoutes.test.ts b/apps/local-server/src/eventStreamRoutes.test.ts index 34489c7f..8346283c 100644 --- a/apps/local-server/src/eventStreamRoutes.test.ts +++ b/apps/local-server/src/eventStreamRoutes.test.ts @@ -1,7 +1,19 @@ import { describe, expect, it, vi } from 'vite-plus/test'; -import { createEventStreamRoutes } from './eventStreamRoutes'; +import { + EVENT_STREAM_KEEPALIVE_MS, + createEventStreamRoutes, + createServerConnectedEvent, +} from './eventStreamRoutes'; describe('eventStreamRoutes', () => { + it('exports connected event helper and keepalive interval', () => { + const event = createServerConnectedEvent(); + expect(event.type).toBe('server.connected'); + expect(event.payload).toEqual({ ok: true }); + expect(typeof event.createdAt).toBe('string'); + expect(EVENT_STREAM_KEEPALIVE_MS).toBe(10_000); + }); + it('returns SSE handshake payload and no-buffering header', async () => { const subscribeEvents = vi.fn(() => () => true); const routes = createEventStreamRoutes({ diff --git a/apps/local-server/src/eventStreamRoutes.ts b/apps/local-server/src/eventStreamRoutes.ts index 848555ea..6cbe14bf 100644 --- a/apps/local-server/src/eventStreamRoutes.ts +++ b/apps/local-server/src/eventStreamRoutes.ts @@ -2,6 +2,16 @@ import { Hono } from 'hono'; import { streamSSE } from 'hono/streaming'; import type { subscribeEvents } from './events'; +export const EVENT_STREAM_KEEPALIVE_MS = 10_000; + +export function createServerConnectedEvent() { + return { + type: 'server.connected', + payload: { ok: true }, + createdAt: new Date().toISOString(), + }; +} + interface EventStreamRoutesDependencies { subscribeEvents: typeof subscribeEvents; } @@ -16,6 +26,7 @@ export function createEventStreamRoutes({ subscribeEvents }: EventStreamRoutesDe let cleanedUp = false; const send = (event: unknown) => { + if (stream.aborted) return; void stream.writeSSE({ data: JSON.stringify(event), }); @@ -39,20 +50,14 @@ export function createEventStreamRoutes({ subscribeEvents }: EventStreamRoutesDe c.req.raw.signal.addEventListener('abort', abort, { once: true }); try { - await stream.writeSSE({ - data: JSON.stringify({ - type: 'server.connected', - payload: { ok: true }, - createdAt: new Date().toISOString(), - }), - }); + await stream.writeSSE({ data: JSON.stringify(createServerConnectedEvent()) }); while (!stream.aborted) { if (stream.aborted) { break; } - await stream.sleep(10_000); + await stream.sleep(EVENT_STREAM_KEEPALIVE_MS); await stream.write(`: keep-alive ${Date.now()}\n\n`); } } finally { diff --git a/apps/local-server/src/jobRoutes.test.ts b/apps/local-server/src/jobRoutes.test.ts index 7b513e38..59654a1f 100644 --- a/apps/local-server/src/jobRoutes.test.ts +++ b/apps/local-server/src/jobRoutes.test.ts @@ -304,4 +304,50 @@ describe('jobRoutes', () => { }); expect(enqueueJob).not.toHaveBeenCalled(); }); + + it('rejects malformed JSON and invalid boundary payloads', async () => { + const enqueueJob = vi.fn(); + const routes = createJobRoutes({ + listJobs: () => [], + getJob: () => null, + getJobDetail: async () => null, + cancelQueuedOrRunningJob: () => null, + ensureDefaultProjectId: () => 'project-default', + createJobId: () => 'job-new', + createJob: () => createJob({ id: 'job-new' }), + updateJobFinalPrompt: () => null, + processReferences: async () => ({ + augmentedPrompt: 'draw a lighthouse', + persistedRefs: [], + }), + hydrateSourceSpecAssetPaths: (sourceSpec) => sourceSpec, + readLibraryDir: () => 'D:/library', + resolveProviderExecutionBlocker: () => null, + isReferenceProcessingError, + publishEvent, + logJobCreated: () => {}, + enqueueJob, + }); + + const malformedJson = await routes.request('/', { + method: 'POST', + body: '{"kind":"codex_imagegen",', + headers: { 'Content-Type': 'application/json' }, + }); + expect(malformedJson.status).toBe(400); + await expect(malformedJson.json()).resolves.toMatchObject({ + code: 'invalid_json', + }); + + const invalidPayload = await routes.request('/', { + method: 'POST', + body: JSON.stringify({ kind: 123, prompt: 'draw a lighthouse' }), + headers: { 'Content-Type': 'application/json' }, + }); + expect(invalidPayload.status).toBe(400); + await expect(invalidPayload.json()).resolves.toMatchObject({ + code: 'invalid_request_body', + }); + expect(enqueueJob).not.toHaveBeenCalled(); + }); }); diff --git a/apps/local-server/src/jobRoutes.ts b/apps/local-server/src/jobRoutes.ts index 66690533..2495c72f 100644 --- a/apps/local-server/src/jobRoutes.ts +++ b/apps/local-server/src/jobRoutes.ts @@ -1,4 +1,5 @@ import { Hono } from 'hono'; +import { Either, Schema } from 'effect'; import type { CreateJobRequest, GenerationTaskSpec, @@ -55,6 +56,38 @@ interface JobRoutesDependencies { enqueueJob: (job: Job) => void; } +const CreateJobRequestBoundarySchema = Schema.Struct({ + projectId: Schema.optional(Schema.String), + kind: Schema.Union( + Schema.Literal('dry_run'), + Schema.Literal('codex_imagegen'), + Schema.Literal('image_generate'), + Schema.Literal('image_edit'), + Schema.Literal('style_preset_card'), + Schema.Literal('sprite_sheet'), + Schema.Literal('texture_generate'), + ), + providerId: Schema.optional(Schema.Union(Schema.String, Schema.Null)), + sourceSpec: Schema.optional(Schema.Union(Schema.Unknown, Schema.Null)), + prompt: Schema.optional(Schema.String), + execution: Schema.optional(Schema.Union(Schema.Unknown, Schema.Null)), + references: Schema.optional( + Schema.Array( + Schema.Struct({ + name: Schema.String, + dataUrl: Schema.String, + strength: Schema.Number, + }), + ), + ), +}); + +type CreateJobRequestBoundary = Schema.Schema.Type; + +function decodeCreateJobRequestBoundary(body: unknown) { + return Schema.decodeUnknownEither(CreateJobRequestBoundarySchema)(body); +} + function shouldRequireLocalRunIds(sourceSpec: GenerationTaskSpec | null) { const metadata = sourceSpec?.metadata && @@ -134,7 +167,43 @@ export function createJobRoutes({ }); routes.post('/', async (c) => { - const body = (await c.req.json()) as CreateJobRequest; + const rawBody = await c.req + .json() + .catch(() => ({ __invalidJson: true }) as { __invalidJson: true }); + if ('__invalidJson' in rawBody) { + return c.json( + { + error: 'Invalid request body', + code: 'invalid_json', + reason: 'Request body must be valid JSON.', + }, + 400, + ); + } + + const decodedBody = decodeCreateJobRequestBoundary(rawBody); + if (Either.isLeft(decodedBody)) { + return c.json( + { + error: 'Invalid request body', + code: 'invalid_request_body', + reason: 'Request payload does not match CreateJobRequest boundary schema.', + }, + 400, + ); + } + + const boundaryBody: CreateJobRequestBoundary = decodedBody.right; + const body: CreateJobRequest = { + projectId: boundaryBody.projectId, + kind: boundaryBody.kind, + providerId: boundaryBody.providerId, + sourceSpec: boundaryBody.sourceSpec as CreateJobRequest['sourceSpec'], + prompt: boundaryBody.prompt ?? '', + execution: boundaryBody.execution as CreateJobRequest['execution'], + references: boundaryBody.references as CreateJobRequest['references'], + }; + const projectId = body.projectId || ensureDefaultProjectId(); const prompt = (body.prompt || body.sourceSpec?.prompt || '').trim(); if (!prompt) return c.json({ error: 'Prompt is required' }, 400); diff --git a/apps/local-server/src/librariesRoutes.test.ts b/apps/local-server/src/librariesRoutes.test.ts index 11b093cf..6b3dc27a 100644 --- a/apps/local-server/src/librariesRoutes.test.ts +++ b/apps/local-server/src/librariesRoutes.test.ts @@ -1,21 +1,21 @@ -import { describe, expect, it, vi } from "vite-plus/test"; -import type { StudioLibrary } from "./libraries"; -import { createLibrariesRoutes } from "./librariesRoutes"; +import { describe, expect, it, vi } from 'vite-plus/test'; +import type { StudioLibrary } from './libraries'; +import { createLibrariesRoutes } from './librariesRoutes'; function makeLibrary(overrides: Partial = {}): StudioLibrary { return { - id: overrides.id ?? "library-1", - name: overrides.name ?? "Library 1", - path: overrides.path ?? "D:/Library-1", + id: overrides.id ?? 'library-1', + name: overrides.name ?? 'Library 1', + path: overrides.path ?? 'D:/Library-1', isDefault: overrides.isDefault ?? true, - createdAt: overrides.createdAt ?? "2026-05-29T00:00:00.000Z", + createdAt: overrides.createdAt ?? '2026-05-29T00:00:00.000Z', }; } -describe("librariesRoutes", () => { - it("lists and registers libraries through the route seam", async () => { +describe('librariesRoutes', () => { + it('lists and registers libraries through the route seam', async () => { const listed = [makeLibrary()]; - const created = makeLibrary({ id: "library-2", isDefault: false, name: "New Library" }); + const created = makeLibrary({ id: 'library-2', isDefault: false, name: 'New Library' }); const listLibraries = vi.fn(() => listed); const registerLibrary = vi.fn(() => created); const setDefaultLibrary = vi.fn(() => null); @@ -30,32 +30,32 @@ describe("librariesRoutes", () => { publishEvent, }); - const listResponse = await routes.request("/"); + const listResponse = await routes.request('/'); expect(listResponse.status).toBe(200); await expect(listResponse.json()).resolves.toEqual(listed); - const createResponse = await routes.request("/", { - method: "POST", - body: JSON.stringify({ name: "New Library", path: "D:/new-library", isDefault: false }), - headers: { "Content-Type": "application/json" }, + const createResponse = await routes.request('/', { + method: 'POST', + body: JSON.stringify({ name: 'New Library', path: 'D:/new-library', isDefault: false }), + headers: { 'Content-Type': 'application/json' }, }); expect(createResponse.status).toBe(201); await expect(createResponse.json()).resolves.toEqual(created); expect(registerLibrary).toHaveBeenCalledWith({ - name: "New Library", - path: "D:/new-library", + name: 'New Library', + path: 'D:/new-library', isDefault: false, }); - expect(publishEvent).toHaveBeenCalledWith("library.created", created); + expect(publishEvent).toHaveBeenCalledWith('library.created', created); }); - it("handles set-default and delete responses", async () => { + it('handles set-default and delete responses', async () => { const listLibraries = vi.fn(() => []); const registerLibrary = vi.fn(() => makeLibrary()); const setDefaultLibrary = vi .fn<(...args: [string]) => StudioLibrary | null>() - .mockReturnValueOnce(makeLibrary({ id: "library-2", isDefault: true })) + .mockReturnValueOnce(makeLibrary({ id: 'library-2', isDefault: true })) .mockReturnValueOnce(null); const removeLibrary = vi .fn<(...args: [string]) => boolean>() @@ -71,23 +71,58 @@ describe("librariesRoutes", () => { publishEvent, }); - const setDefaultOk = await routes.request("/library-2/default", { method: "PUT" }); + const setDefaultOk = await routes.request('/library-2/default', { method: 'PUT' }); expect(setDefaultOk.status).toBe(200); - await expect(setDefaultOk.json()).resolves.toEqual(expect.objectContaining({ id: "library-2" })); + await expect(setDefaultOk.json()).resolves.toEqual( + expect.objectContaining({ id: 'library-2' }), + ); - const setDefaultMissing = await routes.request("/missing/default", { method: "PUT" }); + const setDefaultMissing = await routes.request('/missing/default', { method: 'PUT' }); expect(setDefaultMissing.status).toBe(404); - const deleteOk = await routes.request("/library-2", { method: "DELETE" }); + const deleteOk = await routes.request('/library-2', { method: 'DELETE' }); expect(deleteOk.status).toBe(200); await expect(deleteOk.json()).resolves.toEqual({ ok: true }); - const deleteMissing = await routes.request("/missing", { method: "DELETE" }); + const deleteMissing = await routes.request('/missing', { method: 'DELETE' }); expect(deleteMissing.status).toBe(400); expect(publishEvent).toHaveBeenCalledWith( - "library.default", - expect.objectContaining({ id: "library-2" }), + 'library.default', + expect.objectContaining({ id: 'library-2' }), ); }); -}); \ No newline at end of file + + it('rejects malformed JSON and invalid create-library payload', async () => { + const listLibraries = vi.fn(() => []); + const registerLibrary = vi.fn(() => makeLibrary({ id: 'library-2' })); + const setDefaultLibrary = vi.fn(() => null); + const removeLibrary = vi.fn(() => false); + const publishEvent = vi.fn(); + + const routes = createLibrariesRoutes({ + listLibraries, + registerLibrary, + setDefaultLibrary, + removeLibrary, + publishEvent, + }); + + const malformed = await routes.request('/', { + method: 'POST', + body: '{"name":"New Library"', + headers: { 'Content-Type': 'application/json' }, + }); + expect(malformed.status).toBe(400); + await expect(malformed.json()).resolves.toMatchObject({ code: 'invalid_json' }); + + const invalid = await routes.request('/', { + method: 'POST', + body: JSON.stringify({ name: 'x', path: 123 }), + headers: { 'Content-Type': 'application/json' }, + }); + expect(invalid.status).toBe(400); + await expect(invalid.json()).resolves.toMatchObject({ code: 'invalid_request_body' }); + expect(registerLibrary).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/local-server/src/librariesRoutes.ts b/apps/local-server/src/librariesRoutes.ts index 8b3e94d6..8c415a39 100644 --- a/apps/local-server/src/librariesRoutes.ts +++ b/apps/local-server/src/librariesRoutes.ts @@ -1,6 +1,7 @@ -import { Hono } from "hono"; -import type { registerLibrary, listLibraries, removeLibrary, setDefaultLibrary } from "./libraries"; -import type { publishEvent } from "./events"; +import { Hono } from 'hono'; +import { Either, Schema } from 'effect'; +import type { registerLibrary, listLibraries, removeLibrary, setDefaultLibrary } from './libraries'; +import type { publishEvent } from './events'; interface LibrariesRoutesDependencies { listLibraries: typeof listLibraries; @@ -10,6 +11,12 @@ interface LibrariesRoutesDependencies { publishEvent: typeof publishEvent; } +const CreateLibraryBoundarySchema = Schema.Struct({ + name: Schema.optional(Schema.String), + path: Schema.String, + isDefault: Schema.optional(Schema.Boolean), +}); + export function createLibrariesRoutes({ listLibraries, registerLibrary, @@ -19,32 +26,58 @@ export function createLibrariesRoutes({ }: LibrariesRoutesDependencies) { const routes = new Hono(); - routes.get("/", (c) => c.json(listLibraries())); + routes.get('/', (c) => c.json(listLibraries())); + + routes.post('/', async (c) => { + const rawBody = await c.req + .json() + .catch(() => ({ __invalidJson: true }) as { __invalidJson: true }); + if ('__invalidJson' in rawBody) { + return c.json( + { + error: 'Invalid request body', + code: 'invalid_json', + reason: 'Request body must be valid JSON.', + }, + 400, + ); + } + + const decodedBody = Schema.decodeUnknownEither(CreateLibraryBoundarySchema)(rawBody); + if (Either.isLeft(decodedBody)) { + return c.json( + { + error: 'Invalid request body', + code: 'invalid_request_body', + reason: 'Library payload is invalid.', + }, + 400, + ); + } - routes.post("/", async (c) => { - const body = await c.req.json().catch(() => ({})); + const body = decodedBody.right; const library = registerLibrary({ - name: body.name || "Untitled Library", + name: body.name || 'Untitled Library', path: body.path, isDefault: Boolean(body.isDefault), }); - publishEvent("library.created", library); + publishEvent('library.created', library); return c.json(library, 201); }); - routes.put("/:id/default", (c) => { - const library = setDefaultLibrary(c.req.param("id")); - if (!library) return c.json({ error: "Library not found" }, 404); - publishEvent("library.default", library); + routes.put('/:id/default', (c) => { + const library = setDefaultLibrary(c.req.param('id')); + if (!library) return c.json({ error: 'Library not found' }, 404); + publishEvent('library.default', library); return c.json(library); }); - routes.delete("/:id", (c) => { - if (!removeLibrary(c.req.param("id"))) { - return c.json({ error: "Library not found or default library cannot be removed" }, 400); + routes.delete('/:id', (c) => { + if (!removeLibrary(c.req.param('id'))) { + return c.json({ error: 'Library not found or default library cannot be removed' }, 400); } return c.json({ ok: true }); }); return routes; -} \ No newline at end of file +} diff --git a/apps/local-server/src/outputSourceRoutes.test.ts b/apps/local-server/src/outputSourceRoutes.test.ts index 6009f855..b72cbb2d 100644 --- a/apps/local-server/src/outputSourceRoutes.test.ts +++ b/apps/local-server/src/outputSourceRoutes.test.ts @@ -1,14 +1,14 @@ -import { describe, expect, it, vi } from "vite-plus/test"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import os from "node:os"; -import path from "node:path"; +import { describe, expect, it, vi } from 'vite-plus/test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; import { createDefaultEditableStudioSettings, type CatalogImage, -} from "../../../packages/shared/src"; -import type { StudioSettingsStorage } from "./studioSettingsStore"; -import { createOutputSourceRoutes } from "./outputSourceRoutes"; +} from '../../../packages/shared/src'; +import type { StudioSettingsStorage } from './studioSettingsStore'; +import { createOutputSourceRoutes } from './outputSourceRoutes'; function createMemoryStorage(initial?: Record): StudioSettingsStorage { const values = new Map(Object.entries(initial ?? {})); @@ -22,16 +22,16 @@ function createMemoryStorage(initial?: Record): StudioSettingsSt }; } -describe("outputSourceRoutes", () => { - it("registers and lists output source files through the route seam", async () => { - const root = mkdtempSync(path.join(os.tmpdir(), "output-source-routes-")); - const sourceDir = path.join(root, "source"); - const libraryDir = path.join(root, "library"); +describe('outputSourceRoutes', () => { + it('registers and lists output source files through the route seam', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'output-source-routes-')); + const sourceDir = path.join(root, 'source'); + const libraryDir = path.join(root, 'library'); try { mkdirSync(sourceDir, { recursive: true }); mkdirSync(libraryDir, { recursive: true }); - writeFileSync(path.join(sourceDir, "one.png"), "png"); + writeFileSync(path.join(sourceDir, 'one.png'), 'png'); const storage = createMemoryStorage(); const publishEvent = vi.fn(); @@ -39,21 +39,21 @@ describe("outputSourceRoutes", () => { const routes = createOutputSourceRoutes({ settingsStorage: storage, readSettings: () => createDefaultEditableStudioSettings(), - readConfig: () => ({ libraryDir }) as ReturnType, + readConfig: () => ({ libraryDir }) as ReturnType, registerCatalogImage: () => { - throw new Error("registerCatalogImage should not be called in this test"); + throw new Error('registerCatalogImage should not be called in this test'); }, publishEvent, }); - const createResponse = await routes.request("/", { - method: "POST", + const createResponse = await routes.request('/', { + method: 'POST', body: JSON.stringify({ - label: "external source", + label: 'external source', path: sourceDir, - providerId: "comfy", + providerId: 'comfy', }), - headers: { "Content-Type": "application/json" }, + headers: { 'Content-Type': 'application/json' }, }); expect(createResponse.status).toBe(201); @@ -63,9 +63,9 @@ describe("outputSourceRoutes", () => { const filesResponse = await routes.request(`/${created.id}/files?limit=10`); expect(filesResponse.status).toBe(200); const filesPayload = (await filesResponse.json()) as { files: Array<{ fileName: string }> }; - expect(filesPayload.files).toEqual([expect.objectContaining({ fileName: "one.png" })]); + expect(filesPayload.files).toEqual([expect.objectContaining({ fileName: 'one.png' })]); expect(publishEvent).toHaveBeenCalledWith( - "output-source.registered", + 'output-source.registered', expect.objectContaining({ id: created.id }), ); } finally { @@ -73,25 +73,25 @@ describe("outputSourceRoutes", () => { } }); - it("imports files and publishes imported event", async () => { - const root = mkdtempSync(path.join(os.tmpdir(), "output-source-routes-import-")); - const sourceDir = path.join(root, "source"); - const libraryDir = path.join(root, "library"); + it('imports files and publishes imported event', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'output-source-routes-import-')); + const sourceDir = path.join(root, 'source'); + const libraryDir = path.join(root, 'library'); try { mkdirSync(sourceDir, { recursive: true }); mkdirSync(libraryDir, { recursive: true }); - writeFileSync(path.join(sourceDir, "hero.webp"), "webp"); + writeFileSync(path.join(sourceDir, 'hero.webp'), 'webp'); const storage = createMemoryStorage(); const publishEvent = vi.fn(); const registerCatalogImage = vi.fn((input: any) => { const image = { - id: "catalog-1", - libraryId: "library-1", + id: 'catalog-1', + libraryId: 'library-1', filePath: input.filePath, thumbnailPath: null, - publicUrl: "/library/fake", + publicUrl: '/library/fake', thumbnailUrl: null, prompt: input.prompt ?? null, negativePrompt: null, @@ -110,7 +110,7 @@ describe("outputSourceRoutes", () => { deletedAt: null, tags: input.tags ?? [], generationConfig: input.generationConfig ?? null, - createdAt: "2026-05-25T00:00:00.000Z", + createdAt: '2026-05-25T00:00:00.000Z', } satisfies CatalogImage; return image; }); @@ -118,27 +118,27 @@ describe("outputSourceRoutes", () => { const routes = createOutputSourceRoutes({ settingsStorage: storage, readSettings: () => createDefaultEditableStudioSettings(), - readConfig: () => ({ libraryDir }) as ReturnType, + readConfig: () => ({ libraryDir }) as ReturnType, registerCatalogImage, publishEvent, }); - const createResponse = await routes.request("/", { - method: "POST", + const createResponse = await routes.request('/', { + method: 'POST', body: JSON.stringify({ - label: "external source", + label: 'external source', path: sourceDir, - providerId: "comfy", + providerId: 'comfy', }), - headers: { "Content-Type": "application/json" }, + headers: { 'Content-Type': 'application/json' }, }); const created = (await createResponse.json()) as { id: string }; const importResponse = await routes.request(`/${created.id}/import`, { - method: "POST", - body: JSON.stringify({ files: ["hero.webp"], workspaceId: "workspace-1" }), - headers: { "Content-Type": "application/json" }, + method: 'POST', + body: JSON.stringify({ files: ['hero.webp'], workspaceId: 'workspace-1' }), + headers: { 'Content-Type': 'application/json' }, }); expect(importResponse.status).toBe(201); @@ -147,17 +147,174 @@ describe("outputSourceRoutes", () => { }; expect(payload.imported).toEqual([ - expect.objectContaining({ sourceFile: "hero.webp", catalogId: "catalog-1" }), + expect.objectContaining({ sourceFile: 'hero.webp', catalogId: 'catalog-1' }), ]); expect(registerCatalogImage).toHaveBeenCalled(); expect(publishEvent).toHaveBeenCalledWith( - "output-source.imported", + 'output-source.imported', expect.objectContaining({ - imported: [expect.objectContaining({ sourceFile: "hero.webp", catalogId: "catalog-1" })], + imported: [expect.objectContaining({ sourceFile: 'hero.webp', catalogId: 'catalog-1' })], }), ); } finally { rmSync(root, { recursive: true, force: true }); } }); + + it('accepts legacy string limit in import payload', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'output-source-routes-legacy-limit-')); + const sourceDir = path.join(root, 'source'); + const libraryDir = path.join(root, 'library'); + + try { + mkdirSync(sourceDir, { recursive: true }); + mkdirSync(libraryDir, { recursive: true }); + writeFileSync(path.join(sourceDir, 'hero-a.webp'), 'webp'); + writeFileSync(path.join(sourceDir, 'hero-b.webp'), 'webp'); + + const storage = createMemoryStorage(); + const publishEvent = vi.fn(); + const registerCatalogImage = vi.fn((input: any) => { + const image = { + id: `catalog-${registerCatalogImage.mock.calls.length + 1}`, + libraryId: 'library-1', + filePath: input.filePath, + thumbnailPath: null, + publicUrl: '/library/fake', + thumbnailUrl: null, + prompt: input.prompt ?? null, + negativePrompt: null, + aspectRatio: null, + imageSize: null, + width: null, + height: null, + mimeType: input.mimeType, + fileSizeBytes: input.fileSizeBytes ?? null, + jobId: null, + workspaceId: input.workspaceId ?? null, + batchId: null, + recipeId: null, + isFavorite: false, + isDeleted: false, + deletedAt: null, + tags: input.tags ?? [], + generationConfig: input.generationConfig ?? null, + createdAt: '2026-05-25T00:00:00.000Z', + } satisfies CatalogImage; + return image; + }); + + const routes = createOutputSourceRoutes({ + settingsStorage: storage, + readSettings: () => createDefaultEditableStudioSettings(), + readConfig: () => ({ libraryDir }) as ReturnType, + registerCatalogImage, + publishEvent, + }); + + const createResponse = await routes.request('/', { + method: 'POST', + body: JSON.stringify({ + label: 'external source', + path: sourceDir, + providerId: 'comfy', + }), + headers: { 'Content-Type': 'application/json' }, + }); + const created = (await createResponse.json()) as { id: string }; + + const importResponse = await routes.request(`/${created.id}/import`, { + method: 'POST', + body: JSON.stringify({ + files: ['hero-a.webp', 'hero-b.webp'], + limit: '1', + workspaceId: 'workspace-legacy', + }), + headers: { 'Content-Type': 'application/json' }, + }); + + expect(importResponse.status).toBe(201); + const payload = (await importResponse.json()) as { + imported: Array<{ sourceFile: string; catalogId: string }>; + }; + + expect(payload.imported).toHaveLength(1); + expect(payload.imported[0]?.sourceFile).toBe('hero-a.webp'); + expect(registerCatalogImage).toHaveBeenCalledTimes(1); + expect(publishEvent).toHaveBeenCalledWith( + 'output-source.imported', + expect.objectContaining({ + imported: [expect.objectContaining({ sourceFile: 'hero-a.webp' })], + }), + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('rejects malformed JSON and invalid payload shapes', async () => { + const root = mkdtempSync(path.join(os.tmpdir(), 'output-source-routes-invalid-')); + const sourceDir = path.join(root, 'source'); + const libraryDir = path.join(root, 'library'); + + try { + mkdirSync(sourceDir, { recursive: true }); + mkdirSync(libraryDir, { recursive: true }); + + const storage = createMemoryStorage(); + const publishEvent = vi.fn(); + + const routes = createOutputSourceRoutes({ + settingsStorage: storage, + readSettings: () => createDefaultEditableStudioSettings(), + readConfig: () => ({ libraryDir }) as ReturnType, + registerCatalogImage: () => { + throw new Error('registerCatalogImage should not be called in this test'); + }, + publishEvent, + }); + + const malformedRegister = await routes.request('/', { + method: 'POST', + body: '{"path":"x"', + headers: { 'Content-Type': 'application/json' }, + }); + expect(malformedRegister.status).toBe(400); + await expect(malformedRegister.json()).resolves.toMatchObject({ code: 'invalid_json' }); + + const invalidRegister = await routes.request('/', { + method: 'POST', + body: JSON.stringify({ label: 'x', path: 123 }), + headers: { 'Content-Type': 'application/json' }, + }); + expect(invalidRegister.status).toBe(400); + await expect(invalidRegister.json()).resolves.toMatchObject({ code: 'invalid_request_body' }); + + const createResponse = await routes.request('/', { + method: 'POST', + body: JSON.stringify({ label: 'ok', path: sourceDir, providerId: 'comfy' }), + headers: { 'Content-Type': 'application/json' }, + }); + expect(createResponse.status).toBe(201); + const created = (await createResponse.json()) as { id: string }; + + const malformedImport = await routes.request(`/${created.id}/import`, { + method: 'POST', + body: '{"files":["a.png"]', + headers: { 'Content-Type': 'application/json' }, + }); + expect(malformedImport.status).toBe(400); + await expect(malformedImport.json()).resolves.toMatchObject({ code: 'invalid_json' }); + + const invalidImport = await routes.request(`/${created.id}/import`, { + method: 'POST', + body: JSON.stringify({ files: 'hero.webp' }), + headers: { 'Content-Type': 'application/json' }, + }); + expect(invalidImport.status).toBe(400); + await expect(invalidImport.json()).resolves.toMatchObject({ code: 'invalid_request_body' }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); }); diff --git a/apps/local-server/src/outputSourceRoutes.ts b/apps/local-server/src/outputSourceRoutes.ts index 4cf45352..e8b44eaf 100644 --- a/apps/local-server/src/outputSourceRoutes.ts +++ b/apps/local-server/src/outputSourceRoutes.ts @@ -1,16 +1,17 @@ -import { Hono } from "hono"; +import { Hono } from 'hono'; +import { Either, Schema } from 'effect'; import { detectExternalOutputSourceCandidates, importExternalOutputSourceFiles, listExternalOutputSourceFiles, readExternalOutputSourceRegistry, registerExternalOutputSource, -} from "./outputSources"; -import type { StudioSettingsStorage } from "./studioSettingsStore"; -import type { registerCatalogImage } from "./catalog"; -import type { publishEvent } from "./events"; -import type { getSettings } from "./config"; -import type { EditableStudioSettings } from "../../../packages/shared/src"; +} from './outputSources'; +import type { StudioSettingsStorage } from './studioSettingsStore'; +import type { registerCatalogImage } from './catalog'; +import type { publishEvent } from './events'; +import type { getSettings } from './config'; +import type { EditableStudioSettings } from '../../../packages/shared/src'; interface OutputSourceRoutesDependencies { settingsStorage: StudioSettingsStorage; @@ -20,6 +21,65 @@ interface OutputSourceRoutesDependencies { publishEvent: typeof publishEvent; } +const RegisterOutputSourceBoundarySchema = Schema.Struct({ + label: Schema.optional(Schema.String), + path: Schema.String, + providerId: Schema.optional(Schema.Union(Schema.String, Schema.Null)), +}); + +const ImportOutputSourceBoundarySchema = Schema.Struct({ + files: Schema.Array(Schema.String), + limit: Schema.optional(Schema.Number), + workspaceId: Schema.optional(Schema.Union(Schema.String, Schema.Null)), +}); + +function decodeImportOutputSourceBody(rawBody: unknown) { + const strict = Schema.decodeUnknownEither(ImportOutputSourceBoundarySchema)(rawBody); + if (Either.isRight(strict)) return strict.right; + + if (typeof rawBody !== 'object' || rawBody === null || Array.isArray(rawBody)) { + return null; + } + + const raw = rawBody as { + files?: unknown; + limit?: unknown; + workspaceId?: unknown; + }; + + if (!Array.isArray(raw.files) || raw.files.some((item) => typeof item !== 'string')) { + return null; + } + + let limit: number | undefined; + if (typeof raw.limit === 'number') { + limit = raw.limit; + } else if (typeof raw.limit === 'string') { + const parsed = Number.parseInt(raw.limit, 10); + if (!Number.isFinite(parsed)) { + return null; + } + limit = parsed; + } else if (raw.limit !== undefined) { + return null; + } + + let workspaceId: string | null | undefined; + if (raw.workspaceId === undefined) { + workspaceId = undefined; + } else if (raw.workspaceId === null || typeof raw.workspaceId === 'string') { + workspaceId = raw.workspaceId; + } else { + return null; + } + + return { + files: raw.files, + limit, + workspaceId, + }; +} + export function createOutputSourceRoutes({ settingsStorage, readSettings, @@ -29,7 +89,7 @@ export function createOutputSourceRoutes({ }: OutputSourceRoutesDependencies) { const routes = new Hono(); - routes.get("/", (c) => { + routes.get('/', (c) => { const settings = readSettings(); return c.json({ registry: readExternalOutputSourceRegistry(settingsStorage), @@ -40,44 +100,98 @@ export function createOutputSourceRoutes({ }); }); - routes.post("/", async (c) => { - const body = await c.req.json().catch(() => ({})); + routes.post('/', async (c) => { + const rawBody = await c.req + .json() + .catch(() => ({ __invalidJson: true }) as { __invalidJson: true }); + if ('__invalidJson' in rawBody) { + return c.json( + { + error: 'Invalid request body', + code: 'invalid_json', + reason: 'Request body must be valid JSON.', + }, + 400, + ); + } + + const decodedBody = Schema.decodeUnknownEither(RegisterOutputSourceBoundarySchema)(rawBody); + if (Either.isLeft(decodedBody)) { + return c.json( + { + error: 'Invalid request body', + code: 'invalid_request_body', + reason: 'Output source payload is invalid.', + }, + 400, + ); + } + const result = registerExternalOutputSource({ storage: settingsStorage, libraryDir: readConfig().libraryDir, - input: body, + input: decodedBody.right, }); if (!result.ok) { return c.json({ error: result.reason }, 400); } - publishEvent("output-source.registered", result.source); + publishEvent('output-source.registered', result.source); return c.json(result.source, 201); }); - routes.get("/:id/files", (c) => { + routes.get('/:id/files', (c) => { const url = new URL(c.req.url); const result = listExternalOutputSourceFiles({ storage: settingsStorage, - sourceId: c.req.param("id"), - limit: Number(url.searchParams.get("limit") || 100), + sourceId: c.req.param('id'), + limit: Number(url.searchParams.get('limit') || 100), }); if (!result.ok) return c.json({ error: result.reason }, 404); return c.json({ source: result.source, files: result.files }); }); - routes.post("/:id/import", async (c) => { - const body = await c.req.json().catch(() => ({})); + routes.post('/:id/import', async (c) => { + const rawBody = await c.req + .json() + .catch(() => ({ __invalidJson: true }) as { __invalidJson: true }); + if ('__invalidJson' in rawBody) { + return c.json( + { + error: 'Invalid request body', + code: 'invalid_json', + reason: 'Request body must be valid JSON.', + }, + 400, + ); + } + + const decodedBody = decodeImportOutputSourceBody(rawBody); + if (!decodedBody) { + return c.json( + { + error: 'Invalid request body', + code: 'invalid_request_body', + reason: 'Output source import payload is invalid.', + }, + 400, + ); + } + const result = importExternalOutputSourceFiles({ storage: settingsStorage, - sourceId: c.req.param("id"), + sourceId: c.req.param('id'), libraryDir: readConfig().libraryDir, - input: body, + input: decodedBody as { + files: string[]; + limit?: number; + workspaceId?: string | null; + }, registerCatalogImage, }); if (!result.ok) return c.json({ error: result.reason }, 400); - publishEvent("output-source.imported", result.result); + publishEvent('output-source.imported', result.result); return c.json(result.result, 201); }); diff --git a/apps/local-server/src/projectRoutes.test.ts b/apps/local-server/src/projectRoutes.test.ts index 2664a037..7c07ea8c 100644 --- a/apps/local-server/src/projectRoutes.test.ts +++ b/apps/local-server/src/projectRoutes.test.ts @@ -1,21 +1,21 @@ -import { describe, expect, it, vi } from "vite-plus/test"; -import type { Project } from "../../../packages/shared/src"; -import { createProjectRoutes } from "./projectRoutes"; +import { describe, expect, it, vi } from 'vite-plus/test'; +import type { Project } from '../../../packages/shared/src'; +import { createProjectRoutes } from './projectRoutes'; function makeProject(overrides: Partial = {}): Project { return { - id: overrides.id ?? "project-1", - name: overrides.name ?? "Default Project", + id: overrides.id ?? 'project-1', + name: overrides.name ?? 'Default Project', description: overrides.description ?? null, - createdAt: overrides.createdAt ?? "2026-05-29T00:00:00.000Z", - updatedAt: overrides.updatedAt ?? "2026-05-29T00:00:00.000Z", + createdAt: overrides.createdAt ?? '2026-05-29T00:00:00.000Z', + updatedAt: overrides.updatedAt ?? '2026-05-29T00:00:00.000Z', }; } -describe("projectRoutes", () => { - it("lists projects and creates new projects through the seam", async () => { +describe('projectRoutes', () => { + it('lists projects and creates new projects through the seam', async () => { const listed = [makeProject()]; - const created = makeProject({ id: "project-2", name: "New Project", description: "desc" }); + const created = makeProject({ id: 'project-2', name: 'New Project', description: 'desc' }); const listProjects = vi.fn(() => listed); const createProject = vi.fn(() => created); @@ -29,20 +29,51 @@ describe("projectRoutes", () => { logProjectCreated, }); - const listResponse = await routes.request("/"); + const listResponse = await routes.request('/'); expect(listResponse.status).toBe(200); await expect(listResponse.json()).resolves.toEqual(listed); - const createResponse = await routes.request("/", { - method: "POST", - body: JSON.stringify({ name: "New Project", description: "desc" }), - headers: { "Content-Type": "application/json" }, + const createResponse = await routes.request('/', { + method: 'POST', + body: JSON.stringify({ name: 'New Project', description: 'desc' }), + headers: { 'Content-Type': 'application/json' }, }); expect(createResponse.status).toBe(201); await expect(createResponse.json()).resolves.toEqual(created); - expect(createProject).toHaveBeenCalledWith("New Project", "desc"); - expect(publishEvent).toHaveBeenCalledWith("project.created", created); - expect(logProjectCreated).toHaveBeenCalledWith("New Project"); + expect(createProject).toHaveBeenCalledWith('New Project', 'desc'); + expect(publishEvent).toHaveBeenCalledWith('project.created', created); + expect(logProjectCreated).toHaveBeenCalledWith('New Project'); }); -}); \ No newline at end of file + + it('returns 400 for malformed JSON and invalid payload', async () => { + const listProjects = vi.fn(() => []); + const createProject = vi.fn(() => makeProject({ id: 'project-2' })); + const publishEvent = vi.fn(); + const logProjectCreated = vi.fn(); + + const routes = createProjectRoutes({ + listProjects, + createProject, + publishEvent, + logProjectCreated, + }); + + const malformed = await routes.request('/', { + method: 'POST', + body: '{"name":"x"', + headers: { 'Content-Type': 'application/json' }, + }); + expect(malformed.status).toBe(400); + await expect(malformed.json()).resolves.toMatchObject({ code: 'invalid_json' }); + + const invalid = await routes.request('/', { + method: 'POST', + body: JSON.stringify({ name: 123 }), + headers: { 'Content-Type': 'application/json' }, + }); + expect(invalid.status).toBe(400); + await expect(invalid.json()).resolves.toMatchObject({ code: 'invalid_request_body' }); + expect(createProject).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/local-server/src/projectRoutes.ts b/apps/local-server/src/projectRoutes.ts index cd754967..f8e2401c 100644 --- a/apps/local-server/src/projectRoutes.ts +++ b/apps/local-server/src/projectRoutes.ts @@ -1,6 +1,7 @@ -import { Hono } from "hono"; -import type { publishEvent } from "./events"; -import type { Project } from "../../../packages/shared/src"; +import { Hono } from 'hono'; +import { Either, Schema } from 'effect'; +import type { publishEvent } from './events'; +import type { Project } from '../../../packages/shared/src'; interface ProjectRoutesDependencies { listProjects: () => Project[]; @@ -9,6 +10,11 @@ interface ProjectRoutesDependencies { logProjectCreated: (projectName: string) => void; } +const CreateProjectBoundarySchema = Schema.Struct({ + name: Schema.optional(Schema.String), + description: Schema.optional(Schema.Union(Schema.String, Schema.Null)), +}); + export function createProjectRoutes({ listProjects, createProject, @@ -17,15 +23,41 @@ export function createProjectRoutes({ }: ProjectRoutesDependencies) { const routes = new Hono(); - routes.get("/", (c) => c.json(listProjects())); + routes.get('/', (c) => c.json(listProjects())); + + routes.post('/', async (c) => { + const rawBody = await c.req + .json() + .catch(() => ({ __invalidJson: true }) as { __invalidJson: true }); + if ('__invalidJson' in rawBody) { + return c.json( + { + error: 'Invalid request body', + code: 'invalid_json', + reason: 'Request body must be valid JSON.', + }, + 400, + ); + } - routes.post("/", async (c) => { - const body = await c.req.json().catch(() => ({})); - const project = createProject(body.name || "Untitled Project", body.description || null); - publishEvent("project.created", project); + const decodedBody = Schema.decodeUnknownEither(CreateProjectBoundarySchema)(rawBody); + if (Either.isLeft(decodedBody)) { + return c.json( + { + error: 'Invalid request body', + code: 'invalid_request_body', + reason: 'Project payload is invalid.', + }, + 400, + ); + } + + const body = decodedBody.right; + const project = createProject(body.name || 'Untitled Project', body.description || null); + publishEvent('project.created', project); logProjectCreated(project.name); return c.json(project, 201); }); return routes; -} \ No newline at end of file +} diff --git a/apps/local-server/src/providers/codexProvider.test.ts b/apps/local-server/src/providers/codexProvider.test.ts index caca2a4c..9868a0a0 100644 --- a/apps/local-server/src/providers/codexProvider.test.ts +++ b/apps/local-server/src/providers/codexProvider.test.ts @@ -265,6 +265,48 @@ describe('codexProvider', () => { expect(compiled.payload.text).toContain('fresh interpretation'); }); + it('adds structured quality intent before recipe directives', () => { + const sourceSpec = createGenerationTaskSpec({ + id: 'spec-quality', + task: 'image_generate', + providerId: 'codex', + prompt: 'glass owl on a plinth', + quality: { + qualityPresetId: 'product_or_ui_asset', + subject: 'glass owl', + composition: 'centered three-quarter product view', + constraints: ['clean silhouette'], + }, + metadata: { + recipeProviderDirectives: createRecipeProviderDirectives({ + recipeId: 'styles', + title: 'Styles', + sections: [ + { + title: 'Visual DNA', + directives: [{ label: 'Core Aesthetic', value: 'polished glass' }], + }, + ], + }), + }, + }); + + const compiled = compileCodexImagegenInput({ + id: 'job-quality', + projectId: 'project-1', + prompt: 'fallback', + execution: null, + sourceSpec, + }); + + expect(compiled.payload.text).toContain('Quality preset:\nproduct_or_ui_asset'); + expect(compiled.payload.text).toContain('- Subject: glass owl'); + expect(compiled.payload.text).toContain('- Constraint: clean silhouette'); + expect(compiled.payload.text.indexOf('Quality intent:')).toBeLessThan( + compiled.payload.text.indexOf('Recipe directives:'), + ); + }); + it('delegates execution to the Codex Product Runtime with compiled input text', async () => { const calls: TurnParams[] = []; const turn: CodexTurn = { diff --git a/apps/local-server/src/providers/codexProvider.ts b/apps/local-server/src/providers/codexProvider.ts index 754205a0..b5fe4b5a 100644 --- a/apps/local-server/src/providers/codexProvider.ts +++ b/apps/local-server/src/providers/codexProvider.ts @@ -1,6 +1,7 @@ import { createCompiledProviderInput, createGenerationTaskSpec, + composeGenerationQualityPromptSections, type CompiledProviderInput, type GenerationTaskSpec, } from '../../../../packages/shared/src/generationContracts'; @@ -111,12 +112,17 @@ function buildCodexPromptText(sourceSpec: GenerationTaskSpec) { const parts = [`Task: ${sourceSpec.task}`, '', 'Prompt:', sourceSpec.prompt]; const recipeProviderDirectives = sourceSpec.metadata.recipeProviderDirectives; const recipeContext = sourceSpec.metadata.recipeContext; + const qualitySections = composeGenerationQualityPromptSections(sourceSpec); const variationBrief = typeof sourceSpec.metadata.variationBrief === 'string' ? sourceSpec.metadata.variationBrief.trim() : ''; const assetLines = buildCodexAssetLines(sourceSpec); + if (qualitySections.length > 0) { + parts.push('', ...qualitySections); + } + if (isRecipeProviderDirectives(recipeProviderDirectives)) { parts.push( '', diff --git a/apps/local-server/src/providers/externalProviderInputs.test.ts b/apps/local-server/src/providers/externalProviderInputs.test.ts index da0d6026..833e050d 100644 --- a/apps/local-server/src/providers/externalProviderInputs.test.ts +++ b/apps/local-server/src/providers/externalProviderInputs.test.ts @@ -59,6 +59,8 @@ describe('external provider input compilers', () => { recipeId: 'image_to_image', stylePresetId: 'SP03-010', sourceProviderId: 'google', + qualityPresetId: null, + hasQualityIntent: false, hasRecipeProviderDirectives: false, }, }); @@ -169,6 +171,47 @@ describe('external provider input compilers', () => { expect(compiled.payload.prompt).toContain('noticeably different'); }); + it('adds structured quality intent to hosted API prompts and metadata', () => { + const sourceSpec = createGenerationTaskSpec({ + id: 'spec-google-quality', + task: 'image_generate', + providerId: 'google', + prompt: 'glass owl on a plinth', + quality: { + qualityPresetId: 'product_or_ui_asset', + subject: 'glass owl', + lighting: 'softbox highlights on glass edges', + referenceRoles: [ + { + role: 'reference', + assetName: 'mood.png', + instruction: 'Use for cool mineral color mood only.', + }, + ], + }, + }); + + const compiled = compileGoogleImageApiInput({ + id: 'job-google-quality', + projectId: 'project-1', + providerId: 'google', + prompt: 'fallback', + execution: null, + sourceSpec, + }); + + expect(compiled.payload.prompt).toContain('Quality preset:\nproduct_or_ui_asset'); + expect(compiled.payload.prompt).toContain('- Subject: glass owl'); + expect(compiled.payload.prompt).toContain('- Lighting: softbox highlights on glass edges'); + expect(compiled.payload.prompt).toContain( + '- Reference role: mood.png (reference): Use for cool mineral color mood only.', + ); + expect(compiled.payload.metadata).toMatchObject({ + qualityPresetId: 'product_or_ui_asset', + hasQualityIntent: true, + }); + }); + it('compiles Comfy local workflow input for adapter conformance fixtures', () => { const sourceSpec = createGenerationTaskSpec({ id: 'spec-comfy-1', diff --git a/apps/local-server/src/providers/externalProviderInputs.ts b/apps/local-server/src/providers/externalProviderInputs.ts index f1a49214..b6446569 100644 --- a/apps/local-server/src/providers/externalProviderInputs.ts +++ b/apps/local-server/src/providers/externalProviderInputs.ts @@ -2,6 +2,7 @@ import { createCompiledProviderInput, createGenerationTaskSpec, createProviderSessionContract, + composeGenerationQualityPromptSections, type CompiledProviderInput, type GenerationOutputContract, type GenerationProviderId, @@ -38,6 +39,8 @@ export interface HostedImageApiCompiledPayload { recipeId: string | null; stylePresetId: string | null; sourceProviderId: GenerationProviderId | null; + qualityPresetId: string | null; + hasQualityIntent: boolean; hasRecipeProviderDirectives: boolean; }; } @@ -53,6 +56,8 @@ export interface ComfyWorkflowCompiledPayload { recipeId: string | null; stylePresetId: string | null; sourceProviderId: GenerationProviderId | null; + qualityPresetId: string | null; + hasQualityIntent: boolean; hasRecipeProviderDirectives: boolean; }; } @@ -204,6 +209,8 @@ function createProviderPayloadMetadata(sourceSpec: GenerationTaskSpec) { recipeId: sourceSpec.recipeId, stylePresetId: sourceSpec.stylePresetId, sourceProviderId: sourceSpec.providerId, + qualityPresetId: sourceSpec.quality?.qualityPresetId ?? null, + hasQualityIntent: Boolean(sourceSpec.quality), hasRecipeProviderDirectives: isRecipeProviderDirectives( sourceSpec.metadata.recipeProviderDirectives, ), @@ -217,6 +224,11 @@ function buildProviderPrompt(sourceSpec: GenerationTaskSpec) { ? sourceSpec.metadata.variationBrief.trim() : ''; const sections = [sourceSpec.prompt]; + const qualitySections = composeGenerationQualityPromptSections(sourceSpec); + + if (qualitySections.length > 0) { + sections.push('', ...qualitySections); + } if (!isRecipeProviderDirectives(recipeProviderDirectives)) { if (variationBrief) { diff --git a/apps/local-server/src/providers/externalProviderResults.ts b/apps/local-server/src/providers/externalProviderResults.ts index af009232..49caa8ec 100644 --- a/apps/local-server/src/providers/externalProviderResults.ts +++ b/apps/local-server/src/providers/externalProviderResults.ts @@ -1,8 +1,14 @@ import { mkdirSync, writeFileSync } from 'node:fs'; import path from 'node:path'; +import { Effect } from 'effect'; import type { CompiledProviderInput } from '../../../../packages/shared/src'; import type { TurnResult } from '../codex/turn'; import { resolveLibraryPath } from '../library'; +import { + getExternalProviderRetryDelayMs, + isRetryableProviderStatus, + normalizeExternalProviderRetryPolicy, +} from './externalProviderRetryPolicy'; export type ExternalProviderFetch = ( input: string | URL | Request, @@ -76,7 +82,7 @@ export function responseSnippet(value: string, secrets: readonly string[] = []) } export function isRetryableStatus(status: number) { - return status === 408 || status === 429 || status >= 500; + return isRetryableProviderStatus(status); } function isAbortError(error: unknown) { @@ -97,27 +103,54 @@ export async function fetchExternalProviderWithRetry({ input: string | URL | Request; init?: RequestInit; } & ExternalProviderRetryOptions) { - let lastNetworkError: unknown = null; - - for (let attempt = 1; attempt <= maxAttempts; attempt += 1) { - try { - const response = await fetch(input, init); - if (response.ok || !isRetryableStatus(response.status) || attempt === maxAttempts) { - return { response, attempts: attempt }; - } - } catch (error) { - if (isAbortError(error) || attempt === maxAttempts) { - throw error; + const retryPolicy = normalizeExternalProviderRetryPolicy({ + maxAttempts, + retryDelayMs, + }); + + const program = Effect.gen(function* () { + let lastNetworkError: unknown = null; + + for (let attempt = 1; attempt <= retryPolicy.maxAttempts; attempt += 1) { + const attemptResult = yield* Effect.tryPromise({ + try: () => fetch(input, init), + catch: (error) => error, + }).pipe( + Effect.map((response) => ({ type: 'response' as const, response })), + Effect.catchAll((error) => Effect.succeed({ type: 'error' as const, error })), + ); + + if (attemptResult.type === 'response') { + if ( + attemptResult.response.ok || + !isRetryableStatus(attemptResult.response.status) || + attempt === retryPolicy.maxAttempts + ) { + return { response: attemptResult.response, attempts: attempt }; + } + } else { + if (isAbortError(attemptResult.error) || attempt === retryPolicy.maxAttempts) { + return yield* Effect.fail(attemptResult.error); + } + lastNetworkError = attemptResult.error; } - lastNetworkError = error; + + const delayMs = getExternalProviderRetryDelayMs(retryPolicy.retryDelayMs, attempt); + yield* Effect.tryPromise({ + try: () => sleep(delayMs), + catch: (error) => + error instanceof Error ? error : new Error(`${label} retry delay failed.`), + }); } - await sleep(retryDelayMs * attempt); - } + return yield* Effect.fail( + lastNetworkError instanceof Error + ? lastNetworkError + : new Error(`${label} failed without a response.`), + ); + }); - throw lastNetworkError instanceof Error - ? lastNetworkError - : new Error(`${label} failed without a response.`); + return await Effect.runPromise(program); } export function findFirstHostedImageUrl(value: unknown): string | null { diff --git a/apps/local-server/src/providers/externalProviderRetryPolicy.test.ts b/apps/local-server/src/providers/externalProviderRetryPolicy.test.ts new file mode 100644 index 00000000..e3355cfb --- /dev/null +++ b/apps/local-server/src/providers/externalProviderRetryPolicy.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { + getExternalProviderRetryDelayMs, + isRetryableProviderStatus, + normalizeExternalProviderRetryPolicy, +} from './externalProviderRetryPolicy'; + +describe('externalProviderRetryPolicy', () => { + it('normalizes retry policy bounds', () => { + expect( + normalizeExternalProviderRetryPolicy({ + maxAttempts: 0, + retryDelayMs: -100, + }), + ).toEqual({ maxAttempts: 1, retryDelayMs: 0 }); + + expect( + normalizeExternalProviderRetryPolicy({ + maxAttempts: 3.8, + retryDelayMs: 250.9, + }), + ).toEqual({ maxAttempts: 3, retryDelayMs: 250 }); + }); + + it('marks retryable HTTP status codes', () => { + expect(isRetryableProviderStatus(408)).toBe(true); + expect(isRetryableProviderStatus(429)).toBe(true); + expect(isRetryableProviderStatus(500)).toBe(true); + expect(isRetryableProviderStatus(503)).toBe(true); + expect(isRetryableProviderStatus(400)).toBe(false); + expect(isRetryableProviderStatus(404)).toBe(false); + }); + + it('computes linear retry backoff delay', () => { + expect(getExternalProviderRetryDelayMs(100, 1)).toBe(100); + expect(getExternalProviderRetryDelayMs(100, 2)).toBe(200); + expect(getExternalProviderRetryDelayMs(0, 3)).toBe(0); + expect(getExternalProviderRetryDelayMs(-50, 2)).toBe(0); + }); +}); diff --git a/apps/local-server/src/providers/externalProviderRetryPolicy.ts b/apps/local-server/src/providers/externalProviderRetryPolicy.ts new file mode 100644 index 00000000..0e432472 --- /dev/null +++ b/apps/local-server/src/providers/externalProviderRetryPolicy.ts @@ -0,0 +1,21 @@ +export interface ExternalProviderRetryPolicy { + maxAttempts: number; + retryDelayMs: number; +} + +export function normalizeExternalProviderRetryPolicy( + policy: ExternalProviderRetryPolicy, +): ExternalProviderRetryPolicy { + return { + maxAttempts: Math.max(1, Math.floor(policy.maxAttempts)), + retryDelayMs: Math.max(0, Math.floor(policy.retryDelayMs)), + }; +} + +export function isRetryableProviderStatus(status: number) { + return status === 408 || status === 429 || status >= 500; +} + +export function getExternalProviderRetryDelayMs(baseDelayMs: number, attempt: number) { + return Math.max(0, baseDelayMs * attempt); +} diff --git a/apps/local-server/src/runtimeRoutes.test.ts b/apps/local-server/src/runtimeRoutes.test.ts index d2bd38fe..31fe633a 100644 --- a/apps/local-server/src/runtimeRoutes.test.ts +++ b/apps/local-server/src/runtimeRoutes.test.ts @@ -1,16 +1,16 @@ -import { describe, expect, it, vi } from "vite-plus/test"; -import { createRuntimeRoutes } from "./runtimeRoutes"; +import { describe, expect, it, vi } from 'vite-plus/test'; +import { createRuntimeRoutes } from './runtimeRoutes'; -describe("runtimeRoutes", () => { - it("returns health snapshot and bootstrap config", async () => { +describe('runtimeRoutes', () => { + it('returns health snapshot and bootstrap config', async () => { const ensureAppServer = vi.fn(); const routes = createRuntimeRoutes({ readSettings: () => ({ - libraryDir: "D:/library", + libraryDir: 'D:/library', serverPort: 17223, codexWsPort: 17224, - codexImagegenModel: "gpt-image-1", - codexImagegenReasoningEffort: "medium", + codexImagegenModel: 'gpt-image-1', + codexImagegenReasoningEffort: 'medium', codexImagegenServiceTier: null, codexMaxConcurrentJobs: 1, }), @@ -20,26 +20,31 @@ describe("runtimeRoutes", () => { readmePresent: true, missingFolders: [], }), - resolveCodexInvocation: () => ["node", "-e", "process.stdout.write('codex-test')"], - getCodexWsUrl: () => "ws://127.0.0.1:17224", - getEnvLocalPath: () => "D:/repo/.env.local", + resolveCodexInvocation: () => ['node', '-e', "process.stdout.write('codex-test')"], + getCodexWsUrl: () => 'ws://127.0.0.1:17224', + getEnvLocalPath: () => 'D:/repo/.env.local', hasEnvLocalFile: () => true, ensureAppServer, readAppServerDiagnostics: () => ({ pid: 123, lastExitCode: null, lastExitAt: null, - lastInvocation: ["codex", "app-server"], + lastInvocation: ['codex', 'app-server'], lastStartAt: null, lastStartError: null, lastEnsureAt: null, lastEnsureReason: null, }), isAppServerRunning: () => true, - readWorkerStatus: () => ({ currentJobId: null, queueLength: 0, status: "idle" }), + readWorkerStatus: () => ({ + maxConcurrentJobs: 1, + activeWorkerCount: 0, + queuedJobs: 0, + trackedJobs: 0, + }), }); - const healthResponse = await routes.request("/health"); + const healthResponse = await routes.request('/health'); expect(healthResponse.status).toBe(200); const healthPayload = (await healthResponse.json()) as { ok: boolean; @@ -50,22 +55,22 @@ describe("runtimeRoutes", () => { expect(healthPayload.checks.onboardingReady).toBe(true); expect(healthPayload.appServer.running).toBe(true); - const bootstrapResponse = await routes.request("/bootstrap-config"); + const bootstrapResponse = await routes.request('/bootstrap-config'); expect(bootstrapResponse.status).toBe(200); await expect(bootstrapResponse.json()).resolves.toEqual( - expect.objectContaining({ libraryDir: "D:/library", serverPort: 17223 }), + expect.objectContaining({ libraryDir: 'D:/library', serverPort: 17223 }), ); }); - it("starts app-server and returns diagnostics", async () => { + it('starts app-server and returns diagnostics', async () => { const ensureAppServer = vi.fn(); const routes = createRuntimeRoutes({ readSettings: () => ({ - libraryDir: "D:/library", + libraryDir: 'D:/library', serverPort: 17223, codexWsPort: 17224, - codexImagegenModel: "gpt-image-1", - codexImagegenReasoningEffort: "medium", + codexImagegenModel: 'gpt-image-1', + codexImagegenReasoningEffort: 'medium', codexImagegenServiceTier: null, codexMaxConcurrentJobs: 1, }), @@ -75,34 +80,39 @@ describe("runtimeRoutes", () => { readmePresent: true, missingFolders: [], }), - resolveCodexInvocation: () => ["node", "-e", "process.stdout.write('codex-test')"], - getCodexWsUrl: () => "ws://127.0.0.1:17224", - getEnvLocalPath: () => "D:/repo/.env.local", + resolveCodexInvocation: () => ['node', '-e', "process.stdout.write('codex-test')"], + getCodexWsUrl: () => 'ws://127.0.0.1:17224', + getEnvLocalPath: () => 'D:/repo/.env.local', hasEnvLocalFile: () => true, ensureAppServer, readAppServerDiagnostics: () => ({ pid: 456, lastExitCode: null, lastExitAt: null, - lastInvocation: ["codex", "app-server"], + lastInvocation: ['codex', 'app-server'], lastStartAt: null, lastStartError: null, lastEnsureAt: null, - lastEnsureReason: "user", + lastEnsureReason: 'user', }), isAppServerRunning: () => true, - readWorkerStatus: () => ({ currentJobId: null, queueLength: 0, status: "idle" }), + readWorkerStatus: () => ({ + maxConcurrentJobs: 1, + activeWorkerCount: 0, + queuedJobs: 0, + trackedJobs: 0, + }), }); - const response = await routes.request("/app-server/start", { method: "POST" }); + const response = await routes.request('/app-server/start', { method: 'POST' }); expect(response.status).toBe(200); await expect(response.json()).resolves.toEqual( expect.objectContaining({ running: true, - wsUrl: "ws://127.0.0.1:17224", + wsUrl: 'ws://127.0.0.1:17224', pid: 456, }), ); - expect(ensureAppServer).toHaveBeenCalledWith("user"); + expect(ensureAppServer).toHaveBeenCalledWith('user'); }); -}); \ No newline at end of file +}); diff --git a/apps/local-server/src/settingsRoutes.test.ts b/apps/local-server/src/settingsRoutes.test.ts index 939d5da6..e701ca61 100644 --- a/apps/local-server/src/settingsRoutes.test.ts +++ b/apps/local-server/src/settingsRoutes.test.ts @@ -1,11 +1,11 @@ -import { describe, expect, it } from "vite-plus/test"; -import { createDefaultEditableStudioSettings } from "../../../packages/shared/src"; -import { createSettingsRoutes } from "./settingsRoutes"; +import { describe, expect, it } from 'vite-plus/test'; +import { createDefaultEditableStudioSettings } from '../../../packages/shared/src'; +import { createSettingsRoutes } from './settingsRoutes'; import { readEditableStudioSettings, updateEditableStudioSettings, type StudioSettingsStorage, -} from "./studioSettingsStore"; +} from './studioSettingsStore'; function createMemoryStorage(initial?: Record): StudioSettingsStorage { const values = new Map(Object.entries(initial ?? {})); @@ -19,43 +19,67 @@ function createMemoryStorage(initial?: Record): StudioSettingsSt }; } -describe("settingsRoutes", () => { - it("returns editable Studio Settings through the route seam", async () => { +describe('settingsRoutes', () => { + it('returns editable Studio Settings through the route seam', async () => { const storage = createMemoryStorage(); const routes = createSettingsRoutes({ readSettings: () => readEditableStudioSettings(storage), updateSettings: (patch) => updateEditableStudioSettings(storage, patch), }); - const response = await routes.request("/"); + const response = await routes.request('/'); expect(response.status).toBe(200); const payload = (await response.json()) as ReturnType< typeof createDefaultEditableStudioSettings >; - expect(payload.defaultProviderId).toBe("codex"); - expect(payload.defaultOutputMode).toBe("studio_library"); + expect(payload.defaultProviderId).toBe('codex'); + expect(payload.defaultOutputMode).toBe('studio_library'); }); - it("updates editable settings and keeps the new value for subsequent reads", async () => { + it('updates editable settings and keeps the new value for subsequent reads', async () => { const storage = createMemoryStorage(); const routes = createSettingsRoutes({ readSettings: () => readEditableStudioSettings(storage), updateSettings: (patch) => updateEditableStudioSettings(storage, patch), }); - const patchResponse = await routes.request("/", { - method: "PATCH", + const patchResponse = await routes.request('/', { + method: 'PATCH', body: JSON.stringify({ commandCenterCompactMode: true }), - headers: { "Content-Type": "application/json" }, + headers: { 'Content-Type': 'application/json' }, }); expect(patchResponse.status).toBe(200); const patched = (await patchResponse.json()) as { commandCenterCompactMode: boolean }; expect(patched.commandCenterCompactMode).toBe(true); - const readBack = await routes.request("/"); + const readBack = await routes.request('/'); const readBackPayload = (await readBack.json()) as { commandCenterCompactMode: boolean }; expect(readBackPayload.commandCenterCompactMode).toBe(true); }); -}); \ No newline at end of file + + it('returns 400 for malformed JSON or non-object payload', async () => { + const storage = createMemoryStorage(); + const routes = createSettingsRoutes({ + readSettings: () => readEditableStudioSettings(storage), + updateSettings: (patch) => updateEditableStudioSettings(storage, patch), + }); + + const malformed = await routes.request('/', { + method: 'PATCH', + body: '{"commandCenterCompactMode":true', + headers: { 'Content-Type': 'application/json' }, + }); + expect(malformed.status).toBe(400); + await expect(malformed.json()).resolves.toMatchObject({ code: 'invalid_json' }); + + const invalidShape = await routes.request('/', { + method: 'PATCH', + body: JSON.stringify(['not', 'an', 'object']), + headers: { 'Content-Type': 'application/json' }, + }); + expect(invalidShape.status).toBe(400); + await expect(invalidShape.json()).resolves.toMatchObject({ code: 'invalid_request_body' }); + }); +}); diff --git a/apps/local-server/src/settingsRoutes.ts b/apps/local-server/src/settingsRoutes.ts index eb9f0da1..6cd2cab6 100644 --- a/apps/local-server/src/settingsRoutes.ts +++ b/apps/local-server/src/settingsRoutes.ts @@ -1,23 +1,51 @@ -import { Hono } from "hono"; -import type { EditableStudioSettings } from "../../../packages/shared/src"; +import { Hono } from 'hono'; +import { Either, Schema } from 'effect'; +import type { EditableStudioSettings } from '../../../packages/shared/src'; interface SettingsRoutesDependencies { readSettings: () => EditableStudioSettings; updateSettings: (patch: unknown) => EditableStudioSettings; } -export function createSettingsRoutes({ - readSettings, - updateSettings, -}: SettingsRoutesDependencies) { +const SettingsPatchBoundarySchema = Schema.Record({ + key: Schema.String, + value: Schema.Unknown, +}); + +export function createSettingsRoutes({ readSettings, updateSettings }: SettingsRoutesDependencies) { const routes = new Hono(); - routes.get("/", (c) => c.json(readSettings())); + routes.get('/', (c) => c.json(readSettings())); + + routes.patch('/', async (c) => { + const rawBody = await c.req + .json() + .catch(() => ({ __invalidJson: true }) as { __invalidJson: true }); + if ('__invalidJson' in rawBody) { + return c.json( + { + error: 'Invalid request body', + code: 'invalid_json', + reason: 'Request body must be valid JSON.', + }, + 400, + ); + } - routes.patch("/", async (c) => { - const body = await c.req.json().catch(() => ({})); - return c.json(updateSettings(body)); + const decodedBody = Schema.decodeUnknownEither(SettingsPatchBoundarySchema)(rawBody); + if (Either.isLeft(decodedBody)) { + return c.json( + { + error: 'Invalid request body', + code: 'invalid_request_body', + reason: 'Settings patch must be a JSON object.', + }, + 400, + ); + } + + return c.json(updateSettings(decodedBody.right)); }); return routes; -} \ No newline at end of file +} diff --git a/apps/local-server/src/worker.ts b/apps/local-server/src/worker.ts index 52b68fad..843ecf7e 100644 --- a/apps/local-server/src/worker.ts +++ b/apps/local-server/src/worker.ts @@ -1,7 +1,7 @@ -import { mkdirSync, statSync, writeFileSync } from "node:fs"; -import path from "node:path"; -import { getSettings } from "./config"; -import { registerCatalogImage } from "./catalog"; +import { mkdirSync, statSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { getSettings } from './config'; +import { registerCatalogImage } from './catalog'; import { addAsset, addJobEvent, @@ -10,24 +10,29 @@ import { setSettingValue, updateJobStatus, upsertCodexTurn, -} from "./db"; -import { publishEvent } from "./events"; -import { resolveLibraryPath, toPublicAssetUrl } from "./library"; -import { log } from "./logger"; -import { createCodexTurn } from "./codex/turn"; -import type { CodexTurn } from "./codex/turn"; -import { resolveJobExecutionOptions } from "./codex/executionOptions"; -import { createCodexGenerationProvider } from "./providers/codexProvider"; -import { createExternalGenerationProvider } from "./providers/externalProvider"; -import type { GenerationProvider } from "./providers/types"; -import { embedMetadata } from "./metadataEmbedder"; -import { parsePromptTransport } from "../../../packages/shared/src/promptTransport"; -import type { Job } from "../../../packages/shared/src/types"; -import { readEditableStudioSettings } from "./studioSettingsStore"; -import { resolveJobCatalogContext } from "./workerCatalogContext"; -import { resolveWorkerRuntimeTarget } from "./workerRouting"; -import { createWorkerAssetPathing, inferGeneratedAssetMimeType } from "./workerAssetPathing"; -import { createWorkerAssetFinalizer } from "./workerAssetFinalizer"; +} from './db'; +import { publishEvent } from './events'; +import { resolveLibraryPath, toPublicAssetUrl } from './library'; +import { log } from './logger'; +import { createCodexTurn } from './codex/turn'; +import type { CodexTurn } from './codex/turn'; +import { resolveJobExecutionOptions } from './codex/executionOptions'; +import { createCodexGenerationProvider } from './providers/codexProvider'; +import { createExternalGenerationProvider } from './providers/externalProvider'; +import type { GenerationProvider } from './providers/types'; +import { embedMetadata } from './metadataEmbedder'; +import { parsePromptTransport } from '../../../packages/shared/src/promptTransport'; +import type { Job } from '../../../packages/shared/src/types'; +import { readEditableStudioSettings } from './studioSettingsStore'; +import { resolveJobCatalogContext } from './workerCatalogContext'; +import { resolveWorkerRuntimeTarget } from './workerRouting'; +import { createWorkerAssetPathing, inferGeneratedAssetMimeType } from './workerAssetPathing'; +import { createWorkerAssetFinalizer } from './workerAssetFinalizer'; +import { + createAbortWorkerError, + createUnsupportedRuntimeTargetError, + formatWorkerErrorMessage, +} from './workerErrors'; export interface WorkerStatus { maxConcurrentJobs: number; @@ -67,13 +72,11 @@ export interface CreateWorkerControllerDependencies { } function createAbortError() { - const error = new Error("Operation cancelled by user"); - error.name = "AbortError"; - return error; + return createAbortWorkerError(); } function isAbortError(error: unknown) { - return error instanceof Error && error.name === "AbortError"; + return error instanceof Error && error.name === 'AbortError'; } function throwIfAborted(signal?: AbortSignal) { @@ -88,25 +91,25 @@ function waitWithAbort(durationMs: number, signal?: AbortSignal) { return new Promise((resolve, reject) => { const timeout = setTimeout(() => { - signal.removeEventListener("abort", handleAbort); + signal.removeEventListener('abort', handleAbort); resolve(); }, durationMs); const handleAbort = () => { clearTimeout(timeout); - signal.removeEventListener("abort", handleAbort); + signal.removeEventListener('abort', handleAbort); reject(createAbortError()); }; - signal.addEventListener("abort", handleAbort, { once: true }); + signal.addEventListener('abort', handleAbort, { once: true }); }); } function svgForPrompt(prompt: string) { const safePrompt = prompt - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") + .replaceAll('&', '&') + .replaceAll('<', '<') + .replaceAll('>', '>') .slice(0, 180); return ` @@ -179,10 +182,10 @@ export function createWorkerController({ imageSize: parsedPrompt.imageSize, negativePrompt: parsedPrompt.negativePrompt, temperature: 0.8, - model: "codex-imagegen", + model: 'codex-imagegen', executionModel: executionOptions.model, executionReasoningEffort: executionOptions.reasoningEffort, - executionSpeed: executionOptions.serviceTier ?? "standard", + executionSpeed: executionOptions.serviceTier ?? 'standard', batchCount: 1, useThinkingAndSearch: false, }; @@ -192,7 +195,7 @@ export function createWorkerController({ if (job.sourceSpec) { const executionOptions = resolveExecutionOptions(job.execution); const recipeContext = - typeof job.sourceSpec.metadata.recipeContext === "string" + typeof job.sourceSpec.metadata.recipeContext === 'string' ? job.sourceSpec.metadata.recipeContext : null; @@ -206,10 +209,10 @@ export function createWorkerController({ imageSize: job.sourceSpec.output.imageSize, negativePrompt: job.sourceSpec.negativePrompt, temperature: 0.8, - model: "codex-imagegen", + model: 'codex-imagegen', executionModel: executionOptions.model, executionReasoningEffort: executionOptions.reasoningEffort, - executionSpeed: executionOptions.serviceTier ?? "standard", + executionSpeed: executionOptions.serviceTier ?? 'standard', batchCount: job.sourceSpec.output.count, useThinkingAndSearch: false, }; @@ -228,10 +231,10 @@ export function createWorkerController({ imageSize: parsedPrompt.imageSize, negativePrompt: parsedPrompt.negativePrompt, temperature: 0.8, - model: "codex-imagegen", + model: 'codex-imagegen', executionModel: executionOptions.model, executionReasoningEffort: executionOptions.reasoningEffort, - executionSpeed: executionOptions.serviceTier ?? "standard", + executionSpeed: executionOptions.serviceTier ?? 'standard', batchCount: 1, useThinkingAndSearch: false, }; @@ -256,14 +259,14 @@ export function createWorkerController({ async function runDryJob(job: Job, signal?: AbortSignal) { const startedAt = Date.now(); - addJobEventFn(job.id, "dry_run.started", "Dry run asset creation started."); - logger("info", "worker", "Dry run job started.", job.id); + addJobEventFn(job.id, 'dry_run.started', 'Dry run asset creation started.'); + logger('info', 'worker', 'Dry run job started.', job.id); await waitWithAbort(500, signal); throwIfAborted(signal); - const filePath = assetPathing.resolveGeneratedAssetTargetPath(job, "dry_run", ".svg"); + const filePath = assetPathing.resolveGeneratedAssetTargetPath(job, 'dry_run', '.svg'); mkdirSync(path.dirname(filePath), { recursive: true }); - writeFileSync(filePath, svgForPrompt(job.finalPromptUsed), "utf8"); + writeFileSync(filePath, svgForPrompt(job.finalPromptUsed), 'utf8'); const asset = addAssetFn({ projectId: job.projectId, jobId: job.id, @@ -273,7 +276,7 @@ export function createWorkerController({ prompt: job.finalPromptUsed, width: 1200, height: 800, - mimeType: "image/svg+xml", + mimeType: 'image/svg+xml', }); const catalogContext = resolveJobCatalogContextFn(job); const parsedPrompt = parsePromptTransportFn(job.finalPromptUsed); @@ -294,27 +297,27 @@ export function createWorkerController({ recipeId: parsedPrompt.recipeId, generationConfig: buildCatalogGenerationConfig(job.finalPromptUsed), }); - addJobEventFn(job.id, "dry_run.completed", "Dry run asset creation completed.", { + addJobEventFn(job.id, 'dry_run.completed', 'Dry run asset creation completed.', { durationMs: Date.now() - startedAt, assetCount: 1, }); - addJobEventFn(job.id, "asset.created", "Dry run asset created.", { assetId: asset.id }); - publishEventFn("asset.created", asset); - publishEventFn("catalog.created", catalogImage); - updateJobStatusFn(job.id, "completed"); - publishEventFn("job.completed", getJobFn(job.id)); + addJobEventFn(job.id, 'asset.created', 'Dry run asset created.', { assetId: asset.id }); + publishEventFn('asset.created', asset); + publishEventFn('catalog.created', catalogImage); + updateJobStatusFn(job.id, 'completed'); + publishEventFn('job.completed', getJobFn(job.id)); logger( - "info", - "worker", + 'info', + 'worker', `Dry run job completed. Asset: ${path.basename(asset.filePath)}`, job.id, ); } async function runCodexJob(job: Job, signal?: AbortSignal) { - addJobEventFn(job.id, "codex.started", "Codex image generation started."); - logger("info", "worker", "Codex imagegen job started.", job.id); - const turnRecordId = upsertCodexTurnFn({ jobId: job.id, status: "running" }); + addJobEventFn(job.id, 'codex.started', 'Codex image generation started.'); + logger('info', 'worker', 'Codex imagegen job started.', job.id); + const turnRecordId = upsertCodexTurnFn({ jobId: job.id, status: 'running' }); const catalogContext = resolveJobCatalogContextFn(job); const executionOptions = resolveExecutionOptions(job.execution); const result = await codexGenerationProvider.run({ @@ -322,13 +325,13 @@ export function createWorkerController({ projectId: job.projectId, prompt: job.finalPromptUsed, execution: job.execution, - providerId: job.providerId ?? job.sourceSpec?.providerId ?? "codex", + providerId: job.providerId ?? job.sourceSpec?.providerId ?? 'codex', sourceSpec: job.sourceSpec, signal, }); throwIfAborted(signal); - addJobEventFn(job.id, "codex.completed", "Codex image generation completed.", { + addJobEventFn(job.id, 'codex.completed', 'Codex image generation completed.', { durationMs: result.durationMs, assetCount: result.assets.length, threadId: result.threadId, @@ -341,16 +344,16 @@ export function createWorkerController({ codexThreadId: result.threadId, codexTurnId: result.turnId, transcriptPath: result.transcript, - status: result.assets.length > 0 ? "completed" : "needs_review", + status: result.assets.length > 0 ? 'completed' : 'needs_review', }); const discoveredImagePath = result.assets[0]?.sourcePath ?? null; if (!discoveredImagePath) { - updateJobStatusFn(job.id, "needs_review"); - publishEventFn("job.progress", getJobFn(job.id)); + updateJobStatusFn(job.id, 'needs_review'); + publishEventFn('job.progress', getJobFn(job.id)); logger( - "warn", - "worker", + 'warn', + 'worker', `Codex turn completed but no image file was discovered. Transcript: ${result.transcript}`, job.id, ); @@ -361,9 +364,9 @@ export function createWorkerController({ job, catalogContext, discoveredImagePath, - providerId: "codex", + providerId: 'codex', options: { - logPrefix: "Codex", + logPrefix: 'Codex', embedMetadata: true, executionOptions, }, @@ -371,9 +374,9 @@ export function createWorkerController({ } async function runExternalJob(job: Job, signal?: AbortSignal) { - const providerId = job.providerId ?? job.sourceSpec?.providerId ?? "unknown"; - addJobEventFn(job.id, "external.started", `External provider job started: ${providerId}.`); - logger("info", "worker", `External provider job started: ${providerId}.`, job.id); + const providerId = job.providerId ?? job.sourceSpec?.providerId ?? 'unknown'; + addJobEventFn(job.id, 'external.started', `External provider job started: ${providerId}.`); + logger('info', 'worker', `External provider job started: ${providerId}.`, job.id); const catalogContext = resolveJobCatalogContextFn(job); const result = await externalGenerationProvider.run({ @@ -388,7 +391,7 @@ export function createWorkerController({ throwIfAborted(signal); - addJobEventFn(job.id, "external.completed", "External provider execution completed.", { + addJobEventFn(job.id, 'external.completed', 'External provider execution completed.', { transcript: result.transcript, durationMs: result.durationMs, assetCount: result.assets.length, @@ -396,11 +399,11 @@ export function createWorkerController({ const discoveredImagePath = result.assets[0]?.sourcePath ?? null; if (!discoveredImagePath) { - updateJobStatusFn(job.id, "needs_review"); - publishEventFn("job.progress", getJobFn(job.id)); + updateJobStatusFn(job.id, 'needs_review'); + publishEventFn('job.progress', getJobFn(job.id)); logger( - "warn", - "worker", + 'warn', + 'worker', `External provider completed but no image file was discovered. Transcript: ${result.transcript}`, job.id, ); @@ -413,7 +416,7 @@ export function createWorkerController({ discoveredImagePath, providerId, options: { - logPrefix: "External provider", + logPrefix: 'External provider', }, }); } @@ -423,35 +426,40 @@ export function createWorkerController({ runningJobControllers.set(job.id, controller); try { - addJobEventFn(job.id, "job.started", "Job execution started.", { + addJobEventFn(job.id, 'job.started', 'Job execution started.', { startedAt: new Date().toISOString(), }); - updateJobStatusFn(job.id, "running"); - publishEventFn("job.running", getJobFn(job.id)); + updateJobStatusFn(job.id, 'running'); + publishEventFn('job.running', getJobFn(job.id)); const runtimeTarget = resolveWorkerRuntimeTargetFn(job); - if (runtimeTarget === "dry_run") { + if (runtimeTarget === 'dry_run') { await runDryJob(job, controller.signal); - } else if (runtimeTarget === "codex") { + } else if (runtimeTarget === 'codex') { await runCodexJob(job, controller.signal); - } else if (runtimeTarget === "external") { + } else if (runtimeTarget === 'external') { await runExternalJob(job, controller.signal); } else { - throw new Error( - `Unsupported job kind received by worker: kind=${job.kind} provider=${job.providerId ?? job.sourceSpec?.providerId ?? "null"} sourceTask=${job.sourceSpec?.task ?? "null"}`, + throw createUnsupportedRuntimeTargetError( + { + kind: job.kind, + providerId: job.providerId ?? job.sourceSpec?.providerId ?? null, + sourceTask: job.sourceSpec?.task ?? null, + }, + { jobId: job.id }, ); } } catch (error) { if (isAbortError(error)) { - addJobEventFn(job.id, "job.cancelled", "Job cancelled by user."); - updateJobStatusFn(job.id, "cancelled"); - publishEventFn("job.cancelled", getJobFn(job.id)); - logger("info", "worker", "Job cancelled by user.", job.id); + addJobEventFn(job.id, 'job.cancelled', 'Job cancelled by user.'); + updateJobStatusFn(job.id, 'cancelled'); + publishEventFn('job.cancelled', getJobFn(job.id)); + logger('info', 'worker', 'Job cancelled by user.', job.id); } else { - const message = error instanceof Error ? error.message : String(error); - updateJobStatusFn(job.id, "failed", message); - publishEventFn("job.failed", getJobFn(job.id)); - logger("error", "worker", message, job.id); + const message = formatWorkerErrorMessage(error); + updateJobStatusFn(job.id, 'failed', message); + publishEventFn('job.failed', getJobFn(job.id)); + logger('error', 'worker', message, job.id); } } finally { runningJobControllers.delete(job.id); @@ -490,18 +498,18 @@ export function createWorkerController({ if (queuedIndex >= 0) { jobQueue.splice(queuedIndex, 1); runningJobs.delete(jobId); - addJobEventFn(jobId, "job.cancelled", "Queued job cancelled before execution."); - const job = updateJobStatusFn(jobId, "cancelled"); - publishEventFn("job.cancelled", job); - logger("info", "worker", "Queued job cancelled before execution.", jobId); + addJobEventFn(jobId, 'job.cancelled', 'Queued job cancelled before execution.'); + const job = updateJobStatusFn(jobId, 'cancelled'); + publishEventFn('job.cancelled', job); + logger('info', 'worker', 'Queued job cancelled before execution.', jobId); return job; } const controller = runningJobControllers.get(jobId); if (controller) { - addJobEventFn(jobId, "job.cancel.requested", "Cancellation requested for running job."); + addJobEventFn(jobId, 'job.cancel.requested', 'Cancellation requested for running job.'); controller.abort(); - logger("info", "worker", "Cancellation requested for running job.", jobId); + logger('info', 'worker', 'Cancellation requested for running job.', jobId); return getJobFn(jobId); } @@ -520,14 +528,14 @@ export function createWorkerController({ for (const queuedJob of queuedJobs) { runningJobs.delete(queuedJob.id); - addJobEventFn(queuedJob.id, "job.cancelled", "Queued job cancelled during studio reset."); - updateJobStatusFn(queuedJob.id, "cancelled"); - publishEventFn("job.cancelled", getJobFn(queuedJob.id)); + addJobEventFn(queuedJob.id, 'job.cancelled', 'Queued job cancelled during studio reset.'); + updateJobStatusFn(queuedJob.id, 'cancelled'); + publishEventFn('job.cancelled', getJobFn(queuedJob.id)); } for (const [jobId, controller] of runningJobControllers.entries()) { if (!controller.signal.aborted) { - addJobEventFn(jobId, "job.cancel.requested", "Studio reset requested cancellation."); + addJobEventFn(jobId, 'job.cancel.requested', 'Studio reset requested cancellation.'); controller.abort(); } } diff --git a/apps/local-server/src/workerAssetFinalizer.test.ts b/apps/local-server/src/workerAssetFinalizer.test.ts index 11c3e643..06fc6a2d 100644 --- a/apps/local-server/src/workerAssetFinalizer.test.ts +++ b/apps/local-server/src/workerAssetFinalizer.test.ts @@ -1,22 +1,24 @@ -import { describe, expect, it, vi } from "vite-plus/test"; -import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; -import os from "node:os"; -import path from "node:path"; -import type { Job } from "../../../packages/shared/src"; -import { createWorkerAssetFinalizer } from "./workerAssetFinalizer"; +import { describe, expect, it, vi } from 'vite-plus/test'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import type { Job } from '../../../packages/shared/src'; +import type { PromptTransportSnapshot } from '../../../packages/shared/src/promptTransport'; +import type { EmbedResult, ImageGenMetadata } from './metadataEmbedder'; +import { createWorkerAssetFinalizer } from './workerAssetFinalizer'; function createJob(overrides: Partial = {}): Job { return { - id: overrides.id ?? "job-finalizer-1", - projectId: overrides.projectId ?? "project-1", - kind: overrides.kind ?? "image_generate", - providerId: overrides.providerId ?? "codex", + id: overrides.id ?? 'job-finalizer-1', + projectId: overrides.projectId ?? 'project-1', + kind: overrides.kind ?? 'image_generate', + providerId: overrides.providerId ?? 'codex', sourceSpec: overrides.sourceSpec ?? null, - status: overrides.status ?? "running", + status: overrides.status ?? 'running', execution: overrides.execution ?? null, - originalPrompt: overrides.originalPrompt ?? "prompt", + originalPrompt: overrides.originalPrompt ?? 'prompt', expandedPrompt: overrides.expandedPrompt ?? null, - finalPromptUsed: overrides.finalPromptUsed ?? "prompt", + finalPromptUsed: overrides.finalPromptUsed ?? 'prompt', error: overrides.error ?? null, createdAt: overrides.createdAt ?? new Date().toISOString(), updatedAt: overrides.updatedAt ?? new Date().toISOString(), @@ -24,37 +26,76 @@ function createJob(overrides: Partial = {}): Job { }; } -describe("workerAssetFinalizer", () => { - it("finalizes asset using organized path for file and public URL", async () => { - const tempRoot = mkdtempSync(path.join(os.tmpdir(), "worker-asset-finalizer-")); - const organizedPath = path.join(tempRoot, "outputs", "final.png"); +describe('workerAssetFinalizer', () => { + it('finalizes asset using organized path for file and public URL', async () => { + const tempRoot = mkdtempSync(path.join(os.tmpdir(), 'worker-asset-finalizer-')); + const organizedPath = path.join(tempRoot, 'outputs', 'final.png'); mkdirSync(path.dirname(organizedPath), { recursive: true }); - writeFileSync(organizedPath, "png", "utf8"); + writeFileSync(organizedPath, 'png', 'utf8'); const addAsset = vi.fn(() => ({ - id: "asset-1", - projectId: "project-1", - jobId: "job-finalizer-1", + id: 'asset-1', + projectId: 'project-1', + jobId: 'job-finalizer-1', filePath: organizedPath, thumbnailPath: null, - publicUrl: "/library/outputs/final.png", - prompt: "prompt", + publicUrl: '/library/outputs/final.png', + prompt: 'prompt', width: null, height: null, - mimeType: "image/png", + mimeType: 'image/png', createdAt: new Date().toISOString(), deletedAt: null, })); const registerCatalogImage = vi.fn(() => ({ - id: "catalog-1", - libraryId: "library-1", + id: 'catalog-1', + libraryId: 'library-1', + filePath: organizedPath, + thumbnailPath: null, + publicUrl: '/library/outputs/final.png', + thumbnailUrl: null, + prompt: 'prompt', + negativePrompt: null, + aspectRatio: null, + imageSize: null, + width: null, + height: null, + mimeType: 'image/png', + fileSizeBytes: 3, + jobId: 'job-finalizer-1', + workspaceId: 'workspace-1', + batchId: 'batch-1', + recipeId: null, + isFavorite: false, + isDeleted: false, + deletedAt: null, + tags: [], + generationConfig: null, + createdAt: new Date().toISOString(), })); const publishEvent = vi.fn(); const updateJobStatus = vi.fn(); const getJob = vi.fn(() => createJob()); - const toPublicAssetUrl = vi.fn(() => "/library/outputs/final.png"); + const toPublicAssetUrl = vi.fn(() => '/library/outputs/final.png'); const addJobEvent = vi.fn(); const logger = vi.fn(); + const embedMetadataMock = vi.fn< + (filePath: string, metadata: ImageGenMetadata) => Promise + >(async () => ({ + filePath: organizedPath, + bytesWritten: 3, + format: 'png', + })); + const parsePromptTransportMock = vi.fn< + (prompt: string | null | undefined) => PromptTransportSnapshot + >(() => ({ + prompt: 'prompt', + negativePrompt: '', + aspectRatio: '1:1', + imageSize: '1024x1024', + recipeId: null, + recipeContext: '', + })); const finalizer = createWorkerAssetFinalizer({ registerCatalogImage, @@ -65,38 +106,31 @@ describe("workerAssetFinalizer", () => { getJob, toPublicAssetUrl, logger, - embedMetadata: vi.fn(async () => {}), - parsePromptTransport: vi.fn(() => ({ - prompt: "prompt", - negativePrompt: null, - aspectRatio: "1:1", - imageSize: "1024x1024", - recipeId: null, - recipeContext: null, - })), + embedMetadata: embedMetadataMock, + parsePromptTransport: parsePromptTransportMock, resolveExecutionOptions: vi.fn(() => ({ - model: "gpt-5.4-mini", - reasoningEffort: "medium", + model: 'gpt-5.4-mini', + reasoningEffort: 'medium', serviceTier: null, })), resolveCatalogGenerationConfig: vi.fn(() => ({ - prompt: "prompt", + prompt: 'prompt', })), organizeGeneratedAssetPath: vi.fn(() => organizedPath), - inferGeneratedAssetMimeType: vi.fn(() => "image/png"), + inferGeneratedAssetMimeType: vi.fn(() => 'image/png'), }); try { await finalizer.finalizeJobAsset({ job: createJob(), catalogContext: { - workspaceId: "workspace-1", - batchId: "batch-1", + workspaceId: 'workspace-1', + batchId: 'batch-1', }, - discoveredImagePath: "D:/tmp/discovered.png", - providerId: "codex", + discoveredImagePath: 'D:/tmp/discovered.png', + providerId: 'codex', options: { - logPrefix: "Codex", + logPrefix: 'Codex', }, }); @@ -104,11 +138,11 @@ describe("workerAssetFinalizer", () => { expect(addAsset).toHaveBeenCalledWith( expect.objectContaining({ filePath: organizedPath, - publicUrl: "/library/outputs/final.png", + publicUrl: '/library/outputs/final.png', }), ); - expect(updateJobStatus).toHaveBeenCalledWith("job-finalizer-1", "completed"); - expect(publishEvent).toHaveBeenCalledWith("job.completed", expect.anything()); + expect(updateJobStatus).toHaveBeenCalledWith('job-finalizer-1', 'completed'); + expect(publishEvent).toHaveBeenCalledWith('job.completed', expect.anything()); } finally { rmSync(tempRoot, { recursive: true, force: true }); } diff --git a/apps/local-server/src/workerErrors.test.ts b/apps/local-server/src/workerErrors.test.ts new file mode 100644 index 00000000..9ca7717c --- /dev/null +++ b/apps/local-server/src/workerErrors.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { + WorkerError, + createAbortWorkerError, + createUnsupportedRuntimeTargetError, + formatWorkerErrorMessage, +} from './workerErrors'; + +describe('workerErrors', () => { + it('creates abort error with AbortError name and code', () => { + const error = createAbortWorkerError({ jobId: 'job-1' }); + expect(error.name).toBe('AbortError'); + expect(error.code).toBe('abort'); + expect(error.meta).toMatchObject({ jobId: 'job-1' }); + }); + + it('creates unsupported runtime target error with structured metadata', () => { + const error = createUnsupportedRuntimeTargetError( + { + kind: 'unknown-kind', + providerId: 'local-experiment', + sourceTask: null, + }, + { jobId: 'job-2' }, + ); + + expect(error).toBeInstanceOf(WorkerError); + expect(error.code).toBe('unsupported_runtime_target'); + expect(error.message).toContain('Unsupported job kind received by worker'); + expect(error.meta).toMatchObject({ + jobId: 'job-2', + jobKind: 'unknown-kind', + providerId: 'local-experiment', + sourceTask: null, + }); + }); + + it('formats worker and non-worker errors safely', () => { + expect(formatWorkerErrorMessage(new Error('boom'))).toBe('boom'); + expect(formatWorkerErrorMessage('plain string')).toBe('plain string'); + expect(formatWorkerErrorMessage({ detail: 1 })).toBe('[object Object]'); + }); +}); diff --git a/apps/local-server/src/workerErrors.ts b/apps/local-server/src/workerErrors.ts new file mode 100644 index 00000000..e8979b05 --- /dev/null +++ b/apps/local-server/src/workerErrors.ts @@ -0,0 +1,56 @@ +export type WorkerErrorCode = 'abort' | 'unsupported_runtime_target' | 'unknown_worker_error'; + +export interface WorkerErrorMeta { + jobId?: string; + jobKind?: string; + providerId?: string | null; + sourceTask?: string | null; +} + +export class WorkerError extends Error { + readonly code: WorkerErrorCode; + readonly meta: WorkerErrorMeta; + + constructor(code: WorkerErrorCode, message: string, meta: WorkerErrorMeta = {}) { + super(message); + this.name = 'WorkerError'; + this.code = code; + this.meta = meta; + } +} + +export function createAbortWorkerError(meta: WorkerErrorMeta = {}) { + const error = new WorkerError('abort', 'Operation cancelled by user', meta); + error.name = 'AbortError'; + return error; +} + +export function createUnsupportedRuntimeTargetError( + input: { + kind: string; + providerId: string | null; + sourceTask: string | null; + }, + meta: WorkerErrorMeta = {}, +) { + return new WorkerError( + 'unsupported_runtime_target', + `Unsupported job kind received by worker: kind=${input.kind} provider=${input.providerId ?? 'null'} sourceTask=${input.sourceTask ?? 'null'}`, + { + ...meta, + jobKind: input.kind, + providerId: input.providerId, + sourceTask: input.sourceTask, + }, + ); +} + +export function formatWorkerErrorMessage(error: unknown) { + if (error instanceof WorkerError) { + return error.message; + } + if (error instanceof Error) { + return error.message; + } + return String(error); +} diff --git a/bun.lock b/bun.lock index 9d7485b8..a5e8d033 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@fal-ai/client": "^1.10.1", "@gsap/react": "^2.1.2", "clsx": "^2.1.1", + "effect": "^3.19.6", "file-saver": "^2.0.5", "gsap": "^3.15.0", "hono": "^4.12.23", @@ -465,6 +466,8 @@ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + "effect": ["effect@3.21.2", "", { "dependencies": { "@standard-schema/spec": "^1.0.0", "fast-check": "^3.23.1" } }, "sha512-rXd2FGDM8KdjSIrc+mqEELo7ScW7xTVxEf1iInmPSpIde9/nyGuFM710cjTo7/EreGXiUX2MOonPpprbz2XHCg=="], + "electron": ["electron@42.3.0", "", { "dependencies": { "@electron/get": "^5.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js", "install-electron": "install.js" } }, "sha512-9ZiLdRXk+WDxW1OgIUz8J2rIQ5TYU9o629gCOjU48Q3dQiOmym7osWsH5Ubs/Jh4uuFLn6m6SBD2rmRXLAPz9g=="], "electron-to-chromium": ["electron-to-chromium@1.5.352", "", {}, "sha512-9wHk8x6dyuimoe18EdiDPWKExNdxYqo4fn4FwOVVper6RxT3cmpBwBkWWfSOCYJjQdIco/nPhJhNLmn4Ufg1Yg=="], @@ -487,6 +490,8 @@ "extract-zip": ["extract-zip@2.0.1", "", { "dependencies": { "debug": "^4.1.1", "get-stream": "^5.1.0", "yauzl": "^2.10.0" }, "optionalDependencies": { "@types/yauzl": "^2.9.1" }, "bin": { "extract-zip": "cli.js" } }, "sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg=="], + "fast-check": ["fast-check@3.23.2", "", { "dependencies": { "pure-rand": "^6.1.0" } }, "sha512-h5+1OzzfCC3Ef7VbtKdcv7zsstUQwUDlYpUTvjeUsJAssPgLn7QzbboPtL5ro04Mq0rPOsMzl7q5hIbRs2wD1A=="], + "fd-slicer": ["fd-slicer@1.1.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g=="], "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" } }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], @@ -629,6 +634,8 @@ "pump": ["pump@3.0.4", "", { "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" } }, "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA=="], + "pure-rand": ["pure-rand@6.1.0", "", {}, "sha512-bVWawvoZoBYpp6yIoQtQXHZjmz35RSVHnUOTefl8Vcjr8snTPY1wnpSPMWekcFwbxI6gtmT7rSYPFvz71ldiOA=="], + "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], "react-dom": ["react-dom@19.2.6", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.6" } }, "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g=="], diff --git a/components/AppContent.tsx b/components/AppContent.tsx index f00a662f..cab33145 100644 --- a/components/AppContent.tsx +++ b/components/AppContent.tsx @@ -14,7 +14,7 @@ const AppOverlays = React.lazy(() => import('./AppOverlays').then((m) => ({ default: m.AppOverlays })), ); -interface AppContentProps {} +interface AppContentProps { } export const AppContent: React.FC = () => { const shell = useStudioShell(); @@ -39,19 +39,15 @@ export const AppContent: React.FC = () => { {shell.headerToolbar.isVisible && } -
- + diff --git a/components/overlays/StudioSystemOverlays.tsx b/components/overlays/StudioSystemOverlays.tsx index 483cf7e6..b18f0c04 100644 --- a/components/overlays/StudioSystemOverlays.tsx +++ b/components/overlays/StudioSystemOverlays.tsx @@ -27,12 +27,6 @@ export const StudioSystemOverlays: React.FC = ({ isOnboardingReady, isStartingAppServer, isSettingsModalOpen, - isLoadingSettings, - isSavingSettings, - isLoadingOutputSources, - isRegisteringOutputSource, - isBackgroundEnabled, - isResettingStudio, }, closeDebugPanel, mergedLogs, @@ -56,24 +50,40 @@ export const StudioSystemOverlays: React.FC = ({ completeOnboarding, refreshOnboardingHealth, ensureAppServer, - closeSettings, - settings, - settingsError, - providerCapabilities, - providerRuntimePreflight, - outputSources, - outputSourceFiles, - loadingOutputSourceFiles, - importingOutputSources, - settingsLibraryDir, - refreshSettings, - updateSettings, - registerOutputSource, - loadOutputSourceFiles, - importOutputSourceFiles, - onToggleBackground, - onResetStudio, + settingsModule, }) => { + const { + close: closeSettings, + settingsDomain: { + settings, + error: settingsError, + isLoading: isLoadingSettings, + isSaving: isSavingSettings, + refresh: refreshSettings, + update: updateSettings, + }, + providerDomain: { + capabilities: providerCapabilities, + runtimePreflight: providerRuntimePreflight, + }, + outputSourcesDomain: { + outputSources, + outputSourceFiles, + isLoadingOutputSources, + loadingOutputSourceFiles, + isRegisteringOutputSource, + importingOutputSources, + registerOutputSource, + loadOutputSourceFiles, + importOutputSourceFiles, + }, + libraryDir: settingsLibraryDir, + isBackgroundEnabled, + onToggleBackground, + onResetStudio, + isResettingStudio, + } = settingsModule; + const mountedSurfaces = getMountedSystemSurfaceKeys({ isDebugPanelOpen, isDashboardModalOpen, diff --git a/components/overlays/types.ts b/components/overlays/types.ts index 5abf0e42..b63f4d6c 100644 --- a/components/overlays/types.ts +++ b/components/overlays/types.ts @@ -86,27 +86,41 @@ export interface StudioSystemOverlaysProps { completeOnboarding: () => void; refreshOnboardingHealth: () => void; ensureAppServer: () => void; - closeSettings: () => void; - settings: EditableStudioSettings | null; - settingsError: string | null; - providerCapabilities: GenerationProviderCapabilitiesResponse | null; - providerRuntimePreflight: GenerationProviderRuntimePreflightResponse | null; - outputSources: ExternalOutputSourcesResponse | null; - outputSourceFiles: Record; - loadingOutputSourceFiles: Record; - importingOutputSources: Record; - settingsLibraryDir: string | null; - refreshSettings: () => void | Promise; - updateSettings: (patch: EditableStudioSettingsPatch) => void | Promise; - registerOutputSource: (input: RegisterExternalOutputSourceInput) => void | Promise; - loadOutputSourceFiles: (sourceId: string) => void | Promise; - importOutputSourceFiles: ( - sourceId: string, - files: string[], - workspaceId?: string | null, - ) => void | Promise; - onToggleBackground: () => void; - onResetStudio: () => void | Promise; + settingsModule: { + close: () => void; + settingsDomain: { + settings: EditableStudioSettings | null; + error: string | null; + isLoading: boolean; + isSaving: boolean; + refresh: () => void | Promise; + update: (patch: EditableStudioSettingsPatch) => void | Promise; + }; + providerDomain: { + capabilities: GenerationProviderCapabilitiesResponse | null; + runtimePreflight: GenerationProviderRuntimePreflightResponse | null; + }; + outputSourcesDomain: { + outputSources: ExternalOutputSourcesResponse | null; + outputSourceFiles: Record; + isLoadingOutputSources: boolean; + loadingOutputSourceFiles: Record; + isRegisteringOutputSource: boolean; + importingOutputSources: Record; + registerOutputSource: (input: RegisterExternalOutputSourceInput) => void | Promise; + loadOutputSourceFiles: (sourceId: string) => void | Promise; + importOutputSourceFiles: ( + sourceId: string, + files: string[], + workspaceId?: string | null, + ) => void | Promise; + }; + libraryDir: string | null; + isBackgroundEnabled: boolean; + onToggleBackground: () => void; + onResetStudio: () => void | Promise; + isResettingStudio: boolean; + }; } export interface StudioWorkspaceOverlaysProps { diff --git a/components/recipes/StylePresetCatalogSearchSurface.tsx b/components/recipes/StylePresetCatalogSearchSurface.tsx index ad4ee6d1..8aa9de0a 100644 --- a/components/recipes/StylePresetCatalogSearchSurface.tsx +++ b/components/recipes/StylePresetCatalogSearchSurface.tsx @@ -1,7 +1,13 @@ import React, { useEffect, useMemo, useState } from 'react'; import { Search, X, ArrowRight, Database, Sparkles, LoaderCircle } from 'lucide-react'; -import { STYLE_DEFAULT_IMAGES, STYLE_PACK_FALLBACK_IMAGES } from '../../lib/recipeAssetCatalog'; -import { resolveStyleCatalogResultImage } from '../../lib/stylePresetVisuals'; +import { + STYLE_CATEGORY_IMAGES, + STYLE_CATEGORY_PREVIEWS, + STYLE_DEFAULT_IMAGES, + STYLE_PACK_FALLBACK_IMAGES, +} from '../../lib/recipeAssetCatalog'; +import { styleCategoryImageKey } from '../../lib/recipeAssetKeys'; +import { resolveStyleCatalogResultImage, resolveStylePreviewImage } from '../../lib/stylePresetVisuals'; import { searchStylePresetCatalog, @@ -50,11 +56,11 @@ export const StylePresetCatalogSearchSurface: React.FC catalog ? searchStylePresetCatalog(catalog, { - query, - packId: packId || undefined, - task: task || undefined, - limit: 80, - }) + query, + packId: packId || undefined, + task: task || undefined, + limit: 80, + }) : [], [catalog, packId, query, task], ); @@ -137,11 +143,10 @@ export const StylePresetCatalogSearchSurface: React.FC setTask(filter.id)} - className={`h-8 rounded-lg px-2.5 text-[9px] font-black uppercase tracking-widest transition-colors ${ - task === filter.id + className={`h-8 rounded-lg px-2.5 text-[9px] font-black uppercase tracking-widest transition-colors ${task === filter.id ? 'bg-white text-black' : 'text-zinc-500 hover:bg-white/8 hover:text-white' - }`} + }`} > {filter.label} @@ -158,12 +163,21 @@ export const StylePresetCatalogSearchSurface: React.FC 0 ? (
{results.map((result) => { - const resultImage = resolveStyleCatalogResultImage({ + const resultImageFromDefault = resolveStyleCatalogResultImage({ presetId: result.id, packId: result.packId, defaultImages: STYLE_DEFAULT_IMAGES, packFallbackImages: STYLE_PACK_FALLBACK_IMAGES, }); + const categoryImage = STYLE_CATEGORY_IMAGES[ + styleCategoryImageKey(result.packId, result.categoryName) + ]; + const resultImageFromPreview = resolveStylePreviewImage({ + categoryImage, + categoryPreviewImage: STYLE_CATEGORY_PREVIEWS[result.categoryName], + packFallbackImage: STYLE_PACK_FALLBACK_IMAGES[result.packId], + }); + const resultImage = resultImageFromDefault || resultImageFromPreview; return (