feat: add workflow orchestration and media governance
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user