Files
ai-drama-platform/server/workflows.mjs
T

168 lines
10 KiB
JavaScript

import { createGenerationJob } from "./execution.mjs";
import { dbAll, dbGet, dbRun, withTransaction } from "./db.mjs";
import { addAudit, httpError } from "./tenant.mjs";
const now = () => new Date().toISOString();
const makeId = (prefix) => `${prefix}-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`;
function parseJson(value, fallback) {
try { return JSON.parse(value); } catch { return fallback; }
}
function normalizeStep(step, index) {
if (typeof step === "string") return { key: `step-${index + 1}`, label: step, jobKind: "", requiresShot: false, dependsOnPrevious: index > 0 };
return {
key: String(step?.key || `step-${index + 1}`),
label: String(step?.label || step?.name || `步骤 ${index + 1}`),
jobKind: String(step?.jobKind || step?.job_kind || ""),
requiresShot: Boolean(step?.requiresShot ?? step?.requires_shot),
dependsOnPrevious: Boolean(step?.dependsOnPrevious ?? step?.depends_on_previous ?? index > 0),
optional: Boolean(step?.optional)
};
}
function templatePayload(row) {
if (!row) return null;
return {
...row,
organizationId: row.organization_id,
workspaceId: row.workspace_id,
templateKey: row.template_key,
version: Number(row.version_number || 1),
steps: parseJson(row.steps_json, []).map(normalizeStep),
gates: parseJson(row.gates_json, []),
defaultAdapterId: row.default_adapter_id,
scope: row.workspace_id ? "workspace" : row.organization_id ? "organization" : "global"
};
}
function scopedTemplateRows(context, includeArchived = false) {
const statusClause = includeArchived ? "1=1" : "status <> 'archived'";
return dbAll(
`SELECT * FROM workflow_templates
WHERE ${statusClause} AND
((organization_id IS NULL AND workspace_id IS NULL)
OR (organization_id = ? AND workspace_id IS NULL)
OR (organization_id = ? AND workspace_id = ?))
ORDER BY CASE WHEN workspace_id = ? THEN 0 WHEN organization_id = ? THEN 1 ELSE 2 END, version_number DESC, updated_at DESC`,
[context.organization.id, context.organization.id, context.workspace.id, context.workspace.id, context.organization.id]
);
}
export function listWorkflowTemplates(context, options = {}) {
const seen = new Set();
return scopedTemplateRows(context, Boolean(options.includeArchived)).map(templatePayload).filter((template) => {
if (seen.has(template.templateKey)) return false;
seen.add(template.templateKey);
return true;
});
}
function templateForContext(context, templateId) {
const row = dbGet(
`SELECT * FROM workflow_templates
WHERE id = ? AND ((organization_id IS NULL AND workspace_id IS NULL) OR (organization_id = ? AND workspace_id IS NULL) OR (organization_id = ? AND workspace_id = ?))`,
[templateId, context.organization.id, context.organization.id, context.workspace.id]
);
if (!row) throw httpError(404, "workflow_template_not_found", "流程模板不存在或不属于当前工作区");
return row;
}
function validateTemplateBody(body = {}) {
const name = String(body.name || "").trim();
const templateKey = String(body.templateKey || body.template_key || "").trim().toLowerCase();
if (!name || !templateKey) throw httpError(400, "workflow_template_fields_required", "流程模板名称和唯一键不能为空");
if (!/^[a-z0-9][a-z0-9._-]{1,80}$/.test(templateKey)) throw httpError(400, "workflow_template_key_invalid", "流程模板唯一键只能使用小写字母、数字、点、短横线和下划线");
const steps = (Array.isArray(body.steps) ? body.steps : []).slice(0, 32).map(normalizeStep);
if (!steps.length) throw httpError(400, "workflow_template_steps_required", "流程模板至少需要一个步骤");
const status = String(body.status || "draft");
if (!["draft", "active", "archived"].includes(status)) throw httpError(400, "workflow_template_status_invalid", "流程模板状态无效");
return { name, templateKey, category: String(body.category || templateKey), status, description: String(body.description || "").trim(), steps, gates: Array.isArray(body.gates) ? body.gates.slice(0, 32) : [], defaultAdapterId: String(body.defaultAdapterId || body.default_adapter_id || "owned-model-platform"), requestedVersion: Number(body.version || 0) };
}
export function createWorkflowTemplate(context, body = {}) {
const values = validateTemplateBody(body);
const version = values.requestedVersion > 0 ? Math.floor(values.requestedVersion) : Number(dbGet("SELECT MAX(version_number) AS value FROM workflow_templates WHERE organization_id = ? AND workspace_id = ? AND template_key = ?", [context.organization.id, context.workspace.id, values.templateKey])?.value || 0) + 1;
const id = makeId("workflow-template");
const timestamp = now();
dbRun(
`INSERT INTO workflow_templates(id, organization_id, workspace_id, template_key, version_number, name, category, status, description, steps_json, gates_json, default_adapter_id, created_by, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[id, context.organization.id, context.workspace.id, values.templateKey, version, values.name, values.category, values.status, values.description, JSON.stringify(values.steps), JSON.stringify(values.gates), values.defaultAdapterId, context.user.id, timestamp, timestamp]
);
addAudit({ context, action: "workflow.template.created", targetType: "workflow_template", targetId: id, metadata: { templateKey: values.templateKey, version, status: values.status } });
return templatePayload(dbGet("SELECT * FROM workflow_templates WHERE id = ?", [id]));
}
export function updateWorkflowTemplate(context, templateId, body = {}) {
const current = templateForContext(context, templateId);
if (!current.organization_id || current.workspace_id !== context.workspace.id) throw httpError(403, "workflow_template_global_locked", "内置全局模板不能直接修改,请创建当前工作区的新版本");
const values = validateTemplateBody({ ...templatePayload(current), ...body, steps: body.steps || templatePayload(current).steps });
const timestamp = now();
dbRun("UPDATE workflow_templates SET name = ?, category = ?, status = ?, description = ?, steps_json = ?, gates_json = ?, default_adapter_id = ?, updated_at = ? WHERE id = ?", [values.name, values.category, values.status, values.description, JSON.stringify(values.steps), JSON.stringify(values.gates), values.defaultAdapterId, timestamp, templateId]);
addAudit({ context, action: "workflow.template.updated", targetType: "workflow_template", targetId: templateId, metadata: { status: values.status, version: current.version_number } });
return templatePayload(dbGet("SELECT * FROM workflow_templates WHERE id = ?", [templateId]));
}
function runPayload(row) {
if (!row) return null;
return {
...row,
templateId: row.template_id,
episodeId: row.episode_id,
shotId: row.shot_id,
currentStepIndex: Number(row.current_step_index || 0),
steps: parseJson(row.steps_json, []),
jobIds: parseJson(row.job_ids_json, [])
};
}
export function listWorkflowRuns(context, options = {}) {
const params = [context.organization.id, context.workspace.id, context.project?.id || ""];
let where = "organization_id = ? AND workspace_id = ? AND project_id = ?";
if (options.status) { where += " AND status = ?"; params.push(String(options.status)); }
return dbAll(`SELECT * FROM workflow_runs WHERE ${where} ORDER BY created_at DESC LIMIT ?`, [...params, Math.max(1, Math.min(100, Number(options.limit || 50)))]).map(runPayload);
}
export async function instantiateWorkflow(context, templateId, body = {}) {
if (!context.project) throw httpError(400, "project_required", "执行流程模板必须绑定项目");
const template = templatePayload(templateForContext(context, templateId));
if (template.status !== "active") throw httpError(409, "workflow_template_not_active", "只有启用状态的流程模板可以进入生产");
const shotId = String(body.shotId || body.shot_id || "").trim();
const mode = body.mode === "queue" ? "queue" : "plan";
const steps = template.steps.map((step) => ({ ...step, status: step.jobKind ? "planned" : "skipped", jobId: "", errorMessage: "" }));
if (steps.some((step) => step.requiresShot && !shotId)) throw httpError(400, "workflow_shot_required", "当前流程包含镜头级步骤,请指定一个镜头后再执行");
const runId = makeId("workflow-run");
const timestamp = now();
const jobIds = [];
let previousJobId = "";
if (mode === "queue") {
for (const step of steps) {
if (!step.jobKind) continue;
try {
const result = await createGenerationJob(context, {
kind: step.jobKind,
adapter: body.adapter || template.defaultAdapterId,
shotId: shotId || undefined,
dependsOnJobIds: step.dependsOnPrevious && previousJobId ? [previousJobId] : [],
approveExternal: Boolean(body.approveExternal),
priority: body.priority || 50
});
step.status = result.job.status === "blocked" ? "blocked" : "queued";
step.jobId = result.job.id;
previousJobId = result.job.id;
jobIds.push(result.job.id);
} catch (error) {
step.status = "blocked";
step.errorMessage = error.message;
}
}
}
const status = mode === "plan" ? "planned" : steps.some((step) => step.status === "blocked") ? "blocked" : "queued";
withTransaction(() => {
dbRun("INSERT INTO workflow_runs(id, organization_id, workspace_id, project_id, episode_id, shot_id, template_id, status, current_step_index, steps_json, job_ids_json, error_message, created_by, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?)", [runId, context.organization.id, context.workspace.id, context.project.id, body.episodeId || body.episode_id || null, shotId || null, template.id, status, JSON.stringify(steps), JSON.stringify(jobIds), steps.find((step) => step.errorMessage)?.errorMessage || "", context.user.id, timestamp, timestamp]);
});
addAudit({ context, action: "workflow.run.created", targetType: "workflow_run", targetId: runId, result: status === "blocked" ? "blocked" : "ok", metadata: { templateId: template.id, mode, shotId, jobIds, status } });
return { run: runPayload(dbGet("SELECT * FROM workflow_runs WHERE id = ?", [runId])), template, jobs: jobIds };
}