Files
TrueGrowth/scripts/verify-business-orchestration-runtime.mjs
jiam 5119ac0ef8
Some checks failed
CI / main (push) Has been cancelled
CI / release-e2e (push) Has been cancelled
Sync latest TrueGrowth updates
2026-07-08 02:03:18 +08:00

377 lines
12 KiB
JavaScript

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