/** * **从零建一个新流程包并跑通** —— 编排层与活动解耦的验收证据(真库)。 * * 八例 `unified-quote` 端到端证明的是「这一条既有的流能跑」;这里证明的是另一件事: * **拿编排层去开一条新流,不碰活动一行代码**。走的正是设计者的那条路 —— * 建包(草稿存下,不生效)→ 链接已装载的活动 → 激活(双判据闸)→ 起单 → 走完。 * * 这就是「为后续 MVP 开发一个流程包做准备」到底准备好了没有的判据。 * 库的选择与八例同规矩:**绝不回落到开发库**;跳过必须可见。 */ 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 { ProcessPackage, TaskId, WorkOrderId } from '@contracts'; import { processPackageSchema } from '@contracts'; import { NotClosedError } from '@modules/process'; 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_newpkg'; async function provisionDatabase(): Promise<{ url: string; stop(): Promise } | 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; 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, JCAI_TEST_ACCOUNTS: 'chaihao,ai' }), ); await container.org.ensureSeeded(); // 种子只为**装载活动**(六个槽位)—— 新流程包引用它们,不改它们一行 await seedMvp(container); // 人来自鉴权中心(ADR-012):测试自造身份;测试账号策略给它 TEST 岗 + 独立工作组(整条流归自己) chaihao = (await container.org.registerIdentity({ sub: 'it-chaihao', account: 'chaihao', name: '柴浩', roles: ['admin'] })).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 { 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 { 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 acceptCurrent(workflowId: string, values: Record = {}): Promise { 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: {} }, chaihao as never, ); await pump(); } async function seq(workflowId: string): Promise { const instances = await container.process.instancesOf(workflowId); return instances.map((i) => `${i.nodeId}#${i.instance}=${i.terminal ?? '…'}`); } /** * 一条**新流**:受理 → 审批 → 归档。 * 引用的是已装载的活动(`rfq-intake` / `intel-archive`)—— 编排只链接,不碰实现。 */ const NEW_FLOW = (over: Partial = {}): ProcessPackage => processPackageSchema.parse({ id: 'quick-archive', name: '快速归档', version: 1, state: 'active', callInterface: { input: { type: 'object', required: ['customerCode', 'rfqRef'], properties: { customerCode: { type: 'string' }, rfqRef: { type: 'string' } }, }, output: { type: 'object' }, }, nodes: [ { id: 'start', kind: 'start', label: '起', layout: { row: 0, col: 0 } }, { id: 'A1', kind: 'activity', label: '受理', activityId: 'rfq-intake', failure: { kind: 'terminate' } }, { id: 'A2', kind: 'approval', label: '归档审批', reject: { kind: 'terminate' } }, { id: 'A3', kind: 'activity', label: '归档', activityId: 'intel-archive', failure: { kind: 'terminate' } }, { id: 'end', kind: 'end', label: '闭环' }, ], edges: [ { id: 'E1', from: 'start', to: 'A1', mapping: [ { target: 'customerCode', source: { from: 'initiation', path: 'customerCode' } }, { target: 'rfqRef', source: { from: 'initiation', path: 'rfqRef' } }, ], reopen: false, }, { id: 'E2', from: 'A1', to: 'A2', mapping: [], reopen: false }, // 审批点的去向就是它自己的条件出边:通过才归档 { id: 'E3', from: 'A2', to: 'A3', condition: 'verdict = "pass"', mapping: [ { target: 'outcome', source: { from: 'constant', value: 'archived' } }, { target: 'quote_no', source: { from: 'initiation', path: 'rfqRef' } }, { target: 'customer_code', source: { from: 'initiation', path: 'customerCode' } }, { target: 'quotedAt', source: { from: 'constant', value: '2026-07-28' } }, ], reopen: false, }, { id: 'E4', from: 'A3', to: 'end', mapping: [], reopen: false }, ], variables: [], ...over, }); /* * **2026-07-29 起整套跳过 —— 范式统一的直接后果,不是沉默的通过。** * * 这套端到端靠「答活动的验收工单」把流走完;那张工单从前由 flow 引擎的末步发出。 * 活动改为一律案例模型(loop)之后,发不发工单由 Agent 每拍决定 —— * `turn-runner.runTurn` 每一拍都要 `agent.decide`,**环境里没有模型 key 就一步也走不动**。 * * 恢复条件(两件,缺一不可,见 docs/debt.md): * ① 环境有可用模型(JCAI_DEEPSEEK_API_KEY 或等价的实例绑定); * ② 走法改写成 loop 的口径 —— 按 Enabled 集推进,而不是按步序答工单。 * 在此之前**不许把断言改松让它变绿**:绿得不诚实比红着更坏。 */ const SKIP_UNTIL_MODEL = true; // 见上:没有模型,案例活动一步也走不动 describe.skipIf(database === undefined || SKIP_UNTIL_MODEL)('从零建一条新流并跑通 —— 编排层可独立开工', () => { it('① 建包:存下来是**未生效**的草稿,不影响任何在途', async () => { const saved = await container.process.savePackage(NEW_FLOW()); expect(saved.version).toBe(1); expect(saved.state).toBe('retired'); // 未生效版不得起单 —— 「一次只能激活一个版本」对新起单是硬约束 await expect( container.process.start({ packageId: 'quick-archive', subject: '未激活试起', source: { kind: 'registration', ref: 'it', by: chaihao }, data: { customerCode: 'C1', rfqRef: 'R1' }, }), ).rejects.toThrow(); }); it('② 激活闸:图编排不闭合的包激活被拒(判据一)', async () => { // 把 A3 的入边摘掉:A3 成孤岛、A2 的出边指向消失 —— 不闭合 const broken = NEW_FLOW({ id: 'broken-flow' as never, edges: NEW_FLOW().edges.filter((e) => e.id !== 'E3') }); const saved = await container.process.savePackage(broken); await expect(container.process.setPackageState('broken-flow', saved.version, 'active')).rejects.toBeInstanceOf( NotClosedError, ); }); it('③ 激活闸:活动未装载的包激活被拒(判据二)', async () => { const unlinked = NEW_FLOW({ id: 'unlinked-flow' as never, nodes: NEW_FLOW().nodes.map((n) => n.id === 'A1' && n.kind === 'activity' ? { ...n, activityId: '未安装:随手画的' as never } : n, ), }); const saved = await container.process.savePackage(unlinked); const report = await container.process.validate({ ...unlinked, version: saved.version }); expect(report.violations.some((v) => v.kind === 'unknown-activity')).toBe(true); await expect(container.process.setPackageState('unlinked-flow', saved.version, 'active')).rejects.toBeInstanceOf( NotClosedError, ); }); it('④ 两面都齐即可激活 —— 一条流恰一个生效版本', async () => { const packages = await container.process.setPackageState('quick-archive', 1, 'active'); const mine = packages.filter((p) => p.id === 'quick-archive'); expect(mine.filter((p) => p.state === 'active')).toHaveLength(1); expect(mine.find((p) => p.version === 1)?.state).toBe('active'); }); it('⑤ 起单跑通:受理 → 审批 → 归档 → 闭环,末态 completed', async () => { const record = await container.process.start({ packageId: 'quick-archive', subject: '新流首单', source: { kind: 'registration', ref: 'it', by: chaihao }, data: { customerCode: 'C-NEW-1', rfqRef: 'RFQ-NEW-1' }, }); await pump(); // A1 受理:占位活动的收口验收 await acceptCurrent(record.id, { needAlternative: 'false', needSourcing: 'false' }); // A2 审批:审批人由运营时分配(员工组/全员轮值),裁决通过 await container.process.onApprovalVerdict(record.id, 'A2', 'pass', { by: chaihao }); await pump(); /* * A3 归档要 `outcome`,这条流里没人产它 —— **宽进**(2026-07-30): * 缺必填不拒单,任务落「等人」、补全卡进对话流;答上,活动才开跑。 * 这正是「调用活动输入可以不定,进活动受理归一,信息不全派补全单」在真流里的样子。 */ const a3Task = (await container.process.instancesOf(record.id)).find((i) => i.nodeId === 'A3'); expect(a3Task?.taskId, 'A3 已进入(等补全,不是没起)').toBeDefined(); const held = await container.task.get(a3Task!.taskId as never); expect(held.runtime, '缺 outcome —— 等人补全').toBe('awaiting-human'); const cards = await container.humanTool.chatCards(a3Task!.taskId as never); const ask = cards.find((c) => c.k === 'elicitation' && c.done === undefined); expect(ask, '补全卡在对话流里').toBeDefined(); await container.humanTool.replyChatCard( { k: 'elicitation', cardId: ask!.id as never, values: { outcome: 'won' } }, chaihao as never, ); await pump(); // A3 归档:补全齐了,照旧一张收口验收 await acceptCurrent(record.id); expect(await seq(record.id)).toEqual([ 'A1#1=released', 'A2#1=released', 'A3#1=released', 'end#1=released', ]); expect((await container.process.getWorkflow(record.id)).state).toBe('completed'); }); it('⑥ 全程没碰活动一行:两条流引用同一批活动,各跑各的', async () => { // 既有的 unified-quote 照旧可起单 —— 新流的建立没有动到它引用的活动 const other = await container.process.start({ packageId: 'unified-quote', subject: '老流照跑', source: { kind: 'registration', ref: 'it', by: chaihao }, data: { customerCode: 'C-OLD', rfqRef: 'RFQ-OLD', subject: 's', body: 'b' }, }); await pump(); expect((await seq(other.id)).some((s) => s.startsWith('A1#1'))).toBe(true); }); });