Files
ttstd-infinite-canvas/canvas-agent/src/portrait-clearance/task-store.ts
T

677 lines
41 KiB
TypeScript

import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { createPortraitClearanceTaskRequestSchema, portraitClearanceInputSchema, portraitClearanceResultSchema, portraitModelJobCompleteRequestSchema, portraitModelJobFailRequestSchema, portraitModelJobSchema, portraitTaskSummarySchema, type CreatePortraitClearanceTaskRequest, type PortraitClearanceInputRole, type PortraitClearanceTaskStatus, type PortraitModelJob, type PortraitTaskSummary } from "./contracts.js";
import { applyStylizedRiskFloor, summarizeRisk } from "./risk-rules.js";
import { buildPortraitReports } from "./reports.js";
// Base64 expands binary input by roughly 4/3; keep the decoded total below
// 20MB so the signed JSON request remains within Local Runtime's 30MB limit.
const MAX_INPUT_BYTES = 20 * 1024 * 1024;
const MODEL_JOB_LEASE_MS = 180_000;
const MAX_MODEL_JOB_ATTEMPTS = 3;
export type PortraitTaskInputFile = {
id: string;
nodeId: string;
role: PortraitClearanceInputRole;
fileName: string;
mimeType: "image/jpeg" | "image/png" | "image/webp";
relativePath: string;
};
export type PortraitTaskRecord = PortraitTaskSummary & {
schemaVersion: 1;
ownerId: string;
keyId: string;
origin: string;
ownerScopeHash: string;
settings: CreatePortraitClearanceTaskRequest["settings"];
inputs: PortraitTaskInputFile[];
resultRelativePath?: string;
reportRelativePaths?: Partial<Record<"md" | "html" | "docx", string>>;
modelJobs?: PortraitModelJob[];
cancelRequested?: boolean;
};
export type PortraitTaskEvent = {
id: string;
taskId: string;
type: "state" | "candidate" | "error";
at: string;
summary: PortraitTaskSummary;
};
export class PortraitTaskStoreError extends Error {
constructor(readonly code: "portrait_task_not_found" | "portrait_task_forbidden" | "portrait_idempotency_conflict" | "portrait_input_invalid" | "portrait_task_delete_failed" | "portrait_model_job_not_found" | "portrait_model_job_conflict" | "portrait_artifact_not_found", message: string, readonly status = 400) {
super(message);
this.name = "PortraitTaskStoreError";
}
}
export class PortraitTaskStore {
private readonly active = new Map<string, Promise<void>>();
private readonly activeControllers = new Map<string, AbortController>();
private readonly taskLocks = new Map<string, Promise<void>>();
private readonly deleting = new Set<string>();
constructor(private readonly root: string, private readonly runtimeOwnerId: string) {}
async create(request: CreatePortraitClearanceTaskRequest, owner: { keyId: string; origin: string }) {
const parsed = createPortraitClearanceTaskRequestSchema.parse(request);
const queryCount = parsed.inputs.filter((input) => input.role === "query").length;
const referenceCount = parsed.inputs.filter((input) => input.role === "reference").length;
if (parsed.mode === "direct-compare" && (queryCount !== 1 || referenceCount !== 1)) throw new PortraitTaskStoreError("portrait_input_invalid", "直接比对需要唯一的查询图和参考图", 400);
if (parsed.mode === "network-search" && queryCount !== 1) throw new PortraitTaskStoreError("portrait_input_invalid", "网络排查需要唯一的查询图", 400);
const existing = await this.findByOperation(parsed, owner);
if (existing) return { record: existing, created: false };
const existingOperation = (await this.listAll()).find((item) => item.clientOperationId === parsed.clientOperationId);
if (existingOperation) throw new PortraitTaskStoreError("portrait_idempotency_conflict", "同一幂等键已用于另一项肖像排查任务", 409);
const taskId = `portrait-${crypto.randomBytes(16).toString("hex")}`;
const taskRoot = this.taskRoot(taskId);
await fs.mkdir(path.join(taskRoot, "inputs"), { recursive: true });
let inputFiles: PortraitTaskInputFile[];
try {
inputFiles = await persistInputs(taskRoot, parsed.inputs);
} catch (error) {
await fs.rm(taskRoot, { recursive: true, force: true });
throw error;
}
const now = new Date().toISOString();
const record: PortraitTaskRecord = {
schemaVersion: 1,
taskId,
clientOperationId: parsed.clientOperationId,
ownerId: this.runtimeOwnerId,
keyId: owner.keyId,
origin: owner.origin,
ownerScopeHash: parsed.ownerScopeHash,
projectId: parsed.projectId,
nodeId: parsed.nodeId,
mode: parsed.mode,
analysisMode: parsed.analysisMode,
...(parsed.modelRef ? { modelRef: parsed.modelRef } : {}),
status: "queued",
stage: "validating-inputs",
progress: 0,
processedCandidates: 0,
totalCandidates: parsed.mode === "direct-compare" ? 1 : undefined,
createdAt: now,
updatedAt: now,
detailsAvailable: false,
settings: parsed.settings,
inputs: inputFiles,
};
await this.writeRecord(record);
await this.appendEvent(record, "state");
return { record, created: true };
}
async get(taskId: string, owner: { keyId: string; origin: string }) {
const record = await this.readRecord(taskId);
this.assertOwner(record, owner);
return record;
}
async list(query: { projectId?: string; nodeId?: string; ownerScopeHash?: string; limit: number; cursor?: string }, owner: { keyId: string; origin: string }) {
const records = (await this.listAll())
.filter((record) => record.keyId === owner.keyId && record.origin === owner.origin)
.filter((record) => !query.ownerScopeHash || record.ownerScopeHash === query.ownerScopeHash)
.filter((record) => !query.projectId || record.projectId === query.projectId)
.filter((record) => !query.nodeId || record.nodeId === query.nodeId)
.sort((left, right) => right.updatedAt.localeCompare(left.updatedAt) || right.taskId.localeCompare(left.taskId));
const after = query.cursor ? records.findIndex((record) => record.taskId === query.cursor) + 1 : 0;
const selected = records.slice(Math.max(0, after), Math.min(records.length, Math.max(0, after) + query.limit));
return { tasks: selected.map(toSummary), ...(selected.length && selected.at(-1)!.taskId !== records.at(-1)?.taskId ? { nextCursor: selected.at(-1)!.taskId } : {}) };
}
async counts(owner: { keyId: string; origin: string }) {
const records = (await this.listAll()).filter((record) => record.keyId === owner.keyId && record.origin === owner.origin);
const recoverable = records.filter((record) => record.status === "queued" || record.status === "running" || record.status === "waiting_model");
return { active: records.filter((record) => this.isRunning(record.taskId) || record.status === "waiting_model").length, recoverable: recoverable.length };
}
async recoverableRecords() {
return (await this.listAll()).filter((record) => record.status === "queued" || record.status === "running");
}
async update(taskId: string, owner: { keyId: string; origin: string }, patch: Partial<Pick<PortraitTaskRecord, "status" | "stage" | "progress" | "processedCandidates" | "totalCandidates" | "errorCode" | "errorMessage" | "completedAt" | "detailsAvailable" | "resultRelativePath" | "reportRelativePaths" | "modelJobs" | "cancelRequested">>) {
return this.withTaskLock(taskId, () => this.updateUnlocked(taskId, owner, patch));
}
private async updateUnlocked(taskId: string, owner: { keyId: string; origin: string }, patch: Partial<Pick<PortraitTaskRecord, "status" | "stage" | "progress" | "processedCandidates" | "totalCandidates" | "errorCode" | "errorMessage" | "completedAt" | "detailsAvailable" | "resultRelativePath" | "reportRelativePaths" | "modelJobs" | "cancelRequested">>, allowCancelledTransition = false) {
if (this.deleting.has(taskId)) throw new PortraitTaskStoreError("portrait_task_not_found", "任务正在删除", 404);
const record = await this.get(taskId, owner);
if (!allowCancelledTransition && record.status === "cancelled" && patch.status && patch.status !== "cancelled") return record;
const next: PortraitTaskRecord = { ...record, ...patch, updatedAt: new Date().toISOString() };
await this.writeRecord(next);
await this.appendEvent(next, patch.errorCode ? "error" : "state");
return next;
}
async prepareRetry(taskId: string, owner: { keyId: string; origin: string }) {
this.activeControllers.get(taskId)?.abort();
await this.active.get(taskId)?.catch(() => undefined);
return this.withTaskLock(taskId, async () => {
const record = await this.get(taskId, owner);
for (const directory of ["results", "reports", "candidates", "model-inputs"]) {
await fs.rm(path.join(this.taskRoot(record.taskId), directory), { recursive: true, force: true });
}
return this.updateUnlocked(taskId, owner, {
status: "queued",
stage: "validating-inputs",
progress: 0,
processedCandidates: 0,
errorCode: undefined,
errorMessage: undefined,
completedAt: undefined,
detailsAvailable: false,
resultRelativePath: undefined,
reportRelativePaths: undefined,
modelJobs: undefined,
cancelRequested: false,
}, true);
});
}
async writeResult(taskId: string, owner: { keyId: string; origin: string }, result: unknown) {
const record = await this.get(taskId, owner);
const parsed = portraitClearanceResultSchema.parse(result);
const resultRelativePath = "results/clearance-result.json";
const resultPath = path.join(this.taskRoot(record.taskId), resultRelativePath);
await atomicWrite(resultPath, JSON.stringify(parsed, null, 2));
return this.update(taskId, owner, { resultRelativePath, detailsAvailable: true });
}
async readResult(taskId: string, owner: { keyId: string; origin: string }) {
return this.withTaskLock(taskId, async () => {
const record = await this.get(taskId, owner);
if (!record.resultRelativePath) return undefined;
try {
const value = await this.readStoredResult(record);
return this.syncFailedModelPairs(record, owner, value);
} catch {
return undefined;
}
});
}
private async readStoredResult(record: PortraitTaskRecord) {
if (!record.resultRelativePath) return undefined;
return JSON.parse(await fs.readFile(path.join(this.taskRoot(record.taskId), record.resultRelativePath), "utf8")) as unknown;
}
private async syncFailedModelPairs(record: PortraitTaskRecord, owner: { keyId: string; origin: string }, value: unknown) {
const parsed = portraitClearanceResultSchema.safeParse(value);
if (!parsed.success || !record.modelJobs?.some((job) => job.status === "failed" && job.errorMessage)) return value;
let changed = false;
const pairs = parsed.data.pairs.map((pair) => {
const job = record.modelJobs?.find((candidate) => candidate.status === "failed" && candidate.pairId === pair.id && candidate.errorMessage);
if (!job || pair.status === "failed" && pair.error?.code === job.errorCode) return pair;
changed = true;
return { ...pair, status: "failed" as const, riskLevel: "unable_to_determine" as const, analysisPath: "unable" as const, overallSimilarity: undefined, visionComparison: undefined, basis: [...pair.basis, job.errorMessage!].slice(0, 32), limitations: [...new Set([...pair.limitations, "该候选未完成视觉模型比对,已从本批次跳过。"])].slice(0, 32), error: { code: job.errorCode || "portrait_model_job_failed", message: job.errorMessage!, retryable: false } };
});
if (!changed) return parsed.data;
const riskSummary = summarizeRisk(pairs);
const next = portraitClearanceResultSchema.parse({ ...parsed.data, highestRisk: riskSummary.highestRisk, riskCounts: riskSummary.riskCounts, comparedCount: pairs.filter((pair) => pair.status !== "failed").length, pairs });
await atomicWrite(path.join(this.taskRoot(record.taskId), record.resultRelativePath || "results/clearance-result.json"), JSON.stringify(next, null, 2));
if (record.status === "partial" || record.status === "completed") {
await this.refreshReports(record.taskId, owner, next).catch(() => undefined);
}
return next;
}
async writeReports(taskId: string, owner: { keyId: string; origin: string }, reports: { markdown: string; html: string; docx: Uint8Array }) {
return this.withTaskLock(taskId, () => this.writeReportsUnlocked(taskId, owner, reports));
}
private async writeReportsUnlocked(taskId: string, owner: { keyId: string; origin: string }, reports: { markdown: string; html: string; docx: Uint8Array }) {
const record = await this.get(taskId, owner);
const reportDirectory = path.join(this.taskRoot(record.taskId), "reports");
await fs.mkdir(reportDirectory, { recursive: true });
await atomicWrite(path.join(reportDirectory, "clearance-report.md"), reports.markdown);
await atomicWrite(path.join(reportDirectory, "clearance-report.html"), reports.html);
await atomicWriteBytes(path.join(reportDirectory, "clearance-report.docx"), reports.docx);
const reportRelativePaths = { md: "reports/clearance-report.md", html: "reports/clearance-report.html", docx: "reports/clearance-report.docx" } as const;
return this.updateUnlocked(taskId, owner, { reportRelativePaths });
}
async readReport(taskId: string, owner: { keyId: string; origin: string }, artifactId: string) {
const record = await this.get(taskId, owner);
const relativePath = artifactId === "clearance-report.md" ? record.reportRelativePaths?.md : artifactId === "clearance-report.html" ? record.reportRelativePaths?.html : artifactId === "clearance-report.docx" ? record.reportRelativePaths?.docx : undefined;
if (!relativePath) throw new PortraitTaskStoreError("portrait_artifact_not_found", "报告不存在", 404);
const mimeType = artifactId.endsWith(".md") ? "text/markdown; charset=utf-8" : artifactId.endsWith(".html") ? "text/html; charset=utf-8" : "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
const reportPath = path.join(this.taskRoot(record.taskId), relativePath);
if (artifactId === "clearance-report.html") {
const existing = await fs.readFile(reportPath, "utf8").catch(() => "");
if (!existing.includes('data-report-version="2"')) {
await this.refreshReports(taskId, owner, portraitClearanceResultSchema.parse(await this.readResult(taskId, owner)));
}
}
return { mimeType, bytes: await fs.readFile(reportPath) };
}
async ensureModelJobs(taskId: string, owner: { keyId: string; origin: string }) {
return this.withTaskLock(taskId, () => this.ensureModelJobsUnlocked(taskId, owner));
}
private async ensureModelJobsUnlocked(taskId: string, owner: { keyId: string; origin: string }) {
const record = await this.get(taskId, owner);
if (record.modelJobs?.length) return record.modelJobs;
const result = portraitClearanceResultSchema.parse(await this.readStoredResult(record));
const modelJobs = result.pairs.map((pair) => portraitModelJobSchema.parse({
jobId: `portrait-job-${crypto.randomBytes(16).toString("hex")}`,
taskId: record.taskId,
pairId: pair.id,
queryImageId: pair.queryImageId,
comparisonImageId: pair.comparisonImageId,
status: "pending",
attempt: 0,
}));
const next = { ...record, modelJobs, updatedAt: new Date().toISOString() };
await this.writeRecord(next);
await this.appendEvent(next, "state");
return modelJobs;
}
async claimModelJob(taskId: string, owner: { keyId: string; origin: string }): Promise<PortraitModelJob | undefined> {
return this.withTaskLock(taskId, () => this.claimModelJobUnlocked(taskId, owner));
}
private async claimModelJobUnlocked(taskId: string, owner: { keyId: string; origin: string }): Promise<PortraitModelJob | undefined> {
const record = await this.get(taskId, owner);
const modelJobs = record.modelJobs || [];
const now = Date.now();
let changed = false;
const exhausted = modelJobs.find((job) => job.status === "leased" && job.attempt > MAX_MODEL_JOB_ATTEMPTS && job.leaseToken && job.leaseExpiresAt && Date.parse(job.leaseExpiresAt) > now);
if (exhausted?.leaseToken) {
exhausted.status = "failed";
exhausted.errorCode = "portrait_model_attempts_exhausted";
exhausted.errorMessage = "该候选视觉模型多次未完成,已跳过此候选";
exhausted.leaseToken = undefined;
exhausted.leaseExpiresAt = undefined;
changed = true;
}
for (const job of modelJobs) {
if (job.status === "leased" && (!job.leaseExpiresAt || Date.parse(job.leaseExpiresAt) <= now)) {
job.status = "pending";
job.leaseToken = undefined;
job.leaseExpiresAt = undefined;
changed = true;
}
}
const job = modelJobs.find((candidate) => candidate.status === "pending");
if (!job) {
if (changed) await this.saveModelJobs(record, modelJobs);
return undefined;
}
job.status = "leased";
job.attempt += 1;
job.leaseToken = crypto.randomBytes(24).toString("base64url");
job.leaseExpiresAt = new Date(now + MODEL_JOB_LEASE_MS).toISOString();
await this.saveModelJobs(record, modelJobs);
return job;
}
async completeModelJob(taskId: string, owner: { keyId: string; origin: string }, jobId: string, request: unknown) {
return this.withTaskLock(taskId, () => this.completeModelJobUnlocked(taskId, owner, jobId, request));
}
private async completeModelJobUnlocked(taskId: string, owner: { keyId: string; origin: string }, jobId: string, request: unknown) {
const parsed = portraitModelJobCompleteRequestSchema.parse(request);
const record = await this.get(taskId, owner);
if (record.status === "cancelled") throw new PortraitTaskStoreError("portrait_model_job_conflict", "任务已停止,不能继续提交视觉模型结果", 409);
const jobs = record.modelJobs || [];
const job = jobs.find((candidate) => candidate.jobId === jobId);
if (!job) throw new PortraitTaskStoreError("portrait_model_job_not_found", "模型作业不存在", 404);
if (job.status === "completed") return { record, result: await this.readStoredResult(record), job };
assertLeasedJob(job, parsed.attempt, parsed.leaseToken);
const result = portraitClearanceResultSchema.parse(await this.readStoredResult(record));
const pair = result.pairs.find((candidate) => candidate.id === job.pairId);
if (!pair) throw new PortraitTaskStoreError("portrait_model_job_conflict", "模型作业对应的比对结果不存在", 409);
const vision = applyStylizedRiskFloor(parsed.visionComparison);
const nextPair = { ...pair, status: vision.status === "success" ? "success" as const : "partial" as const, riskLevel: vision.riskLevel, overallSimilarity: vision.overallSimilarity, analysisPath: vision.analysisPath, visionComparison: vision, basis: [...pair.basis, ...vision.basis].slice(0, 32), limitations: [...pair.limitations, ...vision.limitations].slice(0, 32) };
const nextPairs = result.pairs.map((candidate) => candidate.id === pair.id ? nextPair : candidate);
const riskSummary = summarizeRisk(nextPairs);
const nextResult = portraitClearanceResultSchema.parse({ ...result, highestRisk: riskSummary.highestRisk, riskCounts: riskSummary.riskCounts, pairs: nextPairs, completedAt: undefined });
await atomicWrite(path.join(this.taskRoot(record.taskId), record.resultRelativePath || "results/clearance-result.json"), JSON.stringify(nextResult, null, 2));
job.status = "completed";
job.leaseToken = undefined;
job.leaseExpiresAt = undefined;
await this.saveModelJobs(record, jobs);
const complete = jobs.every((candidate) => candidate.status === "completed" || candidate.status === "failed");
const completedAt = complete ? new Date().toISOString() : undefined;
const finalResult = completedAt ? portraitClearanceResultSchema.parse({ ...nextResult, completedAt }) : nextResult;
if (completedAt) {
await atomicWrite(path.join(this.taskRoot(record.taskId), record.resultRelativePath || "results/clearance-result.json"), JSON.stringify(finalResult, null, 2));
await this.refreshReports(taskId, owner, finalResult);
}
const updated = await this.updateUnlocked(taskId, owner, complete ? { status: jobs.some((candidate) => candidate.status === "failed") || finalResult.pairs.some((candidate) => candidate.status !== "success") ? "partial" : "completed", stage: "done", progress: 1, processedCandidates: finalResult.pairs.length, completedAt } : { processedCandidates: jobs.filter((candidate) => candidate.status === "completed").length, progress: 0.92 + (jobs.filter((candidate) => candidate.status === "completed").length / Math.max(1, jobs.length)) * 0.08, stage: "model-comparing", status: "waiting_model" });
return { record: updated, result: finalResult, job };
}
async failModelJob(taskId: string, owner: { keyId: string; origin: string }, jobId: string, request: unknown) {
return this.withTaskLock(taskId, () => this.failModelJobUnlocked(taskId, owner, jobId, request));
}
private async failModelJobUnlocked(taskId: string, owner: { keyId: string; origin: string }, jobId: string, request: unknown) {
const parsed = portraitModelJobFailRequestSchema.parse(request);
const record = await this.get(taskId, owner);
if (record.status === "cancelled") throw new PortraitTaskStoreError("portrait_model_job_conflict", "任务已停止,不能继续提交视觉模型结果", 409);
const jobs = record.modelJobs || [];
const job = jobs.find((candidate) => candidate.jobId === jobId);
if (!job) throw new PortraitTaskStoreError("portrait_model_job_not_found", "模型作业不存在", 404);
if (job.status === "failed") return { record, job };
assertLeasedJob(job, parsed.attempt, parsed.leaseToken);
let result = portraitClearanceResultSchema.parse(await this.readStoredResult(record));
if (parsed.retryable && job.attempt < 3) {
job.status = "pending";
job.leaseToken = undefined;
job.leaseExpiresAt = undefined;
} else {
job.status = "failed";
job.errorCode = parsed.errorCode;
job.errorMessage = parsed.errorMessage;
job.leaseToken = undefined;
job.leaseExpiresAt = undefined;
const nextPairs = result.pairs.map((pair) => {
if (pair.id !== job.pairId) return pair;
return { ...pair, status: "failed" as const, riskLevel: "unable_to_determine" as const, analysisPath: "unable" as const, basis: [...pair.basis, parsed.errorMessage].slice(0, 32), limitations: [...pair.limitations, "该候选未完成视觉模型比对,已从本批次跳过。"].slice(0, 32), error: { code: parsed.errorCode, message: parsed.errorMessage, retryable: false } };
});
const riskSummary = summarizeRisk(nextPairs);
result = portraitClearanceResultSchema.parse({ ...result, highestRisk: riskSummary.highestRisk, riskCounts: riskSummary.riskCounts, pairs: nextPairs, completedAt: undefined });
await atomicWrite(path.join(this.taskRoot(record.taskId), record.resultRelativePath || "results/clearance-result.json"), JSON.stringify(result, null, 2));
}
await this.saveModelJobs(record, jobs);
const complete = jobs.every((candidate) => candidate.status === "completed" || candidate.status === "failed");
if (complete) {
const modelLimitations = jobs.filter((candidate) => candidate.status === "failed" && candidate.errorMessage).map((candidate) => `视觉模型作业未完成:${candidate.errorMessage}`).slice(0, 16);
const finalResult = portraitClearanceResultSchema.parse({
...result,
limitations: [...result.limitations, ...modelLimitations].slice(0, 32),
completedAt: new Date().toISOString(),
});
await atomicWrite(path.join(this.taskRoot(record.taskId), record.resultRelativePath || "results/clearance-result.json"), JSON.stringify(finalResult, null, 2));
await this.refreshReports(taskId, owner, finalResult);
}
const updated = complete ? await this.updateUnlocked(taskId, owner, { status: "partial", stage: "done", progress: 1, completedAt: new Date().toISOString() }) : await this.updateUnlocked(taskId, owner, { status: "waiting_model", stage: "waiting-for-model" });
return { record: updated, job };
}
async readInput(taskId: string, owner: { keyId: string; origin: string }, inputId: string) {
const record = await this.get(taskId, owner);
const input = record.inputs.find((candidate) => candidate.id === inputId);
if (!input) throw new PortraitTaskStoreError("portrait_input_invalid", "任务输入不存在", 404);
return { input, bytes: await fs.readFile(path.join(this.taskRoot(record.taskId), input.relativePath)) };
}
async writeCandidate(taskId: string, owner: { keyId: string; origin: string }, artifactId: string, extension: "jpg" | "png" | "webp", bytes: Uint8Array) {
const record = await this.get(taskId, owner);
if (!/^candidate-[A-Za-z0-9._:-]{1,120}$/.test(artifactId)) throw new PortraitTaskStoreError("portrait_input_invalid", "候选文件标识无效", 400);
const relativePath = path.join("candidates", `${artifactId}.${extension}`);
const filePath = path.join(this.taskRoot(record.taskId), relativePath);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await fs.writeFile(filePath, bytes, { flag: "wx" });
return { relativePath };
}
async readImageArtifact(taskId: string, owner: { keyId: string; origin: string }, artifactId: string) {
const record = await this.get(taskId, owner);
const input = record.inputs.find((candidate) => candidate.id === artifactId);
if (input) return { mimeType: input.mimeType, bytes: await fs.readFile(path.join(this.taskRoot(record.taskId), input.relativePath)) };
if (!/^candidate-[A-Za-z0-9._:-]{1,120}$/.test(artifactId)) throw new PortraitTaskStoreError("portrait_input_invalid", "图片资源不存在", 404);
const directory = path.join(this.taskRoot(record.taskId), "candidates");
const entries = await fs.readdir(directory).catch(() => [] as string[]);
const fileName = entries.find((entry) => entry.startsWith(`${artifactId}.`));
if (!fileName) throw new PortraitTaskStoreError("portrait_input_invalid", "图片资源不存在", 404);
const extension = path.extname(fileName).slice(1);
const mimeType = extension === "jpg" ? "image/jpeg" : extension === "png" ? "image/png" : "image/webp";
return { mimeType, bytes: await fs.readFile(path.join(directory, fileName)) };
}
async requestCancel(taskId: string, owner: { keyId: string; origin: string }) {
return this.withTaskLock(taskId, async () => {
const record = await this.get(taskId, owner);
if (isTerminal(record.status)) return record;
const next = await this.updateUnlocked(taskId, owner, { cancelRequested: true, status: "cancelled", stage: "done", errorCode: "portrait_task_cancelled", errorMessage: "任务已停止", completedAt: new Date().toISOString() });
this.activeControllers.get(taskId)?.abort();
return next;
});
}
async delete(taskId: string, owner: { keyId: string; origin: string }) {
await this.withTaskLock(taskId, async () => {
const record = await this.get(taskId, owner);
if (!isTerminal(record.status)) {
await this.updateUnlocked(taskId, owner, { cancelRequested: true, status: "cancelled", stage: "done", errorCode: "portrait_task_cancelled", errorMessage: "任务已停止", completedAt: new Date().toISOString() });
}
this.deleting.add(taskId);
this.activeControllers.get(taskId)?.abort();
});
try {
await this.active.get(taskId)?.catch(() => undefined);
return await this.withTaskLock(taskId, async () => {
const record = await this.get(taskId, owner);
await fs.rm(this.taskRoot(record.taskId), { recursive: true, force: false }).catch(() => { throw new PortraitTaskStoreError("portrait_task_delete_failed", "删除本地排查数据失败", 500); });
return { deleted: true as const };
});
} finally {
this.active.delete(taskId);
this.activeControllers.delete(taskId);
this.deleting.delete(taskId);
}
}
async events(taskId: string, owner: { keyId: string; origin: string }, afterId?: string) {
const record = await this.get(taskId, owner);
const eventPath = path.join(this.taskRoot(record.taskId), "events.ndjson");
let lines: string[] = [];
try { lines = (await fs.readFile(eventPath, "utf8")).split("\n").filter(Boolean); } catch { return []; }
const events = lines.map((line) => JSON.parse(line) as PortraitTaskEvent);
return afterId ? events.filter((event) => event.id > afterId) : events;
}
async inputPath(taskId: string, owner: { keyId: string; origin: string }, inputId: string) {
const record = await this.get(taskId, owner);
const input = record.inputs.find((candidate) => candidate.id === inputId);
if (!input) throw new PortraitTaskStoreError("portrait_input_invalid", "任务输入不存在", 404);
return path.join(this.taskRoot(taskId), input.relativePath);
}
async writeModelInput(taskId: string, owner: { keyId: string; origin: string }, name: "query-search.jpg", bytes: Uint8Array) {
const record = await this.get(taskId, owner);
const relativePath = path.join("model-inputs", name);
const filePath = path.join(this.taskRoot(record.taskId), relativePath);
await fs.mkdir(path.dirname(filePath), { recursive: true });
await atomicWriteBytes(filePath, bytes);
return filePath;
}
start(taskId: string, runner: (record: PortraitTaskRecord, signal: AbortSignal) => Promise<void>) {
if (this.active.has(taskId) || this.deleting.has(taskId)) return;
const controller = new AbortController();
const promise = this.readRecord(taskId).then((record) => runner(record, controller.signal)).finally(() => {
if (this.active.get(taskId) === promise) this.active.delete(taskId);
if (this.activeControllers.get(taskId) === controller) this.activeControllers.delete(taskId);
});
this.active.set(taskId, promise);
this.activeControllers.set(taskId, controller);
void promise.catch(() => undefined);
}
isRunning(taskId: string) {
return this.active.has(taskId);
}
private async findByOperation(request: CreatePortraitClearanceTaskRequest, owner: { keyId: string; origin: string }) {
const record = (await this.listAll()).find((candidate) => candidate.clientOperationId === request.clientOperationId);
if (!record) return undefined;
if (record.keyId !== owner.keyId || record.origin !== owner.origin || record.ownerScopeHash !== request.ownerScopeHash || record.projectId !== request.projectId || record.nodeId !== request.nodeId) throw new PortraitTaskStoreError("portrait_idempotency_conflict", "肖像排查幂等键不匹配", 409);
return record;
}
private async listAll() {
await fs.mkdir(this.tasksRoot(), { recursive: true });
const entries = await fs.readdir(this.tasksRoot(), { withFileTypes: true });
const records: PortraitTaskRecord[] = [];
for (const entry of entries) {
if (!entry.isDirectory()) continue;
try { records.push(await this.readRecord(entry.name)); } catch { /* ignore torn/foreign directories */ }
}
return records;
}
private async withTaskLock<T>(taskId: string, action: () => Promise<T>): Promise<T> {
const previous = this.taskLocks.get(taskId) || Promise.resolve();
let release!: () => void;
const gate = new Promise<void>((resolve) => { release = resolve; });
const queued = previous.then(() => gate);
this.taskLocks.set(taskId, queued);
await previous;
try {
return await action();
} finally {
release();
if (this.taskLocks.get(taskId) === queued) this.taskLocks.delete(taskId);
}
}
private async readRecord(taskId: string) {
if (!/^[a-z0-9-]{16,80}$/.test(taskId)) throw new PortraitTaskStoreError("portrait_task_not_found", "任务不存在", 404);
try {
const value = JSON.parse(await fs.readFile(path.join(this.taskRoot(taskId), "task.json"), "utf8")) as PortraitTaskRecord;
toSummary(value);
if (value.schemaVersion !== 1 || value.ownerId !== this.runtimeOwnerId || !Array.isArray(value.inputs)) throw new Error();
return value;
} catch (error) {
if (error instanceof PortraitTaskStoreError) throw error;
throw new PortraitTaskStoreError("portrait_task_not_found", "任务不存在", 404);
}
}
private assertOwner(record: PortraitTaskRecord, owner: { keyId: string; origin: string }) {
if (record.ownerId !== this.runtimeOwnerId || record.keyId !== owner.keyId || record.origin !== owner.origin) throw new PortraitTaskStoreError("portrait_task_forbidden", "无权访问该本地排查任务", 403);
}
private tasksRoot() { return path.join(this.root, "tasks"); }
private taskRoot(taskId: string) { return path.join(this.tasksRoot(), taskId); }
private async writeRecord(record: PortraitTaskRecord) {
await atomicWrite(path.join(this.taskRoot(record.taskId), "task.json"), JSON.stringify(record, null, 2));
}
private async appendEvent(record: PortraitTaskRecord, type: PortraitTaskEvent["type"]) {
const event: PortraitTaskEvent = { id: `${record.updatedAt}:${crypto.randomBytes(4).toString("hex")}`, taskId: record.taskId, type, at: record.updatedAt, summary: toSummary(record) };
await fs.appendFile(path.join(this.taskRoot(record.taskId), "events.ndjson"), `${JSON.stringify(event)}\n`, "utf8");
}
private async saveModelJobs(record: PortraitTaskRecord, modelJobs: PortraitModelJob[]) {
const next = { ...record, modelJobs: modelJobs.map((job) => portraitModelJobSchema.parse(job)), updatedAt: new Date().toISOString() };
await this.writeRecord(next);
await this.appendEvent(next, "state");
}
private async refreshReports(taskId: string, owner: { keyId: string; origin: string }, result: ReturnType<typeof portraitClearanceResultSchema.parse>) {
const imageIds = new Set([result.queryImageId, ...result.candidates.map((candidate) => candidate.imageArtifactId)]);
const images = [];
for (const imageId of imageIds) {
try {
const image = await this.readImageArtifact(taskId, owner, imageId);
images.push({ id: imageId, mimeType: image.mimeType as "image/jpeg" | "image/png" | "image/webp", bytes: image.bytes });
} catch {
// A report remains useful when a deleted or unavailable candidate image cannot be embedded.
}
}
await this.writeReportsUnlocked(taskId, owner, await buildPortraitReports(result, images));
}
}
export function toSummary(record: PortraitTaskRecord): PortraitTaskSummary {
return portraitTaskSummarySchema.parse({
taskId: record.taskId,
clientOperationId: record.clientOperationId,
ownerScopeHash: record.ownerScopeHash,
projectId: record.projectId,
nodeId: record.nodeId,
mode: record.mode,
analysisMode: record.analysisMode,
...(record.modelRef ? { modelRef: record.modelRef } : {}),
status: record.status,
stage: record.stage,
progress: record.progress,
processedCandidates: record.processedCandidates,
...(record.totalCandidates === undefined ? {} : { totalCandidates: record.totalCandidates }),
...(record.errorCode ? { errorCode: record.errorCode } : {}),
...(record.errorMessage ? { errorMessage: record.errorMessage } : {}),
createdAt: record.createdAt,
updatedAt: record.updatedAt,
...(record.completedAt ? { completedAt: record.completedAt } : {}),
detailsAvailable: record.detailsAvailable,
});
}
export function isTerminal(status: PortraitClearanceTaskStatus) {
return status === "partial" || status === "completed" || status === "failed" || status === "cancelled";
}
export function decodePortraitDataUrl(value: string, expectedMime: PortraitTaskInputFile["mimeType"]) {
const match = /^data:(image\/(?:jpeg|png|webp));base64,([A-Za-z0-9+/=_-]+)$/.exec(value);
if (!match || match[1] !== expectedMime) throw new PortraitTaskStoreError("portrait_input_invalid", "输入图片格式无效", 400);
const data = Buffer.from(match[2]!, "base64");
if (!data.byteLength || data.byteLength > MAX_INPUT_BYTES) throw new PortraitTaskStoreError("portrait_input_invalid", "输入图片超过本机大小限制", 413);
return data;
}
async function persistInputs(taskRoot: string, inputs: CreatePortraitClearanceTaskRequest["inputs"]): Promise<PortraitTaskInputFile[]> {
let totalBytes = 0;
const files: PortraitTaskInputFile[] = [];
for (const [index, input] of inputs.entries()) {
const parsed = portraitClearanceInputSchema.parse(input);
const bytes = decodePortraitDataUrl(parsed.dataUrl, parsed.mimeType);
totalBytes += bytes.byteLength;
if (totalBytes > MAX_INPUT_BYTES) throw new PortraitTaskStoreError("portrait_input_invalid", "任务输入总大小超过本机限制", 413);
const id = `input-${index + 1}`;
const extension = parsed.mimeType === "image/jpeg" ? "jpg" : parsed.mimeType.slice("image/".length);
const relativePath = path.join("inputs", `${id}.${extension}`);
await fs.writeFile(path.join(taskRoot, relativePath), bytes, { flag: "wx" });
files.push({ id, nodeId: parsed.nodeId, role: parsed.role, fileName: parsed.fileName, mimeType: parsed.mimeType, relativePath });
}
return files;
}
async function atomicWrite(filePath: string, contents: string) {
await fs.mkdir(path.dirname(filePath), { recursive: true });
const temporary = `${filePath}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
await fs.writeFile(temporary, contents, { encoding: "utf8", flag: "wx" });
await replaceFile(temporary, filePath);
}
async function atomicWriteBytes(filePath: string, contents: Uint8Array) {
await fs.mkdir(path.dirname(filePath), { recursive: true });
const temporary = `${filePath}.tmp-${process.pid}-${crypto.randomBytes(4).toString("hex")}`;
await fs.writeFile(temporary, contents, { flag: "wx" });
await replaceFile(temporary, filePath);
}
async function replaceFile(temporary: string, target: string) {
try {
await fs.rename(temporary, target);
return;
} catch (renameError) {
// Windows can reject replacing a file that is briefly held by an
// antivirus/indexer. Copying the complete temp file keeps the write
// recoverable without deleting the previous durable record first.
try {
await fs.copyFile(temporary, target);
} catch {
throw renameError;
}
await fs.rm(temporary, { force: true }).catch(() => undefined);
}
}
function assertLeasedJob(job: PortraitModelJob, attempt: number, leaseToken: string) {
if (job.status !== "leased" || job.attempt !== attempt || job.leaseToken !== leaseToken || !job.leaseExpiresAt || Date.parse(job.leaseExpiresAt) <= Date.now()) throw new PortraitTaskStoreError("portrait_model_job_conflict", "模型作业租约已失效,请重新领取", 409);
}