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:
2026-07-27 03:28:40 +08:00
co-authored by Claude Opus 5
parent 594a0a9ca0
commit a9cd6d10c7
47 changed files with 7272 additions and 11 deletions
+44
View File
@@ -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;
},
};
}
+5
View File
@@ -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,
+438
View File
@@ -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,
})),
});
});
}
+52
View File
@@ -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();
}
}),
);
}
+4
View File
@@ -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;
}
+6
View File
@@ -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 },
});
+2 -1
View File
@@ -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>;