Archived
治理台按 §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
94 lines
3.5 KiB
TypeScript
94 lines
3.5 KiB
TypeScript
/**
|
|
* 投递派发器 —— ADR-008 #2。
|
|
*
|
|
* 按 `state='pending' and next_attempt_at <= now()` 取投递记录,
|
|
* 对**目标 ID 取 advisory lock** 串行执行 —— 这正是「同任务按序」的实现(§7.1)。
|
|
* 消费幂等由 `sys.event_consumption(event_id, consumer)` 唯一约束承担。
|
|
*
|
|
* 消费者(任务 / 触发器)随 M4 / M5 落地;在此之前派发器把投递标记为已送达并留痕,
|
|
* 保证投递链路本身可测、不积压(docs/debt.md · M1-3)。
|
|
*/
|
|
import { sql } from 'drizzle-orm';
|
|
import type { Delivery } from '@modules/event';
|
|
import { claimPendingDeliveries } from '@modules/event';
|
|
import type { Container } from '../container.ts';
|
|
|
|
/** 消费者:按目标类型分派 */
|
|
export type Consumer = (delivery: Delivery, container: Container) => Promise<void>;
|
|
|
|
export type DispatcherDeps = {
|
|
readonly container: Container;
|
|
readonly consumers: ReadonlyMap<Delivery['target']['kind'], Consumer>;
|
|
readonly batchSize?: number;
|
|
};
|
|
|
|
/** 目标 ID → advisory lock 键(稳定散列,同一目标恒同一把锁) */
|
|
function lockKey(target: Delivery['target']): number {
|
|
const text = `${target.kind}:${target.id}`;
|
|
let hash = 5381;
|
|
for (let i = 0; i < text.length; i += 1) hash = ((hash << 5) + hash + text.charCodeAt(i)) | 0;
|
|
return hash;
|
|
}
|
|
|
|
export async function dispatchOnce(deps: DispatcherDeps): Promise<number> {
|
|
const { container } = deps;
|
|
const batch = await claimPendingDeliveries(container.db, deps.batchSize ?? 20);
|
|
let handled = 0;
|
|
|
|
for (const delivery of batch) {
|
|
const key = lockKey(delivery.target);
|
|
// 同任务按序:取到锁才处理,取不到说明同目标的另一条正在处理,留待下一轮
|
|
const locked = await container.db.execute<{ ok: boolean }>(
|
|
sql`select pg_try_advisory_lock(${key}) as ok`,
|
|
);
|
|
if (locked.rows[0]?.ok !== true) continue;
|
|
|
|
try {
|
|
const consumer = deps.consumers.get(delivery.target.kind);
|
|
if (consumer !== undefined) {
|
|
const claimed = await container.event.claimConsumption(
|
|
delivery.eventId,
|
|
`${delivery.target.kind}:${delivery.target.id}`,
|
|
);
|
|
// 至少一次 + 幂等:已消费过的重复投递直接标记完成,不再执行副作用
|
|
if (claimed) await consumer(delivery, container);
|
|
}
|
|
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));
|
|
} finally {
|
|
await container.db.execute(sql`select pg_advisory_unlock(${key})`);
|
|
}
|
|
}
|
|
return handled;
|
|
}
|
|
|
|
export function startDispatcher(deps: DispatcherDeps, intervalMs: number): { stop(): void } {
|
|
let stopped = false;
|
|
const tick = async (): Promise<void> => {
|
|
if (stopped) return;
|
|
try {
|
|
await dispatchOnce(deps);
|
|
} catch (error) {
|
|
process.stderr.write(`[dispatcher] ${error instanceof Error ? error.message : String(error)}\n`);
|
|
}
|
|
};
|
|
const timer = setInterval(() => void tick(), intervalMs);
|
|
timer.unref();
|
|
return {
|
|
stop() {
|
|
stopped = true;
|
|
clearInterval(timer);
|
|
},
|
|
};
|
|
}
|