FR-067 · Detection Multi-Agent Dispatch — 設計文件 · 2026-08-25(.1–.5/.7/.8 已實作,驗收迭代中)

一次檢測展開 N 張工單:多 Agent 分派設計定案

檢測任務可設定多組「哪台 agent 掃哪些目標」,一次執行同時派給多台、全部回報完才算完成,報告與通知彙總呈現。本文件承接討論稿,D1–D9 已於 2026-08-23 全數拍板(含 D7 執行粒度、D8 相容範圍兩項 user 修訂),落成資料模型、API 契約、收口演算法與拆分表;2026-08-24 追加 D10(per-assignment 排程執行)成 FR-067.7。

.1–.5/.7/.8 已實作,驗收迭代中 D1–D10 全數拍板(D10:2026-08-24) 前作:FR-056/FR-058 Agent 端(evidence-agent)零改動

FR-067 檢測多 Agent 分派(Detection Multi-Agent Dispatch)— 設計文件

狀態:D1–D10 已拍板;.1–.5/.7/.8 已實作,端到端驗收迭代中(.6 收尾未派)|建立日期:2026-08-23|最後更新:2026-08-25(第二輪回饋 CM-1382/1383 + FR-067.8 三卡落地回寫)|前作:FR-056(檢測工具整合平台)/FR-058(檢測執行) 討論稿(含現況盤點與流程圖):discussion.md

給 PM 的三分鐘版

這節不需要任何技術背景就讀得懂。工程細節從下一節起。

這案在做什麼:檢測工具(弱點掃描、組態檢查那些)是靠派駐在客戶機房的「Agent」去跑 的——Agent 就是一台裝了掃描程式的機器,替我們去打目標主機。客戶的網路通常被切成好幾個 互不相通的區段(辦公網、機房網、對外的 DMZ),一台 Agent 打不到別區段,所以每段各放一台。

本案之前,一次檢測只能派一台,而且是系統隨手挑的——不看那台在不在線上。挑到關機的 機器,畫面就永遠停在「執行中」轉不完,沒有錯誤、沒有告警。要掃三個區段只能開三個任務、 人工拼結果。

本案讓使用者在任務設定裡列出多組「哪台 Agent哪些目標」,按一次執行同時派出去, 全部回報完才算這次檢測完成

為什麼值得做:客戶的多網段是常態不是特例,現況等於把「拆成幾個任務、記得哪個對哪個、 最後自己拼起來」這件事丟給使用者做,而且挑到離線機還會靜默卡死。

影響哪些畫面/操作

畫面 變化
任務設定 → 檢測工具 從「填一組掃描設定」變成「可以加好幾列,每列一台 Agent+自己的掃描目標與參數」;可以複製一列、也可以每列各設幾點才開跑
Agent 下拉選單 只列得出「有掃描能力」的機器,並標示在線/離線
執行紀錄 一次執行變成一組多筆,看得到每筆是哪台掃的;哪台失敗可以單獨重跑那一台
通知信 從每台各發一封改成收口時一封,內含各台結果

拍板了什麼(決策 D1–D10 的白話版,完整理由見 §3):

題目 定案
內部怎麼記 一次執行展開成 N 張工單,外面包一層「群組」做彙總(維持既有「一張工單=一次掃描」的結構不動)
什麼時候算完成 群組裡全部都到終點狀態才算完成——才發通知、才自動結任務
失敗了怎麼辦 可以只重跑失敗的那一組,不必整批重來;也可以單組或整組取消
舊資料怎麼辦 不做轉換(當時還在開發驗證階段,資料之後會重置)
沒設多組的任務 照舊能跑,但改成自動挑一台在線且有掃描能力的機器,不再隨手挑
Agent 端要改嗎 不用,全部在雲端這側完成,客戶已裝好的 Agent 一行都不必動
排程(後加) 每一組可各自設「幾點才開跑」——按執行後那組先排隊,時間到才開掃(避開營業時間)

驗收過程又補了三件(使用者實際用起來才發現的):每一組的掃描設定完全獨立(客戶常見 混合環境,這台 Linux 走一種連線、那台 Windows 走另一種)、掃描目標可以寫整個網段而不必 一台台列、一次執行的多組設定在畫面上改成逐組分塊顯示(六個設定項全攤平在一行,認不出哪 列在做什麼)。

現在到哪了:母卡 CM-1350 已於 2026-08-27 收案,隨 v1.16.0 出貨,189 POC 已裝好。 完整總結見 handoff/2026-08-27-FR-067-SUMMARY.md


變更紀錄

日期 變更 對應
2026-08-23 初版設計定案。D1–D9 全數拍板,其中 D7(執行粒度:整組啟動+單組重跑+單組/整組取消)與 D8(不做存量 backfill)為 user 對討論稿建議的修訂 FR-067 母案
2026-08-24 插入 FR-067.7 per-assignment 排程執行(D10):assignment 子表加 scheduled_at、agent 領單時間過濾、execution 加 scheduled 狀態、「立即開始」端點;拆分表補 T-7.1/T-7.2 D10
2026-08-25 端到端驗收第一輪回饋修正回寫(CM-1375/1377/1378/1379):分派列 UX 重整(§5.8.1)、同 agent 多列+per-列 profile 覆寫(§5.2.1/§5.8.1,推翻原唯一約束)、profile 參照展開移到合併後(§5.4)、測試連線可指定 agent(§5.4)、掃描失敗訊息帶 stderr+Rocky 檔名推 rl(agent 0.2.29,§5.8.2)、legacy 執行紀錄卡片分組鍵統一(§5.6) 驗收回饋
2026-08-25 追加 FR-067.8 每列自足模型(§5.10):廢「任務層參數+列覆寫」改「每列=完整獨立工作設定」,secret 跟列走、零列 fallback 廢掉(agent 留空=自動挑)、SonarQube 多模式混掃+upload per 列檔案槽、複製列+新列帶預設值。執行層與 agent 零改動 FR-067.8
2026-08-25 驗收第二輪回饋落地(CM-1382/CM-1383):分派列可覆寫連線方式 transport(混合 OS 各走各的通道,§5.4/§5.8.1,寫入端兩道驗證 GRC_400109GRC_400110);掃描目標支援 CIDR 與末段範圍語法(§5.4,新 util common/util/scan_target_spec.py;展開與否依欄位分流而非依工具——Nmap 的 hosts 是 agent 登入去跑 nmap 的執行主機要展開、targets 是掃描目標原樣透傳,推翻卡片原假設GRC_400111GRC_400112 + 台數上限 env DETECTION_SCAN_TARGET_MAX_HOSTS);分派列改摘要列+inline 展開卡片(可設定項已達六個,全攤平在一行認不出哪列在做什麼,§5.8.1) CM-1382/CM-1383
2026-08-25 FR-067.8 T-8.1/T-8.2/T-8.3 全數落地(§5.10 由設計定案改寫為實況):BE 列 params 完整化+存量讀取端合併(新 util common/util/detection_assignment_params.py)、逐列 condition-aware 必填驗證與 project_key_suffix 跨列唯一(GRC_400113GRC_400114);FE 任務層掃描參數區收掉(只留 completion_mode)、展開卡片渲染整份 param_schema、複製此列+新列帶第一列預設值、最少一列;SonarQube upload 源碼包改 per 列檔案槽與 per 列 sticky(多包混掃),擋門逐列且擋在建 group 之前、D33 自動派發判斷細到列。執行層與 agent 皆零改動(如設計預期) FR-067.8
2026-08-25 單組重跑前置從群組層收窄到 assignment 線層(§5.5):只看該線自己的最新一筆是否 failed/cancelled,不看群組整體——失敗組不必乾等同群組的慢組;409012 自此無 raiser(碼位保留不回收);收口併發靠 lock_for_close 序列化(判斷用鎖到的那份群組狀態) CM-1397
2026-08-25/26 掃描目標 range 顯示配套(§5.11):台數由 BE 供scan_target_spec.count_spec 二義化+display_counts_of() 唯一入口——「算不算」一律算、「CIDR 怎麼算」依展開/透傳分流)、執行紀錄印原始寫法+台數(派工當下快照 _scan_target_spec,透傳欄位無快照就地算)、FE 過期防護(spec 字串與現值比對,不符退回段數)、警示字改 theme-aware token(--color-orange--color-red);執行紀錄補回傳 targets 欄位——列上講真正被掃的欄位(有 targets 講它,判準看資料不看工具清單),不再把 nmap 跳板機當掃描目標講 CM-1395
2026-08-25 執行抽屜「檢測設定」卡多組時改逐組分塊(§5.8.2):跨列彙整(值不同「/」併陳)在 SonarQube 混模式下講不出「哪列是哪列」;改一組一小塊(組號+agent+該組參數逐組過 condition+逐組摺疊),單組任務版型不變;上傳槽與擋門訊息改標「第 N 組(辨識參數)」(組號用全域序號,與檢測設定卡對得起來) CM-1396

1. 需求背景與目標

檢測工具平台(FR-056/FR-058)目前一次執行只派一台 agent(駐點掃描機),而且是系統隨手挑的——_pick_agent() 底層查詢無排序(app/detection_tools/service/detection_orchestration_service.py:392-398),回傳順序隨心跳更新漂移;也完全不檢查在線狀態last_seen_at 未納入判斷),挑中離線機時工單永遠掛在待領取(pending),沒有回收、沒有告警,使用者只看到「執行中」轉不完(FR-058 design.md:189 已列技術債)。

客戶落地環境常見多個隔離網段(辦公網/機房網/DMZ),每網段一台 agent,單台打不到別的網段。現況 UI 無處指定「這批目標給哪台掃」、執行紀錄也查不到「這份報告誰掃的」,要掃全部網段只能開多個任務人工湊。

目標行為(user 拍板的模型 B):

  1. 任務設置時,檢測工具綁定下可設多組 assignment(每組=一台 agent+一批掃描目標);
  2. 按一次「開始執行」,系統展開成 N 筆執行紀錄(維持既有「一筆執行=一張工單」1:1),外面包一層**執行群組(group)**做彙總;
  3. 各 agent 心跳領各自的單、掃自己的網段、各自回報,單筆回報鏈原樣;
  4. 群組內全部終態才算這次執行完成——彙總狀態、自動完成任務(completion_mode=auto)、通知信都改在 group 收口時做;
  5. 失敗的組可單組重跑(同 group 追加新 execution),彙總以「每個 assignment 的最新一筆」計。

2. 分工概述(30 秒版)

整案一句話:檢測任務可以設定多組「哪台 agent 掃哪些目標」,一次執行同時派給多台,全部回報完才算完成,報告與通知彙總呈現;失敗的那台可以單獨重跑。

給非工程讀者的階段對照表(「完成怎麼判定」都是決策者可以親手操作驗證的方法):

階段 做什麼 產出 完成怎麼判定(決策者檢查法)
① 地基:Agent 清單看得懂 Agent 下拉只列「能做掃描」的機器、標示在線/離線 Agent 清單 API 補齊能力與在線資訊 打開任務設置的 agent 下拉,只看到能掃描的 agent,離線的有標示
② 任務設置多組 assignment 檢測工具綁定介面可加多列「agent+掃描目標」 新資料表(assignment 子表)+設置 UI 在任務設置加兩組 agent+各自網段,儲存後重開頁面設定還在
③ 執行展開與收口 按「開始執行」展開 N 張工單;各 agent 回報後系統判定整組狀態;失敗的組可單組重跑、單組/整組取消 執行群組(group)層+展開/收口/重跑/取消邏輯 按開始執行後,到執行紀錄看到兩台各自的狀態;等兩台都完成,整次執行才顯示完成;故意讓一台失敗後按該組「重跑」,成功後整組轉成功
④ 結果呈現與通知 執行紀錄看得到「哪份報告是哪台掃的」;通知改成收口一封彙總 執行紀錄 group 化+agent 名稱 enrich+彙總通知信 執行紀錄每列有 agent 名稱;收到的通知只有一封、內含兩台各自結果
⑤ 相容與收尾 不設 assignment 的任務照舊能跑(自動挑一台健康的);測試與 SPEC 更新 fallback 邏輯+測試修補+文件 拿一個沒設 assignment 的任務按執行,行為與現在一樣但不會挑到離線機;舊資料不處理(開發驗證階段資料之後重置)
⑦ 排程執行(2026-08-24 追加) 每組 assignment 可設「幾點才開跑」——按執行後那組先等著,時間到 agent 才領單開掃 assignment/工單加 scheduled_at+scheduled 狀態+「立即開始」端點+UI 排程 chip 幫一組設 2 分鐘後的時間、按開始執行,看到那組顯示排程中、時間到自己開跑

3. 決策定案(D1–D10)

# 決策 定案內容 定案理由 被排除方案與原因
D1 內部模型 B:一次執行展開 N 筆 detection_executions(與 agent_tasks 維持 1:1)+新增 group 層彙總 「一筆執行=一張工單」1:1 是大量下游隱含依賴的不變式:工單反查執行(get_by_agent_task_uid)、整支回報處理鏈(handle_result)、report_file_id 單值語意、報告靠 job_evidences.description 字串反解、FE 部分成功(CM-952)/零實質警示(CM-954)渲染。走 B 這些全部原地保留,只需新增 group 聚合+把三件收口邏輯(auto 完成/通知/彙總狀態)上移——而這三件在 A 之下同樣要做 方案 A(一筆 execution 掛多工單、內部變 1:N):要反轉不變式、重寫已成熟且有測試守著的回報鏈,成本高風險大
D2 assignment 存放載體 新子表 job_execution_detection_tool_agents(綁定 1 → N assignment,每列 agent_uid+掃描目標) user 提出兩個未來場景——①未來可能有中心 UI 讓 user 直接維護 assignment;②未來可能開放指派人員在任務執行頁(我的任務)調整 assignment——兩場景都需要「每列有自己的 uid 可單列 CRUD、按列權限稽核、兩入口併發不互蓋」,子表天然支援 tool_params JSONB 內陣列:無列身分(無法單列 CRUD/稽核)、整包讀寫兩入口會互蓋;且 tool_params 現況會整份快照進 agent_tasks.params,assignment 全文會漏到執行紀錄與通知信
D3 指定 agent 失效語意 執行前逐 assignment 驗證(存在/enabled/未 revoke/有 detection_scan capability/在線=last_seen_at 距今 < 心跳間隔 300s × 3),任一不過整次擋下 400,錯誤訊息列出所有不合格機與原因 部分派出=部分網段被跳過,掃描結果殘缺卻顯示「完成」,比不派更糟;整次擋下讓使用者當場修正(換機或等上線);一次列出全部不合格機避免「修一台、再撞下一台」 部分派出(只派健康的):結果殘缺的靜默風險
D4 group 彙總狀態語意 全 succeeded → succeeded;任一 failed 且無 running → partial_failed;全 failed → failed;任一 running → runningcancelled 歸類比照 failed(含 cancelled 就不可能全成功) partial_failed 沿用 CM-952 部分成功視覺語彙(FE 黃色),使用者一眼看出「有掃到但不完整」 只有成功/失敗二值:喪失「部分網段掃完」的資訊
D5 completion_mode=auto 的完成判定 group 內全部 execution succeeded 才 complete_job;partial_failed 不自動完成、留人工判斷;順修現況「多筆各自觸發 complete_job 撞任務狀態 409、receive_result 不吞例外讓 agent 收 5xx」的地雷(收口列鎖,見 §5.3) 部分網段沒掃到,任務不該自動關;409 地雷在多筆模型下必炸,必須此案一併解 任一 succeeded 即完成(現況語意):多網段下等於只掃一段就關任務
D6 通知時點與形式 group 收口時發一封彙總通知(Email/Telegram/Discord),每 assignment 一列(agent 名稱/掃描目標/狀態/發現數),取代現行每筆一封 N 台 N 封是轟炸;單一 assignment 情境收口即單筆,體感不變 維持每筆一封:多台轟炸
D7 執行粒度(user 修訂:比討論稿建議多了單組操作) 整組啟動+單組重跑+單組/整組取消。語意:整組啟動=建 group+N 筆 execution;單組重跑=失敗後對該 assignment 在同一 group 下追加一筆新 execution,彙總狀態以「每個 assignment 的最新一筆」計算(A 成功、B 重跑成功 → 整組成功 → 可觸發 D5 自動完成);單組取消=只取消該組工單,彙總照 D4 重算;「未整組跑過前的單組首次啟動」第一版不做(語意模糊,用「先只設一組」即可達成) 失敗只重跑失敗那台,不必整組重掃(掃描動輒數十分鐘);同 group 追加保留完整歷史、彙總語意單純 討論稿原建議「第一版只做整組取消、單組操作列 future」——user 修訂納入第一版;「單組首次啟動」仍排除(語意模糊)
D8 向下相容(user 修訂:範圍收窄) 不做存量 backfill、不管舊資料(開發驗證階段,資料之後會重置);但「binding 無 assignment 時=單組自動挑一台(走修好的離線過濾 _pick_agent)」的行為保留——這是為「不想指定」的使用情境設計的,不是為舊資料 資料會重置,backfill 是白工;fallback 行為讓簡單場景(單網段、不在乎哪台掃)零設定可用,且比現況健康(不再挑到離線機) 討論稿原建議「73 筆存量 backfill 每筆自成 group」——user 修訂不做;「無 assignment 時直接擋下強制設定」:對簡單場景過度要求
D9 連帶地基缺口 併入本案:①GET /remote-agents 補 capabilities+在線狀態+按 capability 過濾;②執行紀錄 enrich agent 名稱;③test_connection 對齊指定邏輯;④_pick_agent 過濾離線 不是順手優化,是本功能可用性的一部分:FE 下拉沒 capabilities 列不出「能掃描的」(①);查不到「誰掃的」功能半殘(②);測試連線與實際執行不同台會誤導(③);fallback 不過濾離線則 D8 相容路徑仍踩離線卡單老坑(④) 另開案:四項單獨都太小、又都是本案的前置依賴,拆開徒增協調成本
D10(2026-08-24 追加) per-assignment 排程執行 assignment 子表加 scheduled_at TIMESTAMPTZ 可空(空=立即),跟著「agent+掃描目標」一起設定,不另立入口。實作形狀:「group 照常立即建,延後的是 agent 領得到單的時間」——按開始執行當下照常 D3 驗證+建 group+N 筆 execution+N 張工單;agent_tasksscheduled_at(從 assignment 快照);agent 心跳領單查詢加 scheduled_at IS NULL OR scheduled_at <= now() 條件,沒到時間的單 agent 看不到;收口邏輯零改動,不用 APScheduler、無背景 job(也因此無 tenant context 坑)。語意:絕對時間;啟動當下時間已過=立即領;跑完後再次整組執行=全部照當下 assignment 的 scheduled_at 重新判定(過去式時間=立即,語意自洽不清欄位);單組重跑一律立即(人當下按的);週期性排程本版不做,只留欄位擴充空間。連帶調整四件:①execution 加「scheduled」狀態(排程等待中,不顯示 running 誤導),彙總函式視同未終態;②取消排程中的組=短路直接標 cancelled 不打 agent(單還沒被領走);③「一任務最多一個 running group」守門窗變長——排程等待期間不能再按執行,要就先取消整組(UI 要表達);④新增「立即開始」小端點:把該工單 scheduled_at 清成 now(排程中的組可催跑) 不同網段常有不同維護窗(半夜才能掃機房網),per-assignment 才表達得了;「時間過濾領單」讓展開/收口全部沿用既有機制,改動面最小且驗證仍在人在場的當下做 整任務單一排程時間:表達不了「不同網段不同維護窗」。APScheduler 到點才展開:要背景 job、有 tenant context 坑、D3 驗證延到半夜沒人看

4. 現況接入點盤點

座標均出自討論稿兩輪唯讀探脈(詳見 discussion.md §3)。

4.1 可複用(不動或小動)

元件 現況 本案動作
心跳領單機制 Agent 每 300 秒 POST heartbeat(mTLS),回應夾帶自己名下 pending 工單(app/remote_agent/service/agent_enrollment_service.py:305-374);工單 agent_id 建單釘死+領單只查自己名下,天然無重複執行、無搶單 零改動——N 筆工單各釘各的 agent
Agent 端(evidence-agent) Connector 只讀自己那筆工單參數;_task_uid 組唯一資源名,N 筆工單各有 uid 天然不撞 零改動
單筆回報鏈 agent POST result → app/agent_task/service/agent_task_service.py:66-93on_scan_succeeded/faileddetection_orchestration_service.py:466-531)→ detection_result_handler.py:73-155 原樣保留,只在終態寫入後追加 group 收口檢查(§5.3)
FE agent 下拉前例 storage-config 已有 agent 下拉(compliance-manager-fe/src/views/storage-config/StorageConfigForm.vue:502-516,存 agent_uid) 任務設置 assignment 列表沿用同款選取模式

4.2 要動的(元件 × 現況 × 本案動作)

# 元件 現況 本案動作
1 config.job_execution_detection_tools tool_params(JSONB)無 agent 欄位;constraint uq_jedt_job_active 鎖一任務一綁定 新子表 job_execution_detection_tool_agents 掛在綁定下(D2);uq_jedt_job_active 不動
2 _pick_agentdetection_orchestration_service.py:392-398 無序+不驗在線 有 assignment 時照指定;fallback 路徑過濾離線+加穩定排序(D8/D9④)
3 「一任務最多一筆 running」守門 get_running_by_job_execution_uid(:60-68 取 results[0]);caller=start_execution:123 與 cancel_execution:359 語意上移為「一任務最多一個 running group」
4 completion_mode=auto 觸發 on_scan_succeeded:495-502 單筆 succeeded 即 complete_job;N 筆各觸發第 2 筆起撞 409,receive_result 不吞例外 → agent 收 5xx 改 group 收口判定+列鎖保證只收一次(D5,§5.3)
5 通知 _notify_scan_result:654-704 每筆一封(Email/Telegram/Discord) 改 group 收口彙總一封(D6,§5.4)
6 取消 _cancel_running_execution:366-390 打該 agent cancel API,失敗 raise 409 不標記 整組取消 best-effort+單組取消(D7,§5.5)
7 自動派工冪等 發佈任務時 _auto_dispatch_detection_scansapp/grc/service/task_execution_service.py:98-134),冪等=該任務無任何 detection_executions 冪等條件改「該任務無任何 group」
8 執行紀錄 response 無 agent 欄位 group 化 response+enrich agent 名稱(D9②,§5.6)
9 GET /remote-agents 只回 uid+name,不含 capabilities 不過濾(spec 坑 10:docs/specs/current/evidence/remote-agent-manage.md:444 補 capabilities+在線狀態+capability 過濾參數(D9①)
10 test_connectionapp/detection_tools/service/detection_tool_service.py:189-193 agents[0] 對齊可指定 agent(D9③)
11 效能與索引 兩處 N+1(_resolve_scan_params_resolve_report_file_uid);detection_executions.agent_task_uid soft-ref 無 index;兩表無 job_execution_uidagent_task_uid 索引 migration 順手補索引;N+1 於 .4 實作時批次化
12 測試與文件 BE 測試 5 檔 mock 1:1 形狀(test_detection_orchestration.py 等);E2E detection-job-factory.js 只送單 tool;SPEC 三頁(my-tasks/project-planning/project-task-edit) .6 收尾更新

4.3 關鍵既有事實(設計依據)

  • detection_executionsinfra/detection_execution/model/detection_execution.py):agent_task_uid(soft-ref)、job_execution_uidstatus(running/succeeded/failed/cancelled,無狀態機守門)、summary(JSONB)、report_file_idevidence_id。與 agent_tasks 嚴格 1:1——建立點 detection_orchestration_service.py:150-165;反查點 get_by_agent_task_uid(agent 回報不帶 execution_uid,全靠工單 uid 反查)。
  • 報告與工單的關聯是字串約定:job_evidences.description = "[檢測工具] {agent_task.uid}"
  • Agent 註冊表 compliance.remote_agents:uid/name/base_url/capabilities(JSONB,agent 心跳自報,0.2.8 起恆為 ["file_storage","detection_scan"])/last_seen_at(在線判定=距今 < 300s × 3)。
  • 心跳 poll 模型:BE 連不進客戶內網,唯一反向推送是 cancel/probe 走 agent.base_url
  • 綁定寫入鏈:FE(ProjectPlanningView.vue:1125-1127)→ api/grc/serializers/job.py:159-173(DetectionToolBindingSchema)→ app/grc/service/job_service.py:375-430

5. 詳細設計

5.1 一句話架構

維持「一筆執行=一張工單」1:1 不動,往上加一層「執行群組(execution group)」:任務設置存多組 assignment(子表);按執行時建一個 group、每組 assignment 展開一筆 execution+一張工單;單筆回報鏈原樣跑;每筆終態時檢查 group 是否收口,收口才做「彙總狀態/auto 完成任務/彙總通知」三件事;失敗的組在同一 group 下追加新 execution 重跑,彙總以每 assignment 最新一筆計。

%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB'}}}%%
flowchart TD
    JEDT["job_execution_detection_tools<br/>(既有)檢測工具綁定<br/>tool_params/completion_mode"]
    ASG["🆕 job_execution_detection_tool_agents<br/>assignment 子表<br/>agent_uid+scan_targets(每組一列)"]
    GRP["🆕 detection_execution_groups<br/>執行群組<br/>彙總狀態/收口時間"]
    EXE["detection_executions(既有)<br/>單筆執行 status/summary/report<br/>+🆕 group_uid+assignment_uid"]
    TASK["agent_tasks(既有)<br/>agent_id 建單釘死"]
    AGT["remote_agents(既有)<br/>capabilities/last_seen_at"]

    JEDT -->|"1 → N"| ASG
    ASG -.->|"agent_uid 參照"| AGT
    JEDT -->|"執行時建"| GRP
    GRP -->|"1 → N 展開<br/>(重跑時同 assignment 追加)"| EXE
    EXE ===|"嚴格 1:1(不動)"| TASK
    TASK -.->|"agent_id"| AGT
圖 1 — 資料模型:新增 assignment 子表與 execution group 層,既有 1:1 關係不動

5.2 資料模型

5.2.1 新表 job_execution_detection_tool_agents(assignment 子表)

掛在檢測工具綁定(config.job_execution_detection_tools)之下,同 schema。每列=一組「agent+掃描目標」。

欄位 型別 說明
id BIGSERIAL PK
uid VARCHAR(36) UNIQUE NOT NULL 每列有自己的 uid——D2 定案理由的載體(單列 CRUD/按列稽核/兩入口併發不互蓋)
tenant_id BIGINT NOT NULL RLS 用
org_unit_id BIGINT tenant-scoped 表必含(repo 慣例)
job_execution_detection_tool_id BIGINT NOT NULL FK → config.job_execution_detection_tools.id(同 schema;跨 schema 引用時必限定 schema)
agent_uid VARCHAR(36) NOT NULL soft-ref → compliance.remote_agents.uid(跨 schema 不建 FK,比照 detection_executions.agent_task_uid 既有慣例;執行前驗證由 D3 應用層負責)
scan_targets JSONB NOT NULL 掃描目標。形狀 {"hosts": "10.1.0.0/24, 10.1.0.5"}——用物件包一層而非裸字串,保留未來擴充空間(如 per-assignment timeout);執行展開時 merge 進該筆工單的 tool_params(assignment 值覆蓋綁定層同名鍵)
sort_order INTEGER NOT NULL DEFAULT 0 列表顯示順序
is_delete BOOLEAN NOT NULL DEFAULT FALSE 軟刪,沿用 repo 慣例
created_at / updated_at / created_user / updated_user 審計四欄 沿用 repo 慣例

索引:(job_execution_detection_tool_id)唯一約束 (job_execution_detection_tool_id, agent_uid) WHERE is_delete = FALSE2026-08-24 CM-1379 推翻:混合 OS 場景需要同一 agent 開多列各掃各基準,uq_jedta_binding_agent_active 已由 migration 移除。重複判準改由 BE 驗證層負責——「(agent_uid, scan_targets 內容) 不可完全相同」;刻意不做 JSONB DB 唯一,鍵序正規化與 null/{} 等價的成本高於收益,且該重複屬使用者輸入錯誤而非資料完整性風險)。

per-列參數覆寫(2026-08-24 CM-1379 追加)scan_targets JSONB 除 hosts 外亦承載 profilecontent_path 覆寫鍵——留空=沿用任務層 tool_params,有值=該列覆寫同名鍵。這使同一任務可混合 OS 各掃各基準(Ubuntu 列用 ssg-ubuntu2404-ds.xml、Rocky 列用 ssg-rl10-ds.xml)。連帶契約:profile 參照(profile:<uid>)的展開必須發生在「綁定層與 assignment 層合併之後」(見 §5.4)。

5.2.2 新表 detection_execution_groups(執行群組)

選型:獨立表(而非只在 detection_executions 加 group_uid 自聚合)。理由:①收口只跑一次靠 group 列鎖SELECT ... FOR UPDATE 需要一列實體可鎖,見 §5.3);②彙總狀態與收口時間需要落地欄位(列表頁不必每次即時算 N 筆);③「一任務最多一個 running group」守門查 group 表一列即可。

欄位 型別 說明
id BIGSERIAL PK
uid VARCHAR(36) UNIQUE NOT NULL
tenant_id BIGINT NOT NULL
org_unit_id BIGINT
job_execution_uid VARCHAR(36) NOT NULL 隸屬的任務(比照 detection_executions.job_execution_uid soft-ref 慣例)
status VARCHAR(20) NOT NULL running / succeeded / partial_failed / failed(D4;cancelled 的筆歸入 failed 類計算,group 層不另設 cancelled 態)
total_count INTEGER NOT NULL assignment 組數(展開當下定格;fallback 單組=1)
closed_at TIMESTAMP 收口時間(NULL=未收口)
is_delete BOOLEAN NOT NULL DEFAULT FALSE
created_at / updated_at / created_user / updated_user 審計四欄

索引:(job_execution_uid);partial index (job_execution_uid) WHERE status = 'running' AND is_delete = FALSE 支援「最多一個 running group」守門。

5.2.3 既有表擴欄與補索引

  • detection_executions 加兩欄:
    • group_uid VARCHAR(36)(soft-ref → groups.uid;可 NULL——舊資料不 backfill(D8),NULL=group 化之前的 legacy 筆,讀取端視為單筆獨立顯示);
    • assignment_uid VARCHAR(36)(soft-ref → assignment 子表 uid;可 NULL——fallback 自動挑機的隱含單組無 assignment 列,NULL+group_uid 非 NULL=fallback 組。重跑的「最新一筆」以此欄分組)。
  • 補索引(缺口 11 順手還債):detection_executions(job_execution_uid)detection_executions(agent_task_uid)detection_executions(group_uid)

Migration 遵守 sql-migration skill 慣例:檔頭 -- Date:+逐語句日期註解、新表 GRANT ... TO cm_app+sequence 權限、收尾 INSERT public.schema_migrations、只套 DEV。兩張新表的 RLS 政策比照各自 schema 內既有 tenant-scoped 表(實作時對照 job_execution_detection_toolsdetection_executions 現況照抄)。

5.3 group 狀態機與收口演算法(D4/D5)

%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB'}}}%%
stateDiagram-v2
    [*] --> running : 執行展開(任一組 running)
    running --> running : 部分回報,仍有未終態
    running --> succeeded : 每 assignment 最新一筆全 succeeded
    running --> partial_failed : 任一 failed/cancelled 且無 running
    running --> failed : 全 failed/cancelled
    partial_failed --> running : 單組重跑(同 group 追加新 execution)
    failed --> running : 單組重跑
    succeeded --> [*] : 收口(auto 自動完成任務+彙總通知)
圖 2 — group 彙總狀態機(D4 定案語意;重跑會讓 partial_failed/failed 回到 running)

彙總計算(單一函式,所有入口共用):取 group 內「每個 assignment 分組的最新一筆 execution」(DISTINCT ON (assignment_uid) ... ORDER BY assignment_uid, id DESC;fallback 組 assignment_uid 為 NULL、也只會有一條分組線),按 D4 規則歸態:任一 running → running;全 succeeded → succeeded;全 failed/cancelled → failed;否則 → partial_failed。

收口判定與併發防護

  1. 收口檢查放在 on_scan_succeeded / on_scan_failed 單筆終態寫入之後、同一交易內
  2. SELECT ... FOR UPDATE 鎖 group 列,再算彙總——N 筆併發回報時,後到者會等鎖,看到的是前者已提交的終態,保證收口三件事只有「讓彙總離開 running 的那一筆」執行一次
  3. 彙總結果非 running → 更新 group.status+closed_at,然後做收口三件事:
    • 彙總狀態落地(上一步);
    • auto 完成(D5):completion_mode == 'auto' 且彙總 == succeeded 才 complete_job;partial_failed/failed 不自動完成留人工。complete_job 只在收口路徑呼叫一次,現況「多筆各自觸發撞 409 → agent 收 5xx」地雷就此解除receive_result 不吞例外的既有行為不必改);
    • 彙總通知(D6,§5.4)。
  4. 重跑後的再收口:單組重跑把 group.status 撥回 running、closed_at 清空;重跑那筆終態時走同一收口路徑重算。若重算為 succeeded 且 auto 模式 → 觸發 complete_job冪等防護:呼叫前檢查任務現況,已完成則跳過不報錯——防「人工先完成了任務、之後重跑又成功」的邊角)。通知在每次收口都發(重跑收口再發一封彙總,屬合理行為:狀態確實又變了)。

並行守門與冪等上移

  • 「一任務最多一筆 running execution」→「一任務最多一個 running group」:start_execution 的 force 判斷與 cancel_execution 的 caller 改查 group(partial index 支援);
  • _auto_dispatch_detection_scans 冪等條件:從「該任務無任何 execution」改「該任務無任何 group」(D8 不 backfill,舊 execution 無 group——但舊資料所屬任務已跑過的情境在開發階段不存續,資料將重置,不做特判)。

5.4 執行展開與驗證(D3)

start_execution 改造:

  1. 讀綁定下的 assignment 列表(is_delete = FALSE,按 sort_order);空列表 → fallback 隱含單組:走修好的 _pick_agent(過濾 enabled+未 revoke+有 detection_scan capability+在線,並加穩定排序如 ORDER BY id),目標用 tool_params 原值;
  2. 逐 assignment 驗證(D3):agent 存在/enabled/未 revoke/capabilities 含 detection_scan/在線(last_seen_at 距今 < 900s)。收集全部不合格項後一次擋下BadRequestError 帶新 error code(依 GRC_400xxx 命名規則新增),訊息逐台列「agent 名稱+不合格原因」;
  3. 全過 → 同一交易內:建 group(status=running、total_count=N)→ 逐 assignment 建 execution(帶 group_uid+assignment_uid)+工單(agent_id 釘該台;params 以綁定 tool_params 為底、assignment 的 scan_targets 覆蓋同名鍵);
  4. 之後各 agent 心跳領各自的單,回報鏈原樣。

合併與展開順序(2026-08-24 CM-1379 修正,🔴 不變量)_expand_profile_ref()profile:<uid> 參照 → _profile 凍結快照)必須在合併之後、下放到 _dispatch_one 以合併結果為輸入——start_executionrerun 都不得預先在綁定層展開。原設計在合併前展開,分派列覆寫 profile 後第二列會拿 A 基準的檔案快照跑 B 基準的設定,且 B 的檔案被自家下載授權閘(agent_file_access_service)擋掉,症狀是靜默掃錯基準不報錯。有不變量測試守著(六個突變逐一被抓)。

合併粒度細則:單一目標型工具(param_schema 無 hosts,如 ZAP/SonarQube)的合併是「只忽略 hosts 鍵、保留其餘覆寫鍵」(_effective_scan_targets)——不是整包忽略,否則未來任何 per-列參數覆寫對這類工具永遠靜默失效。

assignment 配對規則(寫入端 diff-sync):uid 優先;agent_uid 次要配對僅限「該 agent 只有一列」時適用(同 agent 多列後 agent_uid 不再是自然識別,任選會把兩列的覆寫設定張冠李戴),且同一批送來的多列不可配到同一既有列。

per-列覆寫鍵的演進(CM-1382 transport,2026-08-25)transport(SSH/WinRM)納入 與 hostsprofile 同一套覆寫機制——合併與展開路徑零改動_dispatch_one 本就是 同名鍵覆蓋整包 scan_targets),只補寫入端驗證。混合 OS 場景下同一個 CINC Auditor/GCB 任務的不同分派列因此能各走各的通道(60 網段 Linux 走 SSH、Windows 目標走 WinRM), 不必為每種 OS 各開一個任務。寫入端兩道驗證(_validate_transport_overrides錯誤碼刻意 分開因為要修的地方不同):該工具的 param_schema 沒有 transport 欄位(OpenSCAP/ OpenVAS/ZAP)→ GRC_400109;值不在該欄位 options 內 → GRC_400110(送個沒人認得的值 只會在 agent 端變成神祕失敗,錯在雲端卻只能翻 agent log)。驗證排在任何寫入之前——一列 不合法就整批不寫。schema 讀不到時放行(比照 _tool_accepts_scan_targets 判不出來時維持 原行為)。重複判準(_scan_targets_fingerprint 比對整包內容)自動涵蓋 transport,但 「agent 與目標都相同、只有 transport 不同」正是本卡核心場景,已補斷言釘住避免日後有人 把判準改回只比 hosts

掃描目標語法:CIDR 與末段範圍(CM-1383,2026-08-25):掃描目標欄原本只吃逗號分隔的 單一 IP,要掃一整段就得逐台列出;防火牆規則與既有掃描工具用的都是 CIDR 記法,使用者自然 會這樣填、填了卻被當成一台「名為 192.168.50.0/24 的主機」送出去。新增 util common/util/scan_target_spec.pyvalidate_spec 驗語法/count_spec 算台數/ expand_spec 展開保序去重),支援三種語法混用(192.168.50.0/28, 192.168.60.1-20, 192.168.70.5),分隔符與 agent 端 _normalize_hosts 同一套。

🔴 展開與否的判準是「欄位」不是「工具」(實作時查 evidence-agent 源碼後推翻卡片 原假設):Nmap 的 hosts 不是掃描目標,而是 agent SSH 登入去跑 nmap 的執行主機nmap.py:636 逐台 SSH,檔頭明文「兩種主機不可混為一談」),targets 才是被探測的 目標——照卡片原寫的「Nmap 兩欄原樣透傳」做會讓 agent 去 ssh 192.168.50.0/24。真正的 變因是「這個欄位的值最後被 agent 拿去做什麼」:

工具 欄位 行為 理由
CINC Auditor/GCB/OpenSCAP hosts 展開 connector for host in hosts: 逐台建 SSH 連線
Nmap hosts 展開 同上——這是執行主機清單,不是掃描目標
Nmap targets 透傳 nmap 原生解析 CIDR,併發探測遠比幾百個參數高效
OpenVAS hosts 透傳 丟給 GVM 的目標規格,GVM 原生支援
ZAP/SonarQube 不適用 無 hosts 欄位

分流表放 scan_target_spec.SSH_ITERATED_TARGET_FIELDSBE 常數而非 param_schema 註記——這是 agent 端 connector 實作行為的鏡射,放進 seed 資料等於讓 DB 宣告另一個 repo 的程式碼行為,兩邊不同步時沒有任何東西會報錯,症狀是靜默掃錯目標)。寫入端與派工端查 同一份表。

CIDR 語意刻意與 nmap 不同/24 展開成 254 台而非 256——用途是逐台 SSH 登入, network/broadcast 位址登入必定失敗、在報告留下兩筆假失敗。正因語意不同,原生支援的工具 才走「不展開」那條路。/31(RFC 3021)與 /32 例外沿用 Python hosts() 語意。

驗證與上限(只在寫入端):任務層 params.hosts 與分派列 scan_targets.hosts 共用 同一支驗證(兩處寫法不一致會讓同一個值在一處合法、另一處非法)。語法錯 → GRC_400111, 訊息帶出錯的那一段原文與原因;展開後超上限 → GRC_400112只對逐台登入型工具套用 (OpenVAS 的 /16 是正常用法)。上限用算的不用展的count_spec)——/8 展開成 1600 萬 個字串要 13 秒、2.5GB 記憶體,而這種輸入正是上限要擋的。新 env DETECTION_SCAN_TARGET_MAX_HOSTS (預設 256=剛好容納一個 /24;要掃更大範圍的正解是拆多列平行跑)。派工端刻意不擋、只 展開:擋在那裡使用者會在「按下執行」才看到幾天前存的設定有問題;存量資料或上限調小時 照常展開。跨段範圍(192.168.1.1-192.168.2.5)第一版不支援但明確報錯而非放行——落到 「主機名原樣放行」那條路會變成一台不存在的主機,使用者以為掃了一整段、實際上零台。

agent 零改動:展開後仍是逗號分隔字串,agent 端 _normalize_hosts 本就會再切一次。

test_connection 對齊(D9③)+可指定 agent(2026-08-24 CM-1377):request 加 agent_uid 可選——有帶走 diagnose_dispatchability() 驗可派性(不可派 400 一次列全原因,屬前置失敗不寫 last_test_result);沒帶維持自動挑機(get_dispatchable_agents()[0])。取機邏輯抽成 _resolve_test_agent() 供單台與逐台 probe 共用。回傳新增 agent_uidagent_name,message 前綴標明由哪台執行測試。新 error code DETECTION_TOOLS_400018(msg 帶明細,FE 列入 CODES_KEEPING_BE_DETAIL)。FE 側見 §5.8.1。

5.5 單組重跑與取消(D7)

  • 單組重跑 POST .../detection-executions/groups/{group_uid}/assignments/{assignment_uid}/rerun
    • 前置驗證:group 存在且屬本租戶;group 非 runningCM-1397 修訂,見下);該 assignment 的最新一筆為 failed/cancelled(成功的組不給重跑);該台 agent 過 D3 同款驗證(不過則 400 同款訊息);
    • 動作:同 group 下追加一筆新 execution+新工單(沿用該 assignment 當下的 scan_targets);group 已收口才撥回 running、清 closed_at(CM-1397 修訂);

🔧 CM-1397 修訂(2026-08-25,驗收回饋)——前置條件從群組層收窄到 assignment 線層

原設計group.status != running 才可單組重跑(DETECTION_TOOLS_409012)。理由是「還在跑的群組重跑,同一 assignment 會同時有兩筆 running,彙總的最新一筆語意會在兩筆間跳動」。

問題:那個理由只在同一條 assignment 線上成立——線 A 還在跑不影響線 B 重跑。擋在群組層的代價是失敗組要乾等同群組的慢組(user 2026-08-25 實測:OpenVAS 掃一小時,同群組 SSH 認證失敗的組 30 秒就掛了,帳號修好後想立刻重跑被 409 擋)。

修訂後:只看該 assignment 線自己的最新一筆是不是 failedcancelled,不看群組整體狀態。原本要防的不變式由此承接——該線還在 runningscheduled 時最新一筆就不是終態,一樣被擋(改回 409013,該碼語意擴張為「該線不可重跑」,涵蓋 succeeded 與仍在跑兩種)。409012 自此不再有任何 raiser,碼位保留不回收(FE i18n 與 API 文件已收錄,回收後被重用會讓舊前端翻錯訊息)。

兩情境分流(收口狀態的處理):

  • 群組仍 running(其他線還在跑)→ 追加新筆即可,不呼叫 reopen():狀態本就是 running、closed_at 本就是空,撥回是多餘的寫入。
  • 群組已收口 → 走既有 reopen 路徑撥回 running、清 closed_at

🔴 收口併發(本次修訂的核心風險):重跑端在建立新 execution 之前lock_for_close() 鎖群組列,與收口端(_close_group_if_terminal)鎖同一列而被序列化,只剩兩種順序、皆安全:① 重跑先拿鎖 → 新筆提交後才釋放 → 收口的 list_latest_per_assignment 看得到那筆 running → 彙總算出 running → 不誤收口;② 收口先拿鎖 → 群組落終態 → 重跑讀到收口後狀態 → 走 reopen。判斷用的群組狀態必須取自鎖到的那份,不可用交易早期 get_one() 的無鎖快照——否則會出現「一筆 running 掛在 closed 群組下」(畫面顯示已結束、掃描還在跑,closed_at 停在舊時間)。測試見 test_detection_multi_agent_dispatch.py::TestRerunAssignment(鎖序、快照 vs 鎖、併發不誤收口、重跑落終態後照常收口)。

不動的部分:整組取消、「一任務最多一個 running group」守門——那是防並發疊加,與本卡不同軸。

  • fallback 隱含單組(assignment_uid=NULL)同樣可重跑:path 以保留字 _default 表達或另設 groups/{group_uid}/rerun-default(plan 階段定其一)。
  • 單組取消 POST .../groups/{group_uid}/assignments/{assignment_uid}/cancel:打該台 agent 的 cancel API;可達→標 cancelled、不可達→409 該組維持原態;成功取消後以彙總函式重算 group(可能落 partial_failed/failed)。
  • 整組取消 POST .../groups/{group_uid}/cancel:逐工單打各自 agent 的 cancel API,best-effort——可達的都取消標 cancelled、記錄不可達清單;全部不可達才回 409。此語意比現況寬鬆(現行單台打不到就 409 完全不標記),多台情境一台失聯不可卡死整組取消。取消完成走同一收口路徑(彙總會落 failed/partial_failed,不觸發 auto 完成、照發彙總通知)。

5.6 API 契約

端點 動作 形狀要點
綁定讀寫(既有 DetectionToolBindingSchema,api/grc/serializers/job.py:159-173 擴充 agent_assignments: [{uid?, agent_uid, scan_targets, sort_order, scheduled_at?}](可缺省=不設 assignment;scheduled_at 為 D10 排程欄位,空=立即)。寫入端 diff-sync:帶 uid=更新、無 uid=新增、缺席=軟刪;讀取端每列 enrich agent_nameagent_onlineunknown=EXCLUDE 慣例照舊
GET /remote-agents(D9①) 擴充 每列加 capabilities(陣列)+is_online(bool,last_seen_at < 900s)+last_seen_at;query param capability=detection_scan 過濾。既有 caller(storage-config 下拉)不受影響(加欄不減欄)
start_execution(既有) 改造 成功 response 回 group(uid/status/executions 摘要);驗證不過 400 帶逐台原因(§5.4)
POST .../groups/{group_uid}/assignments/{assignment_uid}/rerun 新增 §5.5
POST .../groups/{group_uid}/assignments/{assignment_uid}/cancel 新增 §5.5
POST .../groups/{group_uid}/cancel 新增(取代單筆 cancel 的對外語意) §5.5
POST .../groups/{group_uid}/assignments/{assignment_uid}/start-now 新增(D10) 排程中的組催跑:清 scheduled_at 成 now(§5.9.3-4)
執行紀錄列表(既有) group 化 response 改兩層:groups: [{uid, status, closed_at, executions: [{...既有欄位, agent_uid, agent_name, assignment_uid, scan_targets, is_latest}]}]agent_name 在 app service 層批次查 enrich(比照審計欄位 enrich 慣例,不在 infra JOIN);legacy 筆(group_uid NULL)以「單筆自成一組」形狀回傳,FE 不需分叉。卡片分組鍵唯一(2026-08-24 b205e07a 修正):「這筆屬於哪張卡」與「is_latest 以哪條線算」是同一分組概念的兩面,統一抽 _execution_card_key()_group_executions()_latest_execution_uids() 共用——原本兩處各寫一份鍵,legacy 筆(兩欄皆 NULL)被算成同一條 latest 線導致整批舊紀錄從畫面消失。回歸測試 test_every_card_has_at_least_one_current_row 鎖「每張卡至少一列當前」不變量

新增 error code 依 GRC_<HTTP 狀態碼><序號> 規則落 common/code/ 對應檔;權限檢查沿用檢測執行既有守門(app service 層,經 domain service)。

5.7 彙總通知(D6)

_notify_scan_result 改為收口時呼叫的 _notify_group_result:標題帶任務名+整組狀態;內文表格每 assignment 一列(agent 名稱/掃描目標/狀態/發現數 findings,取該 assignment 最新一筆)。三通道(Email/Telegram/Discord)同步改。單組取消/重跑本身不發通知,只有收口發。

5.8 FE 兩處 UI

5.8.1 任務設置(ProjectPlanningView 檢測工具綁定區)

本節描述 FR-067.8 T-8.2 落地後的現況形狀(2026-08-25)。 中間演進(驗收第一輪 CM-1375 五波+CM-1377/1379、第二輪 CM-1382/1383)留在本節末「演進沿革」,其中被 取代的部分已明確標示——避免讀者拿中間形狀當現況。

現況形狀:每列=一份完整獨立的工作設定(§5.10)。選完檢測工具後,畫面上只有 「Agent 工作指派」清單與任務層的 completion_mode任務層掃描參數區已整塊移除

版位:「Agent 工作指派」區塊位於「檢測工具」下拉正下方(先決定哪台去掃、掃哪段, 再填該列參數),其下只剩完成模式。

每列的形狀(摘要列+inline 展開卡片,CM-1382):可設定項在每列自足之後已是整份 param_schema,全攤平在一行會擠成一片、多列並排更看不出哪列在做什麼,故:

  • 收合態一行摘要:agent 名稱/目標(多台顯示「N 台」,含 CIDR 或範圍時顯示「N 段」—— FE 刻意不算展開後的總數,展開規則是 BE 的分流邏輯,在 FE 複製一份就是第二套真相)/ transport 與 profile chip/排程 chip(× 可移除)/複製此列鈕/刪除鈕。
  • 展開態:以 DetectionConfigField 渲染整份 param_schema——condition 依本列參數 求值、secret 欄 per 列顯示「已設定」、profile 庫動態選項、advanced 分區照舊。標籤定寬對齊; 窄畫面(<640px)標籤改置上。
  • 🔴 展開用 v-if 不用 v-show(PrimeFlex utility class 帶 !important 會壓過 v-show 的 inline display:none,CM-1375 第五波實錄)。也不用 Accordion——摘要列要放下拉/chip/按鈕。
  • 新增一組=新列直接展開(明確寫進狀態,不靠「沒選 agent 就展開」的預設——只有一台 在線 agent 時新列會自動帶入 agentUid,那個預設不成立)。
  • 存檔驗證沒過的列強制展開+整列紅邊:錯誤欄位藏在收合區裡的話,toast 說「第 2 組有 問題」但畫面上那組看起來一切正常。錯誤標記是 per-列布林陣列,紅框只落在出錯的列。

重複填寫的解法(§5.10-5):「複製此列」鈕(新列以該列為底)+新增列預設帶第一列的 參數值(非空白表單)。secret 欄刻意不抄——抄 key 名會讓新列誤顯示「已設定」,使用者 不填就存進空密碼。

最少一列:剩一列時刪除鈕 disabled;agent 留空=執行時自動挑一台在線的(合法值, D8 的便利性保留、載體改變)。原「空列擋存檔」與 agentAssignmentErrors 旗標隨之退役。

逐列驗證:condition-aware 必填(判準與 BE detection_assignment_params.field_visible 同源)+ project_key_suffix 跨列唯一(兩列落同一 SonarQube 專案會互相覆蓋結果)。 對應 GRC_400113GRC_400114400113 的 BE msg 帶「第 N 列(缺哪些欄位)」,故列入 FE 的 CODES_KEEPING_BE_DETAIL(翻譯當抬頭、BE msg 當明細併陳)。

存檔時 tool.params 刻意整個不送:BE 對未帶欄位是 None-skip,而任務層那份現在只剩 兩個作用——存量資料的讀取端合併底,以及承載 _source_file 這類內部 sticky 欄位。送 {} 會把後者清掉,症狀是 upload 任務重掃時找不到源碼包,且要到執行當下才會發現。

掃描目標欄的語法說明(CM-1383):目標欄下方給三種寫法的說明(放欄位下方而非 placeholder——placeholder 已被更即時的資訊佔用,且一輸入就消失)。工具參數區那格的說明 走 param_schema.hint(BE migration f10e45eb),措辭依 BE 展開分流分三段:逐台登入型 (講上限與「拆成多組分派」的出路)/Nmap hosts(措辭刻意與上段分開,強調是執行主機 不是掃描目標)/原生透傳型(刻意不提台數上限,提了會讓人以為也受限)。兩處措辭一致。

測試連線指定 agent(CM-1377):測試連線對話框加 agent 下拉(資料源 GET /remote-agents?capability=detection_scan;離線機照列標「(離線)」disabled;首項 「自動選擇」=原行為)。開窗門檻=可選 agent >1 台。成功訊息帶執行 probe 的 agent 名稱。

profile 欄為純下拉(openscap/inspec/gcb 三支,CM-1375 第四波):原 select_or_text 手填實務上只產生錯誤輸入(PrimeVue editable Dropdown 打字不過濾選項;openscap 的 XCCDF profile id 手打幾乎必錯),決策者拍板改 select。migration 只改 param_schema 的 type, FE 零改(兩分支共用同一份 selectOptions)。

同 agent 多列(CM-1379):下拉不反灰已選走的台(原「同 agent 不可重複」約束已推翻); BE 擋的是「(agent, 列 params 內容) 完全相同」的重複列。

演進沿革(已被取代的中間形狀,保留供讀 commit 歷史時對照)
階段 當時形狀 現況
.3 初版 每列只有 agent 下拉+目標輸入 已擴成整份 param_schema
CM-1375 第二/三波 「掃描目標單一入口」——hosts 型工具有分派列時參數區隱藏 hostsisHostsOwnedByAssignments),零列時 hosts 欄照舊顯示 整個任務層參數區已移除isHostsOwnedByAssignments 這類「哪一邊擁有這個欄位」的判斷隨之消失
CM-1375~1382 覆寫期 每列只放要覆寫的鍵(hostsprofilecontent_pathtransport),留空=沿用任務層,placeholder 明示「留空沿用:<值>」 「留空沿用」語意全數移除(清 24 支死 i18n key);列上就是完整值
.3~T-8.1 零列=自動分派 fallback(BE 隱含單組);「預設備妥一組」只補畫面不寫 DB 最少一列;自動挑機改由「列上 agent 留空」承載,存量零列綁定讀取時合成一列
CM-1375 第三波 目標欄條件渲染:依 param_schema 有無 hosts 判斷(supportsScanTargets,不寫死工具清單),單一目標型(ZAP/SonarQube)該列只剩 agent 下拉 同一精神保留,但改由渲染整份 param_schema 自然達成(該工具沒有的欄位本就不會出現)

順手修掉的既有缺陷(CM-1382):per-列 UI 狀態(展開/進階/排程 picker)原以 index 當 key,刪掉中間一列後後面每列的 index 都減一,狀態會落在錯的列上(原只影響進階區與 picker,展開做成主要形狀後一眼可見)→ 補 shiftRowUiStateAfterRemoval()。另 GRC_400107 的 FE 文案還停在 CM-1379 放寬前的語意(「不可重複指派同一台 Agent」),與 BE 現行判準 (整列完全相同才擋)不符,一併更正。

5.8.2 執行紀錄(JobExecutionDrawer)group 化

以 group 為卡片單位——群組狀態徽章(succeeded 綠/partial_failed 黃沿 CM-952 語彙/failed 紅/running 動畫)+收口時間;卡片內每 assignment 一列(agent 名稱/目標/狀態/報告連結),失敗列帶「重跑」鈕、running 列帶「取消」鈕,卡片層帶「整組取消」;重跑歷史(非最新筆)收合顯示(收合用 v-if 不用 v-show——PrimeFlex .flex!important 會壓過 v-show 的 inline display,CM-1375 第五波實測)。legacy 單筆照舊單卡呈現。

失敗訊息品質(CM-1378,agent 0.2.29):agent 端 format_failure_detail() 把 stderr(優先)/stdout 收進失敗訊息(截尾段 500 字)——command not found/Permission denied/content 檔不存在這類原因全在 stderr,原本排空即丟導致 UI 顯示「執行失敗(exit=1):」後一片空白,每次都要上目標機重現才能定位。同卡順修 Rocky content 檔名推斷:ComplianceAsCode 的 product 名是 rl 不是 os-release 的 rocky,自動推檔名改雙軸表(前綴, 版本格式)。

「檢測設定」卡多組時改逐組分塊(CM-1396,2026-08-25,FE 45bd3ab 單檔):T-8.2 當初把檢測設定卡做成跨列彙整(同欄位各組值不同就「/」併陳、主機清單取聯集)——各組同構時讀得通,SonarQube 混模式(一組 scan、一組 upload)直接打穿:「執行模式:上傳原始碼壓縮包/原始碼掃描」下面掛一個 Git 網址,看不出哪一組是哪個模式、網址是誰的。併陳的能力上限就是「講得出有哪些值、講不出誰是誰」,值一旦不同構就失效。改法:

  • 多組任務一組一小塊:組號+agent+該組關鍵參數+該組目標清單+該組技術參數摺疊區(各自開合)。工具名稱與完成模式留卡片頂部(任務級資訊,不屬於任何一組)。單組任務維持原單框版型——只有一組時「第 1 組」是純噪音。
  • 逐組參數過 condition(與設定頁擋門、BE 逐列驗證同一套規則):SonarQube 的 Git 網址只在 scan 模式有意義,upload 組上可能躺著切換模式前的殘值,照列會讓執行者以為那組也要掃該 repo。
  • 上傳槽與擋門訊息改標「第 N 組(辨識參數)」(共用同一支函式):agent 名不是穩定識別——同 agent 可多組、自動挑機的組沒有名字。括號放該組模式+模式專屬識別欄位(Git 網址/專案代碼/後綴);刻意不放 profile(基準名太長會擠掉真正要區分的)。無辨識參數退回 agent 名,再無只留組號。🔴 組號用「全部分派列裡的全域序號」,不是 upload 列自己的內部序號——否則混模式下上傳槽說「第 1、2 組」、檢測設定卡說「第 2、4 組」,兩畫面對不起來。
  • 逐組目標清單與 CM-1395 顯示原則對齊(原始寫法標籤+真實台數,快照過期退回段數);逐組呈現後不再跨組取聯集——聯集正是「看不出誰是誰」的來源。
  • BE 零改動(per 列完整參數 T-8.1 已回傳,本卡純呈現層)。

5.9 per-assignment 排程執行(D10,2026-08-24 追加)

5.9.1 資料模型增量

  • job_execution_detection_tool_agents(assignment 子表)加 scheduled_at TIMESTAMPTZ 可空(NULL=立即)。排程跟著「agent+掃描目標」一起設定在同一列,不另立入口。
  • agent_tasksscheduled_at TIMESTAMPTZ 可空——執行展開時從 assignment 快照進工單(fallback 隱含單組恆為 NULL)。
  • detection_executions.status 新增 scheduled 狀態:排程等待中(不顯示 running 誤導使用者「已經在掃了」)。彙總函式 compute_group_statusscheduled未終態(等同 running 效果:group 不收口);group 層狀態不另設 scheduled 態,彙總仍為 running(列表彙總文案由 FE 依明細組成,見 §5.9.4)。

5.9.2 核心機制:「group 照常立即建,延後的是 agent 領得到單的時間」

按「開始執行」當下照常走 §5.4 全流程:D3 逐台驗證(人在場當下就知道哪台不合格)→ 建 group+N 筆 execution+N 張工單。差別只有一處——agent 心跳領單查詢加 scheduled_at IS NULL OR scheduled_at <= now() 條件,沒到時間的單 agent 看不到;時間到後下一次心跳自然領走。收口邏輯零改動(scheduled 筆未終態,group 不會提前收口)。不用 APScheduler、無背景 job——也因此沒有背景 job 讀 tenant-scoped 設定的 context 坑。

語意定案

  • scheduled_at絕對時間;啟動當下時間已過=立即可領(不報錯不擋)。
  • 跑完後再次整組執行=全部照當下 assignment 的 scheduled_at 重新判定——過去式時間自然等於立即,語意自洽,不清欄位(使用者設的維護窗設定保留,要改自己改)。
  • 單組重跑一律立即(重跑是人當下按的,不套排程)。
  • 週期性排程本版不做scheduled_at 單值欄位留未來擴充空間。

5.9.3 連帶調整(四件)

  1. execution 的 scheduled 狀態:展開時 assignment 帶未來 scheduled_at → 該筆 execution 以 scheduled 建立;agent 實際領走單時轉 running。彙總函式視同未終態。
  2. 取消排程中的組=短路:單還沒被領走,直接標 cancelled 不打 agent 的 cancel API(打了也沒東西可取消);判定條件=該筆為 scheduled 且工單未被領取。
  3. 「一任務最多一個 running group」守門窗變長:排程等待期間 group 是 running(含 scheduled 筆),不能再按執行;要重來就先取消整組——UI 必須表達這個狀態(見 §5.9.4 等待端)。
  4. 「立即開始」端點POST .../groups/{group_uid}/assignments/{assignment_uid}/start-now——把該工單(與 execution 快照)的 scheduled_at 清成 now,下一次心跳即領走。排程中的組可催跑,不必取消重來。

5.9.4 UX 規格(user 拍板)

原則:排程是例外,用明確開關表達,不用空值表達;立即 vs 排程用「形狀」一眼可辨,不依賴說明文字。

  1. 設定端(任務設置分派列):預設看不到時間欄位。每列尾一個「⏱ 排程」icon button;按了彈 datetime picker(預設給有意義起點如當晚 22:00,不給空白),選定後該列顯示實心 chip「⏱ 8/25 02:00」帶 × 可移除(移除=立即)。立即 vs 排程用「有無 chip」的形狀判斷。
  2. 執行端(按開始執行的確認框):有任何排程組時,確認框列清單——每組一行「agent(目標)+ 立即/⏱ 時間開始」;時間已過顯示「立即」,不顯示過期時間。
  3. 等待端(執行紀錄):排程中的組獨立狀態徽章「⏱ 排程中」(中性色,與 running 的動態感區隔)+時間;group 彙總列如「2 組:1 執行中、1 排程於 8/25 02:00」;排程中的組提供「立即開始」快捷鈕+「取消」。

5.10 每列自足模型(FR-067.8,2026-08-25 拍板並全數落地)

狀態:T-8.1/T-8.2/T-8.3 皆已實作(BE 5ca869e7 內含 T-8.1、fda3921b=T-8.3; FE da33373=T-8.2、bb23efe error code、a4c5ab4=T-8.3)。本節描述實況, 原設計定案的措辭已就地更新;實作時推翻的前提在各點下方標示。

心智模型翻轉:從「任務層參數+分派列覆寫」改為「每列=一份完整獨立的工作設定」。

為什麼改:覆寫模型是演化出來的(.3 建任務層 → CM-1379/1382 逐鍵開洞),每開一鍵多一條 「留空沿用什麼」說明,使用者看一列要心算兩層合併;且 SonarQube 多模式(pull/scan/upload) 在覆寫語意下開放成本高——「一列 pull、一列 upload」在任務層單一 scan_mode 的形狀下根本 表達不了。業界主流(Nessus/Qualys/InsightVM、AWX、防火牆規則表)的形狀是「每條 job spec 自足+重用靠引用庫」——我們的 profile 庫(FR-059)正是那個「可重用參數包」。

落地內容(逐點對照實作)

  1. 每列自足(T-8.1/T-8.2 ✅):選完工具後直接是「Agent 工作指派」清單,每列=agent+ 該工具完整 param_schema 表單。任務層共用掃描參數區收掉,任務層只留 completion_mode欄位名沿用 scan_targets 不改(DB column 與 API wire key 皆是)——承載內容從「覆寫 子集」擴張成「完整列 params」是語意擴張不是形狀改變(一直都是 JSONB dict),改名要動 migration + FE + control-tree raw SQL + e2e factory,收益只有名字好聽。

  2. secret 跟著列走(T-8.1 ✅):secret:true 欄位(ZAP 登入密碼等)存列 params, FR-058 的加密剝除機制對到列上(DB 密文、API 剝除、FE secret_keys_set per 列顯示 「已設定」)。順序上必須先加密沿用再驗必填,否則「已設定過的密碼留空送回」會被判成 缺漏(job_service._validate_assignment_required_fields 的呼叫點有守)。

  3. 零列 fallback 廢掉(T-8.1/T-8.2 ✅):最少一列;「自動挑機」語意改為列上 agent 留空=執行時自動挑一台在線的(D8 便利性保留、載體改變)。存量「零列靠任務層」的綁定: 讀取端合成一列rows_for_read(),合成列沒有 uid,FE 原樣送回時 diff-sync 走新增 分支,使用者第一次存檔時真正落庫——語意正是「把隱含的 fallback 顯性化」)。

  4. SonarQube 多包混掃(T-8.3 ✅):每列自己的 scan_mode 與對應欄位;upload 列各自的 檔案槽、各自 sticky。per-列 project_key_suffix 驗跨列唯一(GRC_400114)。自動派發 D33 細到列——原本只看任務層 tool_params,會漏掉「任務層 pull、某列 upload」的任務而 判成可自動派,派下去必然打中首次無檔 400,該錯誤被 try/except 吞掉只留 warning,且失敗 不產生執行紀錄使冪等條件永遠成立,每按一次「開始執行任務」就再靜默失敗一次。 擋門逐列且擋在建 group 之前(零工單零執行紀錄,與 D3 逐台驗證同一立場),訊息帶 「第 N 列(agent 名)」。execute 收 source_files{assignment_uid: {uid}},fallback 用保留字 _default);舊的單槽 source_file 保留給無分派列的 fallback 路徑,兩者並存。 單組重跑沿用該列自己的包。agent 零改動——憑工單 uid 逐工單下載本就獨立,授權 resolver 讀的是該工單自己的 params。

  5. 重複填寫的解法(T-8.2 ✅):①每列「複製此列」鈕;②新增列預設帶第一列的參數值 (非空白表單),secret 欄刻意不抄(抄 key 名會讓新列誤顯示「已設定」,使用者不填 就存進空密碼)。不做跨任務參數組庫(過度設計;反悔條件:客戶抱怨跨任務重填同一組參數 時再評)。

  6. 執行層零改動(✅ 如預期):group/展開/收口/彙總通知/重跑取消全部不動——改的是 設定層形狀不是執行機制。BE 展開簡化為「直接拿該列 params 派工」(profile 參照展開照舊 在派工時做)。agent 零改動。

  7. 未來流程引擎對接:每列自足=一列一份完整 job spec,天然對應流程節點/multi-instance 展開;執行層本就獨立於 flow-engine complete 鏈路,對接不需重構。

存量遷移(讀取端相容,不做 DB backfill):canonical 是 common/util/detection_assignment_params.py——merge_row_params()(任務層墊底+列蓋上)/ rows_for_read()(零列合成一列)/field_visible()missing_required_fields()(逐列 condition-aware 驗證)。合併對新資料是恆等運算(完整列 params 已涵蓋任務層每個鍵), 故讀取端與派工端共用同一支函式、兩邊不會漂移。開發驗證階段資料會重置(D8 同款立場)。

實作時推翻/修正的前提(非推測,皆為實跑抓到)

  • 🔴 內部欄位(_ 前綴)的歸屬規則被 T-8.3 推翻:T-8.1 當初讓任務層的內部欄位永遠 獲勝,理由是「列上不該有、FE 也送不出」。upload 源碼包 per 列化之後這個前提不成立—— _source_file 現在是 BE 自己寫在上的 sticky 參照。仍讓任務層獲勝的話兩列會被綁在 同一包上,症狀是「A 列上傳的包,B 列重掃時也用它」而且畫面完全正常。改為與一般鍵 同規則「列上有就列上贏」;T-8.1 的不變式測試改寫成鎖新契約(不是刪掉),並補「存量列 仍由任務層墊底」的回歸。
  • 「FE 不得覆寫內部欄位」改由寫入端保證strip_internal_keys()(剝掉 FE 送來的 _ 前綴鍵)+ internal_keys_of()(保留該列既有的)。寫入端分得出「誰送來的」,合併端 分不出。兩者缺一都出事——列 scan_targets 是整包覆蓋寫入,不主動保留的話使用者在 任務設置改任何一個無關欄位按儲存,都會把該列的 _source_file 清掉。
  • strip_secret_params 只認加密 envelope、不管 _ 前綴,故 sticky 會原樣進設定畫面並 被 FE 當一般參數回送。兩條讀取路徑(GrcJobToolDto/規劃頁 control-tree raw SQL)都補上 剝除,改以不帶底線的 source_file{uid, file_name})表達「這列沿用哪一包」,形狀與 執行紀錄快照一致。
  • JobExecutionDrawer 有四處讀任務層 tool.params(上傳模式判定、檢測設定顯示、執行 主機/掃描目標清單),參數搬到列上後新任務那裡會全部空白且不報錯,看起來像「這個任務 沒設定」→ 改成讀各分派列(跨列彙整:值不同併陳、主機清單取聯集),零列時退回任務層 走存量相容。

5.11 掃描目標 range 的顯示配套(CM-1395,2026-08-25/26 驗收回饋)

CM-1383 引入 CIDR/range 語法後,顯示端三個洞:分派列摘要只講「N 段」不講台數、執行紀錄把展開後的逐台清單整牆印出(/24=254 個 IP 並排)、未掃到警示字在亮色主題白底淡黃不可讀。分兩輪落地(BE 571b5b9ef630282d1be843a7;FE 8a64c1f341a09d6aefb34)。

① 台數由 BE 供,FE 不算(維持 CM-1383「不要第二套展開規則」的立場,只是資料來源從「FE 推算」換成「BE 算好送過來」):

  • common/util/scan_target_spec.pycount_specwhole_network 參數表達 CIDR 兩種語意:展開欄位算可用主機(/24=254,與實際展開一致)、透傳欄位算整段(256,工具實際會處理的量);range 與單一位址兩種語意同數(無歧義)。
  • display_counts_of()唯一入口:掃 hoststargets 兩個目標欄位(TARGET_SPEC_FIELDS),CIDR 語意依工具展開/透傳分流。「算不算」一律算、「CIDR 怎麼算」才分流——第一輪只對展開欄位給台數(理由是透傳欄位台數語意由工具決定),但那個顧慮只在 CIDR 成立,range 161-166 就是 6 台無歧義;整個透傳欄位排除等於為 CIDR 的歧義犧牲無歧義的 range(補洞輪修正)。
  • 逐台寫法(10.0.0.5, 10.0.0.6)不給值——段數本來就是台數,多回一份只是讓 FE 兩條路徑做同一件事。
  • 分派列 DTO 補 scan_target_spec(原始寫法字串)——它同時是 FE 過期防護的依據:FE 把 spec 字串與畫面現值比對,使用者改了目標未存檔時不再顯示過期台數(退回段數原樣顯示)。

② 執行紀錄印原始寫法+台數,不再 IP 牆

  • 來源是派工當下的快照(工單 params 的 _scan_target_spec,經 _visible_scan_params 轉出)——執行紀錄忠於「那一次到底掃了什麼」,不回查分派列現在的設定(與 scan_targets 既有立場一致)。快照優先、無快照就地算:展開欄位 DB 裡已是逐台清單,原始寫法只有快照留得住;透傳欄位存的就是原始寫法,直接算即可(存量舊紀錄因此不重跑也能正確顯示台數)。工具 code 批次取(避免列表頁 N+1,有回歸測試守著)。
  • 執行紀錄改回傳 targets 欄位1be843a7,原本只挑 hosts):nmap 的 hosts 是跳板機、targets 才是被掃網段,只帶 hosts 讓顯示端根本沒有掃描目標可講(user 實測誤以為網段沒帶到)。BE 不預判哪個是「目標」(呈現決策留給 FE);params 無 targets 的工具不多出鍵,行為不變(測試明文鎖住)。
  • FE 抽 execTargetField() 共用(執行紀錄列台數 chip/詳情視窗 hosts+targets 區塊/任務資訊區標題):有 spec 印一個原始寫法 Tag+BE 真實台數,無 spec 維持逐台。刪掉 execHostListexecTargetList(零 caller 的第二套讀法——只回清單、台數由 caller 數長度,正是「range 說成 1 台」症狀的來源)。列上講真正被掃的欄位(有 targets 講它,沒有講 hosts;判準看資料不看工具清單——「有 targets」等同「這支工具把執行主機與掃描目標分兩欄」,寫死清單會在下一支同形狀工具進來時靜默出錯);台數 chip 同欄位同 param_schema 短名,避免上面講網段、下面數跳板。逐台工具顯示完全不變。['hosts','targets'] 收斂成 TARGET_PARAM_KEYS 一份(原三份同義副本),與 BE TARGET_SPEC_FIELDS 對應。
  • 順手:執行詳情長 label(用法寫進標題共 32 字撐爆兩欄 grid)改 paramShortLabel 短標籤+括號段掛 ⓘ tooltip。

③ 警示字配色 theme-aware:未掃到主機警示原寫 PrimeVue --yellow-400(亮色主題是 #fccc55 白底淡黃不可讀),改吃本專案 token --color-orange--color-red(hint-bar warn/error 同一套語彙,兩主題都有對比),四處 inline 色碼抽成 .exec-warn-text.exec-error-text 兩個 class。

⚠️ 順帶發現待決策(未開卡):188 驗收環境有 Nmap 分派列設定填 {"hosts": "192.168.50.0/24"}——對 nmap 而言 hosts 是逐台 SSH 登入去執行 nmap 的機器,實際行為是「登入 254 台、每台各掃一次目標網段」,幾乎確定不是本意。屬欄位標籤 UX 問題(兩欄名字分不出誰是誰),非顯示 bug,本卡未動。


6. 拆分(7 子需求 × 16 子任務)

依賴鏈:.1(地基)∥ .2(資料層)先行 → .3(綁定設置)/.4(展開收口)→ .5(呈現與通知)→ .6(收尾).7(排程)依賴 .2(子表)與 .4(展開/取消鏈),與 .5 平行,但 T-7.2 與 T-5.3 同一棒實作(同動 JobExecutionDrawer)。規模:S≈半天內、M≈1–2 天、L≈3 天+。

FR-067.8 每列自足模型(§5.10)— 依賴:.3/.5(2026-08-25 追加,三卡全數完成

# 子任務 驗收 依賴 Repo 規模
T-8.1 ✅ BE:列 params 完整化——讀寫 schema 改存完整列 params、存量讀取端合併(任務層墊底+零列合成一列)、展開直取列 params 不合併、secret per 列(加密/剝除/secret_keys_set 對列)、驗證改逐列 condition-aware(含 project_key_suffix 唯一)、agent 留空=自動挑機 存完整列讀回完整列;舊資料(含零列)讀出合併正確;展開派工 params 與列一致;secret 列上加密不出 API .3/.5 BE M
T-8.2 ✅ FE:任務層掃描參數區收掉(留 completion_mode)、展開卡片渲染整份 param_schema(DetectionConfigField 復用,condition/secret/profile 動態選項照舊)、複製此列鈕+新列帶第一列預設值、最少一列擋刪最後一列 每列所見即所得;SonarQube 各列可選不同 scan_mode;複製列/預設值生效;build 過 T-8.1 FE M~L
T-8.3 ✅ SonarQube upload per 列:抽屜上傳區改逐 upload 列各一檔案槽、各自 sticky、execute per 列帶 uid、擋門逐列(upload 列無檔不給執行)、D33 自動派發判斷細到列 兩個 upload 列各上各的包、各自重掃沿用;混 pull/scan/upload 一次執行全組展開 T-8.1/T-8.2 BE+FE M

FR-067.1 地基:Agent 清單 API 補齊 — 依賴:無(可先行)

# 子任務 驗收 依賴 Repo 規模
T-1.1 GET /remote-agents 補 capabilities+is_online+last_seen_at+capability 過濾參數;spec 坑 10 一併結案 API 回傳含三欄;帶 capability=detection_scan 只回有該能力的;既有 caller(storage-config)迴歸不壞 BE S
T-1.2 _pick_agent 修好:過濾 enabled/未 revoke/有能力/在線+穩定排序;test_connection 對齊同一選擇邏輯 停掉唯一在線 agent 後執行 fallback 任務 → 明確報錯而非派給離線機;test_connection 挑的與執行挑的同一台 BE S

FR-067.2 資料層:assignment 子表+group 表 — 依賴:無

# 子任務 驗收 依賴 Repo 規模
T-2.1 migration:job_execution_detection_tool_agentsdetection_execution_groups 新表、detection_executions 加 group_uid/assignment_uid、補三支索引;GRANT/schema_migrations/RLS 照慣例 DEV 可套;\d 結構與 §5.2 一致;不做 backfill(D8) BE M
T-2.2 DDD 四層:兩張新表的 entity/repo interface/repo impl/domain service(含「每 assignment 最新一筆」查詢與彙總計算函式、running group 守門查詢) 彙總函式單元可驗(succeeded/partial_failed/failed/running 四情境+重跑後最新一筆語意) T-2.1 BE M

FR-067.3 綁定設置:assignment CRUD — 依賴:.1/.2

# 子任務 驗收 依賴 Repo 規模
T-3.1 BE:DetectionToolBindingSchema 擴 agent_assignments+job_service 寫入鏈 diff-sync(增/改/軟刪)+讀取 enrich agent_name/agent_online 綁定 API 寫兩組讀回兩組;刪一組再讀剩一組;同 agent 重複組被擋 T-2.2 BE M
T-3.2 FE:任務設置「Agent 分派」列表 UI(agent 下拉吃 capability 過濾+離線標示、目標輸入、增刪列、空列表=自動分派說明) 加兩組儲存後重開頁面設定還在;離線 agent 在下拉呈灰不可選 T-1.1/T-3.1 FE M

FR-067.4 執行展開與收口 — 依賴:.2

# 子任務 驗收 依賴 Repo 規模
T-4.1 start_execution 展開:D3 逐台驗證(收集全部不合格一次 400+新 error code)→ 建 group+N 筆 execution+N 工單(params merge);fallback 隱含單組;running group 守門與 _auto_dispatch_detection_scans 冪等上移 group 兩組 assignment 按執行 → DB 出現 1 group+2 execution+2 工單各釘各台;一台離線 → 400 訊息列出該台與原因、零工單產生 T-2.2 BE L
T-4.2 收口邏輯:on_scan_succeeded/failed 尾端 group 列鎖收口(彙總落地+auto 完成 gate+409 地雷解除);complete_job 冪等防護 兩台先後回報:第一台回報後 group 仍 running、任務未完成;第二台回報後 group succeeded、auto 任務自動完成;併發回報不重複 complete_job、agent 不收 5xx T-4.1 BE L
T-4.3 單組重跑+單組/整組取消三端點(§5.5,含 best-effort 取消語意與取消後重算) 一成一敗後對敗組重跑 → 同 group 追加新筆、成功後整組轉 succeeded 並觸發 auto 完成;整組取消時一台失聯仍能取消可達的那台 T-4.2 BE M

FR-067.5 彙總呈現與通知 — 依賴:.4

# 子任務 驗收 依賴 Repo 規模
T-5.1 執行紀錄 API group 化 response+agent_name enrich(含 legacy 單筆自成一組相容形狀) 執行紀錄 API 回 group 兩層形狀,每筆帶 agent 名稱;舊資料(無 group)仍能顯示 T-4.1 BE M
T-5.2 彙總通知 _notify_group_result(Email/Telegram/Discord 三通道,每 assignment 一列) 兩台任務收口只收到一封通知,內含兩台各自 agent 名/目標/狀態/發現數 T-4.2 BE S
T-5.3 FE:JobExecutionDrawer group 化(群組卡片+狀態徽章+每列 agent 名稱與報告連結+重跑/取消鈕+重跑歷史收合) 執行紀錄頁看到群組卡片與各台狀態;失敗列可按重跑;partial_failed 呈黃色 T-5.1/T-4.3 FE L

FR-067.6 收尾:測試修補+SPEC 更新 — 依賴:.3/.4/.5

# 子任務 驗收 依賴 Repo 規模
T-6.1 BE 既有測試 5 檔 mock 形狀對齊 group 模型;核心收口/彙總邏輯測試補齊(共用邏輯,符合測試政策寫新測試條件②) 相關測試檔全綠 .4 BE M
T-6.2 E2E:detection-job-factory 擴多 assignment+多 agent 分派場景 regression(母卡收口動作) site-regression 檢測相關情境綠 .3–.5 test M
T-6.3 SPEC 三頁更新(my-tasks/project-planning/project-task-edit)+remote-agent-manage 坑 10 收掉;走 writing-feature-specs skill、等 user 下令 三頁 spec 含多 agent 分派章節與變更紀錄行 .3–.5 BE(docs) S

FR-067.7 per-assignment 排程執行(D10,2026-08-24 追加) — 依賴:.2(子表)/.4(展開/取消鏈);與 .5 平行,T-7.2 與 T-5.3 同一棒

# 子任務 驗收 依賴 Repo 規模
T-7.1 BE:migration(assignment 子表+agent_tasks+detection_executions 三處加欄/狀態)、綁定 API schema 帶 scheduled_at、展開時快照進工單與 execution(scheduled 狀態)、領單查詢過濾(scheduled_at IS NULL OR <= now())、取消排程組短路、「立即開始」端點、彙總函式視 scheduled 為未終態 設一組 2 分鐘後的排程→按執行→心跳期間 agent 沒領到→時間到下一次心跳領走執行;排程中取消不打 agent 直接 cancelled;立即開始鈕清時間後下次心跳領走 T-2.1/T-4.3 BE M
T-7.2 FE:設定端排程 chip UX(§5.9.4-1)+執行確認框清單(§5.9.4-2)+執行紀錄 scheduled 徽章與「立即開始/取消」(§5.9.4-3)。與 T-5.3 同一棒實作(同動 JobExecutionDrawer) 設定列可加/移除排程 chip;確認框列出各組立即/排程時間;執行紀錄排程組顯示「⏱ 排程中」+時間、可立即開始或取消 T-7.1/T-5.3 FE M

7. 端到端驗收

可親手操作的場景清單(DEV 環境、兩台 agent 各在不同網段):

  1. 正常流(兩台兩網段):任務設置加兩組 assignment(甲+網段 A 目標、乙+網段 B 目標)→ 儲存重開頁面設定還在 → 按開始執行 → 執行紀錄出現一個群組卡片、兩列各帶 agent 名稱與狀態 → 兩台先後回報,第一台完成時整組仍顯示執行中 → 兩台都完成後整組轉成功、auto 模式下任務自動完成 → 只收到一封彙總通知,內含兩台各自結果與發現數。
  2. 離線擋下:把乙 agent 停掉(超過 900 秒無心跳)→ 按開始執行 → 立即 400,錯誤訊息明確列出「乙:離線」→ 執行紀錄零新增、沒有任何工單產生。
  3. 部分失敗+單組重跑:讓乙掃描失敗(如目標填不可達網段)→ 整組轉 partial_failed(黃色)、任務自動完成 → 對乙那組按「重跑」→ 同群組下追加新的一筆、整組回到執行中 → 重跑成功後整組轉成功、auto 觸發任務完成;展開重跑歷史可看到乙的失敗舊筆。
  4. 取消:執行中對單組按取消 → 只有該台工單被取消、另一台照常回報;另跑一輪按「整組取消」且其中一台失聯 → 可達的那台成功取消(不因失聯台卡死),群組收口為失敗類、照發彙總通知、不自動完成。
  5. fallback(不設 assignment):拿一個沒設 assignment 的任務按執行 → 自動挑一台在線且有掃描能力的 agent、單組群組照常收口,行為與現況一致但不會挑到離線機;test_connection 測的與執行挑的是同一台。
  6. 地基迴歸:任務設置的 agent 下拉只列出有 detection_scan 能力的機器、離線者有標示;storage-config 既有 agent 下拉不受影響。