Newer
Older
client_dwatch / docs / handoff / 指示書AY_制御盤のログをD-Secログ画面から取り出す_20261019.md

指示書AY — 制御盤(ラズパイ)のログを D-Sec ログ画面から取り出す(中継方式・サーバーに保存しない)

日付: 2026-10-19 / 担当: Cursor(または監督が指名する実装担当)/ 基点: ★指示書AX の実装の完了後の develop の先端★(着手時に git log --oneline -1 で記入: _______) 前提: ★D-179★(docs/decisions.md:12934 以降。★本書の作成と同時に監督がコミットする★)/ ★指示書AX の実装コミット★(担当ファイルが重なる。下記)/ ★監督判断 第2ラウンド(2026-10-19。M1〜M11。§11 に反映済み。ブリーフより優先)★ / 設計書 docs/handoff/設計_制御盤ログの取り出し_20261019.md(★作成済み・本書と突き合わせ済み(2026-10-19)。制御盤の常駐プログラムの仕様はそちら★)/ D-077(XFF の末尾)/ D-101(IP 制限・ALLOW_DIRECT_ACCESS)/ D-123(シリアルの文字種を絞らない)/ D-4(当たり外れは JS の ===)/ D-054(summary と meta.total)/ D-143(DFA が作るのはビューアー。サーバーの構築保守は TeamWorks)/ D-154(セキュリティはリリース直前)/ CLAUDE.md §3・§6・§7・§9.3・§9.5・§9.6

★ユーザー決定(§2-1 の U1〜U5)と監督の設計判断(§2-2 の D1〜D25)は確定仕様。変えないこと★(CLAUDE.md §0-1)。 ★期待値表(§3)は正。変えないこと★(CLAUDE.md §0-2)。★§11 Q1〜Q16 はすべて監督判断(第2ラウンドと追加)で確定し、本文に反映済み★(★Q14・Q15 は本書の案 P1・P2 をそのまま採用。§2-3★)。

★担当ファイルが指示書AX と重なる★: components/features/ds-logs/(AX 段階7 が DsLogScreen.module.css:83 のコメントを直す)・d-watch-api.yaml・lib/i18n.ts・lib/types/index.ts・lib/fixtures/index.ts。 ★AX の実装コミットが develop に入ってから着手する★。

★行番号は本書の作成時(2026-10-19)の作業ツリーで確かめたもの。develop の先端は AX のコミットで動いている(作成中にも動いた)★。 ★着手時に develop の先端で、本書に書いた行番号をすべて確かめ直すこと★(段階0-4)。ずれていたら関数名・文で探し直す。

★docs/decisions.md に足すのは「D-179 の末尾への『実装の記録』」だけ★(§13 の下書き)。★新しい D 番号は取らない★(★D-180 は指示書AX が予約済み★)。


0. 何をするか

# やること 段階
1 ★API 契約を先に書く★: ブラウザ向け ★6本★(一覧・エクスポートの準備・ダウンロード・トークンの状態/発行/失効)と制御盤向け ★6本★(指示の取得・一覧の結果・★準備完了(prepared)★・チャンク・完了・失敗)を d-watch-api.yaml に 段階2
2 設定値(lib/config/ds-agent.ts)と純粋関数(lib/ds-logs/query.ts・presets.ts・file-name.ts・validate.ts) 段階3
3 ★制御盤トークン★(lib/ds-agent/tokens.ts)。★ハッシュだけを保存★。★発行・失効は D-Watch の API と画面から★(スクリプトは作らない。D16) 段階4・7・9
4 ★認証★: /api/ds-agent/ 配下は ★Bearer だけ★、それ以外は ★cookie だけ★(lib/proxy/authenticate.ts・proxy.ts) 段階5
5 ★中継(relay)★: メモリ内の待ち行列・ロングポーリング・一覧の待ち合わせ・★エクスポートの「準備 → 送信」の2段★・ストリーム(lib/ds-agent/relay.ts)。★時刻とタイマーを注入できる形★ 段階6
6 ルート12本(app/api/ds-devices/[serial_number]/logs/**・…/agent-token・app/api/ds-agent/**) 段階7
7 fixtures の偽制御盤(lib/fixtures/ds-device-logs.ts・lib/ds-agent/fixture-agent.ts)。★relay の同じ経路を通す★ 段階8
8 画面: D-Sec ログの表の各行に「ログ」ボタン → ★モーダル★(種類・期間・並べ替え・一覧・選択・エクスポート・★制御盤トークン★) 段階9
9 ログのイベント(lib/logger/events.ts) 段階10
10 画面確認(fixtures)と、★偽制御盤スクリプトによる非 fixtures の通し確認★ 段階11

★変えないもの★: D-Sec ログの一覧(GET /ds-devices・dsDevices())と備考(PATCH /ds-devices/{serial_number}/note)の挙動/ 既存の cookie 認証・IP 制限の判定(authenticate() の今の分岐)/lib/settings/json-store.ts の中身/prisma//メインDB(★本機能は DB に書かない。読むのは 404 の判定の dsDeviceExists() だけ★)。


1. 背景

  • ★ユーザー要望(2026-10-19)★: 制御盤(ラズパイ)の中のログファイルを、ターミナルを使わずに、PUDU のログのエクスポート画面のように「種類(ファイル名の prefix)・期間で絞り、一覧から選んでダウンロード」したい。サーバーには上げたくない。
  • ★いまの運用★: Raspberry Pi Connect(rpi-connect 2.7.0)の Remote shell から cat で見ている。★Pi Connect にはファイル転送・ポート転送・コマンド実行の公開 API が無い★(D-179 追記 項1。出典は D-179)。→ Pi Connect だけでは要望を満たせない。★Pi Connect は常駐プログラムの導入・更新に使う★。
  • ★D-Watch の今★(コード実測 2026-10-19。出所: 構造(コード・node_modules)):
    • Next.js ★16.3.3★(package.json:18)。middleware は proxy に改名(proxy.ts:1-21)。★proxy は Node ランタイム固定★。
    • ★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。 本体を最後まで受け取ってからハンドラが動く — node_modules/next/dist/server/next-server.js:1197-1217(cloneBodyStream() → finalize())・node_modules/next/dist/server/body-streams.js:52-118(bytesRead > bodySizeLimit で console.warn と push(null))。 → ★proxy を通るパスで 100MB を1本の POST で流すことはできない★。
    • proxy.ts の matcher は静的ファイル以外の全パス(proxy.ts:112-114。/api を含む)。★「matcher で除外しない。関数の中で分岐する」方針★(proxy.ts:106-107)。
    • 判定は lib/proxy/authenticate.ts(★判定するだけ・同期・deps 引数でテスト可能★。:59-94)。順序は ★IP 制限(403)→ 認証(401 / redirect)★。公開パスは /login と /api/auth/login だけ(:25)。
    • 認証は共有トークン(env AUTH_TOKEN)を cookie dwatch_session に入れる方式。比較は verifyToken()(lib/auth/token.ts:22-41。timingSafeEqual)。
    • IP 制限: lib/config/allowed-ips.ts(ALLOWED_IPS 空 :24-26・ALLOW_ALL_WHEN_EMPTY = true :35・TRUSTED_PROXY_HOPS = 1 :63)。★このファイルは proxy のバンドルに judge 系を引き込まないため lib/config/index.ts から独立させてある★(:4-8)。
    • 動かし方は npm run build → npm run start(docs/deploy.md §2)。Docker の可能性あり(同 §1)。★PM2 / cluster の設定は無い → 1プロセス★。前段に nginx(XFF を追記。D-077。★既定の proxy_read_timeout は60秒★)。nginx の設定は TeamWorks 管理でリポジトリに無い。
    • DFA 独自ストアは createJsonStore()(lib/settings/json-store.ts:39。.tmp → rename、壊れたら .bak。★read() は非同期★ :45)。前例 lib/settings/ds-device-notes.ts(data/settings/…。/data は .gitignore:50)。
    • ログは logEvent(level, payload)(lib/logger/index.ts:112)。イベントは lib/logger/events.ts の型付き union(:456-471)に足す。★キー名に token / authorization / facility_name / note 等を使うと本番で消される★(lib/logger/redact.ts:12-46)。
    • ★既存のストリーミング応答の例は無い★(CSV 出力は文字列を丸ごと返している)。
    • 画面: components/features/ds-logs/DsLogScreen.tsx(表の <colgroup> :437-443・見出し :445-453・日時の整形 formatDateTime() :41)。★モーダルの共通部品は無い★。前例は components/features/detail-log/AreaModal.tsx:65(role="dialog" aria-modal="true"・自前の overlay)。ページ送り components/ui/Pagination.tsx(total / page / perPage / onPageChange。★per_page の選択は持たない★)。日付は <input type="date"> + openDatePicker()(components/ui/date-picker.ts:11)。
    • API: /ds-devices(d-watch-api.yaml:1376)・/ds-devices/{serial_number}/note(:1413。path の maxLength: 100・文字種は絞らない)。パスの検証は parsePathSerialNumber()(lib/api/path-params.ts:70-79。★422 validation_failed★)。実在は db.dsDeviceExists()(lib/db/index.ts:370。fixtures :680・Prisma :1944)。
    • テスト: vitest(vitest.config.ts。★include は lib/** と app/** だけ★ — components/ は走らない)。ルートのテストは Prisma 経路と fixtures 経路の2ファイルに分ける流儀(app/api/ds-devices/[serial_number]/note/__tests__/route.test.ts / route.fixtures.test.ts。ストアは fs.mkdtemp + __setDeviceNotesStoreForTesting())。

2. 決定(実装担当は従うこと)

2-1. ユーザー決定(2026-10-19。確定。D-179)

# 決定 帰結
U1 ★「アップロードしたくない」=サーバーに保存しなければ、通過はよい★ ★D-Watch はチャンクをメモリで受けて、待っているブラウザの応答へ流すだけ★。ディスク・DB・ストアに書かない(D3)
U2 ★制御盤のソフトは DFA が作っている。言語は Python★ 常駐プログラムは本書の範囲外(§10)。設計書が正
U3 ★ログを取り出すのは DFA の社内だけ★(顧客・現場には見せない) 既存の cookie 認証・IP 制限の内側に置く。facilities.contract による出し分けはしない
U4 ★A案(自前の中継)をすぐ進める★(D を先に作る案は採らない) 本書
U5 ★ログのフォルダ・ファイル名の規則は後日★ 制御盤の設定ファイルで与える。★D-Watch は「種類(key / label)」を制御盤から受け取るだけ★で、種類の値を持たない・直書きしない

2-2. 監督の設計判断(確定。D-179 追記 項4・第2ラウンド M1〜M11・追加の判断 D25)

# 決定 理由
D1 ★ファイルは制御盤が zip にし、CHUNK_MAX_BYTES(4MB)以下のチャンクに分けて送る。チャンクごとに SHA-256(X-Chunk-Sha256)を付け、D-Watch は長さとハッシュを検証する。最後に complete で合計の長さと全体の SHA-256 を照合する★ ★proxy の 10MB バッファ(超過分は切り捨て・クライアントにはエラーにならない)を避け、切り捨てを検知する★(§1)
D2 ★接続はすべて制御盤から開く。ロングポーリング★(GET /api/ds-agent/commands?wait=25) 店舗のネットワークに入口を作らない。★Next.js のルートハンドラは WebSocket のサーバーになれない★
D3 ★サーバーのディスクに書かない★(一時ファイル・os.tmpdir() も禁止)。チャンクはメモリで受けてブラウザの応答へ流す U1
D4 ★relay はモジュールのシングルトン。globalThis に置く(開発時の HMR で二重にしない)★。★1プロセス前提★(next start を複数立てる・cluster にすると壊れる。再起動で進行中のエクスポートは失われる) 前例 lib/db/prisma.ts:10-22・:76-84(globalThis.__dwatchPrisma)
D5 ★/api/ds-agent/ 配下は cookie では通さない。Authorization: Bearer を lib/ds-agent/tokens.ts で検証する★。★それ以外のパスで Bearer を出されても無視する(cookie が要る)★。★ルートでも requireAgent(request) で再検証して serial を得る★(★ヘッダで serial を渡さない★) D-179 追記 項4-4・項5
D6 ★IP 制限は今のまま全パスにかける(agent パスも)★。★リリース時に許可リストを入れると制御盤が弾かれる★ → 店舗の IP を載せるか、agent パスだけ IP 制限を外すかは ★リリース時に決める(D-154)★。★本書では決めない★ D-179 追記 項4-5
D7 ★制御盤トークンは制御盤ごと。32バイトの乱数を base64url(43文字)★。保存は DFA 独自ストア data/settings/ds-agent-tokens.json(createJsonStore)に { [serial_number]: { token_sha256, issued_at } }。★生の値は保存しない・ログに出さない★。★メインDBには書かない★ D-179 追記 項4-4
D8 ★一覧は制御盤からもらい、絞り込み(種類・期間)・並べ替え・ページ分けは D-Watch 側の純粋関数で行う★(lib/ds-logs/query.ts) 期待値表でテストできる場所に置く
D9 ★設定値は lib/config/ds-agent.ts(新規・独立ファイル)★。値は 段階3-1 の表。★ハードコード禁止★(CLAUDE.md §7) lib/config/index.ts に足すと proxy のバンドルに judge 系を引き込む(allowed-ips.ts:4-8 と同じ理由)
D10 ★fixtures の偽制御盤は relay の同じ経路(待ち行列 → 結果 → 準備完了 → チャンク → 完了)を通す★。fixtures の制御盤のうち1台(★DS-001★)は未接続にして 409 を出す CLAUDE.md §9.5
D11 ★画面は D-Sec ログの表の各行の「ログ」ボタン → モーダル★(AreaModal.tsx の作りに倣う)。★モックに無い画面なので、文言・配置は本書 段階9 を正とする★ ユーザー要望(PUDU の画面)
D12 ★ログに出すのはファイル数とバイト数だけ★。★ファイルの中身・ファイル名の一覧全体・トークンは出さない★ 段階10
D13 ★却下★: 1本の POST で 100MB を流しそのパスだけ matcher から外す(proxy.ts:106-107 に反する)/experimental.proxyClientMaxBodySize を上げる(全量をメモリに溜めてからハンドラが動く)/別プロセスの中継(nginx の振り分けを TeamWorks に頼む・認証が二重)/WebSocket(ルートハンドラはなれない) D-179 追記 項4-2
D14 ★エクスポートは「準備 → 送信」の2段★(M1)。
① POST …/logs/exports は 404 / 409 / 429 / 422 を判定 → 制御盤に kind: "export"(params { file_ids, max_bytes, chunk_bytes })→ ★制御盤が zip を作り終えて POST /api/ds-agent/commands/{command_id}/prepared { total_bytes, sha256, file_count } を送るまで待つ(EXPORT_PREPARE_TIMEOUT_SEC = 50)★ → ★201★ { export_id, download_url, total_bytes, file_count, expires_in_sec }。★準備中の失敗(too_large / file_not_allowed / file_not_found / io_error 等)はこの POST の 502 agent_error(details.agent_code)★、50秒で来なければ 504 agent_timeout。
② GET …/logs/exports/{export_id}(★1回だけ★)は制御盤に kind: "send"(params { export_command_id })→ 最初のチャンクを EXPORT_FIRST_CHUNK_TIMEOUT_SEC(30)待つ(来なければ 504 JSON)→ ★200 で流す。Content-Length: total_bytes を付ける★。流し始めた後の失敗は controller.error()
★準備の失敗を画面の JSON で返せる★。★長い待ちを nginx の60秒より短くする★。ブラウザに進捗が出る
D15 ★上限の判定は zip のバイト数に一本化★(M1)。D-Watch は ★prepared の total_bytes ≤ EXPORT_MAX_BYTES★ を確かめ(超えたら 502 agent_error・agent_code too_large)、★流れたバイト数が total_bytes と一致し、SHA-256 が prepared の sha256 と一致すること★を complete で確かめる 制御盤は元ファイルの合計で早く断り、zip の大きさでも断る(設計書)
D16 ★トークンの発行・失効は D-Watch の API と画面から★(M8): POST /api/ds-devices/{serial_number}/agent-token → 201 { token, issued_at }(★生の値はこの応答で1回だけ。既にあれば作り直し、古いものは無効★)/DELETE …/agent-token → 204(★未発行でも 204。§2-3 P2★)/★発行日時の読み取りは GET …/agent-token → 200 { issued_at }(§2-3 P1)★。★既存の cookie 認証★。★scripts/ds-agent-token.ts は作らない★ ★本番のサーバーは TeamWorks が構築保守する(D-143)ので、サーバー上でスクリプトを動かす前提を置かない★
D17 ★検証エラーは 422 validation_failed★(既存の流儀。M2。ブリーフの 400 validation_error は誤り)。★制御盤向けの形の誤りは 400 result_invalid / chunk_invalid★ §11 Q1・Q10
D18 ★一覧の上限と truncated★(M3): 制御盤は ★更新日時の新しい順に並べてから★ 2万件または JSON 4MB のどちらかに達したら打ち切り truncated: true。LIST_RESULT_MAX_BYTES = 4MB。★ブラウザ向けの一覧の応答に truncated を足し、画面は「件数が多いため、新しいものから一部だけを表示しています」★ §11 Q3
D19 ★認証の通った制御盤の ★すべての★ リクエスト(commands・result・prepared・chunks・complete・fail)で last_seen_at を更新する★(M4) エクスポートの最中(ポーリングが途切れても)未接続にしない
D20 ★EXPORT_IDLE_TIMEOUT_SEC = 45★(M5)。★チャンクの POST に 204 を返すまでの待ち(流量制御)も最大45秒。超えたら 410 export_cancelled にしてエクスポートを失敗にする★ nginx の60秒より短く。制御盤のチャンクの POST のタイムアウトは60秒(設計書)
D21 ★ダウンロードは <a href download> を作って click()★(M1)。★location.href は使わない★。★GET が JSON のエラーになるのは制御盤の切断などまれな場合だけで、そのときは JSON がファイルとして保存されうる(既知の制約)★ 準備の失敗は POST で返る(D14)
D22 ★proxy を async にし、agent パスの判定を authenticateAgent()(新規・async)に分ける★。authenticate() は agent パスなら cookie があっても unauthenticated。★トークンのストアは mtime を見てメモリにキャッシュしてよい★ §11 Q8
D23 ★本書の暫定の採用(M10)★: 期間ボタンの to は省略(Q4)/未知の種類キーは0件(Q5)/zip のファイル名のシリアルは [A-Za-z0-9._-] 以外を _・filename* も付ける(Q6)/順番違いのチャンクはエクスポートも失敗(Q9)/待ち行列が満杯なら 429 agent_busy・410 command_expired・400 result_invalid(Q10)/偽制御盤スクリプト scripts/ds-agent-fake.ts をコミット(Q11)/同じ制御盤の2本目のポーリングは古い方を 204(Q12)/期間の既定は直近24時間・種類や期間を変えたら選択を消す(Q13)/404 の判定は db.dsDeviceExists()(DB は読むだけ) §11
D24 ★zip の中のパスは {root の name}/{root からの相対パス}(制御盤側。M6)★。D-Watch は zip の中身を見ない。★偽制御盤スクリプトと fixtures の zip は root の名前つきで作る★ 本物と同じ形で画面確認する
D25 ★追加の監督判断(2026-10-19)★:
① ★chunks / complete / 送信中の fail の URL の command_id は ★export の指示の id★★。send の指示は自分の command_id を持ち、params.export_command_id で export を指す(★send の id には何も送られてこない★)
② ★send が来たのに制御盤に zip が無い → 制御盤は fail { code: "export_not_found" }★。D-Watch は最初のチャンクの前なら GET を 502 agent_error、流し始めた後ならストリームを error
③ ★list は export と並行して処理される(制御盤側)★。★D-Watch はエクスポートの進行中でも list の指示を待ち行列に入れてよい★(エクスポートの進行中を理由に list を断らない)
④ ★制御盤から来た JSON(result / prepared / complete / fail)の形の誤りは、すべて 400 result_invalid★。チャンクは 400 chunk_invalid
⑤ ★prepared から send・complete・fail・失効までを「進行中」に数える★(1台1本の上限=429 export_busy の判定に入る。準備中も数える)。★prepared の後に GET が EXPORT_CLAIM_TIMEOUT_SEC(60秒)来なければ D-Watch はエクスポートを失効させ、進行中から外す★(制御盤は120秒で zip を消す)
§11 Q16。設計書も同じ内容で直す

2-3. 本書の案で採用されたもの(★確定。追加の監督判断 2026-10-19 で §11 Q14・Q15 を採用★)

# 決定 §11
P1 ★トークンの状態を読む GET /api/ds-devices/{serial_number}/agent-token を足す★(`{ issued_at: string \ null }`。★トークン・ハッシュは返さない★) Q14
P2 ★未発行の DELETE …/agent-token も 204★(冪等) Q15

3. ★期待値表(正)★

★表1〜表6 はブリーフ §5 の柱と第2ラウンドの監督判断から本書で具体化したもの★(★表1・表2・表4・表6 と表3 の関数の行は第1ラウンド、表5 と表3 の API の行は第2ラウンドで作り直した★)。 ★表3 KA4・KA7 は本書の案(P1・P2)が §11 Q14・Q15 で採用されて確定した行★。

表1(lib/ds-logs/query.ts の queryDeviceLogs()。入力の8件)

types(制御盤から来た順)= [{ key: "app", label: "アプリ" }, { key: "comm", label: "通信" }, { key: "sys", label: "システム" }](★ダミー。本物の値はユーザーが後日教える★)

file_id name type size_bytes modified_at(制御盤から来た値) 備考
f1 app_20261019_1100.log app 1000 2026-10-19T11:00:00+09:00 f3 と同時刻
f2 app_20261019_0900.log app 2000 2026-10-19T09:00:00+09:00
f3 comm_20261019_1100.log comm 3000 2026-10-19T11:00:00+09:00 f1 と同時刻
f4 comm_20261018_1200.log comm 4000 2026-10-18T12:00:00+09:00 ★ちょうど 24時間前★
f5 sys_20261019_1130.log sys 5000 2026-10-19T11:30:00+09:00
f6 sys_20261018_1159.log sys 6000 2026-10-18T11:59:59+09:00 24時間前の1秒前
f7 app_20261019_0200.log app 7000 ★2026-10-18T17:00:00Z★ ★= 10/19 02:00 JST。オフセット違い★
f8 app_20261019_1200.log app 8000 2026-10-19T12:00:00+09:00 ★ちょうど now★
# 入力 期待(file_id の並び) total 根拠
Q1 指定なし(sort=date_desc) f8, f5, f1, f3, f2, f7, f4, f6 8 新しい順・★同時刻は name 昇順★(app_… < comm_…)
Q2 types=app f8, f1, f2, f7 4
Q3 types=app,sys f8, f5, f1, f2, f7, f6 6
Q4 types=xyz (空) 0 D23(200・0件)
Q5 types=app,xyz f8, f1, f2, f7 4 D23
Q6 from=2026-10-19T11:00:00+09:00 f8, f5, f1, f3 4 ★ちょうど from は入る★
Q7 to=2026-10-19T11:00:00+09:00 f2, f7, f4, f6 4 ★ちょうど to は入らない★
Q8 from=2026-10-18T12:00:00+09:00&to=2026-10-19T12:00:00+09:00 f5, f1, f3, f2, f7, f4 6 f4 は入る・f8 は入らない・f6 は前
Q9 from=2026-10-19T02:00:00+09:00&to=2026-10-19T02:00:01+09:00 f7 1 ★比較は瞬間(Date.parse)。文字列で比べない★
Q10 from=2026-10-19T02:30:00Z f8, f5 2 from 側の Z も瞬間で
Q11 sort=type_date_desc f8, f1, f2, f7, f3, f4, f5, f6 8 ★types 配列の順 → 新しい順 → name 昇順★
Q12 sort=type_date_desc&types=comm,app f8, f1, f2, f7, f3, f4 6 ★クエリの順ではなく制御盤の types の順★
Q13 from=2026-10-18T12:00:00+09:00(表2 の「直近24時間」) f8, f5, f1, f3, f2, f7, f4 7 f6 は入らない
Q14 Q1 を perPage=3, page=1 f8, f5, f1 8 ★純粋関数には任意の perPage を渡せる★(ルートの 50/100/200 は検証側)
Q15 Q1 を perPage=3, page=3 f4, f6 8 最終ページの端数
Q16 Q1 を perPage=3, page=4 (空) ★8★ ★範囲外のページは空配列、total は正しい★
Q17 出力の modified_at f7 は ★2026-10-19T02:00:00+09:00★、他は入力どおり — ★出力は toJstIso()(lib/format/date.ts:37)で JST に揃える★(CLAUDE.md §9.3)

表2(lib/ds-logs/presets.ts。now = 2026-10-19T12:00:00+09:00)

# 入力 from to 根拠
T1 1h(直近1時間) 2026-10-19T11:00:00+09:00 ★なし★ D23(制御盤の時計が進んでいても最新を外さない)
T2 3h(直近3時間) 2026-10-19T09:00:00+09:00 なし D23
T3 24h(直近24時間) 2026-10-18T12:00:00+09:00 なし D23
T4 all(全て) なし なし
T5 日付の範囲 2026-10-18〜2026-10-19 2026-10-18T00:00:00+09:00 ★2026-10-20T00:00:00+09:00★ ★終了日を含む★
T6 2026-10-31〜2026-10-31 2026-10-31T00:00:00+09:00 2026-11-01T00:00:00+09:00 月またぎ
T7 開始だけ 2026-10-19 2026-10-19T00:00:00+09:00 なし
T8 終了だけ 2026-10-18 なし 2026-10-19T00:00:00+09:00
T9 2026-10-19〜2026-10-18(逆) ★null(不正)★ — 画面が「開始日は終了日以前にしてください」を出し、取得しない
T10 now を UTC で渡す(2026-10-19T03:00:00Z)・1h 2026-10-19T11:00:00+09:00 なし ★実行環境のタイムゾーンに依存しない★

表3(制御盤トークン。関数は lib/ds-agent/tokens.ts、API は app/api/ds-devices/[serial_number]/agent-token/route.ts。ストアは mkdtemp)

# 入力 期待 根拠
K1 issueAgentToken("DS-002") 戻り値の token が /^[A-Za-z0-9_-]{43}$/・issued_at が +09:00 の ISO。ストアに DS-002: { token_sha256: 64桁の16進, issued_at }。★ストアのファイルの中身に生のトークンの文字列が無い★ D7
K2 verifyAgentAuthorization("Bearer " + K1 の値) "DS-002"
K3 "Bearer " + 形は正しいが違う43文字 null
K4 null / "" null
K5 K1 の値だけ(Bearer なし) null
K6 "bearer " + 値(小文字)/"Bearer " + 値(空白2つ)/末尾に空白 null(★厳密に Bearer +空白1つ★) D22
K7 ストアが空(未登録) null
K8 revokeAgentToken("DS-002") の後に K2 null
K9 DS-002 を再発行 ★古い値は null、新しい値は "DS-002"★ D16
K10 DS-002 と DS-003 を発行 それぞれの値がそれぞれのシリアルを返す
K11 ストアの1件の token_sha256 が壊れている(64桁の16進でない) ★その1件だけ無視し例外にしない★。他の制御盤は通る
K12 検証した後に ★別のストアのインスタンスから★ 失効させる(ファイルの mtime が変わる) 次の検証は null(★キャッシュは mtime で無効になる★) D22
KA1 POST /api/ds-devices/DS-002/agent-token(cookie) ★201★ { token, issued_at }。token は K1 の形。★ストアには SHA-256 だけ★ D16
KA2 もう一度 POST 201・新しい値。★1回目の値で verifyAgentAuthorization が null★ D16
KA3 DELETE /api/ds-devices/DS-002/agent-token ★204★。以後 K2 は null D16
KA4 未発行の DELETE ★204★(冪等) ★P2(Q15 採用)★
KA5 POST /api/ds-devices/DS-999/agent-token(制御盤に無い) ★404 not_found★。ストアは変わらない D23
KA6 パスのシリアルが101文字 ★422 validation_failed★ D17
KA7 GET /api/ds-devices/DS-002/agent-token 発行後 { issued_at: "…+09:00" }/未発行 { issued_at: null }。★応答に token / token_sha256 が無い★ ★P1(Q14 採用)★
KA8 KA1〜KA3 のログ ds_agent.token_issued / ds_agent.token_revoked が ★serial_number だけ★ で1行ずつ。★値に K1 の文字列が無い★ 段階10

表4(lib/proxy/authenticate.ts。deps で IP・AUTH_TOKEN・トークン検証を差し替える)

# パス IP cookie Authorization 期待
A1 /api/ds-agent/commands 許可 なし 正しい Bearer ★allow★
A2 同上 許可 ★正しい cookie★ なし ★unauthenticated(401)★
A3 同上 許可 なし 違う Bearer unauthenticated
A4 同上 許可 正しい cookie 違う Bearer unauthenticated
A5 /api/ds-devices 許可 なし ★正しい Bearer★ ★unauthenticated(Bearer を無視)★
A6 /api/ds-devices 許可 正しい cookie 正しい Bearer allow(Bearer は無視)
A7 /api/ds-agent/commands ★拒否★ なし 正しい Bearer ★deny_ip(403)★。★トークン検証を呼ばない★(スパイで0回)
A8 /api/ds-agents(似た名前) 許可 正しい cookie なし allow(★agent パスではない★。前方一致の誤り)
A9 /ds-logs(画面) 許可 なし 正しい Bearer unauthenticated(redirect)
A10 /api/ds-agent / /api/ds-agent/ 許可 なし 正しい Bearer allow(agent パス。ルートが無いので後段で 404)
A11 /api/ds-devices/DS-002/agent-token 許可 なし ★正しい Bearer★ ★unauthenticated★(★制御盤のトークンでトークンを作り直せない★)

表5(lib/ds-agent/relay.ts。★時刻とタイマーを注入★。t は秒。制限値はテストで小さく差し替えてよい)

★エクスポートの状態★: 準備中(POST が待っている)→ 準備完了(201 を返した。受け取り待ち)→ 送信待ち(GET。send を入れた)→ 流れ中 → 完了 / 失敗 / 取り消し / 失効。★準備中〜流れ中を「進行中」と数える★。

# 操作 期待(relay の結果 → ルートの HTTP) 根拠
R1 t=0 制御盤が poll(DS-002, wait=25)。t=1 ブラウザが requestList(DS-002) poll が ★即座に★ { command_id, kind: "list", params: {} }(→ 200)。t=2 submitListResult → ブラウザに一覧(→ 200)
R2 R1 で結果が来ない ★t=16(依頼から15秒)で timeout(→ 504 agent_timeout)★。t=15.999 ではまだ待っている
R3 R2 の後 t=17 に submitListResult gone(→ 410 command_expired) D23
R4 poll が t=0 に始まり t=25 に指示なしで返る(→ 204)。以後リクエストなし ★t=85 は接続中(ちょうど60秒)★、★t=85.001 は未接続(→ 409 agent_offline)★。last_seen_at は t=25
R5 一度もリクエストしていない 未接続。last_seen_at: null・agent_version: null
R6 poll 中(待っている間) 接続中(last_seen_at が60秒より前でも)
R7 ★poll が t=25 に終わり、以後ポーリングは無いが t=80 にチャンクの POST が来た★ ★last_seen_at は t=80★。t=140 は接続中、t=140.001 は未接続。★result・prepared・chunks・complete・fail のどれでも同じ★ ★D19(M4)★
R8 poll の wait が 0 / 26 / abc ★422 validation_failed★ D17
R9 同じ制御盤の2本目の poll(1本目が待っている) ★1本目が即座に null(204)★、2本目が待つ D23
R10 ★準備★: t=0 createExport(DS-002, [f1, f2]) 制御盤の poll に { kind: "export", params: { file_ids: [f1, f2], max_bytes: EXPORT_MAX_BYTES, chunk_bytes: CHUNK_MAX_BYTES } }。t=10 prepared { total_bytes: 9437184, sha256, file_count: 2 } → ★createExport が prepared(→ 201 { export_id, download_url, total_bytes: 9437184, file_count: 2, expires_in_sec: 60 })★ D14
R11 準備で prepared が来ない ★t=50 はまだ待っている、t=50.001 で timeout(→ 504 agent_timeout)★。その後の prepared は gone(→ 410) D14
R12 準備中に制御盤が fail { code: "too_large" }(file_not_allowed / file_not_found / io_error も同じ) ★createExport が agent_error(→ 502。details.agent_code = ["too_large"])★ D14
R13 prepared の total_bytes が ★ちょうど EXPORT_MAX_BYTES★ / ★EXPORT_MAX_BYTES + 1★ 201 / ★502 agent_error(agent_code too_large)★。後者はエクスポートを失敗にする(prepared の応答は 204 のまま。後の send は来ない) D15
R14 prepared(201)から ★t=+60 に claim★ / ★t=+60.001 に claim★ 受理 / not_found(→ 404 export_not_found。失効)
R15 ★同じ export_id で2回目の claim★ not_found(→ 404)
R16 パスのシリアルが違う claim(DS-003 の URL で DS-002 の export_id) not_found(→ 404)
R17 ★送信★: claim → 制御盤の poll に { kind: "send", params: { export_command_id } } → seq 0(4MB)→ seq 1(4MB)→ seq 2(1MB)→ complete { total_bytes: 9437184, sha256 } claim が seq 0 の到着で ★ストリーム(→ 200・Content-Length: 9437184)★。読んだバイト列が seq 0〜2 と一致。ストリームが閉じる D14・★D25 ①(チャンク・complete は export の指示の command_id へ。send の command_id へ送ると 404)★
R18 claim から ★t=+30 に seq 0★ / ★来ないまま t=+30.001★ 受理 / ★timeout(→ 504 agent_timeout。まだ JSON で返せる)★。その後のチャンクは 410 D14
R19 send の後・最初のチャンクの前に制御盤が fail(★export_not_found = 制御盤に zip が無い★ を含む) claim が agent_error(→ 502。agent_code は制御盤の code) D14・★D25 ②★
R20 ★prepared の後・send の前★にチャンク ★409 chunk_out_of_order★
R21 seq 0 の後に ★seq 2★ 409 chunk_out_of_order。★ストリームを error で閉じる★ D23
R22 ★ハッシュ不一致★(流し始めた後) 400 chunk_invalid。ストリームを error D1
R23 ★最初のチャンクのハッシュ不一致★(まだ応答していない) チャンクは 400。★claim は agent_error(→ 502。agent_code chunk_invalid)★ D1
R24 チャンクの長さ ★ちょうど CHUNK_MAX_BYTES★ / ★CHUNK_MAX_BYTES + 1★ / ★0バイト★ 受理 / 400 / 400 D1
R25 ★受け取った合計が prepared の total_bytes を超えた★ そのチャンクは 400 chunk_invalid。ストリームを error ★D15★
R26 complete の total_bytes が prepared と違う/★受け取った合計が total_bytes に足りない★ 400。ストリームを error D15
R27 ★complete の sha256 が prepared の sha256 と違う★/★受け取ったバイト列の SHA-256 が prepared の sha256 と違う★ 400。ストリームを error ★D15★
R28 流し始めた後に制御盤が fail(export_not_found 等。★URL は export の指示の command_id★) ストリームを error。fail は 204 ★D25 ①②★
R29 流し始めた後にブラウザが切断(cancel / request.signal の abort) ★次のチャンクは cancelled(→ 410 export_cancelled)★
R30 ★流量制御★: seq 0 を受けたがブラウザが読んでいない ★seq 0 の pushChunk は保留(204 を返さない)★。reader.read() で解決する。★relay が持つチャンクは常に2つ以下★
R31 ★R30 の保留が ★t=+45★ / ★t=+45.001★ まで続く まだ保留 / ★410 export_cancelled・エクスポートは失敗(ストリームを error)★ ★D20(M5)★
R32 流し始めた後、★次のチャンクが来ないまま t=+45 / t=+45.001★ 流れたまま / ★ストリームを error★。その後のチャンクは 410 ★D20★
R33 同じ制御盤で進行中(準備中・★prepared の後(受け取り待ち)★・送信待ち・流れ中のどれか)に2本目の createExport busy(→ 429 export_busy)。1本目が完了・失敗・失効した後は受理 ★D25 ⑤★
R34 3台で進行中に4台目の createExport busy(→ 429)
R35 DS-003 のトークンで DS-002 の command_id に result・prepared・チャンクを送る not_found(→ 404)。★403 にしない★
R36 ★list の command_id に prepared★ を送る not_found(→ 404)
R37 待ち行列に PENDING_COMMANDS_PER_DEVICE(5)件あるときの requestList / createExport busy(→ 429 agent_busy) D23
R38 一覧の結果に truncated: true そのままブラウザへ ★D18★
R39 状態の変化 未接続 → 接続中で ds_agent.connected を1回、接続中 → 未接続で ds_agent.disconnected を1回。★リクエストのたびには出さない★
R40 ★t=0 prepared(201)。GET が来ない★ → t=60 / ★t=60.001★ 進行中のまま(t=60 に createExport すると 429) / ★失効★: 進行中から外れ、★同じ制御盤の createExport が受理される★。claim は 404 export_not_found。その後に制御盤が送る fail / チャンクは gone(→ 410) ★D25 ⑤★(制御盤は120秒で zip を消す)
R41 ★エクスポートの流れ中(または準備中)に requestList★ ★待ち行列に入れ、制御盤の poll に list が渡る★(断らない)。一覧の結果が返る ★D25 ③★
R42 ★send の指示の command_id★ にチャンク・complete を送る not_found(→ 404) ★D25 ①★
R43 result / prepared / complete / fail の本体の ★形の誤り★(JSON でない・必須の項目が無い・型が違う) ★400 result_invalid(4つとも同じ)★。状態は変えない ★D25 ④★

表6(lib/ds-logs/file-name.ts の exportFileName(serial, now))

# 入力 期待
N1 DS-002・2026-10-19T12:34:56+09:00 DS-002_logs_20261019-123456.zip
N2 同じ瞬間を 2026-10-19T03:34:56Z で 同上(★JST★)
N3 2026-10-19T15:00:00Z …_20261020-000000.zip(日付が変わる)
N4 シリアル DS 002/"x" DS_002__x__logs_….zip(D23)

★テストの書き方の注意★:

  • ★日時は +09:00 付きの ISO 文字列で書く★。new Date(2026, 9, 19) のような実行環境のタイムゾーンに依存する書き方をしない(表2 T10)
  • 表5 は ★本物のタイマーで待たない★。createRelay({ now, setTimer, clearTimer, limits }) に偽の時計を渡し、advance(秒) で進める(vi.useFakeTimers() でもよいが、now と setTimer を両方とも同じ偽の時計から取ること)
  • 表5 の 4MB・9MB は ★limits.chunkMaxBytes を小さく(例 16 バイト)差し替えて★ 同じ規則を確かめてよい。★ただし R13・R24 の境界(ちょうど・+1)と R11・R14・R18・R31・R32 の秒の境界は、差し替えた値で必ず書く★
  • ★期待値は設定値から作る★(CHUNK_MAX_BYTES + 1・EXPORT_PREPARE_TIMEOUT_SEC 等)。★R21〜R23・R27 の「わざと違う値」だけは直書きし、コメントで理由を書く★
  • 表4 の「正しい Bearer」は deps.verifyAgentToken のスタブで作る(ストアに触らない)。★A7 はスタブが呼ばれていないこと(toHaveBeenCalledTimes(0))を見る★
  • 表3 は __setAgentTokenStoreForTesting()(新規。ds-device-notes.ts の __setDeviceNotesStoreForTesting() と同じ形)で mkdtemp のストアに差し替える。★data/settings/ の実物に触らない★。KA1〜KA8 はルートのテスト(段階7-4)
  • ★ディスクに書かないことのテスト★(D3)は 段階7-4 のとおり

4. ★落とし穴(全段階で読むこと)★

# 罠 どう避けるか
4-1 ★proxy は本体を 10MB までメモリにバッファし、超えた分を切り捨てる。クライアントにはエラーにならず、サーバーに警告が1行出るだけ★(§1 の出所) ★チャンクは CHUNK_MAX_BYTES(4MB)以下★。★長さとハッシュを必ず検証する★。★experimental.proxyClientMaxBodySize を上げて解決しようとしない★。★agent パスを matcher から外さない★(proxy.ts:106-107)
4-2 ★relay がモジュールのシングルトンであること★ ★globalThis.__dwatchDsAgentRelay に置く★(lib/db/prisma.ts と同じ形。declare global)。★ルートごとにモジュールが別に読み込まれても同じ relay を見る★(推測。段階11-3 の通し確認で、依頼と結果が別ルートで噛み合うことを確かめる)。★1プロセス前提を壊す変更(cluster・PM2 の複数インスタンス・next start の複数起動)をしない★
4-3 ★全チャンクを配列に溜める★ ★流量制御★: new ReadableStream({ pull, cancel }, { highWaterMark: 1 })。pushChunk は enqueue した後、★desiredSize > 0 になる(pull が呼ばれる)まで 204 を返さない★(★最大 45秒。超えたら 410。D20★)
4-4 ★応答を流し始めた後にステータスを変えようとする★ ★最初のチャンクが届くまで Response を返さない★(それまでの失敗は JSON の 504 / 502)。★流し始めた後の失敗は controller.error()★。ログに残す
4-5 ★ダウンロードの応答ヘッダの付け忘れ★ ★Content-Type: application/zip・Content-Disposition: attachment; filename="…"; filename*=UTF-8''…・Cache-Control: no-store・X-Accel-Buffering: no・Content-Length: total_bytes★ を必ず付ける(nginx に応答をバッファさせない=一時ファイルに書かせない)。ルートのテストで5つとも見る
4-6 ★サーバーのディスクに書く★(一時ファイル・os.tmpdir()・fs.mkdtemp・createWriteStream) ★禁止★(D3)。lib/ds-agent/relay.ts・lib/ds-logs/**・app/api/ds-agent/**・app/api/ds-devices/[serial_number]/logs/** は ★node:fs / node:os を import しない★(段階7-4 で確かめる)。★トークンのストア(tokens.ts)だけは書いてよい★(発行・失効のときだけ)
4-7 ★認証の取り違え★ ★agent パスは cookie で通さない。通常パスは Bearer で通さない★(表4)。★ヘッダで serial を受け取らない★。ルートは requireAgent() で再検証し、★トークンから serial を得る★。★command_id が別の制御盤のものなら 404★(表5 R35)。★トークンの発行・失効は cookie のパス★(表4 A11)
4-8 ★トークン・中身をログやストアに残す★ ★生のトークンは保存しない(SHA-256 だけ)★。★出すのは発行の POST の応答の1回だけ★(画面の state にも閉じたら残さない・localStorage に入れない)。★ファイルの中身・ファイル名の一覧全体をログに出さない★。イベントのキー名に token / authorization / note / detail を使わない(lib/logger/redact.ts が本番で消す)
4-9 ★ブラウザ・制御盤の切断に気づかない★ ★ReadableStream の cancel() と request.signal の abort の両方★でエクスポートを取り消す(次のチャンクは 410)。★ポーリングと準備の POST の request.signal も見る★(ポーリングが切れたら待ちを外す。準備の POST が切れたらエクスポートを取り消す)。★実際の切断で効くかは段階11 で確かめる★
4-10 ★長い待ちが nginx の60秒を超える★ ★準備の POST は最大 50秒(EXPORT_PREPARE_TIMEOUT_SEC)・チャンクの保留とチャンクの間隔は最大 45秒・ダウンロードの最初のチャンクは最大 30秒・ポーリングは最大 25秒・一覧は最大 15秒★。★設定値を上げて 60秒以上にしない★(D14・D20)
4-11 ★Content-Length と実際のバイト数が食い違う★ Content-Length は ★prepared の total_bytes★。★超えるチャンクは受けない(R25)★。足りないまま終わるのは失敗のときだけ(ブラウザ側はダウンロード失敗になる。それでよい)
4-12 ★「実測」と書くときに出所を書かない★ CLAUDE.md §6-6。★「当方の fixtures 由来」「当方の偽制御盤スクリプト由来」「構造(コード・node_modules)」★を書き分ける
4-13 ★JP/EN・ダーク/ライト★ 辞書のキーは ★日本語の原文★(lib/i18n.ts)。★既にあるキーは足さない★(重複は lint で落ちる。閉じる・キャンセル・読み込み中…・種類・日付 は既にある)。★色は CSS 変数★。★components/ はテスト対象外なので画面は目視で確かめる★
4-14 ★t() に生値を通す★ ファイル名・種類のラベル(制御盤から来る)・シリアル・施設名・★トークン★は ★t() に通さない★
4-15 ★シリアルの照合を緩める★ ★relay の鍵・トークンのシリアル・パスのシリアルは JS の ===★(D-4)。大小文字を揃えない・trim しない
4-16 ★設定値の直書き★ ★lib/config/ds-agent.ts から読む★。★このファイルは node: の import を持たない★(画面からも import する)
4-17 ★modified_at を文字列で比べる・並べる★ ★Date.parse() の瞬間で比べる★(表1 Q9・Q10)。★オフセットの無い値は制御盤の結果ごと不正★(段階7-2)
4-18 ★制御盤の結果を検証せずに返す★ ★制御盤の応答は外から来たデータ★。形・長さ・型を検証し、違えば 502 agent_error(段階7-2)。★ファイル名は React の通常の描画だけ★(dangerouslySetInnerHTML を使わない)
4-19 ★git add -A・eslint-disable の追加★ 禁止(CLAUDE.md §7)。★他のセッションの未コミットの変更を含めない★

段階0 — 着手前

  1. ★develop で作業する★(.claude/worktrees/ の中ではない。ワークツリーは main 基点で古い)。git pull で最新にし、★指示書AX の実装コミットが入っていること★を git log で確かめる。入っていなければ着手しない。基点のコミットを冒頭の欄に書く。
  2. git status を控える。★自分が作っていない変更・未追跡ファイルは他のセッションの作業★。触らない・コミットに含めない。
  3. ★docs/decisions.md に D-179 がコミット済みか★を確かめる。未コミットなら ★§13 の追記は D-179 のコミットを待つ★(git add -p で自分の塊だけを選ぶのでもよい)。
  4. ★本書に書いた行番号を develop の先端ですべて確かめ直す★(特に d-watch-api.yaml・lib/types/index.ts・lib/i18n.ts・lib/fixtures/index.ts・DsLogScreen.tsx / .module.css — AX が触る)。ずれは作業報告に書く。
  5. npx prisma generate → npx tsc --noEmit -p . → npm test が通ることを確かめ、★テストの件数を記録する★。通らなければ着手前に報告。
  6. ★Next.js は版が新しい★。次を読む(CLAUDE.md 末尾): node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/proxy.md(async にできること :31)・…/05-config/01-next-config-js/proxyClientMaxBodySize.md・…/01-getting-started/15-route-handlers.md(★GET はキャッシュされない★。app/api/settings/display-limits/route.ts:27-35)・…/03-file-conventions/route.md(ストリーミング・request.signal)。

段階1 — 期待値表をテストにする(実装より先)

★この時点ではテストが落ちる(関数・型が無い)のが正しい★。★コミットはテストだけで1つ(「テストだけ。この時点では落ちる」と明記)★。 各ファイルの冒頭コメントに対応する表を写し、「指示書AY §3 表N。ブリーフ §5 と監督判断 第2ラウンドから本書で具体化。★P1・P2 の行は §11 Q14・Q15 の採用で確定★」と書く。

  1. lib/ds-logs/__tests__/query.test.ts(新規): 表1 Q1〜Q17(it.each)。★Q14〜Q16 は純粋関数を直接呼ぶ★
  2. lib/ds-logs/__tests__/presets.test.ts(新規): 表2 T1〜T10
  3. lib/ds-logs/__tests__/file-name.test.ts(新規): 表6 N1〜N4
  4. lib/ds-agent/__tests__/tokens.test.ts(新規): 表3 K1〜K12(mkdtemp。afterEach で消す)
  5. lib/proxy/__tests__/authenticate.test.ts(★足す★): 表4 A1〜A11。★既存のテストは無修正で通ること★
  6. lib/ds-agent/__tests__/relay.test.ts(新規): 表5 R1〜R43
  7. lib/ds-logs/__tests__/validate.test.ts(新規): 段階3-3 の V1〜V7 と、段階7-2 の W1〜W9

(表3 KA1〜KA8 のルートのテストは、ルートの形が段階7 で決まるので段階7 で書く)


段階2 — API 契約(d-watch-api.yaml。★実装より先。CLAUDE.md §6-1★)

2-1. 足すもの

  1. components.securitySchemes(:1734 付近)に ★agentBearer★(type: http・scheme: bearer。description: 制御盤ごとのトークン。/ds-agent/ 配下だけ。cookie では通らない。D-179)
  2. タグ ds-agent(制御盤向け)
  3. パス(/ds-devices/{serial_number}/note の後ろ・/ds-alerts の前に置く)。★検証エラーはすべて ValidationError(422 validation_failed。D17)★:
パス メソッド security 要点
/ds-devices/{serial_number}/logs GET cookieAuth(既定) クエリ types(カンマ区切り。省略で全種類)・from / to(ISO 8601 ★オフセット必須★。from ≤ modified_at < to)・sort(date_desc 既定 / type_date_desc)・page(1〜)・per_page(★50 / 100 / 200。既定 200★)。200 は DsLogListResponse(★truncated を含む。D18★)。409 agent_offline(details.last_seen_at に最終接続。無ければ空配列)・504 agent_timeout・502 agent_error(details.agent_code)・429 agent_busy・404 not_found・422
/ds-devices/{serial_number}/logs/exports POST cookieAuth 本体 { file_ids: string[] }(1〜EXPORT_MAX_FILES 件・重複なし・各 1〜1024 文字)。★201★ { export_id, download_url, total_bytes, file_count, expires_in_sec }。★最大 50秒待つ(制御盤が zip を作る)★。502 agent_error(too_large / file_not_allowed / file_not_found / io_error 等)・504・409・429 export_busy / agent_busy・404・422
/ds-devices/{serial_number}/logs/exports/{export_id} GET cookieAuth ★1回だけ使える★。200 は application/zip(format: binary)と ★5つのヘッダ(4-5)★。504 agent_timeout(30秒)・502 agent_error・404 export_not_found(無い・失効・使用済み・★パスのシリアルと違う★)。description に ★流し始めた後の失敗はダウンロードの失敗になる(ステータスは200のまま)★・★JSON のエラーはまれ(制御盤の切断など)で、ブラウザによってはファイルとして保存される(D21)★
/ds-devices/{serial_number}/agent-token GET cookieAuth ★P1(Q14 採用)★。200 `{ issued_at: string \ null }`。★トークン・ハッシュは返さない★。404・422
同上 POST cookieAuth 本体なし。★201★ { token, issued_at }。description: ★生の値はこの応答で1回だけ・既にあれば作り直して古いものは無効・保存は SHA-256 だけ(D16)★。404・422
同上 DELETE cookieAuth ★204★(★未発行でも 204(冪等)。P2(Q15 採用)★)。404・422
/ds-agent/commands GET ★agentBearer★ wait(1〜25。既定 25)・ヘッダ X-Agent-Version。200 DsAgentCommand(★kind は list / export / send★)/ 204(指示なし)/ 401 / 422
/ds-agent/commands/{command_id}/result POST agentBearer 本体 DsAgentListResult(★4MB 以下★)。204 / 400 result_invalid / 404 / 410 command_expired
/ds-agent/commands/{command_id}/prepared POST agentBearer ★本体 { total_bytes, sha256, file_count }★(export の指示だけ)。204 / 400 result_invalid / 404 / 410 command_expired
/ds-agent/commands/{command_id}/chunks/{seq} POST agentBearer ★export の指示の command_id(D25)★。application/octet-stream・ヘッダ X-Chunk-Sha256(小文字の16進64桁)。204(★ブラウザ側に空きができてから。最大45秒★)/ 400 chunk_invalid / 409 chunk_out_of_order / 410 export_cancelled / 404
/ds-agent/commands/{command_id}/complete POST agentBearer ★export の指示の command_id(D25)★。{ total_bytes, sha256 } → 204 / 400 result_invalid(形の誤り・値の不一致)/ 410 export_cancelled / 404
/ds-agent/commands/{command_id}/fail POST agentBearer { code, message }(code は 1〜64 文字・message は 1〜500 文字。★送信中の失敗は export の指示の command_id。制御盤に zip が無いときは export_not_found(D25)★)→ 204 / 400 result_invalid / 404 / 410 export_cancelled
  1. スキーマ: DsLogType { key, label }・DsLogFile { file_id, name, type, size_bytes, modified_at }・DsAgentStatus { connected, last_seen_at, agent_version }・ DsLogListResponse { agent, types, items, truncated, meta }・DsLogExportRequest・DsLogExportResponse { export_id, download_url, total_bytes, file_count, expires_in_sec }・ DsAgentTokenStatus { issued_at }・DsAgentTokenIssued { token, issued_at }・ DsAgentCommand { command_id, kind: [list, export, send], params }・DsAgentListResult({ types, items, truncated } または { error: { code, message } })・DsAgentPrepared・DsAgentComplete・DsAgentFail
  2. ★各パスの description に★: ★サーバーには保存しない(D-179)★/一覧は制御盤からもらい D-Watch が絞る(D8)/★1プロセス前提・再起動で進行中は失われる★/★チャンクが 4MB 以下である理由(proxy の 10MB バッファ)★/★エクスポートは準備 → 送信の2段(D14)★

2-2. 型とエラーコード — lib/types/index.ts

  • 上のスキーマと同名の型を足す(CLAUDE.md §9.2。DsDevice(:1024 付近)の近く)
  • ★ErrorCode(:31-45)に足す★: agent_offline / agent_timeout / agent_error / agent_busy / export_busy / export_not_found / chunk_invalid / chunk_out_of_order / export_cancelled / command_expired / result_invalid。★validation_error は足さない(D17)★

コミット(YAML と型で1つ)。


段階3 — 設定値と純粋関数

3-1. lib/config/ds-agent.ts(新規。★node: を import しない・lib/config/index.ts を import しない★)

名前 値 意味
AGENT_POLL_WAIT_SEC 25 ポーリングの待ちの上限(wait の最大・既定)
AGENT_OFFLINE_AFTER_SEC 60 最後のリクエストからこれを ★超えたら★ 未接続(D19)
LIST_TIMEOUT_SEC 15 一覧の結果を待つ上限
EXPORT_PREPARE_TIMEOUT_SEC ★50★ ★制御盤が zip を作り終えるまで(POST が待つ上限。nginx の60秒より短く)★
EXPORT_CLAIM_TIMEOUT_SEC 60 201 から ダウンロード URL を開くまでの上限(expires_in_sec)
EXPORT_FIRST_CHUNK_TIMEOUT_SEC 30 開いてから最初のチャンクまで
EXPORT_IDLE_TIMEOUT_SEC ★45★ ★流し始めた後のチャンクの間隔の上限、かつチャンクの POST を保留する上限(D20)★
CHUNK_MAX_BYTES 4 * 1024 * 1024 1チャンクの上限(proxy の 10MB より十分小さく)
EXPORT_MAX_BYTES 100 * 1024 * 1024 ★1回の zip の上限(仮。D-179 追記 項5)★
EXPORT_MAX_FILES 500 1回に選べるファイル数
EXPORT_CONCURRENCY_PER_DEVICE 1 制御盤ごとの同時エクスポート
EXPORT_CONCURRENCY_TOTAL 3 全体の同時エクスポート
LIST_RESULT_MAX_BYTES ★4 * 1024 * 1024★ 一覧の結果の本体の上限(D18)
LIST_RESULT_MAX_ITEMS 20000 一覧の件数の上限(制御盤はここで打ち切り truncated)
PENDING_COMMANDS_PER_DEVICE 5 制御盤ごとの待ち行列の上限
DS_LOG_PER_PAGE_OPTIONS [50, 100, 200] 画面のページあたり件数(★PAGINATION.maxPerPage(lib/config/index.ts:438)と同じ 200 が上限★)
DS_LOG_DEFAULT_PER_PAGE 200 既定

各定数の docstring に ★「何の上限か・なぜその値か・出所(D-179・監督判断 第2ラウンド M1〜M5)」★ を書く。★リクエストを待たせる5つ(LIST_TIMEOUT_SEC 15・AGENT_POLL_WAIT_SEC 25・EXPORT_FIRST_CHUNK_TIMEOUT_SEC 30・EXPORT_IDLE_TIMEOUT_SEC 45・EXPORT_PREPARE_TIMEOUT_SEC 50)には「nginx の proxy_read_timeout(既定60秒)より短くすること」★ と書く。

3-2. lib/ds-logs/query.ts(新規。純粋関数)

export interface DeviceLogQuery {
  types: readonly string[] | null;   // null = 全種類
  from: number | null;               // 瞬間(ms)。from ≤ t
  to: number | null;                 // t < to
  sort: "date_desc" | "type_date_desc";
  page: number;                      // 1〜
  perPage: number;
}
/** 表1。★入力の配列を書き換えない★。並べ替えの同値は name の `<` / `>`(`localeCompare` を使わない) */
export function queryDeviceLogs(
  types: readonly DsLogType[], items: readonly DsLogFile[], query: DeviceLogQuery
): { items: DsLogFile[]; total: number }
  • ★modified_at は Date.parse() で比べる★。出力の modified_at は ★toJstIso(new Date(ms))★(表1 Q17。秒まで)
  • type_date_desc の種類の順は ★types 配列の添字★。★types に無い種類の項目は来ない★(段階7-2 の検証で弾く)

3-3. クエリ・本体の検証(lib/ds-logs/validate.ts。新規。lib/api/query-result.ts の QueryParseResult を返す。★エラーは 422 validation_failed★)

# 入力 期待
V1 パラメータなし { types: null, from: null, to: null, sort: "date_desc", page: 1, perPage: 200 }
V2 from=2026-10-19(日付だけ)/from=2026-10-19T11:00:00(★オフセットなし★)/from=abc 422
V3 from > to 422。★from = to は通る(0件)★
V4 types=(空)/types=app,,sys(空の要素) 422
V5 sort=name 422
V6 per_page=30 / page=0 / page=1.5 422
V7 POST の本体 { file_ids: [] } / 501件 / 重複あり / 要素が文字列でない / 1025文字 422

コミット。


段階4 — 制御盤トークン(lib/ds-agent/tokens.ts)

/** 32バイトの乱数を base64url。ストアには SHA-256 の16進だけを書く。★生の値は戻り値で1回だけ★ */
export async function issueAgentToken(serialNumber: string): Promise<{ token: string; issued_at: string }>
export async function revokeAgentToken(serialNumber: string): Promise<boolean>   // 無ければ false
export async function agentTokenIssuedAt(serialNumber: string): Promise<string | null>   // P1(Q14 採用)
/** `Authorization` ヘッダの生値 → serial または null。★全件と timingSafeEqual で比べ、途中で抜けない★ */
export async function verifyAgentAuthorization(header: string | null): Promise<string | null>
export function __setAgentTokenStoreForTesting(store: JsonStore<AgentTokens> | null): void
  • 保存先は ★呼び出し時に★ path.join(process.cwd(), "data", "settings", "ds-agent-tokens.json")(ds-device-notes.ts:53-55 と同じ理由)。★再デプロイで消えないこと(Docker なら volume)は監督が docs/deploy.md に書く(§12-2)★
  • ★lib/logger を import しない★(proxy から呼ぶため。json-store.ts:15-17 と同じ考え方)。壊れたストアの警告は onWarn で settings.store_corrupt を出す(呼び出し側。前例 ds-device-notes.ts:65-73)
  • ヘッダの形は ★/^Bearer ([A-Za-z0-9_-]{43})$/★(表3 K5・K6)。形が違えば ★ストアを読まずに null★
  • 比較は ★入力の SHA-256 とストアの各 token_sha256 を timingSafeEqual★。★見つかっても残りを比べ終えてから返す★
  • ★キャッシュ(D22)★: 読んだ内容をメモリに持ち、★fs.stat の mtimeMs が変わったら読み直す★(表3 K12)。★発行・失効の後は自分のキャッシュを捨てる★
  • issued_at は toJstIso(new Date())(+09:00)
  • ★シリアルの実在(DB)はここでは見ない★(ルートが dsDeviceExists() で見る。表3 KA5)

コミット(表3 K1〜K12 が通ること)。


段階5 — 認証(lib/proxy/authenticate.ts と proxy.ts。D22)

  1. authenticate.ts に ★isAgentPath(pathname)★(pathname === "/api/ds-agent" || pathname.startsWith("/api/ds-agent/")。表4 A8・A10)
  2. ★authenticate()(同期)★: IP 判定の後、★agent パスなら cookie を見ずに unauthenticated★(表4 A2 の保険。proxy からは呼ばれない)。★それ以外の分岐は変えない★
  3. ★authenticateAgent(pathname, forwardedFor, authorization, deps)(新規・async)★:
    1. ★IP 判定(authenticate() と同じ関数を共有。複製しない)★ → 拒否なら deny_ip(★トークン検証を呼ばない★。表4 A7)
    2. deps.verifyAgentToken ?? verifyAgentAuthorization → null なら unauthenticated、そうでなければ allow
    3. ★戻り値に serial を入れない★(後段へヘッダで渡さない。D5)
  4. proxy.ts: ★export async function proxy★。isAgentPath(pathname) なら await authenticateAgent(…, request.headers.get("authorization"))、そうでなければ今の authenticate()。 ★401 の応答は今の API の 401 と同じ形★。agent パスの 401 では ★ds_agent.auth_failed(warn)を出す★(reason: "missing" | "invalid"・route。★ヘッダの値は出さない★)。 ★冒頭コメントの「実行順」と matcher のコメントは変えず、agent パスの分岐を1行足す★
  5. ★lib/ds-agent/require-agent.ts(新規)★: requireAgent(request): Promise<{ ok: true; serial: string } | { ok: false; response: Response }>。★制御盤向けのルートの先頭で必ず呼ぶ★(D5)。★通ったら relay の touch(serial) で last_seen_at を更新する(D19。表5 R7)★

★既存の lib/proxy/__tests__/authenticate.test.ts が無修正で通ること★。コミット。


段階6 — relay(lib/ds-agent/relay.ts)

6-1. 形

export interface RelayDeps {
  now: () => number;                                   // ms
  setTimer: (fn: () => void, ms: number) => unknown;   // 既定は setTimeout(★unref() する★)
  clearTimer: (handle: unknown) => void;
  limits: RelayLimits;                                 // 既定は lib/config/ds-agent.ts
  onEvent?: (e: RelayEvent) => void;                   // ログの受け口(段階10)。relay は logger を import しない
}
export function createRelay(deps?: Partial<RelayDeps>): Relay
export function getRelay(): Relay            // globalThis.__dwatchDsAgentRelay(無ければ作る)
export function __setRelayForTesting(relay: Relay | null): void
側 メソッド 戻り値
制御盤 touch(serial) void(★認証の通ったすべてのリクエストで呼ぶ。D19★)
制御盤 poll(serial, agentVersion, waitSec, signal) `Promise<AgentCommand \ null>`
制御盤 submitListResult(serial, commandId, result) `"ok" \ "not_found" \ "gone"`
制御盤 submitPrepared(serial, commandId, { total_bytes, sha256, file_count }) `"ok" \ "not_found" \ "gone"(★total_bytesが上限を超えても"ok"`。エクスポート側を失敗にする。R13★)
制御盤 failCommand(serial, commandId, code, message) `"ok" \ "not_found" \ "gone"`
制御盤 pushChunk(serial, exportCommandId, seq, bytes, sha256Hex) `Promise<"ok" \ "invalid" \ "out_of_order" \ "cancelled" \ "not_found">`
制御盤 completeExport(serial, exportCommandId, totalBytes, sha256Hex) `"ok" \ "invalid" \ "cancelled" \ "not_found"`
ブラウザ agentStatus(serial) { connected, last_seen_at, agent_version }
ブラウザ requestList(serial) `Promise<{ kind: "ok"; result } \ { kind: "offline" \ "timeout" \ "busy" } \ { kind: "agent_error"; code }>`
ブラウザ createExport(serial, fileIds, signal) `Promise<{ kind: "prepared"; exportId; totalBytes; fileCount; expiresInSec } \ { kind: "offline" \ "busy" \ "timeout" } \ { kind: "agent_error"; code }>`
ブラウザ claimExport(serial, exportId, signal) `Promise<{ kind: "stream"; body: ReadableStream; totalBytes } \ { kind: "not_found" \ "timeout" } \ { kind: "agent_error"; code }>`

6-2. 規則

  • ★id(command_id / export_id)は crypto.randomUUID()★(推測できない値。パスに出る)
  • ★エクスポートの流れ(D14)★: createExport で export の指示を入れ ★prepared を最大 50秒待つ★ → prepared の total_bytes を ★EXPORT_MAX_BYTES と比べる(D15)★ → 201 → claimExport で ★send の指示({ export_command_id })を入れる★ → 最初のチャンクを最大30秒 → 流す。★チャンク・complete・送信中の fail は export の指示の command_id に来る。send の指示は自分の id を持ち、params.export_command_id で export を指す(D25 ①)★。★prepared から送信・完了・失敗・失効までを進行中に数え、prepared から60秒 GET が無ければ失効させて進行中から外す(D25 ⑤)★。★list はエクスポートの進行中でも待ち行列に入れる(D25 ③)★
  • ★complete で確かめること(D15)★: 受け取った合計 = prepared の total_bytes = complete の total_bytes、かつ 受け取ったバイト列の SHA-256 = prepared の sha256 = complete の sha256
  • ★last_seen_at は touch()(すべての agent リクエスト)と poll の開始・終了で更新★。poll が待っている間は接続中(表5 R4〜R7)。★状態が変わったときだけ onEvent★(表5 R39。未接続への変化はタイマーで見つける)
  • ★SHA-256 は node:crypto の createHash を逐次★(全体のハッシュのためにチャンクを溜めない)
  • ★流量制御の保留とアイドルは 45秒(D20)★。超えたら 410 とエクスポートの失敗
  • ★終わった・失効したエクスポートと指示は、タイマーで消す★(メモリに残し続けない)。消した後の id は not_found/失効の直後は gone(410)を返せるよう、★短い間(例 EXPORT_IDLE_TIMEOUT_SEC)だけ墓標を残す★
  • ★タイマーは unref()★(テストとプロセスの終了を止めない)
  • docstring に ★1プロセス前提・再起動で失われる・ディスクに書かない・待ちは nginx の60秒より短く★ を書く

コミット(段階1 の表5 が通ること)。


段階7 — ルート

7-1. ブラウザ向け(app/api/ds-devices/[serial_number]/)

ファイル 流れ
logs/route.ts(GET) parsePathSerialNumber() → クエリの検証(3-3)→ ★db.dsDeviceExists()(404)★ → requestList() → 結果の検証(7-2)→ queryDeviceLogs() → 200(agent・types・items・★truncated★・meta)
logs/exports/route.ts(POST) 同じ順で検証・404 → ★createExport(serial, fileIds, request.signal)(最大50秒)★ → ★201★(download_url は /api/ds-devices/${encodeURIComponent(serial)}/logs/exports/${exportId})
logs/exports/[export_id]/route.ts(GET) claimExport(serial, exportId, request.signal) → stream なら new Response(body, { headers })(★4-5 の5つ★。ファイル名は exportFileName(serial, now))→ それ以外は JSON のエラー
agent-token/route.ts(GET / POST / DELETE) parsePathSerialNumber()(422)→ db.dsDeviceExists()(404)→ agentTokenIssuedAt / issueAgentToken / revokeAgentToken → 200 / ★201★ / ★204★。★POST の応答に Cache-Control: no-store★。ログ ds_agent.token_issued / ds_agent.token_revoked(段階10)
  • ★withRequestId(request, …) で包む★(既存のルートと同じ)。500 は logApiError()
  • ★fixtures のときは relay を呼ぶ前に ensureFixtureAgent()(段階8)★。★それ以外の分岐をルートに書かない★(CLAUDE.md §9.5)

7-2. 制御盤の一覧の結果の検証(lib/ds-logs/validate.ts)

★1件でも形が違えば、結果全体を不正とする★(黙って落とさない)→ ブラウザには 502 agent_error(details.agent_code = ["result_invalid"])、制御盤には 400 result_invalid。

# 結果 期待
W1 正しい(types 3・items 8・truncated: false) 受理
W2 types の key が重複 不正
W3 items[].type が types に無い 不正
W4 modified_at に ★オフセットが無い★(Python の naive な isoformat()) 不正
W5 size_bytes が負・小数・文字列 不正
W6 file_id が重複 不正
W7 items が LIST_RESULT_MAX_ITEMS + 1 件 不正(制御盤はそこで打ち切る約束。D18)
W8 本体が LIST_RESULT_MAX_BYTES(4MB)を超える 不正(★Content-Length が無くても読んだ長さで判定★)
W9 { error: { code, message } } failCommand と同じ扱い(ブラウザに 502・agent_code は制御盤の code)

prepared の本体(total_bytes は0以上の整数・sha256 は小文字の16進64桁・file_count は1以上の整数)・complete の本体・fail の本体も同じファイルで検証し、★形が違えばすべて 400 result_invalid(D25 ④)★。

7-3. 制御盤向け(app/api/ds-agent/)

ファイル 要点
commands/route.ts(GET) ★requireAgent()★ → wait の検証(422)→ X-Agent-Version は /^[0-9A-Za-z.+-]{1,32}$/ に合えば採り、合わなければ null(拒否しない)→ poll(serial, version, wait, request.signal) → 200 / 204
commands/[command_id]/result/route.ts requireAgent() → 本体(request.text() の長さで W8)→ 検証 → submitListResult → 204 / 400 / 404 / 410
commands/[command_id]/prepared/route.ts requireAgent() → 本体の検証 → submitPrepared → 204 / 400 / 404 / 410
commands/[command_id]/chunks/[seq]/route.ts requireAgent() → seq は0以上の整数 → X-Chunk-Sha256 は /^[0-9a-f]{64}$/ → new Uint8Array(await request.arrayBuffer()) → pushChunk → 204 / 400 / 409 / 410 / 404
commands/[command_id]/complete/route.ts requireAgent() → { total_bytes, sha256 } の検証(★形の誤りは 400 result_invalid★)→ completeExport
commands/[command_id]/fail/route.ts requireAgent() → { code, message } の検証(★形の誤りは 400 result_invalid★)→ failCommand

★agent のルートは DB を読まない★(USE_FIXTURES に関係なく同じ動き)。

7-4. テスト

ファイル 内容
app/api/ds-devices/[serial_number]/logs/__tests__/route.fixtures.test.ts fixtures で: DS-002 の一覧が200(agent.connected true・types 3つ・truncated false)・絞り込みが効く/★DS-001 は 409★/DS-999 は 404/★POST → 201(total_bytes が fixtures の zip の長さ)→ GET で zip のバイト列が fixtures の zip と一致・ヘッダ5つ★/★2回目の GET は 404★
app/api/ds-devices/[serial_number]/logs/__tests__/route.test.ts ★非 fixtures★(vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false }))・db.dsDeviceExists を vi.spyOn)。relay は __setRelayForTesting(createRelay(偽の時計))。表5 の HTTP の対応(409 / 504 / 502 / 429 / 404 / 422)と、表1 の絞り込みがルートを通って効くこと
app/api/ds-devices/[serial_number]/agent-token/__tests__/route.test.ts(新規) ★表3 KA1〜KA8★(ストアは mkdtemp・db.dsDeviceExists を vi.spyOn・ログは logEvent をスパイ)
app/api/ds-agent/__tests__/routes.test.ts トークンストアは mkdtemp。★Bearer なし・違う → 401(requireAgent() の再検証)★・R7(どのルートでも last_seen_at が進む)・R35・R36・チャンクの 400 / 409 / 410・W1〜W9・prepared の検証
app/api/ds-agent/__tests__/end-to-end.test.ts ★ルートのハンドラを直接呼んで一連★: ブラウザ GET logs ↔ 制御盤 GET commands → POST result / ★ブラウザ POST exports ↔ 制御盤 GET commands(export)→ POST prepared → 201 → ブラウザ GET exports ↔ 制御盤 GET commands(send)→ chunks 0..2 → complete★。★読んだ zip のバイト列と SHA-256 が送ったものと一致・Content-Length が一致★
★ディスクに書かないこと★(同じ end-to-end のファイル) (a) ★vi.spyOn(fs, "writeFileSync")・vi.spyOn(fs.promises, "writeFile")・vi.spyOn(fs.promises, "appendFile")・vi.spyOn(fs, "createWriteStream")・vi.spyOn(fs.promises, "mkdtemp")・vi.spyOn(os, "tmpdir")★ を一連の間に仕掛け、★0回★であること(import fs from "node:fs" / import os from "node:os" の既定の import に仕掛ける)。★トークンの発行(ストアの書き込み)はスパイを仕掛ける前に済ませる★ (b) ★ソースの静的な検査★: lib/ds-agent/relay.ts・lib/ds-logs/*.ts・app/api/ds-agent/**/route.ts・app/api/ds-devices/[serial_number]/logs/**/route.ts を読み、from "node:fs" / from "fs" / from "node:os" / tmpdir / mkdtemp の文字列が無いこと

★ルート本体は静的 import★(it() の中で await import() しない。note/__tests__/route.test.ts の冒頭コメント)。コミット。


段階8 — fixtures の偽制御盤

  1. lib/fixtures/ds-device-logs.ts(新規):
    • fixtureDeviceLogTypes: 種類3つ(★ダミーの名前★。例 app / comm / sys、ラベル「アプリ」「通信」「システム」)
    • fixtureDeviceLogItems(now: Date): ★数百件(例 300)★を now から決まった間隔で作る。★now ちょうど・1時間前ちょうど・3時間前ちょうど・24時間前ちょうど・その1秒前★ の項目を必ず含める(画面の目視用。境界の正しさはテストが持つ)。大きさは 1KB〜50MB(★選択の合計が 100MB を超える組み合わせが作れること★)
    • FIXTURE_EXPORT_ZIP_BASE64: ★中身がダミーの文字だけの小さな正しい zip★。★中のパスは root の名前つき(例 fixture-logs/app_20261019_1100.log。D24)★(フォルダ fixture-logs を PowerShell の Compress-Archive で包み base64 にする。★実在の店名・ログを入れない★)
    • FIXTURE_AGENT_OFFLINE_SERIALS = ["DS-001"](D10)
    • lib/fixtures/index.ts に export * from "./ds-device-logs";
  2. lib/ds-agent/fixture-agent.ts(新規): ensureFixtureAgent(relay, serial):
    • ★FIXTURE_AGENT_OFFLINE_SERIALS と fixtures に無いシリアルは何もしない★(→ 409 / 404 が本物と同じ経路で出る)
    • それ以外は ★relay.poll(serial, "fixture", AGENT_POLL_WAIT_SEC, …) を1本だけ待たせておき★、指示が来たら ★本物と同じメソッド★ で返す: list → submitListResult/★export → submitPrepared(fixtures の zip の長さ・SHA-256・選んだ件数)★/★send → 小さく(例 1KB)分けて pushChunk を複数回 → completeExport★(流量制御の経路を通す)
    • ★1シリアル1本★(globalThis の Set で二重起動を防ぐ)
  3. ★docs/fixtures.md は監督が直す★(ブリーフ §7)。足すべきことは §12-2

コミット。


段階9 — 画面(モーダル)

★見た目は AreaModal.tsx / .module.css から借りる。新しい共通部品は作らない★(components/ui/ に Modal を作らない)。

9-1. components/features/ds-logs/DsLogScreen.tsx

  • 表の右端に ★「ログ」列★(<colgroup> に .colLogs を足す・幅は固定 ★例 80px★、DsLogScreen.module.css の min-width も同じだけ広げる(:82 のコメントの規則))。各行に ログ ボタン → モーダルを開く(serial_number と facility_name を渡す)
  • ★モーダルの開閉以外は今の画面を変えない★

9-2. components/features/ds-logs/DeviceLogsModal.tsx(新規)と .module.css

部分 中身(★文言はこのとおり。JP/EN の辞書に足す★)
見出し 「制御盤のログ」+シリアル・施設名。右に ★接続状態★: 「接続中」(ok の色)/「未接続」(ng の色)+「最終接続 {日時}」(無ければ「最終接続 -」)。閉じる
種類 制御盤から来た types のチェックボックス(★ラベルは生値。t() に通さない★)。★既定は全部★。★1つも選ばれていなければ取得せず「種類を1つ以上選んでください」★
期間 ボタン「直近1時間」「直近3時間」「直近24時間」「全て」(presets.ts)+ <input type="date"> 2つ(openDatePicker。表2 T5〜T9)。★既定は「直近24時間」★(D23)
並べ替え 「日付」(date_desc)「種類+日付」(type_date_desc)
一覧 チェックボックス・ファイル名・種類(ラベル)・大きさ(KB / MB)・更新日時。見出しに「このページをすべて選択」。下に Pagination + ページあたり件数のセレクト(DS_LOG_PER_PAGE_OPTIONS)。★truncated のときは一覧の上に「件数が多いため、新しいものから一部だけを表示しています」(D18)★
選択中 「選択: {n}件・合計 {size}」。★合計が EXPORT_MAX_BYTES を超えたら警告「1回に取り出せるのは {max} までです」+エクスポートを押せなくする★(★元ファイルの合計での目安。最終の判定は制御盤の zip の大きさ(D15)★)。★EXPORT_MAX_FILES を超えても同じ★(「1回に選べるのは {n} 件までです」)
エクスポート 押す → ★「制御盤で準備中…」(最大50秒。ボタンを押せなくする)★ → POST が 201 → download_url を ★<a href download> を作って click()(D21)★ → 「ダウンロードを開始しました({size})」
エラー 409「制御盤が接続されていません(最終接続: {日時})」/504「制御盤から応答がありません。しばらくしてから再度お試しください」/429 export_busy「この制御盤は別のエクスポートを処理中です」/429 agent_busy「制御盤が混み合っています。しばらくしてから再度お試しください」/502「制御盤でエラーが発生しました({code})」(★too_large は「選んだファイルが大きすぎます(1回 {max} まで)」★)
★制御盤トークン★(下部。D16) 「制御盤トークン」+「発行日時 {日時}」/「未発行」(GET …/agent-token。P1(Q14 採用))。ボタン「発行(作り直し)」「失効」。
発行: ★確認「トークンを発行します。発行済みのトークンは使えなくなります。よろしいですか?」★ → POST → ★トークンを読み取り専用の欄に1回だけ表示・「コピー」ボタン・注意「この値は二度と表示されません。制御盤の設定ファイルに入れてください」★。★モーダルを閉じたら state から消す★。
失効: ★確認「トークンを失効させます。この制御盤はログを送れなくなります。よろしいですか?」★ → DELETE。
★この部分は制御盤が未接続(409)でも使える★(初めて設定するときは未接続のため)
  • ★選択の保持(D23)★: file_id → size_bytes の Map で持つ。★ページ送り・並べ替え・ページあたり件数では保持★、★種類・期間を変えたら消す★
  • 取得中は「読み込み中…」(t() に通す)。★一覧の取得は毎回制御盤に問い合わせる★(最大15秒)ので、★前の応答が後から返って上書きしないよう、要求ごとの番号で古い応答を捨てる★
  • ★role="dialog" aria-modal="true"・Esc で閉じる・背景のスクロールを止める★(AreaModal.tsx の作りを確かめて揃える)。★準備中に閉じたら POST を AbortController で取り消す★(サーバー側はエクスポートを取り消す。4-9)
  • ★色は CSS 変数・固定幅のボタン(JP/EN で位置が動かない)★
  • 日時の表示は既存の formatDateTime()(DsLogScreen.tsx:41)と同じ形

9-3. lib/i18n.ts

★足す前に grep する★(閉じる・キャンセル・読み込み中…・種類・日付 は既にある)。EN は ★暫定★。 例: 「ログ」"Logs"/「制御盤のログ」"Controller logs"/「接続中」"Connected"/「未接続」"Not connected"/「直近1時間」"Last 1 hour"/「直近24時間」"Last 24 hours"/「種類+日付」"Type + date"/「このページをすべて選択」"Select all on this page"/「エクスポート」"Export"/「制御盤で準備中…」"Preparing on the controller…"/「制御盤トークン」"Controller token"/「発行(作り直し)」"Issue (regenerate)"/「失効」"Revoke"/「コピー」"Copy"/ほか 9-2 の文言すべて。

コミット(画面は vitest で走らないので段階11 で見る)。


段階10 — ログのイベント(lib/logger/events.ts)

event level フィールド(★これ以外を載せない★) どこで
ds_agent.connected / ds_agent.disconnected info serial_number・agent_version relay の onEvent(状態が変わったときだけ。表5 R39)
ds_agent.auth_failed warn route・reason(missing / invalid) proxy(段階5)。★ヘッダの値・トークンを出さない★
★ds_agent.token_issued / ds_agent.token_revoked★ info ★serial_number だけ★ agent-token のルート(表3 KA8。D16)
ds_logs.list info serial_number・item_count・truncated・duration_ms・result(ok / offline / timeout / busy / agent_error) GET logs
ds_logs.export_prepared info serial_number・file_count・total_bytes・duration_ms POST exports の 201
ds_logs.export_started info serial_number・file_count claim で流し始めたとき
ds_logs.export_completed info serial_number・file_count・total_bytes・duration_ms complete
ds_logs.export_failed warn serial_number・stage(prepare / send)・reason・bytes_sent 失敗・取り消し・失効・タイムアウト
  • LogEvent の union(:456-471)に足す。★reason は固定の文字列の union★(制御盤から来た message を載せない。制御盤の code は agent_code として固定の長さで切って載せてよい)
  • ★file_name / file_ids / token / authorization を載せない★。テストで「イベントの値に file_id・ファイル名・トークンが入っていない」ことを確かめる

コミット。


段階11 — 画面確認と通し確認

★アプリ内ブラウザ(mcp__Claude_Browser__*)を使う。Edge(ヘッドレス・CDP を含む)は使わない★(ユーザー指示 2026-10-16)。 ★ユーザーの :3000(next dev。ステージング向け)は止めない★。★next dev ではなく next build → next start で確かめる★(1プロセス・本番と同じ形。4-2)。

11-1. 全体

  1. npx tsc --noEmit -p . 0件/npm run lint 0件/npm test 全件通過(★段階0 の件数から増えている★)
  2. ★npm run build の出力で、足したルートがすべて ƒ(Dynamic)★であること
  3. grep(★0件★):
    grep -rn "node:fs\|from \"fs\"\|node:os\|tmpdir\|mkdtemp\|createWriteStream" lib/ds-agent/relay.ts lib/ds-logs app/api/ds-agent "app/api/ds-devices/[serial_number]/logs"
    grep -rn "proxyClientMaxBodySize" next.config.ts
    grep -rn "console.log" lib/ds-agent lib/ds-logs app/api/ds-agent
    grep -rn "validation_error" lib app
  4. ★scripts/ds-agent-token.ts が無いこと★(D16)

11-2. fixtures(:3100)

  1. 本体で USE_FIXTURES=true npm run build(PowerShell: $env:USE_FIXTURES='true'; npm run build)
  2. ★テスト用の AUTH_TOKEN をその場で生成★し、★環境変数で渡して★ npx next start -p 3100。★トークンを文書・コミット・報告に書かない★
  3. preview_start の url で http://localhost:3100 を開き、ログインする
  4. 確かめること:
    • D-Sec ログの各行に「ログ」がある。DS-002 で開く → 接続中・種類3つ・既定の期間(直近24時間)で一覧が出る
    • 種類を外す・期間のボタン・日付の範囲・並べ替え・ページ送り・ページあたり件数で一覧が変わる。種類・期間を変えると選択が消える
    • 「このページをすべて選択」→ 合計の表示。★100MB を超えると警告とボタンの無効★
    • ★エクスポート → 「制御盤で準備中…」→ zip が落ちる・開ける・中が fixture-logs/…★
    • ★DS-001 で開くと「制御盤が接続されていません(最終接続: -)」★。★それでも制御盤トークンの部分は使える★
    • ★制御盤トークン★: 発行 → 確認 → 1回だけ表示・コピー → 閉じて開き直すと値は出ず、発行日時だけ → 作り直し → 失効 → 「未発行」(★ここで発行した値は確認後に失効させ、どこにも書かない★)
    • ★JP/EN 切替★でボタン・列の位置が動かない/★ダーク/ライト★で崩れない/Esc で閉じる
    • D-Sec ログの表で横スクロールが増えていない(resize_window 1440×900・JP/EN)。終わったら desktop に戻す
  5. ★終わったら :3100 の listen プロセスを止める★(自分で起動したものだけ)

11-3. 非 fixtures の通し確認(★偽制御盤スクリプト★。ローカルDB dwatch_local)

  1. npm run seed:local(★ローカル以外を拒否する★)。DS-002 が controls にあること
  2. USE_FIXTURES=false で ★ホストが localhost・DB 名が dwatch_local であることを確かめてから★ npm run build → next start -p 3100。★ALLOW_DIRECT_ACCESS=true を環境変数で★(XFF が無いため)
  3. ★画面のモーダル(DS-002)の「制御盤トークン」で発行★し、★その値を環境変数 DS_AGENT_TOKEN で偽制御盤に渡す★(★画面・ファイル・報告に残さない★)
  4. ★偽制御盤 scripts/ds-agent-fake.ts(D23。コミットする)★を tsx で動かす:
    • --server http://localhost:3100・トークンは環境変数
    • ポーリング → list には ★固定の一覧(種類2つ・10件程度)★ → export には ★--zip <パス> のファイルの長さ・SHA-256・件数で prepared★ → send には ★そのファイルを 4MB ごとに送り complete★(★file_ids は見ない。中身の正しさは制御盤(Python)の仕様★)
    • zip は ★9〜10MB 程度★(★4MB を超えて3チャンクになる大きさ★)。★root の名前つき(D24)★: 乱数の中身のファイルを入れたフォルダ(例 fake-root)を Compress-Archive で包む(中のパスが fake-root/…)
    • ★チャンクの POST のタイムアウトは60秒★(D20。設計書と同じ)
  5. 画面から一覧 → 選択 → エクスポート。★落ちたファイルの SHA-256 が --zip のファイルと一致★(Get-FileHash / certutil -hashfile)・★ブラウザに進捗(大きさ)が出る(Content-Length)★。サーバーのログに ds_logs.export_prepared と ds_logs.export_completed(total_bytes が一致)
  6. ★準備の失敗★: 偽制御盤の --fail-prepare too_large で、画面に「選んだファイルが大きすぎます」(POST の 502)
  7. ★切断★: エクスポートの途中でブラウザのダウンロードを取り消す → 偽制御盤に 410 が返り止まる・ds_logs.export_failed(stage: send)
  8. ★偽制御盤を止める★ → 60秒後に画面で「未接続」・ds_agent.disconnected が1回
  9. 応答のヘッダに X-Accel-Buffering: no・Cache-Control: no-store・Content-Length があり、★Content-Encoding が付いていない★こと(read_network_requests)
  10. ★終わったら★ 画面で ★トークンを失効★、:3100 と偽制御盤を止める

★結果の報告には出所を書く★(「当方の fixtures 由来」「当方の偽制御盤スクリプト由来」「当方のシード由来」。CLAUDE.md §6-6)。


8. テスト一覧(本書で足す・直すもの)

ファイル 内容 段階
lib/ds-logs/__tests__/query.test.ts(新規) 表1 1
lib/ds-logs/__tests__/presets.test.ts(新規) 表2 1
lib/ds-logs/__tests__/file-name.test.ts(新規) 表6 1
lib/ds-logs/__tests__/validate.test.ts(新規) V1〜V7・W1〜W9・prepared の本体 1
lib/ds-agent/__tests__/tokens.test.ts(新規) 表3 K1〜K12 1
lib/proxy/__tests__/authenticate.test.ts(足す) 表4。★既存は無修正で通る★ 1
lib/ds-agent/__tests__/relay.test.ts(新規) 表5(★準備 → 送信の2段★) 1
app/api/ds-devices/[serial_number]/logs/__tests__/route.fixtures.test.ts(新規) fixtures の経路 7
app/api/ds-devices/[serial_number]/logs/__tests__/route.test.ts(新規) 非 fixtures の経路 7
★app/api/ds-devices/[serial_number]/agent-token/__tests__/route.test.ts(新規)★ ★表3 KA1〜KA8★ 7
app/api/ds-agent/__tests__/routes.test.ts(新規) 制御盤向けの6本 7
app/api/ds-agent/__tests__/end-to-end.test.ts(新規) 一連・★ディスクに書かない★ 7
lib/logger/__tests__/…(既存の流儀に合わせる) 足したイベントに禁止のキーが無い 10
★無修正で通ること★ app/api/ds-devices/**(既存の2本)・lib/settings/__tests__/*・lib/proxy/__tests__/request-id.test.ts・lib/auth/__tests__/* —

9. 触ってよいもの・触ってはいけないもの

触ってよい

d-watch-api.yaml・lib/types/index.ts(足すだけ)・lib/config/ds-agent.ts(新規)・lib/ds-logs/**(新規)・lib/ds-agent/**(新規)・ lib/proxy/authenticate.ts・proxy.ts(段階5 の範囲だけ)・scripts/ds-agent-fake.ts(新規。開発用)・ app/api/ds-devices/[serial_number]/logs/**(新規)・★app/api/ds-devices/[serial_number]/agent-token/**(新規)★・app/api/ds-agent/**(新規)・lib/fixtures/ds-device-logs.ts(新規)・lib/fixtures/index.ts(1行)・ components/features/ds-logs/**・lib/i18n.ts(足すだけ)・lib/logger/events.ts(足すだけ)・ docs/decisions.md(★D-179 の末尾に「実装の記録」を足すだけ★)・§8 のテスト

触ってはいけない

  • ★CLAUDE.md★(監督が直す)・★docs/decisions.md の D-179 の本文とそれ以前★・docs/spec.md・docs/handoff.md・docs/code-map.md・★docs/fixtures.md★・docs/deploy.md(★監督。§12-2★)
  • ★設計書 docs/handoff/設計_制御盤ログの取り出し_20261019.md★ と docs/handoff/ の他の文書(他の担当の作業)
  • ★scripts/ds-agent-token.ts を作ること★(D16)・package.json(★script を足さない★)
  • ★prisma/schema.prisma / lib/generated/prisma/**★・★メインDBへの書き込み★(★本機能は DB に書かない★。4カラムの UPDATE も使わない)・生SQL
  • lib/settings/json-store.ts・lib/settings/ds-device-notes.ts・lib/auth/**(使うだけ)・lib/config/allowed-ips.ts(★IP 制限の扱いはリリース時。D6★)・lib/config/index.ts(★ds-agent の値を足さない。D9★)
  • next.config.ts(★proxyClientMaxBodySize を足さない。4-1★)・proxy.ts の matcher
  • d-watch-mock.jsx(モックに無い画面。書き換えない)・components/ui/**(共通部品を作らない)
  • GET /ds-devices・PATCH /ds-devices/{serial_number}/note の挙動・lib/fixtures/ds-devices.ts
  • ★認証情報(AUTH_TOKEN・発行した制御盤トークン・DB の接続情報)を文書・コード・テスト・コミット・作業報告に書くこと★
  • ★git add -A★・eslint-disable の追加

10. 範囲外(本書ではやらない)

  • ★制御盤の Python の常駐プログラム★(設計書。DFA の制御盤ソフトの担当が作る)。★zip の中のパス({root の name}/…。D24)・指示の値と設定ファイルの値の小さいほうを採ること・別スレッドでの処理・配布の方法も制御盤側★。本書の偽制御盤スクリプトは ★D-Watch 側の確認用★ で、その代わりにはならない
  • ★TeamWorks の nginx の設定★(第13回 S-5: 制御盤から 443 で届くこと・/api/ds-agent/ の本体 5MB と一時ファイルに書かないこと・X-Accel-Buffering を効かせること・proxy_read_timeout を60秒より短くしないこと)。監督が照会する
  • ★リリース時の IP 制限の扱い★(D6・D-154)と、★トークンの発行・失効の権限の絞り込み★(P1 は DFA 社内の cookie 認証の全員が使える)
  • ★顧客への公開★(U3)・facilities.contract による出し分け
  • 一覧の結果のキャッシュ(毎回制御盤に問い合わせる)・「前回のエクスポートをもう一度」
  • 認証の失敗の回数制限(リリース時のセキュリティ強化。§12-2)

11. 質問と回答(2026-10-19)

★Q1〜Q13 は監督判断(第2ラウンド。M1〜M11)で確定し、本文に反映済み。Q14〜Q16 は追加の監督判断で確定(Q14・Q15 は本書の案 P1・P2 をそのまま採用)。未確定は無い★。 ★判断が変わったら、「直す場所」を本文と同時に直すこと★(本文と末尾の決定が食い違ったまま渡さない)。

11-1. 監督判断(第2ラウンド・2026-10-19)で確定したもの

# 論点 回答 反映した場所
Q1 ブラウザ向けの検証エラーのステータスとコード(ブリーフは 400 validation_error。既存は 422 validation_failed) ★422 validation_failed★(M2。ブリーフの 400 は誤り)。制御盤向けの形の誤りは 400 result_invalid / chunk_invalid §2-2 D17 / 表3 KA6 / 表5 R8 / 段階2 / 3-3 / 7 / 11-1
Q2 エクスポートの指示を制御盤に渡す時点 ★「準備 → 送信」の2段★(M1)。POST で export を渡し prepared を最大50秒待って 201、GET で send を渡す。★暫定(GET の時点で渡す)は置き換え★ §0 / §2-2 D14・D15 / 表5 R10〜R34 / 4-10・4-11 / 段階2・3-1・6・7・8・9・11
Q3 truncated を画面に伝えるか ★伝える★(M3)。制御盤は新しい順に並べてから 2万件 / 4MB で打ち切る。LIST_RESULT_MAX_BYTES は 4MB §2-2 D18 / 表5 R38 / 段階2・3-1・7-2・9-2・10
Q4 期間ボタンの to ★省略(暫定どおり)★ D23 / 表2 T1〜T3
Q5 types の未知のキー ★0件(暫定どおり)★ D23 / 表1 Q4・Q5
Q6 zip のファイル名 ★[A-Za-z0-9._-] 以外を _・filename* も(暫定どおり)★ D23 / 表6 N4 / 4-5
Q7 ダウンロードの開き方 ★<a href download> の click()(暫定どおり。M1)★。GET の JSON のエラーはまれで、ファイルとして保存されうる(既知の制約) D21 / 段階2・9-2
Q8 proxy の非同期化 ★async にし authenticateAgent() を分ける(暫定どおり)★。★トークンのストアは mtime を見てメモリにキャッシュしてよい★ D22 / 表3 K12 / 表4 / 段階4・5
Q9 順番違いのチャンク ★エクスポートも失敗(暫定どおり)★ D23 / 表5 R21
Q10 ブリーフに名前の無いエラー ★429 agent_busy・410 command_expired・400 result_invalid(暫定どおり。M2)★ D17・D23 / 段階2 / 表5 R3・R37
Q11 偽制御盤スクリプトをコミットするか ★scripts/ds-agent-fake.ts としてコミット(暫定どおり)★。★zip は root の名前つき(M6)★ D23・D24 / 段階11-3 / §9
Q12 同じ制御盤からの2本目のポーリング ★古い方を 204(暫定どおり)★ D23 / 表5 R9
Q13 画面の既定と選択の保持 ★期間の既定は直近24時間・種類/期間を変えたら選択を消す(暫定どおり)★ D23 / 段階9-2
— (M4)未接続の判定 ★認証の通った制御盤のすべてのリクエストで last_seen_at を更新★ D19 / 表5 R7 / 段階5-5・6
— (M5)タイムアウト ★EXPORT_IDLE_TIMEOUT_SEC = 45。流量制御の保留も最大45秒で 410★ D20 / 表5 R31・R32 / 段階3-1
Q16 チャンク・complete を送る command_id ★export の指示の id★(追加の監督判断 2026-10-19)。send は自分の id を持ち params.export_command_id で指す。あわせて: send で zip が無ければ fail export_not_found/list はエクスポートと並行(待ち行列に入れてよい)/制御盤の JSON の形の誤りはすべて 400 result_invalid/prepared から失効までを進行中に数え、60秒で失効 §2-2 D25 / 表5 R17・R19・R28・R33・R40〜R43 / 段階2 / 6-2 / 7-2・7-3
— (M8)トークンの発行 ★D-Watch の API(POST / DELETE …/agent-token)と画面から。スクリプトは作らない★。ログ ds_agent.token_issued / token_revoked D16 / 表3 KA1〜KA8 / 表4 A11 / 段階2・4・7・9・10・11 / §9

11-2. 追加の監督判断(2026-10-19)で本書の案を採用したもの

# 論点 具体例 回答 反映した場所
Q14 ★トークンの発行日時を画面に出すための読み取り★ M8 の API は POST と DELETE だけ。画面は「発行日時」を出す(M8)ので読む手段が要る。一覧の GET(…/logs)に載せると、未接続(409)のときに出せない — ★初めて設定するときは必ず未接続★ ★採用(P1)★: GET …/agent-token → 200 `{ issued_at: string \ null }`(★トークンの値・ハッシュは返さない★)。★ブラウザ向け API は6本★ 段階2 / 表3 KA7 / 段階4・7・9-2
Q15 ★未発行の DELETE★ 二重に押した・別の人が先に失効させた ★採用(P2)★: ★204(冪等)★ 表3 KA4 / 段階2・7

12. 申し送り

12-1. 設計書(制御盤の常駐プログラム)の担当へ(★監督経由。本書の実装では設計書を直さない★)

  1. ★D25(追加の監督判断)★は設計書側も同じ内容で直している(監督から連絡あり)。★実装の前に設計書と表5 R17・R19・R28・R33・R40〜R43 が一致しているかを確かめる★
  2. ★チャンクの POST は、ブラウザ側が読むまで 204 が返らない(最大45秒、超えたら 410)★。制御盤の HTTP のタイムアウトは60秒
  3. ★modified_at は必ずオフセット付き★(naive な isoformat() は結果ごと不正になる。W4)
  4. ★X-Chunk-Sha256 は小文字の16進64桁★。seq は0から連番。409 / 410 が返ったら送信をやめる
  5. ★prepared の本体★: total_bytes(0以上の整数)・sha256(小文字の16進64桁)・file_count(1以上)。★send が来たら同じ zip を送る(作り直さない)★ — complete の sha256 は prepared と同じでなければ 400
  6. 一覧の結果は ★4MB 以下・2万件以下★・★items[].type は必ず types のどれか★・file_id は重複させない(W2〜W8)

12-2. 監督へ(本書の実装では直さないもの)

  1. CLAUDE.md §3 の「リリース直前のセキュリティ強化」の対象に ★制御盤トークンの失敗の回数制限★・★agent パスの IP 制限の扱い(D6)★・★トークンの発行・失効を誰ができるか★ を足す
  2. docs/fixtures.md に fixtures の制御盤のログ(種類3つ・約300件・境界の項目・未接続 DS-001・ダミーの zip(fixture-logs/…))を足す(ブリーフ §7)
  3. docs/deploy.md に ★1プロセス前提★(PM2 / cluster にしない・再起動で進行中のエクスポートは失われる)と ★data/settings/ds-agent-tokens.json が再デプロイで消えないこと(Docker なら volume。M8)★
  4. docs/spec.md / docs/code-map.md に機能とファイルの対応

13. 決定の記録(★D-179 の末尾に足す「実装の記録」の下書き★)

- ★実装の記録(20XX-XX-XX。指示書AY)★:
  1. ★コミット★: (段階ごとのハッシュを列挙)
  2. ★API★: ブラウザ向け6本(`GET /ds-devices/{serial_number}/logs`・`POST …/logs/exports`(準備。最大50秒・201)・`GET …/logs/exports/{export_id}`(送信。`Content-Length` 付き)・`GET / POST / DELETE …/agent-token`)と制御盤向け6本(`/ds-agent/commands`・`…/result`・`…/prepared`・`…/chunks/{seq}`・`…/complete`・`…/fail`。kind は list / export / send)。`d-watch-api.yaml` が正
  3. ★部品★: `lib/ds-agent/relay.ts`(メモリ内の中継。`globalThis`。1プロセス前提)・`lib/ds-agent/tokens.ts`(`data/settings/ds-agent-tokens.json` に SHA-256 だけ。mtime でキャッシュ)・`lib/ds-logs/{query,presets,file-name,validate}.ts`(純粋関数)・`lib/config/ds-agent.ts`(設定値。待ちは 15 / 25 / 30 / 45 / 50秒で nginx の60秒より短い)・`scripts/ds-agent-fake.ts`(開発用の偽制御盤)
  4. ★認証★: proxy を async にし、`/api/ds-agent/` 配下は `authenticateAgent()`(IP → Bearer)。cookie では通らない。ルートでも `requireAgent()` で再検証し、すべての agent リクエストで `last_seen_at` を更新。トークンの発行・失効は cookie のパス(API と画面)
  5. ★監督判断 第2ラウンド(M1〜M11)を反映★。★§11 Q14・Q15 は本書の案(P1 `GET …/agent-token`・P2 未発行の DELETE も 204)を監督判断で採用★
  6. ★確認★: テスト件数(前 → 後)/fixtures の画面確認(当方の fixtures 由来)/非 fixtures の通し確認(当方の偽制御盤スクリプト由来: zip の大きさ・チャンク数・SHA-256 の一致・準備の失敗・切断で 410・未接続)
  7. ★本書と違う判断をしたところ★: (あれば)

14. コミット

段階ごとに分ける(段階1 / 段階2 / 段階3 / 段階4 / 段階5 / 段階6 / 段階7 / 段階8 / 段階9 / 段階10 / D-179 の追記)。★git add はファイルパスを明示する★(git add -A 禁止)。 メッセージには ★何を変えたか★ と ★なぜ(D-179。制御盤のログを中継で取り出す・サーバーに保存しない)★ を書く。★段階1 は「テストだけ(この時点では落ちる)」と明記★。 ★docs/decisions.md は自分の塊だけをコミットする★(他のセッションの未コミットの差分を巻き込まない)。★PR の作成とマージは daichi がレビューしてから★(D-154)。


15. 完了条件

  • 着手前に指示書AX の実装コミットが develop に入っていた。基点のコミットを冒頭に記入し、★本書の行番号を先端で確かめ直した★
  • 検証エラーは 422 validation_failed(D17)。§11 Q14・Q15 の採用(P1 GET …/agent-token・P2 未発行の DELETE も 204)を実装した
  • tsc 0件・lint 0件・npm run build 成功(足したルートが ƒ)
  • npm test 全件通過: 前 件 → 後 件
  • 段階11-1 の grep がすべて0件・scripts/ds-agent-token.ts が無い
  • ★ディスクに書かないことをテストで確かめた★(スパイ0回・ソースの静的な検査)
  • ★proxy の matcher・next.config.ts を変えていない★。/api/ds-agent/ は cookie で通らず、他のパスは Bearer で通らない(表4)
  • ★エクスポートは準備 → 送信の2段★: 準備の失敗は POST の 502、prepared の total_bytes の上限・complete の合計と SHA-256 の照合、GET に Content-Length(表5)
  • ★待ちは 15 / 25 / 30 / 45 / 50秒(すべて nginx の60秒より短い)★
  • ★トークンの生の値がストア・ログ・文書・コミットに無い★。発行・失効は API と画面から(ds_agent.token_issued / token_revoked に serial_number だけ)
  • fixtures(:3100)で: 一覧 → 絞り込み → 選択 → ★「制御盤で準備中…」→ zip が落ちる★/★DS-001 で 409 の表示(トークンの部分は使える)★/トークンの発行・作り直し・失効/JP/EN・ダーク/ライト
  • ★非 fixtures で偽制御盤スクリプトによる通し確認★: ★4MB を超えるファイルが複数チャンクで届き、SHA-256 が一致★/準備の失敗が画面に出る/途中の切断で 410/止めて60秒で未接続
  • docs/decisions.md の D-179 の末尾に「実装の記録」を足した(D-179 の本文・CLAUDE.md には触っていない)
  • 作業報告に: コミット一覧/テスト件数(前後)/確認の結果(★出所を書き分ける。CLAUDE.md §6-6★)/本書と違う判断をしたところ/行番号のずれ/§12 の申し送り