377 lines
12 KiB
JavaScript
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);
|
|
});
|