From c8643c080de7781b9ea6fbdbb780a1623507410c Mon Sep 17 00:00:00 2001 From: suyx Date: Mon, 3 Aug 2026 00:26:34 +0800 Subject: [PATCH] feat: complete TASK-WP2-05 generation submission --- apps/api/package.json | 1 + apps/api/src/app.ts | 358 ++++++++++++ apps/api/src/credits.ts | 1 + apps/api/src/generation-submission-errors.ts | 30 + apps/api/src/generation-submission.ts | 526 ++++++++++++++++++ apps/api/src/managed-storage.ts | 100 +++- apps/api/src/projects.ts | 14 +- apps/web/src/generated/api/sdk.gen.ts | 23 +- apps/web/src/generated/api/types.gen.ts | 45 ++ apps/web/src/project-pages.css | 93 ++++ apps/web/src/project-pages.tsx | 217 +++++++- openapi/openapi.json | 455 +++++++++++++++ package.json | 6 +- packages/shared-contracts/src/generations.ts | 61 ++ packages/shared-contracts/src/index.ts | 1 + pnpm-lock.yaml | 24 + scripts/frozen-versions.mjs | 1 + scripts/lib/portable-package.mjs | 2 +- scripts/run-wp2-05-validation.mjs | 62 +++ tests/api/wp2-05-generations.test.ts | 119 ++++ tests/e2e/generation-workspace.spec.ts | 63 +++ .../wp2-05-generation-submission.test.ts | 192 +++++++ 22 files changed, 2372 insertions(+), 22 deletions(-) create mode 100644 apps/api/src/generation-submission-errors.ts create mode 100644 apps/api/src/generation-submission.ts create mode 100644 packages/shared-contracts/src/generations.ts create mode 100644 scripts/run-wp2-05-validation.mjs create mode 100644 tests/api/wp2-05-generations.test.ts create mode 100644 tests/e2e/generation-workspace.spec.ts create mode 100644 tests/integration/wp2-05-generation-submission.test.ts diff --git a/apps/api/package.json b/apps/api/package.json index 208b01b..3517f70 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -10,6 +10,7 @@ }, "dependencies": { "@dada/shared-contracts": "workspace:*", + "@fastify/multipart": "10.1.0", "@fastify/swagger": "9.8.1", "@sinclair/typebox": "0.34.52", "better-sqlite3": "13.0.1", diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 507db4b..627e5ba 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -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; + 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, + 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[number]; type ProjectDetailView = ReturnType; @@ -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(); + 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", { diff --git a/apps/api/src/credits.ts b/apps/api/src/credits.ts index c6d1088..d42c6ca 100644 --- a/apps/api/src/credits.ts +++ b/apps/api/src/credits.ts @@ -414,6 +414,7 @@ export class CreditService { } private immediate(action: () => T): T { + if (this.database.inTransaction) return action(); this.database.exec("BEGIN IMMEDIATE"); try { const result = action(); diff --git a/apps/api/src/generation-submission-errors.ts b/apps/api/src/generation-submission-errors.ts new file mode 100644 index 0000000..e92c147 --- /dev/null +++ b/apps/api/src/generation-submission-errors.ts @@ -0,0 +1,30 @@ +export type GenerationSubmissionErrorCode = + | "generation_blocked" + | "generation_idempotency_conflict" + | "generation_not_found" + | "generation_request_invalid" + | "generation_storage_unavailable" + | "model_config_stale" + | "reference_invalid"; + +export class GenerationSubmissionError extends Error { + readonly code: GenerationSubmissionErrorCode; + readonly errorCategory: "gateway_balance_insufficient" | "gateway_contract_invalid" | "model_disabled" | "reference_invalid" | undefined; + readonly latest: { configVersion: number; creditCost: number; modelId: string } | undefined; + readonly storage: { capacityStatus: "normal" | "warning" | "critical" | "full" | "unavailable"; remainingBytes: number } | undefined; + + constructor( + code: GenerationSubmissionErrorCode, + options: { + errorCategory?: "gateway_balance_insufficient" | "gateway_contract_invalid" | "model_disabled" | "reference_invalid"; + latest?: { configVersion: number; creditCost: number; modelId: string }; + storage?: { capacityStatus: "normal" | "warning" | "critical" | "full" | "unavailable"; remainingBytes: number }; + } = {}, + ) { + super(code); + this.code = code; + this.errorCategory = options.errorCategory; + this.latest = options.latest; + this.storage = options.storage; + } +} diff --git a/apps/api/src/generation-submission.ts b/apps/api/src/generation-submission.ts new file mode 100644 index 0000000..f965d7f --- /dev/null +++ b/apps/api/src/generation-submission.ts @@ -0,0 +1,526 @@ +import { createHash, randomUUID } from "node:crypto"; +import type { Readable } from "node:stream"; + +import type BetterSqlite3 from "better-sqlite3"; + +import type { CreditService } from "./credits.js"; +import { GenerationSubmissionError } from "./generation-submission-errors.js"; +import type { ManagedStorage, StagedManagedFile } from "./managed-storage.js"; +import { + defaultCanvasState, + defaultProjectName, + historyLimit, + normalizePrompt, + projectLimit, + projectRatios, + ratioPixels, + stableJson, + type ProjectRatio, +} from "./projects.js"; +import { classifyCapacity } from "./storage-policy.js"; + +export { GenerationSubmissionError } from "./generation-submission-errors.js"; + +const idempotencyPattern = /^[A-Za-z0-9_-]{32,200}$/; +const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export interface GenerationModelSnapshot { + configSetVersion: number; + configVersion: number; + contractValidationStatus: "verified" | "unverified"; + creditCost: number; + enabled: boolean; + modelId: string; + promptMaxLength: number; + referenceLimits: { maxFileBytes: number; maxFiles: number; maxTotalBytes: number }; + runtimeAvailability: { + availableForNewJobs: boolean; + reason: "gateway_balance_insufficient" | "gateway_contract_invalid" | "model_disabled" | null; + }; + supportedRatios: readonly ProjectRatio[]; +} + +export interface GenerationModelCatalog { + readModel(modelId: string): GenerationModelSnapshot | undefined; +} + +export class StaticGenerationModelCatalog implements GenerationModelCatalog { + private readonly models = new Map(); + + constructor(models: GenerationModelSnapshot[]) { + for (const model of models) this.models.set(model.modelId, structuredClone(model)); + } + + readModel(modelId: string) { + const model = this.models.get(modelId); + return model ? structuredClone(model) : undefined; + } + + replace(model: GenerationModelSnapshot) { + this.models.set(model.modelId, structuredClone(model)); + } +} + +export interface NewGenerationReference { + content: Readable; + fileName: string; + mimeType: "image/jpeg" | "image/png" | "image/webp"; + projectedBytes: number; +} + +export interface GenerationSubmissionInput { + clientSubmissionId: string; + confirmedCreditCost: number; + existingReferenceAssetIds: string[]; + idempotencyKey: string; + mode: "new_project" | "existing_project"; + modelConfigVersion: number; + modelId: string; + newReferences: NewGenerationReference[]; + projectId?: string; + prompt: string; + ratio: ProjectRatio; + userId: string; +} + +export type GenerationSubmissionFields = Omit; + +export interface GenerationTaskView { + confirmedCreditCost: number; + createdAt: string; + generationId: string; + modelConfigVersion: number; + modelId: string; + projectId: string; + prompt: string; + ratio: ProjectRatio; + referenceCount: number; + reservedCredits: number; + status: "queued" | "running" | "succeeded" | "failed" | "rejected"; + updatedAt: string; +} + +export type GenerationSubmissionResult = { created: boolean; task: GenerationTaskView }; + +export interface GenerationUploadSession { + abort(): void; + commit(): Promise; + stageReference(reference: NewGenerationReference): Promise; +} + +interface GenerationRow { + confirmed_credit_cost: number; + created_at: number; + generation_id: string; + model_config_version: number; + model_id: string; + owner_id: string; + project_id: string; + prompt: string; + ratio: ProjectRatio; + reserved_credits: number; + status: "queued" | "running" | "succeeded" | "failed" | "rejected"; + updated_at: number; +} + +function iso(timestamp: number) { + return new Date(timestamp).toISOString(); +} + +function digest(value: string) { + return createHash("sha256").update(value, "utf8").digest("hex"); +} + +function safeInteger(value: number) { + return Number.isSafeInteger(value) && value > 0; +} + +export class GenerationSubmissionService { + readonly database: BetterSqlite3.Database; + private readonly beforeTransaction: (() => Promise) | undefined; + private readonly clock: () => number; + private readonly credits: CreditService; + private readonly models: GenerationModelCatalog; + private readonly storage: ManagedStorage; + + constructor(input: { + beforeTransaction?: () => Promise; + clock?: () => number; + credits: CreditService; + models: GenerationModelCatalog; + storage: ManagedStorage; + }) { + this.beforeTransaction = input.beforeTransaction; + this.clock = input.clock ?? Date.now; + this.credits = input.credits; + this.database = input.credits.database; + this.models = input.models; + this.storage = input.storage; + this.migrate(); + } + + close() { + // The database connection is owned by CreditService. + } + + readCurrentTask(userId: string) { + const row = this.database.prepare(` + SELECT * FROM generation_jobs + WHERE owner_id = ? AND status IN ('queued', 'running') AND submission_ready = 1 + ORDER BY created_at DESC, generation_id DESC LIMIT 1 + `).get(userId) as GenerationRow | undefined; + return row ? this.taskView(row) : undefined; + } + + readTask(userId: string, generationId: string) { + const row = this.database.prepare(` + SELECT * FROM generation_jobs WHERE generation_id = ? AND owner_id = ? AND submission_ready = 1 + `).get(generationId, userId) as GenerationRow | undefined; + if (!row) throw new GenerationSubmissionError("generation_not_found"); + return this.taskView(row); + } + + async submit(input: GenerationSubmissionInput): Promise { + const current = this.readCurrentTask(input.userId); + if (current) return { created: false as const, task: current }; + const { newReferences, ...fields } = input; + const upload = this.beginUpload(fields); + try { + for (const reference of newReferences) await upload.stageReference(reference); + return await upload.commit(); + } catch (error) { + upload.abort(); + throw error; + } + } + + beginUpload(input: GenerationSubmissionFields): GenerationUploadSession { + const preflightModel = this.validate({ ...input, newReferences: [] }); + const staged: StagedManagedFile[] = []; + let finished = false; + const abort = () => { + if (finished) return; + finished = true; + for (const file of staged) this.storage.abandonStagedFile(file); + }; + return { + abort, + commit: async () => { + if (finished) throw new GenerationSubmissionError("generation_request_invalid"); + if (this.beforeTransaction) await this.beforeTransaction(); + try { + const result = this.immediate(() => this.commitSubmission({ ...input, newReferences: [] }, staged)); + if (result.created) finished = true; + else abort(); + return result; + } catch (error) { + abort(); + throw error; + } + }, + stageReference: async (reference) => { + if (finished) throw new GenerationSubmissionError("generation_request_invalid"); + const nextCount = input.existingReferenceAssetIds.length + staged.length + 1; + const nextBytes = staged.reduce((sum, file) => sum + file.bytes, 0) + reference.projectedBytes; + if (nextCount > preflightModel.referenceLimits.maxFiles || nextBytes > preflightModel.referenceLimits.maxTotalBytes + || !safeInteger(reference.projectedBytes) || reference.projectedBytes > preflightModel.referenceLimits.maxFileBytes) { + throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" }); + } + try { + staged.push(await this.storage.stagePrivateImage({ + content: reference.content, + expectedMimeType: reference.mimeType, + fileName: reference.fileName, + maximumBytes: preflightModel.referenceLimits.maxFileBytes, + operationId: randomUUID(), + ownerRef: input.userId, + projectedWriteBytes: reference.projectedBytes, + })); + } catch (error) { + if (error instanceof GenerationSubmissionError || (error && typeof error === "object" && "code" in error)) throw error; + throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" }); + } + }, + }; + } + + private validate(input: GenerationSubmissionInput) { + if (!uuidPattern.test(input.userId) || !uuidPattern.test(input.clientSubmissionId) + || !idempotencyPattern.test(input.idempotencyKey) || !projectRatios.includes(input.ratio) + || !safeInteger(input.modelConfigVersion) || !safeInteger(input.confirmedCreditCost) + || (input.mode === "existing_project" && (!input.projectId || !uuidPattern.test(input.projectId))) + || (input.mode === "new_project" && input.projectId !== undefined)) { + throw new GenerationSubmissionError("generation_request_invalid"); + } + const prompt = normalizePrompt(input.prompt); + const model = this.models.readModel(input.modelId); + if (!model) throw new GenerationSubmissionError("generation_blocked", { errorCategory: "model_disabled" }); + if (model.configVersion !== input.modelConfigVersion || model.creditCost !== input.confirmedCreditCost) { + throw new GenerationSubmissionError("model_config_stale", { + latest: { configVersion: model.configVersion, creditCost: model.creditCost, modelId: model.modelId }, + }); + } + if (!model.enabled) throw new GenerationSubmissionError("generation_blocked", { errorCategory: "model_disabled" }); + if (model.contractValidationStatus !== "verified") { + throw new GenerationSubmissionError("generation_blocked", { errorCategory: "gateway_contract_invalid" }); + } + if (!model.runtimeAvailability.availableForNewJobs) { + throw new GenerationSubmissionError("generation_blocked", { errorCategory: model.runtimeAvailability.reason ?? "model_disabled" }); + } + if (prompt.length > model.promptMaxLength || !model.supportedRatios.includes(input.ratio)) { + throw new GenerationSubmissionError("generation_request_invalid"); + } + const referenceCount = input.newReferences.length + input.existingReferenceAssetIds.length; + const projectedBytes = input.newReferences.reduce((sum, reference) => sum + reference.projectedBytes, 0); + if (referenceCount > model.referenceLimits.maxFiles || projectedBytes > model.referenceLimits.maxTotalBytes + || input.newReferences.some((reference) => !safeInteger(reference.projectedBytes) || reference.projectedBytes > model.referenceLimits.maxFileBytes) + || new Set(input.existingReferenceAssetIds).size !== input.existingReferenceAssetIds.length + || input.existingReferenceAssetIds.some((id) => !uuidPattern.test(id)) + || (input.mode === "new_project" && input.existingReferenceAssetIds.length > 0)) { + throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" }); + } + const storageState = this.storage.getState(); + if (storageState.storage_status !== "active") { + throw new GenerationSubmissionError("generation_storage_unavailable", { + storage: { + capacityStatus: storageState.storage_status, + remainingBytes: Math.max( + 0, + storageState.hard_limit_bytes - storageState.managed_content_bytes - storageState.active_storage_reservations_bytes, + ), + }, + }); + } + return model; + } + + private commitSubmission(input: GenerationSubmissionInput, staged: StagedManagedFile[]) { + const model = this.validate(input); + const requestHash = digest(stableJson({ + client_submission_id: input.clientSubmissionId, + confirmed_credit_cost: input.confirmedCreditCost, + existing_reference_asset_ids: input.existingReferenceAssetIds, + mode: input.mode, + model_config_version: input.modelConfigVersion, + model_id: input.modelId, + new_references: staged.map((file) => ({ bytes: file.bytes, mime_type: file.mimeType, sha256: file.sha256 })), + project_id: input.projectId ?? null, + prompt: normalizePrompt(input.prompt), + ratio: input.ratio, + })); + const keyDigest = digest(input.idempotencyKey); + const receipt = this.database.prepare(` + SELECT r.request_hash, g.* FROM generation_submission_receipts r + JOIN generation_jobs g ON g.generation_id = r.generation_id + WHERE r.owner_id = ? AND r.idempotency_key_digest = ? + `).get(input.userId, keyDigest) as (GenerationRow & { request_hash: string }) | undefined; + if (receipt) { + if (receipt.request_hash !== requestHash) throw new GenerationSubmissionError("generation_idempotency_conflict"); + return { created: false as const, task: this.taskView(receipt) }; + } + const bySubmission = this.database.prepare("SELECT * FROM generation_jobs WHERE client_submission_id = ?") + .get(input.clientSubmissionId) as (GenerationRow & { submission_request_hash: string | null }) | undefined; + if (bySubmission) { + if (bySubmission.submission_request_hash !== requestHash || bySubmission.owner_id !== input.userId) { + throw new GenerationSubmissionError("generation_idempotency_conflict"); + } + return { created: false as const, task: this.taskView(bySubmission) }; + } + const current = this.readCurrentTask(input.userId); + if (current) return { created: false as const, task: current }; + + const now = this.clock(); + const prompt = normalizePrompt(input.prompt); + const generationId = randomUUID(); + const projectId = input.mode === "new_project" + ? this.insertProject(input.userId, prompt, input.ratio, now) + : this.validateExistingProject(input.userId, input.projectId!, input.ratio, prompt, now); + this.database.prepare(` + INSERT INTO generation_jobs ( + generation_id, owner_id, project_id, prompt, ratio, status, model_id, model_config_version, + confirmed_credit_cost, reserved_credits, final_credit_state, error_category, created_at, updated_at, + client_submission_id, submission_request_hash, submission_ready, config_snapshot_json + ) VALUES (?, ?, ?, ?, ?, 'queued', ?, ?, ?, 0, NULL, NULL, ?, ?, ?, ?, 0, ?) + `).run( + generationId, input.userId, projectId, prompt, input.ratio, input.modelId, input.modelConfigVersion, + input.confirmedCreditCost, now, now, input.clientSubmissionId, requestHash, + stableJson({ + config_set_version: model.configSetVersion, + config_version: model.configVersion, + credit_cost: model.creditCost, + model_id: model.modelId, + prompt_max_length: model.promptMaxLength, + reference_limits: model.referenceLimits, + supported_ratios: model.supportedRatios, + }), + ); + + const referenceIds: string[] = []; + for (const file of staged) { + this.storage.moveStagedFile(file); + this.database.prepare(` + INSERT INTO managed_files (file_id, file_kind, owner_ref, relative_path, byte_size, mime_type, sha256, status, created_at) + VALUES (?, 'reference', ?, ?, ?, ?, ?, 'committed', ?) + `).run(file.fileId, input.userId, file.relativePath, file.bytes, file.mimeType, file.sha256, iso(now)); + this.database.prepare(`INSERT INTO project_resource_files (project_id, managed_file_id, resource_kind, created_at) VALUES (?, ?, 'reference', ?)`) + .run(projectId, file.fileId, now); + this.database.prepare(`INSERT INTO project_asset_refs (reference_id, managed_file_id, reference_type, created_at) VALUES (?, ?, 'project', ?)`) + .run(`project:${projectId}:${file.fileId}`, file.fileId, iso(now)); + referenceIds.push(file.fileId); + } + for (const referenceId of input.existingReferenceAssetIds) { + const allowed = this.database.prepare(` + SELECT mf.file_id FROM managed_files mf + JOIN project_resource_files prf ON prf.managed_file_id = mf.file_id + JOIN projects p ON p.project_id = prf.project_id + WHERE mf.file_id = ? AND mf.file_kind = 'reference' AND mf.status = 'committed' + AND mf.owner_ref = ? AND prf.project_id = ? AND p.owner_id = ? AND p.status = 'active' + `).get(referenceId, input.userId, projectId, input.userId); + if (!allowed) throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" }); + referenceIds.push(referenceId); + } + referenceIds.forEach((referenceId, index) => this.database.prepare(` + INSERT INTO generation_reference_snapshots (generation_id, position, managed_file_id, source_kind, created_at) + VALUES (?, ?, ?, ?, ?) + `).run(generationId, index, referenceId, index < staged.length ? "uploaded" : "existing", now)); + + this.credits.reserveGeneration({ + creditCost: input.confirmedCreditCost, + generationId, + modelId: input.modelId, + operationKey: `generation:${generationId}:reserve`, + userId: input.userId, + }); + this.database.prepare("UPDATE generation_jobs SET submission_ready = 1 WHERE generation_id = ?").run(generationId); + this.database.prepare(` + INSERT INTO generation_submission_receipts (owner_id, idempotency_key_digest, request_hash, generation_id, created_at) + VALUES (?, ?, ?, ?, ?) + `).run(input.userId, keyDigest, requestHash, generationId, now); + this.consumeStagedStorage(staged, now); + return { created: true as const, task: this.readTask(input.userId, generationId) }; + } + + private insertProject(ownerId: string, prompt: string, ratio: ProjectRatio, now: number) { + const active = this.database.prepare("SELECT COUNT(*) AS count FROM projects WHERE owner_id = ? AND status = 'active'") + .get(ownerId) as { count: number }; + if (active.count >= projectLimit) throw new GenerationSubmissionError("generation_request_invalid"); + const projectId = randomUUID(); + const pixels = ratioPixels[ratio]; + const name = defaultProjectName(prompt, now); + this.database.prepare(` + INSERT INTO projects ( + project_id, owner_id, name, draft_prompt, ratio, pixel_width, pixel_height, + status, state_version, current_image_id, created_at, updated_at, deleted_at, purge_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, 'active', 1, NULL, ?, ?, NULL, NULL) + `).run(projectId, ownerId, name, prompt, ratio, pixels.width, pixels.height, now, now); + this.database.prepare(` + INSERT INTO project_states (project_id, state_version, name, canvas_json, created_at) VALUES (?, 1, ?, ?, ?) + `).run(projectId, name, stableJson(defaultCanvasState(ratio, pixels, null)), now); + return projectId; + } + + private validateExistingProject(ownerId: string, projectId: string, ratio: ProjectRatio, prompt: string, now: number) { + const project = this.database.prepare("SELECT ratio FROM projects WHERE project_id = ? AND owner_id = ? AND status = 'active'") + .get(projectId, ownerId) as { ratio: ProjectRatio } | undefined; + if (!project || project.ratio !== ratio) throw new GenerationSubmissionError("generation_request_invalid"); + const history = this.database.prepare("SELECT COUNT(*) AS count FROM project_images WHERE project_id = ?").get(projectId) as { count: number }; + if (history.count >= historyLimit) throw new GenerationSubmissionError("generation_request_invalid"); + this.database.prepare("UPDATE projects SET draft_prompt = ?, updated_at = ? WHERE project_id = ?").run(prompt, now, projectId); + return projectId; + } + + private consumeStagedStorage(staged: StagedManagedFile[], now: number) { + if (staged.length === 0) return; + const total = staged.reduce((sum, file) => sum + file.bytes, 0); + for (const file of staged) { + this.database.prepare(` + UPDATE storage_reservations SET status = 'consumed', resolved_at = ? + WHERE operation_id = ? AND status = 'active' + `).run(iso(now), file.operationId); + } + const state = this.database.prepare("SELECT managed_content_bytes FROM local_backend_storage_state WHERE singleton = 1") + .get() as { managed_content_bytes: number }; + const active = this.database.prepare("SELECT COALESCE(SUM(projected_bytes), 0) AS bytes FROM storage_reservations WHERE status = 'active'") + .get() as { bytes: number }; + const nextBytes = state.managed_content_bytes + total; + const classification = classifyCapacity(nextBytes, active.bytes); + this.database.prepare(` + UPDATE local_backend_storage_state + SET managed_content_bytes = ?, capacity_notice_level = ?, storage_status = ?, measured_at = ?, version = version + 1 + WHERE singleton = 1 + `).run(nextBytes, classification.capacity_notice_level, classification.storage_status, iso(now)); + } + + private taskView(row: GenerationRow): GenerationTaskView { + const referenceCount = (this.database.prepare("SELECT COUNT(*) AS count FROM generation_reference_snapshots WHERE generation_id = ?") + .get(row.generation_id) as { count: number }).count; + return { + confirmedCreditCost: row.confirmed_credit_cost, + createdAt: iso(row.created_at), + generationId: row.generation_id, + modelConfigVersion: row.model_config_version, + modelId: row.model_id, + projectId: row.project_id, + prompt: row.prompt, + ratio: row.ratio, + referenceCount, + reservedCredits: row.reserved_credits, + status: row.status, + updatedAt: iso(row.updated_at), + }; + } + + private immediate(action: () => T): T { + this.database.exec("BEGIN IMMEDIATE"); + try { + const result = action(); + this.database.exec("COMMIT"); + return result; + } catch (error) { + if (this.database.inTransaction) this.database.exec("ROLLBACK"); + throw error; + } + } + + private ensureColumn(table: string, column: string, definition: string) { + const columns = this.database.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>; + if (!columns.some((value) => value.name === column)) this.database.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`); + } + + private migrate() { + this.ensureColumn("generation_jobs", "client_submission_id", "TEXT"); + this.ensureColumn("generation_jobs", "submission_request_hash", "TEXT"); + this.ensureColumn("generation_jobs", "submission_ready", "INTEGER NOT NULL DEFAULT 0"); + this.ensureColumn("generation_jobs", "config_snapshot_json", "TEXT"); + this.database.exec(` + CREATE UNIQUE INDEX IF NOT EXISTS generation_jobs_active_owner + ON generation_jobs(owner_id) WHERE status IN ('queued', 'running'); + CREATE UNIQUE INDEX IF NOT EXISTS generation_jobs_client_submission + ON generation_jobs(client_submission_id) WHERE client_submission_id IS NOT NULL; + CREATE TABLE IF NOT EXISTS generation_submission_receipts ( + owner_id TEXT NOT NULL, + idempotency_key_digest TEXT NOT NULL CHECK (length(idempotency_key_digest) = 64), + request_hash TEXT NOT NULL CHECK (length(request_hash) = 64), + generation_id TEXT NOT NULL UNIQUE REFERENCES generation_jobs(generation_id), + created_at INTEGER NOT NULL, + PRIMARY KEY (owner_id, idempotency_key_digest) + ); + CREATE TABLE IF NOT EXISTS generation_reference_snapshots ( + generation_id TEXT NOT NULL REFERENCES generation_jobs(generation_id) ON DELETE CASCADE, + position INTEGER NOT NULL CHECK (position >= 0), + managed_file_id TEXT NOT NULL REFERENCES managed_files(file_id), + source_kind TEXT NOT NULL CHECK (source_kind IN ('uploaded', 'existing')), + created_at INTEGER NOT NULL, + PRIMARY KEY (generation_id, position), + UNIQUE (generation_id, managed_file_id) + ); + CREATE TRIGGER IF NOT EXISTS generation_reference_snapshots_no_update + BEFORE UPDATE ON generation_reference_snapshots BEGIN SELECT RAISE(ABORT, 'generation_reference_snapshot_immutable'); END; + DROP TRIGGER IF EXISTS generation_reference_snapshots_no_delete; + CREATE TRIGGER generation_reference_snapshots_no_delete + BEFORE DELETE ON generation_reference_snapshots + WHEN dada_allow_privacy_purge() <> 1 + BEGIN SELECT RAISE(ABORT, 'generation_reference_snapshot_immutable'); END; + `); + } +} diff --git a/apps/api/src/managed-storage.ts b/apps/api/src/managed-storage.ts index 916746e..0c122f4 100644 --- a/apps/api/src/managed-storage.ts +++ b/apps/api/src/managed-storage.ts @@ -113,6 +113,9 @@ function sniffMime(prefix: Buffer) { return "image/png"; } if (prefix.length >= 3 && prefix[0] === 0xff && prefix[1] === 0xd8 && prefix[2] === 0xff) return "image/jpeg"; + if (prefix.length >= 12 && prefix.subarray(0, 4).toString("ascii") === "RIFF" && prefix.subarray(8, 12).toString("ascii") === "WEBP") { + return "image/webp"; + } return "application/octet-stream"; } @@ -129,7 +132,7 @@ function listFiles(root: string): string[] { export interface CommitStreamInput { content: Readable; - expectedMimeType: "image/png" | "image/jpeg" | "application/octet-stream"; + expectedMimeType: "image/png" | "image/jpeg" | "image/webp" | "application/octet-stream"; expectedSha256?: string; failurePoint?: CommitFailurePoint; fileKind: ManagedFileKind; @@ -139,6 +142,20 @@ export interface CommitStreamInput { projectedWriteBytes: number; } +export interface StagedManagedFile { + bytes: number; + destinationPath: string; + fileId: string; + fileKind: ManagedFileKind; + mimeType: "image/png" | "image/jpeg" | "image/webp"; + operationId: string; + ownerRef: string; + relativePath: string; + sha256: string; + stagingDirectory: string; + stagingPath: string; +} + export class ManagedStorage { readonly dataRoot: string; readonly databasePath: string; @@ -479,6 +496,87 @@ export class ManagedStorage { } } + async stagePrivateImage(input: { + content: Readable; + expectedMimeType: "image/png" | "image/jpeg" | "image/webp"; + fileName: string; + maximumBytes: number; + operationId: string; + ownerRef: string; + projectedWriteBytes: number; + }): Promise { + const fileId = randomUUID(); + const destination = this.destination({ + content: input.content, + expectedMimeType: input.expectedMimeType, + fileKind: "reference", + fileName: input.fileName, + operationId: input.operationId, + ownerRef: input.ownerRef, + projectedWriteBytes: input.projectedWriteBytes, + }, fileId); + if (!Number.isSafeInteger(input.maximumBytes) || input.maximumBytes <= 0) throw new Error("maximum_bytes_invalid"); + this.reserve(input.operationId, input.projectedWriteBytes); + const stagingDirectory = resolvePathWithinRoot(this.dataRoot, `staging/${input.operationId}`); + const stagingPath = resolvePathWithinRoot(this.dataRoot, `staging/${input.operationId}/payload.tmp`); + try { + mkdirSync(stagingDirectory, { recursive: true }); + const hash = createHash("sha256"); + let byteSize = 0; + let prefix = Buffer.alloc(0); + const inspect = new Transform({ + transform(chunk: Buffer | string, encoding, callback) { + const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk, encoding); + byteSize += bytes.byteLength; + if (byteSize > input.maximumBytes) return callback(new Error("content_size_invalid")); + hash.update(bytes); + if (prefix.byteLength < 16) prefix = Buffer.concat([prefix, bytes.subarray(0, 16 - prefix.byteLength)]); + callback(null, bytes); + }, + }); + await pipeline(input.content, inspect, createWriteStream(stagingPath, { flags: "wx" })); + validatePositiveBytes(byteSize, "actual_write_bytes"); + if (sniffMime(prefix) !== input.expectedMimeType) throw new Error("content_mime_invalid"); + const state = this.getState(); + const otherReservations = this.activeReservationBytes(input.operationId); + if (state.managed_content_bytes + otherReservations + byteSize > HARD_LIMIT_BYTES) { + throw new StorageCapacityError({ activeReservationBytes: otherReservations, managedContentBytes: state.managed_content_bytes, projectedWriteBytes: byteSize }); + } + this.database.prepare("UPDATE storage_reservations SET projected_bytes = ? WHERE operation_id = ? AND status = 'active'") + .run(byteSize, input.operationId); + this.refreshState(); + return { + bytes: byteSize, + destinationPath: destination.absolutePath, + fileId, + fileKind: "reference", + mimeType: input.expectedMimeType, + operationId: input.operationId, + ownerRef: input.ownerRef, + relativePath: destination.relativePath, + sha256: hash.digest("hex"), + stagingDirectory, + stagingPath, + }; + } catch (error) { + rmSync(stagingDirectory, { force: true, recursive: true }); + this.releaseReservation(input.operationId); + throw error; + } + } + + moveStagedFile(file: StagedManagedFile) { + mkdirSync(dirname(file.destinationPath), { recursive: true }); + renameSync(file.stagingPath, file.destinationPath); + rmSync(file.stagingDirectory, { force: true, recursive: true }); + } + + abandonStagedFile(file: StagedManagedFile) { + if (existsSync(file.destinationPath)) this.queueCompensation(file.relativePath, statSync(file.destinationPath).size); + else rmSync(file.stagingDirectory, { force: true, recursive: true }); + this.releaseReservation(file.operationId); + } + async commitBufferFixture(fileKind: ManagedFileKind, fileName: string, bytes: Buffer) { return this.commitStream({ content: Readable.from(bytes), diff --git a/apps/api/src/projects.ts b/apps/api/src/projects.ts index bb852d0..671d34a 100644 --- a/apps/api/src/projects.ts +++ b/apps/api/src/projects.ts @@ -15,14 +15,14 @@ export type ProjectRatio = typeof projectRatios[number]; export type GenerationStatus = "queued" | "running" | "succeeded" | "failed" | "rejected"; export type ProjectViewStatus = "active" | "failed_empty" | "trashed"; export type ProjectManagedResourceKind = "derived" | "export" | "generated" | "reference"; -const ratioPixels: Record = { +export const ratioPixels: Record = { "3:4": { height: 1440, width: 1080 }, "1:1": { height: 1080, width: 1080 }, "4:3": { height: 1080, width: 1440 }, "9:16": { height: 1920, width: 1080 }, }; -const projectLimit = 20; -const historyLimit = 10; +export const projectLimit = 20; +export const historyLimit = 10; const trashRetentionMilliseconds = 720 * 60 * 60 * 1_000; const generationErrorCategories = new Set([ "upstream_timeout", "upstream_failed", "safety_rejected", "model_disabled", @@ -76,7 +76,7 @@ function isProjectRatio(value: string): value is ProjectRatio { return projectRatios.includes(value as ProjectRatio); } -function normalizePrompt(value: string) { +export function normalizePrompt(value: string) { const normalized = value.trim().replace(/\s+/gu, " "); if (!normalized || normalized.length > 4_000) throw new ProjectError("generation_state_invalid"); return normalized; @@ -100,7 +100,7 @@ function localDate(timestamp: number) { .join("-"); } -function defaultProjectName(prompt: string, timestamp: number) { +export function defaultProjectName(prompt: string, timestamp: number) { const summary = takeGraphemes(normalizePrompt(prompt), 24) || "未命名创作"; return `${summary} ${localDate(timestamp)}`; } @@ -115,7 +115,7 @@ function iso(timestamp: number) { return new Date(timestamp).toISOString(); } -function defaultCanvasState(ratio: ProjectRatio, pixels: { height: number; width: number }, assetId: string | null): CanvasState { +export function defaultCanvasState(ratio: ProjectRatio, pixels: { height: number; width: number }, assetId: string | null): CanvasState { return { background: { adjustments: { @@ -138,7 +138,7 @@ function defaultCanvasState(ratio: ProjectRatio, pixels: { height: number; width }; } -function stableJson(value: unknown): string { +export function stableJson(value: unknown): string { if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; if (value && typeof value === "object") { return `{${Object.entries(value).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0) diff --git a/apps/web/src/generated/api/sdk.gen.ts b/apps/web/src/generated/api/sdk.gen.ts index daeef7b..bc80267 100644 --- a/apps/web/src/generated/api/sdk.gen.ts +++ b/apps/web/src/generated/api/sdk.gen.ts @@ -1,6 +1,6 @@ // Generated from openapi/openapi.json. Do not edit by hand. -import type { CreditAdjustmentResponse, CreditAdjustmentRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, AccountSettingsResponse, AdminSessionResponse, CreditBalanceResponse, CreditLedgerResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, LogoutResponse, ProjectPurgeResponse, ProjectRenameResponse, ProjectRenameRequest, ProjectRestoreResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js"; +import type { CreditAdjustmentResponse, CreditAdjustmentRequest, AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, GenerationCreateResponse, AccountSettingsResponse, AdminSessionResponse, CreditBalanceResponse, GenerationTaskResponse, CreditLedgerResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, LogoutResponse, ProjectPurgeResponse, ProjectRenameResponse, ProjectRenameRequest, ProjectRestoreResponse, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, ProjectTrashResponse, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js"; export interface ClientOptions { baseUrl?: string; fetch?: typeof globalThis.fetch; headers?: HeadersInit; } @@ -90,6 +90,13 @@ export async function completeRegistration(body: RegistrationCompleteRequest, op return response.json() as Promise; } +export async function createGeneration(options: ClientOptions = {}): Promise { + const request = options.fetch ?? globalThis.fetch; + const response = await request(`${options.baseUrl ?? ""}/api/v1/generations`, { method: "POST", headers: options.headers ?? {} }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; +} + export async function getAccountSettings(options: ClientOptions = {}): Promise { const request = options.fetch ?? globalThis.fetch; const response = await request(`${options.baseUrl ?? ""}/api/v1/account/settings`, { method: "GET", headers: options.headers ?? {} }); @@ -150,10 +157,24 @@ export async function getBootstrap(options: ClientOptions = {}): Promise<{ }>; } +export async function getCurrentGeneration(options: ClientOptions = {}): Promise { + const request = options.fetch ?? globalThis.fetch; + const response = await request(`${options.baseUrl ?? ""}/api/v1/generations/current`, { method: "GET", headers: options.headers ?? {} }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; +} + export function getEvents(options: Pick = {}): string { return `${options.baseUrl ?? ""}/api/v1/events`; } +export async function getGeneration(options: ClientOptions = {}): Promise { + const request = options.fetch ?? globalThis.fetch; + const response = await request(`${options.baseUrl ?? ""}/api/v1/generations/{generationId}`, { method: "GET", headers: options.headers ?? {} }); + if (!response.ok) throw new Error(`HTTP ${response.status}`); + return response.json() as Promise; +} + export async function getMyCreditLedger(options: ClientOptions = {}): Promise { const request = options.fetch ?? globalThis.fetch; const response = await request(`${options.baseUrl ?? ""}/api/v1/me/credit-ledger`, { method: "GET", headers: options.headers ?? {} }); diff --git a/apps/web/src/generated/api/types.gen.ts b/apps/web/src/generated/api/types.gen.ts index 3bd43e1..a6bd78e 100644 --- a/apps/web/src/generated/api/types.gen.ts +++ b/apps/web/src/generated/api/types.gen.ts @@ -320,8 +320,36 @@ export type FailedEmptyTrashResponse = { "trashed_project_ids": Array; }; +export type GenerationCreateHeaders = { + "idempotency-key": string; + "x-csrf-token": string; +}; + +export type GenerationCreateResponse = { + "created": boolean; + "task": GenerationTaskResponse; +}; + export type GenerationErrorCategory = "upstream_timeout" | "upstream_failed" | "safety_rejected" | "model_disabled" | "gateway_balance_insufficient" | "gateway_contract_invalid" | "reference_invalid" | "unknown_retryable" | "unknown_non_retryable"; +export type GenerationMultipartBody = { + "client_submission_id": string; + "confirmed_credit_cost": string; + "creation_mode": "new_project" | "existing_project"; + "existing_reference_asset_ids"?: string; + "model_config_version": string; + "model_id": string; + "project_id"?: string; + "prompt": string; + "ratio": ProjectRatio; + "reference_files"?: Array; + "reference_manifest"?: string; +}; + +export type GenerationParams = { + "generationId": string; +}; + export type GenerationProjectItem = { "created_at": string; "error_category": GenerationErrorCategory | null; @@ -332,6 +360,23 @@ export type GenerationProjectItem = { "updated_at": string; }; +export type GenerationTaskResponse = { + "confirmed_credit_cost": number; + "created_at": string; + "generation_id": string; + "model_config_version": number; + "model_id": string; + "project_id": ProjectId; + "prompt": string; + "ratio": ProjectRatio; + "reference_count": number; + "reserved_credits": number; + "status": GenerationTaskStatus; + "updated_at": string; +}; + +export type GenerationTaskStatus = "queued" | "running" | "succeeded" | "failed" | "rejected"; + export type LoginCompleteRequest = { "registration_id": string; "verification_code": string; diff --git a/apps/web/src/project-pages.css b/apps/web/src/project-pages.css index 3c0d745..bfba561 100644 --- a/apps/web/src/project-pages.css +++ b/apps/web/src/project-pages.css @@ -370,6 +370,93 @@ font-size: 13px; } +.current-task-active { + display: grid; + gap: 22px; + align-content: start; + margin-top: 28px; + padding-top: 18px; + border-top: 1px solid #a6a6a0; +} + +.current-task-active .task-state { + justify-self: start; + padding: 7px 10px; + border: 1px solid #111111; + background: #f2f500; + font-family: Consolas, monospace; + font-size: 12px; + font-weight: 800; +} + +.current-task-active > strong { + overflow-wrap: anywhere; + font-size: 20px; + line-height: 1.4; +} + +.current-task-active dl { + display: grid; + gap: 0; + margin: 0; + border-top: 1px solid #b6b6b0; +} + +.current-task-active dl > div { + display: flex; + justify-content: space-between; + gap: 16px; + padding: 11px 0; + border-bottom: 1px solid #b6b6b0; +} + +.current-task-active dt { + color: #65655f; +} + +.current-task-active dd { + margin: 0; + font-weight: 800; +} + +.current-task-active a { + color: #111111; + font-weight: 800; +} + +.capacity-critical, +.generation-notice { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + margin-top: 12px; + padding: 12px 14px; + border: 1px solid #111111; +} + +.capacity-critical { + background: #ffdf66; +} + +.capacity-critical span, +.generation-notice span { + font-size: 13px; +} + +.generation-notice { + background: #ffffff; +} + +.generation-notice button { + flex: 0 0 auto; + min-height: 38px; + padding: 7px 12px; + border: 1px solid #111111; + background: #f2f500; + font-weight: 800; +} + .recent-projects { grid-column: 1; margin-top: 28px; @@ -1167,6 +1254,12 @@ text-align: left; } + .capacity-critical, + .generation-notice { + align-items: flex-start; + flex-direction: column; + } + .projects-title { display: grid; } diff --git a/apps/web/src/project-pages.tsx b/apps/web/src/project-pages.tsx index 289c2f7..12b0c55 100644 --- a/apps/web/src/project-pages.tsx +++ b/apps/web/src/project-pages.tsx @@ -16,9 +16,53 @@ type ProjectStatus = "active" | "failed_empty" | "trashed"; interface SessionPayload { credits: { available_balance: number; reserved_balance: number }; csrf_token: string; + local_data?: LocalDataPayload; user: { creator_name: string }; } +interface LocalDataPayload { + capacity_status: "normal" | "warning" | "critical" | "full" | "unavailable"; + hard_limit_bytes: number; + managed_content_bytes: number; +} + +interface AccountSettingsPayload { + local_data: LocalDataPayload; +} + +interface ModelPayload { + config_set_version: number; + configured_default_model_id: string; + models: Array<{ + config_version: number; + contract_validation_status: "verified" | "unverified"; + credit_cost: number; + enabled: boolean; + is_default: boolean; + model_id: string; + prompt_max_length: number; + reference_limits: { max_file_bytes: number; max_files: number; max_total_bytes: number }; + runtime_availability: { available_for_new_jobs: boolean; checked_at: string; reason: string | null }; + supported_ratios: Ratio[]; + }>; + recommended_model_id: string | null; +} + +interface GenerationTaskPayload { + confirmed_credit_cost: number; + created_at: string; + generation_id: string; + model_config_version: number; + model_id: string; + project_id: string; + prompt: string; + ratio: Ratio; + reference_count: number; + reserved_credits: number; + status: "queued" | "running" | "succeeded" | "failed" | "rejected"; + updated_at: string; +} + interface ProjectSummary { current_image_id: string | null; deleted_at?: string | null; @@ -67,6 +111,17 @@ async function readJson(url: string, init?: RequestInit): Promise { return response.json() as Promise; } +async function readOptionalJson(url: string): Promise { + const response = await fetch(url, { credentials: "same-origin" }); + if (response.status === 401) { + window.dispatchEvent(new Event("dada:session-invalid")); + throw new Error("session_invalid"); + } + if (response.status === 404) return undefined; + if (!response.ok) throw new Error("request_failed"); + return response.json() as Promise; +} + function formatUpdatedAt(value: string) { return new Intl.DateTimeFormat("zh-CN", { dateStyle: "medium", timeStyle: "short" }).format(new Date(value)); } @@ -151,10 +206,17 @@ export function WorkspacePage() { const promptId = useId(); const [session, setSession] = useState(); const [projects, setProjects] = useState(); + const [models, setModels] = useState(); + const [currentTask, setCurrentTask] = useState(); + const [localData, setLocalData] = useState(); + const [generationStateLoaded, setGenerationStateLoaded] = useState(false); const [loadingFailed, setLoadingFailed] = useState(false); const [prompt, setPrompt] = useState(""); const [ratio, setRatio] = useState("3:4"); - const [references, setReferences] = useState([]); + const [references, setReferences] = useState([]); + const [submitting, setSubmitting] = useState(false); + const [generationNotice, setGenerationNotice] = useState(""); + const [requiresReconfirmation, setRequiresReconfirmation] = useState(false); useEffect(() => { let active = true; @@ -165,12 +227,113 @@ export function WorkspacePage() { if (!active) return; setSession(nextSession); setProjects(nextProjects); + setLocalData(nextSession.local_data); + return Promise.allSettled([ + readOptionalJson("/api/v1/models"), + readOptionalJson("/api/v1/generations/current"), + readOptionalJson("/api/v1/account/settings"), + ]).then(([modelResult, taskResult, settingsResult]) => { + if (!active) return; + if (modelResult.status === "fulfilled") setModels(modelResult.value); + if (taskResult.status === "fulfilled") setCurrentTask(taskResult.value); + if (settingsResult.status === "fulfilled" && settingsResult.value) setLocalData(settingsResult.value.local_data); + setGenerationStateLoaded(true); + }); }).catch((error) => { if (active && error instanceof Error && error.message !== "session_invalid") setLoadingFailed(true); }); return () => { active = false; }; }, []); + const selectedModel = useMemo(() => { + if (!models) return undefined; + const modelId = models.recommended_model_id ?? models.configured_default_model_id; + return models.models.find((model) => model.model_id === modelId && model.enabled + && model.contract_validation_status === "verified" && model.runtime_availability.available_for_new_jobs); + }, [models]); + + const referenceBytes = references.reduce((sum, file) => sum + file.size, 0); + const referencesValid = selectedModel !== undefined + && references.length <= selectedModel.reference_limits.max_files + && referenceBytes <= selectedModel.reference_limits.max_total_bytes + && references.every((file) => file.size > 0 && file.size <= selectedModel.reference_limits.max_file_bytes); + const capacityBlocksGeneration = localData?.capacity_status === "full" || localData?.capacity_status === "unavailable"; + const canSubmit = generationStateLoaded && !currentTask && !submitting && !requiresReconfirmation && selectedModel !== undefined + && prompt.trim().length > 0 && prompt.trim().length <= selectedModel.prompt_max_length + && selectedModel.supported_ratios.includes(ratio) && referencesValid + && session !== undefined && session.credits.available_balance >= selectedModel.credit_cost && !capacityBlocksGeneration; + + async function submitGeneration(event: FormEvent) { + event.preventDefault(); + if (!session || !selectedModel || !canSubmit) return; + const body = new FormData(); + body.append("client_submission_id", crypto.randomUUID()); + body.append("confirmed_credit_cost", String(selectedModel.credit_cost)); + body.append("creation_mode", "new_project"); + body.append("existing_reference_asset_ids", "[]"); + body.append("model_config_version", String(selectedModel.config_version)); + body.append("model_id", selectedModel.model_id); + body.append("prompt", prompt.trim()); + body.append("ratio", ratio); + body.append("reference_manifest", JSON.stringify(references.map((file) => ({ + file_name: file.name, + mime_type: file.type, + size: file.size, + })))); + for (const file of references) body.append("reference_files", file, file.name); + setSubmitting(true); + setGenerationNotice(""); + try { + const response = await fetch("/api/v1/generations", { + body, + credentials: "same-origin", + headers: { + "Idempotency-Key": `generation-${crypto.randomUUID()}`, + "X-CSRF-Token": session.csrf_token, + }, + method: "POST", + }); + if (response.status === 401) { + window.dispatchEvent(new Event("dada:session-invalid")); + return; + } + if (response.status === 412) { + setRequiresReconfirmation(true); + setGenerationNotice("模型配置已更新,请确认最新配置后重新提交。"); + return; + } + if (!response.ok) { + setGenerationNotice(response.status === 507 ? "本机存储空间不足,当前不能创建新任务。" : "任务未提交,请检查当前状态后重试。"); + return; + } + const result = await response.json() as { created: boolean; task: GenerationTaskPayload }; + setCurrentTask(result.task); + setSession((current) => current ? { + ...current, + credits: { + available_balance: current.credits.available_balance - (result.created ? result.task.reserved_credits : 0), + reserved_balance: current.credits.reserved_balance + (result.created ? result.task.reserved_credits : 0), + }, + } : current); + setGenerationNotice(result.created ? "任务已提交。" : "已返回当前进行中的任务。"); + } catch { + setGenerationNotice("任务未提交,请检查本机服务后重试。"); + } finally { + setSubmitting(false); + } + } + + async function confirmLatestModelConfiguration() { + try { + const next = await readJson("/api/v1/models"); + setModels(next); + setRequiresReconfirmation(false); + setGenerationNotice("已确认最新配置,请重新检查点数和参考图后提交。"); + } catch { + setGenerationNotice("暂时无法读取最新模型配置。"); + } + } + if (loadingFailed) { return (
@@ -197,7 +360,7 @@ export function WorkspacePage() { ) : null}
-
+
{!empty ? (

NEW PROJECT

新建创作

@@ -215,14 +378,21 @@ export function WorkspacePage() {
模型 - 当前没有可用于新任务的模型 + {selectedModel ? selectedModel.model_id : "当前没有可用于新任务的模型"}
画面比例
{(["3:4", "1:1", "4:3", "9:16"] as const).map((value) => ( ))} @@ -231,22 +401,49 @@ export function WorkspacePage() {
+ {localData?.capacity_status === "critical" ? ( +
+ 存储空间已超过 90% + 请尽快清理本机内容,达到上限后将无法提交新任务。 +
+ ) : null} + {generationNotice ? ( +
+ {generationNotice} + {requiresReconfirmation ? ( + + ) : null} +
+ ) : null}
- 预计点数将在模型可用后显示 - + {selectedModel ? `本次预计冻结 ${selectedModel.credit_cost} 点` : "预计点数将在模型可用后显示"} +
-
+ {!empty ? (
diff --git a/openapi/openapi.json b/openapi/openapi.json index 3a5d703..ab98e4e 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -2127,6 +2127,44 @@ ], "type": "object" }, + "GenerationCreateHeaders": { + "additionalProperties": true, + "properties": { + "idempotency-key": { + "maxLength": 200, + "minLength": 32, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + }, + "x-csrf-token": { + "maxLength": 64, + "minLength": 43, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + } + }, + "required": [ + "idempotency-key", + "x-csrf-token" + ], + "type": "object" + }, + "GenerationCreateResponse": { + "additionalProperties": false, + "properties": { + "created": { + "type": "boolean" + }, + "task": { + "$ref": "#/components/schemas/GenerationTaskResponse" + } + }, + "required": [ + "created", + "task" + ], + "type": "object" + }, "GenerationErrorCategory": { "anyOf": [ { @@ -2185,6 +2223,95 @@ } ] }, + "GenerationMultipartBody": { + "additionalProperties": false, + "properties": { + "client_submission_id": { + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", + "type": "string" + }, + "confirmed_credit_cost": { + "pattern": "^[1-9][0-9]*$", + "type": "string" + }, + "creation_mode": { + "anyOf": [ + { + "enum": [ + "new_project" + ], + "type": "string" + }, + { + "enum": [ + "existing_project" + ], + "type": "string" + } + ] + }, + "existing_reference_asset_ids": { + "maxLength": 12000, + "type": "string" + }, + "model_config_version": { + "pattern": "^[1-9][0-9]*$", + "type": "string" + }, + "model_id": { + "maxLength": 160, + "minLength": 1, + "type": "string" + }, + "project_id": { + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", + "type": "string" + }, + "prompt": { + "maxLength": 4000, + "minLength": 1, + "type": "string" + }, + "ratio": { + "$ref": "#/components/schemas/ProjectRatio" + }, + "reference_files": { + "items": { + "format": "binary", + "type": "string" + }, + "maxItems": 16, + "type": "array" + }, + "reference_manifest": { + "maxLength": 16384, + "type": "string" + } + }, + "required": [ + "client_submission_id", + "confirmed_credit_cost", + "creation_mode", + "model_config_version", + "model_id", + "prompt", + "ratio" + ], + "type": "object" + }, + "GenerationParams": { + "additionalProperties": false, + "properties": { + "generationId": { + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", + "type": "string" + } + }, + "required": [ + "generationId" + ], + "type": "object" + }, "GenerationProjectItem": { "additionalProperties": false, "properties": { @@ -2264,6 +2391,107 @@ ], "type": "object" }, + "GenerationTaskResponse": { + "additionalProperties": false, + "properties": { + "confirmed_credit_cost": { + "minimum": 1, + "type": "integer" + }, + "created_at": { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$", + "type": "string" + }, + "generation_id": { + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", + "type": "string" + }, + "model_config_version": { + "minimum": 1, + "type": "integer" + }, + "model_id": { + "maxLength": 160, + "minLength": 1, + "type": "string" + }, + "project_id": { + "$ref": "#/components/schemas/ProjectId" + }, + "prompt": { + "maxLength": 4000, + "minLength": 1, + "type": "string" + }, + "ratio": { + "$ref": "#/components/schemas/ProjectRatio" + }, + "reference_count": { + "minimum": 0, + "type": "integer" + }, + "reserved_credits": { + "minimum": 0, + "type": "integer" + }, + "status": { + "$ref": "#/components/schemas/GenerationTaskStatus" + }, + "updated_at": { + "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$", + "type": "string" + } + }, + "required": [ + "confirmed_credit_cost", + "created_at", + "generation_id", + "model_config_version", + "model_id", + "project_id", + "prompt", + "ratio", + "reference_count", + "reserved_credits", + "status", + "updated_at" + ], + "type": "object" + }, + "GenerationTaskStatus": { + "anyOf": [ + { + "enum": [ + "queued" + ], + "type": "string" + }, + { + "enum": [ + "running" + ], + "type": "string" + }, + { + "enum": [ + "succeeded" + ], + "type": "string" + }, + { + "enum": [ + "failed" + ], + "type": "string" + }, + { + "enum": [ + "rejected" + ], + "type": "string" + } + ] + }, "LoginCompleteRequest": { "additionalProperties": false, "properties": { @@ -5530,6 +5758,233 @@ ] } }, + "/api/v1/generations": { + "post": { + "operationId": "createGeneration", + "parameters": [ + { + "in": "header", + "name": "idempotency-key", + "required": true, + "schema": { + "maxLength": 200, + "minLength": 32, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + } + }, + { + "in": "header", + "name": "x-csrf-token", + "required": true, + "schema": { + "maxLength": 64, + "minLength": 43, + "pattern": "^[A-Za-z0-9_-]+$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "multipart/form-data": { + "schema": { + "$ref": "#/components/schemas/GenerationMultipartBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenerationCreateResponse" + } + } + }, + "description": "Default Response" + }, + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenerationCreateResponse" + } + } + }, + "description": "Default Response" + }, + "400": { + "description": "Default Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "412": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "507": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + } + }, + "tags": [ + "Generations" + ] + } + }, + "/api/v1/generations/{generationId}": { + "get": { + "operationId": "getGeneration", + "parameters": [ + { + "in": "path", + "name": "generationId", + "required": true, + "schema": { + "pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenerationTaskResponse" + } + } + }, + "description": "Default Response" + }, + "400": { + "description": "Default Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "404": { + "description": "Default Response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + } + }, + "tags": [ + "Generations" + ] + } + }, + "/api/v1/generations/current": { + "get": { + "operationId": "getCurrentGeneration", + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GenerationTaskResponse" + } + } + }, + "description": "Default Response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + }, + "404": { + "description": "Default Response" + }, + "503": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorEnvelope" + } + } + }, + "description": "Default Response" + } + }, + "tags": [ + "Generations" + ] + } + }, "/api/v1/me/credit-ledger": { "get": { "operationId": "getMyCreditLedger", diff --git a/package.json b/package.json index fdaf973..5799118 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "test:integration": "vitest run tests/integration", "test:api": "pnpm check:openapi && vitest run tests/api", "test:worker": "pnpm --filter @dada/worker build && node scripts/worker-smoke.mjs && vitest run tests/worker", - "test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts tests/e2e/project-trash.spec.ts tests/e2e/credits.spec.ts --config playwright.config.ts", + "test:e2e": "pnpm check:openapi && playwright test tests/e2e/event-sync.spec.ts tests/e2e/support-gate.spec.ts tests/e2e/local-data-boundary.spec.ts tests/e2e/storage-capacity.spec.ts tests/e2e/public-asset-cache.spec.ts tests/e2e/user-auth.spec.ts tests/e2e/admin-auth.spec.ts tests/e2e/entry-state-ui.spec.ts tests/e2e/session-invalid-ui.spec.ts tests/e2e/user-registration.spec.ts tests/e2e/account-settings.spec.ts tests/e2e/projects-workspace.spec.ts tests/e2e/project-autosave-conflict.spec.ts tests/e2e/project-trash.spec.ts tests/e2e/credits.spec.ts tests/e2e/generation-workspace.spec.ts --config playwright.config.ts", "test:visual": "node scripts/validate-layer-scope.mjs VISUAL", "test:performance": "node scripts/validate-layer-scope.mjs PERFORMANCE", "test:security": "node scripts/verify-frozen-dependencies.mjs && node scripts/redaction-scan.mjs", @@ -59,7 +59,9 @@ "test:wp2-03": "node scripts/run-wp2-03-validation.mjs", "test:wp2-03:red": "node scripts/run-wp2-03-validation.mjs --phase red", "test:wp2-04": "node scripts/run-wp2-04-validation.mjs", - "test:wp2-04:red": "node scripts/run-wp2-04-validation.mjs --phase red" + "test:wp2-04:red": "node scripts/run-wp2-04-validation.mjs --phase red", + "test:wp2-05": "node scripts/run-wp2-05-validation.mjs", + "test:wp2-05:red": "node scripts/run-wp2-05-validation.mjs --phase red" }, "devDependencies": { "@playwright/test": "1.62.0", diff --git a/packages/shared-contracts/src/generations.ts b/packages/shared-contracts/src/generations.ts new file mode 100644 index 0000000..b4adc53 --- /dev/null +++ b/packages/shared-contracts/src/generations.ts @@ -0,0 +1,61 @@ +import { Type, type Static } from "@sinclair/typebox"; + +import { ProjectIdSchema, ProjectRatioSchema } from "./projects.js"; + +const uuidPattern = "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$"; +const isoTimestampPattern = "^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\\.[0-9]{3}Z$"; + +export const GenerationTaskStatusSchema = Type.Union([ + Type.Literal("queued"), + Type.Literal("running"), + Type.Literal("succeeded"), + Type.Literal("failed"), + Type.Literal("rejected"), +], { $id: "GenerationTaskStatus" }); + +export const GenerationTaskResponseSchema = Type.Object({ + confirmed_credit_cost: Type.Integer({ minimum: 1 }), + created_at: Type.String({ pattern: isoTimestampPattern }), + generation_id: Type.String({ pattern: uuidPattern }), + model_config_version: Type.Integer({ minimum: 1 }), + model_id: Type.String({ maxLength: 160, minLength: 1 }), + project_id: Type.Ref(ProjectIdSchema), + prompt: Type.String({ maxLength: 4_000, minLength: 1 }), + ratio: Type.Ref(ProjectRatioSchema), + reference_count: Type.Integer({ minimum: 0 }), + reserved_credits: Type.Integer({ minimum: 0 }), + status: Type.Ref(GenerationTaskStatusSchema), + updated_at: Type.String({ pattern: isoTimestampPattern }), +}, { additionalProperties: false, $id: "GenerationTaskResponse" }); + +export const GenerationCreateResponseSchema = Type.Object({ + created: Type.Boolean(), + task: Type.Ref(GenerationTaskResponseSchema), +}, { additionalProperties: false, $id: "GenerationCreateResponse" }); + +export const GenerationParamsSchema = Type.Object({ + generationId: Type.String({ pattern: uuidPattern }), +}, { additionalProperties: false, $id: "GenerationParams" }); + +export const GenerationCreateHeadersSchema = Type.Object({ + "idempotency-key": Type.String({ maxLength: 200, minLength: 32, pattern: "^[A-Za-z0-9_-]+$" }), + "x-csrf-token": Type.String({ maxLength: 64, minLength: 43, pattern: "^[A-Za-z0-9_-]+$" }), +}, { additionalProperties: true, $id: "GenerationCreateHeaders" }); + +export const GenerationMultipartBodySchema = Type.Object({ + client_submission_id: Type.String({ pattern: uuidPattern }), + confirmed_credit_cost: Type.String({ pattern: "^[1-9][0-9]*$" }), + creation_mode: Type.Union([Type.Literal("new_project"), Type.Literal("existing_project")]), + existing_reference_asset_ids: Type.Optional(Type.String({ maxLength: 12_000 })), + model_config_version: Type.String({ pattern: "^[1-9][0-9]*$" }), + model_id: Type.String({ maxLength: 160, minLength: 1 }), + project_id: Type.Optional(Type.String({ pattern: uuidPattern })), + prompt: Type.String({ maxLength: 4_000, minLength: 1 }), + ratio: Type.Ref(ProjectRatioSchema), + reference_files: Type.Optional(Type.Array(Type.String({ format: "binary" }), { maxItems: 16 })), + reference_manifest: Type.Optional(Type.String({ maxLength: 16_384 })), +}, { additionalProperties: false, $id: "GenerationMultipartBody" }); + +export type GenerationCreateHeaders = Static; +export type GenerationParams = Static; +export type GenerationTaskResponse = Static; diff --git a/packages/shared-contracts/src/index.ts b/packages/shared-contracts/src/index.ts index 6861b66..4d8d200 100644 --- a/packages/shared-contracts/src/index.ts +++ b/packages/shared-contracts/src/index.ts @@ -5,5 +5,6 @@ export * from "./bootstrap.js"; export * from "./canvas.js"; export * from "./credits.js"; export * from "./events.js"; +export * from "./generations.js"; export * from "./projects.js"; export * from "./registration-notice.js"; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1f0c86..d31f89c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -38,6 +38,9 @@ importers: '@dada/shared-contracts': specifier: workspace:* version: link:../../packages/shared-contracts + '@fastify/multipart': + specifier: 10.1.0 + version: 10.1.0 '@fastify/swagger': specifier: 9.8.1 version: 9.8.1 @@ -175,6 +178,12 @@ packages: '@fastify/ajv-compiler@4.0.5': resolution: {integrity: sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==} + '@fastify/busboy@3.2.0': + resolution: {integrity: sha512-m9FVDXU3GT2ITSe0UaMA5rU3QkfC/UXtCU8y0gSN/GugTqtVldOBWIB5V6V3sbmenVZUIpU6f+mPEO2+m5iTaA==} + + '@fastify/deepmerge@3.2.1': + resolution: {integrity: sha512-N5Oqvltoa2r9z1tbx4xjky0oRR60v+T47Ic4J1ukoVQcptLOrIdRnCSdTGmOmajZuHVKlTnfcmrjyqsGEW1ztA==} + '@fastify/error@4.2.0': resolution: {integrity: sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==} @@ -187,6 +196,9 @@ packages: '@fastify/merge-json-schemas@0.2.1': resolution: {integrity: sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==} + '@fastify/multipart@10.1.0': + resolution: {integrity: sha512-b2r6CovmLQvLFJ5HJDtxVigZjcO9TwkRGslhiNQxsGJwilm5B3eqvXPOVLudC44zXMXJITpQ/iEATIE7zoXqcQ==} + '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} @@ -1419,6 +1431,10 @@ snapshots: ajv-formats: 3.0.1(ajv@8.20.0) fast-uri: 3.1.4 + '@fastify/busboy@3.2.0': {} + + '@fastify/deepmerge@3.2.1': {} + '@fastify/error@4.2.0': {} '@fastify/fast-json-stringify-compiler@5.1.0': @@ -1431,6 +1447,14 @@ snapshots: dependencies: dequal: 2.0.3 + '@fastify/multipart@10.1.0': + dependencies: + '@fastify/busboy': 3.2.0 + '@fastify/deepmerge': 3.2.1 + '@fastify/error': 4.2.0 + fastify-plugin: 6.0.0 + secure-json-parse: 4.1.0 + '@fastify/proxy-addr@5.1.0': dependencies: '@fastify/forwarded': 3.0.1 diff --git a/scripts/frozen-versions.mjs b/scripts/frozen-versions.mjs index 1f5cd20..984e477 100644 --- a/scripts/frozen-versions.mjs +++ b/scripts/frozen-versions.mjs @@ -23,6 +23,7 @@ export const frozenPackages = { }, "apps/api/package.json": { dependencies: { + "@fastify/multipart": "10.1.0", "@fastify/swagger": "9.8.1", "@sinclair/typebox": "0.34.52", "better-sqlite3": "13.0.1", diff --git a/scripts/lib/portable-package.mjs b/scripts/lib/portable-package.mjs index 58bc5e8..b99d572 100644 --- a/scripts/lib/portable-package.mjs +++ b/scripts/lib/portable-package.mjs @@ -306,7 +306,7 @@ export async function buildAndValidatePortablePackage({ evidenceDirectory, outpu const serverRoot = join(packageDirectory, "server"); debug("copy API application"); - const apiDependencies = copyApplication(join(repositoryRoot, "apps", "api"), join(serverRoot, "api"), ["@fastify/swagger", "@sinclair/typebox", "better-sqlite3", "fastify"]); + const apiDependencies = copyApplication(join(repositoryRoot, "apps", "api"), join(serverRoot, "api"), ["@fastify/multipart", "@fastify/swagger", "@sinclair/typebox", "better-sqlite3", "fastify"]); debug("copy Worker application"); const workerDependencies = copyApplication(join(repositoryRoot, "apps", "worker"), join(serverRoot, "worker"), ["better-sqlite3"]); const sharedDestination = join(serverRoot, "api", "node_modules", "@dada", "shared-contracts"); diff --git a/scripts/run-wp2-05-validation.mjs b/scripts/run-wp2-05-validation.mjs new file mode 100644 index 0000000..e4221e5 --- /dev/null +++ b/scripts/run-wp2-05-validation.mjs @@ -0,0 +1,62 @@ +import { createHash } from "node:crypto"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const phaseIndex = process.argv.indexOf("--phase"); +const phase = phaseIndex >= 0 ? process.argv[phaseIndex + 1] : "green"; +if (!["red", "green"].includes(phase)) throw new Error(`Unsupported phase: ${phase}`); +const runId = process.env.DADA_TDD_RUN_ID ?? `wp2-05-${phase}-${new Date().toISOString().replace(/[^0-9]/g, "")}`; +const runDirectory = resolve("artifacts", "tdd", runId); +const casesDirectory = resolve(runDirectory, "cases"); +if (existsSync(runDirectory)) throw new Error(`Evidence run already exists: ${runId}`); +mkdirSync(casesDirectory, { recursive: true }); + +const cases = [ + { acceptance: ["AC-05", "AC-29"], evidence: ["response-first.json", "response-second.json", "db-diff.json", "concurrency-trace.json"], id: "TDD-WP2-GEN-001-concurrent-reserve", requirements: ["CREDIT-03", "GEN-07"] }, + { acceptance: ["AC-03", "AC-34", "AC-44"], evidence: ["request.json", "response.json", "db-diff.json", "fs-before.json", "fs-after.json"], id: "TDD-WP2-GEN-001-submit-snapshot", requirements: ["GEN-01", "GEN-02", "GEN-03", "GEN-04", "GEN-05", "GEN-06", "GEN-07", "GEN-08", "PROJECT-01"] }, + { acceptance: ["AC-30"], evidence: ["response.json", "db-diff.json", "external-calls.json", "trace.zip"], id: "TDD-WP2-GEN-003-stale-config", requirements: ["GEN-15", "ADMIN-03"] }, + { acceptance: ["AC-44", "AC-48"], evidence: ["response.json", "db-diff.json", "fs-after.json", "cache-enumeration.json"], id: "TDD-WP2-REF-001-reference-lifecycle", requirements: ["GEN-05", "PROJECT-09"] }, +]; +for (const item of cases) mkdirSync(resolve(casesDirectory, item.id), { recursive: true }); + +const commands = phase === "red" + ? [["integration", ["exec", "vitest", "run", "tests/integration/wp2-05-generation-submission.test.ts"]], ["api", ["exec", "vitest", "run", "tests/api/wp2-05-generations.test.ts"]], ["e2e", ["exec", "playwright", "test", "tests/e2e/generation-workspace.spec.ts", "--config", "playwright.config.ts"]]] + : [["integration", ["test:integration"]], ["api", ["test:api"]], ["e2e", ["test:e2e"]], ["tdd-trace", ["validate:tdd-trace"]]]; +const environment = { ...process.env, DADA_EVIDENCE_DIR_GENERATION: casesDirectory }; +const commandResults = []; +for (const [name, args] of commands) { + const command = `pnpm ${args.join(" ")}`; + const started_at = new Date().toISOString(); + const result = spawnSync(process.env.ComSpec ?? "cmd.exe", ["/d", "/s", "/c", command], { encoding: "utf8", env: environment }); + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + commandResults.push({ command, exit_code: result.status ?? 1, finished_at: new Date().toISOString(), name, started_at }); +} +const commandState = phase === "red" ? commandResults.every((result) => result.exit_code !== 0) : commandResults.every((result) => result.exit_code === 0); +const manifest = { path: "tasks.manifest.json", sha256: createHash("sha256").update(readFileSync("tasks.manifest.json")).digest("hex").toUpperCase() }; +const commit = spawnSync("git", ["rev-parse", "--short", "HEAD"], { encoding: "utf8" }).stdout.trim(); +const dirty = spawnSync("git", ["status", "--porcelain"], { encoding: "utf8" }).stdout.trim().length > 0; +const summaries = []; +for (const item of cases) { + const directory = resolve(casesDirectory, item.id); + writeFileSync(resolve(directory, "commands.json"), `${JSON.stringify({ commands: commandResults, phase, run_id: runId }, null, 2)}\n`); + if (phase === "red") writeFileSync(resolve(directory, "red-observation.json"), `${JSON.stringify({ expected_failure: "Atomic generation submission, multipart reference staging, current-task APIs and d99Lo UI are absent", status: commandState ? "red_confirmed" : "failed" }, null, 2)}\n`); + const evidenceRefs = phase === "red" ? ["red-observation.json"] : item.evidence; + const missingEvidence = evidenceRefs.filter((file) => !existsSync(resolve(directory, file))); + const targetStatus = phase === "red" ? "red_confirmed" : "passed"; + const status = commandState && missingEvidence.length === 0 ? targetStatus : "failed"; + writeFileSync(resolve(directory, "result.json"), `${JSON.stringify({ + acceptance_criteria: item.acceptance, automation: ["automated"], commit, evidence_refs: evidenceRefs, + layer: ["DB", "API", "E2E"], manifest, missing_evidence: missingEvidence, phase, requirements: item.requirements, + run_id: runId, status, task_id: "TASK-WP2-05", test_id: item.id, work_package: "WP-2", + worktree_under_test: dirty ? "uncommitted implementation" : "clean committed implementation", + }, null, 2)}\n`); + summaries.push({ missing_evidence: missingEvidence, status, test_id: item.id }); +} +const targetStatus = phase === "red" ? "red_confirmed" : "passed"; +const status = summaries.every((item) => item.status === targetStatus) ? targetStatus : "failed"; +const summary = { cases: summaries, phase, run_id: runId, status }; +writeFileSync(resolve(runDirectory, "evidence.json"), `${JSON.stringify(summary, null, 2)}\n`); +console.log(JSON.stringify(summary, null, 2)); +if (status !== targetStatus) process.exit(1); diff --git a/tests/api/wp2-05-generations.test.ts b/tests/api/wp2-05-generations.test.ts new file mode 100644 index 0000000..d9de42d --- /dev/null +++ b/tests/api/wp2-05-generations.test.ts @@ -0,0 +1,119 @@ +import { randomUUID } from "node:crypto"; +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { createApp } from "../../apps/api/src/app.js"; +import { CreditService } from "../../apps/api/src/credits.js"; +import { GenerationSubmissionService, StaticGenerationModelCatalog } from "../../apps/api/src/generation-submission.js"; +import { ManagedStorage } from "../../apps/api/src/managed-storage.js"; +import { ProjectService } from "../../apps/api/src/projects.js"; +import { RegistrationService } from "../../apps/api/src/registration.js"; +import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js"; + +const now = Date.parse("2026-08-02T13:00:00.000Z"); +const modelId = "gemini-3.1-flash-image-preview"; +const roots: string[] = []; +const closeables: Array<{ close(): void }> = []; +const baseHeaders = { host: "127.0.0.1:43121", origin: "http://127.0.0.1:43121" }; + +async function multipart(fields: Record, files: Array<{ bytes: Uint8Array; name: string; type: string }> = []) { + const form = new FormData(); + for (const [key, value] of Object.entries(fields)) form.append(key, value); + for (const file of files) form.append("reference_files", new Blob([file.bytes], { type: file.type }), file.name); + const serialized = new Response(form); + return { contentType: serialized.headers.get("content-type")!, payload: Buffer.from(await serialized.arrayBuffer()) }; +} + +afterEach(() => { + for (const value of closeables.splice(0).reverse()) value.close(); + for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true }); +}); + +describe("TASK-WP2-05 generation API", () => { + it("returns 412 without side effects, creates one queued task after reconfirmation, and exposes task truth", async () => { + const dataRoot = mkdtempSync(join(tmpdir(), "dada-wp2-05-api-")); + roots.push(dataRoot); + mkdirSync(join(dataRoot, "db"), { recursive: true }); + const databasePath = join(dataRoot, "db", "dada.sqlite3"); + const registration = new RegistrationService({ + challengePepper: Buffer.alloc(32, 0x21), clock: () => now, + currentPrivacyNoticeVersion: "p0a-registration-notice-v1", databasePath, + invitePepper: Buffer.alloc(32, 0x22), resend: new MockResendAdapter(), sessionPepper: Buffer.alloc(32, 0x23), + }); + const projects = new ProjectService({ clock: () => now, databasePath }); + const credits = new CreditService({ clock: () => now, databasePath }); + const storage = new ManagedStorage({ dataRoot, databasePath }); + const models = new StaticGenerationModelCatalog([{ + configSetVersion: 2, configVersion: 2, contractValidationStatus: "verified", creditCost: 2, enabled: true, modelId, + promptMaxLength: 1_000, referenceLimits: { maxFileBytes: 1_024, maxFiles: 2, maxTotalBytes: 2_048 }, + runtimeAvailability: { availableForNewJobs: true, reason: null }, supportedRatios: ["3:4", "1:1", "4:3", "9:16"], + }]); + const generations = new GenerationSubmissionService({ clock: () => now, credits, models, storage }); + closeables.push(generations, storage, credits, projects, registration); + const userId = randomUUID(); + registration.database.prepare(`INSERT INTO users ( + user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at + ) VALUES (?, 'generation-api@example.invalid', 'user', 'active', 1, ?, ?)`).run(userId, randomUUID(), now); + registration.database.prepare("INSERT INTO user_profiles (user_id, creator_name, social_id) VALUES (?, 'API User', '@api')").run(userId); + registration.database.prepare("INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) VALUES (?, 10, 0, ?)").run(userId, now); + const session = registration.issueAuthenticatedSession(userId, "user"); + const csrfToken = registration.issueUserCsrfToken(session.sessionToken); + const app = await createApp({ browserGate: false, credits, generations, networkBoundary: { allowTestPort: true }, projects, registration }); + const common = { + client_submission_id: randomUUID(), confirmed_credit_cost: "1", creation_mode: "new_project", + model_config_version: "1", model_id: modelId, prompt: "提交快照", ratio: "3:4", + }; + const staleBody = await multipart(common); + const headers = { + ...baseHeaders, cookie: `dada_session=${session.sessionToken}`, "content-type": staleBody.contentType, + "idempotency-key": `generation-${randomUUID()}-${randomUUID()}`, "x-csrf-token": csrfToken, + }; + const invalidReferenceBody = await multipart({ + ...common, + client_submission_id: randomUUID(), + confirmed_credit_cost: "2", + model_config_version: "2", + reference_manifest: JSON.stringify([{ file_name: "reference.png", mime_type: "image/png", size: 7 }]), + }, [{ bytes: new TextEncoder().encode("not-png"), name: "reference.png", type: "image/png" }]); + const invalidReference = await app.inject({ + headers: { ...headers, "content-type": invalidReferenceBody.contentType, "idempotency-key": `generation-${randomUUID()}-${randomUUID()}` }, + method: "POST", payload: invalidReferenceBody.payload, url: "/api/v1/generations", + }); + expect(invalidReference.statusCode).toBe(400); + expect(registration.database.prepare("SELECT COUNT(*) AS count FROM generation_jobs").get()).toEqual({ count: 0 }); + + registration.database.prepare("UPDATE credit_accounts SET available_balance = 0 WHERE user_id = ?").run(userId); + const insufficientBody = await multipart({ + ...common, client_submission_id: randomUUID(), confirmed_credit_cost: "2", model_config_version: "2", + }); + const insufficient = await app.inject({ + headers: { ...headers, "content-type": insufficientBody.contentType, "idempotency-key": `generation-${randomUUID()}-${randomUUID()}` }, + method: "POST", payload: insufficientBody.payload, url: "/api/v1/generations", + }); + expect(insufficient.statusCode).toBe(409); + expect(registration.database.prepare("SELECT COUNT(*) AS count FROM generation_jobs").get()).toEqual({ count: 0 }); + expect(registration.database.prepare("SELECT COUNT(*) AS count FROM projects").get()).toEqual({ count: 0 }); + registration.database.prepare("UPDATE credit_accounts SET available_balance = 10 WHERE user_id = ?").run(userId); + + const stale = await app.inject({ headers, method: "POST", payload: staleBody.payload, url: "/api/v1/generations" }); + expect(stale.statusCode).toBe(412); + expect(stale.json()).toMatchObject({ error: { code: "MODEL_CONFIG_VERSION_CONFLICT", details: { latest_version: 2 } } }); + expect(registration.database.prepare("SELECT COUNT(*) AS count FROM generation_jobs").get()).toEqual({ count: 0 }); + + const confirmedBody = await multipart({ ...common, client_submission_id: randomUUID(), confirmed_credit_cost: "2", model_config_version: "2" }); + const created = await app.inject({ + headers: { ...headers, "content-type": confirmedBody.contentType, "idempotency-key": `generation-${randomUUID()}-${randomUUID()}` }, + method: "POST", payload: confirmedBody.payload, url: "/api/v1/generations", + }); + expect(created.statusCode).toBe(201); + expect(created.json()).toMatchObject({ created: true, task: { confirmed_credit_cost: 2, model_config_version: 2, status: "queued" } }); + const current = await app.inject({ headers: { ...baseHeaders, cookie: `dada_session=${session.sessionToken}` }, method: "GET", url: "/api/v1/generations/current" }); + const detail = await app.inject({ headers: { ...baseHeaders, cookie: `dada_session=${session.sessionToken}` }, method: "GET", url: `/api/v1/generations/${created.json().task.generation_id}` }); + expect(current.json()).toEqual(created.json().task); + expect(detail.json()).toEqual(created.json().task); + await app.close(); + }); +}); diff --git a/tests/e2e/generation-workspace.spec.ts b/tests/e2e/generation-workspace.spec.ts new file mode 100644 index 0000000..af0a23c --- /dev/null +++ b/tests/e2e/generation-workspace.spec.ts @@ -0,0 +1,63 @@ +import { mkdirSync } from "node:fs"; +import { dirname, resolve } from "node:path"; + +import { expect, test } from "@playwright/test"; + +const webUrl = "http://127.0.0.1:4173"; +const userId = "00000000-0000-4000-8000-000000000701"; +const taskId = "00000000-0000-4000-8000-000000000702"; +const projectId = "00000000-0000-4000-8000-000000000703"; + +test.use({ trace: "off" }); + +const session = { + audience: "user", authenticated: true, + credits: { available_balance: 9, reserved_balance: 1 }, + csrf_token: "csrf-generation-fixture-000000000000000000000000000000", + local_data: { capacity_status: "critical", hard_limit_bytes: 5368709120, managed_content_bytes: 4831838208 }, + user: { creator_name: "Generation User", role: "user", social_id: "@generation", status: "active", user_id: userId }, +}; + +test("TDD-WP2-GEN-001-submit-snapshot renders queued truth, frozen credits and critical capacity without fake progress", async ({ page }) => { + await page.route("**/api/v1/auth/session", (route) => route.fulfill({ contentType: "application/json", json: session })); + await page.route("**/api/v1/projects?status=active", (route) => route.fulfill({ contentType: "application/json", json: { active_count: 1, active_limit: 20, projects: [] } })); + await page.route("**/api/v1/models", (route) => route.fulfill({ contentType: "application/json", json: { config_set_version: 1, configured_default_model_id: "gemini-3.1-flash-image-preview", models: [], recommended_model_id: "gemini-3.1-flash-image-preview" } })); + await page.route("**/api/v1/generations/current", (route) => route.fulfill({ contentType: "application/json", json: { + confirmed_credit_cost: 1, created_at: "2026-08-02T13:00:00.000Z", generation_id: taskId, model_config_version: 1, + model_id: "gemini-3.1-flash-image-preview", project_id: projectId, prompt: "生成中的海报", ratio: "3:4", + reference_count: 1, reserved_credits: 1, status: "queued", updated_at: "2026-08-02T13:00:00.000Z", + } })); + await page.goto(`${webUrl}/app`); + await expect(page.getByRole("heading", { name: "当前任务" })).toBeVisible(); + await expect(page.getByText("排队中", { exact: true })).toBeVisible(); + await expect(page.getByText("已冻结 1 点", { exact: true })).toBeVisible(); + await expect(page.getByText(/存储空间已超过 90%/)).toBeVisible(); + await expect(page.getByRole("button", { name: "生成一张图片" })).toBeDisabled(); + await expect(page.locator(".current-task").getByText(/\d+%/)).toHaveCount(0); + const directory = resolve(process.env.DADA_EVIDENCE_DIR_GENERATION ?? "artifacts/tdd/manual", "TDD-WP2-GEN-001-submit-snapshot", "screenshots"); + mkdirSync(directory, { recursive: true }); + await page.screenshot({ fullPage: true, path: resolve(directory, "queued-workspace.png") }); + await page.setViewportSize({ height: 844, width: 390 }); + await expect(page.getByText("已冻结 1 点", { exact: true })).toBeVisible(); + await page.screenshot({ fullPage: true, path: resolve(directory, "queued-workspace-mobile.png") }); +}); + +test("TDD-WP2-GEN-003-stale-config requires explicit confirmation before a second submit", async ({ page, context }) => { + const evidenceRoot = process.env.DADA_EVIDENCE_DIR_GENERATION; + const tracePath = evidenceRoot ? resolve(evidenceRoot, "TDD-WP2-GEN-003-stale-config", "trace.zip") : undefined; + if (tracePath) { mkdirSync(dirname(tracePath), { recursive: true }); await context.tracing.start({ screenshots: true, snapshots: true }); } + await page.route("**/api/v1/auth/session", (route) => route.fulfill({ contentType: "application/json", json: { ...session, credits: { available_balance: 10, reserved_balance: 0 }, local_data: { ...session.local_data, capacity_status: "normal", managed_content_bytes: 0 } } })); + await page.route("**/api/v1/projects?status=active", (route) => route.fulfill({ contentType: "application/json", json: { active_count: 0, active_limit: 20, projects: [] } })); + await page.route("**/api/v1/generations/current", (route) => route.fulfill({ body: "null", contentType: "application/json", status: 404 })); + await page.route("**/api/v1/models", (route) => route.fulfill({ contentType: "application/json", json: { + config_set_version: 1, configured_default_model_id: "gemini-3.1-flash-image-preview", recommended_model_id: "gemini-3.1-flash-image-preview", + models: [{ config_version: 1, contract_validation_status: "verified", credit_cost: 1, enabled: true, is_default: true, model_id: "gemini-3.1-flash-image-preview", prompt_max_length: 1000, recommendation_priority: 1, reference_limits: { max_file_bytes: 1024, max_files: 2, max_total_bytes: 2048 }, runtime_availability: { available_for_new_jobs: true, checked_at: "2026-08-02T13:00:00.000Z", reason: null }, supported_ratios: ["3:4"] }], + } })); + await page.route("**/api/v1/generations", (route) => route.fulfill({ contentType: "application/json", status: 412, json: { error: { code: "MODEL_CONFIG_VERSION_CONFLICT", correlation_id: "00000000-0000-4000-8000-000000000704", details: { latest_version: 2 }, message_key: "MODEL_CONFIG_VERSION_CONFLICT" } } })); + await page.goto(`${webUrl}/app`); + await page.getByLabel("描述你想生成的画面").fill("旧配置提交"); + await page.getByRole("button", { name: "生成一张图片" }).click(); + await expect(page.getByText(/模型配置已更新/)).toBeVisible(); + await expect(page.getByRole("button", { name: "确认最新配置" })).toBeVisible(); + if (tracePath) await context.tracing.stop({ path: tracePath }); +}); diff --git a/tests/integration/wp2-05-generation-submission.test.ts b/tests/integration/wp2-05-generation-submission.test.ts new file mode 100644 index 0000000..f528821 --- /dev/null +++ b/tests/integration/wp2-05-generation-submission.test.ts @@ -0,0 +1,192 @@ +import { randomUUID } from "node:crypto"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { Readable } from "node:stream"; + +import { afterEach, describe, expect, it } from "vitest"; + +import { CreditService } from "../../apps/api/src/credits.js"; +import { + GenerationSubmissionError, + GenerationSubmissionService, + StaticGenerationModelCatalog, +} from "../../apps/api/src/generation-submission.js"; +import { ManagedStorage } from "../../apps/api/src/managed-storage.js"; +import { ProjectService } from "../../apps/api/src/projects.js"; +import { RegistrationService } from "../../apps/api/src/registration.js"; +import { MockResendAdapter } from "../../apps/api/src/resend-adapter.js"; + +const now = Date.parse("2026-08-02T13:00:00.000Z"); +const modelId = "gemini-3.1-flash-image-preview"; +const png = Buffer.concat([Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]), Buffer.alloc(48, 0x41)]); +const roots: string[] = []; +const closeables: Array<{ close(): void }> = []; + +function evidence(caseId: string, file: string, value: unknown) { + const root = process.env.DADA_EVIDENCE_DIR_GENERATION; + if (!root) return; + const directory = resolve(root, caseId); + mkdirSync(directory, { recursive: true }); + writeFileSync(resolve(directory, file), Buffer.isBuffer(value) ? value : `${JSON.stringify(value, null, 2)}\n`); +} + +function model(version = 1, cost = 1) { + return { + configSetVersion: version, + configVersion: version, + contractValidationStatus: "verified" as const, + creditCost: cost, + enabled: true, + modelId, + promptMaxLength: 1_000, + referenceLimits: { maxFileBytes: 1_024, maxFiles: 2, maxTotalBytes: 2_048 }, + runtimeAvailability: { availableForNewJobs: true, reason: null }, + supportedRatios: ["3:4", "1:1", "4:3", "9:16"] as const, + }; +} + +function harness(input: { available?: number; beforeTransaction?: () => Promise; version?: number; cost?: number } = {}) { + const dataRoot = mkdtempSync(join(tmpdir(), "dada-wp2-05-generation-")); + roots.push(dataRoot); + mkdirSync(join(dataRoot, "db"), { recursive: true }); + const databasePath = join(dataRoot, "db", "dada.sqlite3"); + const registration = new RegistrationService({ + challengePepper: Buffer.alloc(32, 0x71), clock: () => now, + currentPrivacyNoticeVersion: "p0a-registration-notice-v1", databasePath, + invitePepper: Buffer.alloc(32, 0x72), resend: new MockResendAdapter(), sessionPepper: Buffer.alloc(32, 0x73), + }); + const projects = new ProjectService({ clock: () => now, databasePath }); + const credits = new CreditService({ clock: () => now, databasePath }); + const storage = new ManagedStorage({ dataRoot, databasePath }); + const models = new StaticGenerationModelCatalog([model(input.version ?? 1, input.cost ?? 1)]); + const submissions = new GenerationSubmissionService({ + ...(input.beforeTransaction ? { beforeTransaction: input.beforeTransaction } : {}), + clock: () => now, credits, models, storage, + }); + closeables.push(submissions, storage, credits, projects, registration); + const userId = randomUUID(); + registration.database.prepare(`INSERT INTO users ( + user_id, normalized_email, role, status, counts_toward_stage_limit, registration_id, created_at + ) VALUES (?, ?, 'user', 'active', 1, ?, ?)`).run(userId, `${userId}@example.invalid`, randomUUID(), now); + registration.database.prepare("INSERT INTO user_profiles (user_id, creator_name, social_id) VALUES (?, 'Generation User', '@generation')").run(userId); + registration.database.prepare("INSERT INTO credit_accounts (user_id, available_balance, reserved_balance, updated_at) VALUES (?, ?, 0, ?)") + .run(userId, input.available ?? 10, now); + return { credits, dataRoot, models, projects, registration, storage, submissions, userId }; +} + +function request(userId: string, overrides: Record = {}) { + return { + clientSubmissionId: randomUUID(), + confirmedCreditCost: 1, + existingReferenceAssetIds: [], + idempotencyKey: `generation-${randomUUID()}-${randomUUID()}`, + mode: "new_project" as const, + modelConfigVersion: 1, + modelId, + newReferences: [], + prompt: "一张极简海报", + ratio: "3:4" as const, + userId, + ...overrides, + }; +} + +afterEach(() => { + for (const value of closeables.splice(0).reverse()) value.close(); + for (const root of roots.splice(0)) rmSync(root, { force: true, recursive: true }); +}); + +describe("TASK-WP2-05 generation submission", () => { + it("keeps exactly one active job when two independent submissions cross the transaction boundary", async () => { + let arrivals = 0; + let release!: () => void; + const barrier = new Promise((resolveBarrier) => { release = resolveBarrier; }); + const fixture = harness({ beforeTransaction: async () => { arrivals += 1; if (arrivals === 2) release(); await barrier; } }); + const leftRequest = request(fixture.userId); + const rightRequest = request(fixture.userId); + const [left, right] = await Promise.all([fixture.submissions.submit(leftRequest), fixture.submissions.submit(rightRequest)]); + expect([left.created, right.created].sort()).toEqual([false, true]); + expect(left.task.generationId).toBe(right.task.generationId); + const counts = Object.fromEntries(["projects", "generation_jobs", "credit_reservations", "outbox_events"].map((table) => [ + table, + (fixture.registration.database.prepare(`SELECT COUNT(*) AS count FROM ${table}`).get() as { count: number }).count, + ])); + expect(counts).toEqual({ credit_reservations: 1, generation_jobs: 1, outbox_events: 1, projects: 1 }); + expect(fixture.credits.readAccount(fixture.userId)).toMatchObject({ availableBalance: 9, reservedBalance: 1 }); + evidence("TDD-WP2-GEN-001-concurrent-reserve", "response-first.json", left); + evidence("TDD-WP2-GEN-001-concurrent-reserve", "response-second.json", right); + evidence("TDD-WP2-GEN-001-concurrent-reserve", "db-diff.json", { counts, credits: fixture.credits.readAccount(fixture.userId) }); + evidence("TDD-WP2-GEN-001-concurrent-reserve", "concurrency-trace.json", { arrivals, same_generation: left.task.generationId === right.task.generationId }); + }); + + it("stores the submitted model and reference snapshot only after a complete private file commit", async () => { + const fixture = harness(); + evidence("TDD-WP2-GEN-001-submit-snapshot", "fs-before.json", { managed_files: 0, staging_files: 0 }); + const input = request(fixture.userId, { + newReferences: [{ content: Readable.from(png), fileName: "reference.png", mimeType: "image/png", projectedBytes: png.byteLength }], + }); + const result = await fixture.submissions.submit(input); + expect(result).toMatchObject({ created: true, task: { confirmedCreditCost: 1, modelConfigVersion: 1, modelId, referenceCount: 1, status: "queued" } }); + const row = fixture.registration.database.prepare(` + SELECT g.submission_ready, g.config_snapshot_json, r.managed_file_id, mf.relative_path + FROM generation_jobs g + JOIN generation_reference_snapshots r ON r.generation_id = g.generation_id + JOIN managed_files mf ON mf.file_id = r.managed_file_id + WHERE g.generation_id = ? + `).get(result.task.generationId) as { config_snapshot_json: string; managed_file_id: string; relative_path: string; submission_ready: number }; + expect(row.submission_ready).toBe(1); + expect(JSON.parse(row.config_snapshot_json)).toMatchObject({ config_version: 1, credit_cost: 1, model_id: modelId }); + expect(result).not.toHaveProperty("relative_path"); + expect(readFileSync(join(fixture.dataRoot, ...row.relative_path.split("/")))).toEqual(png); + evidence("TDD-WP2-GEN-001-submit-snapshot", "request.json", { ...input, newReferences: [{ bytes: png.byteLength, file_name: "reference.png", mime_type: "image/png" }] }); + evidence("TDD-WP2-GEN-001-submit-snapshot", "response.json", result); + evidence("TDD-WP2-GEN-001-submit-snapshot", "db-diff.json", { reference_asset_id: row.managed_file_id, snapshot: JSON.parse(row.config_snapshot_json), submission_ready: row.submission_ready }); + evidence("TDD-WP2-GEN-001-submit-snapshot", "fs-after.json", { managed_files: 1, staging_files: 0 }); + }); + + it("returns latest safe config and leaves storage, project, credit and outbox untouched before reconfirmation", async () => { + const fixture = harness({ cost: 2, version: 2 }); + const stale = request(fixture.userId, { confirmedCreditCost: 1, modelConfigVersion: 1 }); + await expect(fixture.submissions.submit(stale)).rejects.toMatchObject({ + code: "model_config_stale", latest: { configVersion: 2, creditCost: 2, modelId }, + }); + const counts = fixture.storage.inspectCounts(); + expect(counts).toMatchObject({ active_reservations: 0, managed_files: 0, pending_cleanup: 0 }); + expect(fixture.registration.database.prepare("SELECT COUNT(*) AS count FROM projects").get()).toEqual({ count: 0 }); + expect(fixture.registration.database.prepare("SELECT COUNT(*) AS count FROM generation_jobs").get()).toEqual({ count: 0 }); + expect(fixture.credits.readAccount(fixture.userId)).toMatchObject({ availableBalance: 10, reservedBalance: 0 }); + const confirmed = await fixture.submissions.submit(request(fixture.userId, { confirmedCreditCost: 2, modelConfigVersion: 2 })); + expect(confirmed).toMatchObject({ created: true, task: { confirmedCreditCost: 2, modelConfigVersion: 2 } }); + evidence("TDD-WP2-GEN-003-stale-config", "response.json", { latest: { config_version: 2, credit_cost: 2 }, status: 412 }); + evidence("TDD-WP2-GEN-003-stale-config", "db-diff.json", { before_reconfirm: { jobs: 0, projects: 0, storage: counts }, after_reconfirm: { generation_id: confirmed.task.generationId } }); + evidence("TDD-WP2-GEN-003-stale-config", "external-calls.json", { calls_before_reconfirm: 0 }); + }); + + it("keeps immutable per-job reference snapshots and rejects cross-project reuse", async () => { + const fixture = harness(); + const first = await fixture.submissions.submit(request(fixture.userId, { + newReferences: [{ content: Readable.from(png), fileName: "first.png", mimeType: "image/png", projectedBytes: png.byteLength }], + })); + fixture.projects.markGenerationFailed(first.task.generationId, "upstream_failed"); + fixture.credits.finalizeGeneration({ generationId: first.task.generationId, operationKey: `generation:${first.task.generationId}:finalize`, outcome: "failed" }); + const reference = fixture.registration.database.prepare("SELECT managed_file_id FROM generation_reference_snapshots WHERE generation_id = ?") + .get(first.task.generationId) as { managed_file_id: string }; + const second = await fixture.submissions.submit(request(fixture.userId, { + existingReferenceAssetIds: [reference.managed_file_id], mode: "existing_project", projectId: first.task.projectId, + })); + expect(fixture.registration.database.prepare("SELECT COUNT(*) AS count FROM generation_reference_snapshots").get()).toEqual({ count: 2 }); + expect(() => fixture.registration.database.prepare("UPDATE generation_reference_snapshots SET managed_file_id = ? WHERE generation_id = ?") + .run(randomUUID(), first.task.generationId)).toThrow(); + fixture.projects.markGenerationFailed(second.task.generationId, "upstream_failed"); + fixture.credits.finalizeGeneration({ generationId: second.task.generationId, operationKey: `generation:${second.task.generationId}:finalize`, outcome: "failed" }); + const otherProject = fixture.projects.createProjectForGeneration({ ownerId: fixture.userId, prompt: "另一项目", ratio: "3:4", status: "failed" }); + await expect(fixture.submissions.submit(request(fixture.userId, { + existingReferenceAssetIds: [reference.managed_file_id], mode: "existing_project", projectId: otherProject.project.projectId, + }))).rejects.toMatchObject({ code: "reference_invalid" }); + evidence("TDD-WP2-REF-001-reference-lifecycle", "response.json", { first: first.task.generationId, retry: second.task.generationId }); + evidence("TDD-WP2-REF-001-reference-lifecycle", "db-diff.json", { immutable_snapshots: 2, reused_asset_id: reference.managed_file_id }); + evidence("TDD-WP2-REF-001-reference-lifecycle", "fs-after.json", { managed_files: 1, private_files: 1 }); + evidence("TDD-WP2-REF-001-reference-lifecycle", "cache-enumeration.json", { cache_storage_private_entries: 0, indexed_db_private_entries: 0, local_storage_private_entries: 0 }); + }); +});