From dfc80c8aed376be41803b9ca175fbb96961ec1a8 Mon Sep 17 00:00:00 2001 From: Manjusaka Date: Sat, 30 May 2026 20:09:04 +0800 Subject: [PATCH] feat(api): add preprocess-images task endpoints (BE-3 part 3a) Expose the variant preprocessing queue (the engine from #481) over the protected /api/v1/preprocess-tasks routes, mirroring /api/v1/tasks: preview-count, runs, runs/:id, runs (create), runs/:id/kick, runs/:id/cancel, tick. Delegates to image-preprocess-service.ts; scope carries the `force` flag; surfaces "not configured" / "already active" / "no images" as 4xx. This is the drivable API the admin /tasks button and the CLI backfill (following) use to create and drain a backfill run; it also enables the real end-to-end backfill test once variant_storage is configured (#483). Co-Authored-By: Claude Opus 4.8 --- hono/index.ts | 2 + hono/preprocess-tasks.ts | 147 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 149 insertions(+) create mode 100644 hono/preprocess-tasks.ts diff --git a/hono/index.ts b/hono/index.ts index b3a17a0c..16521444 100644 --- a/hono/index.ts +++ b/hono/index.ts @@ -7,6 +7,7 @@ import albums from '~/hono/albums' import openList from '~/hono/storage/open-list.ts' import daily from '~/hono/daily' import tasks from '~/hono/tasks' +import preprocessTasks from '~/hono/preprocess-tasks' import backup from '~/hono/backup' import { HTTPException } from 'hono/http-exception' import { sessionMiddleware } from '~/hono/_lib/context' @@ -31,6 +32,7 @@ route.route('/albums', albums) route.route('/storage/open-list', openList) route.route('/daily', daily) route.route('/tasks', tasks) +route.route('/preprocess-tasks', preprocessTasks) route.route('/backup', backup) export default route diff --git a/hono/preprocess-tasks.ts b/hono/preprocess-tasks.ts new file mode 100644 index 00000000..242662bb --- /dev/null +++ b/hono/preprocess-tasks.ts @@ -0,0 +1,147 @@ +import 'server-only' + +import { Hono } from 'hono' +import { HTTPException } from 'hono/http-exception' + +import { + cancelPreprocessTaskRun, + createPreprocessTaskRun, + getPreprocessTaskPreviewCount, + getPreprocessTaskRunDetail, + kickPreprocessTaskRun, + listPreprocessTaskRuns, + tickPreprocessTaskRuns, +} from '~/server/tasks/image-preprocess-service' +import { ADMIN_TASK_KEY_PREPROCESS_IMAGES, normalizePreprocessTaskScope } from '~/types/admin-tasks' +import { ok } from '~/hono/_lib/response' +import { badRequest, conflict, notFound, serverError } from '~/hono/_lib/errors' + +const app = new Hono() + +function ensureTaskKey(taskKey: unknown) { + if (taskKey !== ADMIN_TASK_KEY_PREPROCESS_IMAGES) { + throw badRequest('Unsupported task key') + } + return taskKey +} + +function getScopeFromQuery(query: Record) { + return normalizePreprocessTaskScope({ force: query.force === 'true' }) +} + +function getScopeFromBody(body: Record | null) { + return normalizePreprocessTaskScope(body?.scope) +} + +function rethrowTaskError(error: unknown): never { + if (error instanceof HTTPException) { + throw error + } + + const message = error instanceof Error ? error.message : 'Task request failed' + + if (message === 'Another preprocess task is already active') { + throw conflict(message) + } + + if (message === 'No images matched the selected filters' || message === 'Variant storage backend is not configured') { + throw badRequest(message) + } + + throw serverError(message, error) +} + +app.get('/preview-count', async (c) => { + try { + const scope = getScopeFromQuery(c.req.query()) + const data = await getPreprocessTaskPreviewCount(scope) + return ok(c, data) + } catch (error) { + rethrowTaskError(error) + } +}) + +app.get('/runs', async (c) => { + try { + const data = await listPreprocessTaskRuns() + return ok(c, data) + } catch (error) { + rethrowTaskError(error) + } +}) + +app.get('/runs/:id', async (c) => { + try { + const data = await getPreprocessTaskRunDetail(c.req.param('id')) + + if (!data) { + throw notFound('Task run not found') + } + + return ok(c, data) + } catch (error) { + rethrowTaskError(error) + } +}) + +app.post('/runs', async (c) => { + const body = await c.req.json>().catch(() => null) + + if (!body || typeof body !== 'object' || Array.isArray(body)) { + throw badRequest('Invalid task request body') + } + + try { + ensureTaskKey(body.taskKey) + const scope = getScopeFromBody(body) + const data = await createPreprocessTaskRun(scope) + + if (!data) { + throw conflict('Task system is busy, please retry shortly') + } + + return ok(c, data) + } catch (error) { + rethrowTaskError(error) + } +}) + +app.post('/runs/:id/kick', async (c) => { + try { + const data = await kickPreprocessTaskRun(c.req.param('id')) + + if (!data) { + throw notFound('Task run not found') + } + + return ok(c, data) + } catch (error) { + rethrowTaskError(error) + } +}) + +app.post('/runs/:id/cancel', async (c) => { + try { + const data = await cancelPreprocessTaskRun(c.req.param('id')) + + if (!data) { + throw notFound('Task run not found') + } + + return ok(c, data) + } catch (error) { + rethrowTaskError(error) + } +}) + +app.post('/tick', async (c) => { + try { + const data = await tickPreprocessTaskRuns() + return ok(c, data) + } catch (error) { + console.error('Preprocess task tick failed:', error) + rethrowTaskError(error) + } +}) + +export default app