# 指示書BJ — 制御盤のログ（指示書AY）の点検で出た指摘を直す（2026-11-05）

**担当**: Cursor。監督（Claude Code）がレビューする。
**前提**: develop の `ae30f4a` 以降（指示書AY の実装 `3c7bcc8`〜`ae30f4a` が入った版）。
**着手時の先端**: `git log --oneline -1` の ID を書く: `ae30f4a`。`ae30f4a` と違っても、その間のコミットが資料（`docs/`・`.claude/`）だけなら、本書の行番号はそのまま使える。
確かめ方: `git diff --stat ae30f4a..HEAD -- . ':!docs' ':!.claude'` が★何も出さない★こと。何か出たら、そのファイルの行番号を開いて確かめ直す。
★行番号はすべて `ae30f4a` 時点の実物で監督が確かめた値★。作業中にずれたら、書いてある文言で探す。
★作業の前に必ず読むこと★:
- `docs/handoff/指示書AY_制御盤のログをD-Secログ画面から取り出す_20261019.md` の §3 表5（R43・R44）・段階7-2・段階9-2・§4 の 4-23
- `components/features/ds-logs/DeviceLogsModal.tsx` の `apiErrorMessage()`（:81-115）・一覧の取得（:260-319）・`startExport()`（:385-421）
- `lib/ds-agent/relay.ts` の `submitListResult()`（:457-465）・`pushChunk()`（:512-563）・`touch()`（:424-426）・`markSeen()`（:307-315）

---

## 0. ユーザーの指示（原文）と回答

- 「完了しました、確認してください」（指示書AY の実装の確認）。監督の点検（査読2観点・fixtures の画面・ローカルDB での偽制御盤・本物の `agent.py`）で、下の指摘が出た
- 指摘のうち「制御盤から形の崩れた一覧が来たとき、画面はどうするか」を問うた。選択肢は2つ:
  - A: すぐ 502 にして「制御盤の結果の形が不正です」と出す
  - B: 今のまま、15秒待ってから「応答がありません」と出す
  - ユーザーの回答: ★「Aで、指示書を書いてください」★（2026-11-05）

## 1. 何を直すか（全体像）

| # | 重さ | 何が起きているか（監督が確かめた事実） | ★直した後★ |
|---|---|---|---|
| J1 | 中 | モーダルが `status 0` をすべて「取り消し」とみなして黙る（`DeviceLogsModal.tsx:89`）。`apiFetch` はネットワークの失敗でも `status 0` を返す（`lib/api/client.ts:112-121`）。種類を変えた瞬間に通信が切れると、エラーは出ず、★前の条件の一覧が新しい結果のように残る★ | ★取り消し（`AbortError`）だけ黙る★。それ以外の `status 0` は「通信に失敗しました。時間をおいて再度お試しください」を出し、一覧を空にする |
| J2 | 中 | 制御盤が形の崩れた一覧を `…/result` に送ると、制御盤には 400 が返るだけ。ブラウザは15秒待って 504「制御盤から応答がありません」になり、原因が分からない。指示書AY は段階7-2（502）と表5 R43（状態は変えない）が食い違っていて、Cursor は R43 を採った | ★ユーザー回答 A★: 制御盤には今までどおり 400 `result_invalid`。★同時にブラウザの一覧を 502 `agent_error`（`details.agent_code = ["result_invalid"]`）で終わらせる★。画面は「制御盤の結果の形が不正です」 |
| J3 | 中 | `app/api/ds-agent/__tests__/routes.test.ts:197` が、全テストを同時に走らせたときだけ落ちる（2026-11-05 監督の実行: 4,260件中1件。単独では3回とも通過）。`waitUntilConnected()` が「`flush()` を100回」で打ち切るため、負荷で I/O が遅れると間に合わない | 実時間の上限（5秒）で待つ。`end-to-end.test.ts:91-96` の同じ関数も |
| J4 | 中 | JP/EN を切り替えると、モーダルのボタンが最大33px 動く（fixtures の画面、1440×900 で監督が実測。下の表）。指示書AY 段階11-2 の「モーダルのボタンの位置が動かない」を満たさない | 期間4つ・並べ替え2つ・トークン2つのボタンを ★JP と EN の長いほうの幅で固定★し、位置も幅も動かない |
| J5 | 低 | 制御盤が種類の `key` に空文字や `,` を入れても受理する（`lib/ds-logs/validate.ts:235-237`）。`key: ""` なら画面は `types=,app` で取り直して毎回 422、`key: "a,b"` なら `a` と `b` の2種類に分かれてその種類のファイルが黙って消える | `key` は ★1文字以上・`,` を含まない・前後に空白が無い★。違えば一覧全体を不正（J2 の 502） |
| J6 | 低 | 一覧の `file_id` に長さの上限が無い（`validate.ts:242-243`）。エクスポートの本体は各1024文字まで（`:39`・`:176`）なので、1025文字以上だと一覧には出るのにエクスポートで 422 になる | 一覧でも ★1〜1024文字★（同じ定数 `FILE_ID_MAX_LENGTH`）。違えば一覧全体を不正 |
| J7 | 低 | prepared の `total_bytes: 0` を受理するが、0バイトのチャンクは不正なので、そのエクスポートは決して完了しない（何も送られなければ30秒で 504。0バイトのチャンクや complete が来ればすぐ 502。本物の `agent.py` はチャンク0個なら complete を送る） | `total_bytes` は ★1以上★（本物の常駐プログラムのテスト用サーバー `fake_dwatch.py:631` も1以上） |
| J8 | 低 | 前のチャンクの POST が保留中に次のチャンクが来ると、`pendingPush` と `flowTimer` を上書きする（`relay.ts:556-562`）。先の POST は返らず、45秒後に孤立したタイマーが、まだ流れ中のエクスポートを `flow_timeout` で落とす（45秒以内に完了していれば実害なし。査読役がスクリプトで再現） | 保留中に次のチャンクが来たら ★`chunk_out_of_order` でエクスポートを失敗にする★。先の POST は `export_cancelled`（410）で返る |
| J9 | 低 | `ds_agent.connected` のログの `agent_version` がいつも null（2026-11-05 の通し確認で偽制御盤・本物とも）。認証（`requireAgent()` → `touch()`）が先に「接続」を記録し、版は後の `poll()` で入るため | `requireAgent()` が `X-Agent-Version` を読んで `touch()` に渡す。本物の常駐プログラムは全リクエストにこのヘッダを付けている（`agent.py:796`） |
| J10 | 低 | モーダルを開くたびに一覧の問い合わせが2回飛ぶ。1回目は `types` なしで取り、`selectedTypes` を全種類で埋めると `queryString` が変わって取り直すため（`DeviceLogsModal.tsx:185`・`:286-289`） | ★全種類が選ばれているときは `types` を付けない★（API は省略＝全種類）。開いたときの問い合わせは1回 |
| J11 | 低 | テストの抜け: (a) 「ディスクに書かない」のスパイが一覧の往復だけ（`end-to-end.test.ts:267-289`）。エクスポートの中継（prepared → chunks → complete → 受け取り）の間を見ていない (b) ログのイベントに `file_id`・ファイル名・トークンが入らないことを、手書きのサンプルでしか見ていない（`lib/logger/__tests__/ds-agent-events.test.ts`） (c) zip の中のパスが `<種類>/<ファイル名>` であることのテストが無い（指示書AY M17） | 3つともテストを足す（§4 表T） |
| J12 | 低 | 開始日が終了日より後のとき、画面は「日付の範囲が不正です」を出す（`DeviceLogsModal.tsx:268`）。指示書AY 表2 T9 は「開始日は終了日以前にしてください」 | 逆順のときは「開始日は終了日以前にしてください」。それ以外の不正（日付として読めない）は今のまま |

### J4 の実測（監督。fixtures・1440×900・DS-002 のモーダル。JP の位置 − EN の位置）

| ボタン（JP / EN） | x のずれ | 幅のずれ | EN の幅 |
|---|---|---|---|
| 直近1時間 / Last 1 hour | 0 | −7 | 85 |
| 直近3時間 / Last 3 hours | −7 | −13 | 94 |
| 直近24時間 / Last 24 hours | −20 | −13 | 101 |
| 全て / All | −33 | +7 | 40 |
| 日付 / Date | 0 | −1 | 51 |
| 種類＋日付 / Type + date | −2 | −3 | 89 |
| 発行（作り直し） / Issue (regenerate) | 0 | −9 | 129 |
| 失効 / Revoke | −9 | 0 | 120 |
| ✕・エクスポート・閉じる | 0 | 0〜1 | — |

## 2. API（★先に `d-watch-api.yaml` を直す。CLAUDE.md §6-1★）

### 2-1. `POST /ds-agent/commands/{command_id}/result`（J2）

- 本体の形が誤っていたら（`parseAgentListResult()` が `ok: false` になるもの全部。W2〜W8・W9 の error の形の誤り・W10・R44）:
  1. ★relay の新しいメソッド `rejectListResult(serial, commandId)`★ を呼ぶ。中身は `failCommand()` の list の分岐（`relay.ts:500-505`）と同じで、コードは `"result_invalid"`。★対象は kind が list の指示だけ★（export の指示や見つからないときは何もせず `"not_found"`）。期限切れは `"gone"`
  2. ★戻り値によらず★、制御盤への応答は今までどおり 400 `result_invalid`（`parsed.error`）
- ブラウザの `GET /ds-devices/{serial_number}/logs` は ★502 `agent_error`（`details.agent_code = ["result_invalid"]`）★ で終わる（今の `agent_error` の経路そのまま。ルートは変えない）
- ★その後に正しい結果を送っても受け取らない★: 指示は消えているので 404 `not_found`（`commandNotFound()`。`lib/ds-agent/agent-request.ts:47-49`。★新しいコードを作らない★）
- ★prepared・complete・fail の本体の形の誤りは今のまま★（400・状態は変えない。表5 R43）。今回ユーザーに問うたのは一覧だけ。準備中に形の崩れた prepared が来たときの動き（50秒で 504）は変えない

### 2-2. 検証の強化（J5・J6・J7）

- 一覧の `types[].key`: ★1文字以上・`,` を含まない・前後に空白が無い（`key.trim() === key`）文字列★。`label` は今のまま（文字列）。空白だけの `key`（`" "`）は、画面が `types= ` を送ってサーバーの trim で「省略＝全種類」になり、絞り込めなくなるため
- 一覧の `items[].file_id`: ★1〜1024文字★（`FILE_ID_MAX_LENGTH` を共有する。数字を直書きしない）
- prepared の `total_bytes`: ★1以上の整数★（今は0以上）

### 2-3. `d-watch-api.yaml` で直す箇所（G1: ★grep で関係する語を全部探す★）

- `POST /ds-agent/commands/{command_id}/result`（:2046 付近の説明）: 「1件でも形が違えば結果全体を不正とする(400)」の後に「★ブラウザの一覧は 502 `agent_error`（`agent_code` は `result_invalid`）で終わり、その指示は消える（その後の結果は 404）★」を足す
- `DsResultInvalid`（:2701）: 「状態は変えない(D25 ④)」を「★形の誤りは、`/result` ではブラウザの一覧を 502 で終わらせ（指示書BJ）、prepared・complete・fail では状態を変えない(D25 ④)。complete の値の不一致（合計・SHA-256）はエクスポートを失敗にする★」に直す（★今の文も complete の値の不一致について誤っている★ — `completeExport()` は `endExport()` でエクスポートを失敗にする）
- `GET /ds-devices/{serial_number}/logs` の 502: `DsAgentError`（:2691）に「形の不正は `result_invalid`」が既にあるので★足さない★
- 一覧のスキーマ `DsLogType` / `DsLogFile`（:3641・:3649 付近）: `key` に `minLength: 1` と `pattern: "^[^,\s](?:[^,]*[^,\s])?$"`（1文字以上・`,` を含まない・前後に空白が無い。J5）、`file_id` に `maxLength: 1024`。★この2つのスキーマはブラウザ向けの `DsLogListResponse` と共有している★ — 矛盾はしない（制御盤から来た値をそのまま返すので）。その旨を `description` に1文足す
- prepared のスキーマ `DsAgentPrepared`（:3769 付近）の `total_bytes`: `minimum: 1`。★ブラウザ向けの `DsLogExportResponse.total_bytes`（:3704 付近）も `minimum: 1`★（prepared の値をそのまま返す）。`DsAgentComplete.total_bytes`（:3777 付近）も `minimum: 1` にそろえる（prepared と一致しないと失敗するので、0 は来ても必ず失敗する）
- チャンクの 409 の説明（:2112 付近「seq が連番でない・send の前」）に「★前のチャンクの 204 を待たずに次を送った（J8）。このとき保留中だった前のチャンクの POST は 410 `export_cancelled` で返る★」を足す
- `X-Agent-Version`: 今は `GET /ds-agent/commands` にしか定義が無い。J9 で認証の通る6本すべてが読むので、★残りの5本（result・prepared・chunks・complete・fail）にも同じパラメータを足す★（`$ref` で共有してよい）。説明は「接続の記録に使う。形の合わない値は無視する（拒否しない）」
- 確かめ方: `grep -n "result_invalid\|total_bytes\|file_id\|key:" d-watch-api.yaml` の該当行を報告に貼る

## 3. 画面と中継の直し方

### 3-1. エラーの文言（J1・J2・J12）— ★純粋関数に移して表Eでテストする★

- `DeviceLogsModal.tsx` の `apiErrorMessage()`・`detailStrings()`・`formatFileSize()`・`formatDateTime()`（:60-115）を ★新しいファイル `lib/ds-logs/modal-text.ts` に移す★（中身は下の変更以外そのまま）。モーダルはそこから import する
- `apiErrorMessage()` の `status 0` の扱い:
  - ★`e.cause` が `name === "AbortError"` のもの（`DOMException` か `Error`）なら null★（モーダルを閉じて準備の POST を取り消したとき。指示書AY 4-23・M5）
  - それ以外の `status 0` は ★`t("通信に失敗しました。時間をおいて再度お試しください")`★（`lib/i18n.ts:551` に EN もある既存の文言。新しく作らない）
- `agent_error` の分岐に ★`code === "result_invalid"` → `t("制御盤の結果の形が不正です")`★ を足す（`too_large` と同じ形）。`lib/i18n.ts` に EN `"The controller returned a malformed result"` を足す
- J12: 一覧の取得の effect の日付の分岐（:262-274）で、`queryString === null` かつ日付が理由のとき、★開始日と終了日の両方が読めて開始 ＞ 終了なら「開始日は終了日以前にしてください」★、それ以外は今の「日付の範囲が不正です」。i18n に EN `"The start date must be on or before the end date"` を足す。判定は `parseDate()`（定義は `lib/clean-logs/query.ts:63`。`presets.ts:7` が import 済み）を使い、モーダルに日付の比較を直書きしない（関数 `dateRangeError(start, end): "reversed" | "invalid" | null` を `lib/ds-logs/presets.ts` に足し、表Dで固定する）。★モーダルでは `dateRangeError()` の結果を `useMemo` で作り、一覧の effect の依存に入れる★（effect の中で `period` を直接読むと `react-hooks/exhaustive-deps` に当たる）
- ★一覧の失敗で文言が出るときは、今のコード（:302-305）のとおり一覧を空にする★（J1 で通信の失敗もここに入る）
- ★`agent_error`（J2 の `result_invalid` を含む）のときは見出しを「接続中」にする★（`setConnected(true)`。制御盤は結果を返しているので接続している）。今は `agent_offline` のときしか見出しを直さない（:306-310）ため、開いて最初の一覧が 502 だと、見出しは初期値の「未接続／最終接続 -」のまま本文だけ「制御盤の結果の形が不正です」になる。`last_seen_at` は分からないので今の値のまま。通信の失敗（J1）・`agent_timeout` では見出しを変えない（直前の値のまま）
- ★4MB を超える一覧（W8）も「制御盤の結果の形が不正です」になる★（原因は大きさだが、制御盤側の不具合であることは同じなので許容。文言を分けない）

### 3-2. 開いたときの問い合わせを1回にする（J10）

- `queryString`（:181-192）で、★`selectedTypes` が `types` の全部と一致するとき（件数が同じで、全部含む）は `types` を付けない★。`selectedTypes === null`（まだ種類を知らない）も付けない（今のまま）。★`useMemo` の依存に `types` を足す★
- ★判定は純粋関数にして表Q で固定する（必須）★: `lib/ds-logs/query.ts`（既存。サーバー側の `queryDeviceLogs()` と同じファイル）に `deviceLogTypesParam(knownTypeKeys: readonly string[], selected: ReadonlySet<string> | null): string | null`（`null` ＝ `types` を付けない）を足し、`queryString` から使う
- ★制御盤に種類が1つも無いとき（`types` が空配列）は「種類を1つ以上選んでください」を出さない★: `noTypeSelected` を `selectedTypes !== null && selectedTypes.size === 0 && types.length > 0` にする（今は種類が無いだけで「選んでください」が出る）。種類が無いときは `types` を付けずに取り、0件の表示になる
- 帰結: 開く → 1回目（`types` なし）→ 全種類で埋める → `queryString` は変わらない → 取り直さない
- ★種類を1つ外して戻したとき（全種類に戻る）も `types` なしになる★。API の応答は同じ（省略＝全種類）

### 3-3. ボタンの幅を固定する（J4）

- ★`components/ui/FilterButton.tsx` は触らない★（他の6画面と共有。§8）
- モーダルの期間・並べ替えの `FilterButton` を、モーダルの CSS（`DeviceLogsModal.module.css`）の入れ物（例 `<span className={styles.fixedSlot}>`）で包み、`.fixedSlot > button { min-width: …; }` で幅を固定する。トークンのボタンは既存の `.tokenButton`（:317-330。今は `min-width: 120px`）を直す。★`.tokenButton` は「コピー」（`DeviceLogsModal.tsx:801-807`）にも付いている★ので、コピーも同じ幅に広がる。それでよいが、トークンの表示欄（`.tokenReveal`）の中で折り返さないことを §6 で見る
- 幅は ★JP と EN の長いほう＋余白★ にする。§1 の実測から、★期間4つ 112px・並べ替え2つ 100px・トークン（コピーを含む3つ）140px★（実測 `Last 24 hours` 101px・`Type + date` 89px・`Issue (regenerate)` 129px に、拡大率やフォントの差で動かないよう約10px の余裕）。★4つの期間ボタンを同じ幅にそろえてよい★（そのほうが位置が動かない）
- 確かめ方は §6 の J4（JS で測ってずれ0）

### 3-4. 中継（J2・J8・J9）

- J2: `lib/ds-agent/relay.ts` に `rejectListResult(serial: string, commandId: string): SubmitResult` を足す（`Relay` の型と `relay.ts:423` の実装。`lib/ds-agent/__tests__/fake-relay.ts` は本物を偽の時計で包むだけなので直す箇所は無い）。`submitListResult()` の隣に置き、★list の指示だけ★を `{ kind: "agent_error", code: "result_invalid" }` で解決して `commands` から消す。`ds_logs.list` のイベントは今の `agent_error` の経路で `agent_code: "result_invalid"` が出る（★新しいイベントを作らない★）
- J8: `pushChunk()` の `seq` の照合（:520）の★前★に、`rec.pendingPush !== null` なら `endExport(rec, "failed", "chunk_out_of_order", { agentCode: "chunk_out_of_order" })` して `"out_of_order"` を返す。`endExport()` が先の POST を `"cancelled"` で返し、`flowTimer` も消す（:370-371・:388-389。今のまま）
- J9:
  - `app/api/ds-agent/commands/route.ts:16`・`:27-28` の版の読み取り（正規表現 `/^[0-9A-Za-z.+-]{1,32}$/`）を ★`lib/ds-agent/require-agent.ts` の関数 `agentVersionOf(request): string | null`★ に移し、commands ルートもそこから import する。★`agent-request.ts` には置かない★（`agent-request.ts:8` が既に `require-agent.ts` を import しているので、置くと import が循環する）
  - `requireAgent()`（`lib/ds-agent/require-agent.ts:21`）は `getRelay().touch(serial, agentVersionOf(request))` を呼ぶ。`touch()` の型を `touch(serial: string, agentVersion: string | null): void` にし、中で `markSeen(device(serial), agentVersion)`
  - ★形の合わない版は今までどおり無視する（null。拒否しない）★
  - ★`touch()` の `agentVersion` は省略可（`?`）にしない★ — 省略すると `undefined` が `markSeen()` の `!== null` を通って版に入る。必須にして、呼んでいる箇所を全部直す（罠7）

## 4. 期待値表（★監督が確定。テストはこれをそのまま使う★）

### 表E — `lib/ds-logs/modal-text.ts`（新規テスト `lib/ds-logs/__tests__/modal-text.test.ts`。`t` は渡した文字列をそのまま返す関数）

| # | 入力 | 期待 |
|---|---|---|
| E1 | `ApiFetchError`（status 0・cause は `new DOMException("x", "AbortError")`） | `null` |
| E2 | `ApiFetchError`（status 0・cause は `Object.assign(new Error("x"), { name: "AbortError" })`） | `null` |
| E3 | `ApiFetchError`（status 0・cause は `new TypeError("Failed to fetch")`） | `"通信に失敗しました。時間をおいて再度お試しください"` |
| E4 | `ApiFetchError`（status 0・cause なし） | E3 と同じ |
| E5 | 502 `agent_error`・`details: { agent_code: ["result_invalid"] }` | `"制御盤の結果の形が不正です"` |
| E6 | 502 `agent_error`・`agent_code: ["too_large"]` | 今の文言（`{max}` は `100 MB`） |
| E7 | 502 `agent_error`・`agent_code: ["x"]` | `"制御盤でエラーが発生しました（x）"` |
| E8 | 409 `agent_offline`・`details: { last_seen_at: [] }` | `"制御盤が接続されていません（最終接続: -）"` |
| E9 | `ApiFetchError` でない値（`new Error("x")`） | `"データの取得に失敗しました"` |

`ApiFetchError` の作り方は `lib/api/client.ts:47-60` のコンストラクタ（`{ status, code, serverMessage, details?, cause? }`）。

### 表D — `dateRangeError(start, end)`（`lib/ds-logs/presets.ts`。`lib/ds-logs/__tests__/presets.test.ts` に足す）

| # | start | end | 期待 |
|---|---|---|---|
| D1 | `"2026-10-19"` | `"2026-10-18"` | `"reversed"` |
| D2 | `"2026-10-18"` | `"2026-10-19"` | `null` |
| D3 | `"2026-10-18"` | `"2026-10-18"` | `null`（同じ日はよい） |
| D4 | `"2026-02-30"` | `"2026-03-01"` | `"invalid"`（存在しない日付） |
| D5 | `null` | `"2026-10-18"` | `null` |
| D6 | `"2026-10-19"` | `null` | `null` |

★`periodFromDates()` の T5〜T9 は変えない★（T9 は今までどおり null）。

### 表R — 中継とルート（`lib/ds-agent/__tests__/relay.test.ts`・`app/api/ds-agent/__tests__/routes.test.ts`）

| # | 手順 | 期待 |
|---|---|---|
| R43′ | 一覧を依頼 → 制御盤が `/result` に `types` の無い本体を送る | 制御盤には 400 `result_invalid`。★ブラウザの一覧は `{ kind: "agent_error", code: "result_invalid" }`（ルート経由なら 502・`details.agent_code = ["result_invalid"]`）★。その後に正しい結果を送ると 404 `not_found`（`commandNotFound()`。`lib/ds-agent/agent-request.ts:47-49`。★新しいコードを作らない★） |
| R43″ | `routes.test.ts:228-249` の `it.each` の全行（W2〜W4b の :229-232 を含む）（今「状態は変えない（後から正しい結果を受け取れる）」） | ★期待を R43′ に変える★（テストの名前も「ブラウザの一覧は 502 で終わる」に） |
| R43‴ | export の指示の id で `/result` に形の崩れた本体 | 400 `result_invalid`。★エクスポートは続く★（`rejectListResult()` は list だけ） |
| R45 | `key: ""` の種類を含む一覧 | `parseAgentListResult()` が 400 `result_invalid`（→ R43′ の流れ） |
| R46 | `key: "a,b"` の種類を含む一覧 | R45 と同じ |
| R46b | `key: " "`・`key: " app"` の種類を含む一覧 | R45 と同じ。`key: "a b"`（中の空白）はよい |
| R47 | `file_id` が1025文字の項目を含む一覧 | R45 と同じ。1024文字はよい |
| R48 | prepared の `total_bytes: 0` | 400 `result_invalid`。`1` はよい |
| R49 | エクスポートで seq0 を送る → ブラウザが読まないうちに seq1 を送る | seq1 は 409 `chunk_out_of_order`。★seq0 の POST は `"cancelled"`（ルートなら 410 `export_cancelled`）で返る★。ブラウザのストリームはエラー。`ds_logs.export_failed` の `reason` は `chunk_out_of_order`。★45秒後に `flow_timeout` のイベントが出ない★ |
| R50 | 認証の通ったリクエスト（`/result` など poll 以外）に `X-Agent-Version: 1.2.3` → 未接続から接続へ | `ds_agent.connected` の `agent_version` が `"1.2.3"` |
| R51 | R50 で `X-Agent-Version: bad version!` | `agent_version` は null（拒否しない） |

### 表T — テストの補強（J3・J11）

| # | 何を | どう |
|---|---|---|
| T-a | `waitUntilConnected()`（`routes.test.ts:106-112`・`end-to-end.test.ts:91-96`） | ★`vi.waitFor(() => { if (!connected) throw … }, { timeout: 4000, interval: 5 })`★ のように実時間の上限で待つ（★テストの既定の時間切れが 5000ms（`vitest.config.ts` に `testTimeout` の指定なし）なので、待ちは 4000ms にする★）。偽の時計（`fake.at()`）は進めない |
| T-b | 「ディスクに書かない」（`end-to-end.test.ts:267-289`） | 同じスパイのまま、★一覧の往復の後に、エクスポート（`POST …/exports` → prepared → `GET download_url` → chunks 2つ以上 → complete → 本文を読み切る）まで通してから★ 0回を確かめる |
| T-c | ログのイベント | `end-to-end.test.ts` の一連（一覧 → エクスポート成功 → もう1回エクスポートして途中で失敗させる。例: 制御盤が `fail` を送る）で出たイベントを★2か所から集める★: relay のイベント（`fake.events`。`ds_agent.connected`・`export_completed`・`export_failed` はここ）と、ルートのログ（`vi.mocked(logEvent).mock.calls`。`ds_logs.list`・`export_prepared`・`export_started` はここ）。(1) ★列挙した6種類がそれぞれ1回以上出た★こと (2) どのイベントの `JSON.stringify` にも、使った `file_id`・ファイル名・トークン・SHA-256 が★含まれない★こと。★`file_id` と名前は長い目印の値にする★（例 `FILEID-SENTINEL-7f3a…`・`SENTINEL-DIR/SENTINEL-NAME.log`。短い `"f1"` は偶然一致・見逃しが起きる） |
| T-d | zip の中のパス（指示書AY :784 の「段階7-4 のテストで見る」） | `lib/fixtures/ds-device-logs.ts` の `FIXTURE_EXPORT_ZIP_BASE64` を読んで中央ディレクトリの名前を取り出し、`["api_client/api_client.log", "postcondition_security/postcondition_security.log.2026-10-18"]` と一致する（監督が node で確認した値。順番も） |
| T-e | J10 | ★必須★。表Q（下）を `lib/ds-logs/__tests__/query.test.ts` に足す。画面でも §6 で確かめる |

### 表Q — `deviceLogTypesParam(knownTypeKeys, selected)`（`lib/ds-logs/query.ts`。`lib/ds-logs/__tests__/query.test.ts` に足す）

| # | knownTypeKeys | selected | 期待 |
|---|---|---|---|
| Q1 | `["a","b","c"]` | `null` | `null`（付けない） |
| Q2 | `["a","b","c"]` | `{a,b,c}` | `null`（全種類） |
| Q3 | `["a","b","c"]` | `{a,b}` | `"a,b"`（★`selected` を `knownTypeKeys` の順に並べる★） |
| Q4 | `["a","b","c"]` | `{c,a}` | `"a,c"` |
| Q5 | `["a","b"]` | `{a,x}`（件数は同じだが `b` を含まない） | `"a,x"`（全種類と誤認しない。罠5。知らない key は `knownTypeKeys` にある key の後ろに `selected` の順で並べる。サーバーは知らない種類を0件として返す） |
| Q6 | `[]` | `{}` | `null`（種類が無い。モーダルの `noTypeSelected` も `types.length > 0` の条件で偽になる） |

★G3: 足したテストは、一度わざと壊して赤になるのを確かめてから戻す★（例: J8 の判定を消す → R49 が赤、`rejectListResult()` を呼ばない → R43′ が赤、T-b は `relay.ts` の `pushChunk()` に一時的に `(await import("node:fs")).writeFileSync("sabotage.tmp", "")` を足す → ★T-b のテスト名で赤★になること（ソースの静的検査のテストも赤になるのは想定内）。★T-b のスパイは `mockImplementation(() => undefined)` にして本物を呼ばない★（`vi.spyOn` だけだと本物が呼ばれてファイルが書かれる）。壊した変更はコミットしない。

## 5. 段階（この順で。段階ごとにコミット）

0. 着手前: 冒頭の「着手時の先端」を書く。`git status --short` で ★他のセッションの未コミットの変更★（今は `components/features/errors/ErrorContentPicker.tsx`・`lib/errors/error-content-label.ts` とそのテスト）を確かめ、★触らない・コミットに含めない★。`npx vitest run` の件数（前）を控える（`ae30f4a` で 169ファイル・4,260件）
1. テストの土台: T-a（J3）。★全件を3回走らせて3回とも全件通過★を確かめてからコミット
2. `d-watch-api.yaml`（§2-3）
3. 検証と中継: J5・J6・J7（`validate.ts`）、J2（`relay.ts`・`/result` ルート）、J8、J9（`agent-request.ts`・`require-agent.ts`・commands ルート・`relay.ts`）＋表R
4. 画面: `lib/ds-logs/modal-text.ts`（新規）＋表E、`dateRangeError()`＋表D、`deviceLogTypesParam()`＋表Q、`DeviceLogsModal.tsx`（J1・J2 の文言と見出し・J10・J12）、`DeviceLogsModal.module.css`（J4）、`lib/i18n.ts`（EN 2つ）
5. テストの補強: T-b・T-c・T-d（T-e＝表Q は段階4 で）
6. 資料: `docs/logging-design.md`（`ds_agent.connected` の `agent_version` が認証の時点で入ること。`ds_logs.list` の `agent_code` に `result_invalid` が出ること）、`docs/decisions.md` の D-179 の末尾（指示書AY の「実装の記録」の後ろ・D-180 の見出しの前。★D-179 の本文と AY の記録は書き換えない★）に §9 を足す
7. tsc・lint・`npm run build`・vitest 全件（★3回★）の件数と、段階ごとのコミットを報告（★報告は日本語★）

## 6. 画面で確かめること（fixtures）

手順は指示書AY 段階11-2 と同じ（`USE_FIXTURES=true npm run build` → `USE_FIXTURES=true` と試験用の `AUTH_TOKEN` を環境変数で渡して `npx next start -p 3100`。★:3000 は触らない★。終わったら :3100 を止める。★止める前に、`Get-NetTCPConnection -LocalPort 3100 -State Listen` の `OwningProcess` のコマンド行が自分の `next start -p 3100` であることを確かめる★ — 2026-11-05、親を止めても子の node が :3100 に残った。コマンド行は `next\dist\bin\next" start -p 3100` のように `next` の後に引用符が入るので、`'next start'` で照合すると外れる）。アプリ内ブラウザを使う（Edge は使わない）。

- J4: `resize_window` 1440×900。★測る条件をそろえる: 開いた直後（期間は直近24時間・1ページだけ）・トークンの値は表示していない状態★（ページ送りの「前へ／次へ」は `components/ui/Pagination.tsx` で触れない約束なので、2ページ以上だと JP/EN で幅が変わる。測る対象に入れない）。DS-002 のモーダルを JP で開き、`javascript_tool` で `[role=dialog]` の中の `tbody` 以外のボタンの `getBoundingClientRect()` の x・y・幅を控える → 閉じて EN に切り替えて開き直し、同じく測る → ★全ボタンで x・y・幅のずれが 0★（1px 以内は可）。ダーク/ライトでも崩れない。D-Sec ログの表に横スクロールが出ない
- J10: モーダルを開いた直後の `GET /api/ds-devices/DS-002/logs` が★1回★（`read_network_requests` で数える）。種類を1つ外すと `types=` 付きで1回、戻すと `types` なしで1回
- J12: 開始日 2026-10-19・終了日 2026-10-18 で「開始日は終了日以前にしてください」。EN で英文
- J1: `javascript_tool` で `window.fetch` を差し替えて一覧の取得だけ `Promise.reject(new TypeError("Failed to fetch"))` にし、種類を1つ外す → 「通信に失敗しました。時間をおいて再度お試しください」が出て、一覧が空になる。差し替えを戻して種類を戻すと一覧が出る
- 今までの動き（一覧・絞り込み・0件の表示・全選択と 100MB の警告・エクスポートの `fetch` 確認・トークンの発行と失効・DS-001 の未接続・編集中は「ログ」が押せない・Esc・Tab・背景のクリック）が変わらない

★J2・J8・J9 は fixtures の画面では起きない★（fixtures の偽制御盤は形の正しい一覧しか返さない）。単体テスト（表R）で確かめ、画面での確認はしない。

## 7. 既知の罠

1. ★`rejectListResult()` を `failCommand()` で代用しない★ — `failCommand()` は export の指示ならエクスポートを終わらせる。`/result` に export の id が来たときにエクスポートを壊す（R43‴ が赤になる）
2. ★`/result` の 400 の応答本文は今の `parsed.error` のまま★。`rejectListResult()` の戻り値（`not_found` など）で 404 や 410 に変えない（制御盤には「本体が悪い」ことだけを返す）
3. ★`AbortError` の判定は `cause.name` で行う★。`instanceof DOMException` だけだと、Node のテスト環境やブラウザの実装差で外れる（E2）
4. ★`status 0` をすべて文言にしてしまうと、モーダルを閉じたときの取り消しで、閉じた後に state を触る★（4-23）。E1・E2 が null であること
5. ★J10 で「全種類」を `types.length === selectedTypes.size` だけで判定しない★ — 種類の一覧が変わった（制御盤の設定が変わった）ときに、知らない key を含む集合を全種類と誤認する。★件数が同じ、かつ `types` の key を全部含む★で判定する
6. ★J4 で `FilterButton` に `className` の引数を足さない★（§8 で触らない約束。入れ物の CSS で包む）
7. ★J9 で `touch()` の引数を足すと、`touch()` を呼んでいる箇所（`require-agent.ts` とテスト）が型エラーになる★。`grep -rn "\.touch(" lib app` で全部直す（★`relay.test.ts` に7か所・`app/api/ds-devices` の `route.test.ts` に2か所★）。★vitest は型を検査しないので、直し漏れは `tsc` でしか分からない★
8. ★J7 で `total_bytes` を1以上にすると、既存のテスト `lib/ds-logs/__tests__/validate.test.ts:409`（「total_bytes=0・file_count=1（下限）」は通る）が赤になる★。指示書AY 段階7-2 の「0以上」（`fake_dwatch.py` との差は実害なし）を本書で取り消す。そのテストの期待を R48 に合わせて直し、報告に書く
9. ★J3 の `vi.waitFor` で偽の時計を進めない★（`fake.at()` を待ちの中で呼ぶと、タイマーが登録される前に時刻が進み、別の理由で落ちる。`routes.test.ts:103-104` のコメントのとおり）
10. ★T-d の zip の名前の順は、zip の中央ディレクトリの順★。並べ替えて比べない（順番も監督の確認値どおり）

## 8. 共通ルール

- G1〜G4（指示書AZ §2）: API の挙動が変わったら YAML の影響を受ける箇所を全部直す／古い挙動を書いたコメントを残さない（★少なくとも次の全部★: `result/route.ts:5` の「状態は変えない」、`relay.ts:125` の `touch` の説明、`DeviceLogsModal.tsx:81` の「status 0（取り消し）は null」、`lib/ds-logs/validate.ts:305` の「`total_bytes` は0以上の整数」と `:2-3` の見出しの「W1〜W11」（R45〜R48 を足す）、`lib/ds-agent/__tests__/relay.test.ts` の `:2` の「R1〜R43」・`:52` の表の R43・`:953` のテスト名・`:976` のコメントの「状態は変えない（正しい結果を受け取れる）」（★relay を直接呼ぶので緑のままだが、記述が J2 と食い違う★）、`lib/ds-agent/require-agent.ts:6`（版も入れる）、`lib/logger/events.ts:512` の `agent_code` の説明（`result_invalid` は D-Watch が付けるコード）。`prepared/route.ts:5`・`fail/route.ts:5` の「状態は変えない」は正しいので直さない）／テストは壊して赤を確かめる／決定の記録にコミット・件数の前後・画面確認（出所つき）・本書と違う判断を書く
- 期待値表（§4）を先にテストにする。数字・順番を変えない。合わないと思ったら実装を変えずに監督に報告
- `git add -A` 禁止。★ファイルパスを明示してコミットする★（段階ごと）。PR とマージは daichi のレビューの後
- 触らない: `components/ui/`（`FilterButton.tsx` を含む）・`docs/handoff/指示書AY_*`・`docs/handoff/設計_制御盤ログの取り出し_*`・`components/features/errors/ErrorContentPicker.tsx`・`lib/errors/error-content-label.ts` とそのテスト・`prisma/`・`CLAUDE.md`・`C:\Users\daich\dev\dwatch-log-agent\`（制御盤のプログラム。読むだけ）
- DB に書き込まない。:3000 を起動・停止しない

## 9. 決定の記録（段階6 で `docs/decisions.md` の D-179 の末尾に足す。★新しい D 番号は取らない★）

```markdown
- ★点検の指摘の直し（2026-11-05。指示書BJ）★:
  1. ★制御盤から形の崩れた一覧が来たら、ブラウザの一覧をすぐ 502 `agent_error`（`agent_code` `result_invalid`）で終わらせる★（ユーザー回答 2026-11-05「A」）。制御盤には今までどおり 400。指示書AY の段階7-2（502）と表5 R43（状態は変えない）の食い違いは、一覧については段階7-2 を採る。prepared・complete・fail は R43 のまま
     - 却下: B（15秒待って 504「応答がありません」）— 原因が制御盤側の形の誤りだと分からない
  2. 画面は取り消し（`AbortError`）だけ黙り、通信の失敗は文言を出して一覧を空にする
  3. 制御盤の一覧の `key` は1文字以上で `,` を含まず前後に空白が無い・`file_id` は1〜1024文字・prepared（とブラウザ向けの応答・complete）の `total_bytes` は1以上（指示書AY 段階7-2 の「0以上」を取り消す）
  4. チャンクの POST が保留中に次のチャンクが来たら `chunk_out_of_order` でエクスポートを失敗にする（並列送信は約束違反。孤立したタイマーで後から落ちるのを防ぐ）
  5. 接続のイベントの版は認証の時点で `X-Agent-Version` から入れる
  6. モーダルを開いたときの一覧の問い合わせは1回（全種類のときは `types` を付けない）。制御盤に種類が無いときは「種類を1つ以上選んでください」を出さない。JP/EN でモーダルのボタンの位置と幅を動かさない（期間・並べ替え・トークンのボタンを固定幅に）
  6b. 一覧が `agent_error` で失敗したときは見出しを「接続中」にする（制御盤は結果を返している）
  7. ★残したもの★: `eslint-disable` が2行（指示書AY 4-19 は1行。どちらも `set-state-in-effect` で実害なし）・段階11-1 の grep がテストのファイル（スパイと `mkdtemp`）と型のコメント（「`validation_error` は足さない」）に当たる（指示書AY の grep の書き方の問題。テストが必要とする記述）・種類を素早く切り替えたときに制御盤への一覧の依頼が溜まりうる（推測。待ち行列5件を超えると 429。実機で起きたら直す）
  8. ★実装の記録★: コミット／テスト件数（前 169ファイル・4,260件 → 後）／fixtures の画面確認（当方の fixtures 由来）／本書と違う判断
```
