feat: complete TASK-WP2-03 project lifecycle

This commit is contained in:
suyx
2026-08-02 20:00:18 +08:00
parent da6fa25e60
commit 9b4860cfc2
17 changed files with 1477 additions and 10 deletions
+67
View File
@@ -45,6 +45,9 @@ import {
ProjectRatioSchema,
ProjectRenameRequestSchema,
ProjectRenameResponseSchema,
ProjectRestoreResponseSchema,
ProjectPurgeResponseSchema,
ProjectTrashResponseSchema,
ProjectStateConflictResponseSchema,
ProjectStateSaveHeadersSchema,
ProjectStateSaveResponseSchema,
@@ -370,6 +373,9 @@ export async function createApp(options: CreateAppOptions = {}) {
ProjectEditableStateSchema,
ProjectRenameRequestSchema,
ProjectRenameResponseSchema,
ProjectTrashResponseSchema,
ProjectRestoreResponseSchema,
ProjectPurgeResponseSchema,
ProjectStateSaveHeadersSchema,
ProjectStateSaveResponseSchema,
ProjectStateConflictResponseSchema,
@@ -1247,6 +1253,67 @@ export async function createApp(options: CreateAppOptions = {}) {
},
);
const projectLifecycle = (
action: "trash" | "restore" | "purge",
operationId: string,
responseSchema: typeof ProjectTrashResponseSchema | typeof ProjectRestoreResponseSchema | typeof ProjectPurgeResponseSchema,
) => {
app.post(
`/api/v1/projects/:projectId/${action}`,
{
attachValidation: true,
schema: {
headers: Type.Ref(CsrfHeadersSchema),
operationId,
params: Type.Ref(ProjectParamsSchema),
response: {
200: Type.Ref(responseSchema),
400: Type.Null(),
401: Type.Ref(ErrorEnvelopeSchema),
403: Type.Ref(ErrorEnvelopeSchema),
404: Type.Null(),
409: Type.Null(),
503: Type.Ref(ErrorEnvelopeSchema),
},
tags: ["Projects"],
},
},
async (request, reply) => {
if (request.validationError) return reply.code(400).send(null);
if (!options.registration || !options.projects) {
return reply.code(503).send(createErrorEnvelope({ code: "AUTH_SERVICE_UNAVAILABLE", correlationId: request.id }));
}
const token = cookieValue(headerValue(request.headers.cookie), userSessionCookieName);
const csrfToken = headerValue(request.headers["x-csrf-token"]);
if (!token || !csrfToken) {
return reply.code(401).send(createErrorEnvelope({ code: "AUTH_SESSION_INVALID", correlationId: request.id }));
}
try {
const owner = options.registration.authorizeUserMutation({ csrfToken, sessionToken: token });
const projectId = (request.params as ProjectParams).projectId;
if (action === "trash") {
const project = options.projects.trashProject(owner.userId, projectId);
return { deleted_at: project.deletedAt, project_id: projectId, purge_at: project.purgeAt, status: "trashed" as const };
}
if (action === "restore") {
options.projects.restoreProject(owner.userId, projectId);
return { deleted_at: null, project_id: projectId, purge_at: null, status: "active" as const };
}
options.projects.purgeProject(owner.userId, projectId);
return { project_id: projectId, status: "purged" as const };
} catch (error) {
return error instanceof RegistrationError
? registrationFailure(reply, request.id, error)
: projectFailure(reply, request.id, error);
}
},
);
};
projectLifecycle("trash", "trashProject", ProjectTrashResponseSchema);
projectLifecycle("restore", "restoreProject", ProjectRestoreResponseSchema);
projectLifecycle("purge", "purgeProject", ProjectPurgeResponseSchema);
app.post(
"/api/v1/support/check",
{
+187
View File
@@ -14,6 +14,7 @@ export const projectRatios = ["3:4", "1:1", "4:3", "9:16"] as const;
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 }> = {
"3:4": { height: 1440, width: 1080 },
"1:1": { height: 1080, width: 1080 },
@@ -394,7 +395,100 @@ export class ProjectService {
return { ignoredProjectIds, trashedProjectIds };
}
trashProject(ownerId: string, projectId: string) {
const now = this.clock();
let purged = false;
let result!: ReturnType<ProjectService["projectSummary"]>;
const transaction = this.database.transaction(() => {
const project = this.readOwnedProject(ownerId, projectId);
if (project.status === "purged") throw new ProjectError("project_not_found");
if (project.status === "trashed") {
if (project.purge_at !== null && project.purge_at <= now) {
this.transitionToPurged(project, now);
purged = true;
return;
}
result = this.projectSummary(project, this.successfulImageCount(projectId), this.latestGenerationStatus(projectId));
return;
}
this.database.prepare(`
UPDATE projects
SET status = 'trashed', deleted_at = ?, purge_at = ?, updated_at = ?, state_version = state_version + 1
WHERE project_id = ? AND owner_id = ? AND status = 'active'
`).run(now, now + trashRetentionMilliseconds, now, projectId, ownerId);
const updated = this.readOwnedProject(ownerId, projectId);
result = this.projectSummary(updated, this.successfulImageCount(projectId), this.latestGenerationStatus(projectId));
});
transaction.immediate();
if (purged) throw new ProjectError("project_not_found");
return result;
}
restoreProject(ownerId: string, projectId: string) {
const now = this.clock();
let purged = false;
const transaction = this.database.transaction(() => {
const project = this.readOwnedProject(ownerId, projectId);
if (project.status === "purged") throw new ProjectError("project_not_found");
if (project.status === "active") return;
if (project.purge_at !== null && project.purge_at <= now) {
this.transitionToPurged(project, now);
purged = true;
return;
}
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 ProjectError("project_active_limit");
this.database.prepare(`
UPDATE projects
SET status = 'active', deleted_at = NULL, purge_at = NULL, updated_at = ?, state_version = state_version + 1
WHERE project_id = ? AND owner_id = ? AND status = 'trashed'
`).run(now, projectId, ownerId);
});
transaction.immediate();
if (purged) throw new ProjectError("project_not_found");
return this.getProject(ownerId, projectId);
}
purgeProject(ownerId: string, projectId: string) {
const now = this.clock();
const transaction = this.database.transaction(() => {
const project = this.readOwnedProject(ownerId, projectId);
if (project.status !== "trashed") throw new ProjectError("project_not_found");
this.transitionToPurged(project, now);
});
transaction.immediate();
return { projectId, status: "purged" as const };
}
linkManagedResource(ownerId: string, projectId: string, managedFileId: string, resourceKind: ProjectManagedResourceKind) {
const project = this.readOwnedProject(ownerId, projectId);
if (project.status === "purged" || !["derived", "export", "generated", "reference"].includes(resourceKind)) {
throw new ProjectError("project_not_found");
}
if (!this.tableExists("managed_files") || !this.tableExists("project_asset_refs")) {
throw new ProjectError("project_state_invalid");
}
const managed = this.database.prepare(`
SELECT file_id FROM managed_files WHERE file_id = ? AND owner_ref = ? AND status = 'committed'
`).get(managedFileId, ownerId);
if (!managed) throw new ProjectError("project_state_invalid");
const now = this.clock();
const transaction = this.database.transaction(() => {
this.database.prepare(`
INSERT OR IGNORE INTO project_resource_files (project_id, managed_file_id, resource_kind, created_at)
VALUES (?, ?, ?, ?)
`).run(projectId, managedFileId, resourceKind, now);
this.database.prepare(`
INSERT OR IGNORE INTO project_asset_refs (reference_id, managed_file_id, reference_type, created_at)
VALUES (?, ?, 'project', ?)
`).run(`project:${projectId}:${managedFileId}`, managedFileId, iso(now));
});
transaction.immediate();
}
listProjects(ownerId: string, status: "active" | "trashed") {
this.purgeExpiredProjects(ownerId);
const rows = this.database.prepare(`
SELECT p.*,
(SELECT COUNT(*) FROM project_images i WHERE i.project_id = p.project_id) AS image_count,
@@ -407,6 +501,7 @@ export class ProjectService {
}
getProject(ownerId: string, projectId: string) {
this.purgeExpiredProjects(ownerId, projectId);
const row = this.readOwnedProject(ownerId, projectId);
if (row.status === "purged") throw new ProjectError("project_not_found");
const generations = this.database.prepare(`
@@ -504,6 +599,78 @@ export class ProjectService {
return row.count;
}
private latestGenerationStatus(projectId: string) {
const row = this.database.prepare(`
SELECT status FROM generation_jobs WHERE project_id = ? ORDER BY created_at DESC, rowid DESC LIMIT 1
`).get(projectId) as { status: string } | undefined;
return row?.status ?? null;
}
private tableExists(name: string) {
return Boolean(this.database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name));
}
private purgeExpiredProjects(ownerId: string, projectId?: string) {
const now = this.clock();
const transaction = this.database.transaction(() => {
const rows = this.database.prepare(`
SELECT * FROM projects
WHERE owner_id = ? AND status = 'trashed' AND purge_at <= ? AND (? IS NULL OR project_id = ?)
ORDER BY purge_at, project_id
`).all(ownerId, now, projectId ?? null, projectId ?? null) as ProjectRow[];
for (const row of rows) this.transitionToPurged(row, now);
});
transaction.immediate();
}
private transitionToPurged(project: ProjectRow, now: number) {
const changed = this.database.prepare(`
UPDATE projects SET status = 'purged', updated_at = ?, state_version = state_version + 1
WHERE project_id = ? AND status = 'trashed'
`).run(now, project.project_id);
if (changed.changes !== 1) return;
this.queueManagedProjectFiles(project.project_id, now);
this.database.prepare(`
INSERT OR IGNORE INTO project_cleanup_queue (
cleanup_id, project_id, owner_id, resource_scope_json, status, created_at, completed_at, last_error
) VALUES (?, ?, ?, ?, 'pending', ?, NULL, NULL)
`).run(
randomUUID(), project.project_id, project.owner_id,
stableJson(["project_state", "generation", "generated_image", "reference", "location", "latest_export"]), now,
);
}
private queueManagedProjectFiles(projectId: string, now: number) {
if (!["managed_files", "file_cleanup_queue", "project_asset_refs"].every((table) => this.tableExists(table))) return;
const files = this.database.prepare(`
SELECT mf.file_id, mf.relative_path, mf.byte_size
FROM project_resource_files prf
JOIN managed_files mf ON mf.file_id = prf.managed_file_id
WHERE prf.project_id = ? AND mf.status = 'committed'
ORDER BY mf.file_id
`).all(projectId) as Array<{ byte_size: number; file_id: string; relative_path: string }>;
for (const file of files) {
this.database.prepare("DELETE FROM project_asset_refs WHERE reference_id = ?")
.run(`project:${projectId}:${file.file_id}`);
const otherProject = this.database.prepare(`
SELECT 1 FROM project_resource_files prf
JOIN projects p ON p.project_id = prf.project_id
WHERE prf.managed_file_id = ? AND prf.project_id <> ? AND p.status <> 'purged'
LIMIT 1
`).get(file.file_id, projectId);
const otherReference = this.database.prepare("SELECT 1 FROM project_asset_refs WHERE managed_file_id = ? LIMIT 1").get(file.file_id);
if (otherProject || otherReference) continue;
this.database.prepare("UPDATE managed_files SET status = 'purged', purged_at = ? WHERE file_id = ? AND status = 'committed'")
.run(iso(now), file.file_id);
this.database.prepare(`
INSERT OR IGNORE INTO file_cleanup_queue (
cleanup_id, managed_file_id, relative_path, byte_size, counts_toward_managed,
reason, status, created_at, completed_at, last_error
) VALUES (?, ?, ?, ?, 1, 'purge', 'pending', ?, NULL, NULL)
`).run(randomUUID(), file.file_id, file.relative_path, file.byte_size, iso(now));
}
}
private projectSummary(row: ProjectRow, imageCount: number, latestStatus: string | null) {
const status: ProjectViewStatus = row.status === "trashed"
? "trashed"
@@ -572,6 +739,26 @@ export class ProjectService {
FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS project_states_latest ON project_states(project_id, state_version DESC);
CREATE TABLE IF NOT EXISTS project_resource_files (
project_id TEXT NOT NULL,
managed_file_id TEXT NOT NULL,
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('derived', 'export', 'generated', 'reference')),
created_at INTEGER NOT NULL,
PRIMARY KEY (project_id, managed_file_id),
FOREIGN KEY (project_id) REFERENCES projects(project_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS project_resource_files_managed ON project_resource_files(managed_file_id, project_id);
CREATE TABLE IF NOT EXISTS project_cleanup_queue (
cleanup_id TEXT PRIMARY KEY,
project_id TEXT NOT NULL UNIQUE,
owner_id TEXT NOT NULL,
resource_scope_json TEXT NOT NULL CHECK (json_valid(resource_scope_json)),
status TEXT NOT NULL CHECK (status IN ('pending', 'completed', 'failed')),
created_at INTEGER NOT NULL,
completed_at INTEGER,
last_error TEXT
);
CREATE INDEX IF NOT EXISTS project_cleanup_queue_status_created ON project_cleanup_queue(status, created_at, project_id);
CREATE TABLE IF NOT EXISTS project_state_idempotency (
owner_id TEXT NOT NULL,
project_id TEXT NOT NULL,
+22 -1
View File
@@ -1,6 +1,6 @@
// Generated from openapi/openapi.json. Do not edit by hand.
import type { AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, AccountSettingsResponse, AdminSessionResponse, ProjectDetailResponse, UserSessionResponse, ProjectListResponse, LogoutResponse, ProjectRenameResponse, ProjectRenameRequest, ProjectStateSaveResponse, ProjectEditableState, AccountDeletionSendResponse, RegistrationSendResponse, AdminLoginSendRequest, LoginSendRequest, RegistrationSendRequest, FailedEmptyTrashResponse, FailedEmptyTrashRequest, AccountProfileUpdateResponse, AccountProfileUpdateRequest } from "./types.gen.js";
import type { AccountDeletionResponse, AccountDeletionCompleteRequest, AdminLoginCompleteResponse, AdminLoginCompleteRequest, LoginCompleteResponse, LoginCompleteRequest, RegistrationCompleteResponse, RegistrationCompleteRequest, AccountSettingsResponse, AdminSessionResponse, 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; }
@@ -166,6 +166,13 @@ export async function logoutUser(options: ClientOptions = {}): Promise<LogoutRes
return response.json() as Promise<LogoutResponse>;
}
export async function purgeProject(options: ClientOptions = {}): Promise<ProjectPurgeResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}/purge`, { method: "POST", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<ProjectPurgeResponse>;
}
export async function renameProject(body: ProjectRenameRequest, options: ClientOptions = {}): Promise<ProjectRenameResponse> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
@@ -175,6 +182,13 @@ export async function renameProject(body: ProjectRenameRequest, options: ClientO
return response.json() as Promise<ProjectRenameResponse>;
}
export async function restoreProject(options: ClientOptions = {}): Promise<ProjectRestoreResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}/restore`, { method: "POST", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<ProjectRestoreResponse>;
}
export async function saveProjectState(body: ProjectEditableState, options: ClientOptions = {}): Promise<ProjectStateSaveResponse> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
@@ -227,6 +241,13 @@ export async function trashFailedEmptyProjects(body: FailedEmptyTrashRequest, op
return response.json() as Promise<FailedEmptyTrashResponse>;
}
export async function trashProject(options: ClientOptions = {}): Promise<ProjectTrashResponse> {
const request = options.fetch ?? globalThis.fetch;
const response = await request(`${options.baseUrl ?? ""}/api/v1/projects/{projectId}/trash`, { method: "POST", headers: options.headers ?? {} });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json() as Promise<ProjectTrashResponse>;
}
export async function updateAccountProfile(body: AccountProfileUpdateRequest, options: ClientOptions = {}): Promise<AccountProfileUpdateResponse> {
const request = options.fetch ?? globalThis.fetch;
const headers = new Headers(options.headers);
+19
View File
@@ -357,6 +357,11 @@ export type ProjectParams = {
"projectId": ProjectId;
};
export type ProjectPurgeResponse = {
"project_id": ProjectId;
"status": "purged";
};
export type ProjectRatio = "3:4" | "1:1" | "4:3" | "9:16";
export type ProjectRenameRequest = {
@@ -369,6 +374,13 @@ export type ProjectRenameResponse = {
"status": "renamed";
};
export type ProjectRestoreResponse = {
"deleted_at": null;
"project_id": ProjectId;
"purge_at": null;
"status": "active";
};
export type ProjectStateConflictResponse = {
"latest_state_version": number;
"save_status": "conflicted";
@@ -398,6 +410,13 @@ export type ProjectSummary = {
"updated_at": string;
};
export type ProjectTrashResponse = {
"deleted_at": string;
"project_id": ProjectId;
"purge_at": string;
"status": "trashed";
};
export type ProjectViewStatus = "active" | "failed_empty" | "trashed";
export type RegistrationCompleteHeaders = {
+95 -2
View File
@@ -522,8 +522,8 @@
.project-card-body {
display: grid;
min-height: 170px;
grid-template-rows: minmax(48px, auto) auto auto 1fr;
min-height: 198px;
grid-template-rows: minmax(48px, auto) auto auto auto 1fr;
gap: 8px;
padding: 16px;
}
@@ -568,6 +568,46 @@
font-weight: 800;
}
.project-card-body .trash-expiry {
color: #8f1d14;
font-weight: 700;
}
.project-card-body .project-card-actions {
display: flex;
align-self: end;
justify-content: flex-start;
gap: 8px;
flex-wrap: wrap;
}
.project-card-actions a,
.project-card-actions button {
display: inline-grid;
min-height: 38px;
place-items: center;
padding: 7px 10px;
border: 1px solid #111111;
border-radius: 0;
color: #111111;
background: #ffffff;
font-size: 12px;
font-weight: 800;
text-decoration: none;
}
.project-card-actions button:disabled {
border-color: #b2b2ac;
color: #777770;
background: #dfdfda;
cursor: not-allowed;
}
.project-card[data-status="trashed"] .project-card-actions button:last-child:not(:disabled) {
border-color: #a52e24;
color: #8f1d14;
}
.projects-stale,
.projects-notice {
padding: 12px 14px;
@@ -912,6 +952,55 @@
background: rgb(17 17 17 / 58%);
}
.project-purge-overlay {
position: fixed;
z-index: 40;
inset: 0;
display: grid;
place-items: center;
padding: 24px;
background: rgb(17 17 17 / 62%);
}
.project-purge-dialog {
width: min(520px, 100%);
padding: 28px;
border: 2px solid #111111;
background: #ffffff;
box-shadow: 10px 10px 0 #d14a3b;
}
.project-purge-dialog > p:first-child {
margin: 0 0 8px;
font-family: Consolas, monospace;
font-size: 11px;
font-weight: 700;
}
.project-purge-dialog h2 {
margin: 0 0 10px;
}
.project-purge-dialog > div {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 22px;
}
.project-purge-dialog button {
min-height: 44px;
border: 1px solid #111111;
border-radius: 0;
background: #ffffff;
font-weight: 800;
}
.project-purge-dialog button:first-child {
color: #ffffff;
background: #a52e24;
}
.project-leave-dialog {
width: min(560px, 100%);
padding: 28px;
@@ -1120,6 +1209,10 @@
grid-template-columns: 1fr;
}
.project-purge-dialog > div {
grid-template-columns: 1fr;
}
.project-actions {
grid-template-columns: 1fr;
}
+66 -2
View File
@@ -262,8 +262,13 @@ export function WorkspacePage() {
);
}
function ProjectCard({ project, selectable, selected, onSelect }: {
function ProjectCard({ activeLimitReached, busy, onPurge, onRestore, onSelect, onTrash, project, selectable, selected }: {
activeLimitReached?: boolean;
busy?: boolean;
onPurge?: (() => void) | undefined;
onRestore?: (() => void) | undefined;
onSelect?: (selected: boolean) => void;
onTrash?: (() => void) | undefined;
project: ProjectSummary;
selectable?: boolean;
selected?: boolean;
@@ -284,8 +289,14 @@ function ProjectCard({ project, selectable, selected, onSelect }: {
<div className="project-card-body">
<div><h3 title={project.name}>{project.name}</h3><span>{project.status === "failed_empty" ? "生成失败" : project.status === "trashed" ? "回收站" : "项目"}</span></div>
<p>{project.successful_image_count} · {project.ratio}</p>
{project.status === "trashed" && project.purge_at ? <p className="trash-expiry"> {formatUpdatedAt(project.purge_at)} </p> : null}
<time dateTime={project.updated_at}>{formatUpdatedAt(project.updated_at)}</time>
<a aria-label={`打开项目:${project.name}`} href={`/app/projects/${project.project_id}`}></a>
<div className="project-card-actions">
{project.status !== "trashed" ? <a aria-label={`打开项目:${project.name}`} href={`/app/projects/${project.project_id}`}></a> : null}
{onTrash ? <button aria-label={`移入回收站:${project.name}`} disabled={busy} onClick={onTrash} type="button"></button> : null}
{onRestore ? <button aria-label={`恢复项目:${project.name}`} disabled={busy || activeLimitReached} onClick={onRestore} type="button"></button> : null}
{onPurge ? <button aria-label={`永久删除:${project.name}`} disabled={busy} onClick={onPurge} type="button"></button> : null}
</div>
</div>
</article>
);
@@ -300,6 +311,8 @@ export function ProjectsPage() {
const [selected, setSelected] = useState<string[]>([]);
const [loadingFailed, setLoadingFailed] = useState(false);
const [notice, setNotice] = useState("");
const [busyProjectId, setBusyProjectId] = useState<string>();
const [pendingPurge, setPendingPurge] = useState<ProjectSummary>();
useEffect(() => {
let active = true;
@@ -346,6 +359,36 @@ export function ProjectsPage() {
}
}
async function runLifecycle(project: ProjectSummary, action: "trash" | "restore" | "purge") {
if (!session || busyProjectId) return;
setBusyProjectId(project.project_id);
try {
await readJson(`/api/v1/projects/${project.project_id}/${action}`, {
headers: { "X-CSRF-Token": session.csrf_token },
method: "POST",
});
setPayload((current) => current ? {
...current,
active_count: action === "trash"
? Math.max(0, current.active_count - 1)
: action === "restore"
? Math.min(current.active_limit, current.active_count + 1)
: current.active_count,
projects: current.projects.filter((item) => item.project_id !== project.project_id),
} : current);
setNotice(action === "trash"
? "项目已移入回收站"
: action === "restore"
? "项目已恢复"
: "项目已永久删除,物理清理将在后台完成");
if (action === "purge") setPendingPurge(undefined);
} catch {
setNotice("操作未完成,请重试");
} finally {
setBusyProjectId(undefined);
}
}
if (!payload && !loadingFailed) return <LoadingPage label="正在读取项目" />;
return (
@@ -371,6 +414,9 @@ export function ProjectsPage() {
</div>
{loadingFailed ? <p className="projects-stale" role="alert">{payload ? "当前显示上次读取的数据。" : ""}</p> : null}
{notice ? <p className="projects-notice" role="status">{notice}</p> : null}
{status === "trashed" && payload && payload.active_count === payload.active_limit ? (
<p className="projects-stale"> 20 </p>
) : null}
{visible.length === 0 ? (
<section className="projects-empty">
<h2>{query ? "没有符合条件的项目" : status === "active" ? "还没有项目" : "回收站为空"}</h2>
@@ -385,6 +431,11 @@ export function ProjectsPage() {
? [...current, project.project_id]
: current.filter((projectId) => projectId !== project.project_id))}
project={project}
activeLimitReached={payload?.active_count === payload?.active_limit}
busy={busyProjectId === project.project_id}
onPurge={status === "trashed" ? () => setPendingPurge(project) : undefined}
onRestore={status === "trashed" ? () => runLifecycle(project, "restore") : undefined}
onTrash={status === "active" ? () => runLifecycle(project, "trash") : undefined}
selectable={status === "active" && project.status === "failed_empty"}
selected={selected.includes(project.project_id)}
/>
@@ -398,6 +449,19 @@ export function ProjectsPage() {
</section>
) : null}
</main>
{pendingPurge ? (
<div className="project-purge-overlay">
<section aria-labelledby="project-purge-title" aria-modal="true" className="project-purge-dialog" role="dialog">
<p>IRREVERSIBLE ACTION</p>
<h2 id="project-purge-title"></h2>
<p>{pendingPurge.name}</p>
<div>
<button disabled={busyProjectId === pendingPurge.project_id} onClick={() => runLifecycle(pendingPurge, "purge")} type="button"></button>
<button disabled={Boolean(busyProjectId)} onClick={() => setPendingPurge(undefined)} type="button"></button>
</div>
</section>
</div>
) : null}
<LocalOnlyFooter />
</div>
);
+241
View File
@@ -0,0 +1,241 @@
import { randomUUID } from "node:crypto";
import { rmSync } from "node:fs";
import { createRequire } from "node:module";
import { isAbsolute, relative, resolve } from "node:path";
import type BetterSqlite3 from "better-sqlite3";
const require = createRequire(import.meta.url);
const Database = require("better-sqlite3") as typeof BetterSqlite3;
interface ProjectCleanupRow {
cleanup_id: string;
project_id: string;
}
interface ExpiredProjectRow {
owner_id: string;
project_id: string;
}
interface FileCleanupRow {
byte_size: number;
cleanup_id: string;
counts_toward_managed: 0 | 1;
managed_file_id: string | null;
relative_path: string;
}
const resourceScope = JSON.stringify([
"project_state", "generation", "generated_image", "reference", "location", "latest_export",
]);
function iso(timestamp: number) {
return new Date(timestamp).toISOString();
}
export class ProjectPurgeCleanup {
private readonly clock: () => number;
private readonly dataRoot: string | undefined;
private readonly database: BetterSqlite3.Database;
constructor(input: { clock?: () => number; dataRoot?: string; databasePath: string }) {
this.clock = input.clock ?? Date.now;
this.dataRoot = input.dataRoot ? resolve(input.dataRoot) : undefined;
const nativeBinding = process.env.DADA_SQLITE_NATIVE_BINDING;
this.database = new Database(input.databasePath, nativeBinding ? { nativeBinding } : undefined);
this.database.pragma("journal_mode = WAL");
this.database.pragma("foreign_keys = ON");
this.database.pragma("busy_timeout = 5000");
}
close() {
this.database.close();
}
run() {
const expired = this.sweepExpired();
const relational = this.processPending();
const physical = this.processFileCleanup();
return { expired, physical, relational };
}
sweepExpired() {
if (!this.tableExists("projects") || !this.tableExists("project_cleanup_queue")) return 0;
const now = this.clock();
const rows = this.database.prepare(`
SELECT project_id, owner_id FROM projects
WHERE status = 'trashed' AND purge_at <= ?
ORDER BY purge_at, project_id
`).all(now) as ExpiredProjectRow[];
let expired = 0;
for (const row of rows) {
const transaction = this.database.transaction(() => {
const changed = this.database.prepare(`
UPDATE projects SET status = 'purged', updated_at = ?, state_version = state_version + 1
WHERE project_id = ? AND status = 'trashed'
`).run(now, row.project_id);
if (changed.changes !== 1) return false;
this.queueManagedFiles(row.project_id, now);
this.database.prepare(`
INSERT OR IGNORE INTO project_cleanup_queue (
cleanup_id, project_id, owner_id, resource_scope_json, status, created_at, completed_at, last_error
) VALUES (?, ?, ?, ?, 'pending', ?, NULL, NULL)
`).run(randomUUID(), row.project_id, row.owner_id, resourceScope, now);
return true;
});
if (transaction.immediate()) expired += 1;
}
return expired;
}
processPending() {
if (!this.tableExists("project_cleanup_queue") || !this.tableExists("projects")) return { completed: 0, failed: 0 };
const rows = this.database.prepare(`
SELECT cleanup_id, project_id FROM project_cleanup_queue
WHERE status IN ('pending', 'failed') ORDER BY created_at, project_id
`).all() as ProjectCleanupRow[];
let completed = 0;
let failed = 0;
for (const row of rows) {
try {
const transaction = this.database.transaction(() => {
this.database.prepare("DELETE FROM projects WHERE project_id = ? AND status = 'purged'").run(row.project_id);
this.database.prepare(`
UPDATE project_cleanup_queue
SET status = 'completed', completed_at = ?, last_error = NULL
WHERE cleanup_id = ?
`).run(this.clock(), row.cleanup_id);
});
transaction.immediate();
completed += 1;
} catch {
this.database.prepare(`
UPDATE project_cleanup_queue SET status = 'failed', last_error = 'project_cleanup_failed'
WHERE cleanup_id = ?
`).run(row.cleanup_id);
failed += 1;
}
}
return { completed, failed };
}
processFileCleanup() {
if (!this.dataRoot || !this.tableExists("file_cleanup_queue") || !this.tableExists("managed_files")) {
return { completed: 0, failed: 0 };
}
const rows = this.database.prepare(`
SELECT cleanup_id, managed_file_id, relative_path, byte_size, counts_toward_managed
FROM file_cleanup_queue WHERE status IN ('pending', 'failed') ORDER BY created_at, cleanup_id
`).all() as FileCleanupRow[];
let completed = 0;
let failed = 0;
for (const row of rows) {
try {
const path = this.resolveManagedPath(row.relative_path);
rmSync(path, { force: true });
const transaction = this.database.transaction(() => {
if (row.counts_toward_managed === 1 && row.managed_file_id) {
if (this.tableExists("project_asset_refs")) {
this.database.prepare("DELETE FROM project_asset_refs WHERE managed_file_id = ?").run(row.managed_file_id);
}
if (this.tableExists("project_resource_files")) {
this.database.prepare("DELETE FROM project_resource_files WHERE managed_file_id = ?").run(row.managed_file_id);
}
if (this.tableExists("asset_cleanup_request_items")) {
const requests = this.database.prepare("SELECT request_id FROM asset_cleanup_request_items WHERE managed_file_id = ?")
.all(row.managed_file_id) as Array<{ request_id: string }>;
this.database.prepare("DELETE FROM asset_cleanup_request_items WHERE managed_file_id = ?").run(row.managed_file_id);
if (this.tableExists("asset_cleanup_requests")) {
for (const request of requests) {
const pending = this.database.prepare("SELECT COUNT(*) AS count FROM asset_cleanup_request_items WHERE request_id = ?")
.get(request.request_id) as { count: number };
if (pending.count === 0) {
this.database.prepare("UPDATE asset_cleanup_requests SET status = 'completed' WHERE request_id = ? AND status = 'queued'")
.run(request.request_id);
}
}
}
}
this.database.prepare("DELETE FROM managed_files WHERE file_id = ?").run(row.managed_file_id);
if (this.tableExists("local_backend_storage_state")) this.decrementManagedCapacity(row.byte_size);
}
this.database.prepare(`
UPDATE file_cleanup_queue SET status = 'completed', completed_at = ?, last_error = NULL
WHERE cleanup_id = ?
`).run(iso(this.clock()), row.cleanup_id);
});
transaction.immediate();
completed += 1;
} catch {
this.database.prepare(`
UPDATE file_cleanup_queue SET status = 'failed', last_error = 'physical_file_cleanup_failed'
WHERE cleanup_id = ?
`).run(row.cleanup_id);
failed += 1;
}
}
return { completed, failed };
}
private queueManagedFiles(projectId: string, now: number) {
if (!["project_resource_files", "managed_files", "project_asset_refs", "file_cleanup_queue"]
.every((table) => this.tableExists(table))) return;
const files = this.database.prepare(`
SELECT mf.file_id, mf.relative_path, mf.byte_size
FROM project_resource_files prf
JOIN managed_files mf ON mf.file_id = prf.managed_file_id
WHERE prf.project_id = ? AND mf.status = 'committed'
ORDER BY mf.file_id
`).all(projectId) as Array<{ byte_size: number; file_id: string; relative_path: string }>;
for (const file of files) {
this.database.prepare("DELETE FROM project_asset_refs WHERE reference_id = ?")
.run(`project:${projectId}:${file.file_id}`);
const otherProject = this.database.prepare(`
SELECT 1 FROM project_resource_files prf
JOIN projects p ON p.project_id = prf.project_id
WHERE prf.managed_file_id = ? AND prf.project_id <> ? AND p.status <> 'purged'
LIMIT 1
`).get(file.file_id, projectId);
const otherReference = this.database.prepare("SELECT 1 FROM project_asset_refs WHERE managed_file_id = ? LIMIT 1").get(file.file_id);
if (otherProject || otherReference) continue;
this.database.prepare("UPDATE managed_files SET status = 'purged', purged_at = ? WHERE file_id = ? AND status = 'committed'")
.run(iso(now), file.file_id);
this.database.prepare(`
INSERT OR IGNORE INTO file_cleanup_queue (
cleanup_id, managed_file_id, relative_path, byte_size, counts_toward_managed,
reason, status, created_at, completed_at, last_error
) VALUES (?, ?, ?, ?, 1, 'purge', 'pending', ?, NULL, NULL)
`).run(randomUUID(), file.file_id, file.relative_path, file.byte_size, iso(now));
}
}
private tableExists(name: string) {
return Boolean(this.database.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name));
}
private resolveManagedPath(relativePath: string) {
const path = resolve(this.dataRoot!, relativePath);
const scoped = relative(this.dataRoot!, path);
if (!scoped || scoped.startsWith("..") || isAbsolute(scoped)) throw new Error("managed_path_outside_data_root");
return path;
}
private decrementManagedCapacity(bytes: number) {
const current = this.database.prepare(`
SELECT managed_content_bytes FROM local_backend_storage_state WHERE singleton = 1
`).get() as { managed_content_bytes: number } | undefined;
if (!current) return;
const managed = Math.max(0, current.managed_content_bytes - bytes);
const notice = managed < 4_294_967_296 ? "normal" : managed < 4_831_838_208 ? "warning" : "critical";
const reservations = this.tableExists("storage_reservations")
? (this.database.prepare("SELECT COALESCE(SUM(projected_bytes), 0) AS bytes FROM storage_reservations WHERE status = 'active'").get() as { bytes: number }).bytes
: 0;
const status = managed + reservations >= 5_368_709_120 ? "full" : "active";
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(managed, notice, status, iso(this.clock()));
}
}
+5
View File
@@ -4,6 +4,7 @@ import { join } from "node:path";
import { WorkerAiCallGate } from "./ai-call-gate.js";
import { readConfiguredLocalDataRoot } from "./runtime-config.js";
import { RetentionCleanup } from "./retention-cleanup.js";
import { ProjectPurgeCleanup } from "./project-purge-cleanup.js";
import { StructuredJsonlLogger } from "./structured-log.js";
import { WorkerStorageStatus } from "./storage-status.js";
import { attachWorkerSupervisorControl, initializeWorkerCredentialClient, receiveWorkerCredentials } from "./supervisor-channel.js";
@@ -28,11 +29,13 @@ if (!workerPort && process.argv.includes("--dada-credential-stdin")) {
const keepAlive = setInterval(() => undefined, 30_000);
let storage: WorkerStorageStatus | undefined;
let retention: RetentionCleanup | undefined;
let projectCleanup: ProjectPurgeCleanup | undefined;
let retentionTimer: ReturnType<typeof setInterval> | undefined;
const control = attachWorkerSupervisorControl(controlPipe, () => {
clearInterval(keepAlive);
if (retentionTimer) clearInterval(retentionTimer);
retention?.close();
projectCleanup?.close();
storage?.close();
});
let storageStatus: "active" | "unavailable" = "active";
@@ -41,9 +44,11 @@ if (!workerPort && process.argv.includes("--dada-credential-stdin")) {
const databasePath = join(dataRoot, "db", "dada.sqlite3");
storage = new WorkerStorageStatus(databasePath);
retention = new RetentionCleanup({ databasePath });
projectCleanup = new ProjectPurgeCleanup({ dataRoot, databasePath });
const runRetentionCleanup = () => {
try {
retention?.purgeExpired();
projectCleanup?.run();
} catch {
storageStatus = "unavailable";
storage?.markLogUnavailable();