const port = Number(process.env.TRUEGROWTH_VERIFY_PORT || 48219); const base = `http://127.0.0.1:${port}`; process.env.TRUEGROWTH_RUNTIME_STATE_DIR = process.env.TRUEGROWTH_RUNTIME_STATE_DIR || `/tmp/truegrowth-business-orchestration-verify-${process.pid}`; function assert(condition, message) { if (!condition) { throw new Error(message); } } async function post(pathname, body = {}) { const response = await fetchWithTimeout(`${base}${pathname}`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body), }); const payload = await response.json(); if (!response.ok && response.status !== 409) { throw new Error(`${pathname} ${response.status}: ${JSON.stringify(payload)}`); } return payload; } async function get(pathname) { const response = await fetchWithTimeout(`${base}${pathname}`); const payload = await response.json(); if (!response.ok) { throw new Error(`${pathname} ${response.status}: ${JSON.stringify(payload)}`); } return payload; } async function fetchWithTimeout(url, options = {}, timeoutMs = 20000) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { return await fetch(url, { ...options, signal: controller.signal }); } finally { clearTimeout(timer); } } async function waitForRun(runId, predicate, timeoutMs = 8000) { const startedAt = Date.now(); while (Date.now() - startedAt < timeoutMs) { const payload = await get(`/local-api/business-workflows/runs/${runId}`); if (predicate(payload.run)) { return payload.run; } await new Promise((resolve) => setTimeout(resolve, 180)); } throw new Error(`Run ${runId} did not reach expected state`); } function workflowFixture(id, extra = {}) { const now = Date.now(); return { id, name: `验证流程 ${id}`, description: 'Business orchestration runtime verification', version: 1, config: { failurePolicy: 'continue', variables: { topic: 'AI 新闻' }, ...(extra.config || {}), }, createdAt: now, updatedAt: now, nodes: extra.nodes || [ { id: 'schedule', kind: 'schedule-trigger', label: '定时触发', position: { x: 0, y: 0 }, inputs: [], outputs: [{ id: 'time', label: '触发时间', type: 'json' }], config: { schedule: { expression: '0 9 * * *', timezone: 'Asia/Shanghai', enabled: true, overlapPolicy: 'skip', misfirePolicy: 'run-once', }, }, }, { id: 'news', kind: 'intelligence-news', label: '新闻总结', position: { x: 240, y: 0 }, capability: 'intelligence.research', inputs: [{ id: 'trigger', label: '触发', type: 'json' }], outputs: [{ id: 'script', label: '脚本', type: 'script' }], config: { providerId: 'agent-reach-local', prompt: '总结今天适合短视频发布的 AI 产品新闻。', }, }, ], edges: extra.edges || [{ id: 'e1', source: 'schedule', target: 'news' }], }; } function publishingWorkflowFixture(id) { return workflowFixture(id, { nodes: [ { id: 'news', kind: 'intelligence-news', label: '新闻总结', position: { x: 0, y: 0 }, capability: 'intelligence.research', inputs: [], outputs: [{ id: 'script', label: '脚本', type: 'script' }], config: { providerId: 'agent-reach-local', prompt: '总结今天适合抖音发布的 AI 产品新闻,输出一分钟口播脚本。', }, }, { id: 'avatar', kind: 'digital-human', label: '数字人口播', position: { x: 260, y: 0 }, capability: 'video.digital-human', inputs: [{ id: 'script', label: '脚本', type: 'script' }], outputs: [{ id: 'video', label: '视频', type: 'video' }], config: { providerId: 'aigcpanel-local', avatarId: 'truegrowth-anchor', voiceId: 'zh-CN-news', prompt: '使用上游新闻脚本生成数字人口播视频。', }, }, { id: 'edit', kind: 'intelligent-edit', label: '智能剪辑', position: { x: 520, y: 0 }, capability: 'clip.compose', inputs: [{ id: 'video', label: '视频', type: 'video' }], outputs: [{ id: 'video', label: '成片', type: 'video' }], config: { providerId: 'videoagent-local', operations: ['asr', 'highlight-cut', 'subtitle', 'ffmpeg-export'], }, }, { id: 'publish', kind: 'publish', label: '发布中心', position: { x: 780, y: 0 }, capability: 'social.publish', inputs: [{ id: 'video', label: '成片', type: 'video' }], outputs: [{ id: 'campaign', label: '发布草稿', type: 'publish-campaign' }], config: { providerId: 'social-publishing', title: 'AI 新闻每日简报', description: '自动生成的 AI 新闻数字人口播。', publish: { platforms: ['douyin'], accountIds: ['douyin-local-demo'], options: { draftOnly: true }, }, }, }, ], edges: [ { id: 'news-avatar', source: 'news', target: 'avatar' }, { id: 'avatar-edit', source: 'avatar', target: 'edit' }, { id: 'edit-publish', source: 'edit', target: 'publish' }, ], }); } async function main() { const { createLocalApiServer } = await import('./truegrowth-local-api.mjs'); const server = createLocalApiServer(); await new Promise((resolve, reject) => { server.once('error', reject); server.listen(port, '127.0.0.1', resolve); }); try { const workflow = workflowFixture(`verify-${Date.now()}`); console.log('verify: save and validate'); const saved = await post('/local-api/business-workflows/definitions', { workflow, }); assert(saved.validation.valid, 'Saved workflow should validate'); const validated = await post('/local-api/business-workflows/validate', { workflow, }); assert( validated.validation.executionOrder.join('>') === 'schedule>news', 'Validate-only should return expected execution order' ); const runResult = await post('/local-api/business-workflows/run', { workflow, trigger: 'manual', }); const completedRun = await waitForRun(runResult.run.id, (run) => ['succeeded', 'partial', 'failed'].includes(run.status) ); assert(completedRun.progress > 0, 'Manual run should report progress'); const cancelWorkflow = workflowFixture(`verify-cancel-${Date.now()}`); console.log('verify: cancel'); const cancelRun = await post('/local-api/business-workflows/run', { workflow: cancelWorkflow, trigger: 'manual', }); const cancelled = await post( `/local-api/business-workflows/runs/${cancelRun.run.id}/cancel` ); assert(cancelled.run.status === 'cancelled', 'Run cancellation should persist'); const retry = await post( `/local-api/business-workflows/runs/${completedRun.id}/nodes/news/retry` ); assert( retry.run.nodeRuns.find((nodeRun) => nodeRun.nodeId === 'news')?.status === 'queued', 'Node retry should requeue the node' ); const schedule = await post('/local-api/business-workflows/schedules', { // schedule management workflow, schedule: { name: '验证每天9点', expression: '0 9 * * *', timezone: 'Asia/Shanghai', enabled: true, overlapPolicy: 'skip', misfirePolicy: 'run-once', }, }); console.log('verify: schedule management'); assert(schedule.schedule.config.enabled, 'Schedule should be enabled'); const disabled = await post( `/local-api/business-workflows/schedules/${schedule.schedule.id}/disable` ); assert(!disabled.schedule.config.enabled, 'Schedule should disable'); const enabled = await post( `/local-api/business-workflows/schedules/${schedule.schedule.id}/enable` ); assert(enabled.schedule.config.enabled, 'Schedule should enable'); const scheduleRun = await post( `/local-api/business-workflows/schedules/${schedule.schedule.id}/run` ); assert(scheduleRun.run.trigger === 'schedule', 'Schedule run should be marked'); const deleted = await post( `/local-api/business-workflows/schedules/${schedule.schedule.id}/delete` ); assert( !deleted.schedules.some((item) => item.id === schedule.schedule.id), 'Schedule should delete' ); const utilityStart = await post( '/local-api/runtime/business-workflow-utility/start' ); console.log('verify: utility isolation'); assert(utilityStart.status.running, 'Utility worker should start'); const utilityWorkflow = workflowFixture(`verify-utility-${Date.now()}`, { nodes: [ { id: 'clip', kind: 'intelligent-edit', label: '智能剪辑', position: { x: 0, y: 0 }, capability: 'clip.compose', inputs: [], outputs: [{ id: 'video', label: '成片', type: 'video' }], config: { providerId: 'videoagent-local', prompt: '生成字幕成片' }, }, ], edges: [], }); const utilityRun = await post('/local-api/business-workflows/run', { workflow: utilityWorkflow, trigger: 'manual', }); const isolatedRun = await waitForRun(utilityRun.run.id, (run) => ['succeeded', 'partial', 'failed'].includes(run.status) ); const artifact = isolatedRun.nodeRuns[0]?.artifacts?.[0]; assert( artifact?.metadata?.utilityStarted === true, 'Utility isolation metadata should be recorded' ); await post('/local-api/runtime/business-workflow-utility/stop'); console.log('verify: news to publish e2e'); const publishingWorkflow = publishingWorkflowFixture( `verify-publish-${Date.now()}` ); const publishingValidation = await post( '/local-api/business-workflows/validate', { workflow: publishingWorkflow } ); assert( publishingValidation.validation.valid, 'Publishing workflow should validate' ); assert( publishingValidation.validation.executionOrder.join('>') === 'news>avatar>edit>publish', 'Publishing workflow should run in dependency order' ); const publishingRunResult = await post('/local-api/business-workflows/run', { workflow: publishingWorkflow, trigger: 'manual', }); const publishingRun = await waitForRun( publishingRunResult.run.id, (run) => ['succeeded', 'partial', 'failed'].includes(run.status), 12000 ); const publishNodeRun = publishingRun.nodeRuns.find( (nodeRun) => nodeRun.nodeId === 'publish' ); assert(publishNodeRun?.status === 'succeeded', 'Publish node should finish'); const publishArtifact = publishNodeRun.artifacts?.[0]; assert( publishArtifact?.type === 'publish-campaign', 'Publish node should return a publish campaign or draft artifact' ); assert( publishArtifact?.metadata?.adapter === 'social-publishing', 'Publish artifact should use the social publishing adapter' ); assert( Array.isArray(publishArtifact?.metadata?.upstreamArtifactIds) || Boolean(publishArtifact?.metadata?.campaignId), 'Publish artifact should reference upstream artifacts or a campaign id' ); const runtime = await get('/local-api/business-workflows'); assert( runtime.bridge.storage === 'sqlite' || runtime.bridge.storage === 'json', 'Runtime should report persistence storage' ); console.log( JSON.stringify( { ok: true, runId: completedRun.id, storage: runtime.bridge.storage, schedules: runtime.schedules.length, }, null, 2 ) ); } finally { try { await post('/local-api/runtime/business-workflow-utility/stop'); } catch { // The server may already be closing; this is just cleanup. } await new Promise((resolve) => server.close(resolve)); } } main().catch((error) => { console.error(error); process.exit(1); });