living 檔,就地 Edit,只描述此刻。 歷史軌跡看同目錄
FR-067-LOG.md(append-only)。 母案:CM-1350(FR-067 檢測多 Agent 分派)|branch:feature/FR-067|design:../design.md
FR-067 全案已收案(2026-08-27):母卡 CM-1350 收 Done、v1.16.0 已出貨 (189 POC 全新安裝)、CM-1404 安裝包 P0 已根修(build gate+裝機驗 SOP)。 全案總結見同目錄 2026-08-27-FR-067-SUMMARY.md (範圍/各棒 commits/決策/行為差異/教訓/follow-up 全在那裡)。
本檔轉歷史紀錄,不再更新——接手者請讀 SUMMARY(follow-up 清單含後續優化池 六件、CM-1403 E2E 半成品、190 部署回歸、SPEC 站 MkDocs 評估、STG bundle 形態、 PROD LC)。以下各節保留收案時點的最後狀態供查考。
branch / push 現況:FR-067 已併入 main,後續 commit 落在 main。main 已推到 ccb647df=origin/main 同步——原先卡住的 9ae28f68(CM-1402 docs)+b0a468e2 (第四棒交接)已一併上遠端,CM-1402 的 push 阻塞解除。
版號:v1.16.0 已發(6d4ea5ee,含 FR-066+FR-067)。發版 gate(site-regression) user 明示豁免。SPEC 凍結快照 docs/specs/v1.16.0/ 已切(不含 1395/96/97 三卡行為 ——那三卡的文件由 CM-1402 補進 current/,照版本模型不回頭改快照)。
環境現況:
license_center_poc 仍在其上);LC 已更新 main 6c1d916(alembic 到 head)。 DB 已止血補到 136 支 migration。agent 兩台 re-enroll 過來(id=1 .121/ id=2 .167);id=1 主機側 agent 服務 inactive 沒心跳,user 要去 .121 起服務, 起來後下一次心跳 capabilities 自動補上。license 待 user 向 LC 簽新照(新指紋)。>1 台有 detection_scan 能力才顯示 (ToolPluginManage.vue hasAgentChoice)——單台/零台隱藏是設計不是 bug, 本棒已向 user 澄清過一次。全部驗收卡已收 Done(user 2026-08-26 逐張手測):第一輪六卡+續輪 1376/1381/1382/1383+.8 三卡 1386/1387/1388+文件 1393+最後三卡 1395/1396/1397 +部署 1384/1394。進版鏈四卡亦 Done:1398(進版三件套)/1399(出包)/ 1400(189 主產品安裝,user 親裝)/1401(189 LC 更新,user 親做)。CM-1385 (.8 子需求卡)已收。
開著的卡:無——收案時全數收 Done(1402 SPEC user 驗過/1403 E2E ede170a 實跑複核過(並推翻前一棒 flake 判定,根因是既有測案綁死 190 環境,見 SUMMARY follow-up)/1404 user 實裝複測過/1405 手冊+選單抽查過/1356 收尾母卡+ 1310~1315 FR-066 過時追加卡一併收)。
後續優化池(user 2026-08-26 全數定調「後續優化」不開卡,收尾時整理進 follow-up): enroll token 顯示 bug/legacy is_latest/OpenSCAP content 平台派發(規模 M,正解 方向已定)/Windows 無域憑證(短中長三層方案材料在 LOG 第三棒 block)/ZAP 排隊 重試/掃描設定下放指派人員(等 PM)。PROD LC 已裁示:正式出版時找一台設備架, 現階段不動(PROD 簽章鑰四步組同時做)。
2026-08-23 晚間兩個 session 曾在同一工作目錄平行作業(.1 地基 ∥ .2 資料層),現皆已收工。 兩棒 commit 時都逐檔比對、只 add 自己的檔(.1 十三檔/.2 二十八檔),無互相掃到。 此節保留作為下次平行作業的紀律提醒。
接手時務必:
git status 看到不認得的檔案,先確認是不是平行 session 的(比對 ls -lT 修改時間)再動作git add -A/git add ./git checkout-index -a/git stash pop 後的整樹還原)-am| 子需求 | 子任務 | 狀態 |
|---|---|---|
| FR-067.1 地基(CM-1351) | T-1.1 清單 API 補能力/在線(CM-1357) | ✅ 協調者驗收通過 |
| T-1.2 修好自動挑機+test_connection 對齊(CM-1358) | ✅ 協調者驗收通過 | |
| FR-067.2 資料層(CM-1352) | T-2.1 migration 兩新表+擴欄補索引(CM-1359) | ✅ 協調者驗收通過 |
| T-2.2 DDD 四層+彙總計算函式(CM-1360) | ✅ 協調者驗收通過 | |
| FR-067.3 綁定設置(CM-1353) | T-3.1 BE 綁定 API 擴 agent_assignments(CM-1361) | ✅ 協調者驗收通過 |
| T-3.2 FE 任務設置「Agent 分派」列表 UI+空列擋下修(CM-1362) | ✅ 協調者驗收通過 | |
| FR-067.4 執行展開與收口(CM-1354) | T-4.1 展開+D3 逐台驗證(CM-1363) | ✅ 協調者驗收通過 |
| T-4.2 列鎖收口+auto gate+409 地雷(CM-1364) | ✅ 協調者驗收通過 | |
| T-4.3 單組重跑+單組/整組取消(CM-1365) | ✅ 協調者驗收通過 | |
| FR-067.5 彙總呈現與通知(CM-1355) | T-5.1 執行紀錄 API group 化+agent enrich(CM-1366) | ✅ 協調者驗收通過 |
| T-5.2 彙總通知改一封三通道(CM-1367) | ✅ 協調者驗收通過 | |
| T-5.3 FE JobExecutionDrawer group 化+重跑/取消鈕(CM-1368) | ✅ 協調者驗收通過 | |
| FR-067.7 排程執行(CM-1372) | T-7.1 BE 排程欄位+領單過濾+立即開始(CM-1373) | ✅ 協調者驗收通過 |
| T-7.2 FE 排程 chip UX+確認框+排程徽章(CM-1374) | ✅ 協調者驗收通過 | |
| — | 端到端驗收第一輪(雙 agent 雙網段,188 stack) | ✅ 2026-08-24 完成,六回饋卡全清 |
| — | 驗收回饋:CM-1375/1377/1378/1379/1380 | ✅ Done(user 逐張驗過) |
| — | 續輪:CM-1376/1381/1382/1383+部署 1384/1394 | ✅ 部署 Done;其餘修正待驗證 |
| FR-067.8 每列自足(CM-1385) | T-8.1 BE(CM-1386)/T-8.2 FE(CM-1387)/T-8.3 SonarQube upload(CM-1388) | ✅ 三棒完成,協調者抽查通過,修正待驗證 |
| — | 文件同步兩輪(CM-1381+CM-1393) | ✅ 修正待驗證 |
| — | 顯示回饋 CM-1395/1396 + 重跑放寬 CM-1397 | 🔄 1395/1396 在飛;1397 等 1396 收 |
| FR-067.6 收尾 | T-6.1 BE 測試對齊(剩 e2e/SPEC)+T-6.2 E2E test repo+T-6.3 SPEC 三頁 | ⏸ 未派 |
| FR-068 Log 轉發 | 設計定案 D1–D6+CM-1389~1392 四卡就緒 | ⏸ user 裁示等 FR-067 收完再派 |
新 canonical:RemoteAgentDomainService.get_dispatchable_agents(tenant_id, capability) =可派工 agent(啟用/未撤銷/有 capability/在線+id 穩定排序)。 _pick_agent 與 test_connection 都改走這支,故「測通的那台=實際執行的那台」。
原 get_agents_with_capability() 保留為靜態資格查詢(清單頁用,含離線機)。 兩者職責不同,別合併——清單要看得到離線機,派工絕不能挑到離線機。
API:GET /remote-agents 每列多 capabilities/is_online/last_seen_at, 支援 ?capability=detection_scan。加欄不減欄,storage-config 下拉不受影響。
搬家:is_online/utc_now → common/util/agent_auth/heartbeat.py (domain 要用,不得依賴 app 層),原處 re-export。
DB(只套 DEV,STG/POC 等放行):新表 config.job_execution_detection_tool_agents (assignment 子表:每列=一組 agent+掃描目標)、compliance.detection_execution_groups (執行群組);compliance.detection_executions 加 group_uid/assignment_uid +補三支索引。migration 2026-08-23-fr067-2-detection-multi-agent-tables.sql 已入 schema_migrations+manifest.tsv。不做存量 backfill(D8)。
程式:兩張新表各自 entity/query entity/repo interface/repo impl/mapper/ domain service 齊,DI 已 wiring;detection_executions 側同步擴兩欄並加 list_by_group/list_latest_per_assignment。彙總計算獨立成 domain/detection_execution/service/detection_group_status.py::compute_group_status (D4 單一真相,所有入口共用)。30 個新單元測試全綠,彙總函式過三輪突變測試; ddd-compliance-reviewer 掃過 0 findings。
assignment(JobExecutionDetectionToolAgentDomainService) | 方法 | 用途 | |---|---| | list_by_binding(binding_id, tenant_id=None) | 未軟刪清單,已按 sort_order 排序(執行展開的輸入) | | get_one / create / update / soft_delete | 單列 CRUD;update 是 partial;刪一律軟刪 |
group(DetectionExecutionGroupDomainService) | 方法 | 用途 | |---|---| | create_running(job_execution_uid, total_count, tenant_id, user) | 展開時建,total_count 定格 | | get_running_by_job_execution(uid, tenant_id=None) | 「一任務最多一個 running group」守門 | | lock_for_close(uid) | SELECT ... FOR UPDATE 鎖列——收口併發防護核心,caller 必須在 @transaction 內 | | compute_status(statuses) | D4 彙總 | | close(uid, status, user) / reopen(uid, user) | 收口/重跑撥回(reopen 會清空 closed_at) |
execution 側新增(DetectionExecutionDomainService) create_running(..., group_uid=None, assignment_uid=None)/ list_latest_per_assignment(group_uid)(彙總輸入)/list_by_group(group_uid)。
收口標準寫法(.4 照這個走,在 @transaction scope 內、單筆終態寫入之後):
group_domain.lock_for_close(group_uid) # 1. 先鎖
latest = execution_domain.list_latest_per_assignment(group_uid) # 2. 取每線最新
status = group_domain.compute_status([e.status for e in latest]) # 3. 算彙總
if status != "running": # 4. 離開 running 才收口
group_domain.close(group_uid, status, user)
# ...auto 完成 gate(僅 succeeded)+彙總通知
closed_at 清不掉:repo.update(entity) 是「非 None 才寫」語意,closed_at=None 會被 skip。重跑撥回 running 必須走 reopen(),不要自己組 entity。DISTINCT ON 的 ORDER BY 前綴:PG 要求前綴與 DISTINCT ON 欄位一致,故是 ORDER BY assignment_uid, id DESC。改排序時別把 assignment_uid 拿掉。assignment_uid IS NULL 自成一條分組線:PG 視 NULL 為單一 distinct 值—— 這正是 fallback 隱含單組要的語意(已實跑確認),不是 bug。scan_targets NOT NULL:domain service 的 create() 已把 None 正規化成 {}; 繞過 domain 直接寫 model 要自己處理。is_delete = FALSE:軟刪列不佔 slot,「移除某台再 加回同一台」不會撞唯一約束(已實跑確認)。刻意設計,比照 uq_jedt_job_active 前例。is_super_admin = 't'), 不是 job_execution_detection_tools 原始建表 migration 那版(逗號+'true',是壞的)。任務設置可存多組「agent+掃描目標」,UI + API 兩端齊。DB 無異動(表是 .2 建好的)。
寫入(diff-sync):DetectionToolBindingSchema 擴 agent_assignments; job_service._replace_detection_tool_agent_assignments() 做增/改/軟刪。 語意:None(未帶欄位)=不動既有設定(比照 tool_params 的 None-skip,只改任務 名稱的 PUT 不會清空分派);[]=明確清空回到自動挑機 fallback(D8,合法狀態, 不做必填驗證)。update/create 兩分支都掛——只掛一支的話另一條路徑靜默不生效。
讀取(enrich):每列補 agent_name/agent_online,app service 層批次查一次 建 map,不在 infra JOIN(審計欄位 enrich 慣例)。兩條讀取路徑都補了—— get_job/list_jobs 走 GrcJobRepoImpl._batch_fetch_job_tools,而規劃頁任務面板 實際走的是 control-tree 那條 raw SQL(ssp_control_implementation_service)。
FE:ProjectPlanningView 檢測工具區加「Agent 分派」列表(agent 下拉+目標輸入+ 增刪列),空列表顯示「自動分派」說明;離線 agent 標示且不可選;已被別列選走的一併 反灰。i18n 中英雙語補齊。
新 error code:GRC_400107(同綁定重複指派同一台)/GRC_400108(agent_uid 空)。 ⚠️ 尚未同步 FE error-code.json——與 .4 那 5 個一併留給 .5 串 FE 時補。
uq_jedta_binding_agent_active。 故配對規則是 uid 優先、agent_uid 次之——agent_uid 在同一綁定下本就是自然識別 (正是該約束的欄位),據此配對才與 DB 唯一性語意一致。add/update 都會立即 flush,先增後刪會讓 「把 A 台換成 B 台」在 A 尚未讓位前就寫入而撞唯一約束。_batch_fetch_job_tools 的話,存檔當下 看起來正常、重新整理頁面設定就消失(DB 其實有)。任何加在綁定上的新欄位都要同時 補這條 raw SQL。展開:start_execution 讀綁定下 assignment → 逐台走 D3 驗證 → 全過才同一交易內建 group(total_count=N)+N 筆 execution(帶 group_uid/assignment_uid)+N 張工單 (agent_id 各釘各台,params 以綁定 tool_params 為底、assignment scan_targets 覆蓋同名鍵)。無 assignment → fallback 隱含單組(自動挑機,assignment_uid=NULL), 仍建 group——所以收口邏輯只有一套。
D3 驗證新 canonical:RemoteAgentDomainService.diagnose_dispatchability(agent_uid, capability, tenant_id) → (agent, [原因代碼]),空清單=可派。原因代碼:not_found/ disabled/revoked/no_capability/offline。判定條件必須與 get_dispatchable_agents() 保持一致(兩處漂移就會出現「驗證說可以、實際挑不到」的 矛盾),docstring 已寫明此義務。任一不過整次 400,訊息一次列全(甲機(offline); 乙機(no_capability, disabled))。
收口:_close_group_if_terminal(group_uid, user, job=, binding=, job_uid=) 掛在 on_scan_succeeded/on_scan_failed 單筆終態寫入之後、同一交易內。順序是 lock_for_close(列鎖)→ list_latest_per_assignment → compute_status → 非 running 才做三件事。auto 完成 gate 只認 succeeded;complete_job 前檢查任務仍 PROCESSING (冪等)。彙總通知只留 hook 點與 log,本體是 .5 的 T-5.2。
三支新端點(/detection-tools/execution-groups/...): {group}/assignments/{assignment}/rerun/{group}/assignments/{assignment}/cancel/ {group}/cancel(best-effort)。fallback 隱含單組用路徑保留字 _default。
既有 job 層取消端點改成委派整組取消——不改的話 FE 抽屜「取消」鈕群組化後只會取消 第一筆,其餘幾台繼續掃且群組永遠 running 擋住下一輪。
冪等上移:_auto_dispatch_detection_scans 的挑選查詢加一條「無 group」,同時保留 原本「無 execution」那條(D8 不 backfill,只看 group 會讓舊資料被判成沒派過而重派)。
_pick_agent 答不出「為什麼不可派」:既有 get_dispatchable_agents() 只回「有哪些 可派」,D3 要逐台列原因,故另開 diagnose_dispatchability()。兩支的資格條件必須同步 維護,改一邊要改另一邊。GROUP_STATUS_* 常數直接 import domain/detection_execution/service/detection_group_status.py, 不要在 orchestration 內另寫字串字面值(第二套真相)。start_execution response 形狀已改({group_uid, status, total_count, executions:[...]}), 不再是 {agent_task_uid, execution_uid}。FE 目前不讀 response body(只看成功與否) 故無破壞,但 .5 串 FE 時要照新形狀。scan_targets,不是派工快照——改過目標後重跑用新的。_default 是路徑保留字:assignment_uid 在 DB 是 NULL(fallback 組),URL 表達不了 NULL,故用保留字;_normalize_assignment_uid() 負責轉換。T-5.1 執行紀錄 group 化:GET /detection-tools/jobs/{job_uid}/executions 回兩層形狀 (群組 → 各台執行紀錄),形狀見下方「FE 待辦契約」。legacy 筆(group_uid NULL,D8 不 backfill)與「群組列查無」(跨租戶/已刪)一律走同一個「單筆自成一組」合成分支 (is_legacy: true),FE 不需分叉渲染。順修兩處既有 N+1(缺口 11)——_resolve_scan_params 與 _resolve_report_file_uid 各補批次版,剝除邏輯抽成 module-level _visible_scan_params() 由單筆/批次共用。報告檔 id→uid 批次走新的 DetectionReportFileQuery(jedi-file-upload 只有 單筆 by-id 與批次 by-uid,不改外部套件共用 API 面)。
T-5.2 彙總通知一封:_notify_group_result 接在 _close_group_if_terminal() 第 ③ 步 (.4 留的 hook 點)。共用脈絡一次 + 每 assignment 一列(agent/目標/狀態/發現數/時間/ 失敗原因),三通道同步。單筆 _notify_scan_result 只留給 legacy 無 group 的筆——它進不了 收口路徑,拿掉會靜默不通知。租戶 IM 設定沿既有顯式讀法(agent 回報路徑無 user_context)。
T-7.1 per-assignment 排程(D10):核心形狀「group 照常立即建,延後的是 agent 領得到單的 時間」。migration 三處加欄+領單部分索引(只套 DEV);領單查詢加 scheduled_at IS NULL OR <= now()(DB 端 now());展開時從 assignment 快照進工單與 execution 狀態(未來時間 → scheduled);取消排程組短路(scheduled +工單仍 pending → 直接標 cancelled 不打 agent);「立即開始」端點;ack 時 scheduled→running。 單組重跑一律立即(force_immediate),不套 assignment 的維護窗。
scheduled_at 的時區不會被自動轉:jedi-common 的 db_mw.DATETIME_FIELDS 只涵蓋 created_at/updated_at。FE 若送不帶 offset 的本地時間,psycopg 會以 session TZ (三環境皆 Etc/UTC)解讀 → 使用者設的凌晨兩點會變成早上十點才開掃,且畫面完全正常 (存什麼讀什麼)。契約收斂在 common/util/scheduled_time.py(naive 一律視為 UTC)。 FE 送值請帶 offset(2026-08-25T02:00:00+08:00)。list_pending_for_agent,list_for_agent_by_status 刻意不動—— FR-058.7(D25)下載授權判定走那支,套上排程過濾會讓 agent 取檔被自己人擋掉。兩支分家有 測試守著(test_agent_task_service.py),別為了「統一」而合併。execution.status == scheduled 且 task.status == pending。只看前者的話,排程時間到、agent 已領走但尚未回報的瞬間會誤短路 ——掃描還在對方機器上跑卻被標成已取消。Mon, 24 Aug 2026 18:00:00 GMT), 與 grc job 路徑的 ISO 不一致 → 已加 .isoformat()。日後往 control-tree 加任何日期欄位 都要記得(本檔 reviewed_at 早有此慣例)。_ACTIVE_EXECUTION_STATUSES 是「尚未落終態」的單一定義(running + scheduled)—— 取消對象、群組守門都用這一組。別在各處另寫 == "running" 字面值,scheduled 會被漏掉 (症狀是排程中的組取消不掉、整組取消回 409 說沒有執行中的項目)。fail、 Nmap 系 findings),填 0 會被讀成「掃了但什麼都沒發現」。GET /api/1.0/detection-tools/jobs/{job_uid}/executions → data 是群組陣列(新到舊):
[{
"uid": "grp-1", // legacy 筆時=該 execution 自己的 uid
"status": "partial_failed", // running / succeeded / partial_failed / failed
"total_count": 2,
"closed_at": "2026-08-25 02:42:00", // 未收口為 null
"created_at": "2026-08-25 02:00:00",
"is_legacy": false, // true=單筆自成一組(舊資料),形狀完全相同
"executions": [{
"uid": "exec-1", "status": "succeeded", // + scheduled(排程等待中)
"agent_uid": "…", "agent_name": "甲機", // agent 已刪除時 name 為 null,該列照常回
"assignment_uid": "asg-1", // null=fallback 隱含單組
"scan_targets": {"hosts": "10.1.0.0/24"},
"is_latest": true, // false=重跑歷史,FE 收合
"summary": {...}, "reports": [{"uid","file_name"}], "report_file_uid": "…",
"scan_params": {...}, "detection_tool_name": "…",
"started_at": "…", "finished_at": "…", "error_message": null,
"created_user": "blsadmin", "created_user_name": "Billows Admin"
}]
}]
succeeded 綠/partial_failed 黃(沿 CM-952 語彙)/failed 紅/ running 動畫;排程中的組(明細有 scheduled 筆)用「⏱ 排程中」中性色(§5.9.4-3), 與 running 的動態感區隔——group 層 status 仍是 running,徽章要看明細組成。is_latest: false 的筆收合顯示。is_legacy: true 照舊單卡呈現即可(形狀一致,不需分叉)。端點(前綴 /api/1.0/detection-tools) |
用途 | 409 時機 |
|---|---|---|
POST /jobs/{job_uid}/execute |
開始執行(response 已是 group 形狀 {group_uid, status, total_count, executions:[{…, scheduled_at}]}) |
已有 running group |
POST /execution-groups/{g}/assignments/{a}/rerun |
單組重跑(一律立即,不套排程) | 群組仍 running/該組最新一筆非 failed/cancelled |
POST /execution-groups/{g}/assignments/{a}/cancel |
單組取消(排程中的組走短路不打 agent) | 該組最新一筆非 running/scheduled |
POST /execution-groups/{g}/assignments/{a}/start-now |
立即開始(催跑排程中的組) | 該組最新一筆非 scheduled |
POST /execution-groups/{g}/cancel |
整組取消(best-effort,回 {cancelled:[], failed:[]}) |
全部不可達 |
POST /jobs/{job_uid}/cancel |
既有抽屜取消鈕(已改為委派整組取消) | 無 running |
fallback 隱含單組(assignment_uid 為 null)在路徑上用保留字 _default。
scheduled_at(設定端)PUT /grc/project/{proj}/job/{job} 的 tool.agent_assignments[] 每列多一個 scheduled_at:
null=立即;有值=該組延後到該時間才由 agent 領走。scan_targets 同)——移除排程 chip 就送 null,會真的清掉。 注意這與整個 agent_assignments 欄位的 None=不動語意是兩回事(那是欄位層級)。2026-08-25T02:00:00+08:00)。不帶 offset 會被當 UTC, 使用者設的凌晨兩點會變成早上十點才開掃(見上方已知雷 1)。2026-08-24T18:00:00+00:00),兩條讀取路徑(grc job/control-tree)一致。error-code.json)——已於 80e1c29 補齊下表八支(原文誤寫「七個」,實為八支)已全部補上中英翻譯;另補兩支清單漏列的 DETECTION_EXECUTION_404001/404002(實打四支新端點時發現 404002 是四顆新按鈕都會撞到 的碼——群組被刪或跨租戶),共十支。
⚠️ DETECTION_TOOLS_400017 另需 App.vue 配套(已做):t('lang.error.'+code, {default: msg}) 在有翻譯時會整個蓋掉 default,只補翻譯反而把 BE 附在 msg 尾端的逐台原因吞掉。已加 CODES_KEEPING_BE_DETAIL 清單,翻譯當抬頭、BE msg 當明細併陳。日後任何「msg 帶明細」 的碼要補翻譯時,都要一併加進那份清單。
| Code | 語意 |
|---|---|
GRC_400107 |
同一檢測工具下不可重複指派同一台 Agent(.3) |
GRC_400108 |
Agent 分派必須指定 Agent(.3) |
DETECTION_TOOLS_400017 |
指派的 agent 有不可派工者(.4;訊息尾端帶逐台原因,需保留 msg 內容) |
DETECTION_TOOLS_404007 |
找不到該 agent 分派(.4) |
DETECTION_TOOLS_409012 |
執行群組仍在執行中(.4,重跑前置) |
DETECTION_TOOLS_409013 |
只有失敗或已取消的組可以重跑(.4) |
DETECTION_TOOLS_409014 |
整組取消失敗(全部 agent 都打不到)(.4) |
DETECTION_TOOLS_409015 |
該組不在排程等待中(.7,立即開始前置) |
按「開始執行」前,若有任何排程組,確認框要列清單——每組一行「agent(目標)+ 立即/ ⏱ 時間開始」;時間已過顯示「立即」,不顯示過期時間。資料源是綁定讀取回來的 agent_assignments(含 scheduled_at),不需另打 API。
commit:FE repo feature/FR-067 上 cb458a6(群組卡片)/055d9c2(排程 chip)/ 80e1c29(十支 error code + App.vue 明細併陳)。未 push。
交付內容:JobExecutionDrawer 群組卡片(彙總徽章/每台一列帶 agent 名稱與目標/單組 重跑/單組取消/整組取消/重跑歷史收合)、排程 UX 三端(設定列 ⏱ chip、執行確認框清單、 排程中中性徽章+立即開始)、守門窗 tooltip 說明、十支 error code 中英。
b205e07a)list_executions 有兩處各自分組、鍵卻不同步:_group_executions() 以 group_uid or legacy:{uid} 分卡,_latest_execution_uids() 卻以 (group_uid, assignment_uid) 算 is_latest。legacy 筆兩個欄位皆 NULL → 同一任務下所有 舊筆被視為同一條分組線、只有最新那筆得到 true;但每筆又各自成一張卡,於是除最新那張外 每張卡都是「零當前列+一列歷史」,FE 預設收合歷史之下整包稽核紀錄從畫面消失 (DEV 實測 job 9ca1e559… 10 筆只顯示得出 1 筆)。
修法:抽出 _execution_card_key() 由兩處共用——卡片與最新判定是同一個分組概念的兩面, 鍵只能有一份(不是改其中一支的鍵)。真群組行為完全不變,僅 legacy 路徑受影響。補回歸測試 test_every_card_has_at_least_one_current_row(三筆 legacy 鎖「每張卡至少一列當前」的不變量), 已做突變測試確認斷言有牙齒。修後以 DEV 真實資料重打 API:10 張卡各有 1 列 is_latest。
FE 的保底仍保留(groupLatestExecutions 全 false 時退回「全部視為當前」)——那是防禦性 不變量,不是繞過 BE;已驗修好後的 payload 不會讓它重複計算(10 列渲染 10 列、無重複)。
| 項目 | 驗到什麼程度 |
|---|---|
| legacy 單卡渲染 | ✅ DEV 真實資料實跑(10 筆全數渲染、無重複列) |
| group 兩層形狀 parse | ✅ 實打 API(GET .../executions 200,欄位與契約逐項比對相符) |
| 四支新端點路徑可達 | ✅ 實打(皆回業務 404 而非路由 404) |
scheduled_at 時區來回 |
✅ 實跑轉換(本地 02:00 → +08:00 → UTC 18:00 → 讀回 02:00) |
| 確認框清單(立即/排程/過期) | ✅ 邏輯實跑(過期正確顯示「立即」) |
| 多台群組卡片(partial_failed 黃、彙總列) | ⚠️ 僅模擬 payload,無真實多台資料 |
| 重跑/單組取消/整組取消 按下去的行為 | ⚠️ 完全未 live 驗(僅驗端點可達與 UI 條件判斷) |
| 排程到點 agent 領走並轉 running | ⚠️ 完全未 live 驗 |
| 「立即開始」實際效果 | ⚠️ 完全未 live 驗 |
| 頁面實際點選操作(chip 增刪、卡片互動) | ⚠️ 未手測(FE dev server 由 user 啟動) |
npm run build:DEV |
✅ 通過 |
ddd-compliance-reviewer 掃過 diff:0 findings(含 CM-952/CM-954 警示未被弄丟的確認、 i18n 雙語鍵完整性、BE↔︎FE 端點與錯誤碼契約比對)。
BE 已重啟在 port 8000。取 token:
curl -s -X POST http://localhost:8000/api/1.0/login -H 'Content-Type: application/json' \
-d '{"username":"blsadmin","password":"<見 .env / 部署文件>"}'GET /api/1.0/remote-agents → 每列有 capabilities/is_online/last_seen_atGET /api/1.0/remote-agents?capability=detection_scan → 只回有該能力的(DEV 現有 1 台).2 資料層(純資料層,無 API 可打;驗收方式為看結構與跑測試):
# 兩張新表結構+擴欄
PGPASSWORD='<見 .env>' psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev \
-c '\d config.job_execution_detection_tool_agents' \
-c '\d compliance.detection_execution_groups' \
-c '\d compliance.detection_executions'
# migration 已登記
PGPASSWORD='<見 .env>' psql -h 192.168.50.188 -p 25432 -U cmmgr -d guidant_ai_dev \
-c "SELECT filename FROM public.schema_migrations WHERE filename LIKE '%fr067%'"
# 單元測試
pytest test/test_detection_group_status.py test/test_job_execution_detection_tool_agent_domain_service.py -q.3 綁定設置(BE 已實跑九情境全過;FE 頁面操作待手測——FE dev server 依慣例由 使用者啟動):
FE 手測(npm run dev 後開任務設置頁,檢測工具型任務):
BE API(已驗過,迴歸時可再打):
# 寫兩組 → 讀回兩組(每組帶 agent_name/agent_online)
curl -s -X PUT ".../grc/project/<proj>/job/<job>" -H "Authorization: Bearer $TOK" \
-H 'Content-Type: application/json' -d '{"tool":{"uid":"<tool>","agent_assignments":[
{"agent_uid":"<a1>","scan_targets":{"hosts":"10.1.0.0/24"}},
{"agent_uid":"<a2>","scan_targets":{"hosts":"10.2.0.5"}}]}}'已驗九情境:寫兩組讀回兩組/帶 uid 改一組/只送一組(另一組軟刪)/整台換掉(flush 順序)/只改任務名不帶欄位(分派保留)/空陣列清空/移除後加回同一台/同 agent 重複 擋 400/agent_uid 空擋 400。綁定 create 分支另經 job_type 來回切換驗過。
.4 執行展開與收口(執行紀錄群組化與重跑/取消鈕屬 .5 T-5.3,現階段驗收需直接打 API + 查 DB;分派設定現在可直接用 .3 的 UI 或綁定 API 建,不必再插 SQL):
total_count=2)+2 筆 execution(各帶 group_uid/assignment_uid) +2 張 agent_tasks(agent_id 各釘各台)offline、 零工單零執行紀錄running、任務未完成;第二台也完成 → group succeeded+closed_at 有值,auto 模式任務自動完成partial_failed、任務不自動完成running、 closed_at 清空 → 成功後整組 succeeded+auto 觸發完成;對成功的組 rerun → 409cancelled、 失聯那台進 failed,不整個報錯;group 收口為失敗類、不觸發 auto 完成total_count=1、assignment_uid 為 NULL)grep -i "409\|Traceback" log/app.log 應無任務狀態 衝突;agent 側不該收 5xxpytest test/test_detection_multi_agent_dispatch.py -q # 31 例.5 BE 面 + .7 BE 面(BE 已重啟在 port 8000;FE 尚未做,故執行紀錄 group 化與排程 UI 需直接打 API 驗):
TOK=$(curl -s -X POST http://localhost:8000/api/1.0/login -H 'Content-Type: application/json' \
-H 'X-Tenant-Id: 102' -d '{"username":"blsadmin","password":"<見 .env / 部署文件>"}' \
| python3 -c "import sys,json;print(json.load(sys.stdin)['data']['access_token'])")curl -s "http://localhost:8000/api/1.0/detection-tools/jobs/<有執行紀錄的 job>/executions" \
-H "Authorization: Bearer $TOK" -H 'X-Tenant-Id: 102' | python3 -m json.toolagent_name/is_latest/scan_targets)。舊資料 is_legacy: true、單筆一組。agent_assignments[].scheduled_at: "2026-08-25T02:00:00+08:00" → DB 應存 2026-08-24 18:00+00(台北→UTC);只改任務名的 PUT → 排程保留;送 null → 真的清成 NULL。SELECT ... WHERE (scheduled_at IS NULL OR scheduled_at <= now()) 應查不到它。ubuntu-lab-03 在線可驗): 設一組 2 分鐘後的排程 → 按開始執行 → execution 應為 scheduled 狀態、工單帶 scheduled_at;心跳期間 agent 領不到;時間到下一次心跳領走並轉 running。POST /detection-tools/execution-groups/{g}/assignments/{a}/start-now → 工單 scheduled_at 清成 now、execution 轉 running;對非排程中的組打 → 409(DETECTION_TOOLS_409015)。pytest test/test_detection_scheduling.py test/test_detection_execution_grouping.py -q # 34 例.env 的 AGENT_HEARTBEAT_INTERVAL_SEC=60(非預設 300)→ 在線門檻是 180 秒,不是 design.md 舉例的 900 秒。程式讀設定不寫死,兩者都對;驗收時別 拿 900 去對。ubuntu-lab-03(2026-08-24 起,0.2.28 交付包 systemd 原生形態;123 舊 agent 已刪)。capabilities [file_storage, detection_scan]。 agent 排錯前先 systemctl list-units | grep guidant 確認形態,別假設 compose—— 設定檔在 /etc/guidant-agent/agent.env。2026-08-24-fr067-7-per-assignment-scheduling.sql)同樣只套 DEV, 已入 schema_migrations + manifest.tsv。⚠️ scripts/check_migration_manifest.sh 仍會 FAIL——因為有兩支既有未登記檔(2026-08-18-cm1283-*/2026-08-20-cm1317-*,非本 arc 產生,commit 早於本 arc),本 arc 新增的兩支都已登記。guidant_ai_dev),STG/POC 未套等放行——開發期 DEV 領先屬正常狀態, 不是待修問題。上版前置:放行後依序套 STG/POC 並做三環境 schema_migrations diff。9ae28f68 已上 origin/main,剩 user 驗 SPEC 內容 (站起在 localhost:8890)。兩本客戶手冊(docs/user-manual/onprem/ 主產品 10 章+agent/ 檢測 Agent 11 章, 白話版 abe0ae5f,各有 html 版)內容停在 1.15.0 / agent 0.2.28 時代,v1.16.0 已出貨、發布前要對齊:版號字串(guidant-ai-1.15.0.tar.gz→1.16.0、agent 0.2.28 →0.2.30,含安裝輸出示例);FR-067 多 agent 分派若牽動客戶操作面(agent 手冊 「一台 vs 多台」敘述);升級章範例(1.14→1.15 改 1.15→1.16)。更新後 md+html 兩份都要重 build。