Archived
M6(2/n): 治理台九功能 + SSE 订阅层 + 读/治分权
治理台按 §9.1 拆成九个 gov-* feature,共用件落 shared/gov:
· 外壳(左轨/顶栏/面包屑/页签/整页下钻)自原型 Governance.tsx 平移,
DOM 与 g-* 类名原样;路由状态即应用状态,区/页签/下钻栈全编码进 hash 可深链
· 编排画布 diagram-js 画布件 1:1 平移(flow-layout/flow-node-paint/FlowDiagram),
新增 pkg-to-graph 把 ProcessPackage 转成画布图模型;统一报价 v1 全图 12 节点 15 边渲染通过
· 后端补四路由:审批单 / 工作流下钻 / 通道实例 / 度量仪表
SSE 订阅层(§9.2):app 层单一连接,按信封目标失效缓存;
组件不再自行轮询(去掉 refetchInterval)。广播只说「哪个目标动了」,不带业务载荷,
且在投递已落库已送达之后才发 —— 不是第二条投递链路。
两处实质修正:
· 治理路由一律要 govern → 读路由改要 read。客体三类 × 动作三种的分法就是为此:
BD 有 task:read 能看在途、动不了控制事件;一律 govern 等于把三种动作压成一种
· 控制事件/恢复/改派原记为 engine-transition(归机器产出)→ 新增 control 条目种类,
归人裁决侧。归侧是按动作类型的纯推导,所以「谁干的」必须体现在 kind 上;
否则人的叫停会被算进自主率的机器分子
判据 ⑩ 治理台一半跑通:在途监控 / 叫停 / 恢复,治理动作留痕可查。
无权查看不再画成空清单,按岗位说明缺哪一项。
gates: tsc / eslint / depcruise(286 模块)/ api-report / 360 测试 全绿
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KeDBx2PHLavbURXB8LJJ7m
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* 投递回执广播 —— 前端单一 SSE 连接的服务端一侧(编码规范 §9.2)。
|
||||
*
|
||||
* 它**不是第二条投递链路**:投递五诺仍由 `sys.event_delivery` 承担。
|
||||
* 这里只在投递已落库、已送达**之后**,把「哪个目标动了」这一事实告诉在线的浏览器,
|
||||
* 让前端失效对应缓存、回服务端重取投影 —— 前端零过程状态,推送里不带业务载荷。
|
||||
*/
|
||||
|
||||
/** 动了的目标:与信封 `correlation.target` 同构;`kind: 'system'` 表示治理面的全局变更 */
|
||||
export type Touch = {
|
||||
readonly kind: 'task' | 'workflow' | 'staff' | 'system';
|
||||
readonly id: string;
|
||||
/** 变更缘由,仅用于前端选择失效哪一组键 */
|
||||
readonly reason: string;
|
||||
};
|
||||
|
||||
export type Broadcaster = {
|
||||
publish(touch: Touch): void;
|
||||
subscribe(listener: (touch: Touch) => void): () => void;
|
||||
readonly size: number;
|
||||
};
|
||||
|
||||
export function createBroadcaster(): Broadcaster {
|
||||
const listeners = new Set<(touch: Touch) => void>();
|
||||
return {
|
||||
publish(touch) {
|
||||
// 广播失败不得反噬业务:任何一个订阅者抛错都只影响它自己
|
||||
for (const listener of listeners) {
|
||||
try {
|
||||
listener(touch);
|
||||
} catch {
|
||||
listeners.delete(listener);
|
||||
}
|
||||
}
|
||||
},
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
get size() {
|
||||
return listeners.size;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -66,9 +66,13 @@ import {
|
||||
import type { AppConfig } from './config.ts';
|
||||
import { createIdGenerator, systemClock } from './runtime.ts';
|
||||
import { createSecretBox, loadSecretKey } from './secret-box.ts';
|
||||
import { createBroadcaster } from './broadcast.ts';
|
||||
import type { Broadcaster } from './broadcast.ts';
|
||||
|
||||
export type Container = {
|
||||
readonly config: AppConfig;
|
||||
/** 投递回执广播 —— 前端 SSE 的服务端一侧;不承担投递语义 */
|
||||
readonly broadcaster: Broadcaster;
|
||||
readonly pool: Pool;
|
||||
readonly db: Db;
|
||||
readonly org: OrgService;
|
||||
@@ -496,6 +500,7 @@ export async function createContainer(config: AppConfig): Promise<Container> {
|
||||
|
||||
return {
|
||||
config,
|
||||
broadcaster: createBroadcaster(),
|
||||
pool,
|
||||
db,
|
||||
org,
|
||||
|
||||
@@ -0,0 +1,438 @@
|
||||
/**
|
||||
* 治理台九功能的运行面路由(编码规范 §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,
|
||||
listProcessPackagesRoute,
|
||||
listToolsRoute,
|
||||
listTriggersRoute,
|
||||
publishProcessRoute,
|
||||
reassignTaskRoute,
|
||||
resumeTaskRoute,
|
||||
staffIdSchema,
|
||||
taskIdSchema,
|
||||
toggleTriggerRoute,
|
||||
validateProcessRoute,
|
||||
} from '@contracts';
|
||||
import { NotPublishableError } from '@modules/process';
|
||||
import type { Container } from '../container.ts';
|
||||
import type { AppEnv } from '../http-env.ts';
|
||||
|
||||
export function registerGovernanceOpsRoutes(app: Hono<AppEnv>, 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<ApiError | undefined> => {
|
||||
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 readTool = guard('tool', 'read');
|
||||
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);
|
||||
const position = node?.kind === 'approval' ? node.approver.position : '';
|
||||
const holders = position === '' ? [] : await container.org.staffOfPosition(position);
|
||||
return {
|
||||
id: `${w.id}:${i.nodeId}#${i.instance}`,
|
||||
workflowId: w.id,
|
||||
workflowSubject: w.subject,
|
||||
title: `审批:${node?.label ?? i.nodeId}`,
|
||||
approverName: holders[0]?.name ?? '—',
|
||||
approverPosition: position,
|
||||
// 终局唯放行: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.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.post(publishProcessRoute.path, async (c) => {
|
||||
const denied = await governProcess(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
try {
|
||||
return c.json(await container.process.publish(c.req.param('id'), Number(c.req.param('version'))));
|
||||
} catch (error) {
|
||||
if (error instanceof NotPublishableError) {
|
||||
const body: ApiError = { code: 'precondition-failed', message: error.message, detail: error.report };
|
||||
return c.json(body, 412);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
/* ── 触发器配置 ──────────────────────────────────────────────────── */
|
||||
|
||||
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 });
|
||||
});
|
||||
|
||||
/* ── 活动库管理:两维分类 × 两条版本序列 ─────────────────────────── */
|
||||
|
||||
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();
|
||||
return c.json({
|
||||
activities: cards.map((card) => ({
|
||||
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,
|
||||
})),
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
/* ── 工具管理 ────────────────────────────────────────────────────── */
|
||||
|
||||
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({
|
||||
columns: described.columns.map((col) => ({ name: col.name, type: col.type })),
|
||||
primaryKey: [...described.primaryKey],
|
||||
rows: page.rows,
|
||||
total: page.total,
|
||||
});
|
||||
});
|
||||
|
||||
/* ── 记忆管理:活动记忆四层 ─────────────────────────────────────── */
|
||||
|
||||
/**
|
||||
* 度量仪表 —— 自主率与纠正率**成对读**,复合为有效自主率。
|
||||
* 只读近期任务的任务记忆聚合;度量本身不载权力。
|
||||
*/
|
||||
app.get(govMetricsRoute.path, async (c) => {
|
||||
const denied = await readMemory(c.get('account'));
|
||||
if (denied) return c.json(denied, denyStatus(denied));
|
||||
const { staff } = await container.org.listStaff();
|
||||
const nested = await Promise.all(staff.map((s) => container.task.listByOwner(s.id)));
|
||||
const taskIds = nested.flat().map((t) => t.id);
|
||||
const metrics = await container.memory.metrics({
|
||||
taskIds,
|
||||
submissions: 0,
|
||||
corrections: 0,
|
||||
taskCount: taskIds.length,
|
||||
deterministicJudgements: 0,
|
||||
totalJudgements: 0,
|
||||
});
|
||||
return c.json({ ...metrics, taskCount: taskIds.length });
|
||||
});
|
||||
|
||||
/* ── 系统管理 · 通道实例 ────────────────────────────────────────── */
|
||||
|
||||
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,
|
||||
})),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* SSE:前端唯一一条订阅连接(编码规范 §9.2)。
|
||||
*
|
||||
* 「SSE 事件到达 → 按信封目标失效对应 TanStack Query 缓存,由订阅层统一做;
|
||||
* 组件禁止自行轮询或手写缓存。」
|
||||
* 推送里**不带业务载荷**——只说「哪个目标动了」,画面仍由服务端投影重建。
|
||||
*/
|
||||
import type { Hono } from 'hono';
|
||||
import { streamSSE } from 'hono/streaming';
|
||||
import type { Broadcaster } from '../broadcast.ts';
|
||||
import type { AppEnv } from '../http-env.ts';
|
||||
|
||||
export function registerStreamRoute(app: Hono<AppEnv>, broadcaster: Broadcaster): void {
|
||||
app.get('/api/stream', (c) =>
|
||||
streamSSE(c, async (stream) => {
|
||||
const queue: string[] = [];
|
||||
let wake: (() => void) | undefined;
|
||||
|
||||
const unsubscribe = broadcaster.subscribe((touch) => {
|
||||
queue.push(JSON.stringify(touch));
|
||||
wake?.();
|
||||
});
|
||||
stream.onAbort(unsubscribe);
|
||||
|
||||
await stream.writeSSE({ event: 'ready', data: '1' });
|
||||
|
||||
// 心跳兼作断线探测:客户端 EventSource 自带重连,重连后前端整体失效一次
|
||||
const heartbeat = setInterval(() => {
|
||||
queue.push('');
|
||||
wake?.();
|
||||
}, 20_000);
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
if (queue.length === 0) {
|
||||
await new Promise<void>((resolve) => {
|
||||
wake = resolve;
|
||||
});
|
||||
wake = undefined;
|
||||
continue;
|
||||
}
|
||||
const next = queue.shift()!;
|
||||
if (next === '') await stream.writeSSE({ event: 'ping', data: '1' });
|
||||
else await stream.writeSSE({ event: 'touch', data: next });
|
||||
}
|
||||
} finally {
|
||||
clearInterval(heartbeat);
|
||||
unsubscribe();
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -8,8 +8,10 @@ import { DEFAULT_LOGIN_ACCOUNT } from '@modules/org';
|
||||
import type { Container } from './container.ts';
|
||||
import type { AppEnv } from './http-env.ts';
|
||||
import { registerDeskRoutes } from './routes/desk.ts';
|
||||
import { registerGovernanceOpsRoutes } from './routes/governance-ops.ts';
|
||||
import { registerGovernanceRoutes } from './routes/governance.ts';
|
||||
import { registerIngestRoutes } from './routes/ingest.ts';
|
||||
import { registerStreamRoute } from './routes/stream.ts';
|
||||
import { registerWorkbenchRoutes } from './routes/workbench.ts';
|
||||
|
||||
export type { AppEnv };
|
||||
@@ -42,7 +44,9 @@ export function createServer(container: Container): Hono<AppEnv> {
|
||||
registerWorkbenchRoutes(app, container);
|
||||
registerGovernanceRoutes(app, container);
|
||||
registerDeskRoutes(app, container);
|
||||
registerGovernanceOpsRoutes(app, container);
|
||||
registerIngestRoutes(app, container);
|
||||
registerStreamRoute(app, container.broadcaster);
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -56,6 +56,12 @@ export async function dispatchOnce(deps: DispatcherDeps): Promise<number> {
|
||||
await container.db.execute(
|
||||
sql`update sys.event_delivery set state = 'delivered' where id = ${delivery.id}`,
|
||||
);
|
||||
// 已落库、已送达之后才广播 —— 广播不是投递,只是告诉在线浏览器「这个目标动了」
|
||||
container.broadcaster.publish({
|
||||
kind: delivery.target.kind === 'message-position' ? 'staff' : 'task',
|
||||
id: String(delivery.target.id),
|
||||
reason: delivery.target.kind,
|
||||
});
|
||||
handled += 1;
|
||||
} catch (error) {
|
||||
await container.event.reportFailure(delivery.id, error instanceof Error ? error.message : String(error));
|
||||
|
||||
@@ -187,3 +187,20 @@ describe('活动记忆 —— 三件套与不载权力', () => {
|
||||
expect(selectForLoading(rows, { limit: 10 })).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('归侧:治理干预', () => {
|
||||
it('控制事件归人裁决侧 —— 人的叫停不得记成机器产出', () => {
|
||||
// 归侧是纯推导(按动作类型),所以「谁干的」必须体现在 kind 上,而不是靠调用方传侧。
|
||||
expect(deriveSide('control')).toBe('human-verdict');
|
||||
expect(deriveSide('engine-transition')).toBe('machine');
|
||||
});
|
||||
|
||||
it('治理干预进结构化日志骨架,模板与引擎自转可分辨', () => {
|
||||
const lines = structuredLog([
|
||||
entry('control', { seq: 1, title: '控制事件:halt' }),
|
||||
entry('engine-transition', { seq: 2, title: '步 1 完成' }),
|
||||
]);
|
||||
expect(lines.map((l) => l.title)).toEqual(['治理:控制事件:halt', '迁移:步 1 完成']);
|
||||
expect(lines.map((l) => l.sideLabel)).toEqual(['人裁决', '机器产出']);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ const SKELETON: ReadonlySet<EntryKind> = new Set<EntryKind>([
|
||||
'release-verdict',
|
||||
'terminal',
|
||||
'human-input',
|
||||
'control',
|
||||
]);
|
||||
|
||||
const TEMPLATE: Readonly<Record<EntryKind, (e: MemoryEntry) => string>> = {
|
||||
@@ -49,6 +50,7 @@ const TEMPLATE: Readonly<Record<EntryKind, (e: MemoryEntry) => string>> = {
|
||||
'release-verdict': (e) => `放行裁决:${e.title}`,
|
||||
terminal: (e) => `终态:${e.title}`,
|
||||
'human-input': (e) => `留言:${e.title}`,
|
||||
control: (e) => `治理:${e.title}`,
|
||||
};
|
||||
|
||||
/** 结构化日志:零 LLM,可重算,每条自带谁 · 何时 · 依据 · 留痕指针 */
|
||||
|
||||
@@ -21,6 +21,8 @@ export const ENTRY_KINDS = [
|
||||
'release-verdict',
|
||||
'terminal',
|
||||
'human-input',
|
||||
/** 治理干预:控制事件 / 恢复 / 改派 —— 人对过程的裁断,不是引擎自转 */
|
||||
'control',
|
||||
] as const;
|
||||
export type EntryKind = (typeof ENTRY_KINDS)[number];
|
||||
|
||||
@@ -76,6 +78,8 @@ export function deriveSide(kind: EntryKind): AttributionSide {
|
||||
case 'card-reply':
|
||||
case 'human-input':
|
||||
return 'human';
|
||||
// 治理干预由人发起:归人裁决侧,否则自主率会把人的叫停算成机器产出
|
||||
case 'control':
|
||||
case 'release-verdict':
|
||||
case 'terminal':
|
||||
return 'human-verdict';
|
||||
|
||||
@@ -172,7 +172,7 @@ export function createTaskService(deps: TaskServiceDeps): TaskService {
|
||||
await deps.events.publishControl(taskId, semantic, by, reason);
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'engine-transition',
|
||||
kind: 'control',
|
||||
title: `控制事件:${semantic}`,
|
||||
detail: { by, reason, correctionSignal: outcome.kind === 'halted' ? 'heaviest' : undefined },
|
||||
});
|
||||
@@ -190,7 +190,7 @@ export function createTaskService(deps: TaskServiceDeps): TaskService {
|
||||
async resume(taskId, by) {
|
||||
const task = await require(taskId);
|
||||
if (task.terminal !== undefined) throw new TaskAlreadyTerminalError(taskId, task.terminal);
|
||||
await deps.memory.append({ taskId, kind: 'engine-transition', title: '恢复', detail: { by } });
|
||||
await deps.memory.append({ taskId, kind: 'control', title: '恢复', detail: { by } });
|
||||
const updated = await deps.store.update(taskId, { runtime: 'running' });
|
||||
await deps.interaction.setPhase(taskId, 'active');
|
||||
return updated;
|
||||
@@ -202,7 +202,7 @@ export function createTaskService(deps: TaskServiceDeps): TaskService {
|
||||
const updated = await deps.store.update(taskId, { ownerId: to });
|
||||
await deps.memory.append({
|
||||
taskId,
|
||||
kind: 'engine-transition',
|
||||
kind: 'control',
|
||||
title: `改派:${task.ownerId} → ${to}`,
|
||||
detail: { by },
|
||||
});
|
||||
|
||||
@@ -48,7 +48,8 @@ export type ParadigmEngine = {
|
||||
export type MemoryRecorder = {
|
||||
append(input: {
|
||||
taskId: TaskId;
|
||||
kind: 'engine-transition' | 'terminal' | 'observation';
|
||||
/** control = 治理干预(叫停 / 恢复 / 改派):人对过程的裁断,归人裁决侧 */
|
||||
kind: 'engine-transition' | 'terminal' | 'observation' | 'control';
|
||||
title: string;
|
||||
detail?: Record<string, unknown>;
|
||||
}): Promise<void>;
|
||||
|
||||
Reference in New Issue
Block a user