feat(tasks,server,web): bridge outbox events to the SSE bus (U3)

OutboxRelay gains a `mirror` option: listed types are claimed even without a
task route and, after the claim transaction commits, published in order to
the realtime bus (publish failures are logged, delivery state unaffected).
The worker mirrors feed.*, decision.*, download.*, organization.* and
subscription.* (feed.due excluded) with their business-id payloads, so the
home page, inbox, task stream and feed tables finally receive them. Feed
tables refresh on feed.fetched / feed.health_changed; the task stream also
listens to feed and decision events.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-09-28 16:43:49 +08:00
1 parent 04401309fb
commit 615917bb45
6 files changed
+136 -6

No files matched your search

+14
View File
@@ -6,6 +6,16 @@ import { type EventBusPort, sseConnectionCount } from "../http/sse.ts";
import { type HeartbeatWriter, startHeartbeatWriter } from "../modules/system/heartbeat.ts";
import type { ServerContext, ServerModule } from "../modules/types.ts";
/**
* outbox → SSE 桥:这些前缀的 outbox 事件在中继投递成功后镜像到实时总线(payload 原样,带业务 id),
* 首页、收件箱、任务、来源与下载页据此失效刷新。feed.due 是调度器内部节拍,不推给浏览器。
*/
const SSE_MIRROR_PREFIXES = ["feed.", "decision.", "download.", "organization.", "subscription."] as const;
const SSE_MIRROR_EXCLUDED = new Set<string>([EventType.feedDue]);
export const SSE_MIRRORED_OUTBOX_TYPES: readonly string[] = Object.values(EventType).filter(
(t) => SSE_MIRROR_PREFIXES.some((p) => t.startsWith(p)) && !SSE_MIRROR_EXCLUDED.has(t),
);
/** agent 组任务:整段会话占一槽数分钟,与文件复制等 io 任务分池 */
export const AGENT_TASKS = ["runAgent", "resolveDecision"] as const;
@@ -146,6 +156,10 @@ export async function startWorker({ logger, heartbeatMs, bus, runtime, lagProbe
engine: ctx.engine,
routes: collectOutboxRoutes(modules, ctx),
logSink: tasksLogSink(logger),
mirror: {
types: SSE_MIRRORED_OUTBOX_TYPES,
publish: (ev) => ctx.events.publish(ev.type, ev.payload),
},
...(runtime.outboxIntervalMs ? { intervalMs: runtime.outboxIntervalMs } : {}),
});
runtime.setRelay?.(relay);
+36
View File
@@ -138,4 +138,40 @@ describe.skipIf(!hasPg)("bootstrap + worker(真实 PG)", () => {
const rows = await sql<{ status: string }>`select status from outbox_event`.execute(db);
expect(rows.rows).toEqual([{ status: "dispatched" }]);
}, 40_000);
it("outbox → SSE 桥:feed / decision / download / organization 事件提交后镜像到总线,feed.due 不推", async () => {
const got: { type: string; payload: unknown }[] = [];
const off = booted.ctx.events.subscribe((e) => {
if (e.type !== EventType.heartbeat) got.push({ type: e.type, payload: e.payload });
});
const decisionId = "33333333-3333-4333-8333-333333333333";
const feedId = "44444444-4444-4444-8444-444444444444";
await db.transaction().execute(async (trx) => {
await booted.ctx.outbox.emit(trx, EventType.feedDue, { feedId, reason: "manual" });
await booted.ctx.outbox.emit(trx, EventType.feedFetched, {
feedId,
newItems: 2,
updatedItems: 0,
notModified: false,
});
await booted.ctx.outbox.emit(trx, EventType.decisionCreated, {
decisionId,
kind: "identity_unresolved",
severity: "info",
title: "待识别",
});
});
const deadline = Date.now() + 20_000;
while (got.length < 2 && Date.now() < deadline) await new Promise((r) => setTimeout(r, 100));
off();
// 同一事务内的事件 at 相同,相对顺序不保证
got.sort((a, b) => a.type.localeCompare(b.type));
expect(got).toEqual([
{ type: EventType.decisionCreated, payload: expect.objectContaining({ decisionId }) },
{ type: EventType.feedFetched, payload: expect.objectContaining({ feedId, newItems: 2 }) },
]);
// feed.due 没有路由也不镜像,保持待认领
const due = await sql<{ status: string }>`select status from outbox_event where type = 'feed.due'`.execute(db);
expect(due.rows).toEqual([{ status: "pending" }]);
}, 30_000);
});