feat: add workflow orchestration and media governance

This commit is contained in:
xz
2026-08-24 22:36:43 +08:00
parent 9811469a64
commit aa0e0be27a
16 changed files with 775 additions and 13 deletions
+38 -3
View File
@@ -2,7 +2,7 @@ import { mkdir, readFile } from "node:fs/promises";
import { resolve } from "node:path";
import { DatabaseSync } from "node:sqlite";
import { randomBytes, scryptSync } from "node:crypto";
import { sampleProject } from "../src/data/sampleProject.js";
import { sampleProject, workflowTemplates } from "../src/data/sampleProject.js";
import { apiClientStorageMarker, hashApiClientKey } from "./api-client-secrets.mjs";
const serverRoot = resolve(import.meta.dirname);
@@ -219,6 +219,7 @@ function seedRoles() {
["project:create", "创建项目"],
["project:manage", "管理项目设置"],
["project:members:manage", "管理项目成员"],
["workflow:manage", "管理生产流程模板和版本"],
["task:view", "查看项目协作任务"],
["task:manage", "创建、分派和管理项目协作任务"],
["task:complete", "更新本人负责的协作任务状态"],
@@ -257,10 +258,10 @@ function seedRoles() {
org_admin: [
"organization:manage", "organization:members:invite", "workspace:create", "workspace:manage",
"workspace:members:manage", "project:create", "project:manage", "project:members:manage",
"task:view", "task:manage", "task:complete", "model:manage", "usage:view", "billing:manage", "quota:manage", "qa:review", "delivery:approve", "delivery:view", "compliance:manage", "audit:view", "queue:manage", "voice:approve",
"workflow:manage", "task:view", "task:manage", "task:complete", "model:manage", "usage:view", "billing:manage", "quota:manage", "qa:review", "delivery:approve", "delivery:view", "compliance:manage", "audit:view", "queue:manage", "voice:approve",
"system:settings:view", "service:health:view", "organization:roles:manage"
],
producer: ["project:create", "project:manage", "project:members:manage", "task:view", "task:manage", "task:complete", "script:read", "job:create", "job:prioritize", "usage:view", "delivery:approve", "delivery:view", "voice:approve"],
producer: ["project:create", "project:manage", "project:members:manage", "workflow:manage", "task:view", "task:manage", "task:complete", "script:read", "job:create", "job:prioritize", "usage:view", "delivery:approve", "delivery:view", "voice:approve"],
writer: ["script:read", "script:edit", "task:view", "task:complete", "job:create"],
art_director: ["asset:edit", "prompt:edit", "task:view", "task:complete", "job:create"],
voice_editor: ["voice:edit", "voice:approve", "task:view", "task:complete", "job:create"],
@@ -384,8 +385,11 @@ function seedSystemGovernance() {
["app.timezone", "general", "Asia/Shanghai", "string", "默认时区", 0],
["deployment.mode", "deployment", "private-local", "string", "部署模式", 0],
["deployment.local_runner_only", "deployment", true, "boolean", "默认只允许本地 Runner", 0],
["storage.provider", "storage", "local-filesystem", "string", "媒体文件存储提供方;生产可切换为 S3-compatible 适配层", 0],
["storage.root_path", "storage", resolve(projectRoot, "storage"), "path", "资产和生成产物根目录", 0],
["storage.max_upload_mb", "storage", 1024, "number", "单文件最大上传大小", 0],
["storage.retention_days", "storage", 30, "number", "无引用临时媒体的最短保留天数", 0],
["storage.cleanup_unreferenced_only", "storage", true, "boolean", "清理时只允许处理未被生产证据引用的临时文件", 0],
["queue.max_concurrency", "queue", 2, "number", "全局并发任务数", 0],
["generation.single_frame_only", "generation", true, "boolean", "一图一画面策略", 0],
["generation.require_actual_last_frame", "generation", true, "boolean", "视频片段必须使用真实末帧连续", 0],
@@ -433,6 +437,36 @@ function seedSystemGovernance() {
}
}
function workflowStepDefinition(label, index) {
const normalized = String(label || "");
let jobKind = "";
let requiresShot = false;
if (/静态图|分镜图|关键帧|插画/.test(normalized)) { jobKind = "单画面关键帧"; requiresShot = true; }
else if (/图生视频|视频生成|成片/.test(normalized)) { jobKind = "首尾帧图生视频"; requiresShot = true; }
else if (/旁白|配音|TTS/.test(normalized)) { jobKind = "固定 TTS 配音"; requiresShot = true; }
else if (/ASR|字幕|对齐/.test(normalized)) { jobKind = "ASR 台词校验"; requiresShot = true; }
else if (/合成/.test(normalized)) jobKind = "剪辑合成清单";
return { key: `step-${index + 1}`, label: normalized, jobKind, requiresShot, dependsOnPrevious: index > 0 };
}
function seedWorkflowTemplates() {
const timestamp = now();
for (const template of workflowTemplates) {
const status = template.status === "主流程" || template.status === "可用" ? "active" : "draft";
const steps = template.steps.map((label, index) => workflowStepDefinition(label, index));
const gates = [
{ key: "single-frame", label: "一图一画面", blocking: true },
{ key: "continuity-lock", label: "角色 / 场景 / 道具连续性", blocking: true },
{ key: "voice-subtitle-asr", label: "声音 / 字幕 / ASR 对齐", blocking: true },
{ key: "clip-bridge", label: "片段衔接 / 实际末帧", blocking: true }
];
insertIgnore(
"INSERT OR IGNORE 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 (?, NULL, NULL, ?, 1, ?, ?, ?, ?, ?, ?, 'owned-model-platform', 'u-owner', ?, ?)",
[`workflow-global-${template.id}-v1`, template.id, template.name, template.id, status, template.warning || "", JSON.stringify(steps), JSON.stringify(gates), timestamp, timestamp]
);
}
}
function seedUserNotifications() {
const timestamp = now();
insertIgnore(
@@ -583,6 +617,7 @@ withTransaction(() => {
seedMembersAndProjects();
seedModels();
seedSystemGovernance();
seedWorkflowTemplates();
seedProductionGraph();
seedUserNotifications();
});
+64 -2
View File
@@ -130,16 +130,17 @@ import {
probeModelConnector,
updateModelConnector
} from "./execution.mjs";
import { requireStorageQuota, storageSummary } from "./storage.mjs";
import { reclaimStorage, requireStorageQuota, storageCleanupPreview, storageSummary } from "./storage.mjs";
import { backupSummary, createDatabaseBackup } from "./backup.mjs";
import { systemReadiness } from "./readiness.mjs";
import { dispatchNotificationEvent, listUserNotificationPreferences, listUserNotifications, markAllUserNotificationsRead, markUserNotificationRead, notificationDeliveries, updateUserNotificationPreference } from "./notifications.mjs";
import { composeProject, listCompositions } from "./composition.mjs";
import { listProjectArtifacts } from "./media-artifacts.mjs";
import { listProjectArtifacts, readArtifactContent } from "./media-artifacts.mjs";
import { runWorkerOnce, startLocalWorker, workerStatus } from "./worker.mjs";
import { createSsoTicket, handleOidcCallback, redeemSsoTicket, startOidcLogin } from "./oidc.mjs";
import { handleSamlCallback, isSamlProvider, samlServiceProviderMetadata, startSamlLogin } from "./saml.mjs";
import { listWorkItems } from "./work-items.mjs";
import { createWorkflowTemplate, instantiateWorkflow, listWorkflowRuns, listWorkflowTemplates, updateWorkflowTemplate } from "./workflows.mjs";
import { addTaskLink, createProjectTask, createTaskComment, getProjectTask, listProjectActivity, listProjectTasks, listTaskComments, removeTaskLink, updateProjectTask } from "./tasks.mjs";
import { searchPlatform } from "./search.mjs";
import { consumeRateLimit, rateLimitHeaders, rateLimitIdentity } from "./rate-limit.mjs";
@@ -1909,6 +1910,20 @@ createServer(async (req, res) => {
return send(res, 200, { storage: await storageSummary(context) });
}
if (req.method === "GET" && pathname === "/api/system/storage/cleanup-preview") {
const context = resolveContext(req.headers, url.searchParams);
requirePermission(context, "system:settings:view");
return send(res, 200, { cleanup: await storageCleanupPreview(context, { olderThanDays: url.searchParams.get("olderThanDays") || "" }) });
}
if (req.method === "POST" && pathname === "/api/system/storage/reclaim") {
const context = resolveContext(req.headers, url.searchParams);
requirePermission(context, "system:settings:edit");
const result = await reclaimStorage(context, await readBody(req));
addAudit({ context, action: "system.storage.reclaimed", targetType: "storage_cleanup", targetId: `cleanup-${Date.now()}`, metadata: { deletedCount: result.deleted.length, deletedBytes: result.deletedBytes, retentionDays: result.policy.retentionDays } });
return send(res, 200, { cleanup: result, storage: await storageSummary(context) });
}
if (req.method === "GET" && pathname === "/api/billing") {
const context = resolveContext(req.headers, url.searchParams);
requirePermission(context, "usage:view");
@@ -2482,6 +2497,53 @@ createServer(async (req, res) => {
return send(res, 200, { artifacts: listProjectArtifacts(context, { shotId: url.searchParams.get("shotId") || "", jobId: url.searchParams.get("jobId") || "", limit: url.searchParams.get("limit") || 200 }) });
}
const mediaArtifactContentMatch = pathname.match(/^\/api\/production\/media-artifacts\/([^/]+)\/content$/);
if (req.method === "GET" && mediaArtifactContentMatch) {
const context = resolveContext(req.headers, url.searchParams);
if (!hasPermission(context, "delivery:view") && !hasPermission(context, "qa:review")) throw httpError(403, "permission_denied", "当前角色没有媒体证据访问权限");
try {
const payload = await readArtifactContent(context, decodeURIComponent(mediaArtifactContentMatch[1]), url.searchParams.get("frame") || "");
return sendBinary(res, 200, payload.content, payload.contentType, payload.fileName, { etag: `"${payload.contentSha256}"` });
} catch (error) {
if (error.message === "media_artifact_not_found") throw httpError(404, "media_artifact_not_found", "媒体证据不存在或不属于当前项目");
if (error.message === "media_artifact_path_invalid") throw httpError(422, "media_artifact_path_invalid", "媒体证据路径不是 storage/ 下的安全路径");
if (error.code === "ENOENT") throw httpError(404, "media_artifact_content_missing", "媒体文件尚未写入本地存储", { path: error.path || "" });
throw error;
}
}
if (req.method === "GET" && pathname === "/api/workflows/templates") {
const context = resolveContext(req.headers, url.searchParams);
if (!hasPermission(context, "workflow:manage") && !hasPermission(context, "job:create") && !hasPermission(context, "script:read") && !hasPermission(context, "project:create")) throw httpError(403, "permission_denied", "当前角色没有流程模板访问权限");
return send(res, 200, { templates: listWorkflowTemplates(context, { includeArchived: url.searchParams.get("includeArchived") === "1" }), runs: context.project ? listWorkflowRuns(context, { limit: 20 }) : [] });
}
if (req.method === "POST" && pathname === "/api/workflows/templates") {
const context = resolveContext(req.headers, url.searchParams);
requirePermission(context, "workflow:manage");
return send(res, 201, { template: createWorkflowTemplate(context, await readBody(req)), templates: listWorkflowTemplates(context) });
}
const workflowTemplateMatch = pathname.match(/^\/api\/workflows\/templates\/([^/]+)$/);
if (req.method === "PATCH" && workflowTemplateMatch) {
const context = resolveContext(req.headers, url.searchParams);
requirePermission(context, "workflow:manage");
return send(res, 200, { template: updateWorkflowTemplate(context, decodeURIComponent(workflowTemplateMatch[1]), await readBody(req)), templates: listWorkflowTemplates(context) });
}
const workflowInstantiateMatch = pathname.match(/^\/api\/workflows\/templates\/([^/]+)\/instantiate$/);
if (req.method === "POST" && workflowInstantiateMatch) {
const context = resolveContext(req.headers, url.searchParams);
requirePermission(context, "job:create");
return send(res, 201, await instantiateWorkflow(context, decodeURIComponent(workflowInstantiateMatch[1]), await readBody(req)));
}
if (req.method === "GET" && pathname === "/api/workflows/runs") {
const context = resolveContext(req.headers, url.searchParams);
if (!hasPermission(context, "job:create") && !hasPermission(context, "workflow:manage")) throw httpError(403, "permission_denied", "当前角色没有流程运行记录访问权限");
return send(res, 200, { runs: listWorkflowRuns(context, { status: url.searchParams.get("status") || "", limit: url.searchParams.get("limit") || 50 }) });
}
if (req.method === "POST" && pathname === "/api/production/compose") {
const context = resolveContext(req.headers, url.searchParams);
return send(res, 201, await composeProject(context, await readBody(req)));
+54 -2
View File
@@ -1,7 +1,7 @@
import { access, mkdir, writeFile } from "node:fs/promises";
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { createHash } from "node:crypto";
import { execFile } from "node:child_process";
import { resolve, extname } from "node:path";
import { resolve, extname, basename } from "node:path";
import { promisify } from "node:util";
import { dbAll, dbGet, dbRun } from "./db.mjs";
import { inspectStoragePath } from "./media-qa.mjs";
@@ -59,6 +59,24 @@ function safeOutputPath(value) {
return path;
}
function mimeForPath(pathname) {
const extension = extname(String(pathname || "")).toLowerCase();
return {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".webp": "image/webp",
".gif": "image/gif",
".wav": "audio/wav",
".mp3": "audio/mpeg",
".m4a": "audio/mp4",
".mp4": "video/mp4",
".webm": "video/webm",
".json": "application/json",
".txt": "text/plain; charset=utf-8"
}[extension] || "application/octet-stream";
}
async function materializeBase64(job, value, index, kind, mimeType = "") {
const encoded = typeof value === "string" ? value : value?.b64_json || value?.base64 || "";
if (!encoded) return "";
@@ -289,3 +307,37 @@ export function latestArtifactForShot(context, shotId, kind = "video") {
const row = dbGet("SELECT * FROM media_artifacts WHERE organization_id = ? AND workspace_id = ? AND project_id = ? AND shot_id = ? AND kind = ? ORDER BY CASE WHEN status = 'inspected' THEN 0 ELSE 1 END, created_at DESC LIMIT 1", [context.organization.id, context.workspace.id, context.project.id, shotId, kind]);
return artifactPayload(row);
}
export async function readArtifactContent(context, artifactId, frame = "") {
const artifact = dbGet(
`SELECT * FROM media_artifacts
WHERE id = ? AND organization_id = ? AND workspace_id = ? AND project_id = ?`,
[artifactId, context.organization.id, context.workspace.id, context.project.id]
);
if (!artifact) throw new Error("media_artifact_not_found");
const requestedFrame = frame === "first" ? artifact.first_frame_path : frame === "last" ? artifact.last_frame_path : "";
const relativePath = safeOutputPath(requestedFrame || artifact.path);
if (!relativePath) throw new Error("media_artifact_path_invalid");
let content;
try {
content = await readFile(resolve(projectRoot, relativePath));
} catch (error) {
if (error.code === "ENOENT") {
const missing = new Error("media_artifact_content_missing");
missing.code = "ENOENT";
missing.path = relativePath;
throw missing;
}
throw error;
}
const contentSha256 = createHash("sha256").update(content).digest("hex");
const sourceName = basename(relativePath) || basename(artifact.path) || artifact.id;
return {
content,
contentType: frame ? "image/jpeg" : artifact.mime_type || mimeForPath(relativePath),
fileName: sourceName,
contentSha256,
artifact: artifactPayload(artifact),
frame: frame || "source"
};
}
+42
View File
@@ -990,6 +990,46 @@ CREATE TABLE IF NOT EXISTS system_settings (
updated_at TEXT NOT NULL
);
-- Workflow templates are versioned production contracts. Global templates can
-- be overridden at organization or workspace scope without changing project
-- content already in production.
CREATE TABLE IF NOT EXISTS workflow_templates (
id TEXT PRIMARY KEY,
organization_id TEXT REFERENCES organizations(id) ON DELETE CASCADE,
workspace_id TEXT REFERENCES workspaces(id) ON DELETE CASCADE,
template_key TEXT NOT NULL,
version_number INTEGER NOT NULL DEFAULT 1,
name TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'ai-manhua-drama',
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'active', 'archived')),
description TEXT NOT NULL DEFAULT '',
steps_json TEXT NOT NULL DEFAULT '[]',
gates_json TEXT NOT NULL DEFAULT '[]',
default_adapter_id TEXT NOT NULL DEFAULT '',
created_by TEXT REFERENCES users(id),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE (organization_id, workspace_id, template_key, version_number)
);
CREATE TABLE IF NOT EXISTS workflow_runs (
id TEXT PRIMARY KEY,
organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
episode_id TEXT REFERENCES episodes(id) ON DELETE SET NULL,
shot_id TEXT REFERENCES shots(id) ON DELETE SET NULL,
template_id TEXT NOT NULL REFERENCES workflow_templates(id),
status TEXT NOT NULL DEFAULT 'planned' CHECK (status IN ('planned', 'queued', 'running', 'blocked', 'completed', 'failed', 'cancelled')),
current_step_index INTEGER NOT NULL DEFAULT 0,
steps_json TEXT NOT NULL DEFAULT '[]',
job_ids_json TEXT NOT NULL DEFAULT '[]',
error_message TEXT NOT NULL DEFAULT '',
created_by TEXT NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS feature_flags (
key TEXT PRIMARY KEY,
label TEXT NOT NULL,
@@ -1161,6 +1201,8 @@ CREATE INDEX IF NOT EXISTS idx_audit_scope ON audit_logs(organization_id, worksp
CREATE INDEX IF NOT EXISTS idx_invites_scope ON invitations(organization_id, status, created_at);
CREATE INDEX IF NOT EXISTS idx_job_dependencies_dependency ON job_dependencies(depends_on_job_id, job_id);
CREATE INDEX IF NOT EXISTS idx_system_settings_category ON system_settings(category, updated_at);
CREATE INDEX IF NOT EXISTS idx_workflow_templates_scope ON workflow_templates(organization_id, workspace_id, status, template_key, version_number DESC);
CREATE INDEX IF NOT EXISTS idx_workflow_runs_scope ON workflow_runs(organization_id, workspace_id, project_id, status, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_feature_flags_scope ON feature_flags(scope, enabled);
CREATE INDEX IF NOT EXISTS idx_notification_channels_enabled ON notification_channels(enabled, kind);
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_scope ON notification_deliveries(organization_id, created_at);
+86 -1
View File
@@ -1,4 +1,4 @@
import { lstat, readdir } from "node:fs/promises";
import { lstat, readdir, unlink } from "node:fs/promises";
import { resolve } from "node:path";
import { dbAll, dbGet, dbRun } from "./db.mjs";
import { httpError } from "./tenant.mjs";
@@ -96,6 +96,91 @@ export async function requireStorageQuota(context, bytes) {
return { ...summary, remainingBytes: Math.max(0, summary.remainingBytes - requestedBytes) };
}
function configuredSetting(key, fallback) {
const row = dbGet("SELECT value_json FROM system_settings WHERE key = ?", [key]);
if (!row) return fallback;
try { return JSON.parse(row.value_json); } catch { return fallback; }
}
function isTemporaryStorageFile(relativePath) {
return /\/((outputs|frames|tmp|cache))\//i.test(`/${relativePath}`);
}
function protectedPaths(context) {
const protectedSet = new Set();
const add = (value) => {
const path = String(value || "").trim();
if (path.startsWith("storage/") && !path.includes("..")) protectedSet.add(path);
};
for (const row of dbAll("SELECT path, first_frame_path, last_frame_path FROM media_artifacts WHERE organization_id = ? AND workspace_id = ? AND project_id IN (SELECT id FROM projects WHERE workspace_id = ?)", [context.organization.id, context.workspace.id, context.workspace.id])) {
add(row.path); add(row.first_frame_path); add(row.last_frame_path);
}
for (const row of dbAll("SELECT av.storage_path FROM asset_versions av JOIN assets a ON a.id = av.asset_id JOIN projects p ON p.id = a.project_id JOIN workspaces w ON w.id = p.workspace_id WHERE w.organization_id = ? AND p.workspace_id = ?", [context.organization.id, context.workspace.id])) add(row.storage_path);
for (const row of dbAll("SELECT output_path, manifest_path FROM media_compositions WHERE organization_id = ? AND workspace_id = ?", [context.organization.id, context.workspace.id])) {
add(row.output_path); add(row.manifest_path);
}
return protectedSet;
}
export async function storageCleanupPreview(context, options = {}) {
const configuredDays = Number(configuredSetting("storage.retention_days", 30));
const olderThanDays = Math.max(1, Math.min(3650, Number(options.olderThanDays || configuredDays || 30)));
const cutoff = Date.now() - olderThanDays * 86400000;
const protectedSet = protectedPaths(context);
const candidates = [];
for (const projectId of scopedProjectIds(context)) {
const usage = await projectUsage(projectId);
for (const file of usage.files) {
if (!isTemporaryStorageFile(file.relativePath)) continue;
if (protectedSet.has(file.relativePath)) continue;
const modifiedAt = new Date(file.updatedAt).getTime();
if (!Number.isFinite(modifiedAt) || modifiedAt > cutoff) continue;
candidates.push({
relativePath: file.relativePath,
projectId,
bytes: file.bytes,
updatedAt: file.updatedAt,
reason: "超过保留周期且未被媒体证据、资产版本或合成清单引用"
});
}
}
candidates.sort((left, right) => right.bytes - left.bytes);
return {
policy: {
provider: configuredSetting("storage.provider", "local-filesystem"),
retentionDays: olderThanDays,
unreferencedOnly: Boolean(configuredSetting("storage.cleanup_unreferenced_only", true)),
protectedReferenceTypes: ["media_artifacts", "asset_versions", "media_compositions"]
},
cutoff: new Date(cutoff).toISOString(),
candidates,
totalBytes: candidates.reduce((sum, item) => sum + item.bytes, 0),
checkedAt: new Date().toISOString()
};
}
export async function reclaimStorage(context, options = {}) {
const preview = await storageCleanupPreview(context, options);
const requested = new Set(Array.isArray(options.paths) ? options.paths.map((item) => String(item || "").trim()) : preview.candidates.map((item) => item.relativePath));
const targets = preview.candidates.filter((item) => requested.has(item.relativePath));
const deleted = [];
for (const target of targets) {
try {
await unlink(resolve(projectRoot, target.relativePath));
deleted.push(target);
} catch (error) {
if (error.code !== "ENOENT") throw error;
}
}
return {
...preview,
candidates: preview.candidates.filter((item) => !deleted.some((entry) => entry.relativePath === item.relativePath)),
deleted,
deletedBytes: deleted.reduce((sum, item) => sum + item.bytes, 0),
completedAt: new Date().toISOString()
};
}
export function bytesToHuman(bytes) {
const value = Number(bytes || 0);
if (value < 1024) return `${value} B`;
+167
View File
@@ -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 };
}