fix(awooop): clarify operator disposition lanes
All checks were successful
Code Review / ai-code-review (push) Successful in 10s
CD Pipeline / tests (push) Successful in 1m5s
CD Pipeline / build-and-deploy (push) Successful in 3m43s
CD Pipeline / post-deploy-checks (push) Successful in 1m32s

This commit is contained in:
Your Name
2026-05-06 22:24:28 +08:00
parent d2205dc1c0
commit 7f4f5b24ba
7 changed files with 417 additions and 15 deletions

View File

@@ -1501,11 +1501,38 @@
"runsDetail": "Run state is the single view into async work",
"approvals": "Pending Approvals",
"approvalsDetail": "Every high-risk action must stop at the human gate",
"contracts": "Contracts",
"contractsDetail": "Project / Agent / Policy contract publish state"
},
"lanes": {
"title": "Flywheel Lanes",
"contracts": "Contracts",
"contractsDetail": "Project / Agent / Policy contract publish state"
},
"disposition": {
"title": "Disposition Semantics",
"diagnosis": {
"title": "Read-only Diagnosis",
"signal": "AI collected evidence",
"owner": "Owner: AI summarizes, SRE judges",
"route": "Route: Run monitor / incident detail"
},
"approval": {
"title": "Human Gate",
"signal": "High-risk approval pending",
"owner": "Owner: SRE approve / reject",
"route": "Route: Approval queue"
},
"execute": {
"title": "Auto Execution",
"signal": "Low-risk closure path",
"owner": "Owner: MCP Gateway executes and audits",
"route": "Route: Run State / Audit"
},
"manual": {
"title": "Manual Escalation",
"signal": "AI cannot safely repair",
"owner": "Owner: war room takes over",
"route": "Route: AwoooI SRE war room"
}
},
"lanes": {
"title": "Flywheel Lanes",
"live": "Live",
"mirror": "Mirror",
"providerName": "Provider Order",

View File

@@ -1502,11 +1502,38 @@
"runsDetail": "Run state 是非同步任務的唯一觀測入口",
"approvals": "待審批",
"approvalsDetail": "所有高風險動作都必須停在人工閘門",
"contracts": "合約",
"contractsDetail": "Project / Agent / Policy contract 發布狀態"
},
"lanes": {
"title": "飛輪鏈路",
"contracts": "合約",
"contractsDetail": "Project / Agent / Policy contract 發布狀態"
},
"disposition": {
"title": "處置語義",
"diagnosis": {
"title": "只讀診斷",
"signal": "AI 已收集證據",
"owner": "負責AI 先整理SRE 判讀",
"route": "流向Run 監控 / 事件詳情"
},
"approval": {
"title": "人工閘門",
"signal": "高風險待批准",
"owner": "負責SRE approve / reject",
"route": "流向:審批佇列"
},
"execute": {
"title": "自動執行",
"signal": "低風險可閉環",
"owner": "負責MCP Gateway 執行並稽核",
"route": "流向Run State / Audit"
},
"manual": {
"title": "人工升級",
"signal": "AI 無法安全修復",
"owner": "負責:戰情室接手",
"route": "流向AwoooI SRE 戰情室"
}
},
"lanes": {
"title": "飛輪鏈路",
"live": "已接線",
"mirror": "Mirror",
"providerName": "Provider 順序",

View File

@@ -5,13 +5,15 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import {
ShieldCheck,
RefreshCw,
AlertCircle,
Clock,
ArrowRight,
ListChecks,
TriangleAlert,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Link } from "@/i18n/routing";
@@ -97,6 +99,15 @@ function TimeoutCell({ timeoutAt }: { timeoutAt: string | null }) {
);
}
function DecisionPostureBadge() {
return (
<span className="inline-flex items-center gap-1.5 border border-[#d9b36f] bg-[#fff7e8] px-2 py-0.5 text-xs font-semibold text-[#8a5a08]">
<ShieldCheck className="h-3.5 w-3.5" aria-hidden="true" />
</span>
);
}
function ApprovalRow({ approval }: { approval: Approval }) {
const formattedDate = approval.created_at
? new Date(approval.created_at).toLocaleDateString("zh-TW", {
@@ -137,6 +148,9 @@ function ApprovalRow({ approval }: { approval: Approval }) {
{approval.agent_id || "--"}
</span>
</td>
<td className="px-4 py-3">
<DecisionPostureBadge />
</td>
<td className="px-4 py-3">
<span className="text-sm text-muted-foreground font-mono">
{formattedDate}
@@ -193,6 +207,43 @@ export default function ApprovalsPage() {
const ms = getRemainingMs(a.timeout_at);
return ms !== null && ms <= 5 * 60 * 1000;
}).length;
const expiredCount = approvals.filter((a) => {
const ms = getRemainingMs(a.timeout_at);
return ms !== null && ms <= 0;
}).length;
const queueSummary = useMemo(
() => [
{
label: "待人工決策",
value: approvals.length,
detail: "等待 approve / reject 的唯一佇列",
icon: ShieldCheck,
className: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]",
},
{
label: "即將逾時",
value: criticalCount,
detail: "5 分鐘內必須處置",
icon: Clock,
className: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]",
},
{
label: "已逾時",
value: expiredCount,
detail: "不得再自動 resume",
icon: TriangleAlert,
className: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]",
},
{
label: "操作來源",
value: "Run",
detail: "審批必須回到 Operator Run",
icon: ListChecks,
className: "border-[#d8d3c7] bg-white text-[#5f5b52]",
},
],
[approvals.length, criticalCount, expiredCount]
);
return (
<div className="space-y-6">
@@ -230,6 +281,33 @@ export default function ApprovalsPage() {
</div>
</div>
<section className="grid gap-px border border-[#e0ddd4] bg-[#e0ddd4] md:grid-cols-2 xl:grid-cols-4">
{queueSummary.map((item) => {
const Icon = item.icon;
return (
<div key={item.label} className="bg-white px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold text-[#77736a]">{item.label}</p>
<div className="mt-2 font-mono text-2xl font-semibold text-[#141413]">
{item.value}
</div>
</div>
<span
className={cn(
"flex h-8 w-8 items-center justify-center border",
item.className
)}
>
<Icon className="h-4 w-4" aria-hidden="true" />
</span>
</div>
<p className="mt-2 text-xs leading-5 text-[#5f5b52]">{item.detail}</p>
</div>
);
})}
</section>
{/* Error State */}
{error && (
<div className="flex items-start gap-3 border border-[#e2a29b] bg-[#fff0ef] p-4">
@@ -266,6 +344,9 @@ export default function ApprovalsPage() {
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Agent
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Lane
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
</th>
@@ -278,7 +359,7 @@ export default function ApprovalsPage() {
{loading ? (
Array.from({ length: 5 }).map((_, i) => (
<tr key={i} className="border-b border-border">
{Array.from({ length: 5 }).map((_, j) => (
{Array.from({ length: 6 }).map((_, j) => (
<td key={j} className="px-4 py-3">
<div className="h-5 bg-muted animate-pulse rounded w-20" />
</td>

View File

@@ -14,8 +14,11 @@ import {
CheckCircle2,
FileText,
GitBranch,
ListChecks,
RefreshCw,
SearchCheck,
ShieldCheck,
TriangleAlert,
Waypoints,
} from "lucide-react";
import { Link } from "@/i18n/routing";
@@ -140,6 +143,54 @@ function LaneRow({
);
}
function DispositionCell({
title,
signal,
owner,
route,
tone,
icon: Icon,
}: {
title: string;
signal: string;
owner: string;
route: string;
tone: "diagnosis" | "approval" | "execute" | "manual";
icon: typeof SearchCheck;
}) {
return (
<div className="min-h-[154px] border border-[#e0ddd4] bg-white px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold text-[#77736a]">{signal}</p>
<h3 className="mt-2 text-base font-semibold tracking-normal text-[#141413]">
{title}
</h3>
</div>
<span
className={cn(
"flex h-8 w-8 items-center justify-center border",
tone === "diagnosis" && "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]",
tone === "approval" && "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]",
tone === "execute" && "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]",
tone === "manual" && "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]"
)}
>
<Icon className="h-4 w-4" aria-hidden="true" />
</span>
</div>
<dl className="mt-4 grid gap-2 text-xs">
<div className="flex items-center justify-between gap-3">
<dt className="text-[#77736a]">{route}</dt>
</div>
<div className="flex items-center justify-between gap-3">
<dt className="text-[#77736a]">{owner}</dt>
</div>
</dl>
</div>
);
}
export default function AwoooPPage() {
const t = useTranslations("awooop.home");
const locale = useLocale();
@@ -295,6 +346,51 @@ export default function AwoooPPage() {
/>
</section>
<section className="border border-[#e0ddd4] bg-[#e0ddd4]">
<div className="border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">
<div className="flex items-center gap-2">
<ListChecks className="h-4 w-4 text-[#d97757]" aria-hidden="true" />
<h3 className="text-sm font-semibold text-[#141413]">
{t("disposition.title")}
</h3>
</div>
</div>
<div className="grid gap-px md:grid-cols-2 xl:grid-cols-4">
<DispositionCell
title={t("disposition.diagnosis.title")}
signal={t("disposition.diagnosis.signal")}
owner={t("disposition.diagnosis.owner")}
route={t("disposition.diagnosis.route")}
tone="diagnosis"
icon={SearchCheck}
/>
<DispositionCell
title={t("disposition.approval.title")}
signal={t("disposition.approval.signal")}
owner={t("disposition.approval.owner")}
route={t("disposition.approval.route")}
tone="approval"
icon={ShieldCheck}
/>
<DispositionCell
title={t("disposition.execute.title")}
signal={t("disposition.execute.signal")}
owner={t("disposition.execute.owner")}
route={t("disposition.execute.route")}
tone="execute"
icon={Activity}
/>
<DispositionCell
title={t("disposition.manual.title")}
signal={t("disposition.manual.signal")}
owner={t("disposition.manual.owner")}
route={t("disposition.manual.route")}
tone="manual"
icon={TriangleAlert}
/>
</div>
</section>
<section className="grid gap-5 xl:grid-cols-[1.1fr_0.9fr]">
<div className="border border-[#e0ddd4] bg-white">
<div className="border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">

View File

@@ -5,7 +5,7 @@
"use client";
import { useState, useEffect, useCallback, useRef } from "react";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import {
Activity,
RefreshCw,
@@ -15,6 +15,10 @@ import {
ChevronLeft,
ChevronRight,
Cpu,
ListChecks,
SearchCheck,
ShieldCheck,
TriangleAlert,
} from "lucide-react";
import { cn } from "@/lib/utils";
@@ -32,6 +36,8 @@ type RunState =
| "cancelled"
| "timeout";
type RunLane = "intake" | "diagnosis" | "approval" | "execution" | "done" | "manual";
interface Run {
run_id: string;
project_id: string;
@@ -119,6 +125,62 @@ const STATE_CONFIG: Record<
},
};
const LANE_CONFIG: Record<
RunLane,
{
label: string;
detail: string;
icon: typeof Activity;
className: string;
}
> = {
intake: {
label: "排程入口",
detail: "等待 worker 接手",
icon: ListChecks,
className: "border-[#d8d3c7] bg-white text-[#5f5b52]",
},
diagnosis: {
label: "只讀診斷",
detail: "MCP / SSH / RAG 收集證據",
icon: SearchCheck,
className: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]",
},
approval: {
label: "人工閘門",
detail: "等待 SRE 批准或拒絕",
icon: ShieldCheck,
className: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]",
},
execution: {
label: "AI 執行中",
detail: "模型推理或安全動作執行",
icon: Activity,
className: "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]",
},
done: {
label: "已收斂",
detail: "完成並等待稽核回寫",
icon: Activity,
className: "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]",
},
manual: {
label: "人工升級",
detail: "AI 無法閉環,需人工處置",
icon: TriangleAlert,
className: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]",
},
};
function getRunLane(state: RunState): RunLane {
if (state === "pending") return "intake";
if (state === "waiting_tool") return "diagnosis";
if (state === "waiting_approval") return "approval";
if (state === "running") return "execution";
if (state === "completed") return "done";
return "manual";
}
// =============================================================================
// Sub Components
// =============================================================================
@@ -151,6 +213,24 @@ function ShadowBadge({ isShadow }: { isShadow: boolean }) {
);
}
function RunLaneBadge({ state }: { state: RunState }) {
const lane = LANE_CONFIG[getRunLane(state)];
const Icon = lane.icon;
return (
<span
className={cn(
"inline-flex items-center gap-1.5 border px-2 py-0.5 text-xs font-semibold",
lane.className
)}
title={lane.detail}
>
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
{lane.label}
</span>
);
}
function RunRow({ run }: { run: Run }) {
const formattedDate = run.created_at
? new Date(run.created_at).toLocaleDateString("zh-TW", {
@@ -183,6 +263,9 @@ function RunRow({ run }: { run: Run }) {
<td className="px-4 py-3">
<RunStateBadge state={run.state} />
</td>
<td className="px-4 py-3">
<RunLaneBadge state={run.state} />
</td>
<td className="px-4 py-3">
<ShadowBadge isShadow={run.is_shadow} />
</td>
@@ -279,6 +362,20 @@ export default function RunsPage() {
}, [fetchRuns]);
const totalPages = Math.ceil(total / PER_PAGE);
const laneSummary = useMemo(() => {
const counts: Record<RunLane, number> = {
intake: 0,
diagnosis: 0,
approval: 0,
execution: 0,
done: 0,
manual: 0,
};
runs.forEach((run) => {
counts[getRunLane(run.state)] += 1;
});
return counts;
}, [runs]);
return (
<div className="space-y-6">
@@ -309,6 +406,27 @@ export default function RunsPage() {
</div>
</div>
<section className="grid gap-px border border-[#e0ddd4] bg-[#e0ddd4] md:grid-cols-3 xl:grid-cols-6">
{(Object.keys(LANE_CONFIG) as RunLane[]).map((lane) => {
const config = LANE_CONFIG[lane];
const Icon = config.icon;
return (
<div key={lane} className="bg-white px-4 py-3">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold text-[#77736a]">{config.label}</p>
<p className="mt-1 text-xs leading-5 text-[#5f5b52]">{config.detail}</p>
</div>
<Icon className="h-4 w-4 text-[#87867f]" aria-hidden="true" />
</div>
<div className="mt-3 font-mono text-2xl font-semibold text-[#141413]">
{laneSummary[lane]}
</div>
</div>
);
})}
</section>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3 border border-[#e0ddd4] bg-white p-4">
<Filter className="w-4 h-4 text-muted-foreground flex-shrink-0" aria-hidden="true" />
@@ -380,6 +498,9 @@ export default function RunsPage() {
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Lane
</th>
<th className="text-left px-4 py-3 text-xs font-medium text-muted-foreground uppercase tracking-wider">
Shadow
</th>
@@ -395,7 +516,7 @@ export default function RunsPage() {
{loading ? (
Array.from({ length: 8 }).map((_, i) => (
<tr key={i} className="border-b border-border">
{Array.from({ length: 7 }).map((_, j) => (
{Array.from({ length: 8 }).map((_, j) => (
<td key={j} className="px-4 py-3">
<div className="h-5 bg-muted animate-pulse rounded w-20" />
</td>
@@ -404,7 +525,7 @@ export default function RunsPage() {
))
) : runs.length === 0 && !error ? (
<tr>
<td colSpan={7} className="px-4 py-16 text-center">
<td colSpan={8} className="px-4 py-16 text-center">
<Activity className="w-10 h-10 text-muted-foreground/30 mx-auto mb-3" aria-hidden="true" />
<p className="text-sm text-muted-foreground"> Run </p>
</td>

View File

@@ -59,6 +59,24 @@ const workItems: WorkItem[] = [
gate: "Gemini 僅能作為 fallback",
href: "/awooop/runs",
},
{
phase: "P0",
title: "Telegram 診斷 / 修復語義分離",
status: "live",
surface: "Run 監控 / 審批佇列",
source: "automation_state",
gate: "只讀診斷不得標成自動修復失敗",
href: "/awooop/runs",
},
{
phase: "P0",
title: "告警訊息彙整與戰情室分流",
status: "in_progress",
surface: "AwoooP / Telegram",
source: "outbound_message / conversation_event",
gate: "TG Bot 私訊不得承接告警洪流",
href: "/awooop/approvals",
},
{
phase: "P0",
title: "飛輪 KPI 改讀 auto_repair_executions",

View File

@@ -4269,3 +4269,35 @@ Playwright:
- 目前公開環境已無法重現 client-side exception頁面、次級導航、SSE hydration 都正常。
- 若使用者瀏覽器仍看到錯誤,優先判斷為舊 JS chunk / 瀏覽器快取 / 部署切換期間殘留狀態;下一步可要求硬重新整理,或補 Service Worker / cache-busting 檢查。
- 仍需另排前端產品化改善:目前 AwoooP 可用但視覺與資訊密度仍偏 MVP尚未達到正式 Operator Console 品質。
## 2026-05-06台北— AwoooP Operator Console 補上處置語義層
**觸發**:統帥指出 Telegram 告警與 AI 自動化訊息混雜難以快速判斷「AI 已完成診斷」、「AI 可自動修復」、「AI 無法修復需人工」。
### 改動
- `/zh-TW/awooop` 首頁新增「處置語義」四分流:只讀診斷、人工閘門、自動執行、人工升級。
- `/zh-TW/awooop/runs` 依 Run state 推導處置 Lane`waiting_tool` 明確標成只讀診斷,`waiting_approval` 標成人工閘門,`failed/cancelled/timeout` 標成人工升級。
- `/zh-TW/awooop/approvals` 新增審批佇列摘要與「人工閘門」欄位,避免審批項目被誤讀成 AI 已在自動修復。
- `/zh-TW/awooop/work-items` 補上 Telegram 診斷/修復語義分離與告警彙整分流的工作項目。
### 驗證
```text
pnpm --dir apps/web typecheck
# passed
NEXT_PUBLIC_API_URL=https://awoooi.wooo.work pnpm --dir apps/web build
# passedAwoooP routes 均完成 production build
local next start:
/zh-TW/awooop -> HTTP 200
/zh-TW/awooop/runs -> HTTP 200
/zh-TW/awooop/approvals -> HTTP 200
/zh-TW/awooop/work-items -> HTTP 200
```
### 判讀
- 這次只收斂 Operator Console 語義與資訊架構,不改 runtime 決策鏈。
- 本機瀏覽器檢查時因 localhost 呼叫 `https://awoooi.wooo.work` 受到 CORS 限制,頁面本身無 pageerror部署到同源正式環境後需再做 live pageerror 複驗。