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

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

日付: 2026-10-19(★見直し 2026-11-05★)/ 担当: Cursor(または監督が指名する実装担当)/ 基点: ★7492cc5(2026-11-05)★(★着手時に git log --oneline -1 で develop の先端を記入: _______。ずれていたら段階0 で行番号を確かめ直す★)。 ★先端が 7492cc5 と違っても、その間のコミットが資料(docs/・.claude/)だけなら、コードは同じで本書の行番号はそのまま使える★(本書の見直し自体も資料のコミット 8d80dd1 で入っている)。確かめ方: git diff --stat 7492cc5..HEAD -- . ':!docs' ':!.claude' が★何も出さない★こと。何か出たら、そのファイルについて段階0-4 で行番号を確かめ直す。 旧: 作成時は 739de1f、第1回の見直しは 99e5818(2026-10-19。§11-3)。★99e5818 → 7492cc5 で前提が4件崩れていた★(D-Sec ログ画面に編集モード・AreaModal に Esc・Next 16.4.2・json-store の書き込みの直列化)。★本文は 7492cc5 に合わせて直した(§11-4)★ 前提: ★D-179★(docs/decisions.md:12938 以降。コミット済み)/ ★指示書AX の実装コミット★(d282e07。入った)/ ★監督判断 第2ラウンド(2026-10-19。M1〜M11。§11 に反映済み。ブリーフより優先)★ / ★監督の見直し(2026-10-19。99e5818。§11-3)★ / ★監督の見直し(2026-11-05。7492cc5。ユーザー回答1件と監督の裁定 M1〜M20。§11-4。★§11-3 と食い違うところは §11-4 が正★)★ / 設計書 docs/handoff/設計_制御盤ログの取り出し_20261019.md(★作成済み・本書と突き合わせ済み。制御盤の常駐プログラムの仕様はそちら★。★本書の作成後に §1.6・§1.6.1・§5.2・§5.13・§5.13.1 が更新された(§11-3 B-9)。★2026-10-20 に一覧の name の形が {root の name}/{root からの相対パス} に変わった(設計書 §5.5 の表 :884。§11-4 M6)★)/ 常駐プログラムの実物 C:\Users\daich\dev\dwatch-log-agent(★読むだけ。段階11-3-7b で本物どうしの通し確認に使う★)/ 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〜U6)と監督の設計判断(§2-2 の D1〜D25)は確定仕様。変えないこと★(CLAUDE.md §0-1)。 ★期待値表(§3 と、段階3-3 の V・段階7-2 の W)は正。変えないこと★(CLAUDE.md §0-2)。★2026-11-05 に監督が V・W・表5 R43 を改めた(§11-4 M7)。改めた後の表が正★。★§11 Q1〜Q16 はすべて監督判断(第2ラウンドと追加)で確定し、本文に反映済み★(★Q14・Q15 は本書の案 P1・P2 をそのまま採用。§2-3★)。

★担当ファイルが他の指示書と重なっていた★: components/features/ds-logs/・d-watch-api.yaml・lib/i18n.ts・lib/types/index.ts・lib/fixtures/index.ts・lib/logger/events.ts。 ★AX(d282e07)のほか、★指示書BB・BC・BE〜BI が 99e5818 の後に入った★。★特に BC 段階4(bce6a93)で D-Sec ログ画面の備考が編集モードになった★(§1・4-21・段階9-1)。 ★7492cc5 で監督が確かめ直して本文を直した★(§11-4)。

★行番号は 7492cc5(2026-11-05)で監督が確かめ直したもの★(旧: 99e5818・作成時は 739de1f)。 ★着手時に develop の先端で、本書に書いた行番号をすべて確かめ直すこと★(段階0-4)。ずれていたら関数名・文で探し直す。

★docs/decisions.md に足すのは「D-179 の末尾への『実装の記録』」だけ★(§13 の下書き)。★新しい D 番号は取らない★(★D-180(指示書AX)・D-181(指示書BA)は他の指示書の決定★)。 ★D-179 の末尾には 2026-10-19 の『4回目の追記』と 2026-11-05 の追記(本書の2回目の見直し)がある★ので、★実装の記録はその後ろ(D-180 の見出しの前)に足す★。 ★追記の前に grep -n "^### D-1[78][0-9]" docs/decisions.md で位置を確かめる★(7492cc5 では D-179 :12938・D-180 :13050・D-181 :13090。★2026-11-05 の追記で D-180 以降は数行下がる★)。


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 ログの表の各行に「ログ」ボタン(★備考の編集モード中は押せない。U6★)→ ★モーダル★(種類・期間・並べ替え・一覧・選択・エクスポート・★制御盤トークン★)。★モーダルの操作の部品(Tab の閉じ込め・背景のクリック)は lib/ui/modal.ts(新規)へ移して共有する(M2)★ 段階9
9 ログのイベント(lib/logger/events.ts) 段階10
10 画面確認(fixtures)と、★偽制御盤スクリプトによる非 fixtures の通し確認★ 段階11

★変えないもの★: D-Sec ログの一覧(GET /ds-devices・dsDevices())と備考(PATCH /ds-devices/{serial_number}/note)の挙動/ ★D-Sec ログ画面の備考の編集モード(編集/保存(N件)/キャンセル・下書き・<fieldset disabled>。BC 段階4・D-183)の動き★/ 既存の cookie 認証・IP 制限の判定(authenticate() の今の分岐)/lib/settings/json-store.ts の中身/prisma//メインDB(★本機能は DB に書かない。読むのは 404 の判定の dsDeviceExists() だけ★)/ ★エラー一覧の ErrorContentPicker.tsx と lib/errors/__tests__/★(★再 export で import がそのまま通る。M2。ErrorContentPicker.tsx には 7492cc5 の時点で他のセッションの未コミットの変更がある★)。


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、★2026-11-05 に 7492cc5 で確かめ直した★。出所: 構造(コード・node_modules)):
    • Next.js ★16.4.2★(package.json:21。旧: 16.3.3)。middleware は proxy に改名(proxy.ts:1-21)。★proxy は Node ランタイム固定★。
    • ★proxy が走るリクエストは、本体を clone してメモリにバッファする。既定の上限は 10MB。超えた分は切り捨てられ、★クライアントにはエラーにならず、サーバーに警告が1行出るだけ★★(★16.4.2 でも同じ★) — 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:1205-1233(cloneBodyStream() :1225 → finalize() :1231)・node_modules/next/dist/server/body-streams.js:52-118(bytesRead > bodySizeLimit :98 で 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:46。一時ファイル → rename、壊れたら .bak。★read() は非同期★ :52。★lib/logger を import しない★ :22-24)。★2026-10-19 以降、同じストアへの書き込みは待ち行列で直列化され、一時ファイルは書き込みごとに別の名前になった(BB A12)★ — ★本書の使い方(発行・失効のときだけ書く・読みは mtime でキャッシュ)は崩れない★。★JsonStore はファイルのパスを返さない★(read / write だけ。段階4 の mtime のキャッシュはパスを別に持つ。M13)。前例 lib/settings/ds-device-notes.ts(data/settings/…。既定パス :53-55・__setDeviceNotesStoreForTesting()。/data は .gitignore:50)。
    • ログは logEvent(level, payload)(lib/logger/index.ts:113)。イベントは lib/logger/events.ts の型付き union(:486-501)に足す。★キー名に token / authorization / facility_name / note 等を使うと本番で消される★(lib/logger/redact.ts:12-46)。
    • ★既存のストリーミング応答の例は無い★(CSV 出力は文字列を丸ごと返している)。
    • 画面: components/features/ds-logs/DsLogScreen.tsx(表の <colgroup> :464-470・見出し :471-479(<th> :473-477)・日時の整形 formatDateTime() :47)。 ★10-19 の BC 段階4(bce6a93。D-183)で、備考が他の3画面と同じ「編集モード」になった★: editing / drafts / rowErrors / saving(:140-143)・下書きがある間の離脱確認(:146-154)・「編集」→「保存(N件)」「キャンセル」・まとめて保存(saveNoteDrafts() :252)・★編集中は絞り込みとページ送りを <fieldset disabled> で無効にする★(filtersDisabled :221・:322-323・:540)。 ★列数の定数 COLUMN_COUNT = 5(:66)は保存に失敗した行の colSpan(:524)に使う★。0件の表示は :536(「該当するデバイスはありません」)。既存の「全て」ボタンは :410。 ★一覧の取得は useEffect の片付けで cancelled = true にして古い応答を捨てる作法★(:178-214。BC 段階1・D-183。★react-hooks/set-state-in-effect の抑止コメントが :182 にある★)。 旧: 「備考は blur で1件ずつ確定・DsLogScreen.tsx は作成時から変わっていない」— ★誤り(99e5818 の後に変わった)★。 表の最小幅は ★800px★(DsLogScreen.module.css:205。★規則のコメントは :199-201: 「最小幅 = 全列の幅の合計(施設名 200px + 固定4列 600px)」・列を広げたら min-width も広げる・★余った幅は全列に幅の比で配られる(D-167 追記)★★)。 列幅は .colSerial 140・.colFacility 200・.colStatus 112・.colLastReported 120・.colNote 228(:222-250。合計 800)。 ★モーダルの共通部品は無い★。前例は2つ: ① components/features/detail-log/AreaModal.tsx(role="dialog" aria-modal="true" :76・自前の overlay・✕ボタンに aria-label。★Esc で閉じる処理は :66-73(BC 5-4 で入った)★。フォーカスの移動・背景のスクロールの停止は無い)/ ② ★エラー一覧の components/features/errors/ErrorContentPicker.tsx(5b2fdd0。指示書BE)★: 開いたら入力欄へフォーカス・閉じたら呼び出したボタンへ戻す(restoreFocusRef)・Tab の閉じ込め(trapFocusIndex())・背景を押して離したときだけ閉じる(shouldCloseOnBackdrop())・aria-labelledby + useId()。★判定の純粋関数は lib/errors/error-content-picker.ts(shouldCloseOnBackdrop :112・trapFocusIndex :122)★。★背景のスクロールを止める前例はどちらにも無い★(段階9-2)。 ページ送り components/ui/Pagination.tsx(total / page / perPage / onPageChange。★per_page の選択は持たない★)。日付は <input type="date"> + openDatePicker()(components/ui/date-picker.ts:11)。
    • ★画面の API 呼び出し★: apiFetch()(lib/api/client.ts:102)は ★本文を必ず JSON で読む★(:129)ので、★204(本文なし)は parsed = false で ApiFetchError になる★(:144-150)。★ネットワークの失敗・取り消しは status: 0 の ApiFetchError★(:118)。本文の無い応答の前例は素の fetch(app/login/page.tsx:34)。
    • API: /ds-devices(d-watch-api.yaml:1726)・/ds-devices/{serial_number}/note(:1765。path の maxLength: 100(:1776)・文字種は絞らない。★404 の description は「一覧に出ない制御盤(シリアルが無い・論理削除・controls.status が 0/1 以外。D-182)」★)・その次が区切りコメント(:1812)と /ds-alerts(:1813)。 パスの検証は parsePathSerialNumber()(lib/api/path-params.ts:71-80。★422 validation_failed★)。★文字列の id の前例は parsePathReportId()(:107-114。空と上限超だけ 422・文字種は絞らない・Number() を通さない。上限の定数は lib/config/index.ts:791)★。 実在は db.dsDeviceExists()(lib/db/index.ts:421。fixtures :774・Prisma :2137。★Prisma 版は論理削除と controls.status が 0/1 以外も false(BB A1・D-182)★)。 ★本文の検証★: JSON でもオブジェクトでない本文(null・配列・数値)は isJsonObject()(lib/api/json-body.ts:7)で弾き 422「JSONの形式が不正です」(前例 app/api/ds-devices/[serial_number]/note/route.ts:65-69)。 ★クエリの検証★: page / per_page は ★不正値でも 422 にせず、既定値・上限へ丸める★(lib/ds-devices/query.ts:41・:56-60 ほか、page / per_page を持つ7つの query.ts が同じ。toInt() + Math.max(1, …) + Math.min(PAGINATION.maxPerPage, …))。★空文字のパラメータは省略と同じ★(BH S2-2)。★存在しない日付は 422★(lib/clean-logs/query.ts:63-71 の parseDate()。toJstIso() で戻して入力と比べる。BB A7・D-182)。
    • テスト: 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)」を制御盤から受け取るだけ★で、種類の値を持たない・直書きしない
U6 ★「ログ」ボタンは備考の編集モード中は押せない★(ユーザー回答 2026-11-05「編集中は押せない」。§11-4 の裁定一覧では「U1」) 絞り込み・ページ送りと同じ扱い(filtersDisabled)。★下書きを守る★(4-21・段階9-1・段階11-2)

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 ログの表の各行の「ログ」ボタン → モーダル★。★見た目と Esc は AreaModal.tsx(Esc :66-73)、フォーカスの移動・閉じたら呼び出したボタンへ戻す・Tab の閉じ込め・背景を押して離したときだけ閉じるは ErrorContentPicker.tsx に倣う★。★判定の純粋関数(trapFocusIndex・shouldCloseOnBackdrop)は lib/ui/modal.ts(新規)へ移して共有する★(M2。lib/errors/error-content-picker.ts は再 export)。★背景のスクロールの止め方は前例が無いので DeviceLogsModal.tsx の中に書く★(段階9-2)。★モックに無い画面なので、文言・配置は本書 段階9 を正とする★ ユーザー要望(PUDU の画面)。M2(§11-4)
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★(第2ラウンド M2。ブリーフの 400 validation_error は誤り)。★ただし page / per_page は 422 にせず丸める・空文字のパラメータは省略と同じ(既存の query.ts と同じ。§11-4 M7)★。★制御盤向けの形の誤りは 400 result_invalid / chunk_invalid★ §11 Q1・Q10・§11-4 M7
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)★。★一覧の name も同じ形(2026-10-20 に設計書が変更。§11-4 M6)★。D-Watch は zip の中身も name の中身も解析しない(表示と name 昇順の並べ替えにだけ使う)。★偽制御盤スクリプトと fixtures の zip・一覧の name は 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 は純粋関数の入力なので架空の名前のまま★(★2026-10-20 の name の形の変更({root の name}/{相対パス})にも合わせない。§11-4 M6★)。★実機の種類(api_client 等)と name の形(<種類>/<種類>.log / <種類>/<種類>.log.YYYY-MM-DD)は、fixtures(段階8-1)と偽制御盤スクリプト(段階11-3)でだけ使う★(監督の見直し 2026-10-19・2026-11-05。§11-3 B-9・§11-4 M6)。

表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:42)で 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 検証した後に ★別のストアのインスタンスから★(★同じパスに作った2つ目のストア★)失効させる(ファイルの mtime が変わる) 次の検証は null(★キャッシュは mtime で無効になる★) D22・★§11-4 M13(ストアとパスを一緒に差し替える)★
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 ④★
R44 result / prepared / complete / fail の本体が ★JSON だがオブジェクトでない★(null・[]・0・"x") ★400 result_invalid(4つとも同じ。isJsonObject() で判定)★。★500 にしない★。状態は変えない ★D25 ④・§11-4 M7 (d)★

表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() に倣うが、★ストアとパスを一緒に渡す★。段階4・§11-4 M13)で mkdtemp のストアに差し替える。★data/settings/ の実物に触らない★。KA1〜KA8 はルートのテスト(段階7-4)
  • ★表5 R44 は null・[]・0・"x" の4つを it.each で、result / prepared / complete / fail の4ルートすべてに当てる★(ルートのテスト。段階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 が切れたらエクスポートを取り消す)。
★request.signal は Request.signal(Web 標準の AbortSignal。★Next.js の docs には記述が無い★。03-file-conventions/route.md にも signal・abort の記述は無い)★。★ブラウザの切断で abort が来ることは推測★なので、★段階11-3 の非 fixtures の通し確認(ダウンロードを途中で取り消す)で必ず確かめる★。★来ないなら、書き込みの失敗(controller.enqueue 後の書き込みエラー)や cancel() コールバックで検知する★
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 で落ちる。閉じる・キャンセル・読み込み中…・種類・日付 は既にある)。★全て は 7492cc5 の辞書に無い(EN でも「全て」のまま)ので 全て: "All" を足す★ — ★既存の D-Sec ログの「全て」ボタン(DsLogScreen.tsx:410)も EN で "All" になる。これは段階9-1 の「今の画面を変えない」の例外として認める(M3)★。★色は 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)。★他のセッションの未コミットの変更を含めない★(★7492cc5 の時点で components/features/errors/ErrorContentPicker.tsx・lib/errors/error-content-label.ts・lib/errors/__tests__/error-content-label.test.ts に他のセッションの未コミットの変更がある★)。
★例外は1つだけ★: 段階9-2 の一覧の取得を M4 の作法(useEffect の中で setLoading(true))で書くと react-hooks/set-state-in-effect が出るので、★DsLogScreen.tsx:182 と同じ形の1行(// eslint-disable-next-line react-hooks/set-state-in-effect -- 理由)は足してよい★(BC 段階1 で全画面がそろえた形。他の規則の抑止は足さない)
4-20 ★「ログ」列を足して D-Sec ログの表に横スクロールが出る・ボタンの位置が JP/EN で動く★ ★D-167(全列に幅があり、余りは幅の比で全列に配られる。ユーザーが決めた作り)を崩さない★ — 「ログ」列にも <col> で幅(例 72px)を付け、★余りの配分で列が少し広がるのは認める★。★ボタンを固定幅にして(列の中で寄せる位置も固定)、JP/EN で幅と位置が変わらないようにする★。
★表の最小幅は列幅の合計に合わせる★: DsLogScreen.module.css:205 の min-width を ★800px → 872px★(72px の列のとき。★実際に付けた列幅で計算する★)、★:199 のコメントの式も「施設名 200px + 固定5列 672px」のように直す★。段階9-1・11-2(M1)
4-21 ★備考の編集モード中に「ログ」ボタンを押す★ ★押せない(U6。ユーザー回答 2026-11-05「編集中は押せない」)★。★絞り込み・ページ送りと同じ filtersDisabled(DsLogScreen.tsx:221)で無効にする★(disabled={filtersDisabled}。表の行は <fieldset> の外なので ★ボタンに直接付ける★)。★下書きを守るため★。保存かキャンセルで編集モードを抜けた後に押せる。段階9-1・11-2 で確かめる
4-22 ★COLUMN_COUNT を直し忘れる★ ★DsLogScreen.tsx:66 の COLUMN_COUNT = 5 を ★6★ にする★(保存に失敗した行の colSpan(:524)に使う)。★直し忘れると、保存に失敗した行の文言が「ログ」列の手前で切れる★(M1)
4-23 ★apiFetch() で本文の無い応答を読む・取り消しをエラーと出す★ ★トークンの失効(DELETE …/agent-token → 204)は apiFetch() を使わず素の fetch()★(apiFetch は 204 を ApiFetchError にする。lib/api/client.ts:129-150。前例 app/login/page.tsx:34)。★モーダルを閉じて準備の POST を AbortController で取り消したときの ApiFetchError(status: 0)はエラー表示しない★(M5)
4-24 ★存在しない日付を通す★ Date.parse("2026-02-30T00:00:00+09:00") は ★NaN にならず、3/2 00:00 JST(UTC で 2026-03-01T15:00:00Z)の瞬間を返す★(2026-11-05 監督が node で実測。出所: 構造)。★toJstIso() で戻して入力の文字列全体と比べる方法は、Z など +09:00 以外のオフセットの正しい入力(表1 Q10)まで弾くので使わない★。★日付の部分(先頭10文字)を parseDate()(lib/clean-logs/query.ts:63。export 済み)に通して確かめる★(BB の規則。段階3-3 V2・段階7-2 W4。M7 (c))

段階0 — 着手前

  1. ★develop で作業する★(.claude/worktrees/ の中ではない。ワークツリーは main 基点で古い)。git pull で最新にし、基点のコミットを冒頭の欄に書く。★7492cc5 からずれていたら、4 の確かめ直しを省かない★。
  2. git status を控える。★自分が作っていない変更・未追跡ファイルは他のセッションの作業★。触らない・コミットに含めない(★7492cc5 の時点では ErrorContentPicker.tsx ほか3件。4-19★)。
  3. ★grep -n "^### D-1[78][0-9]" docs/decisions.md で D-179 の末尾(2026-11-05 の追記の後ろ・D-180 の前)の位置を控える★。★docs/decisions.md に他のセッションの未コミットの差分があれば、§13 の追記は git add -p で自分の塊だけを選ぶ★。
  4. ★本書に書いた行番号を develop の先端ですべて確かめ直す★(特に d-watch-api.yaml・lib/types/index.ts・lib/i18n.ts・lib/fixtures/index.ts・lib/logger/events.ts・DsLogScreen.tsx / .module.css・lib/errors/error-content-picker.ts — 他の指示書が触る)。ずれは作業報告に書く。
  5. npx prisma generate → npx tsc --noEmit -p . → npm test が通ることを確かめ、★テストの件数を記録する★(7492cc5 の作業ツリーで ★157ファイル・3,855件★。§15)。通らなければ着手前に報告。
  6. ★Next.js は版が新しい(16.4.2)★。次を読む(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-36)・…/03-file-conventions/route.md(ストリーミング。Streaming の節 :367)。★request.signal は route.md に記述が無い★(Web 標準の Request.signal。4-9)。

段階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(★R44 はルートで判定するので段階7-4 のルートのテスト★)
  7. lib/ds-logs/__tests__/validate.test.ts(新規): 段階3-3 の V1〜V10 と X1〜X6(パスパラメータ)、段階7-2 の W1〜W11

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


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

2-1. 足すもの

  1. components.securitySchemes(:2104。cookieAuth :2105)に ★agentBearer★(type: http・scheme: bearer。description: 制御盤ごとのトークン。/ds-agent/ 配下だけ。cookie では通らない。D-179)
  2. タグ ds-agent(制御盤向け)
  3. パス(/ds-devices/{serial_number}/note(:1765)の後ろ・/ds-alerts の区切りコメント(:1812。/ds-alerts は :1813)の前に置く)。★ブラウザ向けの検証エラーは 422 validation_failed(D17)★。components.responses の既存の応答(Unauthorized ほか :2234 から)を使えるものは使う:
パス メソッド security 要点
/ds-devices/{serial_number}/logs GET cookieAuth(既定) クエリ types(カンマ区切り。省略・空で全種類)・from / to(ISO 8601 ★オフセット必須・存在する日付★。from ≤ modified_at < to。空は省略と同じ)・sort(date_desc 既定 / type_date_desc。★description に「この API の sort は向きを持たない1引数。清掃詳細ログ等の sort / order とは別」と書く(M9)★)・page(1〜)・per_page(★1〜200。不正値は既定値・上限へ丸める(既存の一覧と同じ)。既定 200。画面の選択肢は 50 / 100 / 200(M7)★)。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(★description は note と同じ文「一覧に出ない制御盤(シリアルが無い・論理削除・controls.status が 0/1 以外。D-182)」。M11★)・422
/ds-devices/{serial_number}/logs/exports POST cookieAuth 本体 { file_ids: string[] }(1〜EXPORT_MAX_FILES 件・重複なし・各 1〜1024 文字。★本体が JSON でもオブジェクトでなければ 422「JSONの形式が不正です」(note と同じ。M7 (d))★)。★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(★note と同じ文。M11★)・422
/ds-devices/{serial_number}/logs/exports/{export_id} GET cookieAuth ★1回だけ使える★。export_id は ★1〜PATH_EXPORT_ID_MAX_LENGTH 文字(文字種は絞らない。範囲外は 422。M8)★。200 は application/zip(format: binary)と ★5つのヘッダ(4-5)★。504 agent_timeout(30秒)・502 agent_error・404 export_not_found(無い・失効・使用済み・★パスのシリアルと違う★)・422。description に ★流し始めた後の失敗はダウンロードの失敗になる(ステータスは200のまま)★・★JSON のエラーはまれ(制御盤の切断など)で、ブラウザによってはファイルとして保存される(D21)★
/ds-devices/{serial_number}/agent-token GET cookieAuth ★P1(Q14 採用)★。200 `{ issued_at: string \ null }。★トークン・ハッシュは返さない★。404(★description は note と同じ文。dsDeviceExists()` の中身が D-182 で変わった。M11★)・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(★オブジェクトでない本文も。R44★)/ 404 / 410 command_expired。★command_id は1〜PATH_COMMAND_ID_MAX_LENGTH 文字(範囲外は 422。M8。以下の4本も同じ)★
/ds-agent/commands/{command_id}/prepared POST agentBearer ★本体 { total_bytes, sha256, file_count }★(export の指示だけ)。204 / 400 result_invalid / 404 / 410 command_expired / 422
/ds-agent/commands/{command_id}/chunks/{seq} POST agentBearer ★export の指示の command_id(D25)★。★seq は0以上の整数(範囲外は 422。M8)★。application/octet-stream・ヘッダ X-Chunk-Sha256(小文字の16進64桁)。204(★ブラウザ側に空きができてから。最大45秒★)/ 400 chunk_invalid / 409 chunk_out_of_order / 410 export_cancelled / 404 / 422
/ds-agent/commands/{command_id}/complete POST agentBearer ★export の指示の command_id(D25)★。{ total_bytes, sha256 } → 204 / 400 result_invalid(形の誤り・値の不一致)/ 410 export_cancelled / 404 / 422
/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 / 422
  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(:1132)の近く)
  • ★ErrorCode(:32-46)に足す★: 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:470)と同じ 200 が上限★)。★API の検証には使わない(API は 1〜200 に丸める。M7)★
DS_LOG_DEFAULT_PER_PAGE 200 既定(★per_page が省略・空・数でないときの値★)
PATH_EXPORT_ID_MAX_LENGTH 64 ★パスの export_id の長さの上限(M8)★。crypto.randomUUID() の36文字を受けられる長さ。★文字種は絞らない★
PATH_COMMAND_ID_MAX_LENGTH 64 ★パスの command_id の長さの上限(M8)★。同上

各定数の 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★)

★2026-11-05 に監督が改めた(§11-4 M7・M8)★。旧: 空の types=・per_page=30・page=0・page=1.5 を 422 にしていた — ★既存の一覧 API の作法(空は省略と同じ・page / per_page は丸める)と食い違うので改めた★。

★作法の手本★: ★page / per_page★ は lib/ds-devices/query.ts:56-60(toInt() + Math.max(1, …) + Math.min(PAGINATION.maxPerPage, …)。★page / per_page を持つ既存の7つの query.ts(clean-logs・ds-alerts・ds-devices・error-reports・facilities・prechecks・schedules)が同じ書き方★。reports の limit も同じ考え方)。既定は DS_LOG_DEFAULT_PER_PAGE(200)。 ★空文字★ は BH の作法(sort= / sort=%20 は省略と同じ。指示書BH S2-2)。★from / to の存在しない日付★ は BB の規則(parseDate()。4-24)。

# 入力 期待
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
V2b ★from=2026-02-30T00:00:00+09:00/to=2026-02-29T00:00:00Z(★2026年は平年★)/from=2026-13-01T00:00:00+09:00(★存在しない日付★)★ ★422★(Date.parse は NaN にならないので、日付の部分を parseDate() で確かめる。4-24)
V2c from=2026-10-19T02:30:00Z/from=2026-10-19T02:30:00+00:00 ★通る★(+09:00 以外のオフセットも正しい入力。表1 Q10。★V2b の確かめ方がこれを弾かないこと★)
V3 from > to 422。★from = to は通る(0件)★
V4 ★types=・types=%20・from=・to=・sort=(空・空白だけ)★ ★省略と同じ★(V1 と同じ値。★422 にしない★)
V4b types=app,,sys/types=app,(★空でない並びの中の空の要素★) 422(★V4 の「全体が空」とは別。打ち間違いを黙って通さない★)
V5 sort=name 422
V6 ★per_page=30 → 30・per_page=500 → 200・per_page=0 → 1・per_page=abc → 200・page=0 → 1・page=1.5 → 1・page=abc → 1★ ★422 にせず丸める★(手本の書き方のとおり。toInt() は Number.parseInt なので 1.5 は 1)
V7 POST の本体 { file_ids: [] } / 501件 / 重複あり / 要素が文字列でない / 1025文字 422
V8 ★POST の本体が JSON だがオブジェクトでない★(null・[]・1・"x") ★422「JSONの形式が不正です」★(isJsonObject()。note と同じ文。M7 (d))
V9 POST の本体が JSON でない({) 422「JSONの形式が不正です」(note と同じ)
V10 wait=0・wait=26・wait=abc(制御盤向け GET /ds-agent/commands) 422(表5 R8。★これは丸めない★ — 制御盤のプログラムの誤りを早く見つけるため)

★パスパラメータ(M8)★: export_id・command_id は ★parsePathReportId()(lib/api/path-params.ts:107-114)と同じ作法★ — ★空と上限超だけ 422・文字種は絞らない・Number() を通さない・入力値をエコーしない★。上限は lib/config/ds-agent.ts の PATH_EXPORT_ID_MAX_LENGTH / PATH_COMMAND_ID_MAX_LENGTH(D9。lib/config/index.ts に足さない)。 ★seq は 0 を許す整数★なので ★parsePathId()(1以上)は使わない★。専用の検証(/^(0|[1-9][0-9]{0,8})$/。先頭の 0・符号・小数・空白を通さない)を validate.ts に書く。

# 入力 期待
X1 export_id / command_id が crypto.randomUUID() の形(36文字) 通る
X2 空文字・PATH_*_MAX_LENGTH + 1 文字 422(★入力値を message / details に入れない★)
X3 export_id=abc(形は違うが長さは範囲内) ★通る★(文字種は絞らない。relay で見つからず 404 export_not_found)
X4 seq=0・seq=1・seq=10 通る(0・1・10)
X5 seq=-1・seq=01・seq=1.0・seq=abc・seq=1e3・空 422
X6 seq=1234567890(10桁) 422(★上限は9桁。4MB × 10億 は 100MB の上限より十分大きい★)

コミット。


段階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>
/** ★ストアとパスを組で持つ★(`JsonStore` はパスを返さないので、mtime を見るためにパスを別に持つ。M13) */
export interface AgentTokenStoreTarget { store: JsonStore<AgentTokens>; filePath: string }
export function createAgentTokenStore(filePath?: string, onWarn?: (info: JsonStoreWarning) => void): AgentTokenStoreTarget
/** ★テスト専用★。★ストアとパスを一緒に差し替える★(`null` で既定へ戻す。キャッシュも捨てる) */
export function __setAgentTokenStoreForTesting(target: AgentTokenStoreTarget | null): void
  • 保存先は ★呼び出し時に★ path.join(process.cwd(), "data", "settings", "ds-agent-tokens.json")(ds-device-notes.ts:53-55 と同じ理由)。★再デプロイで消えないこと(Docker なら volume)は監督が扱う(§12-2・§12-3)★
  • ★createAgentTokenStore(filePath) はパスを受け取ってストアを作り、{ store, filePath } を返す★(M13)。★mtime のキャッシュ(下)はこの filePath を fs.stat する★。★テストはストアとパスを一緒に差し替える★ — ★ストアだけ差し替えてパスが既定のままだと、mtime を data/settings/ の実物で見てしまい、K12 が偶然通ったり実物を読んだりする★
  • ★lib/logger を import しない★(proxy から呼ぶため。json-store.ts:22-24 と同じ考え方)。壊れたストアの警告は onWarn で settings.store_corrupt を出す(呼び出し側。前例 ds-device-notes.ts:65-73)
  • ★書き込みは json-store の待ち行列で直列化される★(2026-10-19 以降。発行と失効が重なってもファイルは壊れない)。★ただし読み → 書きの後勝ちは防がない★(json-store.ts の冒頭コメント)— 同じ制御盤の発行と失効を同時に押すと、結果はどちらか一方になる(P1 は社内の少人数なので許容)
  • ヘッダの形は ★/^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・★method(AuthLogEvent(lib/logger/events.ts:457-471)に合わせる。M10)★。★ヘッダの値は出さない★)。 ★冒頭コメントの「実行順」と 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) 同じ順で検証(★本体は request.json() が失敗したら 422、isJsonObject() が false でも 422「JSONの形式が不正です」(V8・V9。note の route.ts:65-69 と同じ)★)・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) parsePathSerialNumber() → ★export_id の検証(X1〜X3。M8)★ → 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)
  • ★404 の判定は db.dsDeviceExists()。7492cc5 では論理削除と controls.status が 0/1 以外の制御盤も false(D-182)★ — ★D-Sec ログの一覧に出ない制御盤のログは取れない・トークンも作れない★(一覧に出ないので画面から押せない。整合している)

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()) 不正
W4b ★modified_at が存在しない日付★(2026-02-30T00:00:00+09:00) ★不正★(V2b と同じ確かめ方。4-24。M7 (c))
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)
W10 ★本体が JSON だがオブジェクトでない★(null・[]・1・"x") ★不正(制御盤に 400 result_invalid。isJsonObject()。表5 R44・M7 (d))★
W11 items[].name が api_client/api_client.log.2026-10-18 の形(/ を含む) ★受理★(name は {root の name}/{相対パス}。★/ を理由に弾かない・中身を解析しない★。M6)

prepared の本体(total_bytes は0以上の整数・sha256 は小文字の16進64桁・file_count は1以上の整数)・complete の本体・fail の本体も同じファイルで検証し、★形が違えば(オブジェクトでない本文を含め)すべて 400 result_invalid(D25 ④・R44)★。 ★total_bytes の下限の差★: 常駐プログラムの試験用の偽 D-Watch(dwatch-log-agent/fake_dwatch.py:631)は total_bytes を ★1以上★ で検証しているが、本書は ★0以上★。★実害は無い★(空の zip でも22バイトあるので、本物の制御盤が 0 を送ることはない)。★本書の 0以上 のままにする★(M16)。

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 に関係なく同じ動き)。 ★[command_id] を持つ5本は、requireAgent() の後に ★command_id の検証(X1〜X3。M8)★、chunks は ★seq の検証(X4〜X6)★ をしてから本体を読む。★本体は request.text() → JSON.parse の失敗も isJsonObject() の false も 400 result_invalid(R43・R44)★。 ★任意(M12)★: 6本に共通の前処理(requireAgent() → command_id の検証 → 本体の読み取りと isJsonObject())は、BI の lib/clean-logs/advice-response.ts のように ★lib/ds-agent/ の関数に切り出してよい★(例 lib/ds-agent/agent-request.ts。★ルートは薄く保つ★)。切り出すなら ★node:fs / node:os を import しない★(7-4 の静的検査の対象に足す)。

7-4. テスト

ファイル 内容
app/api/ds-devices/[serial_number]/logs/__tests__/route.fixtures.test.ts fixtures で: DS-002 の一覧が200(agent.connected true・types ★4つ(段階8。キーで比べ、仮の表示名を期待値に埋め込まない)★・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 の絞り込みがルートを通って効くこと。★V4(空は省略と同じ)・V6(丸め。meta.per_page に丸めた値)・V2b(存在しない日付は 422)・V8(POST exports の本体が null / [] で 422)・X2(export_id が長すぎて 422)★
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(★ストアとパスを一緒に差し替える。M13★)。★Bearer なし・違う → 401(requireAgent() の再検証)★・R7(どのルートでも last_seen_at が進む)・R35・R36・チャンクの 400 / 409 / 410・W1〜W11・prepared の検証・★R44(オブジェクトでない本文は4ルートとも 400 result_invalid。500 にならない)★・★command_id / seq の 422(X2・X5・X6)★
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(★M12 で前処理を lib/ds-agent/ に切り出したらそのファイルも★)を読み、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: ★種類4つ。実機に合わせる(設計書 §1.6・§5.2。§11-3 B-9)★: api_client / postcondition_security / postcondition_kick_action / send_start_sig(この順)。 ラベルは設計書 §5.2 の仮の名前(「API 通信(報告間隔の取得・通信の有効/無効)(仮)」「セキュリティ状態の報告(仮)」「セコム状態の監視・清掃の起動(仮)」「清掃開始信号(仮)」)。 ★表示名は仮★なので、★テストの期待値に表示名を埋め込みすぎない★(キーで比べる。表示名は fixtures の定数から取る)
    • fixtureDeviceLogItems(now: Date): ★数百件(例 300)★を now から決まった間隔で作る。★name は実機の形★: ★{root の name}/{相対パス}★ — <種類>/<種類>.log(各種類の最新)と <種類>/<種類>.log.YYYY-MM-DD(設計書 §1.6・§5.5 の表 :884。★2026-10-20 に変わった形。zip の中のパスと同じ★。M6。旧: <種類>.log だけ)。 ★now ちょうど・1時間前ちょうど・3時間前ちょうど・24時間前ちょうど・その1秒前★ の項目を必ず含める(画面の目視用。境界の正しさはテストが持つ)。大きさは 1KB〜50MB(★選択の合計が 100MB を超える組み合わせが作れること★。★実機は全部で 1.2MB 程度だが、画面の上限の確認のため大きくしてよい★)
    • FIXTURE_EXPORT_ZIP_BASE64: ★中身がダミーの文字だけの小さな正しい zip★。★中のパスは <種類>/<ファイル名>★(★root はフォルダごと。設計書 §5.2・D24★。例 postcondition_security/postcondition_security.log.2026-10-18)(種類のフォルダを入れた作業用のフォルダの中身を PowerShell の Compress-Archive で包み base64 にする。★実在の店名・ログを入れない★)
    • FIXTURE_AGENT_OFFLINE_SERIALS = ["DS-001"](D10)
    • lib/fixtures/index.ts に export * from "./ds-device-logs";(./ds-devices の export は :18。★AX で ds-alerts.ts を消したので ./ds-alerts の export は無い。1行足す作業には影響しない★)
  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 を作らない)。★判定の純粋関数だけを lib/ui/modal.ts に移して共有する(9-2。M2)★。

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

  • 表の右端に ★「ログ」列★: <colgroup>(:464-470)に <col className={styles.colLogs} /> を、見出し(:471-479)に <th> を、各行(<tr> の最後の <td> の後)に <td> を足す(★<colgroup> の上のコメント(:455-463)のとおり、<col> と <th> と <td> を同じ順に★)。
    • ★列幅(M1)★: .colLogs に幅(例 72px)を付ける。★D-167 の作り(全列に幅があり、余りは幅の比で全列に配られる)を崩さない★ — 余りの配分で「ログ」列も少し広がるのは認める。★ボタンは固定幅(例 width: 56px)で列の中の決まった位置(例 中央)に置き、JP/EN で幅と位置が変わらないようにする★
    • ★DsLogScreen.module.css:205 の min-width: 800px を列幅の合計に合わせる★(72px なら ★872px★)。★:199 のコメントの式も直す★(例「最小幅 872px = 全列の幅の合計(施設名 200px + 固定5列 672px)」。:222 の .colFacility のコメントの「min-width 800 - 固定4列 600」は旧来の由来なのでそのままでよい)。★「余った幅(約350px)」の数字も合計に合わせて直す★(1150 − 872 = 約278px)
    • ★COLUMN_COUNT(:66)を 5 → ★6★ にする(4-22)★
  • 各行に ログ ボタン → モーダルを開く(serial_number と facility_name を渡す。★押したボタンの要素も渡し、閉じたらそこへフォーカスを戻す★(9-2))
  • ★「ログ」ボタンは編集モード中は押せない(U6。4-21)★: disabled={filtersDisabled}(:221)。★表は <fieldset disabled> の外なのでボタンに直接付ける★
  • ★並べ替えに BH の SortableTh(清掃詳細ログ・エラー一覧の列見出しで並べ替える部品)は使わない★(★D-Sec ログの表は並べ替えを持たず、本書のモーダルの並べ替えは向きを持たない2択(9-2)で sort / order の切り替えと API が合わない★。M20)
  • ★モーダルの開閉と「ログ」列以外は今の画面を変えない★(★例外: 辞書に 全て を足すので、既存の「全て」ボタン(:410)が EN で "All" になる(4-13・M3)★)

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

部分 中身(★文言はこのとおり。JP/EN の辞書に足す★)
見出し 「制御盤のログ」+シリアル・施設名。右に ★接続状態★: 「接続中」(ok の色)/「未接続」(ng の色)+「最終接続 {日時}」(無ければ「最終接続 -」)。閉じる
種類 制御盤から来た types のチェックボックス(★ラベルは生値。t() に通さない★)。★既定は全部★。★1つも選ばれていなければ取得せず「種類を1つ以上選んでください」★
期間 ボタン「直近1時間」「直近3時間」「直近24時間」「全て」(presets.ts。★「全て」は辞書に 全て: "All" を足す。M3★)+ <input type="date"> 2つ(openDatePicker。表2 T5〜T9)。★既定は「直近24時間」★(D23)
並べ替え ★ボタン2つ★「日付」(date_desc)「種類+日付」(type_date_desc)(★向きの切り替えは持たない。M9・M20★)
一覧 チェックボックス・ファイル名・種類(ラベル)・大きさ(KB / MB)・更新日時。見出しに「このページをすべて選択」。下に Pagination + ページあたり件数のセレクト(DS_LOG_PER_PAGE_OPTIONS)。★truncated のときは一覧の上に「件数が多いため、新しいものから一部だけを表示しています」(D18)★。
★ファイル名の列(M6)★: name は {root の name}/{相対パス}(例 postcondition_kick_action/postcondition_kick_action.log.2026-10-18)で長いので、★1行で省略表示(text-overflow: ellipsis)し、title に全文★(★t() に通さない。4-14★)。
★0件★: 取得に成功して0件なら「該当するファイルはありません」(★取得の失敗(エラーの文言)と分けて出す。BC 段階2 の作法★。M3)
選択中 「選択: {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(apiFetch)→ ★トークンを読み取り専用の欄に1回だけ表示・「コピー」ボタン・注意「この値は二度と表示されません。制御盤の設定ファイルに入れてください」★。★モーダルを閉じたら state から消す★。
失効: ★確認「トークンを失効させます。この制御盤はログを送れなくなります。よろしいですか?」★ → DELETE(★素の fetch。204 は本文が無いので apiFetch を使わない。4-23・M5★。response.ok でなければ本文の error.message を出す)。
★この部分は制御盤が未接続(409)でも使える★(初めて設定するときは未接続のため)
  • ★選択の保持(D23)★: file_id → size_bytes の Map で持つ。★ページ送り・並べ替え・ページあたり件数では保持★、★種類・期間を変えたら消す★
  • 取得中は「読み込み中…」(t() に通す)。★一覧の取得は毎回制御盤に問い合わせる★(最大15秒)ので、★前の応答が後から返って上書きしないよう、古い応答を捨てる★ — ★BC 段階1(D-183)で全画面がそろえた作法: useEffect の中で取得し、片付けで cancelled = true にする★(手本 DsLogScreen.tsx:178-214。★番号で数える方式は使わない(M4)★。setLoading(true) の抑止コメントは 4-19 の例外のとおり)
  • ★モーダルの作り(M2・D11)★:
    • ★role="dialog" aria-modal="true"・自前の overlay・✕ボタン(aria-label={t("閉じる")})の見た目は AreaModal.tsx に揃える★。★見出しは aria-labelledby + useId()(ErrorContentPicker.tsx と同じ)★
    • ★Esc で閉じる★: ★AreaModal.tsx:66-73 と同じ書き方★(window の keydown で e.key === "Escape" → onClose()。片付けで外す)
    • ★開いたら最初の操作要素へフォーカス・閉じたら呼び出した「ログ」ボタンへ戻す・Tab の閉じ込め・背景を押して離したときだけ閉じる★: ★ErrorContentPicker.tsx と同じ作り★(restoreFocusRef 相当・trapFocusIndex()・shouldCloseOnBackdrop() を onMouseDown と onClick で)
    • ★判定の純粋関数を共有にする★: trapFocusIndex・shouldCloseOnBackdrop を lib/errors/error-content-picker.ts(:112・:122)から ★lib/ui/modal.ts(新規。React も DOM も import しない純粋関数)★へ移し、★lib/errors/error-content-picker.ts からは export { trapFocusIndex, shouldCloseOnBackdrop } from "@/lib/ui/modal"; で再 export する★。★ErrorContentPicker.tsx と lib/errors/__tests__/error-content-picker.test.ts は変えずに通ること★(ErrorContentPicker.tsx には他のセッションの未コミットの変更がある。4-19)。DeviceLogsModal.tsx は @/lib/ui/modal から import する
    • ★背景のスクロールを止める★: ★前例が無い★ので DeviceLogsModal.tsx の中に書く(開いたら document.body.style.overflow = "hidden"、片付けで ★開く前の値に戻す★)
    • ★AreaModal・ErrorContentPicker は触らない・components/ui/ に共通部品は作らない★
  • ★準備中に閉じたら POST を AbortController で取り消す★(サーバー側はエクスポートを取り消す。4-9)。★取り消したときの ApiFetchError(status: 0)はエラー表示しない(閉じた後に state を触らない)★(4-23・M5)
  • ★色は CSS 変数・固定幅のボタン(JP/EN で位置が動かない)★
  • 日時の表示は既存の formatDateTime()(DsLogScreen.tsx:47)と同じ形

9-3. lib/i18n.ts

★足す前に grep する★(閉じる・キャンセル・読み込み中…・種類・日付 は既にある。★全て は無い(7492cc5)★)。EN は ★暫定★。 例: 「ログ」"Logs"/「制御盤のログ」"Controller logs"/「接続中」"Connected"/「未接続」"Not connected"/「直近1時間」"Last 1 hour"/「直近24時間」"Last 24 hours"/★「全て」"All"(★既存の D-Sec ログの「全て」ボタンも EN で "All" になる。M3★)★/「種類+日付」"Type + date"/「このページをすべて選択」"Select all on this page"/★「該当するファイルはありません」"No matching files"(0件。M3)★/「エクスポート」"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・★method★・reason(missing / invalid) proxy(段階5)。★AuthLogEvent(:457-471)に合わせて method を持つ(M10)★。★ヘッダの値・トークンを出さない★
★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)・★agent_code(result が agent_error のときだけ。M10)★ 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・★agent_code(制御盤の code があるときだけ。M10)★ 失敗・取り消し・失効・タイムアウト
  • LogEvent の union(:486-501)に足す。★reason は固定の文字列の union★(制御盤から来た message を載せない)。★制御盤の code は agent_code として固定の長さ(64文字)で切って載せてよい★ — ★載せるのは表で agent_code を書いた2つ(ds_logs.list と ds_logs.export_failed)だけ★(旧: 箇条書きにだけ書いてあり表と食い違っていた。M10)
  • ★file_name / file_ids / token / authorization を載せない★。テストで「イベントの値に file_id・ファイル名・トークンが入っていない」ことを確かめる
  • ★docs/logging-design.md に足す(Cursor の担当。M10。BI と同じ)★: §3「ログイベントと共通フィールド」(:103)のイベントの表(:108-117)に上の10個(ds_agent.* 5つ・ds_logs.* 5つ。名前空間でまとめて2行にしてよい)を、§10 の「既存の全APIルート」(:477)の一覧に足したルートのファイル(ブラウザ向け4ファイル・制御盤向け6ファイル)を。★それ以外の節は変えない★

コミット。


段階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)

★.claude/launch.json の d-watch-fixtures は next dev なので使わない★(M14)。

  1. ★:3100 が空いていることを確かめる★(Get-NetTCPConnection -LocalPort 3100 -State Listen。何か居たら自分で起動したものか分からないので止めずに報告する)
  2. 本体で $env:USE_FIXTURES='true'; npm run build
  3. ★起動時にも USE_FIXTURES=true を渡す★(M14): ★.env.local の2行目は USE_FIXTURES=false、lib/db/use-fixtures.ts は ★実行時に★ process.env.USE_FIXTURES !== "false" を読む★ — ★起動時に渡さないと .env.local の値でステージングDB(実データ)につながる★。 ★テスト用の AUTH_TOKEN をその場で生成★し、★環境変数で渡して★ 起動する(例 $env:USE_FIXTURES='true'; $env:AUTH_TOKEN=<生成した値>; npx next start -p 3100)。★トークンを文書・コミット・報告に書かない★。ALLOW_DIRECT_ACCESS=true は .env.local(36行目)にあるので渡さなくてよい
  4. preview_start の url で http://localhost:3100 を開き、ログインする
  5. 確かめること:
    • D-Sec ログの各行に「ログ」がある。DS-002 で開く → 接続中・★種類4つ(api_client ほか。段階8)★・既定の期間(直近24時間)で一覧が出る。★ファイル名が api_client/api_client.log… の形で、長いものは省略され、title で全文が出る(M6)★
    • ★「編集」を押して編集モードにすると「ログ」が押せない/「保存」か「キャンセル」で抜けた後に押せる(U6・4-21)★。★編集モードの動き(下書き・保存(N件)・離脱確認)が今までどおり★
    • ★モーダルの操作(M2)★: 開くと最初の操作要素にフォーカス/Tab・Shift+Tab がモーダルの中で回る/Esc で閉じる/★モーダルの中で押して背景で離しても閉じない・背景で押して離すと閉じる★/開いている間は背景がスクロールしない・★閉じたら元に戻る★/★閉じたら押した「ログ」ボタンにフォーカスが戻る★
    • 種類を外す・期間のボタン・日付の範囲・並べ替え(ボタン2つ)・ページ送り・ページあたり件数で一覧が変わる。種類・期間を変えると選択が消える。★0件のとき「該当するファイルはありません」★(取得の失敗とは別の表示。M3)
    • ★取得中に種類を続けて変えても、最後の条件の一覧が残る(古い応答で上書きされない。M4)★
    • 「このページをすべて選択」→ 合計の表示。★100MB を超えると警告とボタンの無効★
    • ★エクスポート(M17)★: ★ファイルの保存はユーザーの許可が要るので、通常は javascript_tool で確かめる★ — ページの中で fetch で POST …/logs/exports(201)→ fetch(download_url) → ★ヘッダ5つ(4-5)があり Content-Encoding が無いこと★・★crypto.subtle.digest("SHA-256", await r.arrayBuffer()) が fixtures の zip(FIXTURE_EXPORT_ZIP_BASE64 を node で SHA-256 にした値)と一致★・★同じ download_url の2回目が 404★。★zip の中のパスが <種類>/<ファイル名> であることは段階7-4 のテストで見る★。 ★画面の「エクスポート」を押して「制御盤で準備中…」→ ファイルが保存されるところは、ユーザーに許可を取ってから1回だけ★(取れなければ「制御盤で準備中…」の表示までを見て、保存は未確認と報告する)
    • ★DS-001 で開くと「制御盤が接続されていません(最終接続: -)」★。★それでも制御盤トークンの部分は使える★
    • ★制御盤トークン★: 発行 → 確認 → 1回だけ表示・コピー → 閉じて開き直すと値は出ず、発行日時だけ → 作り直し → 失効 → 「未発行」(★ここで発行した値は確認後に失効させ、どこにも書かない★)。★確認の window.confirm は javascript_tool で window.confirm = () => true に差し替えてよい(M17)★
    • ★JP/EN 切替★で「ログ」ボタンの幅と位置・モーダルのボタンの位置が動かない(★既存の「全て」ボタンが EN で "All" になるのは意図どおり。M3★)/★ダーク/ライト★で崩れない
    • D-Sec ログの表で横スクロールが増えていない(resize_window 1440×900・JP/EN)。★「ログ」ボタンの幅と位置が JP/EN で変わらない★(★列が余りの配分で少し広がるのは認める。D-167・4-20・M1★)。★保存に失敗した行の文言が「ログ」列まで届いている(COLUMN_COUNT 6。4-22)★ — 失敗を起こすのが難しければ、コードで COLUMN_COUNT が <th> の数と一致することを見る。終わったら desktop に戻す
  6. ★終わったら :3100 の listen プロセスを止める★(M14): ★Get-NetTCPConnection -LocalPort 3100 -State Listen の OwningProcess のコマンド行(Get-CimInstance Win32_Process -Filter "ProcessId=<pid>" の CommandLine)が ★自分が起動した next start -p 3100 であることを確かめてから★ 止める。★:3000(ユーザーの next dev)は止めない★

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

★ローカルDB へ向ける(M15)★: 接続先は2系統ある — ★シード(scripts/seed-local-db.ts)は DATABASE_URL だけ★を読み(:92。dotenv は既にある環境変数を上書きしない)、★アプリは DATABASE_HOST / DATABASE_PORT / DATABASE_USER / DATABASE_PASSWORD / DATABASE_NAME があればそちらを優先する★(lib/db/prisma.ts:42-65)。★.env.local の8〜12行目と14行目はステージングの値★なので、★両方の系統を環境変数で上書きしないとステージングにつながる★。 ローカル用の値は ★.env.local の5行目のコメント(ローカル用の DATABASE_URL)★ にある。★その行から PowerShell で URL を読み、DATABASE_URL と DATABASE_HOST〜DATABASE_NAME の環境変数に入れる★([System.Uri] で分解。★パスワードを画面・報告に出さない★)。★起動前に host が localhost(または 127.0.0.1)・DB 名が dwatch_local であることだけを表示して確かめる★。ALLOW_DIRECT_ACCESS=true は .env.local にある(XFF が無いため要る)。 ★MySQL のサービス MySQL84 は手動起動★。Get-Service MySQL84 が止まっていたら、★管理者権限が要るのでユーザーに起動してもらう★(Claude からは起動できない)。

  1. npm run seed:local(★lib/db/local-guard.ts がローカル以外を拒否する★)。DS-002・DS-003 が controls にあること(★controls.status が 0/1 で、D-Sec ログの一覧に出ること。D-182★)
  2. 上の環境変数と $env:USE_FIXTURES='false' で npm run build → 同じ環境変数で npx next start -p 3100(11-2 の 1・6 と同じく、空きを確かめてから起動し、コマンド行を確かめてから止める)
  3. ★画面のモーダル(DS-002)の「制御盤トークン」で発行★し、★その値を環境変数 DS_AGENT_TOKEN で偽制御盤に渡す★(★画面・ファイル・報告に残さない★)
  4. ★偽制御盤 scripts/ds-agent-fake.ts(D23。コミットする)★を tsx で動かす:
    • --server http://localhost:3100・トークンは環境変数
    • ポーリング → list には ★固定の一覧(10件程度)★ → export には ★--zip <パス> のファイルの長さ・SHA-256・件数で prepared★ → send には ★そのファイルを 4MB ごとに送り complete★(★file_ids は見ない。中身の正しさは制御盤(Python)の仕様★)
    • ★固定の一覧の種類と name は実機に合わせる(§11-3 B-9・§11-4 M6)★: 種類は api_client / postcondition_security / postcondition_kick_action / send_start_sig(ラベルは設計書 §5.2 の仮の名前)、name は ★<種類>/<種類>.log と <種類>/<種類>.log.YYYY-MM-DD★({root の name}/{相対パス}。zip の中のパスと同じ)
    • zip は ★9〜10MB 程度★(★4MB を超えて3チャンクになる大きさ★)。★中のパスは <種類>/<ファイル名>(root はフォルダごと。設計書 §5.2・D24)★: 種類のフォルダ(例 postcondition_security/)に乱数の中身のファイル(例 postcondition_security.log.2026-10-18)を入れ、それらのフォルダを Compress-Archive で包む
    • ★チャンクの POST のタイムアウトは60秒★(D20。設計書と同じ)
  5. 画面から一覧 → 選択(★選んだ file_id を控える★)。★エクスポートは 11-2 と同じく javascript_tool で確かめる(M17)★: ページの中で fetch で POST …/logs/exports(本体は控えた file_ids)→ 201 → fetch(download_url) → ★crypto.subtle.digest("SHA-256", …) が --zip のファイルの SHA-256(Get-FileHash)と一致★・★Content-Length が --zip のファイルの長さ★。サーバーのログに ds_logs.export_prepared と ds_logs.export_completed(total_bytes が一致)。 ★画面の「エクスポート」を押してファイルを保存し、ブラウザに進捗(大きさ)が出ることを見る確認は、ユーザーに許可を取ってから1回だけ★(11-2 と合わせて1回でよい)
  6. ★準備の失敗★: 偽制御盤の --fail-prepare too_large で、画面に「選んだファイルが大きすぎます」(POST の 502。★これは画面の「エクスポート」を押してよい — ファイルは保存されない★)
  7. ★切断★: javascript_tool で fetch(download_url) の body.getReader() から1回 read() した後に ★reader.cancel()★(M17。ブラウザのダウンロードの取り消しの代わり)→ 偽制御盤に 410 が返り止まる・ds_logs.export_failed(stage: send)。 ★request.signal の abort が実際に来たか(または cancel()・書き込みの失敗のどれで検知したか)を記録する★(★abort が来ることは推測。4-9★)。★どれでも検知できなければ報告して止まる★ 7b. ★本物の常駐プログラム(C:\Users\daich\dev\dwatch-log-agent\agent.py)での通し確認(M16)★ — 偽制御盤スクリプトは D-Watch 側の確認用なので、★最後に本物どうしをつなぐ★:
    • ★制御盤は DS-003 を使う★(DS-002 は偽制御盤スクリプトのトークンと取り合わない)。画面のモーダル(DS-003)でトークンを発行する
    • ★試験用のログフォルダと設定ファイルはスクラッチパッドに作る★(dwatch-log-agent のフォルダには何も書かない。★agent.py は読むだけ★): 4つの種類のフォルダ(api_client/ ほか。config.example.json の roots と同じ名前)に、<種類>.log と <種類>.log.YYYY-MM-DD の試験用ファイル(乱数の中身。★1つは 4MB を超える大きさ★)
    • 設定ファイル(例 config.local.json)は config.example.json を写し、server_url は http://localhost:3100・serial_number は DS-003・roots[].path は試験用フォルダ・temp_dir はスクラッチパッドの中。★トークンは環境変数から Python で設定ファイルに書き込み、画面・報告に表示しない★
    • 動かす: python agent.py --config <設定ファイル> --server http://localhost:3100(★http:// は localhost / 127.0.0.1 のときだけ通る★。Ctrl+C か TaskStop で止める)
    • 確かめること: ★画面で DS-003 が「接続中」・種類4つ・name が <種類>/<ファイル名> の形★/★選んだファイルのエクスポート(javascript_tool)で zip が届き、complete が通る(サーバーのログに ds_logs.export_completed)★/★zip の中のパスが <種類>/<ファイル名>★(受け取ったバイト列をスクラッチパッドに書くのはユーザーの許可を取ってから。取れなければサーバーのログの total_bytes と file_count で代える)/★止めて60秒で「未接続」★
    • ★片付け★: 常駐プログラムを止める → 画面で DS-003 のトークンを失効 → スクラッチパッドの試験用フォルダと設定ファイル(トークンが入っている)を消す
    • ★常駐プログラムの API の形(パス・ヘッダ・send の export_not_found・一覧の失敗の本文・各タイムアウトが D-Watch の待ちより長いこと・modified_at がオフセット付き・一覧の打ち切り)は、7492cc5 の時点で監督の点検役が本書と一致することを読んで確かめた★(推測ではなくコードを読んだ結果。ただし動かしてはいない)。★total_bytes の下限の差(fake_dwatch.py:631 は1以上・本書は0以上)は実害なし★(段階7-2)
  8. ★偽制御盤を止める★ → 60秒後に画面で「未接続」・ds_agent.disconnected が1回
  9. 応答のヘッダに X-Accel-Buffering: no・Cache-Control: no-store・Content-Length があり、★Content-Encoding が付いていない★こと(5 の fetch の response.headers で見る。read_network_requests でもよい)
  10. ★終わったら★ 画面で ★トークンを失効★(DS-002・DS-003)、:3100(11-2 の 6 の手順で)と偽制御盤を止める

★結果の報告には出所を書く★(「当方の 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〜V10・X1〜X6・W1〜W11・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__/*・★lib/errors/__tests__/error-content-picker.test.ts(trapFocusIndex / shouldCloseOnBackdrop を lib/ui/modal.ts へ移しても再 export で通る。M2)★ —

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(足すだけ)・ ★lib/ui/modal.ts(新規。trapFocusIndex・shouldCloseOnBackdrop の移し先。M2)★・★lib/errors/error-content-picker.ts(★2つの関数を移して再 export に置き換えるだけ。他の関数は変えない★)★・ ★docs/logging-design.md(★§3 のイベントの表と §10 の「既存の全APIルート」の一覧に足すだけ。M10★)★・ docs/decisions.md(★D-179 の末尾(2026-11-05 の追記の後ろ)に「実装の記録」を足すだけ★)・§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・§12-3★)・★docs/logging-design.md の §3 の表と §10 の一覧以外★
  • ★設計書 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/**(共通部品を作らない)
  • ★components/features/errors/ErrorContentPicker.tsx・components/features/detail-log/AreaModal.tsx・lib/errors/__tests__/**・lib/errors/error-content-label.ts★(★ErrorContentPicker.tsx ほかには他のセッションの未コミットの変更がある。4-19★)
  • .claude/launch.json(★d-watch-fixtures は next dev なので段階11 では使わない。書き換えもしない★)
  • ★C:\Users\daich\dev\dwatch-log-agent の中のファイル★(★読むだけ。段階11-3-7b の試験用フォルダ・設定はスクラッチパッドに作る★)
  • 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)
  • ★制御盤の再起動への対応★: 本番機は ★更新のある日の 12:00 にラズパイごと再起動する★(設計書 §5.13.1 の改訂案)。★D-Watch 側では一時的に「未接続」になるだけで、特別な処理は要らない★(その瞬間に進行中のエクスポートは失敗する。既存の失敗の扱いのまま)

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

★Q1〜Q13 は監督判断(第2ラウンド。M1〜M11)で確定し、本文に反映済み。Q14〜Q16 は追加の監督判断で確定(Q14・Q15 は本書の案 P1・P2 をそのまま採用)。未確定は無い★。 ★11-3 は 99e5818 での監督の見直し(B-1〜B-9)。11-4 は 7492cc5 での監督の見直し(ユーザー回答 U1 と裁定 M1〜M20)。どちらも本文に反映済み。★11-3 と 11-4 が食い違うところは 11-4 が正★(11-3 の該当する行には「旧:」と 11-4 への参照を書いた)★。 ★判断が変わったら、「直す場所」を本文と同時に直すこと★(本文と末尾の決定が食い違ったまま渡さない)。

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

11-3 見直し(2026-10-19)

★develop が 739de1f(本書の作成時)から 99e5818(指示書AX・AZ・BA の実装の後)に進んだので、監督が先端で参照を突き合わせ、次のとおり判断した★。 ★期待値表の値(入力・期待される並び・状態コード)は変えていない★(表1 の name も元のまま。B-9)。

# 論点 判断 反映した場所
B-1 ★基点と行番号★ 旧: ★基点は 99e5818★(★2026-11-05 に 7492cc5 へ改めた。行番号も 7492cc5 で確かめ直した(§11-4 の「行番号」)★)。AX の実装(d282e07。記録 af26086)が入り、前提は満たされた。当時直した行番号: D-179 12934 → 12938・logEvent 112 → 113・colgroup・/ds-devices・note・securitySchemes・dsDeviceExists・/ds-alerts(★値はどれも 7492cc5 でさらに動いた。本文の値が正★)。★着手時に先端がずれていたら段階0-4 で確かめ直す★ 冒頭 / §1 / 段階0-1 / 段階2-1 / 段階9-1
B-2 ★決定番号★ ★D-180(指示書AX)・D-181(指示書BA)は他の指示書の決定★。★AY は新しい D 番号を取らず、D-179 の末尾に「実装の記録」を足すだけ★。★D-179 の末尾には 2026-10-19 の「4回目の追記」がある★ので、その後ろ(D-180 の前)に足す。追記の前に grep -n "^### D-1[78][0-9]" docs/decisions.md で位置を確かめる 冒頭 / 段階0-3 / §9 / §13
B-3 ★request.signal の参照先★ ★route.md には signal・abort の記述が無い★。★Web 標準の Request.signal(Next.js の docs に記述なし)★。★ブラウザの切断で abort が来ることは推測★ → ★段階11-3 で必ず確かめる★。来ないなら書き込みの失敗や cancel() で検知する。ストリーミングの参照は route.md の Streaming の節(:367)のまま 4-9 / 段階0-6 / 段階11-3 の 7
B-4 ★モーダルの Esc・フォーカス・背景のスクロール★ 旧(10-19): 「AreaModal に Esc が無いので DeviceLogsModal.tsx の中に全部書く・共通部品は作らない」。★2026-11-05 に改めた(§11-4 M2)★: ★AreaModal には BC 5-4 で Esc が入った(:66-73)★ので Esc はその書き方に倣う。フォーカスの移動・Tab の閉じ込め・背景のクリックは ★エラー一覧の ErrorContentPicker.tsx(指示書BE)に倣い、判定の純粋関数を lib/ui/modal.ts に移して共有する★。背景のスクロールの停止だけは前例が無いので DeviceLogsModal.tsx の中に書く §1 / D11 / 段階9-2 / 段階11-2
B-5 ★D-Sec ログ画面は変わったか★ 旧(10-19): 「DsLogScreen.tsx は作成時から変わっていない」— ★誤りになった(§11-4)★。★10-19 の BC 段階4(bce6a93)で備考が編集モードになった★(ほかに BB・BC の 370ac96 / 52e23e5 / 6c2848c / 50a3082 も触った) §1
B-6 ★「ログ」列の幅★ 旧(10-19): 「固定幅にし、余り幅の配分で広がらないようにする」。★2026-11-05 に改めた(§11-4 M1)★: ★D-167(全列に幅があり余りは比で配られる)を崩さない。列が少し広がるのは認め、ボタンを固定幅にする★。最小幅は 800 → 872px(72px の列のとき) 4-20 / 段階9-1 / 段階11-2
B-7 ★備考の編集中に「ログ」を押したとき★ 旧(10-19): 備考は1件ずつ確定する作りなので特別な処理はしない、としていた。★2026-11-05 に改めた(§11-4 U1)★: ★編集モード中は「ログ」を押せない★(ユーザー回答「編集中は押せない」) §1 / 4-21 / 段階9-1 / 段階11-2
B-8 ★lib/fixtures/index.ts の変化★ ★AX で ds-alerts.ts を消したので ./ds-alerts の export は無くなった★。★1行足す作業には影響しない★ 段階8-1
B-9 ★設計書の更新(§1.6・§1.6.1・§5.2・§5.13・§5.13.1)への追従★ ① ★fixtures の偽の一覧と偽制御盤スクリプトの種類と root 名を実機に合わせる★: 種類は api_client / postcondition_security / postcondition_kick_action / send_start_sig(表示名は設計書の仮の名前。★テストの期待値に表示名を埋め込みすぎない★)。ファイル名は <種類>.log と <種類>.log.YYYY-MM-DD(★一覧の name は 2026-10-20 から <種類>/<ファイル名>。§11-4 M6★)。★zip の中のパスは <種類>/<ファイル名>(root はフォルダごと)★。② ★表1 は純粋関数の入力なので架空の名前のまま★(★実機の種類とファイル名の形は fixtures(段階8-1)と偽制御盤スクリプト(段階11-3)でだけ使う★。一度実機の形に置き換えたが、名前の日付と modified_at が食い違って実装者を迷わせるため監督判断で取り消した)。③ ★制御盤は更新のある日の 12:00 に再起動する★。★D-Watch 側は一時的に「未接続」になるだけで、特別な処理は要らない★ §3 冒頭 / 段階7-4 / 段階8-1 / 段階11-2・11-3 / §10 / §12-2

11-4 監督の見直し(2026-11-05。基点 7492cc5)

★develop が 99e5818 から 7492cc5 に進んだ(指示書BB・BC・BE〜BI ほか)。監督が点検役2人(前半=冒頭〜§4・段階0〜7、後半=段階8〜11・§10〜§12)に先端で突き合わせさせ、次のとおり裁定した★。★本文と §11-3 を同時に直した★(§11-3 の B-1・B-4〜B-7 は「旧:」と書いてここを指す)。

★崩れていた前提(4件)★:

  1. ★D-Sec ログ画面に編集モードが入った★(10-19 の BC 段階4 bce6a93。editing / drafts / saveNoteDrafts・編集/保存(N件)/キャンセル・<fieldset disabled>。旧の「備考は1件ずつ確定」の前提は消えた。表の列数の定数 COLUMN_COUNT = 5(:66)がある)
  2. ★AreaModal に Esc が入った★(:66-73。✕に aria-label。フォーカス・スクロールの停止は無い。role="dialog" :76)
  3. ★Next.js が 16.3.3 → 16.4.2★(package.json:21。next-server.js の参照は :1205-1233。10MB で切り捨てる挙動は同じ)
  4. ★json-store の書き込みが直列化された★(createJsonStore :46・read :52・logger を import しない :22-24。★本書の使い方は崩れない★)

★裁定★:

# 何を なぜ どこを直したか
U1 ★「ログ」ボタンは編集モード中は押せない★(ユーザー回答 2026-11-05「編集中は押せない」。本文では §2-1 の U6) 絞り込み・ページ送りと同じ扱い。★下書きを守る★ §0 / §2-1 U6 / 4-21 / 段階9-1 / 段階11-2 / §11-3 B-7
M1 ★「ログ」列は D-167 を崩さない★。列が少し広がるのは認め、★ボタンを固定幅に★。最小幅は列幅の合計(800 → 872px。72px の列のとき)・コメントの式も直す。★COLUMN_COUNT を 6 に★ D-167 はユーザーが決めた作り。COLUMN_COUNT を直し忘れると保存に失敗した行の文言が切れる 4-20 / 4-22(新)/ 段階9-1 / 段階11-2 / §11-3 B-6
M2 ★trapFocusIndex・shouldCloseOnBackdrop を lib/ui/modal.ts(新規)へ移し、lib/errors/error-content-picker.ts から再 export★。Esc は AreaModal と同じ書き方、フォーカス・Tab・背景のクリックは ErrorContentPicker と同じ。背景のスクロールの停止は自前 前例(BE)があるのに二重に書かない。既存のテストと ErrorContentPicker.tsx(他のセッションの未コミットあり)を変えずに済む §0 / §1 / D11 / 段階9-2 / §8 / §9 / §11-3 B-4
M3 ★全て: "All" を辞書に足す★(既存の D-Sec ログの「全て」ボタンも EN で "All" になる。「今の画面を変えない」の例外)。★0件の文言「該当するファイルはありません」を足す★ 全て は辞書に無く EN でも日本語のまま。失敗と該当なしを分ける(BC 段階2) 4-13 / 段階9-1・9-2・9-3 / 段階11-2
M4 ★古い応答は useEffect の片付けで cancelled = true にして捨てる★(番号方式はやめる) BC 段階1(D-183)で全画面がそろえた作法 段階9-2 / 4-19(抑止コメントの例外)
M5 ★失効(DELETE。204)は素の fetch★。★準備の POST を取り消したときの ApiFetchError(status 0)はエラー表示しない★ apiFetch は 204 を例外にする(lib/api/client.ts:129-150。前例 app/login/page.tsx:34) 4-23(新)/ 段階9-2
M6 ★一覧の name は {root の name}/{相対パス}(zip の中のパスと同じ)★。fixtures・偽制御盤スクリプトをこの形に。表1 は架空の名前のまま。ファイル名の列は省略表示+title 2026-10-20 に設計書(§5.5 の表)と常駐プログラムが変わった 冒頭 / §3 冒頭 / D24 / 段階7-2 W11 / 段階8 / 段階9-2 / 段階11-2・11-3 / §11-3 B-9
M7 ★クエリと本文の検証を改めた★: (a) 空は省略と同じ(V4)(b) page / per_page は丸める(V6。手本 lib/ds-devices/query.ts:56-60。page / per_page を持つ7つの query.ts が同じ)(c) 存在しない日付は 422(V2b・W4b。parseDate() で日付の部分を確かめる)(d) オブジェクトでない JSON は POST exports が 422「JSONの形式が不正です」(V8)・制御盤向けは 400 result_invalid(R44・W10)。旧 D17 の「既存の流儀」という書き方は消した 既存の一覧 API と食い違っていた(旧 V4・V6 は 422)。Date.parse は存在しない日付で NaN にならない。null の本文で 500 にしない(BB) 冒頭 / §1 / D17 / 表5 R44(新)/ 4-24(新)/ 段階2 / 段階3-1・3-3 / 段階7 / §8
M8 ★export_id・command_id は parsePathReportId() と同じ作法★(空と上限超だけ 422・文字種は絞らない・Number を通さない。上限は lib/config/ds-agent.ts)。★seq は 0 を許す整数なので parsePathId() を使わず専用の検証★ パスの検証が書かれていなかった 段階2 / 段階3-1 / 段階3-3 X1〜X6(新)/ 段階7
M9 ★並べ替えはボタン2つのまま★。YAML の description に「この API の sort は向きを持たない1引数」と書く 清掃詳細ログ等の sort / order と取り違えない 段階2 / 段階9-2
M10 ★ds_logs.list と ds_logs.export_failed の表に agent_code★。union は :486-501。ds_agent.auth_failed は method を持つ。★docs/logging-design.md の §3 の表と §10 の一覧への追記は Cursor の担当★ 箇条書きと表が食い違っていた。AuthLogEvent に合わせる。BI と同じ分担 段階5 / 段階10 / §9
M11 ★404 の description は note と同じ文★(一覧に出ない制御盤。D-182) dsDeviceExists() の中身が変わった(論理削除・controls.status) 段階2 / 段階7-1
M12 ★agent の6本の前処理は lib/ds-agent/ に切り出してよい(任意)★ BI の lib/clean-logs/advice-response.ts と同じ考え方 段階7-3・7-4
M13 ★トークンのストアはパスを受け取って作り、テストはストアとパスを一緒に差し替える★ JsonStore はパスを返さないので、mtime のキャッシュ(K12)が見るパスが決まらなかった 段階4 / 表3 K12 / §3 テストの注意 / 段階7-4
M14 ★段階11-2 は起動時にも USE_FIXTURES=true★。止めるときは 3100 のプロセスのコマンド行を確かめる。launch.json の d-watch-fixtures(next dev)は使わない .env.local は false で、use-fixtures.ts は実行時に読む。渡さないとステージングDB 段階11-2
M15 ★段階11-3 のローカルDB への向け方★: シードは DATABASE_URL、アプリは DATABASE_HOST 系を優先(prisma.ts:42-65)。どちらも .env.local 5行目のコメント(ローカル用の値)から環境変数で渡し、host と DB 名を確かめる。MySQL84 は手動起動 .env.local の有効な値はステージング。片方だけ上書きするとステージングにつながる 段階11-3
M16 ★本物の常駐プログラムでの通し確認(11-3-7b)を足した★(DS-003・トークンは表示しない・agent.py は読むだけ・試験用のフォルダと設定はスクラッチパッド・片付けまで)。fake_dwatch.py:631 の total_bytes ≥ 1 と本書の ≥ 0 の差は実害なし 偽制御盤スクリプトだけでは本物との食い違いを見つけられない 段階7-2 / 段階11-3
M17 ★ダウンロードの確認は javascript_tool の fetch で★(ヘッダ5つと Content-Encoding 無し・crypto.subtle.digest の SHA-256・取り消しは reader.cancel())。★画面で保存する確認はユーザーの許可を取って1回だけ★。window.confirm は差し替えてよい アプリ内ブラウザでのファイルの保存にはユーザーの許可が要る 段階11-2・11-3
M18 ★§12 に申し送りを足した★: 設計書 §5.11 と常駐プログラムの README の :3000 を :3100(next start)に/★ステージングのコンテナを作り直すと data/settings が消える恐れ★(docs/deploy.md に記述なし。TeamWorks への確認) 段階11 の手順と食い違う。トークンが反映のたびに消えると制御盤が全部止まる §12-1・§12-3(新)
M19 ★Next.js は 16.4.2★。next-server.js は :1205-1233。10MB で切り捨てる挙動は同じ。json-store の直列化で本書の使い方は崩れない 前提の確かめ直し §1 / 段階0-6 / 段階4
M20 ★段階9 の並べ替えに BH の SortableTh は使わない★ 向きの切り替えと API が合わない(M9) 段階9-1

★行番号(99e5818 → 7492cc5。監督が実物で確かめた)★: D-180 13041 → 13050・D-181 13081 → 13090(D-179 :12938 はそのまま)/events の union 456-471 → 486-501/DsLogScreen.tsx の colgroup 438-444 → 464-470・見出し 445-452 → 471-479・formatDateTime 41 → 47/DsLogScreen.module.css の最小幅 82-88 → 199-205/YAML の /ds-devices 1381 → 1726・note 1418 → 1765(maxLength 1429 → 1776)・/ds-alerts 1457 → 1813(区切り :1812)・securitySchemes 1718 → 2104/parsePathSerialNumber 70-79 → 71-80/dsDeviceExists 369 → 421(fixtures 689 → 774・Prisma 1957 → 2137)/DsDevice 1024 → 1132/ErrorCode 31-45 → 32-46/PAGINATION.maxPerPage 438 → 470/toJstIso 37 → 42/json-store の createJsonStore 39 → 46・read 45 → 52/next-server.js 1197-1217 → 1205-1233/package.json の next :18 → :21。★logEvent :113 は変わらない★。 ★テストの件数(7492cc5 の作業ツリー。監督の点検役の実測): 157ファイル・3,855件★(§15 の「前」)。


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)
  7. ★(2026-11-05 追加。M18)設計書 §5.11「開発時の動かし方」(:991。:993 と :999)と常駐プログラムの README.md の「開発 PC での動かし方」(:106・:108・:115)を、★:3100 の next start★(--server http://localhost:3100)に直す★。★:3000 はユーザーの next dev(ステージング向け)で、試験では触らない(段階11)★。 ★あわせて README の「D-Watch 側(指示書AY)ができるまでは…1・2 はできない」(:137)を AY の実装の後に直す★。★設計書の担当の作業(本書の実装では直さない)★

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

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

12-3. TeamWorks への確認・依頼(★監督が扱う。2026-11-05 追加。M18 (b)★)

  1. ★ステージングのアプリはコンテナで、反映のたびにイメージを作り直している★(fetch + reset --hard の後に作り直す運用)。★data/settings/ がボリュームに載っていなければ、反映のたびに中身が消える★ — ★本書のトークン(ds-agent-tokens.json)だけでなく、既存の advice.json・display-limits.json・ds-device-notes.json も★。 ★帰結の例★: トークンが消えると、★次の反映の直後からすべての制御盤が 401 になりログを送れない★(画面では「未発行」に戻る)。備考・アドバイスの設定・表示の上限も初期値に戻る。
  2. ★docs/deploy.md にはこの記述が無い★(7492cc5)。★ボリュームの有無を TeamWorks に確かめ、無ければ data/settings をボリュームに載せるよう依頼する★。結果を §12-2 の 3 と docs/deploy.md に書く

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

★足す場所は D-179 の末尾の「2026-11-05 の追記」(本書の2回目の見直し)の後ろ、D-180 の見出しの前★(grep -n "^### D-1[78][0-9]" docs/decisions.md で確かめる)。★新しい D 番号は取らない★。

- ★実装の記録(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)を監督判断で採用★。★2026-11-05 の見直し(§11-4。編集モード中は「ログ」を押せない・モーダルの部品を `lib/ui/modal.ts` で共有・空は省略と同じ/page と per_page は丸める/存在しない日付は 422 ほか)を反映★
  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. 完了条件

  • 基点のコミットを冒頭に記入し(7492cc5 以降)、★本書の行番号を先端で確かめ直した★
  • 検証エラーは 422 validation_failed(D17)。§11 Q14・Q15 の採用(P1 GET …/agent-token・P2 未発行の DELETE も 204)を実装した
  • tsc 0件・lint 0件・npm run build 成功(足したルートが ƒ)
  • npm test 全件通過: 前 ★157ファイル・3,855件(7492cc5)★ → 後 ファイル・ 件(★着手時の先端で数え直して前が違えば、その値も書く★)
  • ★lib/ui/modal.ts に2つの関数を移し、lib/errors/error-content-picker.ts は再 export。lib/errors/__tests__/error-content-picker.test.ts と ErrorContentPicker.tsx は無修正★(M2)
  • ★編集モード中は「ログ」が押せない(U6)・COLUMN_COUNT が 6・最小幅が列幅の合計★(M1)
  • 段階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。★起動時にも USE_FIXTURES=true★)で: 一覧 → 絞り込み → 選択 → ★エクスポート(javascript_tool の fetch でヘッダ5つ・SHA-256 の一致・2回目は 404。画面での保存はユーザーの許可を取って1回だけ)★/★DS-001 で 409 の表示(トークンの部分は使える)★/トークンの発行・作り直し・失効/★モーダルの Esc・フォーカス・Tab・背景のクリック・スクロールの停止★/JP/EN・ダーク/ライト
  • ★非 fixtures で偽制御盤スクリプトによる通し確認★: ★4MB を超えるファイルが複数チャンクで届き、SHA-256 が一致★/準備の失敗が画面に出る/途中の切断で 410/止めて60秒で未接続
  • ★本物の常駐プログラム(agent.py。DS-003)での通し確認(11-3-7b)★と片付け(トークンの失効・試験用フォルダと設定の削除)
  • ★docs/logging-design.md の §3 の表と §10 の一覧に足した★(M10)
  • docs/decisions.md の D-179 の末尾に「実装の記録」を足した(D-179 の本文・CLAUDE.md には触っていない)
  • 作業報告に: コミット一覧/テスト件数(前後)/確認の結果(★出所を書き分ける。CLAUDE.md §6-6★)/本書と違う判断をしたところ/行番号のずれ/§12 の申し送り