diff --git a/client/src/pages/CodeAnimation.jsx b/client/src/pages/CodeAnimation.jsx index 9d4bad3a05..8cc82c06e7 100644 --- a/client/src/pages/CodeAnimation.jsx +++ b/client/src/pages/CodeAnimation.jsx @@ -5,15 +5,19 @@ import PageHeader from '../components/PageHeader'; import ProviderModelSelector from '../components/ProviderModelSelector'; import AlbumTrackPicker from '../components/music/AlbumTrackPicker'; import CodeAnimationPreview from '../components/codeAnimation/CodeAnimationPreview'; +import InfiniteScrollFooter from '../components/ui/InfiniteScrollFooter'; import useProviderModels from '../hooks/useProviderModels'; +import { usePagedCollection } from '../hooks/usePagedCollection'; +import { useSocketSubscription } from '../hooks/useSocketSubscription'; import { useAutoRefetch } from '../hooks/useAutoRefetch'; +import socket from '../services/socket'; import toast from '../components/ui/Toast'; import { buildCodeAnimationPrompt, generateCodeAnimationBrief, getCodeAnimationJob, getCodeAnimationOptions, - listCodeAnimationJobs, + listCodeAnimationJobPage, listMoodBoardNames, listTracks, listUniverseNames, @@ -28,7 +32,6 @@ import { formatCount, timeAgo } from '../utils/formatters'; const DRAFT_KEY = 'portos.codeAnimation.draft'; const JOB_POLL_MS = 3_000; -const GALLERY_POLL_MS = 10_000; // Mood-board choice sentinels: follow the universe's linked board, or none. const BOARD_FOLLOW_UNIVERSE = 'universe'; const BOARD_NONE = 'none'; @@ -231,7 +234,6 @@ function galleryJob(job) { id: job.id, status: job.status, title: job.title, - concept: job.concept || job.input?.concept || '', providerId: job.providerId, model: job.model, error: job.error, @@ -289,9 +291,7 @@ export default function CodeAnimation() { const [built, setBuilt] = useState(null); const [starting, setStarting] = useState(false); const [job, setJob] = useState(null); - const [savedJobs, setSavedJobs] = useState([]); - const [galleryLoaded, setGalleryLoaded] = useState(false); - const [galleryError, setGalleryError] = useState(''); + const [galleryCounts, setGalleryCounts] = useState({ running: 0, completed: 0 }); const [effort, setEffort] = useState(''); const [briefEffort, setBriefEffort] = useState(''); const [pastedHtml, setPastedHtml] = useState(''); @@ -299,7 +299,6 @@ export default function CodeAnimation() { const jobIdRef = useRef(jobId); const hydratedJobIdRef = useRef(''); const locallyStartedJobIdRef = useRef(''); - const galleryRequestRef = useRef(0); const { providers, selectedProviderId, @@ -322,21 +321,20 @@ export default function CodeAnimation() { const update = (patch) => setDraft((prev) => ({ ...prev, ...patch })); const updateFormat = (patch) => setDraft((prev) => ({ ...prev, format: { ...prev.format, ...patch } })); - const refreshGallery = useCallback(async () => { - const requestId = ++galleryRequestRef.current; - const rows = await listCodeAnimationJobs({ silent: true }).catch((error) => { - if (requestId !== galleryRequestRef.current) return null; - setGalleryError(error.message || 'Failed to load animation gallery'); - setGalleryLoaded(true); - return null; - }); - if (requestId !== galleryRequestRef.current) return; - if (!Array.isArray(rows)) return; - setSavedJobs(rows); - setGalleryError(''); - setGalleryLoaded(true); + const fetchGalleryPage = useCallback(async ({ cursor, signal }) => { + const page = await listCodeAnimationJobPage({ cursor, signal }); + if (!signal.aborted) setGalleryCounts(page.counts); + return page; }, []); - useAutoRefetch(refreshGallery, GALLERY_POLL_MS, { enabled: true, pollOnly: true }); + const gallery = usePagedCollection(fetchGalleryPage); + const savedJobs = gallery.items; + const setSavedJobs = gallery.setItems; + useSocketSubscription('code-animation', { onResubscribe: gallery.refreshFirst }); + useEffect(() => { + const refresh = () => gallery.refreshFirst(); + socket.on('code-animation:changed', refresh); + return () => socket.off('code-animation:changed', refresh); + }, [gallery.refreshFirst]); useEffect(() => { safeWriteJsonStorage(DRAFT_KEY, draft); }, [draft]); @@ -365,8 +363,8 @@ export default function CodeAnimation() { // blank-slate idea generator. const briefSeeds = !!(draft.universeId || draft.seedIdea.trim() || draft.concept.trim() || draft.title.trim()); const canWriteBrief = briefSeeds && !writingBrief; - const inProgressCount = savedJobs.filter((item) => item.status === 'running').length; - const completedCount = savedJobs.filter((item) => item.status === 'completed').length; + const inProgressCount = galleryCounts.running; + const completedCount = galleryCounts.completed; // Poll the generation job named in the URL until it settles. The ref drops a // response for a job the user has since replaced. @@ -391,7 +389,6 @@ export default function CodeAnimation() { if (next.status === 'completed' && next.html) setPreview({ html: next.html, audioUrl: next.audioUrl, frame: next.frame }); else setPreview(null); if (next.status !== 'missing') { - galleryRequestRef.current += 1; setSavedJobs((previous) => [galleryJob(next), ...previous.filter((item) => item.id !== requested)] .sort((a, b) => (b.createdAt || '').localeCompare(a.createdAt || ''))); } @@ -534,7 +531,6 @@ export default function CodeAnimation() { setBuilt({ prompt: started.prompt, attachments: started.attachments, frame: started.frame, audioUrl: started.audioUrl, moodBoardId: started.moodBoardId, briefKey }); setJob(started); setPreview(null); - galleryRequestRef.current += 1; setSavedJobs((previous) => [galleryJob(started), ...previous.filter((item) => item.id !== started.id)]); navigate(`/code-animation/${encodeURIComponent(started.id)}`); }; @@ -567,9 +563,8 @@ export default function CodeAnimation() { New animation - {galleryError &&

{galleryError}

} - {!galleryLoaded &&

Loading animations…

} - {galleryLoaded && savedJobs.length === 0 && !galleryError && ( + {!gallery.loaded &&

Loading animations…

} + {gallery.loaded && savedJobs.length === 0 && !gallery.error && (

Generated animations will appear here so you can reopen them later.

)} {savedJobs.length > 0 && ( @@ -594,7 +589,7 @@ export default function CodeAnimation() { >
-

{item.title || item.concept || 'Untitled animation'}

+

{item.title || 'Untitled animation'}

{statusLabel}{item.model ? ` · ${item.model}` : ''} @@ -605,6 +600,8 @@ export default function CodeAnimation() { })}
)} +
diff --git a/client/src/pages/CodeAnimation.test.jsx b/client/src/pages/CodeAnimation.test.jsx index 1492961e35..ee4dcc2bbe 100644 --- a/client/src/pages/CodeAnimation.test.jsx +++ b/client/src/pages/CodeAnimation.test.jsx @@ -4,13 +4,24 @@ import userEvent from '@testing-library/user-event'; import { MemoryRouter, Route, Routes } from 'react-router'; const pollHarness = vi.hoisted(() => ({ callbacks: new Map() })); +const socketHarness = vi.hoisted(() => ({ handlers: new Map() })); + +vi.mock('../services/socket', () => ({ default: { + emit: vi.fn(), + on: vi.fn((event, handler) => { + const handlers = socketHarness.handlers.get(event) || new Set(); + handlers.add(handler); + socketHarness.handlers.set(event, handlers); + }), + off: vi.fn((event, handler) => socketHarness.handlers.get(event)?.delete(handler)), +} })); vi.mock('../services/api', () => ({ buildCodeAnimationPrompt: vi.fn(), generateCodeAnimationBrief: vi.fn(), getCodeAnimationJob: vi.fn(), getCodeAnimationOptions: vi.fn(), - listCodeAnimationJobs: vi.fn().mockResolvedValue([]), + listCodeAnimationJobPage: vi.fn().mockResolvedValue({ items: [], total: 0, counts: { running: 0, completed: 0 }, nextCursor: null }), listMoodBoardNames: vi.fn(), listTracks: vi.fn().mockResolvedValue([]), listUniverseNames: vi.fn(), @@ -42,6 +53,7 @@ import { buildCodeAnimationPrompt, generateCodeAnimationBrief, getCodeAnimationJob, + listCodeAnimationJobPage, getCodeAnimationOptions, listMoodBoardNames, listTracks, @@ -77,6 +89,8 @@ describe('Code Animation page', () => { beforeEach(() => { vi.clearAllMocks(); pollHarness.callbacks.clear(); + socketHarness.handlers.clear(); + listCodeAnimationJobPage.mockResolvedValue({ items: [], total: 0, counts: { running: 0, completed: 0 }, nextCursor: null }); localStorage.clear(); getCodeAnimationOptions.mockResolvedValue(OPTIONS); listUniverseNames.mockResolvedValue([{ id: 'u1', name: 'Example Universe' }]); @@ -106,6 +120,28 @@ describe('Code Animation page', () => { expect(screen.getByText('Reference images (0/8)')).toBeInTheDocument(); }); + it('loads one compact page, stays idle, and refreshes on durable changes and reconnect', async () => { + const jobs = Array.from({ length: 50 }, (_, n) => ({ + id: `job-${n}`, status: 'completed', title: `Animation ${n}`, createdAt: '2026-01-01T00:00:00.000Z', + })); + listCodeAnimationJobPage.mockResolvedValue({ items: jobs, total: 1000, + counts: { running: 0, completed: 1000 }, nextCursor: 'next-page' }); + await renderPage(); + await waitFor(() => expect(screen.getAllByRole('link', { name: /Animation \d+/ })).toHaveLength(50)); + expect(listCodeAnimationJobPage).toHaveBeenCalledTimes(1); + expect(pollHarness.callbacks.has(10_000)).toBe(false); + expect(screen.getByText('0 in progress · 1,000 completed')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Load older animations' })).toBeInTheDocument(); + vi.useFakeTimers(); + await act(async () => { vi.advanceTimersByTime(60_000); }); + vi.useRealTimers(); + expect(listCodeAnimationJobPage).toHaveBeenCalledTimes(1); + await act(async () => { for (const handler of socketHarness.handlers.get('code-animation:changed') || []) handler({ id: 'job-0' }); }); + await waitFor(() => expect(listCodeAnimationJobPage).toHaveBeenCalledTimes(2)); + await act(async () => { for (const handler of socketHarness.handlers.get('connect') || []) handler(); }); + await waitFor(() => expect(listCodeAnimationJobPage).toHaveBeenCalledTimes(3)); + }); + it('builds a universe-styled prompt that follows the universe mood board by default', async () => { const user = userEvent.setup(); await renderPage(); diff --git a/client/src/services/apiCodeAnimation.js b/client/src/services/apiCodeAnimation.js index fd5eac3f1d..303d12f29e 100644 --- a/client/src/services/apiCodeAnimation.js +++ b/client/src/services/apiCodeAnimation.js @@ -26,7 +26,9 @@ export const startCodeAnimationGeneration = (brief, options) => request('/code-a ...options, }); -export const listCodeAnimationJobs = (options) => request('/code-animation/jobs', options); +export const listCodeAnimationJobPage = ({ cursor, signal, limit = 50 } = {}) => + request(`/code-animation/jobs?limit=${limit}${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ''}`, + { signal, silent: true }); export const getCodeAnimationJob = (id, options) => request(`/code-animation/generate/${encodeURIComponent(id)}`, options); diff --git a/server/lib/socketEventContracts.js b/server/lib/socketEventContracts.js index e06a9239ad..59322c2355 100644 --- a/server/lib/socketEventContracts.js +++ b/server/lib/socketEventContracts.js @@ -74,6 +74,12 @@ export const SOCKET_EVENT_CONTRACTS = Object.freeze({ summary: 'Release readiness observation; the last subscriber stops the observer.', payloadSchema: { type: 'object', properties: {}, additionalProperties: false }, }, + 'code-animation:changed': { + direction: 'server-to-client', + summary: 'Invalidate the bounded Code Animation gallery after a durable job change.', + payloadSchema: { type: 'object', properties: { id: { type: 'string' } }, required: ['id'], additionalProperties: false }, + + }, 'brain:changed': { direction: 'server-to-client', summary: 'Invalidate Brain summary/settings after a persisted change.', diff --git a/server/routes/codeAnimation.js b/server/routes/codeAnimation.js index c1c43723a5..55b4d25917 100644 --- a/server/routes/codeAnimation.js +++ b/server/routes/codeAnimation.js @@ -21,6 +21,7 @@ import { generateCodeAnimationBrief, getCodeAnimationJob, listCodeAnimationJobs, + pageCodeAnimationJobs, getCodeAnimationOptions, startCodeAnimationGeneration, } from '../services/codeAnimation/index.js'; @@ -135,8 +136,16 @@ router.post('/generate', asyncHandler(async (req, res) => { res.status(202).json(await startCodeAnimationGeneration(input)); })); -router.get('/jobs', asyncHandler(async (_req, res) => { - res.json(await listCodeAnimationJobs()); +const jobsPageSchema = z.object({ + limit: z.coerce.number().int().min(1).max(1000).optional(), + cursor: z.string().min(1).max(256).optional(), +}).strict(); + +router.get('/jobs', asyncHandler(async (req, res) => { + // Existing query-less callers retain the array contract. + res.json(Object.keys(req.query).length === 0 + ? await listCodeAnimationJobs() + : await pageCodeAnimationJobs(validateRequest(jobsPageSchema, req.query))); })); router.get('/generate/:id', asyncHandler(async (req, res) => { diff --git a/server/routes/codeAnimation.test.js b/server/routes/codeAnimation.test.js index 1fad6c3980..63216ce8d0 100644 --- a/server/routes/codeAnimation.test.js +++ b/server/routes/codeAnimation.test.js @@ -17,6 +17,16 @@ vi.mock('../services/codeAnimation/jobStore.js', () => ({ getCodeAnimationJobRecord: vi.fn(async (id) => codeAnimationRecords.get(id) ?? null), isCodeAnimationJobId: (id) => /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(id), listCodeAnimationJobRecords: vi.fn(async () => [...codeAnimationRecords.values()]), + listRunningCodeAnimationJobIds: vi.fn(async () => [...codeAnimationRecords.values()].filter((job) => job.status === 'running').map((job) => job.id)), + listCodeAnimationJobPage: vi.fn(async ({ limit, cursor }) => [...codeAnimationRecords.values()] + .filter((job) => !cursor || job.createdAt < cursor.createdAt || (job.createdAt === cursor.createdAt && job.id < cursor.id)) + .sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id)) + .slice(0, limit + 1).map(({ id, status, title, providerId, model, createdAt }) => ({ id, status, title, providerId, model, createdAt }))), + countCodeAnimationJobs: vi.fn(async () => ({ + total: codeAnimationRecords.size, + running: [...codeAnimationRecords.values()].filter((job) => job.status === 'running').length, + completed: [...codeAnimationRecords.values()].filter((job) => job.status === 'completed').length, + })), readCodeAnimationHtml: vi.fn(async (id) => codeAnimationHtml.get(id)), saveCodeAnimationHtml: vi.fn(async (id, html) => codeAnimationHtml.set(id, html)), saveCodeAnimationJobRecord: vi.fn(async (job) => codeAnimationRecords.set(job.id, job)), @@ -36,6 +46,7 @@ import { getUniverse } from '../services/universeBuilder/crud.js'; import { getBoard } from '../services/moodBoard/db.js'; import { getProviderById } from '../services/providers.js'; import { getTrack } from '../services/tracks/index.js'; +import { listCodeAnimationJobPage, listCodeAnimationJobRecords } from '../services/codeAnimation/jobStore.js'; import { assertProvider, resolveProviderAndModel, runPromptThroughProvider } from '../services/promptRunner.js'; import routes from './codeAnimation.js'; @@ -96,6 +107,51 @@ beforeEach(() => { resolveProviderAndModel.mockResolvedValue({ provider: { id: 'api-1', type: 'api' }, selectedModel: 'example-model' }); }); +describe('GET /api/code-animation/jobs', () => { + it('reconciles only stale running records before paging and counting', async () => { + const id = '00000000-0000-4000-8000-000000000001'; + codeAnimationRecords.set(id, { id, status: 'running', title: 'Interrupted', concept: 'Private brief', + createdAt: '2026-01-01T00:00:00.000Z' }); + const response = await request(makeApp()).get('/api/code-animation/jobs?limit=50'); + expect(response.status).toBe(200); + expect(response.body.items).toMatchObject([{ id, status: 'failed' }]); + expect(response.body.counts).toEqual({ running: 0, completed: 0 }); + expect(listCodeAnimationJobRecords).not.toHaveBeenCalled(); + expect((await request(makeApp()).get(`/api/code-animation/generate/${id}`)).body.error) + .toMatch(/interrupted by a server restart/); + }); + + it('keeps the legacy array and pages a compact thousand-job archive with stable equal-time cursors', async () => { + const createdAt = '2026-01-01T00:00:00.000Z'; + for (let n = 0; n < 1000; n += 1) { + const id = `00000000-0000-4000-8000-${n.toString(16).padStart(12, '0')}`; + codeAnimationRecords.set(id, { id, status: 'completed', title: `Animation ${n}`, concept: 'x'.repeat(2000), createdAt }); + } + const app = makeApp(); + const legacy = await request(app).get('/api/code-animation/jobs'); + expect(legacy.status).toBe(200); + expect(legacy.body).toHaveLength(1000); + expect(legacy.body[0].concept).toHaveLength(2000); + + const first = await request(app).get('/api/code-animation/jobs?limit=50'); + expect(first.status).toBe(200); + expect(first.body.items).toHaveLength(50); + expect(first.body.items[0]).not.toHaveProperty('concept'); + expect(first.body.counts).toEqual({ running: 0, completed: 1000 }); + expect(first.body.total).toBe(1000); + expect(JSON.stringify(first.body).length).toBeLessThan(JSON.stringify(legacy.body).length / 10); + expect(first.body.nextCursor).toBeTruthy(); + expect(JSON.parse(Buffer.from(first.body.nextCursor, 'base64url').toString('utf8'))).toEqual([createdAt, first.body.items.at(-1).id]); + const second = await request(app).get(`/api/code-animation/jobs?limit=50&cursor=${encodeURIComponent(first.body.nextCursor)}`); + expect(second.body.items).toHaveLength(50); + expect(new Set([...first.body.items, ...second.body.items].map(({ id }) => id)).size).toBe(100); + const capped = await request(app).get('/api/code-animation/jobs?limit=1000'); + expect(capped.body.items).toHaveLength(100); + expect(listCodeAnimationJobPage).toHaveBeenLastCalledWith({ limit: 100, cursor: null }); + expect((await request(app).get('/api/code-animation/jobs?cursor=garbage')).status).toBe(400); + }); +}); + describe('POST /api/code-animation/brief', () => { const briefResponse = (body) => ({ runId: 'run-b', text: `\`\`\`json\n${JSON.stringify(body)}\n\`\`\`` }); diff --git a/server/services/codeAnimation/index.js b/server/services/codeAnimation/index.js index 2b75ee09d1..79a62e246a 100644 --- a/server/services/codeAnimation/index.js +++ b/server/services/codeAnimation/index.js @@ -14,6 +14,7 @@ import { randomUUID } from 'crypto'; import { ServerError } from '../../lib/errorHandler.js'; +import { emitCodeAnimationChanged } from '../socket.js'; import { PATHS } from '../../lib/paths.js'; import { makePathResolver, resolveGalleryImage, resolveImageRef } from '../../lib/pathSafety.js'; import { universeVisualStyleTokens } from '../../lib/universeVisualStyle.js'; @@ -25,6 +26,9 @@ import { getCodeAnimationJobRecord, isCodeAnimationJobId, listCodeAnimationJobRecords, + listRunningCodeAnimationJobIds, + listCodeAnimationJobPage, + countCodeAnimationJobs, readCodeAnimationHtml, saveCodeAnimationHtml, saveCodeAnimationJobRecord, @@ -428,6 +432,7 @@ async function reconcileJob(job) { updatedAt: now, }; await saveCodeAnimationJobRecord(interrupted); + emitCodeAnimationChanged(job.id); return interrupted; } @@ -440,6 +445,45 @@ export async function listCodeAnimationJobs() { })); } +const JOB_PAGE_SIZE = 50; +const JOB_PAGE_MAX = 100; + +function decodeJobCursor(value) { + if (!value) return null; + let tuple; + try { tuple = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')); } catch { /* invalid cursor */ } + if (!Array.isArray(tuple) || tuple.length !== 2 + || typeof tuple[0] !== 'string' || Number.isNaN(Date.parse(tuple[0])) + || !isCodeAnimationJobId(tuple[1])) { + throw new ServerError('Invalid job cursor', { status: 400, code: 'INVALID_CURSOR' }); + } + return { createdAt: new Date(tuple[0]).toISOString(), id: tuple[1] }; +} + +export async function pageCodeAnimationJobs({ limit = JOB_PAGE_SIZE, cursor } = {}) { + const size = Math.min(limit, JOB_PAGE_MAX); + const after = decodeJobCursor(cursor); + // Reconcile only the small running subset before counting or reading a page. + const runningIds = await listRunningCodeAnimationJobIds(); + await Promise.all(runningIds.filter((id) => !activeJobs.has(id)).map(async (id) => { + const record = await getCodeAnimationJobRecord(id); + if (record) await reconcileJob(record); + })); + const [rows, counts] = await Promise.all([ + listCodeAnimationJobPage({ limit: size, cursor: after }), + countCodeAnimationJobs(), + ]); + const items = rows.slice(0, size); + const last = items.at(-1); + return { + items, + total: counts.total, + counts: { running: counts.running, completed: counts.completed }, + nextCursor: rows.length > size && last + ? Buffer.from(JSON.stringify([last.createdAt, last.id])).toString('base64url') : null, + }; +} + async function runGeneration({ provider, model, effort, prompt, referencePaths }) { const { runPromptThroughProvider } = await import('../promptRunner.js'); const result = await runPromptThroughProvider({ @@ -498,6 +542,7 @@ export async function startCodeAnimationGeneration(input) { activeJobs.delete(id); throw error; }); + emitCodeAnimationChanged(id); console.log(`🎞️ Code animation generation ${id.slice(0, 8)} started on ${provider.id}`); runGeneration({ provider, model: input.model, effort: input.effort, prompt: built.prompt, referencePaths: built.referencePaths }) .then(async ({ html, provider: ranOn, model, runId }) => { @@ -512,6 +557,7 @@ export async function startCodeAnimationGeneration(input) { completedAt, updatedAt: completedAt, }); + emitCodeAnimationChanged(id); activeJobs.delete(id); console.log(`✅ Code animation generation ${id.slice(0, 8)} completed (${html.length} chars)`); }) @@ -519,7 +565,7 @@ export async function startCodeAnimationGeneration(input) { const message = String(error?.message || error || 'Generation failed').slice(0, 2_000); const completedAt = new Date().toISOString(); const failed = { ...job, status: 'failed', error: message, completedAt, updatedAt: completedAt }; - await saveCodeAnimationJobRecord(failed).catch((persistError) => { + await saveCodeAnimationJobRecord(failed).then(() => emitCodeAnimationChanged(id)).catch((persistError) => { console.error(`❌ Code animation generation ${id.slice(0, 8)} status could not be saved: ${persistError.message}`); }); activeJobs.delete(id); @@ -547,6 +593,7 @@ export async function getCodeAnimationJob(id) { updatedAt: completedAt, }; await saveCodeAnimationJobRecord(job); + emitCodeAnimationChanged(id); return { ...job, html: null }; } } diff --git a/server/services/codeAnimation/jobStore.js b/server/services/codeAnimation/jobStore.js index ef74ec060e..d5c8a78eb0 100644 --- a/server/services/codeAnimation/jobStore.js +++ b/server/services/codeAnimation/jobStore.js @@ -34,6 +34,34 @@ export async function listCodeAnimationJobRecords() { return rows; } +export async function listRunningCodeAnimationJobIds() { + const { rows } = await query("SELECT id FROM code_animation_jobs WHERE status = 'running'"); + return rows.map(({ id }) => id); +} + +export async function listCodeAnimationJobPage({ limit, cursor }) { + const { rows } = await query( + `SELECT id, status, COALESCE(NULLIF(title, ''), LEFT(concept, 120)) AS title, + provider_id AS "providerId", model, created_at AS "createdAt" + FROM code_animation_jobs + WHERE ($1::timestamptz IS NULL OR (created_at, id) < ($1::timestamptz, $2::text)) + ORDER BY created_at DESC, id DESC + LIMIT $3`, + [cursor?.createdAt ?? null, cursor?.id ?? null, limit + 1], + ); + return rows; +} + +export async function countCodeAnimationJobs() { + const { rows } = await query( + `SELECT COUNT(*)::integer AS total, + COUNT(*) FILTER (WHERE status = 'running')::integer AS running, + COUNT(*) FILTER (WHERE status = 'completed')::integer AS completed + FROM code_animation_jobs`, + ); + return rows[0]; +} + export async function getCodeAnimationJobRecord(id) { if (!isCodeAnimationJobId(id)) return null; const { rows } = await query('SELECT data FROM code_animation_jobs WHERE id = $1', [id]); diff --git a/server/services/codeAnimation/jobStore.test.js b/server/services/codeAnimation/jobStore.test.js new file mode 100644 index 0000000000..73b0ee2737 --- /dev/null +++ b/server/services/codeAnimation/jobStore.test.js @@ -0,0 +1,21 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../lib/db.js', () => ({ query: vi.fn() })); + +import { query } from '../../lib/db.js'; +import { listCodeAnimationJobPage } from './jobStore.js'; + +beforeEach(() => vi.clearAllMocks()); + +describe('Code Animation persisted history', () => { + it('uses the text-id keyset index and reads only one compact lookahead page', async () => { + query.mockResolvedValue({ rows: [] }); + await listCodeAnimationJobPage({ limit: 50, cursor: { + createdAt: '2026-01-01T00:00:00.000Z', id: '00000000-0000-4000-8000-000000000001', + } }); + const [sql, params] = query.mock.calls[0]; + expect(sql).toContain('(created_at, id) < ($1::timestamptz, $2::text)'); + expect(sql).toContain("LEFT(concept, 120)"); + expect(params).toEqual(['2026-01-01T00:00:00.000Z', '00000000-0000-4000-8000-000000000001', 51]); + }); +}); diff --git a/server/services/socket.js b/server/services/socket.js index c23232c3ab..d8fb61c254 100644 --- a/server/services/socket.js +++ b/server/services/socket.js @@ -63,6 +63,7 @@ const agentSubscribers = new Set(); const instanceSubscribers = new Set(); // Store loop subscribers const loopSubscribers = new Set(); +const codeAnimationSubscribers = new Set(); // Store Beeper realtime subscribers (#33). Invalidation frames and transport // liveness ONLY — see setupBeeperEventForwarding for why this may never be a // global emit. @@ -80,7 +81,11 @@ export function getIo() { return ioInstance; } -const ALL_SUBSCRIBER_SETS = [cosSubscribers, errorSubscribers, notificationSubscribers, agentSubscribers, instanceSubscribers, loopSubscribers, beeperSubscribers, fableLoomSubscribers]; +const ALL_SUBSCRIBER_SETS = [cosSubscribers, errorSubscribers, notificationSubscribers, agentSubscribers, instanceSubscribers, loopSubscribers, codeAnimationSubscribers, beeperSubscribers, fableLoomSubscribers]; + +export function emitCodeAnimationChanged(id) { + broadcastToSet(codeAnimationSubscribers, 'code-animation:changed', { id }); +} function broadcastToSet(set, event, data) { const disconnected = []; @@ -157,6 +162,7 @@ function registerSubscriptionHandlers(socket, _io) { registerSubscriber(socket, 'agents', agentSubscribers); registerSubscriber(socket, 'instances', instanceSubscribers); registerSubscriber(socket, 'loops', loopSubscribers); + registerSubscriber(socket, 'code-animation', codeAnimationSubscribers); registerSubscriber(socket, 'beeper', beeperSubscribers); registerSubscriber(socket, 'fableloom', fableLoomSubscribers); }