FR-067 · Detection Multi-Agent Dispatch — 設計文件 · 2026-08-25(.1–.5/.7/.8 已實作,驗收迭代中)
檢測任務可設定多組「哪台 agent 掃哪些目標」,一次執行同時派給多台、全部回報完才算完成,報告與通知彙總呈現。本文件承接討論稿,D1–D9 已於 2026-08-23 全數拍板(含 D7 執行粒度、D8 相容範圍兩項 user 修訂),落成資料模型、API 契約、收口演算法與拆分表;2026-08-24 追加 D10(per-assignment 排程執行)成 FR-067.7。
狀態: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
這節不需要任何技術背景就讀得懂。工程細節從下一節起。
這案在做什麼:檢測工具(弱點掃描、組態檢查那些)是靠派駐在客戶機房的「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_400109/GRC_400110);掃描目標支援 CIDR 與末段範圍語法(§5.4,新 util common/util/scan_target_spec.py;展開與否依欄位分流而非依工具——Nmap 的 hosts 是 agent 登入去跑 nmap 的執行主機要展開、targets 是掃描目標原樣透傳,推翻卡片原假設;GRC_400111/GRC_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_400113/GRC_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 |
檢測工具平台(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):
整案一句話:檢測任務可以設定多組「哪台 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 分鐘後的時間、按開始執行,看到那組顯示排程中、時間到自己開跑 |
| # | 決策 | 定案內容 | 定案理由 | 被排除方案與原因 |
|---|---|---|---|---|
| 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 → running;cancelled 歸類比照 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_tasks 加 scheduled_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 驗證延到半夜沒人看 |
座標均出自討論稿兩輪唯讀探脈(詳見 discussion.md §3)。
| 元件 | 現況 | 本案動作 |
|---|---|---|
| 心跳領單機制 | 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-93 → on_scan_succeeded/failed(detection_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 列表沿用同款選取模式 |
| # | 元件 | 現況 | 本案動作 |
|---|---|---|---|
| 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_agent(detection_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_scans(app/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_connection(app/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_uid/agent_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 收尾更新 |
detection_executions(infra/detection_execution/model/detection_execution.py):agent_task_uid(soft-ref)、job_execution_uid、status(running/succeeded/failed/cancelled,無狀態機守門)、summary(JSONB)、report_file_id/evidence_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}"。compliance.remote_agents:uid/name/base_url/capabilities(JSONB,agent 心跳自報,0.2.8 起恆為 ["file_storage","detection_scan"])/last_seen_at(在線判定=距今 < 300s × 3)。agent.base_url。ProjectPlanningView.vue:1125-1127)→ api/grc/serializers/job.py:159-173(DetectionToolBindingSchema)→ app/grc/service/job_service.py:375-430。維持「一筆執行=一張工單」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
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);唯一約束 (2026-08-24 CM-1379 推翻:混合 OS 場景需要同一 agent 開多列各掃各基準,(job_execution_detection_tool_id, agent_uid) WHERE is_delete = FALSEuq_jedta_binding_agent_active 已由 migration 移除。重複判準改由 BE 驗證層負責——「(agent_uid, scan_targets 內容) 不可完全相同」;刻意不做 JSONB DB 唯一,鍵序正規化與 null/{} 等價的成本高於收益,且該重複屬使用者輸入錯誤而非資料完整性風險)。
per-列參數覆寫(2026-08-24 CM-1379 追加):scan_targets JSONB 除 hosts 外亦承載 profile/content_path 覆寫鍵——留空=沿用任務層 tool_params,有值=該列覆寫同名鍵。這使同一任務可混合 OS 各掃各基準(Ubuntu 列用 ssg-ubuntu2404-ds.xml、Rocky 列用 ssg-rl10-ds.xml)。連帶契約:profile 參照(profile:<uid>)的展開必須發生在「綁定層與 assignment 層合併之後」(見 §5.4)。
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」守門。
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 組。重跑的「最新一筆」以此欄分組)。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_tools 與 detection_executions 現況照抄)。
%%{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 自動完成任務+彙總通知)
彙總計算(單一函式,所有入口共用):取 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。
收口判定與併發防護:
on_scan_succeeded / on_scan_failed 單筆終態寫入之後、同一交易內;SELECT ... FOR UPDATE 鎖 group 列,再算彙總——N 筆併發回報時,後到者會等鎖,看到的是前者已提交的終態,保證收口三件事只有「讓彙總離開 running 的那一筆」執行一次;closed_at,然後做收口三件事:
completion_mode == 'auto' 且彙總 == succeeded 才 complete_job;partial_failed/failed 不自動完成留人工。complete_job 只在收口路徑呼叫一次,現況「多筆各自觸發撞 409 → agent 收 5xx」地雷就此解除(receive_result 不吞例外的既有行為不必改);closed_at 清空;重跑那筆終態時走同一收口路徑重算。若重算為 succeeded 且 auto 模式 → 觸發 complete_job(冪等防護:呼叫前檢查任務現況,已完成則跳過不報錯——防「人工先完成了任務、之後重跑又成功」的邊角)。通知在每次收口都發(重跑收口再發一封彙總,屬合理行為:狀態確實又變了)。並行守門與冪等上移:
start_execution 的 force 判斷與 cancel_execution 的 caller 改查 group(partial index 支援);_auto_dispatch_detection_scans 冪等條件:從「該任務無任何 execution」改「該任務無任何 group」(D8 不 backfill,舊 execution 無 group——但舊資料所屬任務已跑過的情境在開發階段不存續,資料將重置,不做特判)。start_execution 改造:
is_delete = FALSE,按 sort_order);空列表 → fallback 隱含單組:走修好的 _pick_agent(過濾 enabled+未 revoke+有 detection_scan capability+在線,並加穩定排序如 ORDER BY id),目標用 tool_params 原值;detection_scan/在線(last_seen_at 距今 < 900s)。收集全部不合格項後一次擋下,BadRequestError 帶新 error code(依 GRC_400xxx 命名規則新增),訊息逐台列「agent 名稱+不合格原因」;agent_id 釘該台;params 以綁定 tool_params 為底、assignment 的 scan_targets 覆蓋同名鍵);合併與展開順序(2026-08-24 CM-1379 修正,🔴 不變量):_expand_profile_ref()(profile:<uid> 參照 → _profile 凍結快照)必須在合併之後、下放到 _dispatch_one 以合併結果為輸入——start_execution/rerun 都不得預先在綁定層展開。原設計在合併前展開,分派列覆寫 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)納入 與 hosts/profile 同一套覆寫機制——合併與展開路徑零改動(_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.py(validate_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_FIELDS(BE 常數而非 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_uid/agent_name,message 前綴標明由哪台執行測試。新 error code DETECTION_TOOLS_400018(msg 帶明細,FE 列入 CODES_KEEPING_BE_DETAIL)。FE 側見 §5.8.1。
POST .../detection-executions/groups/{group_uid}/assignments/{assignment_uid}/rerun:
🔧 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 線自己的最新一筆是不是
failed/cancelled,不看群組整體狀態。原本要防的不變式由此承接——該線還在running/scheduled時最新一筆就不是終態,一樣被擋(改回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」守門——那是防並發疊加,與本卡不同軸。
_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 完成、照發彙總通知)。| 端點 | 動作 | 形狀要點 |
|---|---|---|
綁定讀寫(既有 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_name+agent_online。unknown=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)。
_notify_scan_result 改為收口時呼叫的 _notify_group_result:標題帶任務名+整組狀態;內文表格每 assignment 一列(agent 名稱/掃描目標/狀態/發現數 findings,取該 assignment 最新一筆)。三通道(Email/Telegram/Discord)同步改。單組取消/重跑本身不發通知,只有收口發。
本節描述 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,全攤平在一行會擠成一片、多列並排更看不出哪列在做什麼,故:
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/按鈕。重複填寫的解法(§5.10-5):「複製此列」鈕(新列以該列為底)+新增列預設帶第一列的 參數值(非空白表單)。secret 欄刻意不抄——抄 key 名會讓新列誤顯示「已設定」,使用者 不填就存進空密碼。
最少一列:剩一列時刪除鈕 disabled;agent 留空=執行時自動挑一台在線的(合法值, D8 的便利性保留、載體改變)。原「空列擋存檔」與 agentAssignmentErrors 旗標隨之退役。
逐列驗證:condition-aware 必填(判準與 BE detection_assignment_params.field_visible 同源)+ project_key_suffix 跨列唯一(兩列落同一 SonarQube 專案會互相覆蓋結果)。 對應 GRC_400113/GRC_400114;400113 的 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 內容) 完全相同」的重複列。
| 階段 | 當時形狀 | 現況 |
|---|---|---|
| .3 初版 | 每列只有 agent 下拉+目標輸入 | 已擴成整份 param_schema |
| CM-1375 第二/三波 | 「掃描目標單一入口」——hosts 型工具有分派列時參數區隱藏 hosts 欄(isHostsOwnedByAssignments),零列時 hosts 欄照舊顯示 |
整個任務層參數區已移除,isHostsOwnedByAssignments 這類「哪一邊擁有這個欄位」的判斷隨之消失 |
| CM-1375~1382 覆寫期 | 每列只放要覆寫的鍵(hosts/profile/content_path/transport),留空=沿用任務層,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 現行判準 (整列完全相同才擋)不符,一併更正。
以 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 網址,看不出哪一組是哪個模式、網址是誰的。併陳的能力上限就是「講得出有哪些值、講不出誰是誰」,值一旦不同構就失效。改法:
job_execution_detection_tool_agents(assignment 子表)加 scheduled_at TIMESTAMPTZ 可空(NULL=立即)。排程跟著「agent+掃描目標」一起設定在同一列,不另立入口。agent_tasks 加 scheduled_at TIMESTAMPTZ 可空——執行展開時從 assignment 快照進工單(fallback 隱含單組恆為 NULL)。detection_executions.status 新增 scheduled 狀態:排程等待中(不顯示 running 誤導使用者「已經在掃了」)。彙總函式 compute_group_status 視 scheduled 為未終態(等同 running 效果:group 不收口);group 層狀態不另設 scheduled 態,彙總仍為 running(列表彙總文案由 FE 依明細組成,見 §5.9.4)。按「開始執行」當下照常走 §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 是絕對時間;啟動當下時間已過=立即可領(不報錯不擋)。scheduled_at 重新判定——過去式時間自然等於立即,語意自洽,不清欄位(使用者設的維護窗設定保留,要改自己改)。scheduled_at 單值欄位留未來擴充空間。scheduled_at → 該筆 execution 以 scheduled 建立;agent 實際領走單時轉 running。彙總函式視同未終態。POST .../groups/{group_uid}/assignments/{assignment_uid}/start-now——把該工單(與 execution 快照)的 scheduled_at 清成 now,下一次心跳即領走。排程中的組可催跑,不必取消重來。原則:排程是例外,用明確開關表達,不用空值表達;立即 vs 排程用「形狀」一眼可辨,不依賴說明文字。
狀態:T-8.1/T-8.2/T-8.3 皆已實作(BE
5ca869e7內含 T-8.1、fda3921b=T-8.3; FEda33373=T-8.2、bb23efeerror 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)正是那個「可重用參數包」。
落地內容(逐點對照實作):
每列自足(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,收益只有名字好聽。
secret 跟著列走(T-8.1 ✅):secret:true 欄位(ZAP 登入密碼等)存列 params, FR-058 的加密剝除機制對到列上(DB 密文、API 剝除、FE secret_keys_set per 列顯示 「已設定」)。順序上必須先加密沿用再驗必填,否則「已設定過的密碼留空送回」會被判成 缺漏(job_service._validate_assignment_required_fields 的呼叫點有守)。
零列 fallback 廢掉(T-8.1/T-8.2 ✅):最少一列;「自動挑機」語意改為列上 agent 留空=執行時自動挑一台在線的(D8 便利性保留、載體改變)。存量「零列靠任務層」的綁定: 讀取端合成一列(rows_for_read(),合成列沒有 uid,FE 原樣送回時 diff-sync 走新增 分支,使用者第一次存檔時真正落庫——語意正是「把隱含的 fallback 顯性化」)。
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。
重複填寫的解法(T-8.2 ✅):①每列「複製此列」鈕;②新增列預設帶第一列的參數值 (非空白表單),secret 欄刻意不抄(抄 key 名會讓新列誤顯示「已設定」,使用者不填 就存進空密碼)。不做跨任務參數組庫(過度設計;反悔條件:客戶抱怨跨任務重填同一組參數 時再評)。
執行層零改動(✅ 如預期):group/展開/收口/彙總通知/重跑取消全部不動——改的是 設定層形狀不是執行機制。BE 展開簡化為「直接拿該列 params 派工」(profile 參照展開照舊 在派工時做)。agent 零改動。
未來流程引擎對接:每列自足=一列一份完整 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 的不變式測試改寫成鎖新契約(不是刪掉),並補「存量列 仍由任務層墊底」的回歸。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(上傳模式判定、檢測設定顯示、執行 主機/掃描目標清單),參數搬到列上後新任務那裡會全部空白且不報錯,看起來像「這個任務 沒設定」→ 改成讀各分派列(跨列彙整:值不同併陳、主機清單取聯集),零列時退回任務層 走存量相容。CM-1383 引入 CIDR/range 語法後,顯示端三個洞:分派列摘要只講「N 段」不講台數、執行紀錄把展開後的逐台清單整牆印出(/24=254 個 IP 並排)、未掃到警示字在亮色主題白底淡黃不可讀。分兩輪落地(BE 571b5b9e/f630282d/1be843a7;FE 8a64c1f/341a09d/6aefb34)。
① 台數由 BE 供,FE 不算(維持 CM-1383「不要第二套展開規則」的立場,只是資料來源從「FE 推算」換成「BE 算好送過來」):
common/util/scan_target_spec.py 的 count_spec 加 whole_network 參數表達 CIDR 兩種語意:展開欄位算可用主機(/24=254,與實際展開一致)、透傳欄位算整段(256,工具實際會處理的量);range 與單一位址兩種語意同數(無歧義)。display_counts_of() 為唯一入口:掃 hosts 與 targets 兩個目標欄位(TARGET_SPEC_FIELDS),CIDR 語意依工具展開/透傳分流。「算不算」一律算、「CIDR 怎麼算」才分流——第一輪只對展開欄位給台數(理由是透傳欄位台數語意由工具決定),但那個顧慮只在 CIDR 成立,range 161-166 就是 6 台無歧義;整個透傳欄位排除等於為 CIDR 的歧義犧牲無歧義的 range(補洞輪修正)。10.0.0.5, 10.0.0.6)不給值——段數本來就是台數,多回一份只是讓 FE 兩條路徑做同一件事。scan_target_spec(原始寫法字串)——它同時是 FE 過期防護的依據:FE 把 spec 字串與畫面現值比對,使用者改了目標未存檔時不再顯示過期台數(退回段數原樣顯示)。② 執行紀錄印原始寫法+台數,不再 IP 牆:
_scan_target_spec,經 _visible_scan_params 轉出)——執行紀錄忠於「那一次到底掃了什麼」,不回查分派列現在的設定(與 scan_targets 既有立場一致)。快照優先、無快照就地算:展開欄位 DB 裡已是逐台清單,原始寫法只有快照留得住;透傳欄位存的就是原始寫法,直接算即可(存量舊紀錄因此不重跑也能正確顯示台數)。工具 code 批次取(避免列表頁 N+1,有回歸測試守著)。targets 欄位(1be843a7,原本只挑 hosts):nmap 的 hosts 是跳板機、targets 才是被掃網段,只帶 hosts 讓顯示端根本沒有掃描目標可講(user 實測誤以為網段沒帶到)。BE 不預判哪個是「目標」(呈現決策留給 FE);params 無 targets 的工具不多出鍵,行為不變(測試明文鎖住)。execTargetField() 共用(執行紀錄列台數 chip/詳情視窗 hosts+targets 區塊/任務資訊區標題):有 spec 印一個原始寫法 Tag+BE 真實台數,無 spec 維持逐台。刪掉 execHostList/execTargetList(零 caller 的第二套讀法——只回清單、台數由 caller 數長度,正是「range 說成 1 台」症狀的來源)。列上講真正被掃的欄位(有 targets 講它,沒有講 hosts;判準看資料不看工具清單——「有 targets」等同「這支工具把執行主機與掃描目標分兩欄」,寫死清單會在下一支同形狀工具進來時靜默出錯);台數 chip 同欄位同 param_schema 短名,避免上面講網段、下面數跳板。逐台工具顯示完全不變。['hosts','targets'] 收斂成 TARGET_PARAM_KEYS 一份(原三份同義副本),與 BE TARGET_SPEC_FIELDS 對應。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,本卡未動。
依賴鏈:.1(地基)∥ .2(資料層)先行 → .3(綁定設置)/.4(展開收口)→ .5(呈現與通知)→ .6(收尾);.7(排程)依賴 .2(子表)與 .4(展開/取消鏈),與 .5 平行,但 T-7.2 與 T-5.3 同一棒實作(同動 JobExecutionDrawer)。規模:S≈半天內、M≈1–2 天、L≈3 天+。
| # | 子任務 | 驗收 | 依賴 | 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 |
| # | 子任務 | 驗收 | 依賴 | 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 |
| # | 子任務 | 驗收 | 依賴 | Repo | 規模 |
|---|---|---|---|---|---|
| T-2.1 | migration:job_execution_detection_tool_agents+detection_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 |
| # | 子任務 | 驗收 | 依賴 | 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 |
| # | 子任務 | 驗收 | 依賴 | 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 |
| # | 子任務 | 驗收 | 依賴 | 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 |
| # | 子任務 | 驗收 | 依賴 | 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 |
| # | 子任務 | 驗收 | 依賴 | 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 |
可親手操作的場景清單(DEV 環境、兩台 agent 各在不同網段):
detection_scan 能力的機器、離線者有標示;storage-config 既有 agent 下拉不受影響。