# 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`](./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_service` 的 `threading.Thread + send_mail_notification`。**完成模式不新增任何 JobStatus 狀態**——auto 呼叫既有 `complete_job`，manual 什麼都不做（任務留 PROCESSING，人工用既有「完成任務」按鈕）。報告下載沿用既有 `/file/download/<uid>`，零新端點。

**Tech Stack:** 同前。

---

## 前置：關鍵決策（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 |

---

## 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 |

---

## 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`

- [ ] **Step 1: migration — job_evidences source 加 DETECTION_TOOL**

```sql
-- 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;
```

- [ ] **Step 2: migration — detection_executions 執行史表**

```sql
-- 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 驗證。

- [ ] **Step 3: DDD 全層 detection_execution 模組**（複製 remote_agent 骨架，tenant-scoped）。

- [ ] **Step 4: 寫 failing test（轉證據三段式 + source=DETECTION_TOOL）**

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

- [ ] **Step 5: 轉證據 handler（照抄 ImportDriveFileHandler 三段式）**

`app/detection_tools/service/detection_result_handler.py`：
```python
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。

- [ ] **Step 6: 跑 test 通過** → PASS。

- [ ] **Step 7: Commit**
```bash
git commit -m "feat(fr056): detection result → evidence handler + execution history (T-4.1)"
```

---

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

**Files:**
- Create/Modify: `app/detection_tools/service/detection_orchestration_service.py`（開始執行 + 完成模式分岔）
- Modify: `app/remote_agent/service/remote_agent_service.py` 的 `receive_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`

- [ ] **Step 1: 寫 failing test（auto vs manual 分岔 + 通知）**

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

- [ ] **Step 2: 「開始執行」app service**

```python
@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}
```

- [ ] **Step 3: 完成模式分岔 + 通知（掛在 FR-056.3 的 receive_result 成功分支）**

FR-056.3 Task 2 的 `receive_result` 成功時，於此觸發（把 TODO(FR-056.4) 換成實作）：
```python
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：
```python
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），可重試。

- [ ] **Step 4: 「開始執行」endpoint**

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

- [ ] **Step 5: 跑 test 通過 + 手測 auto/manual 兩路**

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

- [ ] **Step 6: Commit**
```bash
git commit -m "feat(fr056): execution orchestration + completion mode + notify (T-4.2)

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

---

## Task 3（T-4.3）: FE 任務執行抽屜

**FE repo。** 任務執行抽屜（既有元件，runner 先定位 detection 任務執行時開的抽屜 Vue 檔）。

- [ ] **Step 1: 「開始執行」按鈕** → 打 `POST /detection-tools/jobs/<uid>/execute`，loading 狀態，成功 toast。

- [ ] **Step 2: 執行歷史列表** → 打 `GET /detection-tools/jobs/<uid>/executions`（Task 2 補此唯讀 endpoint），顯示每筆：時間、狀態（running/succeeded/failed）、摘要、報告下載連結（`/file/download/<report_file.uid>`，沿用既有下載）。

- [ ] **Step 3: 手動重新執行** → 執行歷史區「重新執行」按鈕 = 再打一次 execute（PROCESSING 下既有能力），新增一筆歷史，不覆蓋。

- [ ] **Step 4: manual 模式完成** → **不新增 UI**。manual 任務掃完仍 PROCESSING，執行人員用抽屜既有的「完成任務」按鈕手動完成（跟一般任務一模一樣）。若證據不可用，可用既有「上傳證據」補一份、或重新執行，再按完成。

- [ ] **Step 5: i18n 補文案**（start_execution、execution_history、re_execute、status_running/succeeded/failed 等，zh-tw+en）。

- [ ] **Step 6: 手測** → auto 任務執行後自動完成；manual 任務執行後停 PROCESSING、可看歷史、可重掃、可下載報告、可手動完成。

- [ ] **Step 7: Commit（FE repo）**
```bash
git commit -m "feat(fr056): detection task execution drawer (T-4.3)"
```

---

## 完成後收尾（等 user 下令）

- [ ] **FE error-code.json 同步**（Task 1/2 新增的 error code，三語系 zh-tw/en/zh-cn）。
- [ ] STG/POC 套三支 migration（source constraint + detection_executions + 前階段的）。
- [ ] 更新任務執行頁 spec（`docs/specs/current/`）。
- [ ] Notion FR-056.4 子卡標「修正待驗證」+ 回寫；母案 FR-056 標整體完成。
- [ ] e2e 測試計畫（test repo）——完整 auto/manual 兩路 happy path + 失敗重試。

---

## 決策狀態

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

---

## 附錄：範本來源

- 轉證據三段式：`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
