FR-056.4 執行編排 + 結果轉證據 + 通知 + 歷史 Implementation Plan

For agentic workers / runner: REQUIRED SUB-SKILL: superpowers:subagent-driven-development / executing-plans. - [ ] checkbox syntax.

依賴:FR-056.1 / .2 / .3(收尾整合階段)。 母案設計見 design.md

Goal: 把 FR-056.1~.3 串成完整可用流程:任務執行抽屜「開始執行」→ 派工 → Agent 掃描回報 → 報告自動存回 job_evidences(source=DETECTION_TOOL)+ detection_executions 落執行史 + 發 mail 通知負責人 → 依完成模式 auto 自動完成 / manual 留任務給人工。執行歷史可查、報告可下載(沿用既有下載端點)。

Architecture: 結果轉證據照抄 ImportDriveFileHandler(DRIVE_SYNC)三段式:報告存 Minio → 組 JobEvidenceEntity(source="DETECTION_TOOL", created_user="detection-agent@system") → domain service .add()。通知照 job_batch_complete_servicethreading.Thread + send_mail_notification完成模式不新增任何 JobStatus 狀態——auto 呼叫既有 complete_job,manual 什麼都不做(任務留 PROCESSING,人工用既有「完成任務」按鈕)。報告下載沿用既有 /file/download/<uid>,零新端點。

Tech Stack: 同前。


§1

前置:關鍵決策(runner 必讀)

項目 定案 來源
完成模式語意 不是狀態。auto=系統掃完自動呼叫既有 complete_job;manual=系統不做事,任務留 PROCESSING,人工按既有「完成任務」 user 澄清 2026-07-26
不動 jedi_flow_engine JobStatus(TODO/PROCESSING/COMPLETED/CANCEL)零新增,complete_job 前置檢查不改 user 澄清(原「待覆核新狀態」方案作廢)
轉證據模板 ImportDriveFileHandler.handle() 三段式,新系統帳號常數 detection-agent@system Explore
source 新值 job_evidences.source CHECK constraint 加 DETECTION_TOOL(需 migration) Explore
evidence_type(D11 定案) 用既有 FILE不用 REPORT。REPORT 型別會牽動「其他判斷證據檔案的地方」,改動面大;第一版用 FILE 最安全,要區分檢測報告類證據未來另開需求 user 拍板 2026-07-26
通知模板 job_batch_complete_service._send_batch_summary_notification(threading.Thread + send_mail_notification,收件人查 task_assignees/ParticipantRole) Explore
報告下載 不新建端點,沿用 /file/download/<uid>(UploadFileDownloadRoute),report 存 upload_files 拿 uid 即可 Explore
完成模式讀取來源 FR-056.2 建的 config.job_execution_detection_tools.completion_mode FR-056.2
重掃 PROCESSING 下既有「再執行」能力(再建一筆 agent_task),每次一筆 detection_executions 獨立紀錄 design

§2

Task 分佈(對應 design T-4.x)

Task 對應 產出 依賴 Repo
Task 1 T-4.1 job_evidences source 加 DETECTION_TOOL + detection_executions 表 + 轉證據 handler FR-056.3 BE
Task 2 T-4.2 執行編排(開始執行串派工 / 完成模式分岔 / 通知) Task 1 + FR-056.2 BE
Task 3 T-4.3 FE 任務執行抽屜(開始執行 / 重掃 / 執行歷史 / 下載) Task 2 FE

§3

Task 1(T-4.1): source 加值 + detection_executions 表 + 轉證據 handler

Files:

  • Create: scripts/sql/2026-07-26-fr056-4-job-evidences-detection-source.sql(改 CHECK constraint)
  • Create: scripts/sql/2026-07-26-fr056-4-detection-executions.sql(執行史表)
  • Create: infra/domain/app 全層 detection_execution 模組
  • Create: app/detection_tools/service/detection_result_handler.py(轉證據,mirror ImportDriveFileHandler)
  • Modify: infra/flow_engine/models/job_evidence.py(comment 更新 source 值)
  • Test: test/test_detection_result_handler.py
-- Date: 2026-07-26
-- FR-056.4 job_evidences.source 加 DETECTION_TOOL
ALTER TABLE compliance.job_evidences DROP CONSTRAINT IF EXISTS chk_job_evidences_source;
ALTER TABLE compliance.job_evidences ADD CONSTRAINT chk_job_evidences_source
    CHECK (source IN ('SYSTEM_UPLOAD','DRIVE_SYNC','DETECTION_TOOL'));
INSERT INTO public.schema_migrations(filename, note) VALUES
  ('2026-07-26-fr056-4-job-evidences-detection-source.sql', 'FR-056.4 job_evidences source 加 DETECTION_TOOL') ON CONFLICT (filename) DO NOTHING;
-- Date: 2026-07-26
-- FR-056.4 檢測執行歷史(一次執行一筆,可查可下載)
CREATE TABLE compliance.detection_executions (
    id                 BIGSERIAL PRIMARY KEY,
    uid                VARCHAR(36)  NOT NULL UNIQUE,
    tenant_id          BIGINT       NOT NULL,
    agent_task_uid     VARCHAR(36),                         -- soft-ref → compliance.agent_tasks.uid
    job_execution_uid  VARCHAR(50)  NOT NULL,               -- soft-ref → job_executions.uid
    detection_tool_id  BIGINT       NOT NULL,
    started_at         TIMESTAMPTZ, finished_at TIMESTAMPTZ,
    status             VARCHAR(20)  NOT NULL DEFAULT 'running',  -- running/succeeded/failed
    summary            JSONB,                               -- 摘要(發現幾項等)
    report_file_id     BIGINT,                              -- soft-ref → upload_files.id(原始報告,供下載)
    evidence_id        BIGINT,                              -- soft-ref → job_evidences.id(轉出的證據)
    error_message      TEXT,
    created_user       VARCHAR(255), updated_user VARCHAR(255),
    created_at         TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at         TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE compliance.detection_executions IS 'FR-056.4 檢測執行歷史(每次執行一筆,重掃不覆蓋)';
GRANT SELECT, INSERT, UPDATE, DELETE ON compliance.detection_executions TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE compliance.detection_executions_id_seq TO cm_app;
ALTER TABLE compliance.detection_executions ENABLE ROW LEVEL SECURITY;
CREATE POLICY detection_executions_tenant_isolation ON compliance.detection_executions
    USING (current_setting('app.is_super_admin', TRUE) = 'true'
           OR tenant_id = ANY (string_to_array(current_setting('app.allowed_tenant_paths', TRUE), ',')::BIGINT[]));
INSERT INTO public.schema_migrations(filename, note) VALUES
  ('2026-07-26-fr056-4-detection-executions.sql', 'FR-056.4 檢測執行歷史表') ON CONFLICT (filename) DO NOTHING;

套進 DEV + psql 驗證。

test/test_detection_result_handler.py:mock file_upload + evidence domain service,驗證 handler 收到 agent 結果後:(a) 報告存 Minio;(b) JobEvidenceEntitysource="DETECTION_TOOL"created_user="detection-agent@system";(c) detection_executions 落一筆 status=succeeded、report_file_id/evidence_id 有值。

app/detection_tools/service/detection_result_handler.py

DETECTION_AGENT_USER = "detection-agent@system"

class DetectionResultHandler:
    def __init__(self, file_upload_service, job_evidence_domain_service,
                 detection_execution_domain_service, job_execution_domain_service):
        ...

    @transaction
    def handle_result(self, agent_task, report_file_bytes, summary):
        """agent 回報成功後呼叫:報告存 Minio → 寫 job_evidences(DETECTION_TOOL) → detection_executions 落史。"""
        job = self._job_exec.get_job_execution(JobExecutionQueryEntity(uid=agent_task.job_execution_uid))
        # (1) 存 Minio(mirror ImportDriveFileHandler)
        fs = FileStorage(stream=io.BytesIO(report_file_bytes), filename=f"detection_report_{agent_task.uid}.xml",
                         content_type="application/xml")
        save_dir = f"JOB_EVIDENCES/{job.uid}"
        uploaded = self._file_upload.upload_files([fs], DETECTION_AGENT_USER, save_dir)[0]
        # (2) 寫 job_evidences(source=DETECTION_TOOL)
        evidence = self._evidence.add(JobEvidenceEntity(
            main_workflow_execution_id=job.main_workflow_execution_id,
            workflow_execution_id=job.workflow_execution_id,
            job_execution_id=job.id,
            evidence_type="FILE",            # D11 定案:用 FILE(REPORT 改動面大,未來另開需求)
            source="DETECTION_TOOL",
            file_id=uploaded.id,
            content_hash=getattr(uploaded, "checksum", None), hash_algorithm="MD5",
            description=f"[檢測工具] {agent_task.uid}",
            created_user=DETECTION_AGENT_USER, updated_user=DETECTION_AGENT_USER,
        ))
        # (3) detection_executions 落史
        self._exec.write_success(agent_task.uid, job.uid, report_file_id=uploaded.id,
                                 evidence_id=evidence.id, summary=summary, user=DETECTION_AGENT_USER)
        return evidence

注意: 不改 job_evidence_service.add_job_evidence(那是 user 手動上傳路徑)——照 DRIVE_SYNC 慣例直接呼叫 domain service .add() 自訂 source。

git commit -m "feat(fr056): detection result → evidence handler + execution history (T-4.1)"

§4

Task 2(T-4.2): 執行編排(開始執行 / 完成模式分岔 / 通知)

Files:

  • Create/Modify: app/detection_tools/service/detection_orchestration_service.py(開始執行 + 完成模式分岔)
  • Modify: app/remote_agent/service/remote_agent_service.pyreceive_result(FR-056.3 留的 TODO 掛鉤點:成功時觸發 Task 1 handler + 本 Task 的完成/通知)
  • Modify: api/detection_tools/routes/detection_tool_route.py(「開始執行」endpoint)
  • Test: test/test_detection_orchestration.py

驗證:(a) 開始執行 → 建 agent_task(pending) + detection_executions(running);(b) 結果成功 + completion_mode=auto → 呼叫 complete_job;(c) completion_mode=manual → 不呼叫 complete_job(任務留 PROCESSING);(d) 兩情況都發 mail 給負責人。

@transaction
def start_execution(self, job_uid, curr_user, tenant_id):
    binding = self._jedt_domain.get_one(job_execution_uid=job_uid)   # FR-056.2 綁定
    if not binding:
        raise BadRequestError(DetectionToolsErrorCode.DETECTION_TOOL_NOT_BOUND)
    # 選一台有 detection_scan capability 的 agent(tenant 內)
    agent = self._pick_agent(tenant_id)
    # 解密該租戶工具憑證,隨派工下發(走 mTLS,見 FR-056.3 憑證下發決策)
    creds = self._resolve_credentials(binding, tenant_id)
    task = self._agent_task_domain.create(AgentTaskEntity(
        uid=str(uuid.uuid4()), tenant_id=tenant_id, agent_id=agent.id,
        job_execution_uid=job_uid, detection_tool_id=binding.detection_tool_id,
        params={**binding.tool_params, "_credentials": creds}, status="pending",
        created_user=curr_user, updated_user=curr_user))
    self._exec_domain.create(DetectionExecutionEntity(
        uid=str(uuid.uuid4()), tenant_id=tenant_id, agent_task_uid=task.uid,
        job_execution_uid=job_uid, detection_tool_id=binding.detection_tool_id,
        status="running", started_at=datetime.now(), created_user=curr_user))
    return {"agent_task_uid": task.uid}

FR-056.3 Task 2 的 receive_result 成功時,於此觸發(把 TODO(FR-056.4) 換成實作):

def _on_scan_succeeded(self, agent_task, report_bytes, summary):
    evidence = self._result_handler.handle_result(agent_task, report_bytes, summary)  # Task 1
    binding = self._jedt_domain.get_one(job_execution_uid=agent_task.job_execution_uid)
    self._notify_owner(agent_task.job_execution_uid, evidence)                          # 發信
    if binding.completion_mode == "auto":
        self._workflow_execution_service.complete_job(...)   # 既有 complete_job,PROCESSING→COMPLETED
    # manual:什麼都不做,任務留 PROCESSING,等人工手動完成

通知照 job_batch_complete_service pattern:

def _notify_owner(self, job_uid, evidence):
    targets = self._get_job_notify_targets(job_uid)   # 查 task_assignees / ParticipantRole email
    subject = _("detection_scan_completed_subject")
    for info in targets:
        if info["email"]:
            content = _("detection_scan_completed_content") % {"nickname": info["nickname"]}
            threading.Thread(target=notification_service.send_mail_notification,
                             kwargs={"to": info["email"], "subject": subject, "content": content, "is_html": True}).start()

掃描失敗(receive_result failed 分支):detection_executions 標 failed(Task 1 的 write_failure),任務留 PROCESSING(不 silent,FE 執行歷史看得到 failed),可重試。

POST /detection-tools/jobs/<job_uid>/execute(掛 @jwt_required() + 專案參與者守門;這是 GRC 專案資源,用軸③ project-role 或既有 job 操作守門,非軸④ capability——runner 對齊既有 job 操作 endpoint 的守門方式)。

手測:建 detection 任務綁 auto → 執行 → 掃完任務自動 COMPLETED + 收信 + 證據入庫;建另一任務綁 manual → 執行 → 掃完任務仍 PROCESSING + 收信 + 證據入庫,手動按完成才 COMPLETED。

git commit -m "feat(fr056): execution orchestration + completion mode + notify (T-4.2)

開始執行串派工;掃完轉證據 + 發信;completion_mode=auto 呼叫既有 complete_job,manual 留任務 PROCESSING 等人工。零新增 JobStatus。"

§5

Task 3(T-4.3): FE 任務執行抽屜

FE repo。 任務執行抽屜(既有元件,runner 先定位 detection 任務執行時開的抽屜 Vue 檔)。

git commit -m "feat(fr056): detection task execution drawer (T-4.3)"

§6

完成後收尾(等 user 下令)


§7

決策狀態

  1. evidence_typeD11 定案:用 FILE(不用 REPORT)。REPORT 會牽動其他判斷證據檔案的地方、改動面大,第一版 FILE 最安全,未來要區分檢測報告類證據另開需求。
  2. 「開始執行」守門軸(runner 逕行):這是 GRC 專案內的 job 操作,對齊既有 job 操作(complete_job 等)的守門方式(專案參與者/角色),非工具管理的軸④ capability。runner 看既有 job route 守門照做——實作對齊,非阻塞決策。

§8

附錄:範本來源

  • 轉證據三段式:app/cloud_integration/service/handlers/import_drive_file_handler.py::handle(DRIVE_SYNC)
  • job_evidences source constraint:scripts/sql/2026-04-24-job-evidences-add-drive-source.sql
  • 通知:app/grc/service/job_batch_complete_service.py::_send_batch_summary_notification + notification_service.send_mail_notification
  • 收件人查詢:infra/grc/repository/job_batch_complete_query.py::get_batch_notification_data(task_assignees/ParticipantRole)
  • 既有完成任務:app/flow_engine/service/workflow_execution_service.py::complete_job(不改,auto 模式直接呼叫)
  • 報告下載:api/uploadfile/routes/uploadfile_route.py::UploadFileDownloadRoute/file/download/<uid>,沿用)
  • DDD 九層:同 FR-056.1 附錄 A