feat: complete TASK-WP2-05 generation submission
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -36,6 +36,12 @@ import {
|
||||
ErrorDetailsSchema,
|
||||
ErrorEnvelopeSchema,
|
||||
GenerationErrorCategorySchema,
|
||||
GenerationCreateHeadersSchema,
|
||||
GenerationCreateResponseSchema,
|
||||
GenerationParamsSchema,
|
||||
GenerationMultipartBodySchema,
|
||||
GenerationTaskResponseSchema,
|
||||
GenerationTaskStatusSchema,
|
||||
LoginCompleteRequestSchema,
|
||||
LoginCompleteResponseSchema,
|
||||
LoginSendRequestSchema,
|
||||
@@ -88,6 +94,8 @@ import {
|
||||
type LoginCompleteRequest,
|
||||
type LoginSendRequest,
|
||||
type FailedEmptyTrashRequest,
|
||||
type GenerationCreateHeaders,
|
||||
type GenerationParams,
|
||||
type ProjectListQuery,
|
||||
type ProjectParams,
|
||||
type ProjectRenameRequest,
|
||||
@@ -97,6 +105,7 @@ import {
|
||||
type RegistrationSendRequest,
|
||||
} from "@dada/shared-contracts";
|
||||
import swagger from "@fastify/swagger";
|
||||
import multipart from "@fastify/multipart";
|
||||
import Fastify, { type FastifyReply } from "fastify";
|
||||
|
||||
import {
|
||||
@@ -116,6 +125,14 @@ import { EventHub } from "./event-hub.js";
|
||||
import { CreditError } from "./credit-errors.js";
|
||||
import type { CreditService } from "./credits.js";
|
||||
import type { PublicAssetResolver } from "./local-data-root.js";
|
||||
import { GenerationSubmissionError } from "./generation-submission-errors.js";
|
||||
import type {
|
||||
GenerationSubmissionFields,
|
||||
GenerationSubmissionService,
|
||||
GenerationTaskView,
|
||||
GenerationUploadSession,
|
||||
NewGenerationReference,
|
||||
} from "./generation-submission.js";
|
||||
import { isAllowedNetworkRequest, type NetworkBoundaryOptions } from "./network-boundary.js";
|
||||
import { ProjectError } from "./project-errors.js";
|
||||
import type { ProjectService } from "./projects.js";
|
||||
@@ -144,6 +161,7 @@ export interface CreateAppOptions {
|
||||
browserSupportSecret?: Buffer;
|
||||
credits?: CreditService;
|
||||
eventHub?: EventHub;
|
||||
generations?: GenerationSubmissionService;
|
||||
networkBoundary?: NetworkBoundaryOptions;
|
||||
publicAssets?: PublicAssetResolver;
|
||||
projects?: ProjectService;
|
||||
@@ -248,6 +266,175 @@ function creditFailure(reply: FastifyReply, correlationId: string, error: unknow
|
||||
return reply.code(status).send(null);
|
||||
}
|
||||
|
||||
function generationTaskResponse(task: GenerationTaskView) {
|
||||
return {
|
||||
confirmed_credit_cost: task.confirmedCreditCost,
|
||||
created_at: task.createdAt,
|
||||
generation_id: task.generationId,
|
||||
model_config_version: task.modelConfigVersion,
|
||||
model_id: task.modelId,
|
||||
project_id: task.projectId,
|
||||
prompt: task.prompt,
|
||||
ratio: task.ratio,
|
||||
reference_count: task.referenceCount,
|
||||
reserved_credits: task.reservedCredits,
|
||||
status: task.status,
|
||||
updated_at: task.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
function generationFailure(reply: FastifyReply, correlationId: string, error: unknown) {
|
||||
if (error instanceof GenerationSubmissionError) {
|
||||
if (error.code === "model_config_stale") {
|
||||
return reply.code(412).send(createErrorEnvelope({
|
||||
code: "MODEL_CONFIG_VERSION_CONFLICT",
|
||||
correlationId,
|
||||
details: { latest_version: error.latest?.configVersion ?? 0 },
|
||||
}));
|
||||
}
|
||||
if (error.code === "generation_idempotency_conflict") {
|
||||
return reply.code(409).send(createErrorEnvelope({ code: "IDEMPOTENCY_KEY_CONFLICT", correlationId }));
|
||||
}
|
||||
if (error.code === "generation_blocked") {
|
||||
return reply.code(503).send(createErrorEnvelope({
|
||||
code: "AUTH_SERVICE_UNAVAILABLE",
|
||||
correlationId,
|
||||
...(error.errorCategory ? { errorCategory: error.errorCategory } : {}),
|
||||
}));
|
||||
}
|
||||
if (error.code === "generation_storage_unavailable") {
|
||||
return reply.code(507).send(createErrorEnvelope({
|
||||
code: "STORAGE_CAPACITY_EXCEEDED",
|
||||
correlationId,
|
||||
details: {
|
||||
capacity_status: error.storage?.capacityStatus ?? "unavailable",
|
||||
remaining_bytes: error.storage?.remainingBytes ?? 0,
|
||||
},
|
||||
}));
|
||||
}
|
||||
return reply.code(error.code === "generation_not_found" ? 404 : 400).send(null);
|
||||
}
|
||||
if (error && typeof error === "object" && "code" in error) {
|
||||
if (typeof error.code === "string" && error.code.startsWith("FST_")) {
|
||||
return reply.code(400).send(null);
|
||||
}
|
||||
if (error.code === "STORAGE_CAPACITY_EXCEEDED") {
|
||||
const details = "details" in error && error.details && typeof error.details === "object"
|
||||
? error.details as { capacity_status?: "normal" | "warning" | "critical" | "full" | "unavailable"; remaining_bytes?: number }
|
||||
: {};
|
||||
return reply.code(507).send(createErrorEnvelope({
|
||||
code: "STORAGE_CAPACITY_EXCEEDED",
|
||||
correlationId,
|
||||
details: {
|
||||
capacity_status: details.capacity_status ?? "full",
|
||||
remaining_bytes: details.remaining_bytes ?? 0,
|
||||
},
|
||||
}));
|
||||
}
|
||||
if (error.code === "storage_unavailable") {
|
||||
return reply.code(507).send(createErrorEnvelope({
|
||||
code: "STORAGE_CAPACITY_EXCEEDED",
|
||||
correlationId,
|
||||
details: { capacity_status: "unavailable", remaining_bytes: 0 },
|
||||
}));
|
||||
}
|
||||
}
|
||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId }));
|
||||
}
|
||||
|
||||
const generationFieldNames = new Set([
|
||||
"client_submission_id",
|
||||
"confirmed_credit_cost",
|
||||
"creation_mode",
|
||||
"existing_reference_asset_ids",
|
||||
"model_config_version",
|
||||
"model_id",
|
||||
"project_id",
|
||||
"prompt",
|
||||
"ratio",
|
||||
"reference_manifest",
|
||||
]);
|
||||
|
||||
interface ReferenceManifestEntry {
|
||||
fileName: string;
|
||||
mimeType: NewGenerationReference["mimeType"];
|
||||
projectedBytes: number;
|
||||
}
|
||||
|
||||
function positiveIntegerField(value: string | undefined) {
|
||||
if (!value || !/^[1-9][0-9]*$/.test(value)) throw new GenerationSubmissionError("generation_request_invalid");
|
||||
const parsed = Number(value);
|
||||
if (!Number.isSafeInteger(parsed)) throw new GenerationSubmissionError("generation_request_invalid");
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function jsonStringArray(value: string | undefined) {
|
||||
if (value === undefined) return [];
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
throw new GenerationSubmissionError("generation_request_invalid");
|
||||
}
|
||||
if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== "string")) {
|
||||
throw new GenerationSubmissionError("generation_request_invalid");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function referenceManifest(value: string | undefined): ReferenceManifestEntry[] {
|
||||
if (value === undefined) return [];
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(value);
|
||||
} catch {
|
||||
throw new GenerationSubmissionError("generation_request_invalid");
|
||||
}
|
||||
if (!Array.isArray(parsed)) throw new GenerationSubmissionError("generation_request_invalid");
|
||||
return parsed.map((entry) => {
|
||||
if (!entry || typeof entry !== "object") throw new GenerationSubmissionError("generation_request_invalid");
|
||||
const item = entry as Record<string, unknown>;
|
||||
const keys = Object.keys(item).toSorted();
|
||||
if (keys.join(",") !== "file_name,mime_type,size" || typeof item.file_name !== "string"
|
||||
|| !["image/jpeg", "image/png", "image/webp"].includes(String(item.mime_type))
|
||||
|| !Number.isSafeInteger(item.size) || Number(item.size) <= 0) {
|
||||
throw new GenerationSubmissionError("generation_request_invalid");
|
||||
}
|
||||
return {
|
||||
fileName: item.file_name,
|
||||
mimeType: item.mime_type as NewGenerationReference["mimeType"],
|
||||
projectedBytes: Number(item.size),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function generationFields(
|
||||
values: Map<string, string>,
|
||||
input: { idempotencyKey: string; userId: string },
|
||||
): { fields: GenerationSubmissionFields; manifest: ReferenceManifestEntry[] } {
|
||||
for (const name of values.keys()) {
|
||||
if (!generationFieldNames.has(name)) throw new GenerationSubmissionError("generation_request_invalid");
|
||||
}
|
||||
const creationMode = values.get("creation_mode");
|
||||
if (creationMode !== "new_project" && creationMode !== "existing_project") {
|
||||
throw new GenerationSubmissionError("generation_request_invalid");
|
||||
}
|
||||
const fields: GenerationSubmissionFields = {
|
||||
clientSubmissionId: values.get("client_submission_id") ?? "",
|
||||
confirmedCreditCost: positiveIntegerField(values.get("confirmed_credit_cost")),
|
||||
existingReferenceAssetIds: jsonStringArray(values.get("existing_reference_asset_ids")),
|
||||
idempotencyKey: input.idempotencyKey,
|
||||
mode: creationMode,
|
||||
modelConfigVersion: positiveIntegerField(values.get("model_config_version")),
|
||||
modelId: values.get("model_id") ?? "",
|
||||
...(values.has("project_id") ? { projectId: values.get("project_id")! } : {}),
|
||||
prompt: values.get("prompt") ?? "",
|
||||
ratio: values.get("ratio") as GenerationSubmissionFields["ratio"],
|
||||
userId: input.userId,
|
||||
};
|
||||
return { fields, manifest: referenceManifest(values.get("reference_manifest")) };
|
||||
}
|
||||
|
||||
type ProjectSummaryView = ReturnType<ProjectService["listProjects"]>[number];
|
||||
type ProjectDetailView = ReturnType<ProjectService["getProject"]>;
|
||||
|
||||
@@ -351,9 +538,26 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
},
|
||||
});
|
||||
|
||||
await app.register(multipart, {
|
||||
limits: {
|
||||
fieldNameSize: 120,
|
||||
fieldSize: 16 * 1024,
|
||||
fields: 20,
|
||||
fileSize: 100 * 1024 * 1024,
|
||||
files: 16,
|
||||
parts: 36,
|
||||
},
|
||||
});
|
||||
|
||||
for (const schema of [
|
||||
CorrelationIdSchema,
|
||||
GenerationErrorCategorySchema,
|
||||
GenerationTaskStatusSchema,
|
||||
GenerationTaskResponseSchema,
|
||||
GenerationCreateResponseSchema,
|
||||
GenerationParamsSchema,
|
||||
GenerationCreateHeadersSchema,
|
||||
GenerationMultipartBodySchema,
|
||||
StableEngineeringErrorCodeSchema,
|
||||
ErrorDetailsSchema,
|
||||
ErrorEnvelopeSchema,
|
||||
@@ -1277,6 +1481,160 @@ export async function createApp(options: CreateAppOptions = {}) {
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/v1/generations/current",
|
||||
{
|
||||
schema: {
|
||||
operationId: "getCurrentGeneration",
|
||||
response: {
|
||||
200: Type.Ref(GenerationTaskResponseSchema),
|
||||
401: Type.Ref(ErrorEnvelopeSchema),
|
||||
404: Type.Null(),
|
||||
503: Type.Ref(ErrorEnvelopeSchema),
|
||||
},
|
||||
tags: ["Generations"],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (!options.registration || !options.generations) {
|
||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
||||
}
|
||||
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
|
||||
const session = token ? options.registration.readUserSession(token) : undefined;
|
||||
if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
||||
const task = options.generations.readCurrentTask(session.userId);
|
||||
return task ? generationTaskResponse(task) : reply.code(404).send(null);
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/v1/generations/:generationId",
|
||||
{
|
||||
attachValidation: true,
|
||||
schema: {
|
||||
operationId: "getGeneration",
|
||||
params: Type.Ref(GenerationParamsSchema),
|
||||
response: {
|
||||
200: Type.Ref(GenerationTaskResponseSchema),
|
||||
400: Type.Null(),
|
||||
401: Type.Ref(ErrorEnvelopeSchema),
|
||||
404: Type.Null(),
|
||||
503: Type.Ref(ErrorEnvelopeSchema),
|
||||
},
|
||||
tags: ["Generations"],
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (request.validationError) return reply.code(400).send(null);
|
||||
if (!options.registration || !options.generations) {
|
||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
||||
}
|
||||
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
|
||||
const session = token ? options.registration.readUserSession(token) : undefined;
|
||||
if (!session) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
||||
try {
|
||||
return generationTaskResponse(options.generations.readTask(session.userId, (request.params as GenerationParams).generationId));
|
||||
} catch (error) {
|
||||
return generationFailure(reply, request.id, error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/api/v1/generations",
|
||||
{
|
||||
attachValidation: true,
|
||||
schema: {
|
||||
consumes: ["multipart/form-data"],
|
||||
body: Type.Optional(Type.Ref(GenerationMultipartBodySchema)),
|
||||
headers: Type.Ref(GenerationCreateHeadersSchema),
|
||||
operationId: "createGeneration",
|
||||
response: {
|
||||
200: Type.Ref(GenerationCreateResponseSchema),
|
||||
201: Type.Ref(GenerationCreateResponseSchema),
|
||||
400: Type.Null(),
|
||||
401: Type.Ref(ErrorEnvelopeSchema),
|
||||
403: Type.Ref(ErrorEnvelopeSchema),
|
||||
409: Type.Ref(ErrorEnvelopeSchema),
|
||||
412: Type.Ref(ErrorEnvelopeSchema),
|
||||
503: Type.Ref(ErrorEnvelopeSchema),
|
||||
507: Type.Ref(ErrorEnvelopeSchema),
|
||||
},
|
||||
tags: ["Generations"],
|
||||
},
|
||||
validatorCompiler: () => (data) => ({ value: data }),
|
||||
},
|
||||
async (request, reply) => {
|
||||
if (request.validationError || !request.isMultipart()) return reply.code(400).send(null);
|
||||
if (!options.registration || !options.generations) {
|
||||
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
|
||||
}
|
||||
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
|
||||
const idempotencyKey = headerValue(request.headers["idempotency-key"]);
|
||||
const csrfToken = headerValue(request.headers["x-csrf-token"]);
|
||||
if (!idempotencyKey || !/^[A-Za-z0-9_-]{32,200}$/.test(idempotencyKey)
|
||||
|| !csrfToken || !/^[A-Za-z0-9_-]{43,64}$/.test(csrfToken)) return reply.code(400).send(null);
|
||||
const headers = { "idempotency-key": idempotencyKey, "x-csrf-token": csrfToken } satisfies GenerationCreateHeaders;
|
||||
if (!token) return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
|
||||
let upload: GenerationUploadSession | undefined;
|
||||
try {
|
||||
const owner = options.registration.authorizeUserMutation({ csrfToken: headers["x-csrf-token"], sessionToken: token });
|
||||
const current = options.generations.readCurrentTask(owner.userId);
|
||||
if (current) return { created: false, task: generationTaskResponse(current) };
|
||||
|
||||
const values = new Map<string, string>();
|
||||
let manifest: ReferenceManifestEntry[] | undefined;
|
||||
let fileIndex = 0;
|
||||
for await (const part of request.parts()) {
|
||||
if (part.type === "field") {
|
||||
if (upload || values.has(part.fieldname) || typeof part.value !== "string") {
|
||||
throw new GenerationSubmissionError("generation_request_invalid");
|
||||
}
|
||||
values.set(part.fieldname, part.value);
|
||||
continue;
|
||||
}
|
||||
if (part.fieldname !== "reference_files" || !part.filename) {
|
||||
throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" });
|
||||
}
|
||||
if (!upload) {
|
||||
const parsed = generationFields(values, { idempotencyKey: headers["idempotency-key"], userId: owner.userId });
|
||||
manifest = parsed.manifest;
|
||||
upload = options.generations.beginUpload(parsed.fields);
|
||||
}
|
||||
const expected = manifest?.[fileIndex];
|
||||
if (!expected || expected.fileName !== part.filename || expected.mimeType !== part.mimetype) {
|
||||
throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" });
|
||||
}
|
||||
await upload.stageReference({
|
||||
content: part.file,
|
||||
fileName: part.filename,
|
||||
mimeType: expected.mimeType,
|
||||
projectedBytes: expected.projectedBytes,
|
||||
});
|
||||
if (part.file.truncated) throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" });
|
||||
fileIndex += 1;
|
||||
}
|
||||
if (!upload) {
|
||||
const parsed = generationFields(values, { idempotencyKey: headers["idempotency-key"], userId: owner.userId });
|
||||
manifest = parsed.manifest;
|
||||
upload = options.generations.beginUpload(parsed.fields);
|
||||
}
|
||||
if (fileIndex !== (manifest?.length ?? 0)) {
|
||||
throw new GenerationSubmissionError("reference_invalid", { errorCategory: "reference_invalid" });
|
||||
}
|
||||
const result = await upload.commit();
|
||||
return reply.code(result.created ? 201 : 200).send({ created: result.created, task: generationTaskResponse(result.task) });
|
||||
} catch (error) {
|
||||
upload?.abort();
|
||||
return error instanceof RegistrationError
|
||||
? registrationFailure(reply, request.id, error)
|
||||
: error instanceof CreditError
|
||||
? creditFailure(reply, request.id, error)
|
||||
: generationFailure(reply, request.id, error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.get(
|
||||
"/api/v1/projects",
|
||||
{
|
||||
|
||||
@@ -414,6 +414,7 @@ export class CreditService {
|
||||
}
|
||||
|
||||
private immediate<T>(action: () => T): T {
|
||||
if (this.database.inTransaction) return action();
|
||||
this.database.exec("BEGIN IMMEDIATE");
|
||||
try {
|
||||
const result = action();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<string, GenerationModelSnapshot>();
|
||||
|
||||
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<GenerationSubmissionInput, "newReferences">;
|
||||
|
||||
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<GenerationSubmissionResult>;
|
||||
stageReference(reference: NewGenerationReference): Promise<void>;
|
||||
}
|
||||
|
||||
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<void>) | undefined;
|
||||
private readonly clock: () => number;
|
||||
private readonly credits: CreditService;
|
||||
private readonly models: GenerationModelCatalog;
|
||||
private readonly storage: ManagedStorage;
|
||||
|
||||
constructor(input: {
|
||||
beforeTransaction?: () => Promise<void>;
|
||||
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<GenerationSubmissionResult> {
|
||||
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<T>(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;
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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<StagedManagedFile> {
|
||||
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),
|
||||
|
||||
@@ -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<ProjectRatio, { height: number; width: number }> = {
|
||||
export const ratioPixels: Record<ProjectRatio, { height: number; width: number }> = {
|
||||
"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)
|
||||
|
||||
@@ -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<RegistrationCompleteResponse>;
|
||||
}
|
||||
|
||||
export async function createGeneration(options: ClientOptions = {}): Promise<GenerationCreateResponse> {
|
||||
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<GenerationCreateResponse>;
|
||||
}
|
||||
|
||||
export async function getAccountSettings(options: ClientOptions = {}): Promise<AccountSettingsResponse> {
|
||||
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<GenerationTaskResponse> {
|
||||
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<GenerationTaskResponse>;
|
||||
}
|
||||
|
||||
export function getEvents(options: Pick<ClientOptions, "baseUrl"> = {}): string {
|
||||
return `${options.baseUrl ?? ""}/api/v1/events`;
|
||||
}
|
||||
|
||||
export async function getGeneration(options: ClientOptions = {}): Promise<GenerationTaskResponse> {
|
||||
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<GenerationTaskResponse>;
|
||||
}
|
||||
|
||||
export async function getMyCreditLedger(options: ClientOptions = {}): Promise<CreditLedgerResponse> {
|
||||
const request = options.fetch ?? globalThis.fetch;
|
||||
const response = await request(`${options.baseUrl ?? ""}/api/v1/me/credit-ledger`, { method: "GET", headers: options.headers ?? {} });
|
||||
|
||||
@@ -320,8 +320,36 @@ export type FailedEmptyTrashResponse = {
|
||||
"trashed_project_ids": Array<ProjectId>;
|
||||
};
|
||||
|
||||
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<string>;
|
||||
"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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+207
-10
@@ -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<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
return response.json() as Promise<T>;
|
||||
}
|
||||
|
||||
async function readOptionalJson<T>(url: string): Promise<T | undefined> {
|
||||
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<T>;
|
||||
}
|
||||
|
||||
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<SessionPayload>();
|
||||
const [projects, setProjects] = useState<ProjectListPayload>();
|
||||
const [models, setModels] = useState<ModelPayload>();
|
||||
const [currentTask, setCurrentTask] = useState<GenerationTaskPayload>();
|
||||
const [localData, setLocalData] = useState<LocalDataPayload>();
|
||||
const [generationStateLoaded, setGenerationStateLoaded] = useState(false);
|
||||
const [loadingFailed, setLoadingFailed] = useState(false);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [ratio, setRatio] = useState<Ratio>("3:4");
|
||||
const [references, setReferences] = useState<string[]>([]);
|
||||
const [references, setReferences] = useState<File[]>([]);
|
||||
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<ModelPayload>("/api/v1/models"),
|
||||
readOptionalJson<GenerationTaskPayload>("/api/v1/generations/current"),
|
||||
readOptionalJson<AccountSettingsPayload>("/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<ModelPayload>("/api/v1/models");
|
||||
setModels(next);
|
||||
setRequiresReconfirmation(false);
|
||||
setGenerationNotice("已确认最新配置,请重新检查点数和参考图后提交。");
|
||||
} catch {
|
||||
setGenerationNotice("暂时无法读取最新模型配置。");
|
||||
}
|
||||
}
|
||||
|
||||
if (loadingFailed) {
|
||||
return (
|
||||
<main className="product-loading">
|
||||
@@ -197,7 +360,7 @@ export function WorkspacePage() {
|
||||
</section>
|
||||
) : null}
|
||||
<main className={`workspace-main ${empty ? "is-empty" : "has-projects"}`}>
|
||||
<section className="generation-area" aria-labelledby={empty ? undefined : "workspace-compact-title"}>
|
||||
<form className="generation-area" aria-labelledby={empty ? undefined : "workspace-compact-title"} onSubmit={submitGeneration}>
|
||||
{!empty ? (
|
||||
<div className="workspace-compact-heading">
|
||||
<div><p>NEW PROJECT</p><h1 id="workspace-compact-title">新建创作</h1></div>
|
||||
@@ -215,14 +378,21 @@ export function WorkspacePage() {
|
||||
<div className="generation-options">
|
||||
<div className="model-status" aria-live="polite">
|
||||
<span>模型</span>
|
||||
<strong>当前没有可用于新任务的模型</strong>
|
||||
<strong>{selectedModel ? selectedModel.model_id : "当前没有可用于新任务的模型"}</strong>
|
||||
</div>
|
||||
<fieldset className="ratio-control">
|
||||
<legend>画面比例</legend>
|
||||
<div>
|
||||
{(["3:4", "1:1", "4:3", "9:16"] as const).map((value) => (
|
||||
<label key={value}>
|
||||
<input checked={ratio === value} name="ratio" onChange={() => setRatio(value)} type="radio" value={value} />
|
||||
<input
|
||||
checked={ratio === value}
|
||||
disabled={selectedModel !== undefined && !selectedModel.supported_ratios.includes(value)}
|
||||
name="ratio"
|
||||
onChange={() => setRatio(value)}
|
||||
type="radio"
|
||||
value={value}
|
||||
/>
|
||||
<span>{value}</span>
|
||||
</label>
|
||||
))}
|
||||
@@ -231,22 +401,49 @@ export function WorkspacePage() {
|
||||
</div>
|
||||
<label className="reference-input">
|
||||
<span>添加参考图</span>
|
||||
<small>{references.length > 0 ? references.join("、") : "尚未提交,仅保留在当前页面"}</small>
|
||||
<small>{references.length > 0 ? references.map((file) => file.name).join("、") : "尚未提交,仅保留在当前页面"}</small>
|
||||
<input
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
multiple
|
||||
onChange={(event) => setReferences(Array.from(event.target.files ?? []).map((file) => file.name))}
|
||||
onChange={(event) => setReferences(Array.from(event.target.files ?? []))}
|
||||
type="file"
|
||||
/>
|
||||
</label>
|
||||
{localData?.capacity_status === "critical" ? (
|
||||
<div className="capacity-critical" role="status">
|
||||
<strong>存储空间已超过 90%</strong>
|
||||
<span>请尽快清理本机内容,达到上限后将无法提交新任务。</span>
|
||||
</div>
|
||||
) : null}
|
||||
{generationNotice ? (
|
||||
<div className="generation-notice" role="status">
|
||||
<span>{generationNotice}</span>
|
||||
{requiresReconfirmation ? (
|
||||
<button onClick={confirmLatestModelConfiguration} type="button">确认最新配置</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="generation-submit">
|
||||
<span>预计点数将在模型可用后显示</span>
|
||||
<button disabled type="button">生成一张图片</button>
|
||||
<span>{selectedModel ? `本次预计冻结 ${selectedModel.credit_cost} 点` : "预计点数将在模型可用后显示"}</span>
|
||||
<button disabled={!canSubmit} type="submit">{submitting ? "正在提交" : "生成一张图片"}</button>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
<aside className="current-task" aria-labelledby="current-task-title">
|
||||
<h2 id="current-task-title">当前任务</h2>
|
||||
<div className="current-task-empty"><i /><strong>没有进行中的任务</strong><span>任务状态会持续显示在这里</span></div>
|
||||
{currentTask ? (
|
||||
<div className="current-task-active">
|
||||
<span className="task-state">{currentTask.status === "queued" ? "排队中" : "处理中"}</span>
|
||||
<strong>{currentTask.prompt}</strong>
|
||||
<dl>
|
||||
<div><dt>画面比例</dt><dd>{currentTask.ratio}</dd></div>
|
||||
<div><dt>参考图</dt><dd>{currentTask.reference_count} 张</dd></div>
|
||||
<div><dt>点数</dt><dd>已冻结 {currentTask.reserved_credits} 点</dd></div>
|
||||
</dl>
|
||||
<a href={`/app/projects/${currentTask.project_id}`}>返回当前项目</a>
|
||||
</div>
|
||||
) : (
|
||||
<div className="current-task-empty"><i /><strong>没有进行中的任务</strong><span>任务状态会持续显示在这里</span></div>
|
||||
)}
|
||||
</aside>
|
||||
{!empty ? (
|
||||
<section className="recent-projects" aria-labelledby="recent-title">
|
||||
|
||||
@@ -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",
|
||||
|
||||
+4
-2
@@ -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",
|
||||
|
||||
@@ -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<typeof GenerationCreateHeadersSchema>;
|
||||
export type GenerationParams = Static<typeof GenerationParamsSchema>;
|
||||
export type GenerationTaskResponse = Static<typeof GenerationTaskResponseSchema>;
|
||||
@@ -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";
|
||||
|
||||
Generated
+24
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
@@ -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<string, string>, 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();
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -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<void>; 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<string, unknown> = {}) {
|
||||
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<void>((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<GenerationSubmissionError>({
|
||||
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 });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user