# 設計 — 制御盤（ラズパイ）のログを D-Watch の画面から取り出す（兼・制御盤の常駐プログラム仕様書）

**作成日**: 2026-10-19 / **作成者**: Claude Code（監督）/ **決定**: D-179（A案・中継方式。`docs/decisions.md:12934` 以降）

**読み手**:

| 読み手 | 読むところ |
|---|---|
| D-Watch の実装者・監督 | 全章。特に 2・3・4・6〜9 |
| ★制御盤（ラズパイ）の Python 担当（DFA 社内）★ | ★1・2・4.2〜4.4・5・7.2・8・11★。★本リポジトリのコードは読まなくてよい★（本書だけで常駐プログラムが作れるように書いてある） |

★**本書は監督の設計を正とする**★（設計ブリーフ 2026-10-19 ＋ ★監督判断 第2ラウンド M1〜M11（2026-10-19）。ブリーフと食い違うところは M が優先★）。
本文中の「（M1）」などは、その監督判断の番号。D-Watch 側の実装の細部（テストの期待値表・段階）は指示書AY に書く。
★「（本書の補い）」「（補い）」と付けた箇所は、監督の設計に書かれていない細部を本書で埋めたもの★。★2026-10-19 に監督がすべて採用した★（変えたいときは監督に相談する）。
★`complete` / `fail` の 410 のコードは `export_cancelled`★（export が失効・取り消し済み。`result` / `prepared` の 410 は `command_expired`。監督判断 2026-10-19）。

---

## 1. 目的と前提

### 1.1 ユーザー要望（2026-10-19）

- 制御盤（ラズパイ）の中のログファイルを、★ターミナルを使わずに★取り出したい
- PUDU のログのエクスポート画面のように、★種類（ファイル名の prefix）・期間で絞り、一覧から選んでダウンロード★したい
- ★サーバーにはアップロードしたくない★

### 1.2 ユーザー回答（2026-10-19）

| # | 回答 |
|---|---|
| 1 | 「アップロードしたくない」＝ ★サーバーに保存しなければ、通過はよい★ |
| 2 | ★制御盤のソフトは DFA が作っている★。言語は ★Python★ |
| 3 | ログを取り出すのは ★DFA の社内だけ★（顧客・現場には見せない） |
| 4 | ★A案（自前の中継）をすぐ進める★（D を先に作る案は採らない） |
| 5 | ログのフォルダ・ファイル名の規則は ★「後で教える」★ → 設定ファイルで与え、★本書では空欄★にする（→ ★2026-10-19 に実機で実測して埋めた。§1.6★） |

出所: D-179（`docs/decisions.md:12946-12948`・`:12986-12990`）。

### 1.3 現状の運用

- Raspberry Pi Connect（connect.raspberrypi.com、`rpi-connect` 2.7.0）の ★Remote shell から `cat` でログを見ている★。全台 Online（ユーザー申告）

### 1.4 Raspberry Pi Connect の調査（公式資料。2026-10-19）

| 項目 | 内容 | 出典 |
|---|---|---|
| 機能 | Screen sharing / Remote shell / Remote update の3つ | https://www.raspberrypi.com/documentation/services/connect.html |
| ファイル転送・ポート転送・コマンド実行の公開 API | ★無い★ | 同上 |
| 組織向けの Management API | デバイス・認証キー・更新物の管理だけ | https://www.raspberrypi.com/documentation/services/connect-for-organisations.html |
| Remote shell から手元へ落とす方法 | ★テキストのコピー＆ペーストだけ★ | Raspberry Pi CTO のフォーラム回答（2025-04-28）https://forums.raspberrypi.com/viewtopic.php?t=387152 |
| Remote update | ★ベータ★。`.tar.zst` を上げると、中のスクリプトを ★root で★実行できる。上げた物は ★2時間で消える★ | 監督の調査（M9）。connect-for-organisations.html |
| 通信 | WebRTC。直接つながらないときは Raspberry Pi 社の中継を通る | connect.html |
| 組織向けの料金・権限 | 1台 月0.5ドル。Member / Administrator。監査ログ90日 | connect-for-organisations.html |

★**帰結: Pi Connect だけでは要望を満たせない**★。
Pi Connect は ★常駐プログラムの導入・更新に使う★（現地に行かずに済む。§5.12）。

### 1.5 D-Watch の制約（コード実測 2026-10-19。出所は構造＝リポジトリのコード）

| 制約 | 内容 | 出所 |
|---|---|---|
| Next.js の版 | 16.3.3。middleware は ★proxy に改名★（`proxy.ts`）。proxy は Node ランタイム固定 | `node_modules/next/package.json:3` |
| ★proxy は本体をメモリにためる★ | proxy が走るリクエストは、本体を clone して ★メモリにバッファ★する。★既定の上限は 10MB★。超えた分は ★切り捨て★られる。★クライアントにはエラーにならず、サーバーに警告が1行出るだけ★ | `node_modules/next/dist/docs/01-app/03-api-reference/05-config/01-next-config-js/proxyClientMaxBodySize.md:7-9`・`:71-76` |
| ★本体を最後まで受け取ってからハンドラが動く★ | proxy の後で本体の差し替え（`finalize()`）を待つ | `node_modules/next/dist/server/next-server.js:1197-1217`・`node_modules/next/dist/server/body-streams.js:52-118` |
| proxy の matcher | 静的ファイル以外の全パス（`/api` を含む） | `proxy.ts:112-114` |
| ★matcher で除外しない方針★ | 「matcher で削ると保護漏れの発見が難しくなる。関数の中で分岐する」 | `proxy.ts:106-107` |
| 判定の部品 | `lib/proxy/authenticate.ts`（判定するだけ・純粋・`deps` 引数でテスト可能）。順序は ★IP 制限（403）→ 認証（401 / redirect）★ | `lib/proxy/authenticate.ts:1-9` |
| 公開パス | `/login` と `/api/auth/login` だけ | `lib/proxy/authenticate.ts:25` |
| 認証 | 共有トークン（env `AUTH_TOKEN`）を cookie `dwatch_session` に入れる。`verifyToken` は `timingSafeEqual` | `lib/auth/cookie.ts:12`・`lib/auth/token.ts:10,22-40` |
| IP 制限 | `ALLOWED_IPS` は空・`ALLOW_ALL_WHEN_EMPTY = true`・`TRUSTED_PROXY_HOPS = 1`。`X-Forwarded-For` の ★末尾★から数える | `lib/config/allowed-ips.ts:24,35,63`・`lib/auth/client-ip.ts:36` |
| 動かし方 | `npm run build` → `npm run start`。Docker の可能性あり | `docs/deploy.md:65-67`・`:36-38` |
| プロセス数 | PM2 / cluster の設定は無い → ★1プロセス★ | 構造（リポジトリに該当の設定が無い） |
| nginx | 前段にある（XFF を追記する。D-077）。★設定はリポジトリに無い（TeamWorks 管理）★ | `docs/deploy.md` §4 |
| DFA 独自ストア | `createJsonStore`（`.tmp` → rename、壊れたら `.bak`）。例: `data/settings/ds-device-notes.json` | `lib/settings/json-store.ts:39`・`lib/settings/ds-device-notes.ts:14` |
| 制御盤の存在の確認 | `db.dsDeviceExists(serialNumber)`（DB は読むだけ） | `lib/db/index.ts:370` |
| ログ | `logEvent(level, payload)`。イベントは `lib/logger/events.ts` の型付き union に足す。名前は「名前空間.動作」 | `lib/logger/index.ts:113` |
| ストリーミング応答 | ★既存の例は無い★（CSV 出力は文字列を丸ごと返している） | 構造 |
| エラーの形 | `{ error: { code, message, details? } }`。401 は `unauthorized`、403 は `forbidden`、404 は `not_found`、★検証エラーは 422 `validation_failed`★（M2） | `proxy.ts:69,84`・`app/api/ds-devices/[serial_number]/note/route.ts:112` |
| 相関ID | 応答ヘッダ `x-request-id` | `lib/logger/context.ts:34` |

### 1.6 制御盤の環境（2026-10-19 実測）

★**`T000000002` は制御盤の開発機**★（ユーザー回答 2026-10-19）。当初は「開発機での変更が、本番機（各店舗の制御盤）へ毎日の git の取り込みで反映される」と理解した（ユーザーの言葉は「本番機が毎日特定の時間に git を除いて差分があれば merge します」。★「除いて」は「覗いて」の誤変換と解釈した★）。
★**本番機の実測（2026-10-19。§1.6.1）で、取り込みの中身が分かった**★: ★「merge」ではなく、毎日12:00に root で `git fetch origin master` → `git reset --hard origin/master`（`/home/dfa`）★。★反映されるのは「開発機の `/home/dfa` の変更」ではなく「master に push したもの」★（翌日12:00に全本番機へ。動くのはサービスの再起動の後）。§5.13・§11.1-9〜10・12。

★**出所: 実機の実測**★。2026-10-19 15:50 JST、監督が Pi Connect の Remote shell から ★読み取りだけのコマンド★で確かめた。対象は ★Pi Connect 上のデバイス名 `T000000002`★ の1台。ファイルの中身は先頭数行だけ、長い文字列は伏せて見た。
★git・権限・更新の仕組みの行（表の「★追記★」）は 2026-10-19 16時台 JST に同じ開発機で同じ方法（読み取りのみ）で確かめた★。URL の認証情報は伏せて表示した。★下の表は開発機の値★。本番機（Pi Connect 上の名前 `DFAS010003_アピタ金沢文庫`）は監督のツールの安全確認で操作が止められたため、★ユーザーが Remote shell で打って出力を貼った★（§1.6.1）。

| 項目 | 実測 |
|---|---|
| OS | Debian GNU/Linux 12（bookworm）。Raspberry Pi OS デスクトップ版 2024-11-19 イメージ・64bit |
| Python | 3.11.2 |
| ディスク | ルート 58G 中 12G 使用 |
| journald | 1022.3M |
| 時刻 | ★JST★（`date` が JST を表示） |
| ユーザー | `dfa` |
| 制御盤のプログラム | `/home/dfa/` 直下の Python（`gw_in_out_V2.py` / `post_condition_and_kick_action_V2.py` / `post_condition_security_V2.py` / `send_start_sig_V2.py` / `api_client.py` / `shared_utils.py` など） |
| systemd | `gw_in_out` / `post_condition_and_kick_action` / `post_condition_security`（いずれも `User=dfa`・`WorkingDirectory=/home/dfa/`・enabled・稼働中）と `get_interval_time`（static） |
| ★ログの出し方★ | `/home/dfa/shared_utils.py:33-48`。`LOG_DIR/<プログラム名>/<プログラム名>.log` に `TimedRotatingFileHandler(when='midnight', backupCount=7)` |
| 書式 | `'%(asctime)s -%(levelname)s - %(message)s'`（例: `2026-10-19 15:46:06,844 -INFO - …`。本文は日本語）。★INFO / WARNING / ERROR が1つのファイルに混ざる★ |
| ローテーション後の名前 | `<プログラム名>.log.YYYY-MM-DD` |
| ★backupCount=7 の注意★ | 「★ファイル7個★」であって7日ではない。制御盤が止まっていた期間があると ★7日より古い日付のファイルが残る★（実測で `2026-06-19` などが残っている） |
| ★ログのフォルダ★ | ★`/home/dfa/log/`（合計 1.2M）★。下の表の4つ |
| 権限 | ログのファイルは 644・フォルダは 755。★`/home/dfa` 自体は 700★（★追記★。専用ユーザーは中に入れない） |
| 認証情報らしきファイル | ★`/home/dfa` に `current_token.txt` などが 644 で置かれている★ |
| 複製 | `/home/dfa/dsecurity/` と `/home/dfa/d-s_control/`（root 所有）にも同じ作りの `shared_utils.py` がある（`d-s_control` 版は `when='h'`・`backupCount=168`）。★稼働中のサービスは `/home/dfa/` 直下のものだけ★で、これらの下に log フォルダは見当たらなかった |
| ★git（★追記★）★ | ★`/home/dfa` 全体が git リポジトリ★。origin は `https://git.d-sec.dfarobotics.com/git/develop/client_dsecurity.git`。最新コミット `db374ba 2026-06-23 Add gitignore for chromium`。★本番機の HEAD（`5efdb2b`）とは違う★（§1.6.1） |
| ★`.gitignore`（★追記★）★ | ★開発機の `/home/dfa/.gitignore` は `*.txt` / `.cache/` / `.config/`★（★`db374ba` で足したもので、本番機（master＝`5efdb2b`）には入っていない★。§1.6.1）。★master の `.gitignore` は `device_info.txt` / `current_token.txt`★（下の「別の clone」）。どちらでも `current_token.txt`・`device_info.txt` は git に入らない（配られない） |
| 追跡中のファイル（★追記★） | ★開発機の `/home/dfa` では 1260 個のうち 1210 個が `.cache/` と `.config/` の下★（ignore を足す前に追跡されたもの。Chromium のキャッシュ・設定など）。`git status` では `.bash_history`・`.Xauthority`・Chromium のデータ（IndexedDB 等）も追跡対象として変更ありと出る。★開発機のローカルの状態であり、master・本番機には入っていない★（★本番機の追跡ファイルは13個、`.cache/`・`.config/` の下は0個★。2026-10-19 17時台 本番機の実測。§1.6.1）。→ §11.3 |
| 別の clone（★追記★） | `/home/dfa/dsecurity/` は同じ origin の別の clone（最新 `5efdb2b 2026-06-23`）。その `.gitignore` は `device_info.txt` / `current_token.txt`。★`5efdb2b` は本番機の HEAD と同じ★（§1.6.1）→ ★master へ push しているのはこの clone と推測★（§11.1-12）。★`/home/dfa/dsecurity/.netrc`（git の認証情報が入る種類のファイル。中身は見ていない）が 644★ |
| root 所有のリポジトリ（★追記★） | `/home/dfa/d-s_control/` は root 所有のリポジトリ（`dfa` から git を打つと dubious ownership で止まる） |
| ★更新の仕組み（★追記★）★ | 開発機では `/etc/cron.d/d-sec` が ★毎分★ `dfa` で `/etc/cron.shell/update_dscontrol.sh` を起動する。★開発機にはこのスクリプトが無い★（毎分失敗している。推測）。★本番機は中身が違う（毎日12:00・root・スクリプトあり）★（§1.6.1） |
| systemd のタイマー（★追記★） | `get_interval_time.timer` がある（crontab の毎時起動とは別に、タイマーでも `get_interval_time` を起動している） |

`/home/dfa/log/` の中身:

| フォルダ | ファイル | 1日の大きさの目安 | 役割（★推測。ユーザー確認待ち★） |
|---|---|---|---|
| `api_client/` | `api_client.log` ＋ `api_client.log.YYYY-MM-DD` 6個 | 数KB〜210KB | 報告間隔（API の値。例 60）の取得と通信の有効・無効 |
| `postcondition_security/` | 同様に7個 | 数KB〜190KB | セキュリティ状態（例 新状態: 1）の変化・定期報告とその成否 |
| `postcondition_kick_action/` | 同様に7個 | 数KB〜240KB | 待機中のセコム状態の監視・清掃時刻・清掃の起動 |
| `send_start_sig/` | `send_start_sig.log`（0バイト・2026-06-19） | 0 | 清掃開始信号（空） |

出力はおおむね1分に1〜2行。

**設計上の帰結**:

| # | 帰結 |
|---|---|
| 1 | ★全部合わせても 1.2MB 程度★。`EXPORT_MAX_BYTES`（100MB）には余裕がある。★上限を下げる必要はない（仮のまま）★。一覧も ★30件弱★なので `truncated` はまず起きない（★本番機はもっと多いと推測。それでも500件前後で上限に十分収まる★。§1.6.1 #9） |
| 2 | ★種類はプログラムごと（＝フォルダごと）★。PUDU の画面の「ファイルプレフィックス」に相当する。§5.2 の `roots` に実値を入れた |
| 3 | ★常駐プログラムを専用ユーザーで動かすなら、`/home/dfa/log` を読める必要がある★。ただし `/home/dfa` には認証情報らしきファイルが 644 で置かれているので、★専用ユーザーに `/home/dfa` 全体の読み取りを与えない方法★を制御盤の担当が決める。★`/home/dfa` は 700 なので、`/home/dfa/log` に ACL を付けるだけでは届かない★（例: `/home/dfa` に通過（x）だけを ACL で与え `/home/dfa/log` に読み取りを与える／ログの出し先を `/var/log/dsecurity/` などへ移す）。→ §5.13・§11.1-6 |
| 4 | ★レベルは1つのファイルに混ざっている★。「エラーだけ取り出す」はファイル単位の取り出しの範囲外（必要なら将来の拡張）。→ §11.1-7 |
| 5 | journald（1GB）にもサービスの標準出力が入っている可能性がある。★今回は対象外（ファイルのログだけ）★。→ §11.1-8 |

#### 1.6.1 本番機の実測（2026-10-19）

★**出所: ユーザーが本番機で実行した結果**★。対象は ★Pi Connect 上の名前 `DFAS010003_アピタ金沢文庫`★ の1台。ユーザーが Pi Connect の Remote shell で ★読み取りだけのコマンド★（§11.1 の「本番機で確かめること」）を打ち、出力を貼った。`update_dscontrol.sh` は ★URL の認証情報を伏せて表示★した。

```
$ cat /etc/cron.d/d-sec
SHELL=/bin/bash
PATH=/sbin:/bin:/usr/sbin:/usr/bin
0 12 * * * root /etc/cron.shell/update_dscontrol.sh

$ （/etc/cron.shell/update_dscontrol.sh。認証情報を伏せて表示）
#!/bin/bash
cd /home/dfa
/usr/bin/git config --global --add safe.directory /home/dfa
/usr/bin/git fetch origin master
/usr/bin/git reset --hard origin/master

$ git -C ~ log -1 → 5efdb2b 2026-06-23 17:22:55 +0900
$ ls ~/log
api_client  interval.log  postcondition_security  sendtasksig.log  gw.log  postcondition.log  postcondition_security.log  gw_in_out.log  postcondition_kick_action  send_start_sig
$ 稼働中: gw_in_out.service / post_condition_and_kick_action.service / post_condition_security.service
```

稼働中の3つのサービスの説明はいずれも「…を実行 (異常終了時のみ再起動)」＝ ★`Restart=on-failure` と推測★（ユニットの中身は見ていない）。

**開発機と本番機の違い**:

| 項目 | 開発機（`T000000002`） | 本番機（`DFAS010003_アピタ金沢文庫`） |
|---|---|---|
| `/etc/cron.d/d-sec` の頻度 | ★毎分★ | ★毎日 12:00★ |
| 実行するユーザー | `dfa` | ★root★ |
| `/etc/cron.shell/update_dscontrol.sh` | ★無い★ | ★ある★（`cd /home/dfa` → `git fetch origin master` → `git reset --hard origin/master`） |
| `/home/dfa` の HEAD | `db374ba 2026-06-23 Add gitignore for chromium` | ★`5efdb2b 2026-06-23 17:22:55 +0900`★（＝開発機の別の clone `/home/dfa/dsecurity` の HEAD と同じ） |
| `~/log` の中身 | 4つのフォルダ（`api_client` / `postcondition_kick_action` / `postcondition_security` / `send_start_sig`） | ★同じ4つのフォルダ＋直下のファイル6個★（`gw.log` / `gw_in_out.log` / `interval.log` / `postcondition.log` / `postcondition_security.log` / `sendtasksig.log`）。★直下の6個は最終更新 2025-03-12〜2026-02-12 で、今は書かれていない（古い版の名残）★（17時台の追加の実測。下） |
| フォルダのディレクトリのエントリの大きさ | 4KB（4096） | ★16〜20KB★（`api_client` 16384・`postcondition_kick_action` / `postcondition_security` 20480。`send_start_sig` は 4096）（17時台の追加の実測） |
| git が追跡しているファイル | 1260 個（うち `.cache/`・`.config/` の下が 1210 個） | ★13 個（`.cache/`・`.config/` の下は 0 個）★（17時台の追加の実測） |
| 稼働中のサービス | `gw_in_out` / `post_condition_and_kick_action` / `post_condition_security` | 同じ3つ |

**分かったこと・帰結**:

| # | 内容 |
|---|---|
| 1 | ★反映の仕組みは「merge」ではなく「毎日12:00に root で `git fetch origin master` → `git reset --hard origin/master`（`/home/dfa`）」★ |
| 2 | ★本番機で追跡中のファイルを書き換えても、毎日12:00に master の内容へ戻される★（ローカルの変更は消える）。★追跡していないファイル・ignore されたファイルは残る★（`reset --hard` は untracked を消さない。`git clean` は無い）→ ログ（`~/log`）や `*.txt` は消えない |
| 3 | ★取り込みの後にサービスの再起動は無い★。新しいコードが動くのは再起動（OS の再起動か、異常終了で systemd が再起動したとき）の後 |
| 4 | ★root で reset するので、書き換わったファイルの所有者が root になりうる★（推測） |
| 5 | ★本番機の HEAD `5efdb2b` は開発機の `/home/dfa/dsecurity`（別の clone）の HEAD と同じで、開発機の `/home/dfa` の HEAD（`db374ba`）とは違う★ → ★master へ push しているのは開発機の `~/dsecurity` の clone で、開発機の `/home/dfa` 自体は master より先の（push していない、または別のブランチの）状態★と推測。★つまり「開発機の `/home/dfa` の変更がそのまま本番機に反映される」のではなく、「master に push したものが翌日12:00に本番機に反映される」★。どこで編集して push しているかは制御盤の担当に確認が要る（§11.1-12） |
| 6 | ★本番機のログは開発機と作りが違う★（直下にファイルが6個ある）。→ ★古い版の名残と分かった★（17時台の追加の実測。下の #7）。§11.1-13 は解消 |

**追加の実測（2026-10-19 17時台）**:

★**出所: ユーザーが本番機で実行した結果**★。対象は同じ ★`DFAS010003_アピタ金沢文庫`★ の1台。ユーザーが Pi Connect の Remote shell で ★読み取りだけのコマンド★（§11.1 の「本番機で確かめること」の3行）を打ち、出力を貼った。

```
$ ls -la --time-style=long-iso ~/log
合計 106260
drwxr-xr-x  6 dfa dfa     4096 2025-10-03 19:09 .
drwx------ 31 dfa dfa     4096 2026-10-19 17:18 ..
drwxr-xr-x  2 dfa dfa    16384 2026-10-19 04:59 api_client
-rw-r--r--  1 dfa dfa   148200 2025-03-12 20:52 gw.log
-rw-r--r--  1 dfa dfa        0 2025-04-09 14:48 gw_in_out.log
-rw-r--r--  1 dfa dfa  3308957 2026-02-12 14:34 interval.log
-rw-r--r--  1 dfa dfa 98963325 2025-10-03 19:03 postcondition.log
drwxr-xr-x  2 dfa dfa    20480 2026-10-19 00:00 postcondition_kick_action
drwxr-xr-x  2 dfa dfa    20480 2026-10-19 00:00 postcondition_security
-rw-r--r--  1 dfa dfa  6156288 2025-10-03 19:10 postcondition_security.log
drwxr-xr-x  2 dfa dfa     4096 2026-08-20 00:00 send_start_sig
-rw-r--r--  1 dfa dfa   128582 2025-06-09 17:10 sendtasksig.log
$ git -C ~ ls-files | wc -l → 13
$ git -C ~ ls-files | grep -cE '^\.(cache|config)/' → 0
```

**分かったこと・帰結（続き）**:

| # | 内容 |
|---|---|
| 7 | ★`~/log` 直下の6ファイルは古い版の名残★（最終更新 2025-03-12〜2026-02-12。今は書かれていない）。★取り出しの対象にしない★（監督判断 2026-10-19）。理由: ★今の調査に要るのはプログラム別フォルダのログ★／★`postcondition.log` は1ファイルで約 99MB あり、上限（100MB）にほぼ届く★。必要になったら「旧ログ」の種類を足す（★その場合は上限の見直しが要る★）。§11.1-13 は解消 |
| 8 | ★直下のファイルを拾わないよう、§5.2 の `roots` を「プログラム別フォルダごと」（4つ・`recursive: false`）にした★（監督判断 2026-10-19）。以前の例（`/home/dfa/log` を1つの root にして `patterns` をファイル名（basename）に当てる）では、★直下の `postcondition_security.log` もフォルダの中の同名ファイルと同じ種類として拾ってしまう★ |
| 9 | ★本番機のフォルダは開発機よりファイルが多いと推測★: ディレクトリのエントリの大きさが 16〜20KB（開発機は 4KB）。master の版（`5efdb2b`）の `shared_utils.py` が `~/d-s_control` 版と同じく1時間ごとの切り替え（`when='h'`・`backupCount=168`）だと、★1フォルダ最大169ファイル・合計 500件前後★（★推測★）。★一覧の上限（2万件・4MB）には十分収まる★。§5.2 の `patterns` の `<名前>.log.*` は時刻つきの名前（`.YYYY-MM-DD_HH`）にも当たる。★ただし推測の根拠は弱い★: ext4 のディレクトリのエントリはファイルを消しても縮まないので、16〜20KB は ★過去の最大のファイル数★を表すだけかもしれない。また `postcondition_*` のフォルダの更新日時が ★00:00★ なのは、1時間ごとではなく日ごと（`midnight`）の切り替えと合う（★推測★）。→ ★ファイル数と名前の形は未確認（§11.1-15）★ |
| 10 | ★本番機の git が追跡しているのは13ファイルだけで、`.cache/`・`.config/` の下は0★。★開発機の `/home/dfa` で `.cache/`・`.config/` が追跡されているのは開発機のローカルの状態に限られ、master・本番機には入っていない★（13個の名前は見ていない。`.bash_history` などが入っているかは未確認だが、量の上でブラウザのデータが広がっている状態ではない）。§11.1-14 は解消。→ §11.3-4 |

---

## 2. 全体の形

### 2.1 図

```
 DFA 社内の PC
┌──────────────┐
│ ブラウザ        │
└──────┬───────┘
       │ HTTPS（cookie 認証・IP 制限。既存のまま）
       ▼
┌──────────────────────────────────────────────┐
│ D-Watch のサーバー（さくらのクラウド VM。構築保守は TeamWorks） │
│  ┌────────┐   ┌───────────────────────────┐ │
│  │ nginx  │──▶│ D-Watch（Next.js・1プロセス）     │ │
│  └────────┘   │  ・proxy（IP 制限 → 認証）         │ │
│     ▲         │  ・lib/ds-agent/relay.ts            │ │
│     │         │    ＝メモリ内の中継（ディスクに書かない）│ │
│     │         └───────────────────────────┘ │
└─────┼────────────────────────────────────────┘
      │ HTTPS（443）・★接続は常に制御盤から開く★
      │  ① GET  /api/ds-agent/commands?wait=25   （ロングポーリング）
      │  ② POST .../result                       （一覧の結果）
      │  ③ POST .../prepared                     （zip を作り終えた）
      │  ④ POST .../chunks/{seq}                 （zip を 4MB 以下に分けて）
      │  ⑤ POST .../complete | .../fail
┌─────┴──────────────────────────┐
│ 店舗の制御盤（ラズパイ）                 │
│  dwatch-log-agent（Python・systemd） │
│  ・読むのはログのフォルダだけ（許可リスト）│
│  ・list / export / send 以外は実行しない │
└────────────────────────────────┘
```

### 2.2 流れ（一覧）

1. ブラウザが `GET /api/ds-devices/{serial}/logs` を呼ぶ
2. D-Watch はその制御盤の待ち行列に `list` の指示を入れる
3. 制御盤のロングポーリング（①）がその指示を受け取る
4. 制御盤はログのフォルダを歩き、★更新日時の新しい順に並べて★、上限までを `result`（②）で返す（M3）
5. D-Watch は ★全件を受け取ってから★、種類・期間で絞り、並べ替え、ページに分けてブラウザへ返す（`lib/ds-logs/query.ts`）

### 2.3 流れ（ダウンロード）— ★「準備 → 送信」の2段★（M1）

```
ブラウザ                 D-Watch                          制御盤
  │ POST …/logs/exports    │                                │
  │ { file_ids }  ───────▶ │ 待ち行列に kind:"export"        │
  │                        │ ─────────(①の応答)──────────▶ │ 検査 → zip を作る
  │  「制御盤で準備中…」     │ ◀── ③ POST …/prepared ─────── │ { total_bytes, sha256, file_count }
  │ ◀── 201 { export_id,   │   （50秒以内。来なければ 504）   │
  │   download_url, … } ── │                                │ zip を一時フォルダに持ったまま待つ
  │                        │                                │ （120秒以内に send が来なければ消す）
  │ GET download_url ────▶ │ 待ち行列に kind:"send"          │
  │                        │ ─────────(①の応答)──────────▶ │
  │                        │ ◀── ④ POST …/chunks/0 ──────── │
  │ ◀── 200 zip（流す）──── │ ◀── ④ POST …/chunks/1 ──────── │
  │   Content-Length 付き   │            …                   │
  │                        │ ◀── ⑤ POST …/complete ──────── │ 一時ファイルを消す
```

1. ブラウザが `POST /api/ds-devices/{serial}/logs/exports` に `file_ids` を送る
2. D-Watch は制御盤へ `export` の指示を入れ、★制御盤が zip を作り終えて `prepared` を送るまで待つ★（最大50秒）
3. ★準備中の失敗（`too_large`・`file_not_allowed` 等）は、この POST の JSON エラーで返る★。成功なら 201 で `download_url` を受け取る
4. ブラウザが `download_url` を `<a href download>` の click() で開く（★1回だけ使える★。60秒で失効）
5. D-Watch は制御盤へ `send` の指示を入れる。制御盤は作っておいた zip を ★4MB 以下のチャンク★に分けて ④ で送る
6. D-Watch は各チャンクの長さと SHA-256 を確かめ、★待っているブラウザの応答へそのまま流す★
7. 制御盤は最後に ⑤ `complete`（合計の長さと SHA-256）を送る。D-Watch は `prepared` で届いた値と一致するかを確かめる

### 2.4 「サーバーのディスクに書かない」の守り方

| どこで | どう守るか |
|---|---|
| D-Watch | チャンクを ★メモリで受け、ブラウザの応答へ流すだけ★。ファイルにも DB にも書かない。★ブラウザ側に空きができるまでチャンクの 204 を返さない★（流量制御。最大45秒。M5）ので、メモリに溜まるのは1〜2チャンク |
| D-Watch の proxy | 本体をメモリにバッファする（§1.5）。★1チャンク 4MB 以下・一覧の結果 4MB 以下★にして、上限 10MB と切り捨てを避ける。各チャンクの SHA-256 で ★切り捨てを検知する★ |
| nginx（リクエスト） | `client_body_buffer_size` を 5MB 以上にする、または `proxy_request_buffering off`。★本体を一時ファイルに書かせない★（TeamWorks に依頼。§10） |
| nginx（応答） | D-Watch が応答に `X-Accel-Buffering: no` を付ける。これを有効にしておく（`proxy_ignore_headers` で消さない）、または該当パスで `proxy_buffering off`。★応答を一時ファイルに書かせない★（§10） |
| 制御盤 | zip は制御盤の `temp_dir` に作る（制御盤のディスク）。★送り終わったら・失敗したら・send が来なかったら消す★（§5.8） |

メモリの目安（★推測★）: 1チャンク 4MB × 写し2〜3個（proxy の clone・ハンドラ・ブラウザ側の待ち）× 同時3本 ＝ 数十MB 程度。

---

## 3. 却下した案と理由

| 案 | 却下の理由 |
|---|---|
| 1本の POST で 100MB を流し、そのパスだけ proxy の matcher から外す | `proxy.ts:106-107` の方針（matcher で外すと保護漏れの発見が難しい）に反する。認証・IP 制限・相関ID を ★ハンドラ側で作り直す★ことになる |
| `experimental.proxyClientMaxBodySize` を 100MB に上げる | 上限を上げても ★全量をメモリに溜めてからハンドラが動く★（ストリーミングにならない。§1.5）。同時に複数あるとメモリを食う |
| 別プロセスの中継サーバー（WebSocket 等） | nginx の振り分けを TeamWorks に頼む必要がある。プロセスが2つになり、★認証を二重に持つ★ |
| WebSocket | ★Next.js のルートハンドラは WebSocket のサーバーになれない★ |
| エクスポートを1段（ダウンロードを開いてから zip を作る） | 準備中の失敗（`too_large` 等）をダウンロードの応答でしか返せず、★エラーの JSON がファイルとして保存される★。上限の判定も元のファイルの合計と zip の大きさで2つに割れる（M1 で2段にした） |
| B案（VPN で PC から制御盤へ直接つなぐ） | 全制御盤に VPN と店舗ネットワークの許可が要る。D-Watch と別のログイン・別画面（D-179） |
| C案（制御盤がサーバーへ上げて一時保存） | ★「サーバーにアップロードしたくない」に反する★（D-179） |
| Pi Connect だけで済ませる | ファイル転送・コマンド実行の API が無い。手元へ落とせるのはテキストのコピーだけ（§1.4） |
| トークンをサーバー上のスクリプトで発行する | 本番のサーバーは TeamWorks が構築保守する（D-143）。サーバーでスクリプトを動かす前提は置けない（M8。D-Watch の API と画面で発行する） |

---

## 4. API

`d-watch-api.yaml` に足す（★先に YAML を直す★。`CLAUDE.md` §6-1）。キーは snake_case。エラーは `{ error: { code, message, details? } }`。

### 4.1 ブラウザ向け（既存の cookie 認証・IP 制限のまま）

#### (1) `GET /api/ds-devices/{serial_number}/logs` — 一覧

| クエリ | 内容 |
|---|---|
| `types` | カンマ区切りの種類キー。省略で全種類。★存在しないキーは0件になる★（M10） |
| `from` / `to` | ISO 8601。省略可。★`from ≤ modified_at < to`★ |
| `sort` | `date_desc`（既定）/ `type_date_desc` |
| `page` | 1 以上 |
| `per_page` | 50 / 100 / 200。既定 200 |

200 の例:

```json
{
  "agent": { "connected": true, "last_seen_at": "2026-10-19T11:59:40+09:00", "agent_version": "1.0.0" },
  "types": [ { "key": "app", "label": "アプリ" } ],
  "items": [
    { "file_id": "MDphcHAvYXBwLTIwMjYxMDE5LmxvZw", "name": "app/app-20261019.log", "type": "app",
      "size_bytes": 182344, "modified_at": "2026-10-19T11:58:03+09:00" }
  ],
  "truncated": false,
  "meta": { "total": 1, "page": 1, "per_page": 200 }
}
```

（★種類・ファイル名は架空の例★）

- D-Watch は制御盤に `list` を依頼し、返ってきた ★全件を D-Watch 側の純粋関数で絞り込み・並べ替え・ページ分け★する（`lib/ds-logs/query.ts`。期待値表でテスト）
- ★`truncated` は制御盤が一覧を打ち切ったか★（M3）。`true` なら画面に「件数が多いため、新しいものから一部だけを表示しています」を出す

| 状態 | code | いつ |
|---|---|---|
| 409 | `agent_offline` | 最後のリクエストから ★60秒超★ |
| 429 | `agent_busy` | その制御盤の待ち行列が上限（5）（M10） |
| 504 | `agent_timeout` | ★15秒以内★に結果が来ない |
| 502 | `agent_error` | 制御盤が失敗を返した（`details` に制御盤の code） |
| 422 | `validation_failed` | クエリの誤り（M2） |
| 404 | `not_found` | 制御盤が存在しない（`db.dsDeviceExists()`。M10） |

path の `serial_number` は既存と同じく maxLength 100・文字種は絞らない（D-123。`parsePathSerialNumber` `lib/api/path-params.ts:70`）。

#### (2) `POST /api/ds-devices/{serial_number}/logs/exports` — エクスポートの準備（M1）

- 本体: `{ "file_ids": ["…"] }`（1〜500件）
- 順に: 既存チェック（404 / 409 / 429 / 422）→ 制御盤へ `kind:"export"` の指示を入れる → ★制御盤が `prepared` を送るまで待つ（最大50秒）★
- 画面はこの間「制御盤で準備中…」を出す
- 201:

```json
{
  "export_id": "exp_…",
  "download_url": "/api/ds-devices/{serial}/logs/exports/exp_…",
  "total_bytes": 9123456,
  "file_count": 2,
  "expires_in_sec": 60
}
```

- `download_url` は (3) の URL。★60秒以内に開かないと失効★

| 状態 | code | いつ |
|---|---|---|
| 404 | `not_found` | 制御盤が存在しない |
| 409 | `agent_offline` | 制御盤が未接続 |
| 429 | `export_busy` | 同じ制御盤で進行中、または全体の同時数の上限 |
| 429 | `agent_busy` | 待ち行列が上限 |
| 422 | `validation_failed` | `file_ids` が0件・500件超など |
| 502 | `agent_error` | ★準備中に制御盤が失敗を返した★（`details` に制御盤の code: `too_large` / `file_not_allowed` / `file_not_found` / `io_error` 等）。★`prepared` の `total_bytes` が `EXPORT_MAX_BYTES` を超えたときも 502（`too_large`）★ |
| 504 | `agent_timeout` | 50秒以内に `prepared` が来ない |

#### (3) `GET /api/ds-devices/{serial_number}/logs/exports/{export_id}` — ダウンロード（M1）

- ブラウザは `<a href download>` の click() で開く。★1回だけ使える★
- D-Watch は制御盤へ `kind:"send"` の指示を入れ、最初のチャンクを待つ
- 200 の応答ヘッダ:

```
Content-Type: application/zip
Content-Length: {prepared の total_bytes}          ← ブラウザに進捗が出る
Content-Disposition: attachment; filename="{置換後の名前}"; filename*=UTF-8''{符号化した名前}
Cache-Control: no-store
X-Accel-Buffering: no                               ← nginx に応答をバッファさせない
```

- 名前は `{serial}_logs_{YYYYMMDD-HHmmss}.zip`（日時は JST）。`serial_number` は文字種を絞っていない（D-123）ので、`filename` は置き換えた ASCII、`filename*` は元の名前を符号化する（M10。細部は指示書AY）
- 本体は制御盤から届いたチャンクを順に流す

| 状態 | code | いつ |
|---|---|---|
| 504 | `agent_timeout` | ★最初のチャンクが30秒以内に来ない★（まだ応答を始めていないので JSON で返せる） |
| 502 | `agent_error` | ★最初のチャンクの前に★制御盤が `fail` を送った（例: `export_not_found` ＝ 制御盤に zip が無い）、または最初のチャンクが不正（`details` に code。指示書AY 表5 R19・R23） |
| 404 | `export_not_found` | 無い・失効・使用済み・パスのシリアルと違う |
| （流し始めた後） | — | チャンクが45秒来ない・ハッシュ不一致・順番違い・合計が `prepared` と不一致・制御盤が `fail` を送った → ★ストリームを error で閉じる★（ブラウザではダウンロード失敗）。ログに残す |

★準備の失敗は (2) で返るので、(3) が JSON のエラーになるのは制御盤の切断などまれな場合だけ★。その場合 ★JSON がファイルとして保存されることがある★（既知の制約。§9）。

#### (4) `GET /api/ds-devices/{serial_number}/agent-token` — 発行日時の読み取り（監督判断。指示書AY Q14）

- 200: `{ "issued_at": "2026-10-19T12:00:00+09:00" }`。未発行なら `{ "issued_at": null }`
- ★トークンの値（ハッシュも）は返さない★。画面の「制御盤トークン」の発行日時の表示に使う（制御盤が未接続でも読める）
- 404 `not_found`（制御盤が存在しない）/ 422 `validation_failed`（パスの誤り）

#### (5) `POST /api/ds-devices/{serial_number}/agent-token` — 制御盤トークンの発行（M8）

- 本体なし。201: `{ "token": "q3N9…（43文字）", "issued_at": "2026-10-19T12:00:00+09:00" }`（`Cache-Control: no-store`）
- ★生のトークンはこの応答で1回だけ★。既にあれば ★作り直し、古いものは無効★になる
- 404 `not_found`（制御盤が存在しない）/ 422 `validation_failed`

#### (6) `DELETE /api/ds-devices/{serial_number}/agent-token` — 失効（M8）

- 204。失効後、その制御盤の要求は 401
- ★未発行でも 204（冪等）★（監督判断。指示書AY Q15）
- 404 `not_found` / 422 `validation_failed`

### 4.2 制御盤向け（`/api/ds-agent/` 配下。6本）

★**cookie は受け付けない。`Authorization: Bearer <制御盤トークン>` が必須**★。トークンは制御盤ごとに1つ（§8.1）。
★制御盤のシリアル番号はトークンから決まる★。ヘッダや本体でシリアル番号を送る必要はない（送っても使わない）。

★認証の通った制御盤のリクエストは、6本どれでも `last_seen_at` を更新する★（M4）。

**すべてのリクエストに付けるヘッダ**:

```
Authorization: Bearer <制御盤トークン>
X-Agent-Version: 1.0.0
User-Agent: dwatch-log-agent/1.0.0
```

`command_id` は D-Watch が発行する ★不透明な文字列★。URL に入れるときは `urllib.parse.quote(command_id, safe="")` で符号化する（補い）。

**指示の種類（`kind`）**: ★`list` / `export` / `send` の3つ★（M1）。

| kind | params | 制御盤がすること | 返し先 |
|---|---|---|---|
| `list` | `{}` | ログの一覧を作る | `result`（失敗も `result` の `error`） |
| `export` | `{ file_ids, max_bytes, chunk_bytes }` | 検査して zip を作る（★まだ送らない★） | 成功は `prepared`、失敗は `fail` |
| `send` | `{ export_command_id }` | `export_command_id` の zip をチャンクで送る | `chunks` → `complete`、失敗は `fail` |

★`chunks` / `complete` と、送信中の `fail` の URL の `{command_id}` は、★`export` の指示の `command_id`（＝ `send` の `export_command_id`）★を使う★（監督判断 m）。`send` の指示は自分の `command_id` を持つが、★`send` の id には何も返さない★。

#### (A) `GET /api/ds-agent/commands?wait=25` — 指示を待つ（ロングポーリング）

- `wait` は 1〜25 秒
- `agent_version`（`X-Agent-Version`）もここで記録する（メモリ）
- 指示があれば 200、無ければ `wait` 秒待って 204
- ★同じ制御盤から新しいポーリングが来たら、古いほうは 204 で返す★（M10。再起動の直後などに起きる）

リクエスト:

```
GET /api/ds-agent/commands?wait=25 HTTP/1.1
Host: dwatch.example.jp
Authorization: Bearer q3N9…（43文字）
X-Agent-Version: 1.0.0
User-Agent: dwatch-log-agent/1.0.0
```

200（一覧）:

```json
{ "command_id": "cmd_7f3a…", "kind": "list", "params": {} }
```

200（エクスポートの準備）:

```json
{
  "command_id": "cmd_91bc…",
  "kind": "export",
  "params": {
    "file_ids": ["MDphcHAvYXBwLTIwMjYxMDE5LmxvZw", "MDphcHAvYXBwLTIwMjYxMDE4LmxvZw"],
    "max_bytes": 104857600,
    "chunk_bytes": 4194304
  }
}
```

200（送信）:

```json
{ "command_id": "cmd_c04d…", "kind": "send", "params": { "export_command_id": "cmd_91bc…" } }
```

204: 本体なし（指示なし）。

#### (B) `POST /api/ds-agent/commands/{command_id}/result` — 一覧の結果

- `Content-Type: application/json`。★本体は 4MB 以下★（M3）

成功のとき:

```json
{
  "types": [ { "key": "app", "label": "アプリ" } ],
  "items": [
    { "file_id": "MDphcHAvYXBwLTIwMjYxMDE5LmxvZw", "name": "app/app-20261019.log", "type": "app",
      "size_bytes": 182344, "modified_at": "2026-10-19T11:58:03+09:00" }
  ],
  "truncated": false
}
```

失敗のとき:

```json
{ "error": { "code": "io_error", "message": "permission denied: (フォルダ名)" } }
```

- 応答: 204。失効済み（15秒を過ぎた等）なら 410 `command_expired`。形が違えば 400 `result_invalid`（M2・監督判断 q）。★指示が見つからない（消された後・別の制御盤の指示・kind が違う）なら 404★

#### (C) `POST /api/ds-agent/commands/{command_id}/prepared` — zip を作り終えた（M1）

`{command_id}` は `export` の指示のもの。

```json
{ "total_bytes": 9123456, "sha256": "（zip 全体の SHA-256。16進小文字64桁）", "file_count": 2 }
```

- 応答: 204。失効済み（50秒を過ぎた等）なら 410 `command_expired`（★制御盤は zip を消す★）。形の誤りは 400 `result_invalid`（監督判断 q）。指示が見つからない（`list` の id など）なら 404
- ★`total_bytes` が D-Watch の `EXPORT_MAX_BYTES` を超えても応答は 204★（D-Watch はブラウザに 502 `too_large` を返し、`send` を送らない。制御盤の zip は120秒で消える）

#### (D) `POST /api/ds-agent/commands/{command_id}/chunks/{seq}` — zip の本体を送る

- `{command_id}` は `export` の指示のもの（監督判断 m）。`seq` は ★0 から連番★
- 本体は zip のバイト列の一部。★`chunk_bytes`（4MB）以下・空でない★

```
POST /api/ds-agent/commands/cmd_91bc…/chunks/0 HTTP/1.1
Authorization: Bearer q3N9…
X-Agent-Version: 1.0.0
User-Agent: dwatch-log-agent/1.0.0
Content-Type: application/octet-stream
Content-Length: 4194304
X-Chunk-Sha256: ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad

（4194304 バイトのバイト列）
```

- `X-Chunk-Sha256` は ★そのチャンクの本体★の SHA-256 を16進（小文字64桁）で
- D-Watch は長さ・ハッシュを検証し、ブラウザ側のストリームに入れる
- ★ブラウザ側に空きができるまで 204 を返さない（最大45秒。超えたら 410 `export_cancelled` にして export を失敗にする）★（M5）。★制御盤は 204 を受け取るまで次のチャンクを送らない★

#### (E) `POST /api/ds-agent/commands/{command_id}/complete` — 送り終わり

```json
{ "total_bytes": 9123456, "sha256": "（zip 全体の SHA-256。prepared と同じ値）" }
```

- 204。★流れたバイト数が `prepared` の `total_bytes` と一致し、SHA-256 が `prepared` の `sha256` と一致すること★を D-Watch が確かめる（M1）。合わなければ 400 `result_invalid`（D-Watch はストリームを error で閉じる）。形の誤りも 400 `result_invalid`（監督判断 q）。ほかに 404（指示が見つからない・`send` の id に送った）/ 410

#### (F) `POST /api/ds-agent/commands/{command_id}/fail` — 失敗の報告

```json
{ "code": "too_large", "message": "zip 131072000 bytes > max 104857600" }
```

- 204。形の誤りは 400 `result_invalid`（監督判断 q）。ほかに 404 / 410
- `code` は 1〜64 文字、`message` は 1〜500 文字

★制御盤から来る JSON（`result` / `prepared` / `complete` / `fail`）の形の誤りは、すべて 400 `result_invalid`。チャンクの誤りは 400 `chunk_invalid`★（M2・監督判断 q）。

### 4.3 制御盤が返す・送る code の一覧

| code | どこで | 意味 |
|---|---|---|
| `file_not_allowed` | export の `fail` | `file_id` が壊れている・root の外・通常のファイルでない・シンボリックリンク・patterns に合わない（§5.6） |
| `file_not_found` | export の `fail` | 一覧のあとでファイルが消えた。★`os.lstat` が `FileNotFoundError` のときだけ★。それ以外の検査落ちは `file_not_allowed`（M1・監督判断 o）。★検査の後、zip を作る途中でファイルを開こうとして無かった（`FileNotFoundError`）ときも `file_not_found`★（2026-10-20 監督判断。それ以外の読み取りの失敗は `io_error`） |
| `too_large` | export の `fail` | 元のファイルの合計、または ★作った zip の大きさ★が `max_bytes` を超える（M1） |
| `io_error` | list の `result`・export / send の `fail` | 読み取り・zip 作成・空き容量不足など（M1） |
| `export_not_found` | send の `fail`（URL は export の id） | `export_command_id` の zip が無い（120秒を過ぎて消した後・作っていない）（監督判断 n） |
| `unsupported` | `fail` | `list` / `export` / `send` 以外の `kind` |
| `internal_error` | 同上 | 想定外の例外（補い） |

`message` には ★ファイルの中身・トークンを入れない★。ファイル名は1つまでなら入れてよい（補い）。

### 4.4 状態コードごとの制御盤の振る舞い

★制御盤の Python 担当はこの表のとおりに作る★。「（補い）」は本書で埋めた箇所。

**(A) `GET /commands`**

| 応答 | 制御盤の振る舞い |
|---|---|
| 200 | 指示を処理に回す（§5.4）。★すぐ次のポーリングに戻る★ |
| 204 | すぐ次のポーリングに戻る。バックオフをリセット |
| 401 | ★設定の誤り（トークン違い・失効）★として journald に error を出し、★60秒待って再試行★ |
| 403 | IP 制限で拒否されたとして journald に error を出し、60秒待って再試行（補い） |
| 400 / 422 | journald に error。60秒待って再試行（補い。`wait` の値の誤り＝プログラムの誤り） |
| 5xx（502/503/504 は nginx から来ることもある）・接続エラー・タイムアウト | ★指数バックオフ★（5秒 → 10 → 20 → 40 → 60 → 60 …）。成功したらリセット |

リクエストのタイムアウトは ★`wait` ＋ 10秒★（25 なら 35秒）。

**(B) `POST /result`・(C) `POST /prepared`**

| 応答 | 振る舞い |
|---|---|
| 204 | 完了（prepared なら zip を持ったまま `send` を待つ。§5.8） |
| 410 | 失効（D-Watch は諦めた）。journald に warn。★prepared なら zip を消す★ |
| 404 | 指示が見つからない（D-Watch の再起動の後など）。journald に warn。捨てる（zip は消す）（補い） |
| 401 / 403 | (A) と同じ扱い（この指示は捨てる。zip は消す） |
| 413（nginx）・400 | journald に error。捨てる（zip は消す）（補い） |
| 5xx・接続エラー | ★再送しない★。journald に warn。zip は消す（補い。D-Watch は15秒／50秒で諦めるので再送の意味が薄い） |

**(D) `POST /chunks/{seq}`**

| 応答 | 振る舞い |
|---|---|
| 204 | 次のチャンクへ |
| 400 `chunk_invalid` | ★送信をやめる★。D-Watch 側ではすでに失敗扱い。journald に error |
| 409 `chunk_out_of_order` | ★送信をやめる★（プログラムの誤り。D-Watch 側でも失敗扱い。M10）。journald に error |
| 410 `export_cancelled` | ★送信をやめる★（ブラウザが切断・失効・45秒待っても空かなかった）。journald に info |
| 404 | ★送信をやめる★（指示が見つからない。D-Watch の再起動の後など）。journald に warn（補い） |
| 401 / 403 | 送信をやめる。(A) と同じく journald に error |
| 5xx・接続エラー・タイムアウト | ★送信をやめ、`fail` を1回だけ送る（失敗は無視）★（補い。同じ `seq` の再送はしない — 前回が届いていると 409 になるため） |

チャンクの POST のタイムアウトは ★60秒★（M5。D-Watch は最大45秒で 204 か 410 を返す）。

**(E) `POST /complete`・(F) `POST /fail`**

| 応答 | 振る舞い |
|---|---|
| 204 | 完了 |
| それ以外・接続エラー | journald に warn。再送しない（補い） |

★send が終わったら（成功でも失敗でも）一時ファイルを消す★（§5.8）。

---

## 5. 制御盤の常駐プログラムの仕様（dwatch-log-agent）

★**DFA の制御盤ソフトの担当が作る。本リポジトリの外**★。

### 5.1 方針

| 項目 | 内容 |
|---|---|
| 言語 | Python 3 |
| ライブラリ | ★標準ライブラリだけ★: `urllib.request` / `http.client`・`json`・`hashlib`・`zipfile`・`fnmatch`・`os`・`tempfile`・`time`・`logging`・`threading`（M4）。加えて `ssl`・`base64`・`argparse`・`queue`（補い） |
| 動かし方 | systemd のサービス（`Restart=always`） |
| 実行ユーザー | ★root で動かさない★。専用ユーザー `dwatch-log-agent` に ★ログのフォルダの読み取り権限だけ★を与える |
| できること | ★`list` / `export` / `send` だけ★。★シェルのコマンドを実行する機能は持たない★。未知の `kind` は `fail` に `unsupported` |
| 読めるもの | ★設定ファイルの `roots` × `patterns` に合う通常のファイルだけ★（許可リスト方式） |
| 接続 | ★制御盤から外向きだけ★。待ち受けのポートは開かない |
| スレッド | ★ポーリングのスレッドと、`export` / `send` を1つずつ処理するスレッドを分ける。処理中もポーリングを続ける★（M4）。★`list` は `export` と並行して別スレッドですぐ処理する。`export` は1台同時に1本★（監督判断 p。§5.4） |

### 5.2 設定ファイル

置き場所: `/etc/dwatch-log-agent/config.json`（★パーミッション 600、所有者は `dwatch-log-agent`★。トークンが入るため）

```json
{
  "server_url": "https://（D-Watch のサーバー。後日）",
  "serial_number": "（この制御盤のシリアル番号）",
  "token": "（D-Watch の画面で発行したトークン。§8.1）",
  "poll_wait_sec": 25,
  "max_export_bytes": 104857600,
  "chunk_bytes": 4194304,
  "temp_dir": "/var/tmp/dwatch-log-agent",
  "roots": [
    {
      "name": "api_client",
      "path": "/home/dfa/log/api_client",
      "recursive": false,
      "types": [
        { "key": "api_client", "label": "API 通信（報告間隔の取得・通信の有効/無効）（仮）",
          "patterns": ["api_client.log", "api_client.log.*"] }
      ]
    },
    {
      "name": "postcondition_security",
      "path": "/home/dfa/log/postcondition_security",
      "recursive": false,
      "types": [
        { "key": "postcondition_security", "label": "セキュリティ状態の報告（仮）",
          "patterns": ["postcondition_security.log", "postcondition_security.log.*"] }
      ]
    },
    {
      "name": "postcondition_kick_action",
      "path": "/home/dfa/log/postcondition_kick_action",
      "recursive": false,
      "types": [
        { "key": "postcondition_kick_action", "label": "セコム状態の監視・清掃の起動（仮）",
          "patterns": ["postcondition_kick_action.log", "postcondition_kick_action.log.*"] }
      ]
    },
    {
      "name": "send_start_sig",
      "path": "/home/dfa/log/send_start_sig",
      "recursive": false,
      "types": [
        { "key": "send_start_sig", "label": "清掃開始信号（仮）",
          "patterns": ["send_start_sig.log", "send_start_sig.log.*"] }
      ]
    }
  ]
}
```

★**`roots` は 2026-10-19 の実機の実測（§1.6・§1.6.1）で埋めた**★。★`label` は中身からの推測で（仮）。ユーザー確認待ち★（§11.1-1）。

★**root は「プログラム別フォルダごと」に1つ**★（監督判断 2026-10-19。§1.6.1 #8）:

- ★4つの root★。`name` ＝ フォルダ名、`path` ＝ `/home/dfa/log/<フォルダ名>`、★`recursive: false`★（フォルダの中のファイルだけ。下のフォルダは歩かない）、`types` は1つ
- ★zip の中のパスは `{root の name}/{ファイル名}`★（M6。例: `postcondition_security/postcondition_security.log.2026-10-18`）。開発機の `~/log` の並びと同じになる
- ★`/home/dfa/log` 直下のファイルは、どの root にも入らないので一覧に出ず、取り出せない★。本番機の直下にある6個（`gw.log` / `gw_in_out.log` / `interval.log` / `postcondition.log` / `postcondition_security.log` / `sendtasksig.log`）は ★古い版の名残で、取り出しの対象にしない★（監督判断 2026-10-19。§1.6.1 #7）
- ★`/home/dfa/log` を1つの root にして `patterns` をファイル名（basename）に当てる形にはしない★（以前の版の例）。★直下の `postcondition_security.log` もフォルダの中の同名ファイルと同じ種類として拾ってしまう★ため
- 必要になったら「旧ログ」の root（`path` ＝ `/home/dfa/log`・`recursive: false`・6つのファイル名を `patterns` に）を足す。★その場合は上限（`max_export_bytes`）の見直しが要る★（`postcondition.log` だけで約 99MB）
- `patterns` の `<名前>.log.*` は、日付つき（`.YYYY-MM-DD`）にも時刻つき（`.YYYY-MM-DD_HH`）にも当たる。★本番機のフォルダは開発機よりファイルが多いと推測★（§1.6.1 #9・§11.1-15）

| キー | 意味 |
|---|---|
| `server_url` | D-Watch の URL。★`https://` 必須★（`http://` は `localhost` / `127.0.0.1` のときだけ許す。開発用） |
| `serial_number` | この制御盤のシリアル番号（`controls.serial_number` と同じもの）。★D-Watch へは送らない★。ログに出して、どの制御盤の設定かを人が確かめるためのもの |
| `token` | 制御盤トークン。★ログに出さない★ |
| `poll_wait_sec` | ロングポーリングの `wait`（1〜25） |
| `max_export_bytes` | 1回のエクスポートの上限（★zip の大きさ★。元のファイルの合計でも先に断る） |
| `chunk_bytes` | 1チャンクの大きさ（4MB 以下） |
| `temp_dir` | zip を作る一時フォルダ。専用ユーザーだけが書ける（700） |
| `roots[].name` | ★必須・`roots` の中で一意・`[A-Za-z0-9_-]` だけ★。★zip の中のフォルダ名になる★（M6） |
| `roots[].path` | ログのフォルダ（絶対パス） |
| `roots[].recursive` / `max_depth` | 下のフォルダまで歩くか・何段まで。★上の例はすべて `recursive: false`（root のフォルダの中のファイルだけ）★。`max_depth` は `recursive: true` のときだけ使う（`false` なら書かない） |
| `roots[].types[].key` | 種類のキー（英数字・`_`・`-`。画面の絞り込みに使う） |
| `roots[].types[].label` | 画面に出す名前 |
| `roots[].types[].patterns` | ファイル名のパターン（`fnmatch`。★ファイル名（basename）に当てる★（補い）） |

`max_bytes` / `chunk_bytes` は指示（§4.2 (A)）でも届く。★設定ファイルの値と指示の値の小さいほうを使う★（M7）。

起動時に設定を検査し（`roots[].name` の形と重複を含む）、誤りがあれば journald に error を出して ★終了コード 1 で終わる★（補い）。

### 5.3 systemd ユニットの例

`/etc/systemd/system/dwatch-log-agent.service`:

```ini
[Unit]
Description=D-Watch log agent (list/export of controller logs)
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=dwatch-log-agent
Group=dwatch-log-agent
ExecStart=/usr/bin/python3 /opt/dwatch-log-agent/agent.py --config /etc/dwatch-log-agent/config.json
Restart=always
RestartSec=10

# 以下は推奨（本書の補い）。ログのフォルダが読めなくなったら外して確かめる
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths=/var/tmp/dwatch-log-agent
PrivateTmp=true

[Install]
WantedBy=multi-user.target
```

### 5.4 動き（擬似コード）

```python
work_q = queue.Queue()          # export / send を1つずつ処理する
prepared = {}                    # export_command_id -> PreparedZip（一時ファイルのパス・作った時刻）

def main():
    cfg = load_and_validate_config(args.config)      # 誤りなら exit(1)
    log.info("start version=%s serial=%s server=%s", VERSION, cfg.serial_number, cfg.server_url)
    clean_temp_dir()                                  # 前回の残りを消す（補い）
    threading.Thread(target=worker, daemon=True).start()
    threading.Thread(target=sweeper, daemon=True).start()   # send が来ない zip を消す
    backoff = 5
    while not stopping:                               # ★ポーリングのスレッド。処理中も止めない（M4）★
        try:
            status, body = http_get(f"{cfg.server_url}/api/ds-agent/commands?wait={cfg.poll_wait_sec}",
                                    timeout=cfg.poll_wait_sec + 10)
        except NetworkError as e:
            log.warning("poll failed: %s", e)
            sleep(backoff); backoff = min(backoff * 2, 60); continue

        if status == 200:
            backoff = 5
            cmd = json.loads(body)
            if cmd["kind"] == "list":
                threading.Thread(target=run, args=(do_list, cmd)).start()
                # ★list は export と並行して別スレッドですぐ処理（監督判断 p）★。
                # export / send の後ろに並べると15秒で切れることがある。export は1台同時に1本
            elif cmd["kind"] in ("export", "send"):
                work_q.put(cmd)
            else:
                post_fail(cmd["command_id"], "unsupported", "unknown kind")
        elif status == 204:
            backoff = 5
        elif status in (401, 403, 400, 422):
            log.error("poll rejected status=%d (token / IP / request の誤り)", status)
            sleep(60)
        else:                                          # 5xx など
            log.warning("poll status=%d", status)
            sleep(backoff); backoff = min(backoff * 2, 60)
    log.info("stop")

def worker():
    while True:
        cmd = work_q.get()
        run(do_export if cmd["kind"] == "export" else do_send, cmd)

def run(fn, cmd):
    t0 = time.monotonic()
    result = fn(cmd)                                   # 例外は fn の中で fail に変える
    log.info("command kind=%s files=%d bytes=%d duration_ms=%d result=%s", ...)

def sweeper():
    while True:
        for cid, z in list(prepared.items()):
            if time.monotonic() - z.prepared_at > 120:  # ★prepared から120秒 send が来なければ消す（M1）★
                remove_quietly(z.path); prepared.pop(cid, None)
        sleep(5)
```

`prepared` は2つのスレッドから触るので、`threading.Lock` で守る（補い）。

### 5.5 list の手順

```python
def do_list(cmd):
    found = []
    for i, root in enumerate(cfg.roots):
        root_real = os.path.realpath(root.path)
        for path in walk(root_real, recursive=root.recursive, max_depth=root.max_depth):
            st = os.lstat(path)
            if not stat.S_ISREG(st.st_mode):           # ★通常のファイルだけ。シンボリックリンクは飛ばす★
                continue
            t = match_type(root, os.path.basename(path))   # どの種類の patterns に合うか
            if t is None:                              # ★どの種類にも合わないファイルは返さない（許可リスト）★
                continue
            rel = os.path.relpath(path, root_real)     # 区切りは "/"
            found.append((st.st_mtime, i, rel, t, st))
    found.sort(key=lambda x: x[0], reverse=True)       # ★更新日時の新しい順に並べてから打ち切る（M3）★
    items, size, truncated = [], BASE_JSON_SIZE, False
    for mtime, i, rel, t, st in found:
        item = {
            "file_id": b64url(f"{i}:{rel}"),
            "name": f"{root.name}/{rel}",              # zip の中のパスと同じ（2026-10-20 監督判断）
            "type": t.key,
            "size_bytes": st.st_size,
            "modified_at": iso8601_with_tz(mtime),     # 例 2026-10-19T11:58:03+09:00
        }
        size += len(json.dumps(item, ensure_ascii=False).encode()) + 1
        if len(items) >= 20000 or size > 4 * 1024 * 1024:
            truncated = True; break
        items.append(item)
    post_result(cmd, {"types": all_types(cfg), "items": items, "truncated": truncated})
```

| 項目 | 内容 |
|---|---|
| 対象 | ★通常のファイルだけ★（`os.lstat` で判定。★シンボリックリンクは飛ばす★）。フォルダを歩くときも ★シンボリックリンクのフォルダには入らない★（`os.walk(followlinks=False)` 相当） |
| 種類 | `patterns` に合うものだけ。★どの種類にも合わないファイルは返さない★ |
| `file_id` | ★「roots の番号:root からの相対パス」を base64url にしたもの★（UTF-8。例: `0:app/app-20261019.log` → `MDphcHAvYXBwLTIwMjYxMDE5LmxvZw`。パディングの `=` は付けない（補い））。★D-Watch は中身を見ない★ |
| `name` | ★`{root の name}/{root からの相対パス}`★（例 `postcondition_security/postcondition_security.log.2026-10-18`）。★zip の中のパスと同じ形★にして、画面の一覧と落としたファイルの名前を揃える（2026-10-20 監督判断。常駐プログラムの実装と合わせた。旧: root からの相対パスだけ）。★D-Watch は中身を解析しない★（表示と name 昇順の並べ替えにだけ使う） |
| `modified_at` | ★タイムゾーン付き ISO 8601★。ラズパイの時計（★Asia/Tokyo の前提★）。秒まで |
| `types` | 設定ファイルの全種類を設定の順に返す（0件の種類も返す。補い）。★D-Watch の「種類＋日付」の並びはこの順★ |
| 上限 | ★更新日時の新しい順に並べてから、2万件または JSON 4MB のどちらかに達したら打ち切り、`truncated: true`★（M3） |

### 5.6 export の手順（準備）

`file_id` ごとに、★次をすべて確かめる★。★1つでも違えば、zip を作らずに `fail`★。

| # | 検査 | 違ったときの code |
|---|---|---|
| 1 | base64url を戻せる。`"番号:相対パス"` の形で、番号が `roots` の範囲内 | `file_not_allowed` |
| 2 | 相対パスが絶対パスでない・NUL を含まない | `file_not_allowed` |
| 3 | ファイルがある（`os.lstat` が `FileNotFoundError` にならない） | `file_not_found`（★この検査だけ★。監督判断 o） |
| 4 | `os.path.realpath(root.path + "/" + 相対パス)` が ★`os.path.realpath(root.path)` の内側★（`os.path.commonpath` で比べる。`..` やリンクで外へ出ていない） | `file_not_allowed` |
| 5 | `os.lstat` で ★通常のファイル★（★シンボリックリンクでない★） | `file_not_allowed` |
| 6 | ファイル名が、その root のどれかの種類の ★patterns に合う★ | `file_not_allowed` |
| 7 | `max_depth` の内側（補い）。★`recursive: false` の root なら、相対パスに `/` を含まない（root のフォルダの直下のファイル）★ | `file_not_allowed` |

続けて（★上限の判定は zip の大きさに一本化。M1★）:

- ★元のファイルの大きさの合計が `max_bytes` を超えたら、zip を作らずに `fail` に `too_large`★（早く断る）
- ★zip を作った後、zip の大きさが `max_bytes` を超えたら `fail` に `too_large`★（zip は消す）
- `file_ids` が0件・501件以上なら `fail` に `file_not_allowed`（補い。D-Watch 側でも 1〜500 件に絞っている）

```python
def do_export(cmd):
    cid, p = cmd["command_id"], cmd["params"]
    max_bytes = min(p["max_bytes"], cfg.max_export_bytes)        # ★小さいほう（M7）★
    tmp = None
    try:
        files = [check_file_id(fid) for fid in p["file_ids"]]   # 違えば CheckError(code)
        if sum(f.size for f in files) > max_bytes:
            return post_fail(cid, "too_large", ...)
        ensure_free_space(cfg.temp_dir, sum(f.size for f in files))   # 補い
        fd, tmp = tempfile.mkstemp(dir=cfg.temp_dir, suffix=".zip")
        with os.fdopen(fd, "wb") as fp, zipfile.ZipFile(fp, "w", zipfile.ZIP_DEFLATED) as z:
            for f in files:
                z.write(f.real_path, arcname=f"{f.root.name}/{f.rel_path}")   # ★{root の name}/{相対パス}（M6）★
        total_bytes = os.path.getsize(tmp)
        if total_bytes > max_bytes:
            return post_fail(cid, "too_large", ...)
        digest = sha256_of_file(tmp)
        status = post_prepared(cid, {"total_bytes": total_bytes, "sha256": digest, "file_count": len(files)})
        if status == 204:
            chunk_bytes = min(p["chunk_bytes"], cfg.chunk_bytes)   # ★小さいほう（M7）★
            prepared[cid] = PreparedZip(tmp, total_bytes, digest, chunk_bytes, time.monotonic())
            tmp = None                                  # 消すのは send の後か sweeper
    except CheckError as e:
        post_fail(cid, e.code, ...)
    except OSError:
        post_fail(cid, "io_error", ...)
    finally:
        if tmp: remove_quietly(tmp)                     # ★prepared まで行かなかったら必ず消す★
```

### 5.7 send の手順（送信）

```python
def do_send(cmd):
    eid = cmd["params"]["export_command_id"]
    z = prepared.pop(eid, None)
    if z is None:
        return post_fail(eid, "export_not_found", ...)   # 120秒を過ぎて消した等（監督判断 n）
    chunk_bytes = z.chunk_bytes                         # export の指示と設定ファイルの小さいほう
    try:
        n = 0; seq = 0; total = hashlib.sha256()
        with open(z.path, "rb") as fp:
            while (buf := fp.read(chunk_bytes)):
                status = post_chunk(eid, seq, buf, sha256_hex(buf), timeout=60)   # ★60秒（M5）★
                if status != 204:
                    return stop_on_chunk_status(eid, status)   # §4.4 の表
                total.update(buf); n += len(buf); seq += 1
        post_complete(eid, {"total_bytes": n, "sha256": total.hexdigest()})
    finally:
        remove_quietly(z.path)                          # ★成功でも失敗でも消す★
```

### 5.8 一時ファイルの扱い

| 項目 | 内容 |
|---|---|
| 置き場所 | `temp_dir`（既定 `/var/tmp/dwatch-log-agent`）。所有者 `dwatch-log-agent`・パーミッション 700 |
| 作り方 | `tempfile.mkstemp(dir=temp_dir)` |
| 消す時 | ★(1) prepared まで行かなかった（検査・上限・入出力の失敗、prepared が 204 以外）★ / ★(2) send を送り終えた・送信を途中でやめた★ / ★(3) prepared から120秒（`EXPORT_CLAIM_TIMEOUT_SEC` 60秒＋余裕）以内に send が来なかった★（M1） |
| 起動時 | ★`temp_dir` に残っている `*.zip` を消す★（前回の異常終了の残り。補い） |
| 空き容量 | zip を作る前に `shutil.disk_usage(temp_dir)` で合計＋余裕があるか確かめ、無ければ `io_error`（補い） |

### 5.9 ログ（journald）

| 出すもの | レベル |
|---|---|
| 起動（版・シリアル番号・`server_url`）・停止 | info |
| 接続エラー・状態コードの異常 | warning / error |
| 処理した指示（`kind`・ファイル数・バイト数・所要時間・結果） | info |

★**出さないもの: ファイルの中身・トークン**★。`Authorization` ヘッダを含む例外の文字列をそのまま出さないこと。
ファイル名の一覧全体も出さない（件数とバイト数だけ。補い。D-Watch 側のログと揃える）。

確認: `journalctl -u dwatch-log-agent -f`

### 5.10 HTTPS

- ★HTTPS 必須・証明書を検証する★（`ssl.create_default_context()` を使い、検証を切らない）
- `http://` は `server_url` のホストが `localhost` / `127.0.0.1` のときだけ許す（開発用）。それ以外の `http://` は起動時に error で終了
- リダイレクトは追わない（補い。トークンを別のホストへ送らないため）

### 5.11 開発時の動かし方

1. 開発 PC で D-Watch を動かす（`npm run dev`。`http://localhost:3000`）
2. D-Watch の D-Sec ログ画面 → 開発用の制御盤の行の「ログ」→ モーダルの下の「制御盤トークン」で ★発行★し、表示されたトークンをコピーする（§8.1）。★開発用のシリアルは `controls` に存在するもの★（fixtures なら fixtures の制御盤）
3. 試験用のログフォルダを作り、開発用の設定ファイルに書く（`roots[].path` を試験用フォルダに）
4. 開発 PC で常駐プログラムを動かす:

```
python3 agent.py --config ./config.dev.json --server http://localhost:3000
```

（`--server` は設定ファイルの `server_url` を上書きする）

5. 画面で一覧が出る → 選んで「エクスポート」→「制御盤で準備中…」→ zip が落ちる、を確かめる
6. 異常系: 常駐プログラムを止めて60秒後に「未接続」になる / シンボリックリンク・patterns に合わないファイルが一覧に出ない / 合計 100MB 超で押せない / export 中に一覧を開いても「未接続」にならない / トークンを失効すると journald に 401 が出る

D-Watch 側には、常駐プログラムの代わりに動く偽の制御盤 `scripts/ds-agent-fake.ts` がある（M10。D-Watch の実装者向け）。

### 5.12 導入と更新の手順（Pi Connect で。現地に行かない）

★Pi Connect にはファイル転送が無い★（§1.4）。配り方は次の2つ。★どちらにするかは制御盤の担当が決める★（M9）。
本番機は毎日12:00に master を取り込む（`git reset --hard origin/master`。§1.6.1）ので、★コードは git で配れる見込み★（★ただし取り込みの後に再起動は無い。§5.13★。★差分があればラズパイごと再起動する改訂案は §5.13.1★）。その場合でも ★1台ずつの作業は残る★（§5.13）。下の手順はその1台ずつの作業の下敷きにもなる。

| 手 | 内容 |
|---|---|
| ア（基本） | ★常駐プログラムを1ファイルの Python にして、Remote shell に heredoc で貼り付け、`sha256sum` で確かめる★ |
| イ | ★Pi Connect の Remote update★（組織向け・ベータ）。`.tar.zst` に `agent.py`・ユニット・導入スクリプトを入れて上げると、スクリプトを ★root で★実行できる。上げた物は2時間で消える。★トークンは入れないこと★（設定ファイルは Remote shell で書く） |

**導入（ア）**:

```bash
# 1. 専用ユーザー（ログインできない）
sudo useradd --system --no-create-home --shell /usr/sbin/nologin dwatch-log-agent

# 2. フォルダ
sudo install -d -o root -g root -m 755 /opt/dwatch-log-agent
sudo install -d -o dwatch-log-agent -g dwatch-log-agent -m 700 /etc/dwatch-log-agent
sudo install -d -o dwatch-log-agent -g dwatch-log-agent -m 700 /var/tmp/dwatch-log-agent

# 3. プログラムを置く（Remote shell に貼り付ける。EOF までが本体）
sudo tee /opt/dwatch-log-agent/agent.py > /dev/null <<'EOF'
（agent.py の全文）
EOF
sudo chmod 644 /opt/dwatch-log-agent/agent.py
sha256sum /opt/dwatch-log-agent/agent.py   # ★配布元の値と一致することを確かめる★

# 4. 設定ファイル（トークンを含む）
sudo tee /etc/dwatch-log-agent/config.json > /dev/null <<'EOF'
{ …§5.2… }
EOF
sudo chown dwatch-log-agent:dwatch-log-agent /etc/dwatch-log-agent/config.json
sudo chmod 600 /etc/dwatch-log-agent/config.json

# 5. ログのフォルダの読み取り権限だけを与える（例: ACL。★与え方は §11.1-6 で制御盤の担当が決める★）
#    ★/home/dfa 全体を読めるようにしないこと★（認証情報らしきファイルがある。§1.6）
#    ★/home/dfa は 700 なので、下の2行だけでは届かない★。通過（x）だけを与える行が要る（§5.13）
sudo setfacl -m u:dwatch-log-agent:x /home/dfa   # 通過だけ（一覧・読み取りは与えない）
sudo setfacl -R -m u:dwatch-log-agent:rX /home/dfa/log
sudo setfacl -R -d -m u:dwatch-log-agent:rX /home/dfa/log   # 今後できるファイルにも

# 6. サービス
sudo tee /etc/systemd/system/dwatch-log-agent.service > /dev/null <<'EOF'
（§5.3）
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now dwatch-log-agent

# 7. 確認
systemctl status dwatch-log-agent
journalctl -u dwatch-log-agent -n 50
timedatectl                                  # 時計が合っていて Asia/Tokyo か（§11.1）
```

8. D-Watch の画面でその制御盤が「接続中」になり、一覧が出ることを確かめる

手順4のトークンは、D-Watch の画面で ★発行した直後に1回だけ表示される★もの（§8.1）。

**更新**: 手順3（貼り付け → `sha256sum` で確認）→ `sudo systemctl restart dwatch-log-agent` → 手順7・8。
★更新中は進行中のエクスポートが失敗する★（利用者に知らせる）。

**トークンの入れ替え**: D-Watch の画面で「発行（作り直し）」（古いものは無効になる）→ 手順4 → `sudo systemctl restart dwatch-log-agent`。

**止める・外す**: `sudo systemctl disable --now dwatch-log-agent` → ファイルとユーザーを消す → D-Watch の画面で「失効」。

### 5.13 配布と制御盤ごとの値（開発機 → 本番機）

★**前提**★: §1.6 で実測した `T000000002` は ★開発機★。本番機（各店舗の制御盤）は ★毎日12:00に root で origin の master を取り込む（`git fetch origin master` → `git reset --hard origin/master`）★（2026-10-19 本番機の実測。§1.6.1）。★反映されるのは「master に push したもの」で、開発機の `/home/dfa` の変更がそのまま届くわけではない★（開発機の `/home/dfa` の HEAD は master と違う。どこで編集して push しているかは §11.1-12）。

★**リポジトリと更新の仕組み（2026-10-19 開発機・本番機の実測。§1.6・§1.6.1）**★:

| 項目 | 実測・推測 |
|---|---|
| リポジトリ | ★`/home/dfa` 全体★（本番機も `cd /home/dfa` で取り込む）。origin は `https://git.d-sec.dfarobotics.com/git/develop/client_dsecurity.git`。★本番機の HEAD は `5efdb2b`（master）★ |
| `.gitignore` | ★master は `device_info.txt` / `current_token.txt`★（開発機の `/home/dfa` の `*.txt` / `.cache/` / `.config/` は `db374ba` で足したもので、本番機には入っていない）→ ★既存の制御盤ごとの値は「`/home/dfa` 直下の `*.txt`」に置いて git で配らない作り★ |
| ★更新（実測で確定）★ | ★本番機: `/etc/cron.d/d-sec` が毎日 12:00 に root で `/etc/cron.shell/update_dscontrol.sh` を起動する★。中身は `cd /home/dfa` → `git config --global --add safe.directory /home/dfa` → `git fetch origin master` → ★`git reset --hard origin/master`★。（開発機の `/etc/cron.d/d-sec` は毎分・`dfa`・スクリプトが無い。★開発機と本番機で cron の中身も違う★） |
| ★追跡中のファイルの扱い★ | ★本番機で追跡中のファイルを書き換えても、毎日12:00に master の内容へ戻される★（ローカルの変更は消える） |
| ★追跡していないファイル★ | ★残る★（`reset --hard` は untracked・ignore されたファイルを消さない。`git clean` は無い）→ ログ（`~/log`）や `*.txt` は消えない |
| ★取り込みの後の再起動・後処理★ | ★無い★（スクリプトは reset で終わる）。新しいコードが動くのは、OS の再起動か、異常終了で systemd が再起動したとき。★→ 差分があればラズパイごと再起動する改訂案を §5.13.1 に置いた（ユーザー要望 2026-10-19）★ |
| ファイルの所有者 | ★root で reset するので、書き換わったファイルの所有者が root になりうる★（推測） |
| 既存のサービスの再起動の設定 | 説明が「…を実行 (異常終了時のみ再起動)」＝ ★`Restart=on-failure` と推測★ → ★終了コード 0 で終わると再起動されない★ |
| `/home/dfa` の権限 | ★700★（専用ユーザーは中に入れない。開発機の実測） |

**git で配るもの・配らないもの**:

| 物 | git に入れるか | 置き場所 |
|---|---|---|
| 常駐プログラムのコード（1ファイルの Python `agent.py`） | ★入れる★ → ★master に push すれば翌日12:00に本番機へ配られる★ | git リポジトリ（本番機では `/home/dfa`）の中。★ただし実行は下の「コードの置き場所」★ |
| systemd のユニットの雛形（§5.3） | ★入れる★（雛形として。`/etc/systemd/system/` への配置は1台ずつ。下の表） | 同上 |
| 制御盤ごとに違う値（`serial_number`・`token`） | ★**入れない**★ | 下の2案のどちらか（★制御盤の担当が決める★） |

**制御盤ごとの値の置き場所（2案）**:

| 案 | 置き場所 | 長所 | 短所 |
|---|---|---|---|
| ★A（監督の推奨）★ | ★リポジトリの外の `/etc/dwatch-log-agent/config.json`★（600・専用ユーザーの所有。§5.2・§5.12 の手順4） | ★専用ユーザーは 700 の `/home/dfa` に入れないので、`/home/dfa` の中には置けない★。★トークンを 644 で置かずに済む★。★リポジトリの外なので毎日12:00の `reset --hard` の影響を受けない★（§1.6.1） | 既存の作り（`*.txt`）とは揃わない |
| B | 既存に倣い `/home/dfa` 直下の `*.txt` | 既存の作りと揃う。★untracked なので `reset --hard` では消えない★ | ★専用ユーザーが読めない★（`/home/dfa` が 700）。読ませるには `/home/dfa` 全体の扱いを変えることになる。★既存の `current_token.txt` と同じく 644 で置かれやすい★。★master の `.gitignore`（`device_info.txt` / `current_token.txt`）にその名前を足さないと `git status` に出続ける（実害は無い）★ |

★**`serial_number`・`token` を git に入れてしまうと、全店舗が開発機のトークンとシリアルで接続し、どの店舗のログか区別できなくなる**★（D-Watch 側ではトークンに紐づく serial で制御盤を識別する。`serial_number` は D-Watch へ送らないが、★トークンが同じなら全店舗が開発機として扱われる★。§5.2・§8.1 の「1台1トークン」）。★B を採る場合は、master の `.gitignore` にそのファイル名を足す★（★master の `.gitignore` は `*.txt` ではなく `device_info.txt` / `current_token.txt` の名指し★。§1.6.1）。★逆に、追跡中のファイルに書いた値は毎日12:00に master の内容へ戻る（消える）★。

**専用ユーザーにログを読ませる方法（§11.1-6 の具体化）**: ★`/home/dfa` が 700 なので、`/home/dfa/log` に ACL を付けるだけでは届かない★。次のどちらか（★制御盤の担当が決める★）。

| 案 | 内容 |
|---|---|
| ア | ★`/home/dfa` に通過（x）だけを ACL で与え★（一覧・読み取りは与えない）、★`/home/dfa/log` に読み取り（`rX`）を与える★（§5.12 の手順5） |
| イ | ★ログの出し先を `/home/dfa` の外へ移す★（例: `/var/log/dsecurity/`。`shared_utils.py` の `LOG_DIR` を変える。§5.2 の `roots[].path` も変える） |

★コードの置き場所の注意（推測。制御盤の担当が決める）★: §5.3 の `ExecStart` は `/opt/dwatch-log-agent/agent.py` を指している。リポジトリは `/home/dfa` 全体なので、`ExecStart` をリポジトリの中のパスにするか、取り込みの後に `/opt/` へ写す手順が要る。★前者なら専用ユーザーがそのファイルを読める必要があり、700 の `/home/dfa` と、`/home/dfa` 全体の読み取りを与えない方針（§11.1-6）と両立させること★（ア の ACL でそのファイルだけに読み取りを与える等）。★後者は、取り込みの後に後処理が無い（§1.6.1）ので、`update_dscontrol.sh`（リポジトリの外。`/etc/cron.shell/`）を1台ずつ書き換えるか、常駐プログラムの起動時に写すなどの工夫が要る★。★前者の場合、`reset --hard` は root で走るので、書き換わったファイルの所有者が root になりうる（推測）★。読み取り（644）なら専用ユーザーの実行に支障は無い見込みだが、★ACL をファイルに付けた場合は、reset でファイルが作り直されると ACL が外れる恐れがある★（フォルダに既定の ACL（`setfacl -d`）を付けておく。推測）。

**git の取り込みでは済まない作業（本番機1台ごとに1回）**:

| # | 作業 | §5.12 の手順 |
|---|---|---|
| 1 | 専用ユーザー `dwatch-log-agent` の作成 | 手順1 |
| 2 | `/home/dfa/log` への読み取り権限の付与 | 手順5 |
| 3 | `/etc/dwatch-log-agent/config.json` の作成（★D-Watch の画面でその制御盤用に発行したトークンを貼る★。§8.1） | 手順2・4 |
| 4 | systemd ユニットの配置と `systemctl enable --now` | 手順6 |

→ ★取り込みの後に何かを実行する仕組みは無い★（`update_dscontrol.sh` は reset で終わる。§1.6.1）ので、★Pi Connect の Remote shell で1台ずつ行う★（§5.12 のア）か、★Remote update（§5.12 のイ）で初回セットアップのスクリプトを root で実行する★。★トークンだけはスクリプトに入れられない★（制御盤ごとに違う。画面で発行して1台ずつ貼る）。

**コードを更新したときの再起動（監督の推奨）**: ★**下の §5.13.1 の改訂案（差分があればラズパイごと再起動）が全本番機に入れば、この仕組みは不要**★（更新のたびに OS ごと再起動し、常駐プログラムも新しいコードで起動するため）。★以下は、改訂案が入るまでのつなぎ、または入らなかった場合の代わりとして残す★。
★取り込みの後にサービスの再起動は無い★（§1.6.1）ので、★そのままでは新しいコードは次の再起動まで動かない★。そこで ★常駐プログラムは自分のファイルが書き換わったこと（例: 更新日時や `sha256`。ポーリングの合間に確かめる）を検知して終了し、systemd に再起動させる★。★常駐プログラムのユニットは `Restart=always` にする★（§5.3）。★既存のサービスは `Restart=on-failure`（推測）なので、終了コード 0 で終わると再起動されない★ — ★`Restart=always` を `on-failure` に変えないこと★（変えるなら終了コードを 0 以外にする）。★再起動の瞬間に進行中のエクスポートは失敗する★（§5.12 の「更新」と同じ。検知しても進行中のエクスポートが終わるまで待ってから終了するのが望ましい（補い））。

**開発機で試してから push する**: ★master に push したものは、次の 12:00（12:00 より後の push なら翌日12:00）に全本番機へ入る★（root の cron。§1.6.1）。★ただし動くのは再起動の後★（上の「再起動」）。★取り込みは自動なので、push した不具合は次の 12:00 に全店舗へ広がる★。§5.11 の確かめを開発機で済ませてから push する。★本番機で直接直したもの（追跡中のファイル）は毎日12:00に戻る★。

**開発機の登録**: ★開発機自身も制御盤の1台として D-Watch に登録し、トークンを発行して試す★（開発機のトークンは開発機の `/etc/dwatch-log-agent/config.json` にだけ置く）。

#### 5.13.1 更新スクリプトの改訂案（差分があればラズパイごと再起動）

★**ユーザー要望（2026-10-19）**★: 「本番機は、もし差分があって更新したら再起動するようにもしたいです。差分がなければそのままで」。
★**再起動は ラズパイごと（OS の再起動）**★（ユーザー回答 2026-10-19。サービスだけの再起動ではない）。

★対象は本番機の `/etc/cron.shell/update_dscontrol.sh`★（`/etc/cron.d/d-sec` から毎日 12:00 に root で起動される。§1.6.1）。★cron（`/etc/cron.d/d-sec`）は変えない★。改訂案（これをそのまま置く）:

```bash
#!/bin/bash
# 毎日12:00（/etc/cron.d/d-sec）に root で実行する。
# master に差分があれば取り込み、ラズパイごと再起動する。差分が無ければ何もしない。
# 動作は journald に残す（確認: journalctl -t update_dscontrol）
set -u
TAG=update_dscontrol
log() { /usr/bin/logger -t "$TAG" "$*"; }

cd /home/dfa || { log "ERROR: cd /home/dfa に失敗。何もしない"; exit 1; }

# 毎回 --add すると同じ行が増えるので、無いときだけ足す
/usr/bin/git config --global --get-all safe.directory | /usr/bin/grep -qx /home/dfa \
  || /usr/bin/git config --global --add safe.directory /home/dfa

before=$(/usr/bin/git rev-parse HEAD) || { log "ERROR: HEAD を読めない。何もしない"; exit 1; }

if ! /usr/bin/git fetch origin master; then
  log "ERROR: fetch に失敗（ネットワーク等）。何もしない"
  exit 1
fi
target=$(/usr/bin/git rev-parse origin/master) || { log "ERROR: origin/master を読めない。何もしない"; exit 1; }

# 追跡中のファイルを本番機で直接書き換えていた場合も「差分あり」とする（reset で戻るため）
dirty=$(/usr/bin/git status --porcelain --untracked-files=no)

if [ "$before" = "$target" ] && [ -z "$dirty" ]; then
  log "差分なし（${before}）。何もしない"
  exit 0
fi

if ! /usr/bin/git reset --hard origin/master; then
  log "ERROR: reset に失敗（${before} -> ${target}）。再起動しない"
  exit 1
fi

log "更新した（${before} -> ${target}、本番機での直接の変更: $([ -n "$dirty" ] && echo あり || echo なし)）。ラズパイを再起動する"
/usr/bin/sync
/usr/bin/systemctl reboot
```

**今のスクリプト（§1.6.1）との違い**:

| 項目 | 今 | 改訂案 |
|---|---|---|
| 差分が無いとき | ★毎回 `reset --hard` する★（再起動は無い） | ★reset も再起動もしない★（「差分なし」を記録して終わる） |
| 差分があるとき | `reset --hard` で終わる（★新しいコードは次の再起動まで動かない★） | ★`reset --hard` のあと OS を再起動する★（`sync` → `systemctl reboot`） |
| 失敗したとき（fetch・reset） | 何も残らない（cron の出力は捨てられる見込み。推測） | ★再起動しない★。★ERROR を記録して終わる★ |
| 記録 | 無い | ★journald にタグ `update_dscontrol` で記録する★（`logger -t`。確認: `journalctl -t update_dscontrol`） |
| `safe.directory` | ★毎回 `--add` する（同じ行が root の `~/.gitconfig` に1日1行ずつ増える）★ | ★無いときだけ足す★（重複追加をやめた） |

★**「差分」の定義**★（監督判断 2026-10-19）: 次のどちらか。
- ★master のコミットが進んだとき★（`HEAD` ≠ `origin/master`）
- ★本番機で追跡中のファイルが直接書き換えられていたとき★（`git status --porcelain --untracked-files=no` が空でない。reset で master の内容へ戻るので、動いているコードが変わる）

★後者を外す（コミットが進んだときだけ再起動する）なら、`dirty` の条件を消す★（`dirty=…` の行と `&& [ -z "$dirty" ]` を消し、記録の文言から「本番機での直接の変更」を外す）。★追跡していないファイル（ログ・`*.txt`）は差分に数えない★（`--untracked-files=no`。`reset --hard` でも消えない。§1.6.1 #2）。

**注意**:

| # | 注意 |
|---|---|
| 1 | ★**再起動の間（1分前後・推測）は、制御盤からの生死報告が止まる**★。12:00 は報告間隔が 60秒の時間帯（第10回の設定。開発機の `postcondition_kick_action` のログに「INV_TIME.txt を 60 に更新しました」（2026-10-19 15:46）、`api_client` のログに「API値: 60」。§1.6）なので、★**更新があった日は 12:00 過ぎに「生死報告なし」（`D-S_CONTROL_FAILURE`）のエラーや D-Sec アラートが出うる**★。★**許容する（対応不要として見送る）**★（ユーザー判断 2026-10-19「この時間帯のアラートであればなんの問題もないのでスルーでいい」。§11.1-16 解消） |
| 2 | ★12:00 は清掃（夜間）とは重ならない見込み★（推測）。★セキュリティの解除・復帰の処理中に再起動しないか★は、★制御盤の担当が確かめる★ |
| 3 | ★**このスクリプトは git で配られない**★（`/etc/cron.shell/` の下。リポジトリ `/home/dfa` の外）ので、★本番機1台ずつ一度だけ入れ替える★（§11.1-17）。手順は下 |
| 4 | ★**先に開発機で試す — ただし開発機の `/home/dfa` では動かさない**★。開発機は cron の中身が違う（毎分・`dfa`・スクリプトが無い。§1.6.1）ので、cron は変えずに手で1回動かすことになる。★**ところが開発機の `/home/dfa` は master と違う状態（HEAD `db374ba`。§1.6.1 #5）なので、このスクリプトを動かすと「差分あり」になり、`reset --hard origin/master` で開発機の `/home/dfa` が master に戻ってしまい、そのうえ開発機が再起動する**★。★**開発機では `/home/dfa` に向けて動かさないこと**★。試すなら ★別の clone★（例: `/home/dfa` とは別のフォルダに master を clone し、スクリプトの写しの `cd /home/dfa` と `safe.directory` のパスをそこに変え、★`systemctl reboot` の行を `echo` に変えて★ `sudo bash <写し>` で動かす）で、「差分なし」と、コミットを1つ戻したときの「更新した」の記録（`journalctl -t update_dscontrol`）を確かめる |
| 5 | ★journald が再起動をまたいで残るか★（推測で残る。journald が 1GB 使っている。§1.6）。`journalctl --list-boots` に前の起動が並べば残っている。★残らない設定なら、「更新した」の記録は再起動で消える★（その場合は手順5の確認ができない） |
| 6 | 更新のたびに OS ごと再起動するので、★既存の3つのサービス（`Restart=on-failure` と推測）も常駐プログラムも、起動時に新しいコードで立ち上がる★（いずれも enabled。§1.6）。★再起動の瞬間に進行中のエクスポートは失敗する★（§5.12 の「更新」と同じ。12:00 に取り出しをしている利用者は少ない見込み） |

**本番機への入れ替えの手順**（1台ずつ。Pi Connect の Remote shell で。sudo が要る）:

```bash
# (1) 今のスクリプトを控える
sudo cp -p /etc/cron.shell/update_dscontrol.sh /etc/cron.shell/update_dscontrol.sh.bak

# (2) 新しい内容を書く（上の改訂案を heredoc で貼る。EOF までが本体）
sudo tee /etc/cron.shell/update_dscontrol.sh > /dev/null <<'EOF'
（上の改訂案の全文）
EOF

# (3) 文法を確かめる（何も出なければよい）
sudo bash -n /etc/cron.shell/update_dscontrol.sh

# (4) 実行できるようにする
sudo chmod 755 /etc/cron.shell/update_dscontrol.sh

# (5) 翌日 12:00 過ぎに、「差分なし」または「更新した」が記録されたことを確かめる
journalctl -t update_dscontrol --since today
```

★(1) の控えは、新しい内容に問題があったときに `sudo cp -p /etc/cron.shell/update_dscontrol.sh.bak /etc/cron.shell/update_dscontrol.sh` で戻すため★。改訂案の fetch は今と同じ `git fetch origin master`（origin は `/home/dfa/.git/config` のもの）。★手順5で「ERROR: fetch に失敗」が続くなら、控え (1) と見比べる★。

★**常駐プログラムへの影響**★: ★更新のたびにラズパイごと再起動するので、常駐プログラムが自分の更新を検知して終了する仕組み（上の「コードを更新したときの再起動」）は不要になる★。★ユニットは `Restart=always` のまま★（§5.3。異常終了の備え）。★改訂案が全本番機に入るまでは、上の仕組みをつなぎとして残す★（入らなかった場合はそれが代わり）。

---

## 6. D-Watch 側の部品の一覧（新規・変更）

★詳細（期待値表・段階・テスト）は指示書AY（`docs/handoff/指示書AY_制御盤のログをD-Secログ画面から取り出す_20261019.md`）に譲る★。

| ファイル | 役割 |
|---|---|
| `d-watch-api.yaml` | §4 のブラウザ向け6本・制御盤向け6本を足す（★先に直す★） |
| `lib/config/ds-agent.ts` | 設定値（§7.1）。★ハードコード禁止★ |
| `lib/ds-agent/relay.ts` | ★メモリ内の中継★。制御盤ごとの指示の待ち行列・ポーリングの待ち合わせ・list の結果の待ち合わせ・export の準備の待ち合わせ・send のストリーム。★モジュールのシングルトン（開発時の HMR に備え `globalThis` に置く）★。★1プロセス前提★。時刻とタイマーを注入できる形にしてテストする |
| `lib/ds-agent/tokens.ts` | 制御盤トークンの発行・失効・発行日時の読み取り・検証。保存は DFA 独自ストア `data/settings/ds-agent-tokens.json`（`createJsonStore`）に `{ [serial_number]: { token_sha256, issued_at } }`。★生のトークンは保存しない★。比較は `timingSafeEqual`。ファイルの mtime を見てメモリにキャッシュしてよい（M10） |
| `app/api/ds-devices/[serial_number]/agent-token/route.ts` | トークンの発行日時の読み取り（GET。指示書AY Q14）・発行（POST）・失効（DELETE。未発行でも 204。Q15）（M8）。★`scripts/ds-agent-token.ts` は作らない★ |
| `lib/ds-logs/query.ts` | 一覧の絞り込み（`types` / `from` / `to`）・並べ替え・ページ分けの ★純粋関数★ |
| `lib/ds-logs/presets.ts` | 期間のボタン（直近1時間 / 3時間 / 24時間 / 全て）を `now` から `from` に変える純粋関数（★`to` は省略★。M10） |
| `lib/ds-logs/file-name.ts` | zip のファイル名 `{serial}_logs_{YYYYMMDD-HHmmss}.zip`（JST・シリアルの置換）の純粋関数 |
| `lib/ds-logs/validate.ts` | クエリ・本体の検証（422）と、制御盤から来た `result` / `prepared` / `complete` / `fail` の検証（400 `result_invalid`） |
| `lib/proxy/authenticate.ts`・`proxy.ts` | `/api/ds-agent/` 配下は ★cookie では通さず★ Bearer トークンを検証（§8）。proxy を async にし、`authenticateAgent()` を分ける（M10） |
| `lib/ds-agent/require-agent.ts` | 制御盤向けのルートの先頭でトークンを再検証して serial を得る `requireAgent()`。通ったら `last_seen_at` を更新（M4） |
| `app/api/ds-devices/[serial_number]/logs/…` | ブラウザ向けの一覧・準備・ダウンロードのルート |
| `app/api/ds-agent/commands/…` | 制御盤向け6本のルート。★ルートでも `requireAgent(request)` でトークンを再検証して serial を得る★ |
| `lib/logger/events.ts` | §8.5 のイベントを足す |
| `lib/fixtures/ds-device-logs.ts` | 偽の一覧（種類3つ・数百件・境界の時刻を含む）と小さな zip（base64）。★偽の制御盤は relay の同じ経路（待ち行列 → 結果）を通す★。1台は「未接続」にして 409 を出す |
| `lib/ds-agent/fixture-agent.ts` | fixtures のときに relay へポーリングして指示に答える偽の制御盤（`ensureFixtureAgent()`） |
| `scripts/ds-agent-fake.ts` | 開発用の偽の制御盤（コミットする。M10） |
| `components/features/ds-logs/`（`DsLogScreen.tsx` を変更・`DeviceLogsModal.tsx` と `.module.css` を新規） | D-Sec ログの表の各行に「ログ」ボタン → モーダル（`components/features/detail-log/AreaModal.tsx:65` の作りに倣う）。文言・配置は監督の設計を正とする（★モックに無い画面★） |
| `lib/i18n.ts` | JP/EN の辞書に足す |
| `lib/types/index.ts` | 型を足す（YAML のスキーマ名と揃える） |

画面の要点（監督の設計から）:

| 部分 | 内容 |
|---|---|
| 見出し | 「制御盤のログ」＋シリアル・施設名、接続状態（接続中 / 未接続・最終接続 日時） |
| 種類 | 制御盤から来た `types` をチェックボックスで（既定は全部） |
| 期間 | 「直近1時間」「直近3時間」「直近24時間」「全て」＋日付の範囲。★既定は直近24時間★（M10） |
| 並べ替え | 「日付」「種類＋日付」 |
| 一覧 | チェックボックス・ファイル名・種類・大きさ・更新日時。「このページをすべて選択」。ページ送り（50/100/200件）。★条件を変えたら選択を消す★（M10） |
| 打ち切り | `truncated` のとき「件数が多いため、新しいものから一部だけを表示しています」（M3） |
| 選択中 | 「選択: N件・合計 X MB」。上限（100MB）を超えたら警告し、エクスポートのボタンを押せなくする |
| エクスポート | POST → 「制御盤で準備中…」→ 返ってきた `download_url` を `<a href download>` の click() で開く（M1） |
| 制御盤トークン | モーダルの下部。発行日時の表示（`GET …/agent-token`。★制御盤が未接続でも使える★）・「発行（作り直し）」「失効」ボタン。発行時は確認ダイアログ → ★発行したトークンを1回だけ表示してコピーさせる★（M8） |
| エラーの文言 | 未接続「制御盤が接続されていません（最終接続: …）」/ 応答なし「制御盤から応答がありません。しばらくしてから再度お試しください」/ 混雑「この制御盤は別のエクスポートを処理中です」 |

★指示書AX（D-Sec アラート。同日作成）と担当ファイルが重なる★（`components/features/ds-logs/`・`d-watch-api.yaml`・`lib/i18n.ts`・`lib/types/index.ts`・`lib/fixtures/index.ts`）。★AX の実装が終わってから AY に着手する★。

---

## 7. 設定値の一覧

### 7.1 D-Watch 側（`lib/config/ds-agent.ts`）

| 名前 | 値 | 意味 |
|---|---|---|
| `AGENT_POLL_WAIT_SEC` | 25 | ロングポーリングの `wait` の上限 |
| `AGENT_OFFLINE_AFTER_SEC` | 60 | 制御盤の最後のリクエスト（6本どれでも。M4）からこれを超えたら「未接続」（409） |
| `LIST_TIMEOUT_SEC` | 15 | list の結果を待つ時間（超えたら 504） |
| `EXPORT_PREPARE_TIMEOUT_SEC` | 50 | export の `prepared` を待つ時間（超えたら 504）。nginx の60秒より短く（M1） |
| `EXPORT_CLAIM_TIMEOUT_SEC` | 60 | `prepared` の後、`download_url` を開くまでの期限。超えたら export を失効させ、★制御盤には次のポーリングで何も送らない★（制御盤は120秒で zip を消す。監督判断 r） |
| `EXPORT_FIRST_CHUNK_TIMEOUT_SEC` | 30 | ダウンロードを開いてから最初のチャンクまで（超えたら 504） |
| `EXPORT_IDLE_TIMEOUT_SEC` | 45 | 流し始めた後のチャンクの間隔の上限、および ★チャンクに 204 を返すまでの待ち（流量制御）の上限★。超えたら export を失敗にする。nginx の60秒より短く（M5） |
| `CHUNK_MAX_BYTES` | 4 × 1024 × 1024 | 1チャンクの上限 |
| `EXPORT_MAX_BYTES` | 100 × 1024 × 1024（★仮★） | 1回のエクスポートの上限（★zip の大きさ★。M1） |
| `EXPORT_MAX_FILES` | 500 | 1回に選べるファイル数の上限 |
| `EXPORT_CONCURRENCY_PER_DEVICE` | 1 | 1台あたりの同時エクスポート数。★export の指示から、`prepared` を経て `send`・`complete`・`fail`・失効までを「進行中」に数える★（監督判断 r） |
| `EXPORT_CONCURRENCY_TOTAL` | 3 | 全体の同時エクスポート数 |
| `LIST_RESULT_MAX_BYTES` | 4 × 1024 × 1024 | list の結果（JSON）の上限。proxy の10MB未満（M3） |
| `LIST_RESULT_MAX_ITEMS` | 20000 | list の件数の上限（制御盤はここで打ち切り `truncated`。M3） |
| `PENDING_COMMANDS_PER_DEVICE` | 5 | 1台あたりの待ち行列の上限（超えたら 429 `agent_busy`。M10） |
| `DS_LOG_PER_PAGE_OPTIONS` / `DS_LOG_DEFAULT_PER_PAGE` | `[50, 100, 200]` / 200 | 画面のページあたり件数 |

★リクエストを待たせる値（15・25・30・45・50秒）はすべて nginx の `proxy_read_timeout`（既定60秒）より短い★。

### 7.2 制御盤側（`/etc/dwatch-log-agent/config.json` とプログラム内の定数）

| 名前 | 値 | 意味 |
|---|---|---|
| `poll_wait_sec` | 25 | ロングポーリングの `wait` |
| ポーリングのタイムアウト | `poll_wait_sec` ＋ 10 秒 | |
| バックオフ | 5 → 10 → 20 → 40 → 60 秒（上限60） | 接続エラー・5xx のとき |
| 401 / 403 の待ち | 60 秒 | |
| チャンクの POST のタイムアウト | 60 秒（M5） | |
| send を待つ時間 | prepared から 120 秒（M1） | 過ぎたら zip を消す |
| `max_export_bytes` | 104857600（100MB。★仮★） | zip の大きさの上限 |
| `chunk_bytes` | 4194304（4MB） | |
| list の上限 | 新しい順に 20000 件・JSON 4MB（M3） | |
| `temp_dir` | `/var/tmp/dwatch-log-agent` | |
| `roots` | ★4つ（`/home/dfa/log/<フォルダ名>`。プログラム別フォルダごと・`recursive: false`・種類は各1つ）★（§5.2。★2026-10-19 実測で埋めた。label は仮★。★`/home/dfa/log` 直下のファイルは対象外★） | |

---

## 8. セキュリティ

★遠隔で制御盤のファイルを読める経路は、店舗ネットワークへの入口になる★（D-179）。次で守る。

### 8.1 制御盤トークン（発行・保管・失効）（M8）

| 段階 | 内容 |
|---|---|
| 発行 | ★D-Watch の画面（ログのモーダルの下部「制御盤トークン」）から★。API は `POST /api/ds-devices/{serial_number}/agent-token`（既存の cookie 認証。DFA 社内だけが使う）。★32バイトの乱数を base64url★（43文字）。★生の値は応答で1回だけ★。既にあれば作り直し、古いものは無効 |
| D-Watch 側の保管 | `data/settings/ds-agent-tokens.json` に ★SHA-256 だけ★（`token_sha256`・`issued_at`）。★生のトークンは保存しない★。メインDBには書かない。★再デプロイで消えないこと（Docker ならボリューム）★。`docs/deploy.md` への申し送りは監督が書く |
| 制御盤側の保管 | `/etc/dwatch-log-agent/config.json`（600・所有者は専用ユーザー） |
| 渡し方 | 発行した人が、画面に1回だけ出た値をコピーし、Pi Connect の Remote shell で設定ファイルに貼る。★チャット・メール・チケットに貼らない★（補い） |
| 比較 | `timingSafeEqual` |
| 読み取り | 画面は発行日時だけを `GET /api/ds-devices/{serial_number}/agent-token`（`{ issued_at \| null }`）で読む。★トークンの値・ハッシュは返さない★（指示書AY Q14） |
| 失効 | 画面の「失効」（`DELETE /api/ds-devices/{serial_number}/agent-token`）。失効後の要求は 401。★未発行でも 204（冪等）★（指示書AY Q15） |
| 入れ替え | 画面の「発行（作り直し）」→ 設定ファイルを書き換え → 再起動（§5.12） |
| 1台1トークン | トークンから serial が決まる。★ヘッダで serial を渡さない★（詐称の余地を作らない） |
| ★サーバー上のスクリプトでは発行しない★ | 本番のサーバーは TeamWorks が構築保守する（D-143） |

`controls.password`（`prisma/schema.prisma:205`）は ★読まない★（TeamWorks の DB の認証情報を流用しない。D-179）。

### 8.2 D-Watch の認証の分け方

| パス | cookie | Bearer |
|---|---|---|
| `/api/ds-agent/` 配下 | ★通さない★ | ★必須★。無い・違えば 401 |
| それ以外（トークンの読み取り・発行・失効を含む） | 必須（既存のまま） | ★無視する★（cookie が要る） |

- ★IP 制限は今のまま全パスにかける★（agent パスも。判定の順序は IP → トークン）
- proxy を async にし、制御盤の判定は `authenticateAgent()` に分ける（M10）
- ★proxy の判定だけに頼らない★。ルートでも `requireAgent(request)` でトークンを再検証する

### 8.3 制御盤で読めるもの（許可リスト方式）

- ★設定ファイルの `roots` × `patterns` に合う通常のファイルだけ★
- シンボリックリンクは一覧に出さず、export でも拒む。realpath が root の内側かを確かめる（§5.6）
- ★`list` / `export` / `send` 以外は実行しない★。シェルのコマンドを実行する機能は持たない

### 8.4 root で動かさない

- 専用ユーザー `dwatch-log-agent`（ログインできない）
- ログのフォルダの ★読み取り権限だけ★。書けるのは `temp_dir` だけ
- systemd の `NoNewPrivileges` / `ProtectSystem=strict` 等（§5.3。推奨）
- ★Pi Connect の Remote update（§5.12 イ）は導入スクリプトを root で動かす★。導入の時だけで、常駐プログラム自体は専用ユーザーで動く

### 8.5 ログに出さないもの

| D-Watch のイベント | レベル | 中身 |
|---|---|---|
| `ds_agent.connected` / `ds_agent.disconnected` | info | serial_number, agent_version。★状態が変わったときだけ★ |
| `ds_agent.auth_failed` | warn | route, reason（`missing` / `invalid`）。★トークンの値は出さない★ |
| `ds_agent.token_issued` / `ds_agent.token_revoked` | info | ★serial_number だけ★（M8） |
| `ds_logs.list` | info | serial_number, item_count, truncated, duration_ms, result |
| `ds_logs.export_prepared` | info | serial_number, file_count, total_bytes, duration_ms（準備の 201。M1） |
| `ds_logs.export_started` | info | serial_number, file_count（流し始めたとき） |
| `ds_logs.export_completed` | info | serial_number, file_count, total_bytes, duration_ms |
| `ds_logs.export_failed` | warn | serial_number, stage（`prepare` / `send`）, reason, bytes_sent |

細部（どこで出すか）は指示書AY 段階10 が正。

★**ファイルの中身・ファイル名の一覧全体・トークンは出さない**★（ファイル数とバイト数だけ）。制御盤側も同じ（§5.9）。

### 8.6 IP 制限とリリース時の論点

- いまは `ALLOW_ALL_WHEN_EMPTY = true`（`lib/config/allowed-ips.ts:35`）なので、制御盤からの要求も通る
- ★リリース時に許可リストを入れると、店舗の IP が載らない限り制御盤が弾かれる★
- ★リリース直前のセキュリティ強化（D-154 項3。`docs/decisions.md:12417`）で次のどちらかを決める。いまは決めない★:

| 選択肢 | 内容 |
|---|---|
| ア | 店舗の IP を許可リストに入れる（店舗の回線が固定 IP かどうかに依存する。★未確認★） |
| イ | agent パスだけ IP 制限を外し、トークン必須にする |

---

## 9. 運用上の制約

| 制約 | 内容 |
|---|---|
| ★1プロセス前提★ | 中継はメモリの中にある。★`next start` を複数立てる・cluster にする・複数台に分けると壊れる★（制御盤がつないだプロセスとブラウザがつないだプロセスが違うと届かない） |
| ★再起動で進行中のものは失われる★ | D-Watch の再起動・デプロイで、準備中・送信中の export・待ち中の list は失敗する。制御盤は再接続するが、エクスポートはやり直し（制御盤の zip は120秒で消える） |
| 同時数の上限 | 1台に同時1本・全体で同時3本（429 `export_busy`）。★`prepared` からダウンロードを開くまでの間（最大60秒）も1本に数える★（監督判断 r）。待ち行列は1台5件（429 `agent_busy`） |
| 上限 | 1回 zip で 100MB（★仮★）・500ファイル。一覧は新しい順に2万件・4MB まで |
| 準備の待ち | POST は最大50秒かかる（その間「制御盤で準備中…」） |
| ダウンロード URL | ★1回だけ・60秒で失効★ |
| ★ダウンロードの失敗が JSON のファイルになることがある★ | (3) が JSON のエラー（504 / 502 / 404）を返すと、`<a href download>` で開いているため ★JSON がファイルとして保存されうる★。準備の失敗は (2) で返るので、起きるのは制御盤の切断などまれな場合だけ（M1。既知の制約） |
| 未接続の判定 | 制御盤の最後のリクエストから60秒。制御盤の再起動直後や回線の瞬断で一時的に「未接続」になる |
| 制御盤側の更新 | 常駐プログラムの再起動中はエクスポートできない |
| トークンの保存先 | `data/settings/ds-agent-tokens.json` は再デプロイで消えないこと（Docker ならボリューム）。消えると全制御盤が 401 になり、発行し直しが要る（M8） |

---

## 10. TeamWorks への依頼（第13回 S-5）

D-Watch のサーバー（nginx）について、次をお願いする（第13回 S-5。`docs/handoff/TeamWorks確認事項_第13回_20261019_送付版.md:78` 以降）。

| # | 依頼 | 設定の例 |
|---|---|---|
| 1 | 店舗の制御盤から D-Watch のサーバーへ ★HTTPS（443番）★で届くこと（当方でも制御盤から確かめる） | — |
| 2 | `/api/ds-agent/` 配下のリクエスト本体を ★5MB まで★受けること。★本体をディスクの一時ファイルに書かないこと★ | `client_max_body_size 5m;` と、`client_body_buffer_size 5m;`（以上）または `proxy_request_buffering off;` |
| 3 | D-Watch が応答に付ける `X-Accel-Buffering: no` を有効にしておくこと。★応答をディスクの一時ファイルに書かないため★ | `proxy_ignore_headers` で `X-Accel-Buffering` を消さない。または `/api/ds-devices/*/logs/exports/` で `proxy_buffering off;` |
| 4 | `proxy_read_timeout` を ★60秒より短くしない★こと（D-Watch が1回の応答を待たせるのは最大50秒: ポーリング25秒・チャンクの受け取り45秒・エクスポートの準備50秒。既定の60秒のままで足りる） | 既定のまま |

---

## 11. 未決

### 11.1 監督の設計にある未決

| # | 未決 | 決まったら直す場所 |
|---|---|---|
| 1 | ★ログのフォルダ・ファイル名の規則・種類★ — ★実測で埋めた（label は仮。ユーザー確認待ち）★（2026-10-19。§1.6・§5.2）。残りは ★label の確定★。実測は ★開発機★ `T000000002` が主。★本番機（`DFAS010003_アピタ金沢文庫`）は `~/log` の一覧（日時・大きさつき）だけ見た★（§1.6.1）: ★4つのフォルダは同じ。直下のファイル6個は古い版の名残で対象にしない★（#13 解消）。★フォルダの中のファイル数と名前の形は未確認★（#15）。★本番機は master を取り込む（開発機の `/home/dfa` とは HEAD が違う）★ので、開発機と同じ作りとは限らない。★ただし git で配られないもの（§5.13 の表）は1台ずつ★ | §5.2 の `types[].label`・fixtures の種類 |
| 2 | ★1回の上限 100MB は仮★（実測では全部で 1.2MB 程度。下げる必要はない。§1.6） | `EXPORT_MAX_BYTES`・`max_export_bytes`・画面の警告 |
| 3 | ★リリース時の IP 制限の扱い★（§8.6 のア／イ） | `lib/proxy/authenticate.ts`・`lib/config/allowed-ips.ts`・`CLAUDE.md` §3 |
| 4 | ★Pi の時計★。`modified_at` はラズパイの時計（Asia/Tokyo の前提）。★タイムゾーンは JST を確認★（2026-10-19 実測。`date`）。★NTP で合っているかは未確認★。ずれると期間の絞り込みがずれる | 導入手順（§5.12 の手順7 `timedatectl`） |
| 5 | ★配り方（§5.12 のア／イ）★ — 制御盤の担当が決める（M9）。アの数百行の貼り付けが崩れないか・イの中身は ★未確認★ | §5.12 |
| 6 | ★専用ユーザーに `/home/dfa/log` だけを読ませる方法★ — 制御盤の担当が決める。`/home/dfa` には `current_token.txt` など認証情報らしきファイルが 644 で置かれているので、★`/home/dfa` 全体の読み取りは与えない★。★`/home/dfa` は 700 と判明★（2026-10-19 16時台 開発機の実測。§1.6）なので、★`/home/dfa` に通過（x）だけを ACL で与え `/home/dfa/log` に読み取りを与える★か、★ログの出し先を `/home/dfa` の外（`/var/log/dsecurity/` など）へ移す★かのどちらか（§5.13） | §5.12 の手順5・§5.2 の `roots[].path`（出し先を移すなら） |
| 7 | ★レベル（INFO / WARNING / ERROR）は1つのファイルに混ざっている★。「エラーだけ取り出す」はファイル単位の取り出しの範囲外（必要なら将来の拡張） | — |
| 8 | journald（1GB）にもサービスの標準出力が入っている可能性がある。★今回は対象外（ファイルのログだけ）★ | — |
| 9 | ★解消★（2026-10-19 本番機の実測。§1.6.1）。「git を除いて」＝「覗いて」の解釈は合っていたが、★取り込みは merge ではなく `git fetch origin master` → `git reset --hard origin/master`★。★本番機の `/etc/cron.d/d-sec` が毎日 12:00 に root で `/etc/cron.shell/update_dscontrol.sh` を起動する★（開発機の毎分・`dfa`・スクリプト無しとは違う） | §1.6.1・§5.13 |
| 10 | ★解消★（2026-10-19 本番機の実測。§1.6.1）。★リポジトリは `/home/dfa`（`cd /home/dfa` で取り込む）・時刻は毎日 12:00・★取り込みの後のサービスの再起動や後処理は無い★。→ 常駐プログラムは自分のファイルの書き換えを検知して終了し、`Restart=always` で再起動させる（監督の推奨。§5.13）。★→ ユーザー要望で「差分があればラズパイごと再起動」の更新スクリプトの改訂案を作った（§5.13.1）。入れば上の検知の仕組みは不要（#16・#17）★ | §5.13（初回セットアップ・再起動の方法・コードの置き場所）・§5.13.1 |
| 11 | ★解消★（2026-10-19 16時台 開発機の実測）。★`/home/dfa/.gitignore` に `*.txt` があり、`current_token.txt`・`device_info.txt` など `*.txt` の制御盤ごとの値は git に入らない★。常駐プログラムの制御盤ごとの値の置き場所は §5.13 の2案（★監督の推奨は A: `/etc/dwatch-log-agent/config.json`★。リポジトリの外なので毎日12:00の `reset --hard` の影響を受けない）。★本番機（master）の `.gitignore` は `device_info.txt` / `current_token.txt` の名指し★（§1.6.1） | §5.13・§5.2 |
| 12 | ★どこで編集して master へ push しているか★ — 本番機の HEAD `5efdb2b` は開発機の `/home/dfa/dsecurity`（別の clone）の HEAD と同じで、開発機の `/home/dfa`（`db374ba`）とは違う（§1.6.1）→ ★開発機の `~/dsecurity` から push していると推測★。★制御盤の担当に確認する★。常駐プログラムのコードをどこに置いて push するかが決まる | §1.6.1・§5.13（配布の手順） |
| 13 | ★解消★（2026-10-19 17時台 本番機の実測。§1.6.1 #7）。★本番機の `~/log` 直下の6ファイルは最終更新 2025-03-12〜2026-02-12 で、今は書かれていない（古い版の名残）★。★取り出しの対象にしない★（監督判断）。§5.2 の `roots` をプログラム別フォルダごとにして、直下のファイルを拾わないようにした。必要になったら「旧ログ」の種類を足す（★その場合は上限の見直しが要る。`postcondition.log` だけで約 99MB★） | §5.2・§7.2 |
| 14 | ★解消★（2026-10-19 17時台 本番機の実測。§1.6.1 #10）。★本番機の追跡ファイルは13個、`.cache/`・`.config/` の下は0個★ → 開発機の `/home/dfa` の追跡は開発機のローカルの状態に限られる | §11.3-4 |
| 15 | ★本番機のフォルダのファイル数と名前の形★ — ディレクトリのエントリの大きさ（16〜20KB。開発機は 4KB）から、開発機より多いと推測（1時間ごとの切り替えなら1フォルダ最大169ファイル・合計 500件前後。★推測★。§1.6.1 #9）。一覧の上限（2万件・4MB）には十分収まる見込み。★名前が `<名前>.log.YYYY-MM-DD` か `<名前>.log.YYYY-MM-DD_HH` か★を下のコマンドで確かめる（どちらでも §5.2 の `patterns` に当たる） | §1.6.1・§5.2（`patterns` の確認）・§11.1-1 |
| 16 | ★**解消（ユーザー判断 2026-10-19）: 許容する**★（「この時間帯のアラートであればなんの問題もないのでスルーでいい」。更新の時刻は 12:00 のまま・アラートを抑える仕組みは作らない）。以下は判断前の論点: ★12:00 の再起動で出る「生死報告なし」のアラートの扱い★ — 改訂案（§5.13.1）では、更新があった日は 12:00 にラズパイごと再起動するので、★その間（1分前後・推測）生死報告が止まり、`D-S_CONTROL_FAILURE` のエラーや D-Sec アラートが出うる★（12:00 は報告間隔 60秒の時間帯）。★許容する／更新の時刻を変える（`/etc/cron.d/d-sec`。これも1台ずつ）／運用で「12:00 の更新日のアラートは確認だけ」とする★のどれか。★セキュリティの解除・復帰の処理中に再起動しないかは制御盤の担当が確かめる★ | §5.13.1 の注意1・2・運用の手引き |
| 17 | ★改訂案を誰がいつ全本番機に入れるか★ — `/etc/cron.shell/update_dscontrol.sh` は git で配られない（`/etc` の下）ので、★本番機1台ずつ Pi Connect の Remote shell で一度だけ入れ替える★（§5.13.1 の手順）。★入れ終わるまでは、常駐プログラムの自己検知による再起動（§5.13）をつなぎとして残す★。★開発機の `/home/dfa` では動かさない★（master に戻り再起動する。§5.13.1 の注意4） | §5.13・§5.13.1 |

**本番機で確かめること（コマンド例つき）**: ★本番機（例: `DFAS010003_アピタ金沢文庫`）は監督のツールからは操作が止められているので、★ユーザー自身が Pi Connect の Remote shell で打つ★想定。★どれも読み取りだけ★。結果は §1.6.1・§5.2 と上の #15 に反映する。★cron・`update_dscontrol.sh`・HEAD・`~/log` の一覧（日時・大きさつき）・稼働中のサービス・追跡ファイル数は 2026-10-19 に確かめ済み★（§1.6.1）。

```bash
ls ~/log/postcondition_security | wc -l   # フォルダの中のファイル数（開発機は7個）（#15）
ls ~/log/postcondition_security | tail -3 # ローテーション後の名前の形（.YYYY-MM-DD か .YYYY-MM-DD_HH か）（#15）
```

### 11.2 本書で気づいた点

| # | 点 | 状態 |
|---|---|---|
| a | export 中もポーリングを続けるか | ★解消（M4）★ |
| b | list の上限2万件と結果の上限 2MB が合わない | ★解消（M3。新しい順・2万件か 4MB）★ |
| c | `truncated` をブラウザへ返す欄が無い | ★解消（M3）★ |
| d | `EXPORT_IDLE_TIMEOUT_SEC` と nginx の60秒が同じ | ★解消（M5。45秒・チャンクの POST は60秒）★ |
| e | 100MB の判定の基準が2つある | ★解消（M1。zip の大きさに一本化）★ |
| f | `max_bytes` / `chunk_bytes` の二重定義 | ★解消（M7。小さいほう）★ |
| g | 複数の root で zip の中の名前が重なる | ★解消（M6。`{root の name}/…`）★ |
| h | `export` の指示を渡す時点 | ★解消（M1。準備 → 送信の2段）★ |
| i | 404 の判定元 | ★解消（M10。`db.dsDeviceExists()`）★ |
| j | 待ち行列の上限を超えたときの応答 | ★解消（M10。429 `agent_busy`）★ |
| k | 本番でトークンを発行するのは誰か | ★解消（M8。D-Watch の API と画面）★ |
| l | 常駐プログラムの配り方 | ★解消（M9。ア／イのどちらかを制御盤の担当が決める。§11.1-5）★ |
| m | `chunks` / `complete` / 送信中の `fail` の URL に入れる `command_id` | ★解消（監督判断 2026-10-19）★。`export` の指示の id。`send` は自分の id を持つが、`params.export_command_id` で export を指す |
| n | `send` が来たが zip が無いときの返し方 | ★解消（監督判断 2026-10-19）★。`fail` に `export_not_found` |
| o | `file_not_found` と `file_not_allowed` の分け目 | ★解消（監督判断 2026-10-19）★。`lstat` が `FileNotFoundError` のときだけ `file_not_found`、それ以外の検査落ちは `file_not_allowed` |
| p | export / send を処理する間の list | ★解消（監督判断 2026-10-19）★。list は export と並行して別スレッドですぐ処理。export は1台同時に1本 |
| q | 制御盤から来る JSON の形の誤りの code | ★解消（監督判断 2026-10-19）★。`result` / `prepared` / `complete` / `fail` はすべて 400 `result_invalid`。チャンクは 400 `chunk_invalid` |
| r | 1台に同時1本の export の数え方 | ★解消（監督判断 2026-10-19）★。export の指示から `prepared` を経て `send`・`complete`・`fail`・失効までを「進行中」に数える。★失効は D-Watch 側で `prepared` の後に GET が60秒来ないとき★（進行中から外し、制御盤には何も送らない）。制御盤は120秒で zip を消す |
| s | トークンの発行日時を画面に出す読み取り・未発行の失効 | ★解消（監督判断 2026-10-19。指示書AY Q14・Q15 を採用）★。`GET …/agent-token` → 200 `{ issued_at \| null }`（値は返さない）。未発行の `DELETE` も 204（冪等）。ブラウザ向け API は6本（§4.1） |

### 11.3 参考（本機能の範囲外）

2026-10-19 の実測（§1.6）で気づいたこと。★本機能とは別★。制御盤の担当に伝える。

★**どれも開発機（`T000000002`）での状態**★。★本番機は「開発機の `/home/dfa`」ではなく「master」を毎日12:00に取り込む★（§1.6.1）ので、★本番機に同じ状態があるかは、master に入っているかで決まる★（未確認のものは各行に書いた）。★master に入っているものは、直さずにおくと全本番機に残り続ける★。★直すなら master で直す（制御盤の担当）★。

★**参考**★: ★本番機は毎日12:00に `git reset --hard origin/master` するので、本番機で直接直したものは毎日12:00に戻る★（本番機で設定を追跡中のファイルに書くと消える。追跡していないファイルは残る）。§1.6.1・§5.13。

| # | 点 |
|---|---|
| 1 | crontab が `@reboot` と毎時で `/home/dfa/get_interval_time_V2.py` を起動しているが、★`/home/dfa/` 直下にそのファイルが見当たらなかった★（`ls` の結果）。★推測: 毎時失敗している★ |
| 2 | crontab の `@reboot` は systemd サービスと同じプログラムも起動しているが、★稼働中のプロセスは各1つだった★ |
| 3 | `/home/dfa` に `current_token.txt` など認証情報らしきファイルが ★644★ で置かれている（§1.6。本機能への影響は §11.1-6） |
| 4 | ★開発機の `/home/dfa` では `.cache/`・`.config/`・`.bash_history`・`.Xauthority` などが git で追跡されている★（追跡中の 1260 個のうち 1210 個が `.cache/` と `.config/` の下。`.gitignore` に足す前に追跡されたもの。§1.6）。★これは開発機の `/home/dfa` のローカルの状態に限られ、master・本番機には入っていない★（★本番機の追跡ファイルは13個、`.cache/`・`.config/` の下は0個★。2026-10-19 17時台 本番機の実測。§1.6.1 #10。13個の名前は見ていない）。★開発機の `/home/dfa` から誤って master へ push しないよう注意★（push すると、ブラウザのデータ（Chromium の IndexedDB 等）やシェルの履歴が git サーバーと、翌日12:00に全本番機へ広がる。推測）。`git rm --cached` で追跡を外す等は制御盤の担当が判断 |
| 5 | ★`/home/dfa/dsecurity/.netrc`（git の認証情報が入る種類のファイル。中身は見ていない）が 644★ |
| 6 | 開発機では `/etc/cron.d/d-sec` が毎分起動する `/etc/cron.shell/update_dscontrol.sh` が無い。★推測: 毎分失敗している★（★本番機は毎日12:00・root・スクリプトあり★。§1.6.1） |
| 7 | `get_interval_time` は ★systemd のタイマー（`get_interval_time.timer`）と crontab の両方★から起動されている（#1 の crontab 側は存在しないファイルを指している） |

---

## 12. 関連

| 資料 | 場所 |
|---|---|
| 決定 | D-179（`docs/decisions.md:12934` 以降） |
| D-Watch 側の指示書 | 指示書AY `docs/handoff/指示書AY_制御盤のログをD-Secログ画面から取り出す_20261019.md`（作成済み） |
| TeamWorks への照会 | 第13回の文案 `docs/handoff/TeamWorks確認事項_第13回_20261019_送付版.md`（S-5） |
| 同日の別の指示書（担当ファイルが重なる） | 指示書AX `docs/handoff/指示書AX_D-Secアラートをrobot_error_reportsから読む_20261019.md` |
| 線引き | D-143（DFA が作るのはビューアーだけ。サーバーの構築保守は TeamWorks）・D-154 項3（セキュリティはリリース直前） |
| IP 制限・nginx の前提 | `docs/deploy.md` §4・§5、D-077 項5 |
| Raspberry Pi Connect | https://www.raspberrypi.com/documentation/services/connect.html ・ https://www.raspberrypi.com/documentation/services/connect-for-organisations.html ・ https://forums.raspberrypi.com/viewtopic.php?t=387152 |
