仿真测试台(后端):节点指派 + 全链 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:
2026-07-29 01:43:49 +08:00
co-authored by Claude Fable 5
parent 9df2294f6e
commit 5fc6dfdfe1
34 changed files with 1301 additions and 41 deletions
+1
View File
@@ -19,6 +19,7 @@
"@ai-sdk/openai-compatible": "^3.0.14",
"@hono/node-server": "^2.0.12",
"@mastra/core": "^1.53.0",
"@mastra/observability": "^1.16.2",
"ai": "^7.0.37",
"ajv": "^8.20.0",
"ajv-formats": "^3.0.1",
@@ -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();
});
});
+17
View File
@@ -63,6 +63,9 @@ import type { MinuteService } from '@modules/minute';
import type { ReportService } from '@modules/report';
import { FIRST_PARTY_TOOLS } from './tools/first-party.ts';
import { httpToolHandler } from './tools/http-tool.ts';
import { createMastraSpanFactory } from './mastra-spans.ts';
import { createTraceService, createTraceStore } from '@modules/trace';
import type { TraceService } from '@modules/trace';
import { wireEngines } from './wiring/engines.ts';
import { wireDeskElements } from './wiring/desk-elements.ts';
import type { ChannelService } from '@modules/channel';
@@ -104,6 +107,8 @@ export type Container = {
readonly trigger: TriggerService;
readonly minute: MinuteService;
readonly report: ReportService;
/** 全链观测:工作流 → 节点 → 步骤 / 工具 / 模型 / 人卡 */
readonly trace: TraceService;
close(): Promise<void>;
};
@@ -512,8 +517,19 @@ export async function createContainer(config: AppConfig): Promise<Container> {
/* ── L5–L7:范式引擎、任务、流程层 ─────────────────────────────── */
/*
* 全链观测:一条工作流一条 trace。span 由 Mastra 的 Observability 组件产出
* (我们的执行不经 Mastra 图,所以手动打点),一开就落库 —— 测试台要「点一下就看得见」。
*/
const trace = createTraceService({
store: createTraceStore(db),
clock: systemClock,
spans: createMastraSpanFactory(),
});
const engines = wireEngines({
db,
trace,
clock: systemClock,
ids,
event,
@@ -587,6 +603,7 @@ export async function createContainer(config: AppConfig): Promise<Container> {
trigger: engines.trigger,
minute: deskElements.minute,
report: deskElements.report,
trace,
async close() {
await pool.end();
},
+66
View File
@@ -0,0 +1,66 @@
/**
* `SpanFactory` 的 **Mastra 实现** —— 用 Mastra 的 Observability 组件产 span
* (`@mastra` 只准 adapter 与 app,§6.2)。
*
* 我们的执行不经 Mastra 图,所以它的自动 tracing 不会产出任何东西;
* 但 span 的**形状与 id 体系**(traceId / spanId / 父子链)照用它的 ——
* 将来真接了 Mastra workflow,这些 span 与它自己产的能拼进同一棵树。
*
* 这里只要 id 与父子关系:落库、耗时、入出参由 trace 模块自己管
* (它要「一开就落」,而 Mastra 的 exporter 是缓冲 + 定时冲刷的批量路)。
*/
import { DefaultObservabilityInstance } from '@mastra/observability';
import type { SpanFactory } from '@modules/trace';
/**
* Mastra span 对象上我们要用的那几个字段。
* Mastra 的 `Span<TType>` 泛型极深且随版本变;这里只声明**我们真正用到的**面,
* 由 `instance` 的窄化包装一次性对上 —— 断言收在 `spanApi` 这一处。
*/
type MastraSpan = {
id: string;
traceId: string;
createChildSpan(options: { type: string; name: string }): MastraSpan;
};
/** Mastra 实例的窄化面:类型桥收在这一处,别撒到调用点上 */
type SpanApi = { startSpan(options: { type: string; name: string }): MastraSpan };
export function createMastraSpanFactory(serviceName = 'jcai'): SpanFactory {
const instance: SpanApi = new DefaultObservabilityInstance({
config: { serviceName, sampling: { type: 'always' }, exporters: [] },
} as never) as SpanApi;
/*
* Mastra 的 span 对象要留着 —— 子 span 得从父对象上开(`createChildSpan`),
* 光有 id 拼不出父子链。这张表按 spanId 存活着的 span;
* 观测是**尽力而为**的:表里查不到父亲(进程重启、span 太老被清)就另起一棵树,
* 不抛错、不让业务停摆。
*/
const live = new Map<string, MastraSpan>();
const remember = (span: MastraSpan): { spanId: string; traceId: string } => {
live.set(span.id, span);
// 只留最近的一批:测试台一次跑一条流,几十个 span 足够;不设上限会随长跑泄漏
if (live.size > 2000) {
const oldest = live.keys().next().value;
if (oldest !== undefined) live.delete(oldest);
}
return { spanId: span.id, traceId: span.traceId };
};
return {
root(name) {
return remember(instance.startSpan({ type: 'generic', name }));
},
child(parent, name) {
const held = live.get(parent.spanId);
if (held === undefined) {
// 父亲不在手上:另起一棵树,别把整条链丢掉(观测尽力而为)
return remember(instance.startSpan({ type: 'generic', name }));
}
return remember(held.createChildSpan({ type: 'generic', name }));
},
};
}
+42
View File
@@ -25,6 +25,8 @@ import {
listTriggersRoute,
registerToolRoute,
saveProcessRoute,
testRunRoute,
traceOfWorkflowRoute,
setProcessStateRoute,
reassignTaskRoute,
resumeTaskRoute,
@@ -213,6 +215,46 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
return c.json({ approvals: perWorkflow.flat() });
});
/**
* 仿真测试台 · 起一条流 —— **带节点指派**把整条流定向到指定账号。
*
* 这是「后端检测活动与流程」的入口:真实任务照旧落到被指派人的工作台
* (指派不是代答,人那一环还是真的),测试台只管起单与看穿全程。
*/
app.post(testRunRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const parsed = testRunRoute.body.safeParse(await c.req.json());
if (!parsed.success) {
const body: ApiError = { code: 'validation-failed', message: '试跑入参不合协议', detail: parsed.error.issues };
return c.json(body, 400);
}
const me = await container.org.findByAccount(c.get('account'));
try {
const record = await container.process.start({
packageId: parsed.data.packageId,
subject: parsed.data.subject,
// 凡单必有来源:测试台起的单来源是「设计台试跑」,登记人即操作者
source: { kind: 'registration', ref: 'testbed', ...(me === undefined ? {} : { by: me.id }) },
data: parsed.data.data,
...(Object.keys(parsed.data.assignments).length === 0 ? {} : { assignments: parsed.data.assignments }),
});
return c.json({ workflowId: record.id, state: record.state });
} catch (error) {
// 起不来的原因要如实说(不合调用接口 / 无生效版 / 岗位空缺),别让人对着空白猜
const body: ApiError = { code: 'validation-failed', message: error instanceof Error ? error.message : String(error) };
return c.json(body, 400);
}
});
/** 全链 Trace:一条工作流的 span 树 —— 节点 → 步骤 / 工具 / 模型 / 人卡 */
app.get(traceOfWorkflowRoute.path, async (c) => {
const denied = await governProcess(c.get('account'));
if (denied) return c.json(denied, denyStatus(denied));
const spans = await container.trace.ofWorkflow(c.req.param('id'));
return c.json({ spans });
});
/** 工作流下钻:流程包 × 节点实例 × 在途任务 —— 编排画布的数据面 */
app.get(govWorkflowRoute.path, async (c) => {
const denied = await readTask(c.get('account'));
+129 -17
View File
@@ -9,6 +9,7 @@ import type { EventService } from '@modules/event';
import type { LedgerService } from '@modules/ledger';
import type { MemoryService } from '@modules/memory';
import type { ToolboxService, ToolInvoker } from '@modules/toolbox';
import type { TraceService } from '@modules/trace';
import { compileToolBus } from '@modules/toolbox';
import type { HumanToolService } from '@modules/human-tool';
import { renderView } from '@modules/interaction';
@@ -25,7 +26,8 @@ import { createLoopStateStore, createTurnRunner } from '@modules/loop-engine';
import { createSkillEngineService } from '@modules/skill-engine';
import { createTaskService, createTaskStore } from '@modules/task';
import type { TaskService } from '@modules/task';
import { createProcessService, createProcessStore } from '@modules/process';
import { createProcessService, createProcessStore, createSelfHostedRuntime } from '@modules/process';
import type { FlowRuntime, ProcessServiceDeps } from '@modules/process';
import type { ProcessService } from '@modules/process';
import { createTriggerService, createTriggerStore } from '@modules/trigger';
import type { TriggerService } from '@modules/trigger';
@@ -56,6 +58,8 @@ export type EngineWiringDeps = {
readonly skills: SkillEngineService;
readonly release: ReleaseService;
readonly schemas: { validate(schema: Record<string, unknown>, value: unknown): { ok: boolean; errors: string[] } };
/** 全链观测:节点起落、工具/模型/人卡各打一个 span —— 观测尽力而为,不掀翻业务 */
readonly trace: TraceService;
};
export type EngineWiring = {
@@ -251,6 +255,13 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
}) {
const context = { taskId: input.taskId, ownerId: input.ownerId, humanCallCap: input.humanCallCap };
const title = String(input.card['title'] ?? '请处理');
// 人卡一张一个 span:发给谁、问了什么、什么工单 —— 「等人」这一段也要在时间轴上
const cardSpan = deps.trace.beginAction({
taskId: input.taskId,
name: `人卡:${title}`,
kind: 'human',
input: { workOrder: input.workOrder, ownerId: input.ownerId },
});
/*
* 依据快照:优先用活动给的结构化 `basis`(`{k,v}[]`);没给才把卡面其余键铺成依据。
@@ -300,6 +311,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
}),
});
await taskStore.addConsumption(input.taskId, { humanCalls: 1 });
await cardSpan?.end({ output: { cardId: issued.order.id, arrival: issued.arrival } });
return { id: issued.order.id };
}
const fields = declaredFields ?? [{ key: 'result', label: '结果', kind: 'textarea' as const, required: true }];
@@ -311,19 +323,28 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
});
// 打扰率的计量对象:每发一张卡就是调了一次人 —— 不计就等于没有上限
await taskStore.addConsumption(input.taskId, { humanCalls: 1 });
return { id: issued.order.id };
await cardSpan?.end({ output: { cardId: issued.order.id, arrival: issued.arrival } });
return { id: issued.order.id };
},
};
const toolGateway = {
invoke: async (input: { tool: string; input: Record<string, unknown>; activityId: string; taskId: TaskId; stepNo?: number }) => {
const result = await deps.toolInvoker.invoke({
tool: input.tool,
input: input.input,
activityId: input.activityId,
taskId: input.taskId,
...(input.stepNo === undefined ? {} : { stepNo: input.stepNo }),
});
// 每次工具调用一个 span —— 观测面要看得见活动内部到底调了什么、花了多久
const span = deps.trace.beginAction({ taskId: input.taskId, name: `工具:${input.tool}`, kind: 'tool', input: input.input });
const result = await deps.toolInvoker
.invoke({
tool: input.tool,
input: input.input,
activityId: input.activityId,
taskId: input.taskId,
...(input.stepNo === undefined ? {} : { stepNo: input.stepNo }),
})
.catch(async (error: unknown) => {
await span?.fail(error);
throw error;
});
await span?.end({ output: result.ok ? (result.output ?? {}) : { error: result.error?.message } });
return {
ok: result.ok,
...(result.output === undefined ? {} : { output: result.output }),
@@ -400,6 +421,37 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
clock,
});
/**
* 模型网关加观测:每次模型调用一个 span(用途名、标签、用量)——
* 「活动内部到底问了几次模型」在时间轴上要看得见。
* 包一层不改语义:录制回放、四元绑定、凭证隔离全在原网关里,这里只旁观。
*/
const tracedGateway: ModelGateway = {
...deps.modelGateway,
generate: async (call) => {
const span =
call.taskId === undefined
? undefined
: deps.trace.beginAction({
taskId: call.taskId,
name: `模型:${call.label ?? call.model}`,
kind: 'model',
input: { model: call.model, label: call.label },
});
try {
const result = await deps.modelGateway.generate(call);
await span?.end({
output: { chars: result.text.length },
attributes: { tokens: result.usage.inputTokens + result.usage.outputTokens, mode: result.mode },
});
return result;
} catch (error) {
await span?.fail(error);
throw error;
}
},
};
/* ── Agent 回路:单拍 or 开环(工具总线)────────────────────────── */
/** 活动声明的自定义模型名 —— 活动只见用途名,不见厂商型号 */
@@ -455,7 +507,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
const agentRunner =
globalThis.process.env['JCAI_AGENT_OPEN_LOOP'] === '1'
? createMastraAgentRunner({
gateway: deps.modelGateway,
gateway: tracedGateway,
modelOf,
busOf,
onUsage: onAgentUsage,
@@ -479,7 +531,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
},
},
})
: createAgentRunner({ gateway: deps.modelGateway, onUsage: onAgentUsage, modelOf });
: createAgentRunner({ gateway: tracedGateway, onUsage: onAgentUsage, modelOf });
/* ── loop-engine ──────────────────────────────────────────────── */
const loop = createTurnRunner({
@@ -725,7 +777,7 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
});
/* ── process(L7) ─────────────────────────────────────────────── */
const process: ProcessService = createProcessService({
const processDeps: ProcessServiceDeps = {
store: createProcessStore(db),
activities: {
async faces() {
@@ -776,8 +828,8 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
},
},
tasks: {
start: async (input) =>
task.start({
start: async (input) => {
const started = await task.start({
activityId: input.activityId,
ownerId: input.ownerId,
title: input.title,
@@ -785,11 +837,29 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
nodeId: input.nodeId,
data: input.data,
...(input.message === undefined ? {} : { message: input.message }),
}),
});
// 节点 span:活动内的每一步、每次调用都会挂在它下面
deps.trace.beginNode({
workflowId: input.workflowId,
nodeId: input.nodeId,
label: input.title,
taskId: started.id,
assigneeId: input.ownerId,
input: input.data,
});
return started;
},
},
approvals: {
async raise(input) {
// 审批点:卡到审批人工位 —— 与活动级的放行不同层
deps.trace.beginNode({
workflowId: input.workflowId,
nodeId: input.nodeId,
label: `审批:${input.subject}`,
assigneeId: input.approverId,
input: Object.fromEntries(input.basis.map((b) => [b.k, b.v])),
});
const issued = await deps.humanTool.issueExecution(
{ taskId: input.workflowId as never, ownerId: input.approverId, humanCallCap: 99 },
{
@@ -842,7 +912,24 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
},
clock,
ids,
});
};
/*
* 观测的挂点:**root span 必须先于任何节点 span**。
* `runtime.begin` 正是「单已建好、还没进第一个节点」那一刻 —— 挂在这儿,
* 后面所有节点/工具/模型/人卡的 span 才认得到父亲。挂在 `start` 返回之后就晚了:
* 起单是同步走到第一个节点的,节点 span 会先出生、另起一棵孤树(实测)。
*/
const selfRuntime = createSelfHostedRuntime(processDeps);
const tracedRuntime: FlowRuntime = {
...selfRuntime,
async begin(record, pkg) {
deps.trace.beginWorkflow({ workflowId: record.id, subject: record.subject, input: record.initiation });
await selfRuntime.begin(record, pkg);
},
};
const process: ProcessService = createProcessService({ ...processDeps, runtime: tracedRuntime });
/* ── trigger(L7) ────────────────────────────────────────────── */
const trigger = createTriggerService({
@@ -897,5 +984,30 @@ export function wireEngines(deps: EngineWiringDeps): EngineWiring {
// 延后绑定落定:此后任务终态才报得到流程层
processRef.bind(process);
return { task, process, trigger };
/**
* 流程服务加观测:起单开一条 trace(root span),终态收尾。
* 包一层不改语义 —— 节点、工具、模型、人卡的 span 都会挂进这条 trace。
*/
const tracedProcess: ProcessService = {
...process,
async onTaskTerminal(taskId, terminal, ports) {
// 节点 span 先收(这一步办完了),再让流程往下走 —— 时间轴上不倒序
await deps.trace.endNode({ taskId, output: { terminal } });
const state = await process.onTaskTerminal(taskId, terminal, ports);
return state;
},
async onApprovalVerdict(workflowId, nodeId, verdict, payload) {
await deps.trace.endNode({ workflowId, nodeId, output: { verdict } });
const state = await process.onApprovalVerdict(workflowId, nodeId, verdict, payload);
if (state === 'completed' || state === 'terminated') await deps.trace.endWorkflow({ workflowId, state });
return state;
},
async refreshState(workflowId) {
const state = await process.refreshState(workflowId);
if (state === 'completed' || state === 'terminated') await deps.trace.endWorkflow({ workflowId, state });
return state;
},
};
return { task, process: tracedProcess, trigger };
}
+5
View File
@@ -49,6 +49,11 @@ export type StartWorkflowInput = {
/** 凡单必有来源:触发器即来源;纪要登记起单的,登记人即来源 */
readonly source: { kind: 'periodic' | 'event' | 'registration'; ref: string; by?: string };
readonly data: Record<string, unknown>;
/**
* 节点指派 `{ 节点id → 员工id }`(可选)—— 起单时把某几个节点定向给指定账号。
* 不传即照旧:员工组轮值 / 全员轮值。指的人不在岗时退回轮值,单不能没人接。
*/
readonly assignments?: Record<string, string>;
};
export type ProcessService = {
+3
View File
@@ -64,3 +64,6 @@ export type { CompiledFlow, CompileHooks, RunResult } from './internal/adapter/w
export { compilePackage } from './internal/adapter/workflow-compiler.ts';
export type { MastraRunner, MastraRuntimeDeps } from './internal/adapter/mastra-runtime.ts';
export { createMastraRuntime } from './internal/adapter/mastra-runtime.ts';
/** 自持运行时(默认实现)—— 装配层要包一层观测时得拿得到它 */
export type { SelfHostedRuntimeDeps } from './internal/runtime/self-hosted-runtime.ts';
export { createSelfHostedRuntime } from './internal/runtime/self-hosted-runtime.ts';
@@ -32,6 +32,8 @@ export const workflowTable = sys.table(
initiation: jsonb('initiation').notNull(),
/** 工作组绑定快照(ADR-011)—— 起单时装载,之后改组不影响这条单 */
workGroup: jsonb('work_group'),
/** 节点指派:{ 节点id → 员工id } —— 测试台定向;生产起单不传,照旧轮值 */
assignments: jsonb('assignments'),
ports: jsonb('ports').notNull().default({}),
vars: jsonb('vars').notNull().default({}),
startedAt: timestamp('started_at', { withTimezone: true }).notNull().defaultNow(),
@@ -21,6 +21,7 @@ function toWorkflow(row: WorkflowRow): WorkflowRecord {
source: row.source as WorkflowRecord['source'],
initiation: row.initiation as Record<string, unknown>,
...(row.workGroup === null ? {} : { workGroup: row.workGroup as Record<string, string> }),
...(row.assignments === null ? {} : { assignments: row.assignments as Record<string, string> }),
ports: row.ports as Record<string, Record<string, unknown>>,
vars: row.vars as Record<string, unknown>,
startedAt: row.startedAt.toISOString(),
@@ -107,6 +108,7 @@ export function createProcessStore(db: Db): ProcessStore {
source: record.source,
initiation: record.initiation,
workGroup: record.workGroup ?? null,
assignments: record.assignments ?? null,
ports: record.ports,
vars: record.vars,
startedAt: new Date(record.startedAt),
@@ -151,6 +153,8 @@ export function createProcessStore(db: Db): ProcessStore {
...(r.failureDigested === null ? {} : { failureDigested: r.failureDigested }),
...(r.taskId === null ? {} : { taskId: r.taskId }),
...(r.approvalId === null ? {} : { approvalId: r.approvalId }),
// 派给谁:审批箱收件、员工面链条、治理列表全按它认人 —— 写了不读回来等于没写
...(r.assigneeId === null ? {} : { assigneeId: r.assigneeId }),
}),
);
},
@@ -101,21 +101,31 @@ export function createSelfHostedRuntime(deps: SelfHostedRuntimeDeps): FlowRuntim
}
/*
* 谁来干:**编排不带岗位**(2026-07-28 第五轮,活动点与审批点同一口径)——
* 负责人是**运营时分配**的:工作流绑了员工组就按组轮值(组员并集里最闲者先),
* 没绑组就全员轮值;staffId 稳定排序保证同输入必同结果。
* 分派结果记在节点实例上(assignee)—— 审批箱、链条、治理列表都按它认人。
* 谁来干:**编排不带岗位**(活动点与审批点同一口径)——负责人是**运营时分配**的。
* 三级取人,先到先得:
* ① **节点指派**:起单时点名了这个节点归谁(测试台定向)—— 人在岗就是他;
* ② 员工组轮值:工作流绑了组,在组员并集里挑最闲的;
* ③ 全员轮值:在岗候选并集里挑最闲的。
* staffId 稳定排序保证同输入必同结果;分派结果记在节点实例上(assignee)——
* 审批箱、链条、治理列表都按它认人。
*/
const seen = new Map<string, { staffId: string; name: string; openTasks: number }>();
for (const position of await deps.org.positions()) {
for (const c of await deps.org.candidates(position)) if (!seen.has(c.staffId)) seen.set(c.staffId, c);
}
const everyone = [...seen.values()];
// ① 点名优先:指的人得在岗 —— 不在岗就退回轮值,单不能没人接
const named = record.assignments?.[node.id];
const namedHit = named === undefined ? undefined : everyone.find((c) => c.staffId === named);
const group = new Set(Object.values(record.workGroup ?? {}));
const groupPool = everyone.filter((c) => group.has(c.staffId));
// 组员全不在岗时退回全员 —— 单不能没人接
const pool = groupPool.length > 0 ? groupPool : everyone;
const spec = { position: groupPool.length > 0 ? '(员工组轮值)' : '(全员轮值)', strategy: 'sole' as const };
const pool = namedHit !== undefined ? [namedHit] : groupPool.length > 0 ? groupPool : everyone;
const spec = {
position: namedHit !== undefined ? '(节点指派)' : groupPool.length > 0 ? '(员工组轮值)' : '(全员轮值)',
strategy: 'sole' as const,
};
const resolution = resolveOwner(spec, { candidates: pool });
if (resolution.kind === 'vacant') throw new PositionVacantError(spec.position);
@@ -201,6 +201,8 @@ export function createProcessService(deps: ProcessServiceDeps): ProcessService {
vars: {},
startedAt: deps.clock.now().toISOString(),
...(binding === undefined ? {} : { workGroup: { ...binding } }),
// 节点指派:起单时定死某几个节点归谁(测试台定向);不传即照旧轮值
...(input.assignments === undefined ? {} : { assignments: { ...input.assignments } }),
};
await deps.store.insertWorkflow(record);
await deps.events.publish({ workflowId: record.id, kind: 'workflow-started', detail: { source: input.source } });
+6
View File
@@ -23,6 +23,12 @@ export type WorkflowRecord = {
* 留痕要能解释「当时为什么派给他」,而不是被今天的组织调整改写(同 AM 9.1 版本信封)。
*/
readonly workGroup?: Record<string, string>;
/**
* 节点指派 `{ 节点id → 员工id }` —— **起单时定死某几个节点归谁**。
* 分派仍是运营时的事:这只是在轮值之前插一句「这个节点就他了」,
* 指的人不在岗则退回轮值(单不能没人接)。测试台用它把整条流走给指定账号。
*/
readonly assignments?: Record<string, string>;
/** 各节点输出口载荷 —— 边映射装配的来源 */
readonly ports: Record<string, Record<string, unknown>>;
readonly vars: Record<string, unknown>;
+28
View File
@@ -0,0 +1,28 @@
# module: trace
职责: 全链观测 —— 一条工作流一条 trace;节点实例、活动内每一步、
每次工具调用 / 模型调用 / 人卡各一个 span(嵌套、耗时、入出参、错误)
不负责: 人读的留痕(那是 memory 的任务记忆)、事件流(那是 event 的信封)
公开出口: index.ts
- 类型:`TraceService` `TraceSpan` `SpanHandle` `SpanKind` `TraceStore` `SpanFactory`
- 用例工厂:`createTraceService(deps)`
- adapter:`createTraceStore`;Drizzle 表:`traceSpanTable`
层级: L2(编码规范 §4;只依赖 shared)
依赖: 无模块依赖
被依赖: app(装配层把 span 打在执行链路上)
不变量:
- **span 由 Mastra Observability 组件产出**:我们的执行不经 Mastra 图,
所以是**手动打点** —— app 层用 `Observability` 实例做 `SpanFactory`,
换掉它只需换这一个工厂
- **一开就落、一收就补**:不走 Mastra exporter 的批量异步路(缓冲 + 定时冲刷),
测试台要的是「点一下就看得见」—— 观测面不该比被观测的事晚
- **观测尽力而为**:写 span 失败不掀翻被观测的事(try/catch 吞掉)——
观测坏了不该让流程停摆
- **三样东西各管各的,谁也不替代谁**:
· **trace**(此模块)= 机器读的时间轴:嵌套、耗时、入出参、错误;
· **任务记忆**(memory)= 人读的留痕:谁在何时做了什么、依据什么;
· **工作区报告**(工作台)= **活动自持的 HTML 报告** —— 活动自己产出的业务成果,
系统不代它生成、不从 trace 里凑。观测面看得见「怎么跑的」,报告说的是「干出了什么」
+91
View File
@@ -0,0 +1,91 @@
/**
* module: trace —— **全链留痕的观测面**(设计台「每个活动完全 Trace 监控」)。
*
* 一条工作流 = 一条 trace;节点实例 = 一个 span;活动内的每一步、每次工具调用、
* 每次模型调用、每张人卡 = 子 span。span 由 **Mastra Observability 组件**产出
* (我们的执行不经 Mastra 图,所以是**手动打点**),再由自定义 exporter 落库。
*
* 与任务记忆的分工:任务记忆是**人读的留痕**(谁在何时做了什么、依据什么),
* 一件事一行;trace 是**机器读的时间轴**(嵌套、耗时、入出参、错误),
* 一次调用一个 span。两者并行,不互相替代。
*/
export type SpanKind = 'workflow' | 'node' | 'step' | 'tool' | 'model' | 'human';
export type TraceSpan = {
readonly id: string;
readonly traceId: string;
readonly parentId?: string;
readonly name: string;
readonly kind: SpanKind;
readonly workflowId?: string;
readonly nodeId?: string;
readonly taskId?: string;
readonly input?: Record<string, unknown>;
readonly output?: Record<string, unknown>;
readonly error?: { message: string };
readonly attributes?: Record<string, unknown>;
readonly startedAt: string;
readonly endedAt?: string;
};
/** 一次可观测的执行:开一个 span,干活,收尾 —— 收尾必做,不然时间轴上永远挂着 */
export type SpanHandle = {
readonly id: string;
readonly traceId: string;
child(input: {
name: string;
kind: SpanKind;
nodeId?: string;
taskId?: string;
input?: Record<string, unknown>;
attributes?: Record<string, unknown>;
}): SpanHandle;
end(out?: { output?: Record<string, unknown>; attributes?: Record<string, unknown> }): Promise<void>;
fail(error: unknown): Promise<void>;
};
export type TraceService = {
/*
* 作用域索引 —— 调用方只报 id,父子关系由服务自己拼。
* 打点散在装配层的十几处,让每处自己拎 handle 传来传去,一处漏传整棵树就断了。
*/
/** 开一条工作流的 trace(root span) */
beginWorkflow(input: { workflowId: string; subject: string; input?: Record<string, unknown> }): void;
/** 开一个节点 span(自动挂在工作流 span 下) */
beginNode(input: {
workflowId: string;
nodeId: string;
label: string;
taskId?: string;
approvalId?: string;
assigneeId?: string;
input?: Record<string, unknown>;
}): void;
/** 活动内的一次动作:步骤 / 工具 / 模型 / 人卡 —— 自动挂在该任务的节点 span 下 */
beginAction(input: {
taskId: string;
name: string;
kind: SpanKind;
input?: Record<string, unknown>;
}): SpanHandle | undefined;
/** 收尾:节点终态 / 工作流终态 */
endNode(input: { taskId?: string; workflowId?: string; nodeId?: string; output?: Record<string, unknown> }): Promise<void>;
endWorkflow(input: { workflowId: string; state: string }): Promise<void>;
/** 起一条 trace(工作流级)或挂在既有 trace 下 —— 低层口,便利层用它 */
start(input: {
name: string;
kind: SpanKind;
workflowId?: string;
nodeId?: string;
taskId?: string;
parent?: { traceId: string; spanId: string };
input?: Record<string, unknown>;
attributes?: Record<string, unknown>;
}): SpanHandle;
/** 这条工作流的完整 span 树(按开始时间序;树形由 parentId 拼) */
ofWorkflow(workflowId: string): Promise<TraceSpan[]>;
/** 这个任务(活动实例)的 span —— 活动内每一步都在这儿 */
ofTask(taskId: string): Promise<TraceSpan[]>;
flush(): Promise<void>;
};
+10
View File
@@ -0,0 +1,10 @@
/**
* module `trace` 唯一出口(编码规范 §9.1)。
* 全链观测:一条工作流一条 trace,节点/步骤/工具/模型/人卡逐层 span。
*/
export type { SpanHandle, SpanKind, TraceService, TraceSpan } from './contract.ts';
export type { Clock, TraceStore } from './port.ts';
export type { SpanFactory, TraceServiceDeps } from './internal/usecase/trace-service.ts';
export { createTraceService } from './internal/usecase/trace-service.ts';
export { createTraceStore } from './internal/adapter/trace-store.ts';
export { traceSpanTable } from './internal/adapter/trace-schema.ts';
@@ -0,0 +1,27 @@
/**
* trace 的 sys schema 表(编码规范 §7 / ADR-002)。
* span 只增不改:开 span 一行,收尾补 output/ended_at —— 与留痕同一条规矩。
*/
import { index, jsonb, text, timestamp } from 'drizzle-orm/pg-core';
import { sys } from '@shared/db.ts';
export const traceSpanTable = sys.table(
'trace_span',
{
id: text('id').primaryKey(),
traceId: text('trace_id').notNull(),
parentId: text('parent_id'),
name: text('name').notNull(),
kind: text('kind').notNull(),
workflowId: text('workflow_id'),
nodeId: text('node_id'),
taskId: text('task_id'),
input: jsonb('input'),
output: jsonb('output'),
error: jsonb('error'),
attributes: jsonb('attributes'),
startedAt: timestamp('started_at', { withTimezone: true }).notNull(),
endedAt: timestamp('ended_at', { withTimezone: true }),
},
(t) => [index('trace_span_trace_idx').on(t.traceId, t.startedAt), index('trace_span_workflow_idx').on(t.workflowId, t.startedAt)],
);
@@ -0,0 +1,78 @@
/** trace 存储 adapter —— Drizzle 只准出现在 adapter(编码规范 §5.3) */
import { asc, eq } from 'drizzle-orm';
import type { Db } from '@shared/db.ts';
import type { TraceSpan } from '../../contract.ts';
import type { TraceStore } from '../../port.ts';
import { traceSpanTable } from './trace-schema.ts';
type Row = typeof traceSpanTable.$inferSelect;
const toSpan = (r: Row): TraceSpan => ({
id: r.id,
traceId: r.traceId,
...(r.parentId === null ? {} : { parentId: r.parentId }),
name: r.name,
kind: r.kind as TraceSpan['kind'],
...(r.workflowId === null ? {} : { workflowId: r.workflowId }),
...(r.nodeId === null ? {} : { nodeId: r.nodeId }),
...(r.taskId === null ? {} : { taskId: r.taskId }),
...(r.input === null ? {} : { input: r.input as Record<string, unknown> }),
...(r.output === null ? {} : { output: r.output as Record<string, unknown> }),
...(r.error === null ? {} : { error: r.error as { message: string } }),
...(r.attributes === null ? {} : { attributes: r.attributes as Record<string, unknown> }),
startedAt: r.startedAt.toISOString(),
...(r.endedAt === null ? {} : { endedAt: r.endedAt.toISOString() }),
});
export function createTraceStore(db: Db): TraceStore {
return {
async insert(span) {
await db.insert(traceSpanTable).values({
id: span.id,
traceId: span.traceId,
parentId: span.parentId ?? null,
name: span.name,
kind: span.kind,
workflowId: span.workflowId ?? null,
nodeId: span.nodeId ?? null,
taskId: span.taskId ?? null,
input: span.input ?? null,
output: span.output ?? null,
error: span.error ?? null,
attributes: span.attributes ?? null,
startedAt: new Date(span.startedAt),
endedAt: span.endedAt === undefined ? null : new Date(span.endedAt),
});
},
async finish(id, patch) {
await db
.update(traceSpanTable)
.set({
...(patch.output === undefined ? {} : { output: patch.output }),
...(patch.error === undefined ? {} : { error: patch.error }),
...(patch.attributes === undefined ? {} : { attributes: patch.attributes }),
endedAt: new Date(patch.endedAt),
})
.where(eq(traceSpanTable.id, id));
},
async listByWorkflow(workflowId) {
const rows = await db
.select()
.from(traceSpanTable)
.where(eq(traceSpanTable.workflowId, workflowId))
.orderBy(asc(traceSpanTable.startedAt));
return rows.map(toSpan);
},
async listByTask(taskId) {
const rows = await db
.select()
.from(traceSpanTable)
.where(eq(traceSpanTable.taskId, taskId))
.orderBy(asc(traceSpanTable.startedAt));
return rows.map(toSpan);
},
};
}
@@ -0,0 +1,185 @@
/**
* trace 用例 —— **手动打点的全链观测**(编码规范 §5.3;`@mastra` 只准 adapter/app §6.2)。
*
* 我们的执行不经 Mastra 图(自持流程运行时 + 自持范式引擎),所以 Mastra 自带的
* 自动 tracing 不会产出任何 span。这里把它的**观测组件**当作产 span 的工具用:
* `Observability` 实例出 span(标准形状:traceId / parentSpanId / input / output /
* errorInfo / 起止时刻),我们在执行链路的关键点手动开合。
*
* 落库不用 Mastra 的 storage —— exporter 那条路是**批量异步**的(缓冲 + 定时冲刷),
* 而测试台要的是「点一下就看得见」。所以 span 一开就落一行、一收就补一行,
* 两笔写都在 usecase 里同步做:**观测面不该比被观测的事晚**。
*
* Mastra 侧只用来生成 id 与保证 span 形状 —— 换掉它只需改 `spanFactory`。
*/
import type { SpanHandle, SpanKind, TraceService, TraceSpan } from '../../contract.ts';
import type { Clock, TraceStore } from '../../port.ts';
/** span 工厂:出 id 与父子关系 —— 由 app 层用 Mastra 的 Observability 供给 */
export type SpanFactory = {
root(name: string): { spanId: string; traceId: string };
child(parent: { traceId: string; spanId: string }, name: string): { spanId: string; traceId: string };
};
export type TraceServiceDeps = {
readonly store: TraceStore;
readonly clock: Clock;
readonly spans: SpanFactory;
};
export function createTraceService(deps: TraceServiceDeps): TraceService {
/** 落一行「开」;失败不掀翻被观测的事 —— 观测坏了不该让业务停摆 */
const open = async (span: TraceSpan): Promise<void> => {
try {
await deps.store.insert(span);
} catch {
/* 观测尽力而为:trace 写不进去也不能让流程跑不动 */
}
};
const close = async (
id: string,
patch: { output?: Record<string, unknown>; error?: { message: string }; attributes?: Record<string, unknown> },
): Promise<void> => {
try {
await deps.store.finish(id, { ...patch, endedAt: deps.clock.now().toISOString() });
} catch {
/* 同上 */
}
};
const handleOf = (
ids: { spanId: string; traceId: string },
scope: { workflowId?: string; nodeId?: string; taskId?: string },
): SpanHandle => ({
id: ids.spanId,
traceId: ids.traceId,
child(input) {
const born = deps.spans.child({ traceId: ids.traceId, spanId: ids.spanId }, input.name);
// 归属向下继承:子 span 没说自己属于谁,就跟父亲同一条流、同一个节点
const childScope = {
...scope,
...(input.nodeId === undefined ? {} : { nodeId: input.nodeId }),
...(input.taskId === undefined ? {} : { taskId: input.taskId }),
};
void open({
id: born.spanId,
traceId: born.traceId,
parentId: ids.spanId,
name: input.name,
kind: input.kind,
...childScope,
...(input.input === undefined ? {} : { input: input.input }),
...(input.attributes === undefined ? {} : { attributes: input.attributes }),
startedAt: deps.clock.now().toISOString(),
});
return handleOf(born, childScope);
},
async end(out) {
await close(ids.spanId, { ...(out?.output === undefined ? {} : { output: out.output }), ...(out?.attributes === undefined ? {} : { attributes: out.attributes }) });
},
async fail(error) {
await close(ids.spanId, { error: { message: error instanceof Error ? error.message : String(error) } });
},
});
/*
* 作用域索引:workflowId → 工作流 span,taskId → 节点 span。
* 打点散在装配层十几处,让每处自己拎 handle 传来传去,一处漏传整棵树就断了 ——
* 所以由服务记着「这一单现在挂在哪儿」,调用方只报 id。
* 进程内存活即可:trace 已经落库了,这张表只是为了拼父子。
*/
const workflowSpans = new Map<string, SpanHandle>();
const nodeSpans = new Map<string, SpanHandle>();
const service: TraceService = {
beginWorkflow({ workflowId, subject, input }) {
const span = service.start({
name: `流程:${subject}`,
kind: 'workflow',
workflowId,
...(input === undefined ? {} : { input }),
});
workflowSpans.set(workflowId, span);
},
beginNode({ workflowId, nodeId, label, taskId, approvalId, assigneeId, input }) {
// 派给谁、卡了哪张审批单 —— 开 span 那一刻就写清「这一步在谁手上」
const shape = {
name: `${nodeId} ${label}`,
kind: 'node' as const,
nodeId,
...(taskId === undefined ? {} : { taskId }),
...(input === undefined ? {} : { input }),
attributes: {
...(assigneeId === undefined ? {} : { assigneeId }),
...(approvalId === undefined ? {} : { approvalId }),
},
};
// 工作流 span 不在手上(进程重启过)就另起一棵 —— 观测尽力而为,不让业务停摆
const parent = workflowSpans.get(workflowId);
const span = parent === undefined ? service.start({ ...shape, workflowId }) : parent.child(shape);
nodeSpans.set(taskId ?? `${workflowId}:${nodeId}`, span);
},
beginAction({ taskId, name, kind, input }) {
const parent = nodeSpans.get(taskId);
if (parent === undefined) return undefined;
return parent.child({ name, kind, taskId, ...(input === undefined ? {} : { input }) });
},
async endNode({ taskId, workflowId, nodeId, output }) {
const key = taskId ?? `${workflowId}:${nodeId}`;
const span = nodeSpans.get(key);
if (span === undefined) return;
nodeSpans.delete(key);
await span.end(output === undefined ? undefined : { output });
},
async endWorkflow({ workflowId, state }) {
const span = workflowSpans.get(workflowId);
if (span === undefined) return;
workflowSpans.delete(workflowId);
await span.end({ output: { state } });
},
start(input) {
const born =
input.parent === undefined ? deps.spans.root(input.name) : deps.spans.child(input.parent, input.name);
const scope = {
...(input.workflowId === undefined ? {} : { workflowId: input.workflowId }),
...(input.nodeId === undefined ? {} : { nodeId: input.nodeId }),
...(input.taskId === undefined ? {} : { taskId: input.taskId }),
};
void open({
id: born.spanId,
traceId: born.traceId,
...(input.parent === undefined ? {} : { parentId: input.parent.spanId }),
name: input.name,
kind: input.kind as SpanKind,
...scope,
...(input.input === undefined ? {} : { input: input.input }),
...(input.attributes === undefined ? {} : { attributes: input.attributes }),
startedAt: deps.clock.now().toISOString(),
});
return handleOf(born, scope);
},
async ofWorkflow(workflowId) {
return deps.store.listByWorkflow(workflowId);
},
async ofTask(taskId) {
return deps.store.listByTask(taskId);
},
async flush() {
/* 同步落库,没有缓冲要冲 —— 留这个口子是给将来换成批量 exporter 用的 */
},
};
return service;
}
+12
View File
@@ -0,0 +1,12 @@
/** trace 的存储端口 —— 落库形状由 adapter 决定,usecase 不认识 Drizzle */
import type { TraceSpan } from './contract.ts';
export type TraceStore = {
/** 只增不改的两笔:开 span 写一行,收尾补 output/ended_at */
insert(span: TraceSpan): Promise<void>;
finish(id: string, patch: { output?: Record<string, unknown>; error?: { message: string }; attributes?: Record<string, unknown>; endedAt: string }): Promise<void>;
listByWorkflow(workflowId: string): Promise<TraceSpan[]>;
listByTask(taskId: string): Promise<TraceSpan[]>;
};
export type Clock = { now(): Date };