Archived
仿真测试台(后端):节点指派 + 全链 Trace —— 起单定向到指定账号,真实任务落工作台
用户定的形态:测试台是**后端检测活动与流程**的,真实任务照旧到工作台
(不然指定账号干嘛)—— 所以测试台只管「起单 + 看穿」,人那一环还是真的。
**节点指派**(迁移 0016 workflow.assignments):
- StartWorkflowInput 增 `assignments {节点id → 员工id}`;分派改三级取人:
①节点指派(点名的人在岗就是他)②员工组轮值 ③全员轮值 —— 指派是加法不是替换,
点名的人不在岗退回轮值(单不能没人接);事件里如实记 `(节点指派)`
- **顺带修一个真 bug**:`node_instance.assignee_id` 写了但 `listInstances` 从没读回来
(审批箱收件、员工面链条、治理列表全按它认人 —— 写了不读等于没写)
**全链 Trace**(新模块 trace,L2;迁移 0017 sys.trace_span):
- span 由 **Mastra Observability 组件**产出(执行不经 Mastra 图,所以手动打点),
一开就落库、一收就补 —— 观测面不该比被观测的事晚(不走 exporter 的批量异步路)
- 打点覆盖:工作流 root → 节点(记着派给了谁)→ 活动内每次工具调用 / 模型调用 / 人卡
- **root span 挂在 `runtime.begin`**:那是「单已建好、还没进第一个节点」那一刻 ——
挂在 start 返回之后就晚了,起单同步走到第一个节点,节点 span 会先出生、另起孤树(实测)
- 观测尽力而为:写 span 失败吞掉,不掀翻业务
- 三样东西各管各的:trace(机器读时间轴)/ 任务记忆(人读留痕)/
**工作区报告(活动自持的 HTML)** —— 谁也不替代谁
路由:`POST /api/gov/testbed/run`(带指派起单)、`GET /api/gov/workflows/:id/trace`。
端到端六例(真库):指派生效 / 任务真落到被指派人手上并由他答单 / 下游节点同样生效 /
全链 span 逐层挂得上且耗时齐备 / 一条流一个 traceId / 不指派照旧轮值。五闸全过。
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -0,0 +1,214 @@
|
||||
/**
|
||||
* 仿真测试台端到端(真库)—— **后端检测活动与流程**的那条路:
|
||||
* 起单时把节点定向到指定账号 → 真实任务落到那个人手上 → 全链 Trace 看穿每一步。
|
||||
*
|
||||
* 这里守三件事:
|
||||
* ① **指派生效**:点名了的节点就派给那个人(不是轮值挑出来的);
|
||||
* ② **任务是真的**:落到被指派人的工作台(他能在自己的任务列表里看到并处理);
|
||||
* ③ **Trace 全链**:工作流 → 节点 → 活动内每一步(工具 / 人卡)逐层挂得上,耗时齐备。
|
||||
*/
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { Pool } from 'pg';
|
||||
import { drizzle } from 'drizzle-orm/node-postgres';
|
||||
import { migrate } from 'drizzle-orm/node-postgres/migrator';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { dirname, join, resolve } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import type { TaskId, WorkOrderId } from '@contracts';
|
||||
import { loadConfig } from '../config.ts';
|
||||
import { createContainer } from '../container.ts';
|
||||
import type { Container } from '../container.ts';
|
||||
import { seedMvp } from '../seed-mvp.ts';
|
||||
import { advanceOnce } from '../workers/task-advancer.ts';
|
||||
import { dispatchOnce } from '../workers/dispatcher.ts';
|
||||
import { createConsumers } from '../workers/consumers.ts';
|
||||
|
||||
const IT_DB = 'jcai_it_testbed';
|
||||
|
||||
async function provisionDatabase(): Promise<{ url: string; stop(): Promise<void> } | undefined> {
|
||||
const explicit = process.env['JCAI_TEST_DATABASE_URL'];
|
||||
if (explicit !== undefined) return { url: explicit, stop: async () => undefined };
|
||||
const base = process.env['DATABASE_URL'] ?? 'postgres://jcai:jcai@localhost:54329/jcai';
|
||||
try {
|
||||
const admin = new Pool({ connectionString: base, max: 1, connectionTimeoutMillis: 1500 });
|
||||
const exists = await admin.query(`select 1 from pg_database where datname = $1`, [IT_DB]);
|
||||
if (exists.rowCount === 0) await admin.query(`create database ${IT_DB}`);
|
||||
await admin.end();
|
||||
return { url: base.replace(/\/[^/]*$/, `/${IT_DB}`), stop: async () => undefined };
|
||||
} catch {
|
||||
/* 开发实例连不上,试 Testcontainers */
|
||||
}
|
||||
try {
|
||||
const { PostgreSqlContainer } = await import('@testcontainers/postgresql');
|
||||
const started = await new PostgreSqlContainer('postgres:16-alpine').start();
|
||||
return { url: started.getConnectionUri(), stop: () => started.stop().then(() => undefined) };
|
||||
} catch (error) {
|
||||
process.stderr.write(
|
||||
`⚠️ 测试台 E2E 跳过:连不上开发实例也起不了 Testcontainers(${error instanceof Error ? error.message : String(error)})\n`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function migrationsFolder(): string {
|
||||
let dir = dirname(fileURLToPath(import.meta.url));
|
||||
for (let i = 0; i < 8; i += 1) {
|
||||
const candidate = join(dir, 'db', 'migrations');
|
||||
if (existsSync(candidate)) return candidate;
|
||||
const parent = resolve(dir, '..');
|
||||
if (parent === dir) break;
|
||||
dir = parent;
|
||||
}
|
||||
throw new Error('找不到 db/migrations');
|
||||
}
|
||||
|
||||
const database = await provisionDatabase();
|
||||
|
||||
let pool: Pool | undefined;
|
||||
let container: Container;
|
||||
let chaihao: string;
|
||||
let ai: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
if (database === undefined) return;
|
||||
pool = new Pool({ connectionString: database.url, max: 4 });
|
||||
for (const schema of ['sys', 'ledger', 'mastra', 'queue']) {
|
||||
await pool.query(`drop schema if exists "${schema}" cascade`);
|
||||
await pool.query(`create schema "${schema}"`);
|
||||
}
|
||||
await migrate(drizzle(pool), { migrationsFolder: migrationsFolder(), migrationsSchema: 'sys' });
|
||||
container = await createContainer(loadConfig({ ...process.env, DATABASE_URL: database.url }));
|
||||
await container.org.ensureSeeded();
|
||||
await seedMvp(container);
|
||||
const { staff } = await container.org.listStaff();
|
||||
chaihao = staff.find((s) => s.account === 'Chaihao')!.id;
|
||||
ai = staff.find((s) => s.account === 'AI')!.id;
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (database === undefined) return;
|
||||
await container?.close();
|
||||
await pool?.end();
|
||||
await database.stop();
|
||||
});
|
||||
|
||||
const consumers = createConsumers();
|
||||
|
||||
async function pump(): Promise<void> {
|
||||
for (let i = 0; i < 60; i += 1) {
|
||||
const delivered = await dispatchOnce({ container, consumers });
|
||||
const advanced = await advanceOnce({ container });
|
||||
if (delivered === 0 && advanced === 0) return;
|
||||
}
|
||||
throw new Error('泵 60 轮未静止 —— 流转在空转');
|
||||
}
|
||||
|
||||
async function currentTask(workflowId: string): Promise<TaskId> {
|
||||
const rows = await pool!.query<{ id: string }>(
|
||||
`select id from sys.task where workflow_id = $1 and terminal is null order by started_at desc limit 1`,
|
||||
[workflowId],
|
||||
);
|
||||
expect(rows.rowCount, `流 ${workflowId} 应有在途任务`).toBe(1);
|
||||
return rows.rows[0]!.id as TaskId;
|
||||
}
|
||||
|
||||
/** 以**被指派人**的身份答掉验收单 —— 任务是真的落到他手上,不是测试台代答 */
|
||||
async function acceptAs(workflowId: string, staffId: string, values: Record<string, string> = {}): Promise<void> {
|
||||
const taskId = await currentTask(workflowId);
|
||||
const open = await container.humanTool.openWorkOrders(taskId);
|
||||
const order = open.find((o) => o.mode === 'acceptance');
|
||||
expect(order, '应有待答验收单').toBeDefined();
|
||||
await container.humanTool.replyWorkOrder(
|
||||
{ mode: 'acceptance', workOrderId: order!.id as WorkOrderId, verdict: 'pass', values, selectedPorts: {} },
|
||||
staffId as never,
|
||||
);
|
||||
await pump();
|
||||
}
|
||||
|
||||
describe.skipIf(database === undefined)('仿真测试台:指派 → 真实任务 → 全链 Trace', () => {
|
||||
let workflowId: string;
|
||||
|
||||
it('① 起单带节点指派:点名的节点就派给那个人', async () => {
|
||||
const record = await container.process.start({
|
||||
packageId: 'unified-quote',
|
||||
subject: '测试台试跑',
|
||||
source: { kind: 'registration', ref: 'testbed', by: chaihao },
|
||||
data: { customerCode: 'C-TB-1', rfqRef: 'RFQ-TB-1', subject: 's', body: 'b' },
|
||||
// A1 定向给 AI,A4 定向给 Chaihao —— 其余节点照旧轮值
|
||||
assignments: { A1: ai, A4: chaihao },
|
||||
});
|
||||
workflowId = record.id;
|
||||
await pump();
|
||||
|
||||
const instances = await container.process.instancesOf(workflowId);
|
||||
const a1 = instances.find((i) => i.nodeId === 'A1');
|
||||
expect(a1?.assigneeId, 'A1 应派给被点名的 AI').toBe(ai);
|
||||
});
|
||||
|
||||
it('② 任务是真的:落在被指派人的任务列表里,他能处理', async () => {
|
||||
const mine = await container.task.listByOwner(ai as never);
|
||||
const hit = mine.find((t) => t.workflowId === workflowId);
|
||||
expect(hit, 'AI 的工作台上应有这一单的任务').toBeDefined();
|
||||
expect(hit?.ownerId).toBe(ai);
|
||||
|
||||
// 他本人答掉验收单,流继续往下走 —— 测试台不代答
|
||||
await acceptAs(workflowId, ai, { needAlternative: 'false', needSourcing: 'false' });
|
||||
const instances = await container.process.instancesOf(workflowId);
|
||||
expect(instances.find((i) => i.nodeId === 'A1')?.terminal).toBe('released');
|
||||
});
|
||||
|
||||
it('③ 指派对下游节点同样生效:A4 落到点名的人手上', async () => {
|
||||
const instances = await container.process.instancesOf(workflowId);
|
||||
const a4 = instances.find((i) => i.nodeId === 'A4');
|
||||
expect(a4?.assigneeId, 'A4 应派给被点名的 Chaihao').toBe(chaihao);
|
||||
});
|
||||
|
||||
/*
|
||||
* Trace 是这次要的核心:活动内部到底干了什么,时间轴上要看得见 ——
|
||||
* 工作流一条 root,每个节点一个 span,活动内的工具调用/人卡各一个子 span。
|
||||
*/
|
||||
it('④ 全链 Trace:工作流 → 节点 → 活动内动作,逐层挂得上', async () => {
|
||||
const spans = await container.trace.ofWorkflow(workflowId);
|
||||
expect(spans.length, 'span 不该是空的').toBeGreaterThan(0);
|
||||
|
||||
const root = spans.find((s) => s.kind === 'workflow');
|
||||
expect(root, '应有工作流级 root span').toBeDefined();
|
||||
expect(root?.parentId, 'root 没有父亲').toBeUndefined();
|
||||
expect(root?.name).toContain('测试台试跑');
|
||||
|
||||
const nodes = spans.filter((s) => s.kind === 'node');
|
||||
expect(nodes.length, '至少 A1 与 A4 两个节点 span').toBeGreaterThanOrEqual(2);
|
||||
expect(nodes.every((n) => n.parentId === root!.id), '节点 span 都挂在 root 下').toBe(true);
|
||||
expect(nodes.find((n) => n.nodeId === 'A1')?.attributes?.['assigneeId'], '节点 span 记着派给了谁').toBe(ai);
|
||||
|
||||
// 活动内部:A1 的占位骨架会调工具、发人卡 —— 这些子 span 挂在 A1 的节点 span 下
|
||||
const a1Span = nodes.find((n) => n.nodeId === 'A1');
|
||||
const inner = spans.filter((s) => s.parentId === a1Span?.id);
|
||||
expect(inner.length, 'A1 内部应有动作 span(工具 / 人卡)').toBeGreaterThan(0);
|
||||
expect(inner.some((s) => s.kind === 'tool' || s.kind === 'human'), '动作 span 有工具或人卡').toBe(true);
|
||||
|
||||
// 收了尾的 span 有耗时 —— 时间轴才画得出来
|
||||
const closed = spans.filter((s) => s.endedAt !== undefined);
|
||||
expect(closed.length, '至少有一批 span 已收尾').toBeGreaterThan(0);
|
||||
for (const s of closed) expect(new Date(s.endedAt!).getTime()).toBeGreaterThanOrEqual(new Date(s.startedAt).getTime());
|
||||
});
|
||||
|
||||
it('⑤ 一条流一条 trace:所有 span 同一个 traceId', async () => {
|
||||
const spans = await container.trace.ofWorkflow(workflowId);
|
||||
const traceIds = new Set(spans.map((s) => s.traceId));
|
||||
expect(traceIds.size, '同一条流的 span 应共用一个 traceId').toBe(1);
|
||||
});
|
||||
|
||||
it('⑥ 不指派的节点照旧轮值 —— 指派是加法,不是替换', async () => {
|
||||
const record = await container.process.start({
|
||||
packageId: 'unified-quote',
|
||||
subject: '不指派的对照单',
|
||||
source: { kind: 'registration', ref: 'testbed', by: chaihao },
|
||||
data: { customerCode: 'C-TB-2', rfqRef: 'RFQ-TB-2', subject: 's', body: 'b' },
|
||||
});
|
||||
await pump();
|
||||
const instances = await container.process.instancesOf(record.id);
|
||||
const a1 = instances.find((i) => i.nodeId === 'A1');
|
||||
expect(a1?.assigneeId, '没点名也得有人接单(轮值)').toBeDefined();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user