forked from ChaiHao/BPOS-MVP
三面拆分⑫:设计台九区一级目录 —— 一区一内容,测试台收成单例仿真
设计中心从「蓝图 + 四库」摊成九区,顺序即设计的次序: 蓝图 → 触发器 → 流程包 → 活动库 → 工具箱 → 数据域 → 知识库 → 大模型 → 测试台。 触发器与大模型从「并进流程包 / 塞在系统区」里拿出来独立成区 —— 前者与流程包平级 (不入包结构、不随包版本),后者是设计者天天要碰的东西;知识库先立个诚实的空占位。 一区一内容,于是区内页签栏整条删掉(壳层在只剩一格时收起): · 流程包:总览的三枚计数并进货架顶端 · 测试台:入站邮件演练移到触发器区就地验(它验的正是那一区定的规则) · 大模型:从系统区拆出,系统区只剩通道实例 测试台一次只跑一条流。起单前先销毁上一单,刷新页面能认回在跑的那一单, 「停止仿真」把工作流连同它派出的任务、工单、实例、留痕一并拔掉 —— 不留半单挂在别人工作台上。实测:起单 → 1 单落到 AI 工作台 → 停止 → 库里零残留。 功能工具补中文名(migration 0018,读回退到逻辑名),治理台一律主显示中文名。 顺带清掉几处说教式文案(「这些是工具,不是模型 —— 模型是它们跑在上面的发动机…」那类)。 五闸:typecheck / lint / depcruise / test 绿;api:check 快照随本提交入库。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -4,6 +4,7 @@
|
||||
* 「治理台九功能**不设**后端 governance 模块:每个功能是对应模块的管理用例 + 路由组装。」
|
||||
* 「治理动作本身留痕入事件流 —— 治理也被治理。」
|
||||
*/
|
||||
import { sql } from 'drizzle-orm';
|
||||
import type { Hono } from 'hono';
|
||||
import type { ApiError, GovernanceFunction, GrantAction } from '@contracts';
|
||||
import {
|
||||
@@ -25,7 +26,9 @@ import {
|
||||
listTriggersRoute,
|
||||
registerToolRoute,
|
||||
saveProcessRoute,
|
||||
testCurrentRoute,
|
||||
testRunRoute,
|
||||
testStopRoute,
|
||||
traceOfWorkflowRoute,
|
||||
setProcessStateRoute,
|
||||
reassignTaskRoute,
|
||||
@@ -42,6 +45,53 @@ import { NotClosedError, PinnedVersionError, StaleVersionError } from '@modules/
|
||||
import type { Container } from '../container.ts';
|
||||
import type { AppEnv } from '../http-env.ts';
|
||||
|
||||
/** `db.execute` 的行读取 —— Drizzle 的返回型随驱动变,窄化收在这一处 */
|
||||
function rowsOf<T>(result: unknown): T[] {
|
||||
const bag = result as { rows?: T[] } | T[];
|
||||
return Array.isArray(bag) ? bag : (bag.rows ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* 销毁仿真单 —— 把测试台起的那一条(或指定的一条)**连根拔掉**。
|
||||
*
|
||||
* 顺序按外键的反方向来:先拆挂在任务上的东西(工单、卡、记忆、运行时、意向、监听),
|
||||
* 再拆任务与节点实例,最后拆工作流本身;trace 与事件按工作流清。
|
||||
* 用 `source->>'ref' = 'testbed'` 认门:**只动测试台自己起的单**,
|
||||
* 生产单一个字都不碰(它们的来源是触发器或纪要登记)。
|
||||
*/
|
||||
async function destroySimulations(container: Container, workflowId?: string): Promise<number> {
|
||||
const scope = workflowId === undefined ? sql`` : sql` and id = ${workflowId}`;
|
||||
const wfs = await container.db.execute<{ id: string }>(
|
||||
sql`select id from sys.workflow where source->>'ref' = 'testbed'${scope}`,
|
||||
);
|
||||
const ids = rowsOf<{ id: string }>(wfs).map((r) => r.id);
|
||||
if (ids.length === 0) return 0;
|
||||
|
||||
let taskCount = 0;
|
||||
for (const id of ids) {
|
||||
const tasks = await container.db.execute<{ id: string }>(sql`select id from sys.task where workflow_id = ${id}`);
|
||||
const taskIds = rowsOf<{ id: string }>(tasks).map((r) => r.id);
|
||||
taskCount += taskIds.length;
|
||||
for (const taskId of taskIds) {
|
||||
await container.db.execute(sql`delete from sys.work_order where task_id = ${taskId}`);
|
||||
await container.db.execute(sql`delete from sys.chat_card where task_id = ${taskId}`);
|
||||
await container.db.execute(sql`delete from sys.chat_message where task_id = ${taskId}`);
|
||||
await container.db.execute(sql`delete from sys.task_memory where task_id = ${taskId}`);
|
||||
await container.db.execute(sql`delete from sys.task_memory_seal where task_id = ${taskId}`);
|
||||
await container.db.execute(sql`delete from sys.task_view where task_id = ${taskId}`);
|
||||
await container.db.execute(sql`delete from sys.flow_state where task_id = ${taskId}`);
|
||||
await container.db.execute(sql`delete from sys.loop_state where task_id = ${taskId}`);
|
||||
await container.db.execute(sql`delete from sys.action_intent where task_id = ${taskId}`);
|
||||
await container.db.execute(sql`delete from sys.listener where task_id = ${taskId}`);
|
||||
}
|
||||
await container.db.execute(sql`delete from sys.task where workflow_id = ${id}`);
|
||||
await container.db.execute(sql`delete from sys.node_instance where workflow_id = ${id}`);
|
||||
await container.db.execute(sql`delete from sys.trace_span where workflow_id = ${id}`);
|
||||
await container.db.execute(sql`delete from sys.workflow where id = ${id}`);
|
||||
}
|
||||
return taskCount;
|
||||
}
|
||||
|
||||
export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Container): void {
|
||||
/**
|
||||
* 岗位授权:客体 = 治理功能,动作按路由分级 —— **看是 `read`,动才是 `govern`**。
|
||||
@@ -230,6 +280,8 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
return c.json(body, 400);
|
||||
}
|
||||
const me = await container.org.findByAccount(c.get('account'));
|
||||
// **一次只跑一条**:再起一单就先把上一单连根拔掉 —— 仿真台上不留过夜的东西
|
||||
await destroySimulations(container);
|
||||
try {
|
||||
const record = await container.process.start({
|
||||
packageId: parsed.data.packageId,
|
||||
@@ -247,6 +299,33 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 停止仿真 —— **连根拔掉**:工作流、活动任务、工单、节点实例、trace 一并销毁。
|
||||
* 仿真不是生产:试跑留下的任务不该在别人工作台上过夜,也不该混进在途口径。
|
||||
*/
|
||||
app.post(testStopRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const parsed = testStopRoute.body.safeParse(await c.req.json());
|
||||
if (!parsed.success) {
|
||||
const body: ApiError = { code: 'validation-failed', message: '停止仿真入参不合协议' };
|
||||
return c.json(body, 400);
|
||||
}
|
||||
const tasks = await destroySimulations(container, parsed.data.workflowId);
|
||||
return c.json({ destroyed: true, tasks });
|
||||
});
|
||||
|
||||
/** 当前在跑的仿真 —— 刷新页面也认得回来(测试台一次只跑一条) */
|
||||
app.get(testCurrentRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const rows = await container.db.execute<{ id: string; subject: string }>(
|
||||
sql`select id, subject from sys.workflow where source->>'ref' = 'testbed' order by started_at desc limit 1`,
|
||||
);
|
||||
const hit = rowsOf<{ id: string; subject: string }>(rows)[0];
|
||||
return c.json(hit === undefined ? {} : { workflowId: hit.id, subject: hit.subject });
|
||||
});
|
||||
|
||||
/** 全链 Trace:一条工作流的 span 树 —— 节点 → 步骤 / 工具 / 模型 / 人卡 */
|
||||
app.get(traceOfWorkflowRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
@@ -545,7 +624,8 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
}
|
||||
const { contract, samples, connectionValues } = parsed.data;
|
||||
const verdict = await container.toolbox.register({
|
||||
contract,
|
||||
// 没给中文名就以逻辑名兜底 —— 主名不能空
|
||||
contract: { ...contract, displayName: contract.displayName ?? contract.name },
|
||||
// 期望形状统一按「出参是个对象」收:细的形状由契约 output schema 自己说
|
||||
samples: samples.map((s) => ({ input: s.input, expectShape: { type: 'object' } })),
|
||||
...(connectionValues === undefined ? {} : { connectionValues }),
|
||||
@@ -563,6 +643,7 @@ export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, container: Contai
|
||||
const tools = await container.toolbox.list();
|
||||
return c.json({
|
||||
tools: tools.map((t) => ({
|
||||
displayName: t.displayName,
|
||||
name: t.name,
|
||||
version: t.version,
|
||||
description: t.description,
|
||||
|
||||
@@ -15,6 +15,7 @@ import { seedMvpFlow } from './seed-mvp-flow.ts';
|
||||
/** 第一方功能工具:查历史价 —— MVP 用录制回放替身代真 CRM(README:mock 只在外界) */
|
||||
const HISTORY_TOOL = {
|
||||
name: 'crm.history-price-read',
|
||||
displayName: '历史价查询',
|
||||
version: '1.0.0',
|
||||
description: '读取历史价格情报:某 PN 最近一次报价距今天数、行情是否平稳、生命周期状态',
|
||||
input: { type: 'object', required: ['pn'], properties: { pn: { type: 'string' } } },
|
||||
@@ -39,6 +40,7 @@ const HISTORY_TOOL = {
|
||||
const MAIL_TOOL = {
|
||||
...HISTORY_TOOL,
|
||||
name: 'mail.send',
|
||||
displayName: '发送邮件',
|
||||
description: '发出邮件(报价单 / 询价函 / 催办):写类动作,放行时才真正触达世界',
|
||||
input: {
|
||||
type: 'object',
|
||||
@@ -61,6 +63,7 @@ const MAIL_TOOL = {
|
||||
*/
|
||||
const PLACEHOLDER_TOOL = {
|
||||
name: 'slot.placeholder',
|
||||
displayName: '槽位占位产出',
|
||||
version: '0.1.0',
|
||||
description: '活动槽位占位产出:把契约要求的字段落成一行(不做业务判断;真实现后摘除)',
|
||||
input: {
|
||||
|
||||
@@ -29,6 +29,10 @@
|
||||
`human`(**人类工具**,系统内化、零声明即得 —— 调人是 Harness 给的能力)、
|
||||
`llm`(**LLM 五件**:抽取/归类/摘要/裁断/改写,经模型网关一缝;
|
||||
仅当活动声明了模型用途名才上总线 —— 没声明却会问模型是越权,用量也无从归因)
|
||||
- **一名两用**(2026-07-29):`name` 是**机器名**(全局唯一逻辑名,活动按它声明白名单、
|
||||
调用按它寻址),`displayName` 是**人名**(治理台一律主显示中文名,逻辑名退居副标)。
|
||||
`crm.history-price-read` 对机器精确、对人不友好,一屏几十件工具时尤甚;
|
||||
旧工具未填时读回退到 `name`(加法,非破坏)
|
||||
- **一切工具执行经 `ToolInvoker`** —— 绕过它,回放审就对它失明(§12.5#10)
|
||||
- `replay` 模式**绝不回退实调**:缺录即 `ToolReplayMissError`
|
||||
- **白名单在结构上拦截**:功能工具按活动白名单,纯工具按 `ownerActivityId` 私有作用域;
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { ToolContract } from '../internal/domain/tool-contract.ts';
|
||||
|
||||
const CONTRACT: ToolContract = {
|
||||
name: 'erp.stock-read',
|
||||
displayName: 'ERP 库存查询',
|
||||
version: '1.0.0',
|
||||
description: '读取 ERP 现货库存:PN → 库存量',
|
||||
input: { type: 'object', required: ['pn'], properties: { pn: { type: 'string' } } },
|
||||
|
||||
@@ -6,6 +6,7 @@ import { auditPassed, staticAudit } from '../internal/domain/registration-audit.
|
||||
|
||||
const contract = (over: Partial<ToolContract> = {}): ToolContract => ({
|
||||
name: 'crm.customer-read',
|
||||
displayName: '客户档案查询',
|
||||
version: '1.0.0',
|
||||
description: '读取客户资料库中的客户档案,用于取客户等级与付款条款',
|
||||
input: { type: 'object', properties: { customerCode: { type: 'string' } }, required: ['customerCode'] },
|
||||
|
||||
@@ -7,6 +7,8 @@ import { sys } from '@shared/db.ts';
|
||||
export const toolTable = sys.table(
|
||||
'tool',
|
||||
{
|
||||
/** 中文名 —— 人读的主名(治理台主显示);存量行以逻辑名兜底 */
|
||||
displayName: text('display_name'),
|
||||
name: text('name').primaryKey(),
|
||||
version: text('version').notNull(),
|
||||
description: text('description').notNull(),
|
||||
|
||||
@@ -15,6 +15,8 @@ type Row = typeof toolTable.$inferSelect;
|
||||
function toContract(row: Row): ToolContract {
|
||||
return {
|
||||
name: row.name,
|
||||
// 存量行没中文名就以逻辑名兜底 —— 界面上宁可显示机器名,不显示空白
|
||||
displayName: row.displayName ?? row.name,
|
||||
version: row.version,
|
||||
description: row.description,
|
||||
input: row.input as Record<string, unknown>,
|
||||
@@ -50,6 +52,7 @@ export function createToolRegistry(db: Db): ToolRegistry {
|
||||
name: contract.name,
|
||||
version: contract.version,
|
||||
description: contract.description,
|
||||
displayName: contract.displayName,
|
||||
input: contract.input,
|
||||
output: contract.output,
|
||||
effect: contract.effect,
|
||||
@@ -70,6 +73,7 @@ export function createToolRegistry(db: Db): ToolRegistry {
|
||||
target: toolTable.name,
|
||||
set: {
|
||||
version: values.version,
|
||||
displayName: values.displayName,
|
||||
description: values.description,
|
||||
input: values.input,
|
||||
output: values.output,
|
||||
|
||||
@@ -11,8 +11,13 @@ export type EffectLevel = (typeof EFFECT_LEVELS)[number];
|
||||
export const EFFECT_LEVEL_LABEL: Readonly<Record<EffectLevel, string>> = { read: '读', write: '写' };
|
||||
|
||||
export type ToolContract = {
|
||||
/** 全局唯一逻辑名 */
|
||||
/** 全局唯一逻辑名 —— 机器用:活动按它声明白名单、调用按它寻址 */
|
||||
readonly name: string;
|
||||
/**
|
||||
* 中文名 —— **人用**:治理台一律主显示它,逻辑名退居副标。
|
||||
* 「crm.history-price-read」对机器精确,对人不友好;一屏几十件工具时尤其。
|
||||
*/
|
||||
readonly displayName: string;
|
||||
readonly version: string;
|
||||
/** 面向 AI 的适用说明:何时用、语义、边界 */
|
||||
readonly description: string;
|
||||
|
||||
Reference in New Issue
Block a user