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 }; }