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

@@ -29,12 +29,16 @@ import {
export type StreamTab = "all" | "active" | "failed";
/** 引擎本身不发事件;业务事件足以代表任务在动,2 秒节流刷新 */
/** 引擎本身不发事件;业务事件(outbox → SSE 桥)足以代表任务在动,2 秒节流刷新 */
const TASK_EVENTS = [
"task.created",
"task.status_changed",
"download.status_changed",
"download.threshold",
"organization.changed",
"feed.fetched",
"decision.created",
"decision.updated",
"agent.run_status",
"schedule.changed",
];
@@ -1,8 +1,9 @@
import type { FeedHealth, FeedSource } from "@umt/contracts";
import { useQueryClient } from "@tanstack/react-query";
import { EventType, type FeedHealth, type FeedSource } from "@umt/contracts";
import { RotateCcw, Rss, Zap } from "lucide-react";
import { useState } from "react";
import { useEffect, useRef, useState } from "react";
import { toast } from "sonner";
import { feedGapHint, useFeedAction, useFeeds, useFetchFeedNow } from "~/api/feeds.ts";
import { feedGapHint, feedKeys, useFeedAction, useFeeds, useFetchFeedNow } from "~/api/feeds.ts";
import { useSiteAccounts, useSites } from "~/api/sites.ts";
import { type DataColumn, DataTable } from "~/components/console/data-table.tsx";
import { DetailSection, DetailSheet } from "~/components/console/detail-sheet.tsx";
@@ -12,6 +13,7 @@ import { StatusBadge, type StatusTone } from "~/components/console/status-badge.
import { Button } from "~/components/ui/button.tsx";
import { Switch } from "~/components/ui/switch.tsx";
import { errorMessage } from "~/lib/api.ts";
import { useSse } from "~/lib/sse.ts";
import { formatDateTime } from "~/lib/utils.ts";
import { FeedFormDialog, PURPOSE_LABEL } from "./feed-form.tsx";
import { relTime } from "./release-fields.ts";
@@ -78,6 +80,25 @@ function FeedActions({ f }: { f: FeedSource }) {
/**
* Feed 列表 + 详情抽屉 + 新建/编辑对话框。来源健康页列全部,站点详情按 siteId 过滤。
*/
/** 抓取完成 / 健康变化(outbox → SSE 桥)到达时节流刷新 Feed 列表 */
function useLiveFeeds() {
const qc = useQueryClient();
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
useSse([EventType.feedFetched, EventType.feedHealthChanged], () => {
if (timer.current) return;
timer.current = setTimeout(() => {
timer.current = null;
void qc.invalidateQueries({ queryKey: feedKeys.all });
}, 800);
});
useEffect(
() => () => {
if (timer.current) clearTimeout(timer.current);
},
[],
);
}
export function FeedTable({
siteId,
health,
@@ -93,6 +114,7 @@ export function FeedTable({
filtered?: boolean;
}) {
const feeds = useFeeds({ health, siteId });
useLiveFeeds();
const sites = useSites();
const accounts = useSiteAccounts();
const [editingFeed, setEditingFeed] = useState<FeedSource | null>(null);