diff --git a/api-report/apps/api/src/modules/process/contract.d.ts b/api-report/apps/api/src/modules/process/contract.d.ts index d18d01f..5740333 100644 --- a/api-report/apps/api/src/modules/process/contract.d.ts +++ b/api-report/apps/api/src/modules/process/contract.d.ts @@ -25,6 +25,11 @@ export type StartWorkflowInput = { by?: string; }; readonly data: Record; + /** + * 节点指派 `{ 节点id → 员工id }`(可选)—— 起单时把某几个节点定向给指定账号。 + * 不传即照旧:员工组轮值 / 全员轮值。指的人不在岗时退回轮值,单不能没人接。 + */ + readonly assignments?: Record; }; export type ProcessService = { /** diff --git a/api-report/apps/api/src/modules/process/index.d.ts b/api-report/apps/api/src/modules/process/index.d.ts index 1736f37..3c1ae84 100644 --- a/api-report/apps/api/src/modules/process/index.d.ts +++ b/api-report/apps/api/src/modules/process/index.d.ts @@ -15,3 +15,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'; diff --git a/api-report/apps/api/src/modules/process/port.d.ts b/api-report/apps/api/src/modules/process/port.d.ts index 3f0cfc7..2f2586f 100644 --- a/api-report/apps/api/src/modules/process/port.d.ts +++ b/api-report/apps/api/src/modules/process/port.d.ts @@ -29,6 +29,12 @@ export type WorkflowRecord = { * 留痕要能解释「当时为什么派给他」,而不是被今天的组织调整改写(同 AM 9.1 版本信封)。 */ readonly workGroup?: Record; + /** + * 节点指派 `{ 节点id → 员工id }` —— **起单时定死某几个节点归谁**。 + * 分派仍是运营时的事:这只是在轮值之前插一句「这个节点就他了」, + * 指的人不在岗则退回轮值(单不能没人接)。测试台用它把整条流走给指定账号。 + */ + readonly assignments?: Record; /** 各节点输出口载荷 —— 边映射装配的来源 */ readonly ports: Record>; readonly vars: Record; diff --git a/api-report/apps/api/src/modules/trace/contract.d.ts b/api-report/apps/api/src/modules/trace/contract.d.ts new file mode 100644 index 0000000..ba4d8a8 --- /dev/null +++ b/api-report/apps/api/src/modules/trace/contract.d.ts @@ -0,0 +1,103 @@ +/** + * 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; + readonly output?: Record; + readonly error?: { + message: string; + }; + readonly attributes?: Record; + 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; + attributes?: Record; + }): SpanHandle; + end(out?: { + output?: Record; + attributes?: Record; + }): Promise; + fail(error: unknown): Promise; +}; +export type TraceService = { + /** 开一条工作流的 trace(root span) */ + beginWorkflow(input: { + workflowId: string; + subject: string; + input?: Record; + }): void; + /** 开一个节点 span(自动挂在工作流 span 下) */ + beginNode(input: { + workflowId: string; + nodeId: string; + label: string; + taskId?: string; + approvalId?: string; + assigneeId?: string; + input?: Record; + }): void; + /** 活动内的一次动作:步骤 / 工具 / 模型 / 人卡 —— 自动挂在该任务的节点 span 下 */ + beginAction(input: { + taskId: string; + name: string; + kind: SpanKind; + input?: Record; + }): SpanHandle | undefined; + /** 收尾:节点终态 / 工作流终态 */ + endNode(input: { + taskId?: string; + workflowId?: string; + nodeId?: string; + output?: Record; + }): Promise; + endWorkflow(input: { + workflowId: string; + state: string; + }): Promise; + /** 起一条 trace(工作流级)或挂在既有 trace 下 —— 低层口,便利层用它 */ + start(input: { + name: string; + kind: SpanKind; + workflowId?: string; + nodeId?: string; + taskId?: string; + parent?: { + traceId: string; + spanId: string; + }; + input?: Record; + attributes?: Record; + }): SpanHandle; + /** 这条工作流的完整 span 树(按开始时间序;树形由 parentId 拼) */ + ofWorkflow(workflowId: string): Promise; + /** 这个任务(活动实例)的 span —— 活动内每一步都在这儿 */ + ofTask(taskId: string): Promise; + flush(): Promise; +}; diff --git a/api-report/apps/api/src/modules/trace/index.d.ts b/api-report/apps/api/src/modules/trace/index.d.ts new file mode 100644 index 0000000..38b0314 --- /dev/null +++ b/api-report/apps/api/src/modules/trace/index.d.ts @@ -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'; diff --git a/api-report/apps/api/src/modules/trace/port.d.ts b/api-report/apps/api/src/modules/trace/port.d.ts new file mode 100644 index 0000000..a29f0a9 --- /dev/null +++ b/api-report/apps/api/src/modules/trace/port.d.ts @@ -0,0 +1,19 @@ +/** trace 的存储端口 —— 落库形状由 adapter 决定,usecase 不认识 Drizzle */ +import type { TraceSpan } from './contract.ts'; +export type TraceStore = { + /** 只增不改的两笔:开 span 写一行,收尾补 output/ended_at */ + insert(span: TraceSpan): Promise; + finish(id: string, patch: { + output?: Record; + error?: { + message: string; + }; + attributes?: Record; + endedAt: string; + }): Promise; + listByWorkflow(workflowId: string): Promise; + listByTask(taskId: string): Promise; +}; +export type Clock = { + now(): Date; +}; diff --git a/api-report/packages/contracts/src/api/governance.d.ts b/api-report/packages/contracts/src/api/governance.d.ts index 32e229e..ddea237 100644 --- a/api-report/packages/contracts/src/api/governance.d.ts +++ b/api-report/packages/contracts/src/api/governance.d.ts @@ -2106,6 +2106,64 @@ export declare const listApprovalsRoute: { }, z.core.$strip>>; }, z.core.$strip>; }; +/** + * 起一条流(测试台专用):**带节点指派**把整条流定向到指定账号 —— + * 真实任务照旧落到那个人的工作台,测试台只管起单与观测。 + */ +export declare const testRunRoute: { + readonly method: "POST"; + readonly path: "/api/gov/testbed/run"; + readonly body: z.ZodObject<{ + packageId: z.ZodString; + subject: z.ZodString; + data: z.ZodRecord; + assignments: z.ZodDefault>; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + workflowId: z.ZodString; + state: z.ZodEnum<{ + terminated: "terminated"; + running: "running"; + waiting: "waiting"; + completed: "completed"; + abnormal: "abnormal"; + }>; + }, z.core.$strip>; +}; +/** 全链 Trace:一条工作流的 span 树(节点 → 步骤 / 工具 / 模型 / 人卡) */ +export declare const traceOfWorkflowRoute: { + readonly method: "GET"; + readonly path: "/api/gov/workflows/:id/trace"; + readonly params: z.ZodObject<{ + id: z.ZodString; + }, z.core.$strip>; + readonly output: z.ZodObject<{ + spans: z.ZodArray; + name: z.ZodString; + kind: z.ZodEnum<{ + node: "node"; + human: "human"; + workflow: "workflow"; + tool: "tool"; + model: "model"; + step: "step"; + }>; + nodeId: z.ZodOptional; + taskId: z.ZodOptional; + input: z.ZodOptional>; + output: z.ZodOptional>; + error: z.ZodOptional>; + attributes: z.ZodOptional>; + startedAt: z.ZodString; + endedAt: z.ZodOptional; + }, z.core.$strip>>; + }, z.core.$strip>; +}; export declare const govWorkflowRoute: { readonly method: "GET"; readonly path: "/api/gov/workflows/:id"; diff --git a/apps/api/package.json b/apps/api/package.json index 05ad668..e389b21 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -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", diff --git a/apps/api/src/app/__tests__/testbed-trace.it.test.ts b/apps/api/src/app/__tests__/testbed-trace.it.test.ts new file mode 100644 index 0000000..d87cf00 --- /dev/null +++ b/apps/api/src/app/__tests__/testbed-trace.it.test.ts @@ -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 } | 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 { + 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 acceptAs(workflowId: string, staffId: 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: {} }, + 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(); + }); +}); diff --git a/apps/api/src/app/container.ts b/apps/api/src/app/container.ts index 3614a0b..76d13d2 100644 --- a/apps/api/src/app/container.ts +++ b/apps/api/src/app/container.ts @@ -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; }; @@ -512,8 +517,19 @@ export async function createContainer(config: AppConfig): Promise { /* ── 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 { trigger: engines.trigger, minute: deskElements.minute, report: deskElements.report, + trace, async close() { await pool.end(); }, diff --git a/apps/api/src/app/mastra-spans.ts b/apps/api/src/app/mastra-spans.ts new file mode 100644 index 0000000..ee7b98e --- /dev/null +++ b/apps/api/src/app/mastra-spans.ts @@ -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` 泛型极深且随版本变;这里只声明**我们真正用到的**面, + * 由 `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(); + + 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 })); + }, + }; +} diff --git a/apps/api/src/app/routes/governance-ops.ts b/apps/api/src/app/routes/governance-ops.ts index 5e8cf0a..eb6ee77 100644 --- a/apps/api/src/app/routes/governance-ops.ts +++ b/apps/api/src/app/routes/governance-ops.ts @@ -25,6 +25,8 @@ import { listTriggersRoute, registerToolRoute, saveProcessRoute, + testRunRoute, + traceOfWorkflowRoute, setProcessStateRoute, reassignTaskRoute, resumeTaskRoute, @@ -213,6 +215,46 @@ export function registerGovernanceOpsRoutes(app: Hono, 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')); diff --git a/apps/api/src/app/wiring/engines.ts b/apps/api/src/app/wiring/engines.ts index c659ff0..890b1be 100644 --- a/apps/api/src/app/wiring/engines.ts +++ b/apps/api/src/app/wiring/engines.ts @@ -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, 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; 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 }; } diff --git a/apps/api/src/modules/process/contract.ts b/apps/api/src/modules/process/contract.ts index 40269b4..369941f 100644 --- a/apps/api/src/modules/process/contract.ts +++ b/apps/api/src/modules/process/contract.ts @@ -49,6 +49,11 @@ export type StartWorkflowInput = { /** 凡单必有来源:触发器即来源;纪要登记起单的,登记人即来源 */ readonly source: { kind: 'periodic' | 'event' | 'registration'; ref: string; by?: string }; readonly data: Record; + /** + * 节点指派 `{ 节点id → 员工id }`(可选)—— 起单时把某几个节点定向给指定账号。 + * 不传即照旧:员工组轮值 / 全员轮值。指的人不在岗时退回轮值,单不能没人接。 + */ + readonly assignments?: Record; }; export type ProcessService = { diff --git a/apps/api/src/modules/process/index.ts b/apps/api/src/modules/process/index.ts index 4594452..7aa57ef 100644 --- a/apps/api/src/modules/process/index.ts +++ b/apps/api/src/modules/process/index.ts @@ -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'; diff --git a/apps/api/src/modules/process/internal/adapter/process-schema.ts b/apps/api/src/modules/process/internal/adapter/process-schema.ts index 678c3b5..3a0ff48 100644 --- a/apps/api/src/modules/process/internal/adapter/process-schema.ts +++ b/apps/api/src/modules/process/internal/adapter/process-schema.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(), diff --git a/apps/api/src/modules/process/internal/adapter/process-store.ts b/apps/api/src/modules/process/internal/adapter/process-store.ts index 54f0857..c26831c 100644 --- a/apps/api/src/modules/process/internal/adapter/process-store.ts +++ b/apps/api/src/modules/process/internal/adapter/process-store.ts @@ -21,6 +21,7 @@ function toWorkflow(row: WorkflowRow): WorkflowRecord { source: row.source as WorkflowRecord['source'], initiation: row.initiation as Record, ...(row.workGroup === null ? {} : { workGroup: row.workGroup as Record }), + ...(row.assignments === null ? {} : { assignments: row.assignments as Record }), ports: row.ports as Record>, vars: row.vars as Record, 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 }), }), ); }, diff --git a/apps/api/src/modules/process/internal/runtime/self-hosted-runtime.ts b/apps/api/src/modules/process/internal/runtime/self-hosted-runtime.ts index 623eb9b..15f6c41 100644 --- a/apps/api/src/modules/process/internal/runtime/self-hosted-runtime.ts +++ b/apps/api/src/modules/process/internal/runtime/self-hosted-runtime.ts @@ -101,21 +101,31 @@ export function createSelfHostedRuntime(deps: SelfHostedRuntimeDeps): FlowRuntim } /* - * 谁来干:**编排不带岗位**(2026-07-28 第五轮,活动点与审批点同一口径)—— - * 负责人是**运营时分配**的:工作流绑了员工组就按组轮值(组员并集里最闲者先), - * 没绑组就全员轮值;staffId 稳定排序保证同输入必同结果。 - * 分派结果记在节点实例上(assignee)—— 审批箱、链条、治理列表都按它认人。 + * 谁来干:**编排不带岗位**(活动点与审批点同一口径)——负责人是**运营时分配**的。 + * 三级取人,先到先得: + * ① **节点指派**:起单时点名了这个节点归谁(测试台定向)—— 人在岗就是他; + * ② 员工组轮值:工作流绑了组,在组员并集里挑最闲的; + * ③ 全员轮值:在岗候选并集里挑最闲的。 + * staffId 稳定排序保证同输入必同结果;分派结果记在节点实例上(assignee)—— + * 审批箱、链条、治理列表都按它认人。 */ const seen = new Map(); 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); diff --git a/apps/api/src/modules/process/internal/usecase/process-service.ts b/apps/api/src/modules/process/internal/usecase/process-service.ts index 5cb6ed6..bfd4143 100644 --- a/apps/api/src/modules/process/internal/usecase/process-service.ts +++ b/apps/api/src/modules/process/internal/usecase/process-service.ts @@ -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 } }); diff --git a/apps/api/src/modules/process/port.ts b/apps/api/src/modules/process/port.ts index e722095..8422604 100644 --- a/apps/api/src/modules/process/port.ts +++ b/apps/api/src/modules/process/port.ts @@ -23,6 +23,12 @@ export type WorkflowRecord = { * 留痕要能解释「当时为什么派给他」,而不是被今天的组织调整改写(同 AM 9.1 版本信封)。 */ readonly workGroup?: Record; + /** + * 节点指派 `{ 节点id → 员工id }` —— **起单时定死某几个节点归谁**。 + * 分派仍是运营时的事:这只是在轮值之前插一句「这个节点就他了」, + * 指的人不在岗则退回轮值(单不能没人接)。测试台用它把整条流走给指定账号。 + */ + readonly assignments?: Record; /** 各节点输出口载荷 —— 边映射装配的来源 */ readonly ports: Record>; readonly vars: Record; diff --git a/apps/api/src/modules/trace/CONTRACT.md b/apps/api/src/modules/trace/CONTRACT.md new file mode 100644 index 0000000..c07071b --- /dev/null +++ b/apps/api/src/modules/trace/CONTRACT.md @@ -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 里凑。观测面看得见「怎么跑的」,报告说的是「干出了什么」 diff --git a/apps/api/src/modules/trace/contract.ts b/apps/api/src/modules/trace/contract.ts new file mode 100644 index 0000000..a3f6b59 --- /dev/null +++ b/apps/api/src/modules/trace/contract.ts @@ -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; + readonly output?: Record; + readonly error?: { message: string }; + readonly attributes?: Record; + 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; + attributes?: Record; + }): SpanHandle; + end(out?: { output?: Record; attributes?: Record }): Promise; + fail(error: unknown): Promise; +}; + +export type TraceService = { + /* + * 作用域索引 —— 调用方只报 id,父子关系由服务自己拼。 + * 打点散在装配层的十几处,让每处自己拎 handle 传来传去,一处漏传整棵树就断了。 + */ + /** 开一条工作流的 trace(root span) */ + beginWorkflow(input: { workflowId: string; subject: string; input?: Record }): void; + /** 开一个节点 span(自动挂在工作流 span 下) */ + beginNode(input: { + workflowId: string; + nodeId: string; + label: string; + taskId?: string; + approvalId?: string; + assigneeId?: string; + input?: Record; + }): void; + /** 活动内的一次动作:步骤 / 工具 / 模型 / 人卡 —— 自动挂在该任务的节点 span 下 */ + beginAction(input: { + taskId: string; + name: string; + kind: SpanKind; + input?: Record; + }): SpanHandle | undefined; + /** 收尾:节点终态 / 工作流终态 */ + endNode(input: { taskId?: string; workflowId?: string; nodeId?: string; output?: Record }): Promise; + endWorkflow(input: { workflowId: string; state: string }): Promise; + + /** 起一条 trace(工作流级)或挂在既有 trace 下 —— 低层口,便利层用它 */ + start(input: { + name: string; + kind: SpanKind; + workflowId?: string; + nodeId?: string; + taskId?: string; + parent?: { traceId: string; spanId: string }; + input?: Record; + attributes?: Record; + }): SpanHandle; + /** 这条工作流的完整 span 树(按开始时间序;树形由 parentId 拼) */ + ofWorkflow(workflowId: string): Promise; + /** 这个任务(活动实例)的 span —— 活动内每一步都在这儿 */ + ofTask(taskId: string): Promise; + flush(): Promise; +}; diff --git a/apps/api/src/modules/trace/index.ts b/apps/api/src/modules/trace/index.ts new file mode 100644 index 0000000..38b0314 --- /dev/null +++ b/apps/api/src/modules/trace/index.ts @@ -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'; diff --git a/apps/api/src/modules/trace/internal/adapter/trace-schema.ts b/apps/api/src/modules/trace/internal/adapter/trace-schema.ts new file mode 100644 index 0000000..94dc831 --- /dev/null +++ b/apps/api/src/modules/trace/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)], +); diff --git a/apps/api/src/modules/trace/internal/adapter/trace-store.ts b/apps/api/src/modules/trace/internal/adapter/trace-store.ts new file mode 100644 index 0000000..dc8d2e6 --- /dev/null +++ b/apps/api/src/modules/trace/internal/adapter/trace-store.ts @@ -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 }), + ...(r.output === null ? {} : { output: r.output as Record }), + ...(r.error === null ? {} : { error: r.error as { message: string } }), + ...(r.attributes === null ? {} : { attributes: r.attributes as Record }), + 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); + }, + }; +} diff --git a/apps/api/src/modules/trace/internal/usecase/trace-service.ts b/apps/api/src/modules/trace/internal/usecase/trace-service.ts new file mode 100644 index 0000000..45c2fad --- /dev/null +++ b/apps/api/src/modules/trace/internal/usecase/trace-service.ts @@ -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 => { + try { + await deps.store.insert(span); + } catch { + /* 观测尽力而为:trace 写不进去也不能让流程跑不动 */ + } + }; + + const close = async ( + id: string, + patch: { output?: Record; error?: { message: string }; attributes?: Record }, + ): Promise => { + 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(); + const nodeSpans = new Map(); + + 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; +} diff --git a/apps/api/src/modules/trace/port.ts b/apps/api/src/modules/trace/port.ts new file mode 100644 index 0000000..a405f7f --- /dev/null +++ b/apps/api/src/modules/trace/port.ts @@ -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; + finish(id: string, patch: { output?: Record; error?: { message: string }; attributes?: Record; endedAt: string }): Promise; + listByWorkflow(workflowId: string): Promise; + listByTask(taskId: string): Promise; +}; + +export type Clock = { now(): Date }; diff --git a/db/migrations/0016_workflow_assignments.sql b/db/migrations/0016_workflow_assignments.sql new file mode 100644 index 0000000..e865ac8 --- /dev/null +++ b/db/migrations/0016_workflow_assignments.sql @@ -0,0 +1,3 @@ +-- 节点指派表:起单时把某个节点定向给某个账号(测试台的「给每个活动指定账号」)。 +-- 运营分派机制建成前的定向能力 —— 生产起单不传它,照旧走员工组/全员轮值。 +alter table sys.workflow add column assignments jsonb; diff --git a/db/migrations/0017_trace_span.sql b/db/migrations/0017_trace_span.sql new file mode 100644 index 0000000..5a7000d --- /dev/null +++ b/db/migrations/0017_trace_span.sql @@ -0,0 +1,23 @@ +-- 全链 Trace(Mastra Observability 的 span 落库): +-- 工作流 = 一个 trace;节点实例 = 一个 span;活动内的步骤 / 工具 / 模型 / 人卡 = 子 span。 +-- 我们的执行不经 Mastra 图,所以 span 是**手动打**的 —— 经 Mastra 的 Observability +-- 组件产出标准 span,再由自定义 exporter 落这张表(留痕只增不改,与任务记忆并行不替代)。 +create table if not exists sys.trace_span ( + id text primary key, + trace_id text not null, + parent_id text, + name text not null, + kind text not null, + /** 归属:一条 trace 就是一条工作流;节点/任务能对上就填上 */ + workflow_id text, + node_id text, + task_id text, + input jsonb, + output jsonb, + error jsonb, + attributes jsonb, + started_at timestamptz not null, + ended_at timestamptz +); +create index if not exists trace_span_trace_idx on sys.trace_span (trace_id, started_at); +create index if not exists trace_span_workflow_idx on sys.trace_span (workflow_id, started_at); diff --git a/db/migrations/meta/_journal.json b/db/migrations/meta/_journal.json index 3d22fa3..95ce18c 100644 --- a/db/migrations/meta/_journal.json +++ b/db/migrations/meta/_journal.json @@ -113,6 +113,20 @@ "when": 1785130113170, "tag": "0015_loop_pending_acceptance", "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1785130113171, + "tag": "0016_workflow_assignments", + "breakpoints": true + }, + { + "idx": 17, + "version": "7", + "when": 1785130113172, + "tag": "0017_trace_span", + "breakpoints": true } ] } diff --git a/packages/contracts/src/api/governance.ts b/packages/contracts/src/api/governance.ts index f576547..192cc48 100644 --- a/packages/contracts/src/api/governance.ts +++ b/packages/contracts/src/api/governance.ts @@ -507,6 +507,52 @@ export const listApprovalsRoute = { /* ── 工作流下钻:实例 × 节点 ──────────────────────────────────────────── */ +/* ── 设计台 · 仿真测试台 ─────────────────────────────────────────────── */ + +/** + * 起一条流(测试台专用):**带节点指派**把整条流定向到指定账号 —— + * 真实任务照旧落到那个人的工作台,测试台只管起单与观测。 + */ +export const testRunRoute = { + method: 'POST', + path: '/api/gov/testbed/run', + body: z.object({ + packageId: z.string(), + subject: z.string().min(1), + /** 调用接口入参 —— 按流程包的 callInterface.input 填 */ + data: z.record(z.string(), z.unknown()), + /** 节点指派 `{ 节点id → 员工id }`;不指的节点照旧轮值 */ + assignments: z.record(z.string(), z.string()).default({}), + }), + output: z.object({ workflowId: z.string(), state: workflowStateSchema }), +} as const satisfies RouteContract; + +/** 全链 Trace:一条工作流的 span 树(节点 → 步骤 / 工具 / 模型 / 人卡) */ +export const traceOfWorkflowRoute = { + method: 'GET', + path: '/api/gov/workflows/:id/trace', + params: z.object({ id: z.string() }), + output: z.object({ + spans: z.array( + z.object({ + id: z.string(), + traceId: z.string(), + parentId: z.string().optional(), + name: z.string(), + kind: z.enum(['workflow', 'node', 'step', 'tool', 'model', 'human']), + nodeId: z.string().optional(), + taskId: z.string().optional(), + input: z.record(z.string(), z.unknown()).optional(), + output: z.record(z.string(), z.unknown()).optional(), + error: z.object({ message: z.string() }).optional(), + attributes: z.record(z.string(), z.unknown()).optional(), + startedAt: z.string(), + endedAt: z.string().optional(), + }), + ), + }), +} as const satisfies RouteContract; + export const govWorkflowRoute = { method: 'GET', path: '/api/gov/workflows/:id', diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7bf782..74612e6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: '@mastra/core': specifier: ^1.53.0 version: 1.53.0(@grpc/grpc-js@1.14.4)(ai@7.0.37(zod@4.4.3))(express@5.2.1)(zod@4.4.3) + '@mastra/observability': + specifier: ^1.16.2 + version: 1.16.2(@mastra/core@1.53.0(@grpc/grpc-js@1.14.4)(ai@7.0.37(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(zod@4.4.3) ai: specifier: ^7.0.37 version: 7.0.37(zod@4.4.3) @@ -1095,6 +1098,13 @@ packages: peerDependencies: zod: ^3.25.0 || ^4.0.0 + '@mastra/observability@1.16.2': + resolution: {integrity: sha512-WF1vqzTQ/Cx38ljCPIyVpATf/el4h+mkXWxynrSmUpta5qjEA2gQm20QxEJgn5AMwV0sw5BtDgJh4eE5vbmjQA==} + engines: {node: '>=22.13.0'} + peerDependencies: + '@mastra/core': '>=1.16.0-0 <2.0.0-0' + zod: ^3.25.0 || ^4.0.0 + '@mastra/schema-compat@1.3.4': resolution: {integrity: sha512-2ObUsd21KIVelQy+eKPxJvnMxtmKnWacsIkZovhEYjVcQX9OYTDQ+u4E4RboIJZvurJnFx++/ujQLFznUaEYMg==} engines: {node: '>=22.13.0'} @@ -4464,6 +4474,11 @@ snapshots: - utf-8-validate - workflow + '@mastra/observability@1.16.2(@mastra/core@1.53.0(@grpc/grpc-js@1.14.4)(ai@7.0.37(zod@4.4.3))(express@5.2.1)(zod@4.4.3))(zod@4.4.3)': + dependencies: + '@mastra/core': 1.53.0(@grpc/grpc-js@1.14.4)(ai@7.0.37(zod@4.4.3))(express@5.2.1)(zod@4.4.3) + zod: 4.4.3 + '@mastra/schema-compat@1.3.4(zod@4.4.3)': dependencies: json-schema-to-zod: 2.8.1 diff --git a/tools/depcruise-tiers.generated.cjs b/tools/depcruise-tiers.generated.cjs index 5d5ab0c..bc4fe7a 100644 --- a/tools/depcruise-tiers.generated.cjs +++ b/tools/depcruise-tiers.generated.cjs @@ -23,7 +23,7 @@ module.exports = [ "path": "^apps/api/src/modules/channel/" }, "to": { - "path": "^apps/api/src/modules/(activity-registry|flow-engine|human-tool|interaction|loop-engine|memory|message|minute|process|release|report|skill-engine|task|toolbox|trigger)/" + "path": "^apps/api/src/modules/(activity-registry|flow-engine|human-tool|interaction|loop-engine|memory|message|minute|process|release|report|skill-engine|task|toolbox|trace|trigger)/" } }, { @@ -34,7 +34,7 @@ module.exports = [ "path": "^apps/api/src/modules/event/" }, "to": { - "path": "^apps/api/src/modules/(activity-registry|channel|flow-engine|human-tool|interaction|ledger|llm|loop-engine|memory|message|minute|org|process|release|report|skill-engine|task|toolbox|trigger)/" + "path": "^apps/api/src/modules/(activity-registry|channel|flow-engine|human-tool|interaction|ledger|llm|loop-engine|memory|message|minute|org|process|release|report|skill-engine|task|toolbox|trace|trigger)/" } }, { @@ -78,7 +78,7 @@ module.exports = [ "path": "^apps/api/src/modules/ledger/" }, "to": { - "path": "^apps/api/src/modules/(activity-registry|channel|event|flow-engine|human-tool|interaction|llm|loop-engine|memory|message|minute|org|process|release|report|skill-engine|task|toolbox|trigger)/" + "path": "^apps/api/src/modules/(activity-registry|channel|event|flow-engine|human-tool|interaction|llm|loop-engine|memory|message|minute|org|process|release|report|skill-engine|task|toolbox|trace|trigger)/" } }, { @@ -89,7 +89,7 @@ module.exports = [ "path": "^apps/api/src/modules/llm/" }, "to": { - "path": "^apps/api/src/modules/(activity-registry|channel|event|flow-engine|human-tool|interaction|ledger|loop-engine|memory|message|minute|org|process|release|report|skill-engine|task|toolbox|trigger)/" + "path": "^apps/api/src/modules/(activity-registry|channel|event|flow-engine|human-tool|interaction|ledger|loop-engine|memory|message|minute|org|process|release|report|skill-engine|task|toolbox|trace|trigger)/" } }, { @@ -111,7 +111,7 @@ module.exports = [ "path": "^apps/api/src/modules/memory/" }, "to": { - "path": "^apps/api/src/modules/(activity-registry|channel|flow-engine|human-tool|interaction|loop-engine|message|minute|process|release|report|skill-engine|task|toolbox|trigger)/" + "path": "^apps/api/src/modules/(activity-registry|channel|flow-engine|human-tool|interaction|loop-engine|message|minute|process|release|report|skill-engine|task|toolbox|trace|trigger)/" } }, { @@ -122,7 +122,7 @@ module.exports = [ "path": "^apps/api/src/modules/message/" }, "to": { - "path": "^apps/api/src/modules/(activity-registry|channel|flow-engine|human-tool|interaction|loop-engine|memory|minute|process|release|report|skill-engine|task|toolbox|trigger)/" + "path": "^apps/api/src/modules/(activity-registry|channel|flow-engine|human-tool|interaction|loop-engine|memory|minute|process|release|report|skill-engine|task|toolbox|trace|trigger)/" } }, { @@ -144,7 +144,7 @@ module.exports = [ "path": "^apps/api/src/modules/org/" }, "to": { - "path": "^apps/api/src/modules/(activity-registry|channel|event|flow-engine|human-tool|interaction|ledger|llm|loop-engine|memory|message|minute|process|release|report|skill-engine|task|toolbox|trigger)/" + "path": "^apps/api/src/modules/(activity-registry|channel|event|flow-engine|human-tool|interaction|ledger|llm|loop-engine|memory|message|minute|process|release|report|skill-engine|task|toolbox|trace|trigger)/" } }, { @@ -210,7 +210,18 @@ module.exports = [ "path": "^apps/api/src/modules/toolbox/" }, "to": { - "path": "^apps/api/src/modules/(activity-registry|channel|flow-engine|human-tool|interaction|loop-engine|memory|message|minute|process|release|report|skill-engine|task|trigger)/" + "path": "^apps/api/src/modules/(activity-registry|channel|flow-engine|human-tool|interaction|loop-engine|memory|message|minute|process|release|report|skill-engine|task|trace|trigger)/" + } + }, + { + "name": "tier-order-trace", + "comment": "模块 trace(L2)只准依赖更低层级模块;同层互需一律 port + app 装配(编码规范 §4 / §5.2)", + "severity": "error", + "from": { + "path": "^apps/api/src/modules/trace/" + }, + "to": { + "path": "^apps/api/src/modules/(activity-registry|channel|flow-engine|human-tool|interaction|loop-engine|memory|message|minute|process|release|report|skill-engine|task|toolbox|trigger)/" } }, { @@ -231,7 +242,7 @@ module.exports = [ "from": {}, "to": { "path": "^apps/api/src/modules/([^/]+)/", - "pathNot": "^apps/api/src/modules/(activity-registry|channel|event|flow-engine|human-tool|interaction|ledger|llm|loop-engine|memory|message|minute|org|process|release|report|skill-engine|task|toolbox|trigger)/" + "pathNot": "^apps/api/src/modules/(activity-registry|channel|event|flow-engine|human-tool|interaction|ledger|llm|loop-engine|memory|message|minute|org|process|release|report|skill-engine|task|toolbox|trace|trigger)/" } } ]; diff --git a/tools/module-tiers.json b/tools/module-tiers.json index df44d80..7207ec2 100644 --- a/tools/module-tiers.json +++ b/tools/module-tiers.json @@ -1,13 +1,42 @@ { "$comment": "编码规范 §4 后端模块层级表的机器形式。改层级 = 改本文件 + 改 编码规范.md §4,一起提交(§11.4)。", "tiers": { - "1": ["event", "org", "llm", "ledger"], - "2": ["memory", "toolbox", "channel", "message"], - "3": ["interaction", "human-tool"], - "4": ["release", "activity-registry", "skill-engine"], - "5": ["loop-engine", "flow-engine"], - "6": ["task"], - "7": ["process", "trigger"], - "8": ["minute", "report"] + "1": [ + "event", + "org", + "llm", + "ledger" + ], + "2": [ + "memory", + "toolbox", + "channel", + "message", + "trace" + ], + "3": [ + "interaction", + "human-tool" + ], + "4": [ + "release", + "activity-registry", + "skill-engine" + ], + "5": [ + "loop-engine", + "flow-engine" + ], + "6": [ + "task" + ], + "7": [ + "process", + "trigger" + ], + "8": [ + "minute", + "report" + ] } }