feat: complete TASK-WP2-05 generation submission
This commit is contained in:
@@ -36,6 +36,12 @@ import {
|
||||
ErrorDetailsSchema,
|
||||
ErrorEnvelopeSchema,
|
||||
GenerationErrorCategorySchema,
|
||||
GenerationCreateHeadersSchema,
|
||||
GenerationCreateResponseSchema,
|
||||
GenerationParamsSchema,
|
||||
GenerationMultipartBodySchema,
|
||||
GenerationTaskResponseSchema,
|
||||
GenerationTaskStatusSchema,
|
||||
LoginCompleteRequestSchema,
|
||||
LoginCompleteResponseSchema,
|
||||
LoginSendRequestSchema,
|
||||
@@ -88,6 +94,8 @@ import {
|
||||
type LoginCompleteRequest,
|
||||
type LoginSendRequest,
|
||||
type FailedEmptyTrashRequest,
|
||||
type GenerationCreateHeaders,
|
||||
type GenerationParams,
|
||||
type ProjectListQuery,
|
||||
type ProjectParams,
|
||||
type ProjectRenameRequest,
|
||||
@@ -97,6 +105,7 @@ import {
|
||||
type RegistrationSendRequest,
|
||||
} from "@dada/shared-contracts";
|
||||
import swagger from "@fastify/swagger";
|
||||
import multipart from "@fastify/multipart";
|
||||
import Fastify, { type FastifyReply } from "fastify";
|
||||
|
||||
import {
|
||||
@@ -116,6 +125,14 @@ import { EventHub } from "./event-hub.js";
|
||||
import { CreditError } from "./credit-errors.js";
|
||||
import type { CreditService } from "./credits.js";
|
||||
import type { PublicAssetResolver } from "./local-data-root.js";
|
||||
import { GenerationSubmissionError } from "./generation-submission-errors.js";
|
||||
import type {
|
||||
GenerationSubmissionFields,
|
||||
GenerationSubmissionService,
|
||||
GenerationTaskView,
|
||||
GenerationUploadSession,
|
||||
NewGenerationReference,
|
||||
} from "./generation-submission.js";
|
||||
import { isAllowedNetworkRequest, type NetworkBoundaryOptions } from "./network-boundary.js";
|
||||
import { ProjectError } from "./project-errors.js";
|
||||
import type { ProjectService } from "./projects.js";
|
||||
@@ -144,6 +161,7 @@ export interface CreateAppOptions {
|
||||
browserSupportSecret?: Buffer;
|
||||
credits?: CreditService;
|
||||
eventHub?: EventHub;
|
||||
generations?: GenerationSubmissionService;
|
||||
networkBoundary?: NetworkBoundaryOptions;
|
||||
publicAssets?: PublicAssetResolver;
|
||||
projects?: ProjectService;
|
||||
@@ -248,6 +266,175 @@ function creditFailure(reply: FastifyReply, correlationId: string, error: unknow
|
||||
return reply.code(status).send(null);
|
||||
}
|
||||
|
||||
function generationTaskResponse(task: GenerationTaskView) {
|
||||
return {
|
||||
confirmed_credit_cost: task.confirmedCreditCost,
|
||||
created_at: task.createdAt,
|
||||
generation_id: task.generationId,
|
||||
model_config_version: task.modelConfigVersion,
|
||||
model_id: task.modelId,
|
||||
project_id: task.projectId,
|
||||
prompt: task.prompt,
|
||||
ratio: task.ratio,
|
||||
reference_count: task.referenceCount,
|
||||
reserved_credits: task.reservedCredits,
|
||||
status: task.status,
|
||||
updated_at: task.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function generationFailure(reply: FastifyReply, correlationId: string, error: unknown) {
|
||||
if (error instanceof GenerationSubmissionError) {
|
||||
if (error.code === "model_config_stale") {
|
||||
return reply.code(412).send(createErrorEnvelope({
|
||||
code: "MODEL_CONFIG_VERSION_CONFLICT",
|
||||
correlationId,
|
||||
details: { latest_version: error.latest?.configVersion ?? 0 },
|
||||
}));
|
||||
}
|
||||
if (error.code === "generation_idempotency_conflict") {
|
||||
return reply.code(409).send(createErrorEnvelope({ code: "IDEMPOTENCY_KEY_CONFLICT", correlationId }));
|
||||
}
|
||||
if (error.code === "generation_blocked") {
|
||||
return reply.code(503).send(createErrorEnvelope({
|
||||
code: "AUTH_SERVICE_UNAVAILABLE",
|
||||
correlationId,
|
||||
...(error.errorCategory ? { errorCategory: error.errorCategory } : {}),
|
||||
}));
|
||||
}
|
||||
if (error.code === "generation_storage_unavailable") {
|
||||
return reply.code(507).send(createErrorEnvelope({
|
||||
code: "STORAGE_CAPACITY_EXCEEDED",
|
||||
correlationId,
|
||||
details: {
|
||||
capacity_status: error.storage?.capacityStatus ?? "unavailable",
|
||||
remaining_bytes: error.storage?.remainingBytes ?? 0,
|
||||
},
|
||||
}));
|
||||
}
|
||||
return reply.code(error.code === "generation_not_found" ? 404 : 400).send(null);
|
||||
}
|
||||
if (error && typeof error === "object" && "code" in error) {
|
||||
if (typeof error.code === "string" && error.code.startsWith("FST_")) {
|
||||
return reply.code(400).send(null);
|
||||
}
|
||||
if (error.code === "STORAGE_CAPACITY_EXCEEDED") {
|
||||
const details = "details" in error && error.details && typeof error.details === "object"
|
||||
? error.details as { capacity_status?: "normal" | "warning" | "critical" | "full" | "unavailable"; remaining_bytes?: number }
|
||||
: {};
|
||||
return reply.code(507).send(createErrorEnvelope({
|
||||
code: "STORAGE_CAPACITY_EXCEEDED",
|
||||
correlationId,
|
||||
details: {
|
||||
capacity_status: details.capacity_status ?? "full",
|
||||
remaining_bytes: details.remaining_bytes ?? 0,
|
||||
},
|
||||
}));
|
||||
}
|
||||
if (error.code === "storage_unavailable") {
|
||||
return reply.code(507).send(createErrorEnvelope({
|
||||
code: "STORAGE_CAPACITY_EXCEEDED",
|
||||
correlationId,
|
||||
details: { capacity_status: "unavailable", remaining_bytes: 0 },
|
||||
}));
|
||||
}
|
||||
}
|
||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId }));
|
||||
}
|
||||
|
||||
const generationFieldNames = new Set([
|
||||
"client_submission_id",
|
||||
"confirmed_credit_cost",
|
||||
"creation_mode",
|
||||
"existing_reference_asset_ids",
|
||||
"model_config_version",
|
||||
"model_id",
|
||||
"project_id",
|
||||
"prompt",
|
||||
"ratio",
|
||||
"reference_manifest",
|
||||
]);
|
||||
|
||||
interface ReferenceManifestEntry {
|
||||
fileName: string;
|
||||
mimeType: NewGenerationReference["mimeType"];
|
||||
projectedBytes: number;
|
||||
}
|
||||
|
||||
function positiveIntegerField(value: string | undefined) {
|
||||
if (!value || !/^[1-9][0-9]*$/.test(value)) throw new GenerationSubmissionError("generation_request_invalid");
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed)) throw new GenerationSubmissionError("generation_request_invalid");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function jsonStringArray(value: string | undefined) {
|
||||
if (value === undefined) return [];
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
throw new GenerationSubmissionError("generation_request_invalid");
|
||||
}
|
||||
if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== "string")) {
|
||||
throw new GenerationSubmissionError("generation_request_invalid");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function referenceManifest(value: string | undefined): ReferenceManifestEntry[] {
|
||||
if (value === undefined) return [];
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
throw new GenerationSubmissionError("generation_request_invalid");
|
||||
}
|
||||
if (!Array.isArray(parsed)) throw new GenerationSubmissionError("generation_request_invalid");
|
||||
return parsed.map((entry) => {
|
||||
if (!entry || typeof entry !== "object") throw new GenerationSubmissionError("generation_request_invalid");
|
||||
const item = entry as Record<string, unknown>;
|
||||
const keys = Object.keys(item).toSorted();
|
||||
if (keys.join(",") !== "file_name,mime_type,size" || typeof item.file_name !== "string"
|
||||
|| !["image/jpeg", "image/png", "image/webp"].includes(String(item.mime_type))
|
||||
|| !Number.isSafeInteger(item.size) || Number(item.size) <= 0) {
|
||||
throw new GenerationSubmissionError("generation_request_invalid");
|
||||
}
|
||||
return {
|
||||
fileName: item.file_name,
|
||||
mimeType: item.mime_type as NewGenerationReference["mimeType"],
|
||||
projectedBytes: Number(item.size),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function generationFields(
|
||||
values: Map<string, string>,
|
||||
input: { idempotencyKey: string; userId: string },
|
||||
): { fields: GenerationSubmissionFields; manifest: ReferenceManifestEntry[] } {
|
||||
for (const name of values.keys()) {
|
||||
if (!generationFieldNames.has(name)) throw new GenerationSubmissionError("generation_request_invalid");
|
||||
}
|
||||
const creationMode = values.get("creation_mode");
|
||||
if (creationMode !== "new_project" && creationMode !== "existing_project") {
|
||||
throw new GenerationSubmissionError("generation_request_invalid");
|
||||
}
|
||||
const fields: GenerationSubmissionFields = {
|
||||
clientSubmissionId: values.get("client_submission_id") ?? "",
|
||||
confirmedCreditCost: positiveIntegerField(values.get("confirmed_credit_cost")),
|
||||
existingReferenceAssetIds: jsonStringArray(values.get("existing_reference_asset_ids")),
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
mode: creationMode,
|
||||
modelConfigVersion: positiveIntegerField(values.get("model_config_version")),
|
||||
modelId: values.get("model_id") ?? "",
|
||||
...(values.has("project_id") ? { projectId: values.get("project_id")! } : {}),
|
||||
prompt: values.get("prompt") ?? "",
|
||||
ratio: values.get("ratio") as GenerationSubmissionFields["ratio"],
|
||||
userId: input.userId,
|
||||
};
|
||||
return { fields, manifest: referenceManifest(values.get("reference_manifest")) };
|
||||
}
|
||||
|
||||
type ProjectSummaryView = ReturnType<ProjectService["listProjects"]>[number];
|
||||
type ProjectDetailView = ReturnType<ProjectService["getProject"]>;
|
||||
|
||||
@@ -351,9 +538,26 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
},
|
||||
});
|
||||
|
||||
await app.register(multipart, {
|
||||
limits: {
|
||||
fieldNameSize: 120,
|
||||
fieldSize: 16 * 1024,
|
||||
fields: 20,
|
||||
fileSize: 100 * 1024 * 1024,
|
||||
files: 16,
|
||||
parts: 36,
|
||||
},
|
||||
});
|
||||
|
||||
for (const schema of [
|
||||
CorrelationIdSchema,
|
||||
GenerationErrorCategorySchema,
|
||||
GenerationTaskStatusSchema,
|
||||
GenerationTaskResponseSchema,
|
||||
GenerationCreateResponseSchema,
|
||||
GenerationParamsSchema,
|
||||
GenerationCreateHeadersSchema,
|
||||
GenerationMultipartBodySchema,
|
||||
StableEngineeringErrorCodeSchema,
|
||||
ErrorDetailsSchema,
|
||||
ErrorEnvelopeSchema,
|
||||
@@ -1277,6 +1481,160 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/v1/generations/current",
|
||||
{
|
||||
schema: {
|
||||
operationId: "getCurrentGeneration",
|
||||
response: {
|
||||
200: Type.Ref(GenerationTaskResponseSchema),
|
||||
401: Type.Ref(ErrorEnvelopeSchema),
|
||||
404: Type.Null(),
|
||||
503: Type.Ref(ErrorEnvelopeSchema),
|
||||
},
|
||||
tags: ["Generations"],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (!options.registration || !options.generations) {
|
||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
||||
}
|
||||
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
|
||||
const session = token ? options.registration.readUserSession(token) : undefined;
|
||||
if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
||||
const task = options.generations.readCurrentTask(session.userId);
|
||||
return task ? generationTaskResponse(task) : reply.code(404).send(null);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/v1/generations/:generationId",
|
||||
{
|
||||
attachValidation: true,
|
||||
schema: {
|
||||
operationId: "getGeneration",
|
||||
params: Type.Ref(GenerationParamsSchema),
|
||||
response: {
|
||||
200: Type.Ref(GenerationTaskResponseSchema),
|
||||
400: Type.Null(),
|
||||
401: Type.Ref(ErrorEnvelopeSchema),
|
||||
404: Type.Null(),
|
||||
503: Type.Ref(ErrorEnvelopeSchema),
|
||||
},
|
||||
tags: ["Generations"],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (request.validationError) return reply.code(400).send(null);
|
||||
if (!options.registration || !options.generations) {
|
||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
||||
}
|
||||
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
|
||||
const session = token ? options.registration.readUserSession(token) : undefined;
|
||||
if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
||||
try {
|
||||
return generationTaskResponse(options.generations.readTask(session.userId, (request.params as GenerationParams).generationId));
|
||||
} catch (error) {
|
||||
return generationFailure(reply, request.id, error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/v1/generations",
|
||||
{
|
||||
attachValidation: true,
|
||||
schema: {
|
||||
consumes: ["multipart/form-data"],
|
||||
body: Type.Optional(Type.Ref(GenerationMultipartBodySchema)),
|
||||
headers: Type.Ref(GenerationCreateHeadersSchema),
|
||||
operationId: "createGeneration",
|
||||
response: {
|
||||
200: Type.Ref(GenerationCreateResponseSchema),
|
||||
201: Type.Ref(GenerationCreateResponseSchema),
|
||||
400: Type.Null(),
|
||||
401: Type.Ref(ErrorEnvelopeSchema),
|
||||
403: Type.Ref(ErrorEnvelopeSchema),
|
||||
409: Type.Ref(ErrorEnvelopeSchema),
|
||||
412: Type.Ref(ErrorEnvelopeSchema),
|
||||
503: Type.Ref(ErrorEnvelopeSchema),
|
||||
507: Type.Ref(ErrorEnvelopeSchema),
|
||||
},
|
||||
tags: ["Generations"],
|
||||
},
|
||||
validatorCompiler: () => (data) => ({ value: data }),
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (request.validationError || !request.isMultipart()) return reply.code(400).send(null);
|
||||
if (!options.registration || !options.generations) {
|
||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
||||
}
|
||||
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
|
||||
const idempotencyKey = headerValue(request.headers["idempotency-key"]);
|
||||
const csrfToken = headerValue(request.headers["x-csrf-token"]);
|
||||
if (!idempotencyKey || !/^[A-Za-z0-9_-]{32,200}$/.test(idempotencyKey)
|
||||
|| !csrfToken || !/^[A-Za-z0-9_-]{43,64}$/.test(csrfToken)) return reply.code(400).send(null);
|
||||
const headers = { "idempotency-key": idempotencyKey, "x-csrf-token": csrfToken } satisfies GenerationCreateHeaders;
|
||||
if (!token) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
||||
let upload: GenerationUploadSession | undefined;
|
||||
try {
|
||||
const owner = options.registration.authorizeUserMutation({ csrfToken: headers["x-csrf-token"], sessionToken: token });
|
||||
const current = options.generations.readCurrentTask(owner.userId);
|
||||
if (current) return { created: false, task: generationTaskResponse(current) };
|
||||
|
||||
const values = new Map<string, string>();
|
||||
let manifest: ReferenceManifestEntry[] | undefined;
|
||||
let fileIndex = 0;
|
||||
for await (const part of request.parts()) {
|
||||
if (part.type === "field") {
|
||||
if (upload || values.has(part.fieldname) || typeof part.value !== "string") {
|
||||
throw new GenerationSubmissionError("generation_request_invalid");
|
||||
}
|
||||
values.set(part.fieldname, part.value);
|
||||
continue;
|
||||
}
|
||||
if (part.fieldname !== "reference_files" || !part.filename) {
|
||||
throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" });
|
||||
}
|
||||
if (!upload) {
|
||||
const parsed = generationFields(values, { idempotencyKey: headers["idempotency-key"], userId: owner.userId });
|
||||
manifest = parsed.manifest;
|
||||
upload = options.generations.beginUpload(parsed.fields);
|
||||
}
|
||||
const expected = manifest?.[fileIndex];
|
||||
if (!expected || expected.fileName !== part.filename || expected.mimeType !== part.mimetype) {
|
||||
throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" });
|
||||
}
|
||||
await upload.stageReference({
|
||||
content: part.file,
|
||||
fileName: part.filename,
|
||||
mimeType: expected.mimeType,
|
||||
projectedBytes: expected.projectedBytes,
|
||||
});
|
||||
if (part.file.truncated) throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" });
|
||||
fileIndex += 1;
|
||||
}
|
||||
if (!upload) {
|
||||
const parsed = generationFields(values, { idempotencyKey: headers["idempotency-key"], userId: owner.userId });
|
||||
manifest = parsed.manifest;
|
||||
upload = options.generations.beginUpload(parsed.fields);
|
||||
}
|
||||
if (fileIndex !== (manifest?.length ?? 0)) {
|
||||
throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" });
|
||||
}
|
||||
const result = await upload.commit();
|
||||
return reply.code(result.created ? 201 : 200).send({ created: result.created, task: generationTaskResponse(result.task) });
|
||||
} catch (error) {
|
||||
upload?.abort();
|
||||
return error instanceof RegistrationError
|
||||
? registrationFailure(reply, request.id, error)
|
||||
: error instanceof CreditError
|
||||
? creditFailure(reply, request.id, error)
|
||||
: generationFailure(reply, request.id, error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/v1/projects",
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user