/** * 治理台九功能的运行面路由(编码规范 §3)。 * * 「治理台九功能**不设**后端 governance 模块:每个功能是对应模块的管理用例 + 路由组装。」 * 「治理动作本身留痕入事件流 —— 治理也被治理。」 */ import type { Hono } from 'hono'; import type { ApiError, GovernanceFunction, GrantAction } from '@contracts'; import { ATTRIBUTION_SIDE_LABEL, browseLedgerRoute, controlTaskRoute, govMetricsRoute, govTaskLogRoute, govWorkflowRoute, listActivitiesRoute, listApprovalsRoute, listChannelsRoute, listActivityMemoryRoute, listGovTasksRoute, listLedgerObjectsRoute, installActivityRoute, listProcessPackagesRoute, listToolsRoute, listTriggersRoute, registerToolRoute, saveProcessRoute, setProcessStateRoute, reassignTaskRoute, resumeTaskRoute, staffIdSchema, taskIdSchema, toggleTriggerRoute, upsertTriggerRoute, validateProcessRoute, } from '@contracts'; import { InstallRejectedError } from '@modules/activity-registry'; import type { InstallVerdict } from '@modules/activity-registry'; import { NotClosedError, PinnedVersionError, StaleVersionError } from '@modules/process'; import type { Container } from '../container.ts'; import type { AppEnv } from '../http-env.ts'; export function registerGovernanceOpsRoutes(app: Hono, container: Container): void { /** * 岗位授权:客体 = 治理功能,动作按路由分级 —— **看是 `read`,动才是 `govern`**。 * * 客体三类 × 动作三种的分法就是为此:BD 有 `task:read` 能看在途,却动不了控制事件; * PM 有 `task:govern`,读写皆通(动作蕴含 govern ⊃ write ⊃ read)。 * 一律要 govern 会把只读监控也锁死,等于把三种动作压成一种。 */ function guard(fn: GovernanceFunction, action: GrantAction) { return async (account: string): Promise => { const staff = await container.org.findByAccount(account); if (staff === undefined) return { code: 'unauthenticated', message: '会话无效' }; const ok = await container.org.authorize(staff.id, { kind: 'governance', function: fn }, action); if (ok) return undefined; const need = action === 'govern' ? '治理权' : '查看权'; return { code: 'forbidden', message: `岗位 ${staff.position.code} 无「${fn}」${need}` }; }; } const readTask = guard('task', 'read'); const governTask = guard('task', 'govern'); const readProcess = guard('process', 'read'); const governProcess = guard('process', 'govern'); const readTrigger = guard('trigger', 'read'); const governTrigger = guard('trigger', 'govern'); const readActivity = guard('activity', 'read'); const governActivity = guard('activity', 'govern'); const readTool = guard('tool', 'read'); const governTool = guard('tool', 'govern'); const readData = guard('data', 'read'); const readMemory = guard('memory', 'read'); /** 拒绝的 HTTP 语义:无权 403,无会话 401 */ const denyStatus = (body: ApiError): 401 | 403 => (body.code === 'forbidden' ? 403 : 401); /* ── 任务管理:在途监控 · 控制 · 改派 · 任务日志 ──────────────────── */ app.get(listGovTasksRoute.path, async (c) => { const denied = await readTask(c.get('account')); if (denied) return c.json(denied, denyStatus(denied)); const { staff } = await container.org.listStaff(); const nameOf = new Map(staff.map((s) => [s.id, s.name])); const rows = await Promise.all( staff.map(async (s) => (await container.task.listByOwner(s.id)).map((t) => ({ t, name: s.name }))), ); const workflows = await container.process.listWorkflows(50); return c.json({ tasks: rows.flat().map(({ t, name }) => ({ id: t.id, title: t.title, activityId: t.activityId, paradigm: t.paradigm, ownerName: nameOf.get(t.ownerId) ?? name, runtime: t.runtime, ...(t.terminal === undefined ? {} : { terminal: t.terminal }), ...(t.workflowId === undefined ? {} : { workflowId: t.workflowId }), usedSteps: t.used.steps, capSteps: t.caps.steps, startedAt: t.startedAt, })), workflows: workflows.map((w) => ({ id: w.id, subject: w.subject, state: w.state, packageId: w.packageId, startedAt: w.startedAt, })), }); }); /** 控制三语义 —— 叫停留痕为最重档纠正信号 */ app.post(controlTaskRoute.path, async (c) => { const denied = await governTask(c.get('account')); if (denied) return c.json(denied, denyStatus(denied)); const staff = await container.org.findByAccount(c.get('account')); const parsed = controlTaskRoute.body.safeParse(await c.req.json()); if (!parsed.success || staff === undefined) { const body: ApiError = { code: 'validation-failed', message: '入参不合法' }; return c.json(body, 400); } const outcome = await container.task.control( taskIdSchema.parse(c.req.param('taskId')), parsed.data.semantic, staff.id, parsed.data.reason, ); return c.json({ ok: true, resumable: outcome.resumable }); }); app.post(resumeTaskRoute.path, async (c) => { const denied = await governTask(c.get('account')); if (denied) return c.json(denied, denyStatus(denied)); const staff = await container.org.findByAccount(c.get('account')); if (staff === undefined) return c.json({ code: 'unauthenticated', message: '会话无效' } satisfies ApiError, 401); const task = await container.task.resume(taskIdSchema.parse(c.req.param('taskId')), staff.id); return c.json({ ok: true, runtime: task.runtime }); }); app.post(reassignTaskRoute.path, async (c) => { const denied = await governTask(c.get('account')); if (denied) return c.json(denied, denyStatus(denied)); const staff = await container.org.findByAccount(c.get('account')); const parsed = reassignTaskRoute.body.safeParse(await c.req.json()); if (!parsed.success || staff === undefined) { const body: ApiError = { code: 'validation-failed', message: '入参不合法' }; return c.json(body, 400); } const task = await container.task.reassign( taskIdSchema.parse(c.req.param('taskId')), staffIdSchema.parse(parsed.data.to), staff.id, ); return c.json({ ok: true, ownerId: task.ownerId }); }); app.get(govTaskLogRoute.path, async (c) => { const denied = await readTask(c.get('account')); if (denied) return c.json(denied, denyStatus(denied)); const lines = await container.memory.taskLog(taskIdSchema.parse(c.req.param('taskId'))); return c.json({ log: lines.map((l) => ({ id: l.id, at: l.at, sideLabel: ATTRIBUTION_SIDE_LABEL[l.side], title: l.title })), }); }); /** * 审批单 —— 审批点的产物,**不是任务**:没有预算、没有任务日志,只有批不批。 * 它在实现上是卡到审批人工位的验收工单,`taskId` 位坐的是 workflowId(见 wiring/engines.ts)。 */ app.get(listApprovalsRoute.path, async (c) => { const denied = await readTask(c.get('account')); if (denied) return c.json(denied, denyStatus(denied)); const workflows = await container.process.listWorkflows(50); const perWorkflow = await Promise.all( workflows.map(async (w) => { const pkg = await container.process.getPackage(w.packageId, w.packageVersion); if (pkg === undefined) return []; const instances = await container.process.instancesOf(w.id); const rows = await Promise.all( instances .filter((i) => i.activated && pkg.nodes.find((n) => n.id === i.nodeId)?.kind === 'approval') .map(async (i) => { const node = pkg.nodes.find((n) => n.id === i.nodeId); // 审批人是运营时分配的,实例 assignee 即事实 —— 没派到的如实「—」 const assignee = i.assigneeId === undefined ? undefined : await container.org.getStaff(staffIdSchema.parse(i.assigneeId)).catch(() => undefined); return { id: `${w.id}:${i.nodeId}#${i.instance}`, workflowId: w.id, workflowSubject: w.subject, title: `审批:${node?.label ?? i.nodeId}`, approverName: assignee?.name ?? '—', // 终局唯放行:released = 通过;其余终态 = 退回;未终态 = 待审批 state: i.terminal === undefined ? ('pending' as const) : i.terminal === 'released' ? ('passed' as const) : ('rejected' as const), issuedAt: w.startedAt, }; }), ); return rows; }), ); return c.json({ approvals: perWorkflow.flat() }); }); /** 工作流下钻:流程包 × 节点实例 × 在途任务 —— 编排画布的数据面 */ app.get(govWorkflowRoute.path, async (c) => { const denied = await readTask(c.get('account')); if (denied) return c.json(denied, denyStatus(denied)); const workflow = await container.process.getWorkflow(c.req.param('id')); const pkg = await container.process.getPackage(workflow.packageId, workflow.packageVersion); if (pkg === undefined) { const body: ApiError = { code: 'not-found', message: `流程包缺失:${workflow.packageId}` }; return c.json(body, 404); } const instances = await container.process.instancesOf(workflow.id); const { staff } = await container.org.listStaff(); const nested = await Promise.all( staff.map(async (s) => (await container.task.listByOwner(s.id)).map((t) => ({ t, owner: s.name }))), ); return c.json({ workflow: { id: workflow.id, subject: workflow.subject, state: workflow.state, packageId: workflow.packageId, packageVersion: workflow.packageVersion, source: workflow.source, startedAt: workflow.startedAt, ...(workflow.finishedAt === undefined ? {} : { finishedAt: workflow.finishedAt }), }, pkg, instances: instances.map((i) => ({ nodeId: i.nodeId, instance: i.instance, running: i.running, waiting: i.waiting, activated: i.activated, ...(i.terminal === undefined ? {} : { terminal: i.terminal }), })), tasks: nested .flat() .filter(({ t }) => t.workflowId === workflow.id) .map(({ t, owner }) => ({ id: t.id, nodeId: t.nodeId ?? '', title: t.title, ownerName: owner })), }); }); /* ── 工作流管理 ──────────────────────────────────────────────────── */ app.get(listProcessPackagesRoute.path, async (c) => { const denied = await readProcess(c.get('account')); if (denied) return c.json(denied, denyStatus(denied)); return c.json({ packages: await container.process.listPackages() }); }); /** * 保存编排 —— 保存 ≠ 发布:草稿可反复存;**已发布版本不可回改**。 * 编辑即其身:保存写回当前版本;在途钉版不可变,新版本只出自显式新建。 */ app.put(saveProcessRoute.path, async (c) => { const denied = await governProcess(c.get('account')); if (denied) return c.json(denied, denyStatus(denied)); const parsed = saveProcessRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '流程包不合 schema', detail: parsed.error.issues }; return c.json(body, 400); } /* * 编辑即其身:已存在的版本就地写回(号不变);asNew / 新流程首版才长新版本。 * 画图不检查(未生效版随手存);返回连同闭合报告 —— 异常看板据它列清单。 * 两道闸:在途钉版 409;写回生效版不闭合 412(生效的必须始终可运行)。 */ try { const pkg = await container.process.savePackage(parsed.data.pkg, { asNew: parsed.data.asNew === true }); return c.json({ pkg, report: await container.process.validate(pkg) }); } catch (error) { if (error instanceof PinnedVersionError || error instanceof StaleVersionError) { const body: ApiError = { code: 'conflict', message: error.message }; return c.json(body, 409); } if (error instanceof NotClosedError) { const body: ApiError = { code: 'precondition-failed', message: error.message, detail: error.report }; return c.json(body, 412); } throw error; } }); /** 版本启停:激活即独占,停用即下线 —— 在途按启动版本跑完,零影响 */ app.post(setProcessStateRoute.path, async (c) => { const denied = await governProcess(c.get('account')); if (denied) return c.json(denied, denyStatus(denied)); const parsed = setProcessStateRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '启停状态不合协议' }; return c.json(body, 400); } try { const packages = await container.process.setPackageState( c.req.param('id'), Number(c.req.param('version')), parsed.data.state, ); return c.json({ packages }); } catch (error) { // 不闭合不得激活 —— 412 带整份报告,前端异常看板直接用 if (error instanceof NotClosedError) { const body: ApiError = { code: 'precondition-failed', message: error.message, detail: error.report }; return c.json(body, 412); } throw error; } }); /** 画图即校验 —— 与前端画布同源 */ app.post(validateProcessRoute.path, async (c) => { const denied = await governProcess(c.get('account')); if (denied) return c.json(denied, denyStatus(denied)); const parsed = validateProcessRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '流程包不合 schema', detail: parsed.error.issues }; return c.json(body, 400); } return c.json(await container.process.validate(parsed.data.pkg)); }); /* ── 触发器配置 ──────────────────────────────────────────────────── */ app.get(listTriggersRoute.path, async (c) => { const denied = await readTrigger(c.get('account')); if (denied) return c.json(denied, denyStatus(denied)); return c.json({ triggers: await container.trigger.list() }); }); app.post(toggleTriggerRoute.path, async (c) => { const denied = await governTrigger(c.get('account')); if (denied) return c.json(denied, denyStatus(denied)); const parsed = toggleTriggerRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '入参不合法' }; return c.json(body, 400); } // 触发器的增删启停不改流程包的任何版本 await container.trigger.setEnabled(c.req.param('id'), parsed.data.enabled); return c.json({ ok: true }); }); /** * 调起规则的定义面(设计中心)—— 新建 / 改规则 / 改起单装配。 * 启停不走这里(那是运营的事,见 toggle);改定义同样不动任何流程包版本。 */ app.put(upsertTriggerRoute.path, async (c) => { const denied = await governTrigger(c.get('account')); if (denied) return c.json(denied, denyStatus(denied)); const parsed = upsertTriggerRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '触发器不合 schema', detail: parsed.error.issues }; return c.json(body, 400); } return c.json({ trigger: await container.trigger.upsert(parsed.data.trigger) }); }); /* ── 活动库管理:两维分类 × 两条版本序列 ─────────────────────────── */ /** * 装入 / 换版 —— 经**安装通道**,无代码通道(ADR-004):交清单 + .js 产物,都是数据。 * 两审在这里跑;不过审即 412 并把审计结论原样回给人看,不静默失败。 * 恒为新增、版本并存:装入不覆盖旧版本,在途任务按启动时版本跑完。 */ app.post(installActivityRoute.path, async (c) => { const denied = await governActivity(c.get('account')); if (denied) return c.json(denied, denyStatus(denied)); const parsed = installActivityRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '活动清单不合 schema', detail: parsed.error.issues }; return c.json(body, 400); } const { manifest, artifact } = parsed.data; const reply = (verdict: InstallVerdict, accepted: boolean) => ({ accepted, activityId: manifest.identity.activityId, packageVersion: manifest.identity.packageVersion, staticFindings: verdict.staticFindings.map((f) => ({ code: f.code, severity: f.severity, message: f.message })), replay: { oldPassStillPass: verdict.replay.oldPassStillPass, checked: verdict.replay.checked, skippedNoHistory: verdict.replay.skippedNoHistory, regressions: verdict.replay.regressions.map((r) => ({ taskId: r.taskId, detail: r.detail })), }, }); try { return c.json(reply(await container.registry.install(manifest, artifact), true)); } catch (error) { if (error instanceof InstallRejectedError) { /* * 两审未过:不上架。**按 ApiError 形状回**(与发布门一致)—— * 类型化客户端只从 `detail` 取详情,直接回业务体的话前端拿不到审计结论。 */ const body: ApiError = { code: 'precondition-failed', message: error.message, detail: reply(error.verdict, false), }; return c.json(body, 412); } throw error; } }); app.get(listActivitiesRoute.path, async (c) => { const denied = await readActivity(c.get('account')); if (denied) return c.json(denied, denyStatus(denied)); const cards = await container.registry.list(); /* * 契约面随卡一起给 —— 开发一个活动要照着的东西全在清单里: * 七件契约的入参与出口、声明的工具/模型/域对象/岗位。 * 编排的人也要看它:引用一个活动却不知道它吃什么,边映射就只能靠猜。 */ const faces = await Promise.all( cards.map(async (card) => { const manifest = await container.registry.manifestOf(card.activityId); const data = manifest.contract.input.data as { required?: string[]; properties?: Record; }; const required = data.required ?? []; const props = data.properties ?? {}; const portFields = (payload: unknown) => { const p = payload as { properties?: Record } | undefined; return Object.keys(p?.properties ?? {}); }; return { requiredInputs: required, inputFields: Object.entries(props).map(([name, spec]) => ({ name, type: spec.type ?? 'unknown', required: required.includes(name), })), // 必发口与选择组一并列出:调用方只凭口名与 schema 就能衔接下游 outputPorts: [ ...manifest.contract.output.mandatory, ...manifest.contract.output.selective.flatMap((g) => g.ports), ].map((port) => ({ name: port.name, ...(port.description === undefined ? {} : { description: port.description }), fields: portFields(port.payload), })), tools: manifest.declarations.tools.map((t) => t.name), models: manifest.declarations.models, objects: manifest.declarations.ledger.map((o) => `${o.domain}.${o.object}`), roles: [...manifest.contract.resources.roles], humanCallBudget: manifest.contract.authorization.humanCallBudget, }; }), ); return c.json({ activities: cards.map((card, i) => ({ activityId: card.activityId, name: card.name, category: card.category, paradigm: card.paradigm, enabled: card.enabled, packageVersions: card.packageVersions.map((p) => ({ version: p.version, state: p.state, installedAt: p.installedAt, })), assetVersions: card.assetVersions.map((a) => ({ kind: a.kind, version: a.version, effective: a.effective, })), contract: faces[i]!, })), }); }); /* ── 工具管理 ────────────────────────────────────────────────────── */ /** * 导入自定义工具 —— 与第一方走同一条门:静态审 + 试运行审,过了才入目录。 * 入目录不等于能用:还要治理台把它放进某个活动的白名单(授权在白名单与契约,不在活动代码)。 */ app.post(registerToolRoute.path, async (c) => { const denied = await governTool(c.get('account')); if (denied) return c.json(denied, denyStatus(denied)); const parsed = registerToolRoute.body.safeParse(await c.req.json()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '工具契约不合 schema', detail: parsed.error.issues }; return c.json(body, 400); } const { contract, samples, connectionValues } = parsed.data; const verdict = await container.toolbox.register({ contract, // 期望形状统一按「出参是个对象」收:细的形状由契约 output schema 自己说 samples: samples.map((s) => ({ input: s.input, expectShape: { type: 'object' } })), ...(connectionValues === undefined ? {} : { connectionValues }), }); return c.json({ accepted: verdict.accepted, findings: verdict.staticFindings.map((f) => ({ code: f.code, severity: f.severity, message: f.message })), trialRun: { ran: verdict.trialRun.ran, passed: verdict.trialRun.passed, failures: [...verdict.trialRun.failures] }, }); }); app.get(listToolsRoute.path, async (c) => { const denied = await readTool(c.get('account')); if (denied) return c.json(denied, denyStatus(denied)); const tools = await container.toolbox.list(); return c.json({ tools: tools.map((t) => ({ name: t.name, version: t.version, description: t.description, effect: t.effect, pure: t.pure === true, // 凭证隔离:只回键,永不回值(ADR-009) connectionKeys: [...t.connectionKeys], })), }); }); /* ── 数据管理:域 / 对象目录 + 记录浏览 ─────────────────────────── */ app.get(listLedgerObjectsRoute.path, async (c) => { const denied = await readData(c.get('account')); if (denied) return c.json(denied, denyStatus(denied)); return c.json({ objects: await container.ledger.listObjects() }); }); app.get(browseLedgerRoute.path, async (c) => { const denied = await readData(c.get('account')); if (denied) return c.json(denied, denyStatus(denied)); const address = { domain: c.req.param('domain'), object: c.req.param('object') }; const described = await container.ledger.describe(address); if (described === undefined) { const body: ApiError = { code: 'not-found', message: `台账对象未物化:${address.domain}.${address.object}` }; return c.json(body, 404); } // 记录浏览只读正式版 —— 跨任务只见过门的事实 const page = await container.ledger.query({ address, where: {}, orderBy: [], limit: 50, offset: 0 }); return c.json({ ...(described.label === undefined ? {} : { label: described.label }), columns: described.columns.map((col) => ({ name: col.name, type: col.type, ...(col.label === undefined ? {} : { label: col.label }), })), primaryKey: [...described.primaryKey], rows: page.rows, total: page.total, }); }); /* ── 记忆管理:活动记忆四层 ─────────────────────────────────────── */ /** * 度量仪表 —— 自主率与纠正率**成对读**,复合为有效自主率。 * 只读近期任务的任务记忆聚合;度量本身不载权力。 */ /* * 度量八指标 —— 走 report 模块的**全组织**投影(staffId 传 undefined)。 * * 此前这里调的是 memory 的五指标版,且把 submissions / corrections / * judgements 四个入参硬塞 0 —— 纠正率与 DMN 覆盖率于是结构性恒为 0, * 仪表在说谎。八指标本就在 report 里算好(§report/contract.ts:「治理台记忆区的 * 仪表读数,同源同口径」),这里只是把它接上,不另起一套口径。 */ app.get(govMetricsRoute.path, async (c) => { const denied = await readMemory(c.get('account')); if (denied) return c.json(denied, denyStatus(denied)); const parsed = govMetricsRoute.query.safeParse(c.req.query()); if (!parsed.success) { const body: ApiError = { code: 'validation-failed', message: '统计窗口不合法', detail: parsed.error.issues }; return c.json(body, 400); } const period = parsed.data.period ?? 'month'; const { staff } = await container.org.listStaff(); const nested = await Promise.all(staff.map((s) => container.task.listByOwner(s.id))); const metrics = await container.report.metrics(undefined, period); return c.json({ ...metrics, taskCount: nested.flat().length, period }); }); /* ── 系统管理 · 通道实例 ────────────────────────────────────────── */ app.get(listChannelsRoute.path, async (c) => { const denied = await readTrigger(c.get('account')); if (denied) return c.json(denied, denyStatus(denied)); const channels = await container.channel.list(); return c.json({ channels: channels.map((ch) => ({ id: ch.id, name: ch.name, kind: ch.kind, address: ch.address, enabled: ch.enabled, extractCount: ch.extract.length, mappingCount: ch.mapping.length, ...(ch.fallbackPosition === undefined ? {} : { fallbackPosition: ch.fallbackPosition }), addressWindowMs: ch.addressWindowMs, })), }); }); app.get(listActivityMemoryRoute.path, async (c) => { const denied = await readMemory(c.get('account')); if (denied) return c.json(denied, denyStatus(denied)); const entries = await container.memory.loadActivityMemory(c.req.param('activityId'), { limit: 100 }); return c.json({ entries: entries.map((e) => ({ id: e.id, layer: e.layer, statement: e.statement, evidenceCount: e.evidence.length, ...(e.increment === undefined ? {} : { incrementState: e.increment.state }), createdAt: e.createdAt, })), }); }); }