diff --git a/.claude/commands/d-watch.md b/.claude/commands/d-watch.md index 3a523c6..cd3c71b 100644 --- a/.claude/commands/d-watch.md +++ b/.claude/commands/d-watch.md @@ -41,7 +41,7 @@ | 資料 | 何の正か | |---|---| | `CLAUDE.md` | ★仕様・規約・禁止事項の正★ | -| `docs/decisions.md` | ★決定の正★。★D-174 まで(162件)★。★番号順に並んでいません★ | +| `docs/decisions.md` | ★決定の正★。★D-194 まで(182件。2026-11-05)★。★番号順に並んでいません★ | | `docs/handoff.md` | ★全履歴★。★追記63 まで★ | | `d-watch-api.yaml` | API契約の正 | | `d-watch-mock.jsx` | 画面の正 | @@ -50,9 +50,41 @@ --- -## ★★最新 — 2026-10-30〜11-04 の作業(D-Watch 2セッション目のまとめ)★★ +## ★★最新 — 2026-11-05 の作業(制御盤のログの取り出し AY・BJ・BK と記録の穴埋め)★★ -★この節が一番新しい★。下の「30秒で読む現在地」「最初の報告」の数字は 10-07 時点(古い)。 +★この節が一番新しい★。その下の「2026-10-30〜11-04」も読むこと。「30秒で読む現在地」「最初の報告」の数字は 10-07 時点(古い)。 + +### 現在地(★2026-11-05 夕方 JST 実測★) + +| 項目 | 値 | +|---|---| +| ブランチ | `develop`。★origin/develop は `32dfd11` のまま。ローカルが30コミット以上先★(★ユーザー指示「全ての実装が終わったら push する。まだしない」2026-11-05★)。GitBucket の次のタグは `2026-11-05` | +| テスト | ★170ファイル / 4,315件 全パス★(tsc・lint も緑。`cc2cba2`) | +| 指示書 | ★BK まで(次は `BL`)★。AY・BJ・BK は Cursor が実装し、監督(Claude Code)が点検 | +| 決定 | ★D-194 まで記録した(2026-11-05)★ — それまで見出しが D-181 で止まり、D-182〜D-185(指示書BB〜BE)と BF〜BI・10-27〜10-30 の決定が抜けていた。★D-190〜D-194 は 10-29〜10-30 の判断で、D-188・D-189(11-04)より古い(番号は日付順でない)★。★記録の穴は `grep -c "指示書BX" docs/decisions.md` で指示書ごとに数えると見つかる★ | +| 未コミット | ★別セッションの4ファイル★(`components/features/errors/ErrorContentPicker.tsx`・`lib/errors/error-content-label.ts` とそのテスト(改行コードだけ)・`docs/handoff/指示書AY_*` の1行)。★触らない・コミットに入れない★ | + +### この日にやったこと + +- ★指示書AY(制御盤のログを D-Sec ログ画面から取り出す。D-179)★: 制御盤(Raspberry Pi の常駐プログラム `C:\Users\daich\dev\dwatch-log-agent\agent.py`。★別リポジトリ・読むだけ★)が `/api/ds-agent/commands` を長く待ち(long poll)、一覧・エクスポート(4MB ずつ・SHA-256)を中継する。★中継はメモリだけ(`lib/ds-agent/relay.ts`。globalThis の1つ。プロセス1つ前提)・ディスクに書かない★。制御盤のトークンは SHA-256 で `data/settings/ds-agent-tokens.json`。`/api/ds-agent/` は Bearer だけ、他は cookie +- ★指示書BJ(AY の点検の指摘直し)★: 一覧の結果の形が崩れていたら制御盤に 400 `result_invalid`・ブラウザに 502 `agent_error`(`agent_code: ["result_invalid"]`)/取り消し(AbortError)だけ黙り、通信失敗は文言を出す/ボタンの幅を固定(112・100・140px) +- ★指示書BK(BJ の点検の直し)★: テストが見張りになっていない箇所を直した(壊しても緑だった)/★ファイル ID の上限 1024 → 8192★(制御盤が受け取れる上限にそろえた)/★エクスポートで選ぶ `file_ids` を JSON にした大きさが 48KB を超えたら 422「選んだファイルが多すぎます」★ — ★制御盤は D-Watch の応答を 64KB までしか読まない★(`agent.py` の `RESPONSE_READ_MAX_BYTES`)ので、超えると指示が途中で切れて 50秒後に 504 になっていた +- ★`d-watch-api.yaml` を YAML として読めるようにした★(`precheck_minutes` の説明の `(旧: …` の `: ` を引用符で囲んだ。中身は変えない)。★js-yaml で読める・`$ref` は全部解決する★ことを確かめた +- ★`docs/decisions.md` に D-182〜D-194 を足した★(上の「決定」。D-186 BF・D-187 BG・D-188 BH・D-189 BI・D-190 進み具合は前日・D-191 アラートは事前チェックだけ・D-192 status 6 と不明(n)・D-193 障害サマリの `error_list_id` とエラー内訳・D-194 10-29 の表示の手直し)。`docs/code-map.md` の「D-182〜D-185 は監督待ち」も直した + +### この日に分かった罠 + +1. ★「壊して赤」を指示書に書くときは、その壊し方が本当に赤になるかを監督が先に試す★。BJ で書いた `await import("node:fs")` はどのテストにも捕まらない壊し方だった(BK K2 で静的な検査に `import(`・`require(`・モジュール名の文字列を足して塞いだ) +2. ★点検の報告で「指示書に無い変更」と書く前に、指示書の全部の節(YAML の節・記録の節)を grep する★(BJ §2-3 にあった変更を「無い」と誤って報告した) +3. ★制御盤側の上限は `agent.py` の定数を読む★: `FILE_ID_MAX_LEN = 8192`(受け取る側だけ。作る側に上限なし)・`RESPONSE_READ_MAX_BYTES = 64KB`。D-Watch の上限を変えるときは両方と突き合わせる +4. ★:3100 の後始末★: TaskStop しても子の node が残ることがある。★kill の前に 3100 を listen しているプロセスのコマンド行を見る★(`next\dist\bin\next" start -p 3100`。引用符が挟まるので `'next start'` では当たらない) +5. ★Cursor の「完了」はコミットで確かめる★(`git log 53f46e0..HEAD` 等)。11-05 に1回、何もコミットされていないまま「完了」と言われた(渡し直して解決) + +--- + +## ★★2026-10-30〜11-04 の作業(D-Watch 2セッション目のまとめ)★★ + +★この節の数字は 11-04 時点★。最新は上の「2026-11-05」。 ### 現在地(★2026-11-04 17:4x JST 実測★) @@ -82,6 +114,8 @@ 3. ★`S=$(git commit-tree "origin/develop^{tree}" -p -m "D-Watch develop のスナップショット。GitHub の履歴は含まない")`★(★`-p ` を必ず付ける★ — 付けないとサーバーで pull が効かない)→ `git branch -f gitbucket-snapshot $S` 4. `gitbucket-snapshot^{tree}` と `origin/develop^{tree}` が一致、`git merge-base --is-ancestor $S` を確かめる 5. ★`git push <上の URL> gitbucket-snapshot:develop`(`--force` を付けない)★。拒否されたら誰かが積んだので止めてユーザーに聞く + 6. ★日付のタグを付ける★(ユーザー指示 2026-11-04): `git ls-remote --tags ` で既存を確かめ、`git push $S:refs/tags/YYYY-MM-DD`(同じ日の2回目以降は `-2`・`-3`)。ローカルにはタグを作らない。最初のタグは `2026-11-04`(`0fff50c`) + - ★サーバーで戻すとき★: `git log --oneline`(またはタグ名)で版を選び `git reset --hard <タグ名か ID>` → コンテナ作り直し。最新へ戻すのは `git pull origin develop` - 経緯: 11-04 まで毎回「親なし1コミット」を force push していたので、サーバーで pull が効かず古い版のまま残った(10-29・11-04)。11-04 にいったん合流コミット(`ecb9f68`)でつなぎ直したあと、ユーザー指示「rebase などでまとめて最新の状態に」で★1コミットにまとめ直した(最後の force push)★。サーバーはその1回だけ `git fetch origin` → `git reset --hard origin/develop`。★以後は force push しない★ - ★サーバーへの反映★: サーバーで `git pull` → コンテナをイメージごと作り直して再起動 → ブラウザで Ctrl+Shift+R(`npm run build` だけではコンテナに届かない) - ★コミットの作法★: ファイルを名前で指定(`git add -A` 禁止)・別セッションの6ファイルを入れない・★コミット前に差分へテスト用トークン/本物の `AUTH_TOKEN`/DB のパスワード/接続先 IP が入っていないことを grep で確かめる★(値は表示しない) @@ -96,7 +130,7 @@ 1. ★ステージングは予定なしの行が大半★(詳細ログ 636件中 596件が予定なし。予定ありは40件で全部出発失敗。11-04 実測・先方投入)→ ★「予定あり」が少ない・▼ が少ないのは不具合ではない★(ユーザーが「アドバイスが消えた」と感じた原因。BI で予定なしにも出すようにした) 2. ★詳細ログのツールバーは 1366px で2行になる★(予定のプルダウン 160px を足したため。承知のうえ。BH §12-2) 3. ★開いた行の見出し(`adviceHead()`)とアドバイス本文は EN でも日本語★(前から。本文は運用設定の日本語) -4. ★`d-watch-api.yaml` は js-yaml で読めない箇所が1つある★(`precheck_minutes` の説明の「(旧: …」の `: `。HEAD 以前から)。新しい変更で増えていないかは「この1件だけか」で確かめる +4. ★(2026-11-05 に解消)★ `d-watch-api.yaml` は js-yaml で読めない箇所が1つあった(`precheck_minutes` の説明の「(旧: …」の `: `)。★引用符で囲んで直した★。★説明に `: ` を含む1行の文字列を書くときは引用符で囲むか `|` にする★。確かめ方: `node -e 'require("js-yaml").load(require("fs").readFileSync("d-watch-api.yaml","utf8"))'` 5. ★`clean_task_reports.report_id` は varchar(20) の文字列★(19桁で安全な整数を超える。`Number("1315386034733453312")` は `…300` に丸まる)。`parsePathId()` を流用しない(`parsePathReportId()`) 6. ★`UNSCHEDULED_TIMETABLE_ID_WHERE` はトップレベルの `OR`★。他の条件は展開して並べる(`AND` で包むのは OR が2つになるときだけ)。★`app/api/clean-logs/__tests__/route.test.ts` のスタブの `matchesWhere` は `AND` を黙って無視する★ 7. ★予定あり/予定なしの向き★: エラー一覧は `key.scheduled`(true=予定あり)、詳細ログは `r.unscheduled`(true=予定なし)。`matchesScheduleFilter()` には必ず「予定あり=true」を渡す @@ -106,8 +140,9 @@ | 順 | 内容 | |---|---| -| 1 | ★`docs/decisions.md` に BH・BI の決定を記録★(D-182 以降。★別セッションが decisions.md を編集中なので、そのコミットを待ってから★)。中身は指示書BH §10・§12-8・§13、BI §9(10-29〜10-30 の「0 は予定外」「A案」「アラートは事前チェックだけ」「進み具合は前日」「status 6・不明(n)」「施設マスタの並べ替え」も) | -| 2 | ★`d-watch-api.yaml` を YAML として読めるように直す★(上の罠4。別タスク) | +| 1 | ★(2026-11-05 に済み)★ `docs/decisions.md` に BB〜BI と 10-27〜10-30 の決定を記録(D-182〜D-194) | +| 2 | ★(2026-11-05 に済み)★ `d-watch-api.yaml` を YAML として読めるように直す(上の罠4) | +| 2b | ★(2026-11-05 に済み)★ 古い記述を直した: `docs/code-map.md:69`・`:595`(予定外を入れない=BG の前)・`:656`(エラー一覧の列幅が古い)・`docs/spec.md:80`(「種類の2行目にエラー内容」=今は「詳細」の列)・指示書BB 付録A の D20(「直列化しない」=実装は直列化。D-182)。★code-map の予定外の記述は他にも4か所(:439・:1169・:1328〜1339・:1640 付近)古かったので合わせて直した★ | | 3 | 必要なら: 開いた行の見出しの EN 対応(罠3) | | 4 | ★施設69(MEGAUNY稲沢東店・ロボット2台)のデータが入ったら表示を確かめる★(10-30 の確認では問題なし。データ待ち) | | 5 | 下の「次にやること」(10-07 時点)の PR #6・第12回の回答・点検記録画面(F-05)・契約によるメニューの出し分け・セキュリティはリリース直前、は★まだ残っている前提で、実測してから★進める | @@ -207,7 +242,7 @@ - ★検証が通ったら即コミット★ / ★`git add -A` は禁止★ - ★決定番号は `grep -n "^### D-" docs/decisions.md | tail -3` で実測★(`^### D-0` では3桁台を拾えない) - ★指示書を2本同時に走らせない★ -- ★dev サーバーは `preview_start`★(fixtures は `d-watch-fixtures`、ポート3100)。★ログインは `.env.example` の開発用トークン★(ローカルの開発サーバーに限る) +- ★fixtures の画面確認は本番ビルドで★(2026-11-05 に改めた。旧: `preview_start` の `d-watch-fixtures`(dev サーバー)と `.env.example` の開発用トークン): `USE_FIXTURES=true npm run build` → `AUTH_TOKEN=<スクラッチパッドに乱数で作ったテスト用トークン> USE_FIXTURES=true npx next start -p 3100` → アプリ内ブラウザ(Edge は使わない)。★:3000(ユーザーのサーバー)は止めない★。`.claude/launch.json` の `d-watch-fixtures` は残っているが使わない - ★サブエージェントが起動した dev サーバーは止めてから終わらせる★(2026-10-02 に残ったサーバーの作業プロセスが落ち、PATCH が500になった) - ★新しいクローンでは `npx prisma generate` が要る★ - ★ワークツリーでは `npm run build` が落ちる★(Turbopack が node_modules のジャンクションを拒否)。★ビルドは本体で★ diff --git a/app/api/ds-agent/__tests__/end-to-end.test.ts b/app/api/ds-agent/__tests__/end-to-end.test.ts new file mode 100644 index 0000000..13c45b9 --- /dev/null +++ b/app/api/ds-agent/__tests__/end-to-end.test.ts @@ -0,0 +1,539 @@ +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import { promises as fsp } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// ★ルート本体は静的 import★ +import { GET as getCommands } from "@/app/api/ds-agent/commands/route"; +import { POST as postResult } from "@/app/api/ds-agent/commands/[command_id]/result/route"; +import { POST as postPrepared } from "@/app/api/ds-agent/commands/[command_id]/prepared/route"; +import { POST as postChunk } from "@/app/api/ds-agent/commands/[command_id]/chunks/[seq]/route"; +import { POST as postComplete } from "@/app/api/ds-agent/commands/[command_id]/complete/route"; +import { POST as postFail } from "@/app/api/ds-agent/commands/[command_id]/fail/route"; +import { GET as getLogs } from "@/app/api/ds-devices/[serial_number]/logs/route"; +import { POST as postExport } from "@/app/api/ds-devices/[serial_number]/logs/exports/route"; +import { GET as getExport } from "@/app/api/ds-devices/[serial_number]/logs/exports/[export_id]/route"; +import { EXPORT_FILE_IDS_MAX_BYTES } from "@/lib/config/ds-agent"; +import { db } from "@/lib/db"; +import { createFakeRelay } from "@/lib/ds-agent/__tests__/fake-relay"; +import { __setRelayForTesting } from "@/lib/ds-agent/relay"; +import { __setAgentTokenStoreForTesting, createAgentTokenStore, issueAgentToken } from "@/lib/ds-agent/tokens"; +import { fileIdsOfJsonBytes } from "@/lib/ds-logs/__tests__/file-ids-of-bytes"; +import { FIXTURE_EXPORT_ZIP_BASE64 } from "@/lib/fixtures/ds-device-logs"; +import { logEvent } from "@/lib/logger"; +import type { DsAgentCommand, DsLogExportResponse, DsLogListResponse } from "@/lib/types"; + +/** 制御盤 `agent.py:62` の `RESPONSE_READ_MAX_BYTES`(指示書BK K7-5) */ +const AGENT_RESPONSE_READ_MAX_BYTES = 64 * 1024; + +/** + * 指示書AY 段階7-4 — ルートのハンドラを直接呼んで一連(ブラウザ ↔ 制御盤)。 + * - 一覧: ブラウザ GET logs ↔ 制御盤 GET commands → POST result + * - エクスポート: ブラウザ POST exports ↔ 制御盤 prepared → 201 → ブラウザ GET ↔ chunks 0..2 → complete + * - ★読んだ zip のバイト列と SHA-256 が送ったものと一致・`Content-Length` が一致★ + * - ★ディスクに書かないこと★(スパイ + ソースの静的検査。D3) + */ + +vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false })); +vi.mock("@/lib/logger", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, logEvent: vi.fn() }; +}); +vi.mock("@/lib/logger/api", () => ({ logApiError: vi.fn() })); + +const SERIAL = "DS-002"; +/** ★短い f1 は偶然一致しやすいので長い目印(指示書BJ T-c)★ */ +const SENTINEL_FILE_ID = "FILEID-SENTINEL-7f3a9c2e1b4d6a8e0c5f7d9b1a3e5c7d"; +const SENTINEL_NAME = "SENTINEL-DIR/SENTINEL-NAME.log"; +const VALID_LIST = { + types: [{ key: "app", label: "アプリ" }], + items: [ + { + file_id: SENTINEL_FILE_ID, + name: SENTINEL_NAME, + type: "app", + size_bytes: 10, + modified_at: "2026-10-19T11:00:00+09:00", + }, + ], + truncated: false, +}; + +/** 3チャンク(4・4・4 バイト)で流量制御の経路を通す */ +const CHUNKS = [new Uint8Array([1, 2, 3, 4]), new Uint8Array([5, 6, 7, 8]), new Uint8Array([9, 10, 11, 12])]; +const PAYLOAD = (() => { + const out = new Uint8Array(CHUNKS.reduce((n, c) => n + c.length, 0)); + let o = 0; + for (const c of CHUNKS) { + out.set(c, o); + o += c.length; + } + return out; +})(); +const PAYLOAD_SHA = createHash("sha256").update(PAYLOAD).digest("hex"); + +let dir = ""; +let fake: ReturnType; +let token = ""; +let exists: ReturnType; + +beforeEach(async () => { + dir = await fsp.mkdtemp(path.join(os.tmpdir(), "dwatch-agent-e2e-")); + __setAgentTokenStoreForTesting(createAgentTokenStore(path.join(dir, "ds-agent-tokens.json"))); + // ★トークンの発行(ストアの書き込み)はディスク・スパイを仕掛ける前に済ませる★ + token = (await issueAgentToken(SERIAL)).token; + fake = createFakeRelay({ chunkMaxBytes: 4 }); + __setRelayForTesting(fake.relay); + exists = vi.spyOn(db, "dsDeviceExists").mockResolvedValue(true); +}); + +afterEach(async () => { + __setRelayForTesting(null); + __setAgentTokenStoreForTesting(null); + exists.mockRestore(); + vi.restoreAllMocks(); + await fsp.rm(dir, { recursive: true, force: true }); +}); + +const sha = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex"); + +async function waitUntilConnected(serial = SERIAL): Promise { + await vi.waitFor( + () => { + if (!fake.relay.agentStatus(serial).connected) { + throw new Error(`${serial} が接続状態にならなかった`); + } + }, + { timeout: 4000, interval: 5 }, + ); +} + +function agentRequest(pathname: string, method: string, body?: BodyInit, headers: Record = {}): Request { + return new Request(`http://localhost${pathname}`, { + method, + headers: { authorization: `Bearer ${token}`, ...headers }, + body, + }); +} + +function browserLogs(): Promise { + return getLogs(new Request(`http://localhost/api/ds-devices/${SERIAL}/logs`), { + params: Promise.resolve({ serial_number: SERIAL }), + }); +} + +function browserExport(fileIds: string[] = [SENTINEL_FILE_ID]): Promise { + return postExport( + new Request(`http://localhost/api/ds-devices/${SERIAL}/logs/exports`, { + method: "POST", + body: JSON.stringify({ file_ids: fileIds }), + }), + { params: Promise.resolve({ serial_number: SERIAL }) } + ); +} + +function browserDownload(exportId: string): Promise { + return getExport(new Request(`http://localhost/api/ds-devices/${SERIAL}/logs/exports/${exportId}`), { + params: Promise.resolve({ serial_number: SERIAL, export_id: exportId }), + }); +} + +async function agentPoll(wait = 25): Promise { + return getCommands(agentRequest(`/api/ds-agent/commands?wait=${wait}`, "GET", undefined, { "x-agent-version": "1.0.0" })); +} + +function agentPostResult(commandId: string, body: unknown): Promise { + return postResult(agentRequest(`/api/ds-agent/commands/${commandId}/result`, "POST", JSON.stringify(body)), { + params: Promise.resolve({ command_id: commandId }), + }); +} + +function agentPostPrepared(commandId: string, body: unknown): Promise { + return postPrepared(agentRequest(`/api/ds-agent/commands/${commandId}/prepared`, "POST", JSON.stringify(body)), { + params: Promise.resolve({ command_id: commandId }), + }); +} + +function agentPostChunk(commandId: string, seq: number, bytes: Uint8Array): Promise { + return postChunk( + agentRequest(`/api/ds-agent/commands/${commandId}/chunks/${seq}`, "POST", Buffer.from(bytes), { + "x-chunk-sha256": sha(bytes), + }), + { params: Promise.resolve({ command_id: commandId, seq: String(seq) }) } + ); +} + +function agentPostComplete(commandId: string, body: unknown): Promise { + return postComplete(agentRequest(`/api/ds-agent/commands/${commandId}/complete`, "POST", JSON.stringify(body)), { + params: Promise.resolve({ command_id: commandId }), + }); +} + +function agentPostFail(commandId: string, body: unknown): Promise { + return postFail(agentRequest(`/api/ds-agent/commands/${commandId}/fail`, "POST", JSON.stringify(body)), { + params: Promise.resolve({ command_id: commandId }), + }); +} + +/** 一覧 → prepared → download → chunks → complete まで(本文を読み切る) */ +async function runFullExportRoundTrip(): Promise<{ exportId: string; joined: Uint8Array }> { + const pollExport = agentPoll(); + await waitUntilConnected(); + const exportP = browserExport(); + const exportCmd = (await (await pollExport).json()) as DsAgentCommand; + expect(exportCmd.kind).toBe("export"); + expect( + ( + await agentPostPrepared(exportCmd.command_id, { + total_bytes: PAYLOAD.length, + sha256: PAYLOAD_SHA, + file_count: 1, + }) + ).status + ).toBe(204); + const created = await exportP; + expect(created.status).toBe(201); + const createdBody = (await created.json()) as DsLogExportResponse; + + const pollSend = agentPoll(); + const downloadP = browserDownload(createdBody.export_id); + const sendCmd = (await (await pollSend).json()) as DsAgentCommand; + if (sendCmd.kind !== "send") throw new Error("send 以外"); + const exportCommandId = sendCmd.params.export_command_id; + + const chunk0P = agentPostChunk(exportCommandId, 0, CHUNKS[0]); + const downloadRes = await downloadP; + expect(downloadRes.status).toBe(200); + const reader = downloadRes.body!.getReader(); + const received: Uint8Array[] = []; + const first = await reader.read(); + received.push(first.value!); + expect((await chunk0P).status).toBe(204); + for (const seq of [1, 2]) { + const chunkP = agentPostChunk(exportCommandId, seq, CHUNKS[seq]); + const chunk = await reader.read(); + received.push(chunk.value!); + expect((await chunkP).status).toBe(204); + } + expect( + (await agentPostComplete(exportCommandId, { total_bytes: PAYLOAD.length, sha256: PAYLOAD_SHA })).status + ).toBe(204); + expect((await reader.read()).done).toBe(true); + + const joined = new Uint8Array(received.reduce((n, c) => n + c.length, 0)); + let o = 0; + for (const c of received) { + joined.set(c, o); + o += c.length; + } + return { exportId: createdBody.export_id, joined }; +} + +/** zip 中央ディレクトリの名前(順番どおり) */ +function zipCentralDirectoryNames(bytes: Uint8Array): string[] { + const buf = Buffer.from(bytes); + const names: string[] = []; + let i = 0; + while (i < buf.length - 4) { + if (buf.readUInt32LE(i) === 0x02014b50) { + const nameLen = buf.readUInt16LE(i + 28); + const extraLen = buf.readUInt16LE(i + 30); + const commentLen = buf.readUInt16LE(i + 32); + names.push(buf.subarray(i + 46, i + 46 + nameLen).toString("utf8")); + i += 46 + nameLen + extraLen + commentLen; + } else { + i++; + } + } + return names; +} + +describe("一連: 一覧(ブラウザ GET logs ↔ 制御盤 result)", () => { + it("一覧がルートを通って往復する", async () => { + const poll = agentPoll(); + await waitUntilConnected(); + const listP = browserLogs(); + const pollRes = await poll; + expect(pollRes.status).toBe(200); + const command = (await pollRes.json()) as DsAgentCommand; + expect(command.kind).toBe("list"); + + expect((await agentPostResult(command.command_id, VALID_LIST)).status).toBe(204); + + const listRes = await listP; + expect(listRes.status).toBe(200); + const body = (await listRes.json()) as DsLogListResponse; + expect(body.items.map((i) => i.file_id)).toEqual([SENTINEL_FILE_ID]); + expect(body.agent.connected).toBe(true); + expect(body.truncated).toBe(false); + }); + + it("形の崩れた /result → 制御盤は 400・ブラウザは 502 result_invalid(指示書BK K5)", async () => { + const poll = agentPoll(); + await waitUntilConnected(); + const listP = browserLogs(); + const command = (await (await poll).json()) as DsAgentCommand; + const agentRes = await agentPostResult(command.command_id, { types: [], items: "x" }); + expect(agentRes.status).toBe(400); + expect(((await agentRes.json()) as { error: { code: string } }).error.code).toBe("result_invalid"); + + const listRes = await listP; + expect(listRes.status).toBe(502); + expect(await listRes.json()).toEqual({ + error: { + code: "agent_error", + message: "制御盤でエラーが発生しました", + details: { agent_code: ["result_invalid"] }, + }, + }); + }); +}); + +describe("一連: エクスポート(prepared → chunks 0..2 → complete)", () => { + it("K7-5 file_ids 上限ちょうどでも poll 応答は 64KB 以下で JSON として読める", async () => { + const fileIds = fileIdsOfJsonBytes(EXPORT_FILE_IDS_MAX_BYTES); + expect(new TextEncoder().encode(JSON.stringify(fileIds)).length).toBe(EXPORT_FILE_IDS_MAX_BYTES); + + const poll = agentPoll(); + await waitUntilConnected(); + const exportP = browserExport(fileIds); + const pollRes = await poll; + expect(pollRes.status).toBe(200); + const text = await pollRes.text(); + expect(new TextEncoder().encode(text).length).toBeLessThanOrEqual(AGENT_RESPONSE_READ_MAX_BYTES); + const command = JSON.parse(text) as DsAgentCommand; + expect(command.kind).toBe("export"); + if (command.kind !== "export") throw new Error("export 以外"); + expect(command.params.file_ids).toEqual(fileIds); + + expect( + (await agentPostFail(command.command_id, { code: "io_error", message: "cleanup" })).status + ).toBe(204); + await expect(exportP).resolves.toMatchObject({ status: 502 }); + }); + + it("zip のバイト列と SHA-256・Content-Length が一致する", async () => { + // 1) 準備 + const pollExport = agentPoll(); + await waitUntilConnected(); + const exportP = browserExport(); + const exportCmdRes = await pollExport; + expect(exportCmdRes.status).toBe(200); + const exportCmd = (await exportCmdRes.json()) as DsAgentCommand; + expect(exportCmd.kind).toBe("export"); + + expect( + ( + await agentPostPrepared(exportCmd.command_id, { + total_bytes: PAYLOAD.length, + sha256: PAYLOAD_SHA, + file_count: 1, + }) + ).status + ).toBe(204); + + const created = await exportP; + expect(created.status).toBe(201); + const createdBody = (await created.json()) as DsLogExportResponse; + expect(createdBody.total_bytes).toBe(PAYLOAD.length); + expect(createdBody.file_count).toBe(1); + + // 2) 受け取り(send)+ チャンク 0..2 + const pollSend = agentPoll(); + const downloadP = browserDownload(createdBody.export_id); + const sendCmdRes = await pollSend; + expect(sendCmdRes.status).toBe(200); + const sendCmd = (await sendCmdRes.json()) as DsAgentCommand; + expect(sendCmd.kind).toBe("send"); + if (sendCmd.kind !== "send") throw new Error("send 以外"); + // ★チャンク・complete は export の指示の id★(Q16) + const exportCommandId = sendCmd.params.export_command_id; + + const chunk0P = agentPostChunk(exportCommandId, 0, CHUNKS[0]); + const downloadRes = await downloadP; + expect(downloadRes.status).toBe(200); + expect(downloadRes.headers.get("content-type")).toBe("application/zip"); + expect(downloadRes.headers.get("cache-control")).toBe("no-store"); + expect(downloadRes.headers.get("x-accel-buffering")).toBe("no"); + expect(downloadRes.headers.get("content-disposition")).toMatch(/^attachment;/); + expect(downloadRes.headers.get("content-length")).toBe(String(PAYLOAD.length)); + + const reader = downloadRes.body!.getReader(); + const received: Uint8Array[] = []; + + const first = await reader.read(); + expect(first.done).toBe(false); + received.push(first.value!); + expect((await chunk0P).status).toBe(204); + + const chunk1P = agentPostChunk(exportCommandId, 1, CHUNKS[1]); + const second = await reader.read(); + expect(second.done).toBe(false); + received.push(second.value!); + expect((await chunk1P).status).toBe(204); + + const chunk2P = agentPostChunk(exportCommandId, 2, CHUNKS[2]); + const third = await reader.read(); + expect(third.done).toBe(false); + received.push(third.value!); + expect((await chunk2P).status).toBe(204); + + expect( + (await agentPostComplete(exportCommandId, { total_bytes: PAYLOAD.length, sha256: PAYLOAD_SHA })).status + ).toBe(204); + + const end = await reader.read(); + expect(end.done).toBe(true); + + const joined = new Uint8Array(received.reduce((n, c) => n + c.length, 0)); + let o = 0; + for (const c of received) { + joined.set(c, o); + o += c.length; + } + expect(Buffer.from(joined).equals(Buffer.from(PAYLOAD))).toBe(true); + expect(createHash("sha256").update(joined).digest("hex")).toBe(PAYLOAD_SHA); + }); +}); + +describe("ディスクに書かないこと(D3・指示書BJ T-b)", () => { + // スパイは `fs` の既定オブジェクト経由の書き込みしか捕まえない。名前付き import・動的 import・require は下の静的な検査で捕まえる + it("一覧+エクスポート中継のあいだ writeFile / createWriteStream / mkdtemp / tmpdir は 0回(トークン発行の後)", async () => { + // ★本物を呼ばない(G3: スパイだけだとファイルが書かれる)★ + const writeFileSync = vi.spyOn(fs, "writeFileSync").mockImplementation(() => undefined); + const writeFile = vi.spyOn(fsp, "writeFile").mockImplementation(async () => undefined); + const appendFile = vi.spyOn(fsp, "appendFile").mockImplementation(async () => undefined); + const createWriteStream = vi.spyOn(fs, "createWriteStream").mockImplementation(() => { + throw new Error("createWriteStream が呼ばれた"); + }); + const mkdtemp = vi.spyOn(fsp, "mkdtemp").mockImplementation(async () => { + throw new Error("mkdtemp が呼ばれた"); + }); + const tmpdir = vi.spyOn(os, "tmpdir").mockImplementation(() => { + throw new Error("tmpdir が呼ばれた"); + }); + + const poll = agentPoll(); + await waitUntilConnected(); + const listP = browserLogs(); + const pollRes = await poll; + const command = (await pollRes.json()) as DsAgentCommand; + await agentPostResult(command.command_id, VALID_LIST); + expect((await listP).status).toBe(200); + + const { joined } = await runFullExportRoundTrip(); + expect(Buffer.from(joined).equals(Buffer.from(PAYLOAD))).toBe(true); + + expect(writeFileSync).not.toHaveBeenCalled(); + expect(writeFile).not.toHaveBeenCalled(); + expect(appendFile).not.toHaveBeenCalled(); + expect(createWriteStream).not.toHaveBeenCalled(); + expect(mkdtemp).not.toHaveBeenCalled(); + expect(tmpdir).not.toHaveBeenCalled(); + }); + + it("ソースの静的検査: relay / ds-logs / agent ルート / logs ルート / agent-request に fs・os のモジュール名と require が無い", async () => { + const roots = [ + path.join(process.cwd(), "lib", "ds-agent", "relay.ts"), + path.join(process.cwd(), "lib", "ds-agent", "agent-request.ts"), + path.join(process.cwd(), "lib", "ds-logs"), + path.join(process.cwd(), "app", "api", "ds-agent"), + path.join(process.cwd(), "app", "api", "ds-devices", "[serial_number]", "logs"), + ]; + const files: string[] = []; + for (const root of roots) { + const st = await fsp.stat(root); + if (st.isFile()) { + files.push(root); + continue; + } + const walk = async (dirPath: string) => { + for (const ent of await fsp.readdir(dirPath, { withFileTypes: true })) { + const full = path.join(dirPath, ent.name); + if (ent.isDirectory()) { + if (ent.name === "__tests__") continue; + await walk(full); + } else if (ent.name.endsWith(".ts") && !ent.name.endsWith(".test.ts")) { + files.push(full); + } + } + }; + await walk(root); + } + + const banned = [ + /["'](?:node:)?(?:fs|os)(?:\/promises)?["']/, // import・export・動的 import・require のどれで書いても、モジュール名の文字列で当たる + /\brequire\s*\(/, + /\bimport\s*\(/, // 動的 import はモジュール名によらず禁じる(テンプレート文字列の import(`node:fs`) も塞ぐ) + /\btmpdir\b/, + /\bmkdtemp\b/, + ]; + for (const file of files) { + const text = await fsp.readFile(file, "utf8"); + for (const re of banned) { + expect(text, `${file} に ${re} がある`).not.toMatch(re); + } + } + }); +}); + +describe("ログのイベント(指示書BJ T-c)", () => { + it("一覧→成功エクスポート→失敗エクスポートで6種が出て、file_id・名前・トークン・SHA-256 が載らない", async () => { + vi.mocked(logEvent).mockClear(); + fake.events.length = 0; + + const poll = agentPoll(); + await waitUntilConnected(); + const listP = browserLogs(); + const listCmd = (await (await poll).json()) as DsAgentCommand; + await agentPostResult(listCmd.command_id, VALID_LIST); + expect((await listP).status).toBe(200); + + await runFullExportRoundTrip(); + + // 2本目: 準備中に fail + const pollFail = agentPoll(); + const exportFailP = browserExport(); + const failCmd = (await (await pollFail).json()) as DsAgentCommand; + expect(failCmd.kind).toBe("export"); + expect( + (await agentPostFail(failCmd.command_id, { code: "io_error", message: "読めません" })).status + ).toBe(204); + await expect(exportFailP).resolves.toMatchObject({ status: 502 }); + + const routeEvents = vi.mocked(logEvent).mock.calls.map((call) => call[1] as { event?: string }); + const relayEvents = fake.events; + const all = [...routeEvents, ...relayEvents]; + const kinds = new Set(all.map((e) => e.event).filter((e): e is string => typeof e === "string")); + for (const needed of [ + "ds_agent.connected", + "ds_logs.export_completed", + "ds_logs.export_failed", + "ds_logs.list", + "ds_logs.export_prepared", + "ds_logs.export_started", + ]) { + expect(kinds.has(needed), `${needed} が出ていない`).toBe(true); + } + + const forbidden = [SENTINEL_FILE_ID, SENTINEL_NAME, token, PAYLOAD_SHA]; + for (const event of all) { + const text = JSON.stringify(event); + for (const needle of forbidden) { + expect(text, `${JSON.stringify(event?.event)} に秘密が載っている`).not.toContain(needle); + } + } + }); +}); + +describe("fixtures の zip の中のパス(指示書BJ T-d・指示書AY M17)", () => { + it("中央ディレクトリの名前が <種類>/<ファイル名>(順番どおり)", () => { + const bytes = Buffer.from(FIXTURE_EXPORT_ZIP_BASE64, "base64"); + expect(zipCentralDirectoryNames(bytes)).toEqual([ + "api_client/api_client.log", + "postcondition_security/postcondition_security.log.2026-10-18", + ]); + }); +}); diff --git a/app/api/ds-agent/__tests__/routes.test.ts b/app/api/ds-agent/__tests__/routes.test.ts new file mode 100644 index 0000000..4c4b33c --- /dev/null +++ b/app/api/ds-agent/__tests__/routes.test.ts @@ -0,0 +1,549 @@ +import { createHash } from "node:crypto"; +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// ★ルート本体は静的 import★ +import { GET as getCommands } from "@/app/api/ds-agent/commands/route"; +import { POST as postResult } from "@/app/api/ds-agent/commands/[command_id]/result/route"; +import { POST as postPrepared } from "@/app/api/ds-agent/commands/[command_id]/prepared/route"; +import { POST as postChunk } from "@/app/api/ds-agent/commands/[command_id]/chunks/[seq]/route"; +import { POST as postComplete } from "@/app/api/ds-agent/commands/[command_id]/complete/route"; +import { POST as postFail } from "@/app/api/ds-agent/commands/[command_id]/fail/route"; +import { LIST_RESULT_MAX_ITEMS, PATH_COMMAND_ID_MAX_LENGTH } from "@/lib/config/ds-agent"; +import { createFakeRelay, flush } from "@/lib/ds-agent/__tests__/fake-relay"; +import { __setRelayForTesting } from "@/lib/ds-agent/relay"; +import { __setAgentTokenStoreForTesting, createAgentTokenStore, issueAgentToken } from "@/lib/ds-agent/tokens"; +import type { DsAgentCommand } from "@/lib/types"; + +/** + * 指示書AY 段階7-4 — 制御盤向けの6本(`app/api/ds-agent/**`)。 + * - ★Bearer なし・違う → 401(`requireAgent()` の再検証)★ + * - 表5 R7(どのルートでも last_seen_at が進む)・R35・R36・チャンクの 400 / 409 / 410・段階7-2 W1〜W11・prepared の検証 + * - ★表5 R44(オブジェクトでない本文は4ルートとも 400 result_invalid。500 にならない)★ + * - ★command_id / seq の 422(段階3-3 X2・X5・X6)★ + * + * トークンストアは mkdtemp(★ストアとパスを一緒に差し替える。M13★)。relay は偽の時計。★agent のルートは DB を読まない★。 + */ + +vi.mock("@/lib/logger", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, logEvent: vi.fn() }; +}); + +let dir = ""; +let fake: ReturnType; +let token002 = ""; +let token003 = ""; + +beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), "dwatch-agent-routes-")); + __setAgentTokenStoreForTesting(createAgentTokenStore(path.join(dir, "ds-agent-tokens.json"))); + token002 = (await issueAgentToken("DS-002")).token; + token003 = (await issueAgentToken("DS-003")).token; + fake = createFakeRelay({ chunkMaxBytes: 16 }); + __setRelayForTesting(fake.relay); +}); + +afterEach(async () => { + __setRelayForTesting(null); + __setAgentTokenStoreForTesting(null); + await fs.rm(dir, { recursive: true, force: true }); +}); + +const signal = () => new AbortController().signal; +const sha = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex"); + +interface CallOptions { + token?: string | null; + body?: BodyInit; + headers?: Record; +} + +function agentRequest(pathname: string, method: string, opts: CallOptions = {}): Request { + const token = opts.token === undefined ? token002 : opts.token; + const headers: Record = { ...(opts.headers ?? {}) }; + if (token !== null) headers.authorization = `Bearer ${token}`; + return new Request(`http://localhost${pathname}`, { method, headers, body: opts.body }); +} + +const commands = (qs = "", opts: CallOptions = {}) => + getCommands(agentRequest(`/api/ds-agent/commands${qs}`, "GET", opts)); + +type CommandRoute = "result" | "prepared" | "complete" | "fail"; +const COMMAND_ROUTES = { result: postResult, prepared: postPrepared, complete: postComplete, fail: postFail }; + +function commandPost(route: CommandRoute, commandId: string, body: string, opts: CallOptions = {}) { + return COMMAND_ROUTES[route]( + agentRequest(`/api/ds-agent/commands/${commandId}/${route}`, "POST", { ...opts, body }), + { params: Promise.resolve({ command_id: commandId }) } + ); +} + +function chunkPost(commandId: string, seq: string, bytes: Uint8Array, opts: CallOptions & { sha?: string | null } = {}) { + const headers: Record = {}; + const hash = opts.sha === undefined ? sha(bytes) : opts.sha; + if (hash !== null) headers["x-chunk-sha256"] = hash; + return postChunk( + agentRequest(`/api/ds-agent/commands/${commandId}/chunks/${seq}`, "POST", { + ...opts, + headers, + body: Buffer.from(bytes), + }), + { params: Promise.resolve({ command_id: commandId, seq }) } + ); +} + +async function errorCode(res: Response): Promise { + return ((await res.json()) as { error: { code: string } }).error.code; +} + +/** 本文を1回だけ読んで code と message を両方返す(指示書BK K1) */ +async function errorBody(res: Response): Promise<{ code: string; message: string }> { + const error = ((await res.json()) as { error: { code: string; message: string } }).error; + return { code: error.code, message: error.message }; +} + +/** + * ルート経由の `GET /ds-agent/commands` は `requireAgent()`(async)のあとで poll に入る。 + * ★`flush()` 1回では足りないことがある★ので、接続中になるまで待つ(偽の時計のタイマー登録前に `at()` しない)。 + */ +async function waitUntilConnected(serial = "DS-002"): Promise { + await vi.waitFor( + () => { + if (!fake.relay.agentStatus(serial).connected) { + throw new Error(`${serial} が接続状態にならなかった`); + } + }, + { timeout: 4000, interval: 5 }, + ); +} + +/** ブラウザの役: 一覧を依頼し、制御盤の役: list の指示を受け取る */ +async function pendingList() { + const poll = fake.relay.poll("DS-002", "1.0.0", 25, signal()); + const list = fake.relay.requestList("DS-002"); + const command = (await poll) as DsAgentCommand; + return { list, command }; +} + +/** ブラウザの役: エクスポートを依頼し、制御盤の役: export の指示を受け取る */ +async function pendingExport() { + const poll = fake.relay.poll("DS-002", "1.0.0", 25, signal()); + const created = fake.relay.createExport("DS-002", ["f1"], signal()); + const command = (await poll) as DsAgentCommand; + return { created, command }; +} + +const VALID_LIST = { + types: [{ key: "app", label: "アプリ" }], + items: [{ file_id: "f1", name: "app/app.log", type: "app", size_bytes: 10, modified_at: "2026-10-19T11:00:00+09:00" }], + truncated: false, +}; + +describe("認証(requireAgent の再検証)", () => { + const cases: Array<[string, (opts: CallOptions) => Promise]> = [ + ["commands", (o) => commands("", o)], + ["result", (o) => commandPost("result", "x", "{}", o)], + ["prepared", (o) => commandPost("prepared", "x", "{}", o)], + ["complete", (o) => commandPost("complete", "x", "{}", o)], + ["fail", (o) => commandPost("fail", "x", "{}", o)], + ["chunks", (o) => chunkPost("x", "0", new Uint8Array([1]), o)], + ]; + + it.each(cases)("%s: Bearer なし → 401", async (_name, call) => { + const res = await call({ token: null }); + expect(res.status).toBe(401); + expect(await errorCode(res)).toBe("unauthorized"); + }); + + it.each(cases)("%s: 違う Bearer → 401", async (_name, call) => { + const res = await call({ token: "Z".repeat(43) }); + expect(res.status).toBe(401); + }); + + // ★commands 以外★: requireAgent の touch だけで終わる。commands は poll で待つので別ケース + it.each(cases.filter(([name]) => name !== "commands"))( + "R7 %s: 認証が通れば last_seen_at が進む(D19)", + async (_name, call) => { + await fake.at(80); + await call({}); + expect(fake.relay.agentStatus("DS-002").last_seen_at).toBe("2026-10-19T12:01:20+09:00"); + } + ); + + it("R7 commands: 認証が通れば last_seen_at が進む(D19)", async () => { + await fake.at(80); + const res = commands("?wait=1"); + await waitUntilConnected(); + expect(fake.relay.agentStatus("DS-002").last_seen_at).toBe("2026-10-19T12:01:20+09:00"); + await fake.at(81); + expect((await res).status).toBe(204); + }); +}); + +describe("GET /ds-agent/commands", () => { + it.each(["wait=0", "wait=26", "wait=abc"])("%s → 422(丸めない。表5 R8)", async (qs) => { + const res = await commands(`?${qs}`); + expect(res.status).toBe(422); + expect(await errorCode(res)).toBe("validation_failed"); + }); + + it("指示があれば 200(kind: list)・X-Agent-Version を記録", async () => { + const res = commands("?wait=5", { headers: { "x-agent-version": "1.2.3" } }); + await waitUntilConnected(); + const list = fake.relay.requestList("DS-002"); + const done = await res; + expect(done.status).toBe(200); + const command = (await done.json()) as DsAgentCommand; + expect(command).toMatchObject({ kind: "list", params: {} }); + expect(fake.relay.agentStatus("DS-002").agent_version).toBe("1.2.3"); + fake.relay.submitListResult("DS-002", command.command_id, VALID_LIST); + await expect(list).resolves.toMatchObject({ kind: "ok" }); + }); + + it("指示が無ければ wait 秒で 204・形の合わない X-Agent-Version は無視(拒否しない)", async () => { + const res = commands("?wait=5", { headers: { "x-agent-version": "bad version!" } }); + await waitUntilConnected(); + await fake.at(5); + const done = await res; + expect(done.status).toBe(204); + expect(fake.relay.agentStatus("DS-002").agent_version).toBeNull(); + }); +}); + +describe("POST …/result(段階7-2 W1〜W11)", () => { + it("W1 正しい結果 → 204、ブラウザに一覧が返る", async () => { + const { list, command } = await pendingList(); + const res = await commandPost("result", command.command_id, JSON.stringify(VALID_LIST)); + expect(res.status).toBe(204); + await expect(list).resolves.toEqual({ kind: "ok", result: VALID_LIST }); + }); + + it("W9 { error } → 204、ブラウザには agent_error", async () => { + const { list, command } = await pendingList(); + const res = await commandPost("result", command.command_id, JSON.stringify({ error: { code: "io_error", message: "m" } })); + expect(res.status).toBe(204); + await expect(list).resolves.toEqual({ kind: "agent_error", code: "io_error" }); + }); + + it("W11 name に / を含む → 受理", async () => { + const { command } = await pendingList(); + const body = { ...VALID_LIST, items: [{ ...VALID_LIST.items[0], name: "api_client/api_client.log.2026-10-18" }] }; + expect((await commandPost("result", command.command_id, JSON.stringify(body))).status).toBe(204); + }); + + it.each<[string, unknown]>([ + ["W2 key の重複", { ...VALID_LIST, types: [...VALID_LIST.types, { key: "app", label: "x" }] }], + ["W3 types に無い type", { ...VALID_LIST, items: [{ ...VALID_LIST.items[0], type: "xyz" }] }], + ["W4 オフセットなし", { ...VALID_LIST, items: [{ ...VALID_LIST.items[0], modified_at: "2026-10-19T11:00:00" }] }], + ["W4b 存在しない日付", { ...VALID_LIST, items: [{ ...VALID_LIST.items[0], modified_at: "2026-02-30T00:00:00+09:00" }] }], + ["W5 size_bytes が負", { ...VALID_LIST, items: [{ ...VALID_LIST.items[0], size_bytes: -1 }] }], + ["W6 file_id の重複", { ...VALID_LIST, items: [VALID_LIST.items[0], { ...VALID_LIST.items[0], name: "app/b.log" }] }], + [ + "W7 LIST_RESULT_MAX_ITEMS + 1 件", + { + ...VALID_LIST, + items: Array.from({ length: LIST_RESULT_MAX_ITEMS + 1 }, (_, i) => ({ ...VALID_LIST.items[0], file_id: `f${i}` })), + }, + ], + ["R47 file_id が8193文字", { ...VALID_LIST, items: [{ ...VALID_LIST.items[0], file_id: "x".repeat(8193) }] }], + ])("%s → 400 result_invalid・ブラウザの一覧は 502 で終わる(指示書BJ R43′)", async (_label, body) => { + const { list, command } = await pendingList(); + const res = await commandPost("result", command.command_id, JSON.stringify(body)); + expect(res.status).toBe(400); + expect(await errorCode(res)).toBe("result_invalid"); + await expect(list).resolves.toEqual({ kind: "agent_error", code: "result_invalid" }); + const after = await commandPost("result", command.command_id, JSON.stringify(VALID_LIST)); + expect(after.status).toBe(404); + expect(await errorCode(after)).toBe("not_found"); + }); + + it.each<[string, string]>([ + ["R45 key が空", ""], + ["R46 key にカンマ", "a,b"], + ["R46b key が空白だけ", " "], + ["R46b key の先頭が空白", " app"], + ])("%s → 400 result_invalid・typesの形式が不正です(指示書BK K1)", async (_label, bad) => { + const body = { + ...VALID_LIST, + types: [{ key: bad, label: "x" }], + items: [{ ...VALID_LIST.items[0], type: bad }], + }; + const { list, command } = await pendingList(); + const res = await commandPost("result", command.command_id, JSON.stringify(body)); + expect(res.status).toBe(400); + expect(await errorBody(res)).toEqual({ code: "result_invalid", message: "typesの形式が不正です" }); + await expect(list).resolves.toEqual({ kind: "agent_error", code: "result_invalid" }); + const after = await commandPost("result", command.command_id, JSON.stringify(VALID_LIST)); + expect(after.status).toBe(404); + expect(await errorCode(after)).toBe("not_found"); + }); + + it("W8 本体が 4MB を超える → 400 result_invalid・ブラウザの一覧は 502 で終わる", async () => { + const { list, command } = await pendingList(); + const res = await commandPost("result", command.command_id, JSON.stringify(VALID_LIST) + " ".repeat(4 * 1024 * 1024)); + expect(res.status).toBe(400); + await expect(list).resolves.toEqual({ kind: "agent_error", code: "result_invalid" }); + }); + + it("R43‴ export の指示 id に形の崩れた /result → 400・エクスポートは続く", async () => { + const { created, command } = await pendingExport(); + const res = await commandPost("result", command.command_id, JSON.stringify({ types: [], items: [] })); + expect(res.status).toBe(400); + expect(await errorCode(res)).toBe("result_invalid"); + expect( + ( + await commandPost( + "prepared", + command.command_id, + JSON.stringify({ total_bytes: 4, sha256: "a".repeat(64), file_count: 1 }) + ) + ).status + ).toBe(204); + await expect(created).resolves.toMatchObject({ kind: "prepared" }); + }); + + it("R48 prepared の total_bytes: 0 → 400、1 はよい", async () => { + const { created, command } = await pendingExport(); + expect( + ( + await commandPost( + "prepared", + command.command_id, + JSON.stringify({ total_bytes: 0, sha256: "a".repeat(64), file_count: 1 }) + ) + ).status + ).toBe(400); + expect( + ( + await commandPost( + "prepared", + command.command_id, + JSON.stringify({ total_bytes: 1, sha256: "a".repeat(64), file_count: 1 }) + ) + ).status + ).toBe(204); + await expect(created).resolves.toMatchObject({ kind: "prepared", totalBytes: 1 }); + }); + + it("R50 /result に X-Agent-Version → connected の agent_version が入る", async () => { + await commandPost("result", "x", "{}", { headers: { "x-agent-version": "1.2.3" } }); + const connected = fake.events.filter((e) => e.event === "ds_agent.connected"); + expect(connected).toHaveLength(1); + expect(connected[0]).toMatchObject({ agent_version: "1.2.3" }); + expect(fake.relay.agentStatus("DS-002").agent_version).toBe("1.2.3"); + }); + + it("R51 /result に形の合わない X-Agent-Version → agent_version は null(拒否しない)", async () => { + await commandPost("result", "x", "{}", { headers: { "x-agent-version": "bad version!" } }); + expect(fake.relay.agentStatus("DS-002").agent_version).toBeNull(); + const connected = fake.events.filter((e) => e.event === "ds_agent.connected"); + expect(connected[0]).toMatchObject({ agent_version: null }); + }); + + it("時間切れの後の結果 → 410 command_expired(表5 R3)", async () => { + const { command } = await pendingList(); + await fake.at(fake.limits.listTimeoutSec); + const res = await commandPost("result", command.command_id, JSON.stringify(VALID_LIST)); + expect(res.status).toBe(410); + expect(await errorCode(res)).toBe("command_expired"); + }); + + it("R35 DS-003 のトークンで DS-002 の指示に結果 → 404(403 にしない)", async () => { + const { command } = await pendingList(); + const res = await commandPost("result", command.command_id, JSON.stringify(VALID_LIST), { token: token003 }); + expect(res.status).toBe(404); + }); +}); + +describe("表5 R44 — オブジェクトでない本文は4ルートとも 400 result_invalid(500 にしない)", () => { + const routes: CommandRoute[] = ["result", "prepared", "complete", "fail"]; + const bodies = ["null", "[]", "0", '"x"']; + it.each(routes.flatMap((route) => bodies.map((body) => [route, body] as const)))( + "%s に %s → 400 result_invalid", + async (route, body) => { + const { command } = await pendingExport(); + const res = await commandPost(route, command.command_id, body); + expect(res.status).toBe(400); + expect(await errorCode(res)).toBe("result_invalid"); + } + ); +}); + +describe("POST …/prepared", () => { + it("正しい本体 → 204・ブラウザに prepared", async () => { + const { created, command } = await pendingExport(); + const res = await commandPost( + "prepared", + command.command_id, + JSON.stringify({ total_bytes: 4, sha256: "a".repeat(64), file_count: 1 }) + ); + expect(res.status).toBe(204); + await expect(created).resolves.toMatchObject({ kind: "prepared", totalBytes: 4 }); + }); + + it.each<[string, unknown]>([ + ["sha256 が大文字", { total_bytes: 4, sha256: "A".repeat(64), file_count: 1 }], + ["file_count が0", { total_bytes: 4, sha256: "a".repeat(64), file_count: 0 }], + ["total_bytes が無い", { sha256: "a".repeat(64), file_count: 1 }], + ])("%s → 400 result_invalid", async (_label, body) => { + const { command } = await pendingExport(); + expect((await commandPost("prepared", command.command_id, JSON.stringify(body))).status).toBe(400); + }); + + it("R36 list の指示に prepared → 404", async () => { + const { command } = await pendingList(); + const res = await commandPost( + "prepared", + command.command_id, + JSON.stringify({ total_bytes: 4, sha256: "a".repeat(64), file_count: 1 }) + ); + expect(res.status).toBe(404); + }); +}); + +describe("パスパラメータ(段階3-3 X2・X5・X6)", () => { + it("X2 command_id が長すぎる → 422", async () => { + const res = await commandPost("fail", "c".repeat(PATH_COMMAND_ID_MAX_LENGTH + 1), "{}"); + expect(res.status).toBe(422); + }); + + it.each(["-1", "01", "1.0", "abc", "1e3", "1234567890"])("X5・X6 seq=%s → 422", async (seq) => { + const res = await chunkPost("x", seq, new Uint8Array([1])); + expect(res.status).toBe(422); + }); +}); + +describe("POST …/chunks/{seq}", () => { + async function preparedExport(payload: Uint8Array) { + const { created, command } = await pendingExport(); + fake.relay.submitPrepared("DS-002", command.command_id, { total_bytes: payload.length, sha256: sha(payload), file_count: 1 }); + const prepared = await created; + if (prepared.kind !== "prepared") throw new Error("prepared にならない"); + return { exportCommandId: command.command_id, exportId: prepared.exportId }; + } + + async function sending(payload: Uint8Array) { + const p = await preparedExport(payload); + const poll = fake.relay.poll("DS-002", "1.0.0", 25, signal()); + const claim = fake.relay.claimExport("DS-002", p.exportId, signal()); + await poll; + return { ...p, claim }; + } + + const payload = new Uint8Array(20).fill(5); + + it("X-Chunk-Sha256 が無い・形が違う → 400 chunk_invalid", async () => { + const { exportCommandId } = await sending(payload); + expect((await chunkPost(exportCommandId, "0", payload.subarray(0, 16), { sha: null })).status).toBe(400); + expect((await chunkPost(exportCommandId, "0", payload.subarray(0, 16), { sha: "XYZ" })).status).toBe(400); + }); + + it("R20 send の前のチャンク → 409 chunk_out_of_order", async () => { + const { exportCommandId } = await preparedExport(payload); + const res = await chunkPost(exportCommandId, "0", payload.subarray(0, 16)); + expect(res.status).toBe(409); + expect(await errorCode(res)).toBe("chunk_out_of_order"); + }); + + it("R49 保留中に次のチャンク → 409・先の POST は 410 export_cancelled", async () => { + const { exportCommandId, claim } = await sending(payload); + const first = chunkPost(exportCommandId, "0", payload.subarray(0, 16)); + await flush(); + const claimed = await claim; + if (claimed.kind !== "stream") throw new Error("stream にならない"); + // ★読まないまま次のチャンクを送る★ + const second = await chunkPost(exportCommandId, "1", payload.subarray(16)); + expect(second.status).toBe(409); + expect(await errorCode(second)).toBe("chunk_out_of_order"); + const firstDone = await first; + expect(firstDone.status).toBe(410); + expect(await errorCode(firstDone)).toBe("export_cancelled"); + expect(fake.events.some((e) => e.event === "ds_logs.export_failed" && e.reason === "chunk_out_of_order")).toBe( + true + ); + await fake.at(fake.limits.exportIdleTimeoutSec + 1); + expect(fake.events.some((e) => e.event === "ds_logs.export_failed" && e.reason === "flow_timeout")).toBe(false); + }); + + it("R23 ハッシュ不一致 → 400 chunk_invalid", async () => { + const { exportCommandId, claim } = await sending(payload); + // ★わざと違うハッシュ(全部 0)★ + const res = await chunkPost(exportCommandId, "0", payload.subarray(0, 16), { sha: "0".repeat(64) }); + expect(res.status).toBe(400); + expect(await errorCode(res)).toBe("chunk_invalid"); + await expect(claim).resolves.toEqual({ kind: "agent_error", code: "chunk_invalid" }); + }); + + it("R24 chunkMaxBytes + 1 → 400", async () => { + const big = new Uint8Array(17).fill(5); + const { exportCommandId } = await sending(big); + expect((await chunkPost(exportCommandId, "0", big)).status).toBe(400); + }); + + it("取り消し・失敗の後のチャンク → 410 export_cancelled", async () => { + const { exportCommandId, claim } = await sending(payload); + await fake.at(fake.limits.exportFirstChunkTimeoutSec + 0.001); + await expect(claim).resolves.toEqual({ kind: "timeout" }); + const res = await chunkPost(exportCommandId, "0", payload.subarray(0, 16)); + expect(res.status).toBe(410); + expect(await errorCode(res)).toBe("export_cancelled"); + }); + + it("R35 DS-003 のトークンで DS-002 の指示にチャンク → 404", async () => { + const { exportCommandId } = await sending(payload); + expect((await chunkPost(exportCommandId, "0", payload.subarray(0, 16), { token: token003 })).status).toBe(404); + }); + + it("R42 send の指示の command_id にチャンク → 404", async () => { + const p = await preparedExport(payload); + const poll = fake.relay.poll("DS-002", "1.0.0", 25, signal()); + void fake.relay.claimExport("DS-002", p.exportId, signal()); + const send = (await poll) as DsAgentCommand; + expect((await chunkPost(send.command_id, "0", payload.subarray(0, 16))).status).toBe(404); + }); +}); + +describe("POST …/complete と …/fail", () => { + it("complete: 流れていないのに complete → 400 result_invalid(照合が通らない)", async () => { + const { command } = await pendingExport(); + fake.relay.submitPrepared("DS-002", command.command_id, { total_bytes: 4, sha256: "a".repeat(64), file_count: 1 }); + const res = await commandPost("complete", command.command_id, JSON.stringify({ total_bytes: 4, sha256: "a".repeat(64) })); + expect(res.status).toBe(400); + expect(await errorCode(res)).toBe("result_invalid"); + }); + + it("complete: 終わった後 → 410 export_cancelled", async () => { + const { command } = await pendingExport(); + fake.relay.failCommand("DS-002", command.command_id, "io_error", "m"); + const res = await commandPost("complete", command.command_id, JSON.stringify({ total_bytes: 4, sha256: "a".repeat(64) })); + expect(res.status).toBe(410); + }); + + it("fail: 準備中 → 204・ブラウザに agent_error(制御盤の code)", async () => { + const { created, command } = await pendingExport(); + const res = await commandPost("fail", command.command_id, JSON.stringify({ code: "too_large", message: "大きすぎます" })); + expect(res.status).toBe(204); + await expect(created).resolves.toEqual({ kind: "agent_error", code: "too_large" }); + }); + + it("fail: 形の誤り(code が65文字)→ 400 result_invalid", async () => { + const { command } = await pendingExport(); + const res = await commandPost("fail", command.command_id, JSON.stringify({ code: "c".repeat(65), message: "m" })); + expect(res.status).toBe(400); + }); + + it("fail: 失効の後 → 410", async () => { + const { command } = await pendingExport(); + fake.relay.failCommand("DS-002", command.command_id, "io_error", "m"); + const res = await commandPost("fail", command.command_id, JSON.stringify({ code: "io_error", message: "m" })); + expect(res.status).toBe(410); + }); + + it("無い指示 → 404", async () => { + const res = await commandPost("fail", "no-such", JSON.stringify({ code: "io_error", message: "m" })); + expect(res.status).toBe(404); + }); +}); diff --git "a/app/api/ds-agent/commands/\133command_id\135/chunks/\133seq\135/route.ts" "b/app/api/ds-agent/commands/\133command_id\135/chunks/\133seq\135/route.ts" new file mode 100644 index 0000000..cc6c109 --- /dev/null +++ "b/app/api/ds-agent/commands/\133command_id\135/chunks/\133seq\135/route.ts" @@ -0,0 +1,50 @@ +/** + * POST /ds-agent/commands/{command_id}/chunks/{seq} — zip のチャンク(指示書AY 段階7-3。D1・D20・D25 ①・D-179) + * + * 仕様の正は `d-watch-api.yaml` の同パス。★URL の command_id は export の指示の id★。 + * ★1チャンク 4MB 以下★(proxy が本体を10MBまでしかバッファせず、超えた分を黙って切り捨てるため)。長さとハッシュを必ず検証する。 + * ★204 はブラウザ側に空きができてから(流量制御。最大45秒)★。チャンクはメモリで中継するだけで、サーバーには保存しない。 + */ +import { NextResponse } from "next/server"; +import { agentCommandPreamble, commandNotFound, jsonError, noContent } from "@/lib/ds-agent/agent-request"; +import { getRelay } from "@/lib/ds-agent/relay"; +import { ensureDsAgentLogging } from "@/lib/ds-agent/relay-log"; +import { parsePathSeq } from "@/lib/ds-logs/validate"; +import { withRequestId } from "@/lib/logger"; + +const SHA256_HEX = /^[0-9a-f]{64}$/; + +export async function POST( + request: Request, + { params }: { params: Promise<{ command_id: string; seq: string }> } +) { + return withRequestId(request, async () => { + ensureDsAgentLogging(); + const { command_id, seq: seqRaw } = await params; + const pre = await agentCommandPreamble(request, command_id); + if (!pre.ok) return pre.response; + + const seq = parsePathSeq(seqRaw); + if (!seq.ok) return NextResponse.json(seq.error, { status: seq.status }); + + const sha256 = request.headers.get("x-chunk-sha256"); + if (sha256 === null || !SHA256_HEX.test(sha256)) { + return jsonError(400, "chunk_invalid", "X-Chunk-Sha256 の形式が不正です"); + } + + const bytes = new Uint8Array(await request.arrayBuffer()); + switch (await getRelay().pushChunk(pre.serial, pre.commandId, seq.query, bytes, sha256)) { + case "ok": + return noContent(); + case "invalid": + return jsonError(400, "chunk_invalid", "チャンクの長さかハッシュが不正です"); + case "out_of_order": + return jsonError(409, "chunk_out_of_order", "チャンクの順番が正しくありません"); + case "cancelled": + case "gone": + return jsonError(410, "export_cancelled", "エクスポートは取り消されました"); + case "not_found": + return commandNotFound(); + } + }); +} diff --git "a/app/api/ds-agent/commands/\133command_id\135/complete/route.ts" "b/app/api/ds-agent/commands/\133command_id\135/complete/route.ts" new file mode 100644 index 0000000..293fd46 --- /dev/null +++ "b/app/api/ds-agent/commands/\133command_id\135/complete/route.ts" @@ -0,0 +1,34 @@ +/** + * POST /ds-agent/commands/{command_id}/complete — zip の送信完了(指示書AY 段階7-3。D15・D25 ①・D-179) + * + * 仕様の正は `d-watch-api.yaml` の同パス。★URL の command_id は export の指示の id★。 + * 受け取った合計・SHA-256 を prepared と照合し、違えば 400 `result_invalid`(ブラウザ側のダウンロードは失敗になる)。 + */ +import { NextResponse } from "next/server"; +import { agentCommandPreamble, commandNotFound, jsonError, noContent } from "@/lib/ds-agent/agent-request"; +import { getRelay } from "@/lib/ds-agent/relay"; +import { ensureDsAgentLogging } from "@/lib/ds-agent/relay-log"; +import { parseAgentComplete } from "@/lib/ds-logs/validate"; +import { withRequestId } from "@/lib/logger"; + +export async function POST(request: Request, { params }: { params: Promise<{ command_id: string }> }) { + return withRequestId(request, async () => { + ensureDsAgentLogging(); + const pre = await agentCommandPreamble(request, (await params).command_id); + if (!pre.ok) return pre.response; + + const parsed = parseAgentComplete(await request.text()); + if (!parsed.ok) return NextResponse.json(parsed.error, { status: parsed.status }); + + switch (getRelay().completeExport(pre.serial, pre.commandId, parsed.query.total_bytes, parsed.query.sha256)) { + case "ok": + return noContent(); + case "invalid": + return jsonError(400, "result_invalid", "合計の大きさか SHA-256 が一致しません"); + case "cancelled": + return jsonError(410, "export_cancelled", "エクスポートは取り消されました"); + case "not_found": + return commandNotFound(); + } + }); +} diff --git "a/app/api/ds-agent/commands/\133command_id\135/fail/route.ts" "b/app/api/ds-agent/commands/\133command_id\135/fail/route.ts" new file mode 100644 index 0000000..31fde22 --- /dev/null +++ "b/app/api/ds-agent/commands/\133command_id\135/fail/route.ts" @@ -0,0 +1,32 @@ +/** + * POST /ds-agent/commands/{command_id}/fail — 指示の失敗の報告(指示書AY 段階7-3。D14・D25 ①②・D-179) + * + * 仕様の正は `d-watch-api.yaml` の同パス。送信中の失敗も export の指示の id へ。zip が無いときは `export_not_found`。 + * ★`message` はログにも画面にも出さない★。★形の誤りは 400 `result_invalid`。状態は変えない★(R43・R44)。 + */ +import { NextResponse } from "next/server"; +import { agentCommandPreamble, commandNotFound, jsonError, noContent } from "@/lib/ds-agent/agent-request"; +import { getRelay } from "@/lib/ds-agent/relay"; +import { ensureDsAgentLogging } from "@/lib/ds-agent/relay-log"; +import { parseAgentFail } from "@/lib/ds-logs/validate"; +import { withRequestId } from "@/lib/logger"; + +export async function POST(request: Request, { params }: { params: Promise<{ command_id: string }> }) { + return withRequestId(request, async () => { + ensureDsAgentLogging(); + const pre = await agentCommandPreamble(request, (await params).command_id); + if (!pre.ok) return pre.response; + + const parsed = parseAgentFail(await request.text()); + if (!parsed.ok) return NextResponse.json(parsed.error, { status: parsed.status }); + + switch (getRelay().failCommand(pre.serial, pre.commandId, parsed.query.code, parsed.query.message)) { + case "ok": + return noContent(); + case "gone": + return jsonError(410, "export_cancelled", "エクスポートは取り消されました"); + case "not_found": + return commandNotFound(); + } + }); +} diff --git "a/app/api/ds-agent/commands/\133command_id\135/prepared/route.ts" "b/app/api/ds-agent/commands/\133command_id\135/prepared/route.ts" new file mode 100644 index 0000000..237e0be --- /dev/null +++ "b/app/api/ds-agent/commands/\133command_id\135/prepared/route.ts" @@ -0,0 +1,32 @@ +/** + * POST /ds-agent/commands/{command_id}/prepared — エクスポートの準備完了(指示書AY 段階7-3。D14・D15・D-179) + * + * 仕様の正は `d-watch-api.yaml` の同パス。`total_bytes` が上限を超えていてもこの応答は 204(エクスポート側を失敗にする。R13)。 + * ★形の誤りは 400 `result_invalid`。状態は変えない★(R43・R44)。 + */ +import { NextResponse } from "next/server"; +import { agentCommandPreamble, commandNotFound, jsonError, noContent } from "@/lib/ds-agent/agent-request"; +import { getRelay } from "@/lib/ds-agent/relay"; +import { ensureDsAgentLogging } from "@/lib/ds-agent/relay-log"; +import { parseAgentPrepared } from "@/lib/ds-logs/validate"; +import { withRequestId } from "@/lib/logger"; + +export async function POST(request: Request, { params }: { params: Promise<{ command_id: string }> }) { + return withRequestId(request, async () => { + ensureDsAgentLogging(); + const pre = await agentCommandPreamble(request, (await params).command_id); + if (!pre.ok) return pre.response; + + const parsed = parseAgentPrepared(await request.text()); + if (!parsed.ok) return NextResponse.json(parsed.error, { status: parsed.status }); + + switch (getRelay().submitPrepared(pre.serial, pre.commandId, parsed.query)) { + case "ok": + return noContent(); + case "gone": + return jsonError(410, "command_expired", "指示の期限が切れています"); + case "not_found": + return commandNotFound(); + } + }); +} diff --git "a/app/api/ds-agent/commands/\133command_id\135/result/route.ts" "b/app/api/ds-agent/commands/\133command_id\135/result/route.ts" new file mode 100644 index 0000000..99a29da --- /dev/null +++ "b/app/api/ds-agent/commands/\133command_id\135/result/route.ts" @@ -0,0 +1,36 @@ +/** + * POST /ds-agent/commands/{command_id}/result — 一覧の結果(指示書AY 段階7-3。D-179) + * + * 仕様の正は `d-watch-api.yaml` の同パス。本体は4MB以下(★読んだ長さで判定。W8★)・2万件以下。 + * ★形の誤りは制御盤へ 400 `result_invalid`。同時にブラウザの一覧を 502(`agent_code` は `result_invalid`)で終わらせ、指示は消える★(指示書BJ J2)。 + */ +import { NextResponse } from "next/server"; +import { agentCommandPreamble, commandNotFound, jsonError, noContent } from "@/lib/ds-agent/agent-request"; +import { getRelay } from "@/lib/ds-agent/relay"; +import { ensureDsAgentLogging } from "@/lib/ds-agent/relay-log"; +import { parseAgentListResult } from "@/lib/ds-logs/validate"; +import { withRequestId } from "@/lib/logger"; + +export async function POST(request: Request, { params }: { params: Promise<{ command_id: string }> }) { + return withRequestId(request, async () => { + ensureDsAgentLogging(); + const pre = await agentCommandPreamble(request, (await params).command_id); + if (!pre.ok) return pre.response; + + const parsed = parseAgentListResult(await request.text()); + if (!parsed.ok) { + // ★戻り値によらず制御盤には 400(指示書BJ 罠2)★ + getRelay().rejectListResult(pre.serial, pre.commandId); + return NextResponse.json(parsed.error, { status: parsed.status }); + } + + switch (getRelay().submitListResult(pre.serial, pre.commandId, parsed.query)) { + case "ok": + return noContent(); + case "gone": + return jsonError(410, "command_expired", "指示の期限が切れています"); + case "not_found": + return commandNotFound(); + } + }); +} diff --git a/app/api/ds-agent/commands/route.ts b/app/api/ds-agent/commands/route.ts new file mode 100644 index 0000000..3b6b4b5 --- /dev/null +++ b/app/api/ds-agent/commands/route.ts @@ -0,0 +1,27 @@ +/** + * GET /ds-agent/commands — 制御盤が指示を取りに来る(ロングポーリング。指示書AY 段階7-3。D2・D-179) + * + * 仕様の正は `d-watch-api.yaml` の /ds-agent/commands。認証は制御盤の Bearer だけ(`requireAgent()` で再検証)。 + * 指示があればすぐ 200、無ければ `wait` 秒で 204。★DB を読まない★(`USE_FIXTURES` に関係なく同じ動き)。 + */ +import { NextResponse } from "next/server"; +import { noContent } from "@/lib/ds-agent/agent-request"; +import { getRelay } from "@/lib/ds-agent/relay"; +import { ensureDsAgentLogging } from "@/lib/ds-agent/relay-log"; +import { agentVersionOf, requireAgent } from "@/lib/ds-agent/require-agent"; +import { parseAgentPollWait } from "@/lib/ds-logs/validate"; +import { withRequestId } from "@/lib/logger"; + +export async function GET(request: Request) { + return withRequestId(request, async () => { + ensureDsAgentLogging(); + const auth = await requireAgent(request); + if (!auth.ok) return auth.response; + + const wait = parseAgentPollWait(new URL(request.url).searchParams.get("wait")); + if (!wait.ok) return NextResponse.json(wait.error, { status: wait.status }); + + const command = await getRelay().poll(auth.serial, agentVersionOf(request), wait.query, request.signal); + return command ? NextResponse.json(command) : noContent(); + }); +} diff --git "a/app/api/ds-devices/\133serial_number\135/agent-token/__tests__/route.test.ts" "b/app/api/ds-devices/\133serial_number\135/agent-token/__tests__/route.test.ts" new file mode 100644 index 0000000..7a408fe --- /dev/null +++ "b/app/api/ds-devices/\133serial_number\135/agent-token/__tests__/route.test.ts" @@ -0,0 +1,157 @@ +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// ★ルート本体は静的 import★ +import { DELETE, GET, POST } from "@/app/api/ds-devices/[serial_number]/agent-token/route"; +import { PATH_SERIAL_NUMBER_MAX_LENGTH } from "@/lib/config"; +import { db } from "@/lib/db"; +import { + __setAgentTokenStoreForTesting, + createAgentTokenStore, + verifyAgentAuthorization, +} from "@/lib/ds-agent/tokens"; +import { logEvent } from "@/lib/logger"; +import type { DsAgentTokenIssued } from "@/lib/types"; + +/** + * 指示書AY §3 表3 KA1〜KA8(`app/api/ds-devices/[serial_number]/agent-token/route.ts`)。 + * ブリーフ §5 と監督判断 第2ラウンドから本書で具体化。★P1・P2 の行は §11 Q14・Q15 の採用で確定★(KA4・KA7)。 + * + * | KA1 | POST /ds-devices/DS-002/agent-token(cookie) | 201 { token, issued_at }。token は K1 の形。ストアには SHA-256 だけ | + * | KA2 | もう一度 POST | 201・新しい値。1回目の値で verifyAgentAuthorization が null | + * | KA3 | DELETE /ds-devices/DS-002/agent-token | 204。以後 K2 は null | + * | KA4 | 未発行の DELETE | 204(冪等。P2) | + * | KA5 | POST /ds-devices/DS-999/agent-token(制御盤に無い)| 404 not_found。ストアは変わらない | + * | KA6 | パスのシリアルが101文字 | 422 validation_failed | + * | KA7 | GET /ds-devices/DS-002/agent-token | 発行後 { issued_at: "…+09:00" }/未発行 { issued_at: null }。token / token_sha256 が無い(P1) | + * | KA8 | KA1〜KA3 のログ | ds_agent.token_issued / token_revoked が serial_number だけで1行ずつ。値に K1 の文字列が無い | + * + * ★ストアは mkdtemp(ストアとパスを一緒に差し替える。M13)★・`db.dsDeviceExists` をスパイ・ログは `logEvent` をスパイ。 + */ + +vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false })); +vi.mock("@/lib/logger", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, logEvent: vi.fn() }; +}); +vi.mock("@/lib/logger/api", () => ({ logApiError: vi.fn() })); + +let dir = ""; +let filePath = ""; + +beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), "dwatch-agent-token-route-")); + filePath = path.join(dir, "ds-agent-tokens.json"); + __setAgentTokenStoreForTesting(createAgentTokenStore(filePath)); + vi.spyOn(db, "dsDeviceExists").mockImplementation(async (serial) => serial === "DS-002" || serial === "DS-003"); + vi.mocked(logEvent).mockClear(); +}); + +afterEach(async () => { + __setAgentTokenStoreForTesting(null); + vi.restoreAllMocks(); + await fs.rm(dir, { recursive: true, force: true }); +}); + +const ctx = (serial: string) => ({ params: Promise.resolve({ serial_number: serial }) }); +const req = (serial: string, method: string) => + new Request(`http://localhost/api/ds-devices/${encodeURIComponent(serial)}/agent-token`, { method }); + +async function issue(serial = "DS-002"): Promise { + const res = await POST(req(serial, "POST"), ctx(serial)); + expect(res.status).toBe(201); + return (await res.json()) as DsAgentTokenIssued; +} + +async function storeFileText(): Promise { + return fs.readFile(filePath, "utf-8").catch(() => ""); +} + +describe("制御盤トークンの API — 表3 KA1〜KA8", () => { + it("KA1 POST → 201 { token, issued_at }・ストアには SHA-256 だけ・Cache-Control: no-store", async () => { + const res = await POST(req("DS-002", "POST"), ctx("DS-002")); + expect(res.status).toBe(201); + expect(res.headers.get("cache-control")).toBe("no-store"); + const body = (await res.json()) as DsAgentTokenIssued; + expect(Object.keys(body).sort()).toEqual(["issued_at", "token"]); + expect(body.token).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(body.issued_at).toMatch(/\+09:00$/); + const raw = await storeFileText(); + expect(raw).not.toContain(body.token); + expect(JSON.parse(raw)["DS-002"].token_sha256).toMatch(/^[0-9a-f]{64}$/); + await expect(verifyAgentAuthorization(`Bearer ${body.token}`)).resolves.toBe("DS-002"); + }); + + it("KA2 もう一度 POST → 新しい値。1回目の値は通らない", async () => { + const first = await issue(); + const second = await issue(); + expect(second.token).not.toBe(first.token); + await expect(verifyAgentAuthorization(`Bearer ${first.token}`)).resolves.toBeNull(); + await expect(verifyAgentAuthorization(`Bearer ${second.token}`)).resolves.toBe("DS-002"); + }); + + it("KA3 DELETE → 204。以後は通らない", async () => { + const { token } = await issue(); + const res = await DELETE(req("DS-002", "DELETE"), ctx("DS-002")); + expect(res.status).toBe(204); + expect(await res.text()).toBe(""); + await expect(verifyAgentAuthorization(`Bearer ${token}`)).resolves.toBeNull(); + }); + + it("KA4 未発行の DELETE → 204(冪等。P2)", async () => { + const res = await DELETE(req("DS-002", "DELETE"), ctx("DS-002")); + expect(res.status).toBe(204); + }); + + it("KA5 制御盤に無いシリアル → 404 not_found・ストアは変わらない", async () => { + const res = await POST(req("DS-999", "POST"), ctx("DS-999")); + expect(res.status).toBe(404); + expect(((await res.json()) as { error: { code: string } }).error.code).toBe("not_found"); + expect(await storeFileText()).toBe(""); + }); + + it.each([ + ["GET", GET], + ["POST", POST], + ["DELETE", DELETE], + ] as const)("KA6 %s: パスのシリアルが101文字 → 422 validation_failed", async (method, handler) => { + const serial = "D".repeat(PATH_SERIAL_NUMBER_MAX_LENGTH + 1); + const res = await handler(req(serial, method), ctx(serial)); + expect(res.status).toBe(422); + expect(((await res.json()) as { error: { code: string } }).error.code).toBe("validation_failed"); + }); + + it("KA7 GET: 未発行は { issued_at: null }、発行後は { issued_at: …+09:00 }。token / token_sha256 を返さない", async () => { + const before = await GET(req("DS-002", "GET"), ctx("DS-002")); + expect(before.status).toBe(200); + expect(await before.json()).toEqual({ issued_at: null }); + + const { token, issued_at } = await issue(); + const after = await GET(req("DS-002", "GET"), ctx("DS-002")); + const text = await after.text(); + expect(JSON.parse(text)).toEqual({ issued_at }); + expect(text).not.toContain(token); + expect(text).not.toContain("token"); + }); + + it("KA8 KA1〜KA3 のログ: token_issued / token_revoked が serial_number だけで1行ずつ・トークンの値を含まない", async () => { + const first = await issue(); + const second = await issue(); + await DELETE(req("DS-002", "DELETE"), ctx("DS-002")); + + const calls = vi.mocked(logEvent).mock.calls; + const payloads = calls.map(([, payload]) => payload as unknown as Record); + const issued = payloads.filter((p) => p.event === "ds_agent.token_issued"); + const revoked = payloads.filter((p) => p.event === "ds_agent.token_revoked"); + expect(issued).toEqual([ + { event: "ds_agent.token_issued", serial_number: "DS-002" }, + { event: "ds_agent.token_issued", serial_number: "DS-002" }, + ]); + expect(revoked).toEqual([{ event: "ds_agent.token_revoked", serial_number: "DS-002" }]); + const logged = JSON.stringify(calls); + expect(logged).not.toContain(first.token); + expect(logged).not.toContain(second.token); + }); +}); diff --git "a/app/api/ds-devices/\133serial_number\135/agent-token/route.ts" "b/app/api/ds-devices/\133serial_number\135/agent-token/route.ts" new file mode 100644 index 0000000..4e9839a --- /dev/null +++ "b/app/api/ds-devices/\133serial_number\135/agent-token/route.ts" @@ -0,0 +1,70 @@ +/** + * GET / POST / DELETE /ds-devices/{serial_number}/agent-token — 制御盤トークンの発行日時・発行・失効 + * (指示書AY 段階7-1。D16・P1・P2・D-179) + * + * 仕様の正は `d-watch-api.yaml` の同パス。★既存の cookie 認証のパス★(制御盤のトークンでは作り直せない。表4 A11)。 + * ★生のトークンは POST の応答で1回だけ★(`Cache-Control: no-store`)。保存は SHA-256 だけ(DFA 独自ストア。メインDBには書かない)。 + * ★ログは `serial_number` だけ★(表3 KA8)。 + */ +import { NextResponse } from "next/server"; +import { parsePathSerialNumber } from "@/lib/api/path-params"; +import { db } from "@/lib/db"; +import { errorBody, jsonError, noContent } from "@/lib/ds-agent/agent-request"; +import { ensureDsAgentLogging } from "@/lib/ds-agent/relay-log"; +import { agentTokenIssuedAt, issueAgentToken, revokeAgentToken } from "@/lib/ds-agent/tokens"; +import { logEvent, withRequestId } from "@/lib/logger"; +import { logApiError } from "@/lib/logger/api"; +import type { DsAgentTokenIssued, DsAgentTokenStatus } from "@/lib/types"; + +const ROUTE = "/api/ds-devices/[serial_number]/agent-token"; + +type Method = "GET" | "POST" | "DELETE"; + +async function withDevice( + method: Method, + request: Request, + params: Promise<{ serial_number: string }>, + action: (serial: string) => Promise +): Promise { + return withRequestId(request, async () => { + ensureDsAgentLogging(); + const { serial_number: serialRaw } = await params; + const parsedSerial = parsePathSerialNumber(serialRaw); + if (!parsedSerial.ok) return NextResponse.json(parsedSerial.error, { status: parsedSerial.status }); + const serial = parsedSerial.query; + try { + if (!(await db.dsDeviceExists(serial))) return jsonError(404, "not_found", "デバイスが見つかりません"); + return await action(serial); + } catch (e) { + const body = errorBody("internal_error", "制御盤トークンの処理に失敗しました"); + logApiError({ route: ROUTE, method, body, status: 500, err: e }); + return NextResponse.json(body, { status: 500 }); + } + }); +} + +export async function GET(request: Request, { params }: { params: Promise<{ serial_number: string }> }) { + return withDevice("GET", request, params, async (serial) => { + const body: DsAgentTokenStatus = { issued_at: await agentTokenIssuedAt(serial) }; + return NextResponse.json(body); + }); +} + +export async function POST(request: Request, { params }: { params: Promise<{ serial_number: string }> }) { + return withDevice("POST", request, params, async (serial) => { + const issued = await issueAgentToken(serial); + logEvent("info", { event: "ds_agent.token_issued", serial_number: serial }); + const body: DsAgentTokenIssued = { token: issued.token, issued_at: issued.issued_at }; + return NextResponse.json(body, { status: 201, headers: { "Cache-Control": "no-store" } }); + }); +} + +export async function DELETE(request: Request, { params }: { params: Promise<{ serial_number: string }> }) { + return withDevice("DELETE", request, params, async (serial) => { + // ★未発行でも 204(冪等。P2)★。ログは実際に失効させたときだけ + if (await revokeAgentToken(serial)) { + logEvent("info", { event: "ds_agent.token_revoked", serial_number: serial }); + } + return noContent(); + }); +} diff --git "a/app/api/ds-devices/\133serial_number\135/logs/__tests__/route.fixtures.test.ts" "b/app/api/ds-devices/\133serial_number\135/logs/__tests__/route.fixtures.test.ts" new file mode 100644 index 0000000..8f523ea --- /dev/null +++ "b/app/api/ds-devices/\133serial_number\135/logs/__tests__/route.fixtures.test.ts" @@ -0,0 +1,138 @@ +import { createHash } from "node:crypto"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// ★ルート本体は静的 import★(`note/__tests__/route.test.ts` の冒頭コメント) +import { GET as getLogs } from "@/app/api/ds-devices/[serial_number]/logs/route"; +import { POST as postExport } from "@/app/api/ds-devices/[serial_number]/logs/exports/route"; +import { GET as getExport } from "@/app/api/ds-devices/[serial_number]/logs/exports/[export_id]/route"; +import { __setRelayForTesting } from "@/lib/ds-agent/relay"; +import { FIXTURE_EXPORT_ZIP_BASE64, fixtureDeviceLogTypes } from "@/lib/fixtures/ds-device-logs"; +import type { DsLogExportResponse, DsLogListResponse } from "@/lib/types"; + +/** + * 指示書AY 段階7-4 — ★fixtures の経路★(`USE_FIXTURES=true`)。偽制御盤(`ensureFixtureAgent()`)が relay の同じ経路を通る(D10)。 + * - `DS-002` の一覧が200(接続中・種類4つ(キーで比べる。表示名は仮なので期待値に埋め込まない)・`truncated` false)・絞り込みが効く + * - ★`DS-001` は 409(未接続)★/`DS-999` は 404 + * - ★POST → 201(`total_bytes` が fixtures の zip の長さ)→ GET で zip のバイト列が一致・ヘッダ5つ★/★2回目の GET は 404★ + * + * ★Prisma 経路は同ディレクトリの `route.test.ts`★(`db` はモジュール読み込み時に束縛されるので別ファイル)。 + */ + +vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: true })); +vi.mock("@/lib/logger", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, logEvent: vi.fn() }; +}); + +beforeEach(() => { + // ★テストごとに新しい relay★(前のテストの偽制御盤は relay が変わったことに気づいて止まる) + __setRelayForTesting(null); +}); + +afterEach(() => { + __setRelayForTesting(null); +}); + +const serialParams = (serial: string) => ({ params: Promise.resolve({ serial_number: serial }) }); + +function logsRequest(serial: string, qs = ""): Promise { + return getLogs( + new Request(`http://localhost/api/ds-devices/${encodeURIComponent(serial)}/logs${qs ? `?${qs}` : ""}`), + serialParams(serial) + ); +} + +function exportRequest(serial: string, fileIds: string[]): Promise { + return postExport( + new Request(`http://localhost/api/ds-devices/${encodeURIComponent(serial)}/logs/exports`, { + method: "POST", + body: JSON.stringify({ file_ids: fileIds }), + }), + serialParams(serial) + ); +} + +function downloadRequest(serial: string, downloadUrl: string): Promise { + const exportId = downloadUrl.split("/").pop()!; + return getExport(new Request(`http://localhost${downloadUrl}`), { + params: Promise.resolve({ serial_number: serial, export_id: exportId }), + }); +} + +describe("GET /ds-devices/{serial_number}/logs(fixtures)", () => { + it("DS-002: 200・接続中・種類4つ(キー)・truncated false", async () => { + const res = await logsRequest("DS-002"); + expect(res.status).toBe(200); + const body = (await res.json()) as DsLogListResponse; + expect(body.agent.connected).toBe(true); + expect(body.agent.agent_version).toBe("fixture"); + expect(body.types.map((t) => t.key)).toEqual(fixtureDeviceLogTypes.map((t) => t.key)); + expect(body.truncated).toBe(false); + expect(body.meta.total).toBeGreaterThan(200); + expect(body.meta.per_page).toBe(200); + expect(body.items).toHaveLength(200); + // 名前は {root の name}/{相対パス}(M6) + expect(body.items[0].name).toMatch(/^[a-z_]+\/[a-z_]+\.log/); + }); + + it("DS-002: 種類の絞り込みが効く", async () => { + const key = fixtureDeviceLogTypes[1].key; + const res = await logsRequest("DS-002", `types=${key}&per_page=50`); + expect(res.status).toBe(200); + const body = (await res.json()) as DsLogListResponse; + expect(body.items.length).toBeGreaterThan(0); + expect(body.items.every((i) => i.type === key)).toBe(true); + expect(body.meta.per_page).toBe(50); + }); + + it("DS-001(未接続)→ 409 agent_offline", async () => { + const res = await logsRequest("DS-001"); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ + error: { code: "agent_offline", message: "制御盤が接続されていません", details: { last_seen_at: [] } }, + }); + }); + + it("DS-999(制御盤に無い)→ 404", async () => { + const res = await logsRequest("DS-999"); + expect(res.status).toBe(404); + }); +}); + +describe("エクスポート(fixtures)", () => { + it("POST → 201 → GET で fixtures の zip が届く・ヘッダ5つ・2回目の GET は 404", async () => { + const zip = Buffer.from(FIXTURE_EXPORT_ZIP_BASE64, "base64"); + const list = (await (await logsRequest("DS-002", "per_page=2")).json()) as DsLogListResponse; + const fileIds = list.items.map((i) => i.file_id); + + const created = await exportRequest("DS-002", fileIds); + expect(created.status).toBe(201); + const body = (await created.json()) as DsLogExportResponse; + expect(body.total_bytes).toBe(zip.length); + expect(body.file_count).toBe(fileIds.length); + expect(body.expires_in_sec).toBe(60); + expect(body.download_url).toBe(`/api/ds-devices/DS-002/logs/exports/${body.export_id}`); + + const res = await downloadRequest("DS-002", body.download_url); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toBe("application/zip"); + expect(res.headers.get("content-disposition")).toMatch( + /^attachment; filename="DS-002_logs_\d{8}-\d{6}\.zip"; filename\*=UTF-8''DS-002_logs_\d{8}-\d{6}\.zip$/ + ); + expect(res.headers.get("cache-control")).toBe("no-store"); + expect(res.headers.get("x-accel-buffering")).toBe("no"); + expect(res.headers.get("content-length")).toBe(String(zip.length)); + const received = Buffer.from(await res.arrayBuffer()); + expect(received.equals(zip)).toBe(true); + expect(createHash("sha256").update(received).digest("hex")).toBe(createHash("sha256").update(zip).digest("hex")); + + const again = await downloadRequest("DS-002", body.download_url); + expect(again.status).toBe(404); + expect(((await again.json()) as { error: { code: string } }).error.code).toBe("export_not_found"); + }); + + it("DS-001(未接続)の POST → 409", async () => { + const res = await exportRequest("DS-001", ["fx-1"]); + expect(res.status).toBe(409); + }); +}); diff --git "a/app/api/ds-devices/\133serial_number\135/logs/__tests__/route.test.ts" "b/app/api/ds-devices/\133serial_number\135/logs/__tests__/route.test.ts" new file mode 100644 index 0000000..3a71e64 --- /dev/null +++ "b/app/api/ds-devices/\133serial_number\135/logs/__tests__/route.test.ts" @@ -0,0 +1,322 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// ★ルート本体は静的 import★(`note/__tests__/route.test.ts` の冒頭コメント) +import { GET as getLogs } from "@/app/api/ds-devices/[serial_number]/logs/route"; +import { POST as postExport } from "@/app/api/ds-devices/[serial_number]/logs/exports/route"; +import { GET as getExport } from "@/app/api/ds-devices/[serial_number]/logs/exports/[export_id]/route"; +import { PATH_SERIAL_NUMBER_MAX_LENGTH } from "@/lib/config"; +import { + EXPORT_FILE_IDS_MAX_BYTES, + EXPORT_MAX_BYTES, + PATH_EXPORT_ID_MAX_LENGTH, + PENDING_COMMANDS_PER_DEVICE, +} from "@/lib/config/ds-agent"; +import { db } from "@/lib/db"; +import { createFakeRelay, flush } from "@/lib/ds-agent/__tests__/fake-relay"; +import { __setRelayForTesting } from "@/lib/ds-agent/relay"; +import { fileIdsOfJsonBytes } from "@/lib/ds-logs/__tests__/file-ids-of-bytes"; +import { EXPORT_FILE_IDS_TOO_LARGE_MESSAGE } from "@/lib/ds-logs/validate"; +import type { DsAgentCommand, DsLogExportResponse, DsLogListResponse } from "@/lib/types"; + +/** + * 指示書AY 段階7-4 — ★非 fixtures の経路★(`USE_FIXTURES=false`・`db.dsDeviceExists` をスパイ)。 + * relay は偽の時計(`createFakeRelay()`)に差し替え、制御盤の役はテストが relay を直接呼んで演じる。 + * - 表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)★ + */ + +vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false })); +vi.mock("@/lib/logger", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, logEvent: vi.fn() }; +}); +vi.mock("@/lib/logger/api", () => ({ logApiError: vi.fn() })); + +/** 表1 の入力(架空の名前のまま) */ +const LIST = { + types: [ + { key: "app", label: "アプリ" }, + { key: "comm", label: "通信" }, + { key: "sys", label: "システム" }, + ], + items: [ + { file_id: "f1", name: "app_20261019_1100.log", type: "app", size_bytes: 1000, modified_at: "2026-10-19T11:00:00+09:00" }, + { file_id: "f2", name: "app_20261019_0900.log", type: "app", size_bytes: 2000, modified_at: "2026-10-19T09:00:00+09:00" }, + { file_id: "f3", name: "comm_20261019_1100.log", type: "comm", size_bytes: 3000, modified_at: "2026-10-19T11:00:00+09:00" }, + { file_id: "f4", name: "comm_20261018_1200.log", type: "comm", size_bytes: 4000, modified_at: "2026-10-18T12:00:00+09:00" }, + { file_id: "f5", name: "sys_20261019_1130.log", type: "sys", size_bytes: 5000, modified_at: "2026-10-19T11:30:00+09:00" }, + { file_id: "f6", name: "sys_20261018_1159.log", type: "sys", size_bytes: 6000, modified_at: "2026-10-18T11:59:59+09:00" }, + { file_id: "f7", name: "app_20261019_0200.log", type: "app", size_bytes: 7000, modified_at: "2026-10-18T17:00:00Z" }, + { file_id: "f8", name: "app_20261019_1200.log", type: "app", size_bytes: 8000, modified_at: "2026-10-19T12:00:00+09:00" }, + ], + truncated: false, +}; + +let fake: ReturnType; +let exists: ReturnType; + +beforeEach(() => { + fake = createFakeRelay(); + __setRelayForTesting(fake.relay); + exists = vi.spyOn(db, "dsDeviceExists").mockResolvedValue(true); +}); + +afterEach(() => { + __setRelayForTesting(null); + vi.restoreAllMocks(); +}); + +const signal = () => new AbortController().signal; +const serialParams = (serial: string) => ({ params: Promise.resolve({ serial_number: serial }) }); + +function logsRequest(serial: string, qs = ""): Promise { + return getLogs( + new Request(`http://localhost/api/ds-devices/${encodeURIComponent(serial)}/logs${qs ? `?${qs}` : ""}`), + serialParams(serial) + ); +} + +function exportRequest(serial: string, raw: string): Promise { + return postExport( + new Request(`http://localhost/api/ds-devices/${encodeURIComponent(serial)}/logs/exports`, { method: "POST", body: raw }), + serialParams(serial) + ); +} + +function downloadRequest(serial: string, exportId: string): Promise { + return getExport(new Request(`http://localhost/api/ds-devices/${serial}/logs/exports/${exportId}`), { + params: Promise.resolve({ serial_number: serial, export_id: exportId }), + }); +} + +/** 制御盤の役: 一覧の依頼を受けて結果を返す */ +async function listVia(qs: string, result: Parameters[2] = LIST): Promise { + const poll = fake.relay.poll("DS-002", "1.0.0", 25, signal()); + const res = logsRequest("DS-002", qs); + const command = (await poll) as DsAgentCommand; + expect(command.kind).toBe("list"); + fake.relay.submitListResult("DS-002", command.command_id, result); + return res; +} + +async function errorCode(res: Response): Promise { + return ((await res.json()) as { error: { code: string } }).error.code; +} + +describe("GET /ds-devices/{serial_number}/logs(非 fixtures)", () => { + it("200: 表1 の絞り込み(types=app,sys)がルートを通って効く・agent・truncated・meta", async () => { + const res = await listVia("types=app,sys"); + expect(res.status).toBe(200); + const body = (await res.json()) as DsLogListResponse; + expect(body.items.map((i) => i.file_id)).toEqual(["f8", "f5", "f1", "f2", "f7", "f6"]); + expect(body.types).toEqual(LIST.types); + expect(body.truncated).toBe(false); + expect(body.agent).toMatchObject({ connected: true, agent_version: "1.0.0" }); + expect(body.meta).toEqual({ total: 6, page: 1, per_page: 200 }); + // 表1 Q17: 出力の modified_at は JST + expect(body.items.find((i) => i.file_id === "f7")?.modified_at).toBe("2026-10-19T02:00:00+09:00"); + }); + + it("truncated: true はそのまま返す(D18)", async () => { + const res = await listVia("", { ...LIST, truncated: true }); + expect(((await res.json()) as DsLogListResponse).truncated).toBe(true); + }); + + it("V4 空のパラメータは省略と同じ(types=・from=・to=・sort=)", async () => { + const res = await listVia("types=&from=&to=&sort="); + expect(res.status).toBe(200); + expect(((await res.json()) as DsLogListResponse).meta.total).toBe(8); + }); + + it.each<[string, { page: number; per_page: number }]>([ + ["per_page=500", { page: 1, per_page: 200 }], + ["per_page=abc", { page: 1, per_page: 200 }], + ["per_page=3&page=0", { page: 1, per_page: 3 }], + ])("V6 %s は丸めて meta に入れる", async (qs, expected) => { + const res = await listVia(qs); + expect(res.status).toBe(200); + expect(((await res.json()) as DsLogListResponse).meta).toMatchObject(expected); + }); + + it.each([ + ["V2b 存在しない日付", "from=2026-02-30T00:00:00%2B09:00"], + ["V2 オフセットなし", "from=2026-10-19T11:00:00"], + ["V5 sort=name", "sort=name"], + ])("%s → 422(DB を読まない)", async (_label, qs) => { + const res = await logsRequest("DS-002", qs); + expect(res.status).toBe(422); + expect(await errorCode(res)).toBe("validation_failed"); + expect(exists).not.toHaveBeenCalled(); + }); + + it("シリアルが101文字 → 422", async () => { + const res = await logsRequest("D".repeat(PATH_SERIAL_NUMBER_MAX_LENGTH + 1)); + expect(res.status).toBe(422); + }); + + it("一覧に出ない制御盤 → 404", async () => { + exists.mockResolvedValue(false); + const res = await logsRequest("DS-999"); + expect(res.status).toBe(404); + expect(await errorCode(res)).toBe("not_found"); + }); + + it("未接続 → 409 agent_offline(details.last_seen_at に最終接続)", async () => { + fake.relay.touch("DS-002", null); + await fake.at(61); + const res = await logsRequest("DS-002"); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ + error: { + code: "agent_offline", + message: "制御盤が接続されていません", + details: { last_seen_at: ["2026-10-19T12:00:00+09:00"] }, + }, + }); + }); + + it("制御盤から結果が来ない → 504 agent_timeout", async () => { + const poll = fake.relay.poll("DS-002", "1.0.0", 25, signal()); + const res = logsRequest("DS-002"); + await poll; + await fake.at(fake.limits.listTimeoutSec); + const done = await res; + expect(done.status).toBe(504); + expect(await errorCode(done)).toBe("agent_timeout"); + }); + + it("制御盤が { error } を返す → 502 agent_error(details.agent_code)", async () => { + const res = await listVia("", { error: { code: "io_error", message: "読めません" } }); + expect(res.status).toBe(502); + expect(await res.json()).toEqual({ + error: { code: "agent_error", message: "制御盤でエラーが発生しました", details: { agent_code: ["io_error"] } }, + }); + }); + + it("待ち行列が満杯 → 429 agent_busy", async () => { + fake.relay.touch("DS-002", null); + for (let i = 0; i < PENDING_COMMANDS_PER_DEVICE; i++) void fake.relay.requestList("DS-002"); + const res = await logsRequest("DS-002"); + expect(res.status).toBe(429); + expect(await errorCode(res)).toBe("agent_busy"); + }); +}); + +describe("POST /ds-devices/{serial_number}/logs/exports(非 fixtures)", () => { + /** 制御盤の役: export の指示を受けて prepared を返す */ + async function exportVia(totalBytes: number): Promise { + const poll = fake.relay.poll("DS-002", "1.0.0", 25, signal()); + const res = exportRequest("DS-002", JSON.stringify({ file_ids: ["f1", "f2"] })); + const command = (await poll) as DsAgentCommand; + expect(command).toMatchObject({ kind: "export", params: { file_ids: ["f1", "f2"] } }); + fake.relay.submitPrepared("DS-002", command.command_id, { total_bytes: totalBytes, sha256: "a".repeat(64), file_count: 2 }); + return res; + } + + it("201: export_id・download_url・total_bytes・file_count・expires_in_sec", async () => { + const res = await exportVia(9437184); + expect(res.status).toBe(201); + const body = (await res.json()) as DsLogExportResponse; + expect(body).toEqual({ + export_id: expect.any(String), + download_url: `/api/ds-devices/DS-002/logs/exports/${body.export_id}`, + total_bytes: 9437184, + file_count: 2, + expires_in_sec: 60, + }); + }); + + it("zip が EXPORT_MAX_BYTES を超える → 502 agent_error(too_large。D15)", async () => { + const res = await exportVia(EXPORT_MAX_BYTES + 1); + expect(res.status).toBe(502); + expect(await res.json()).toEqual({ + error: { code: "agent_error", message: "制御盤でエラーが発生しました", details: { agent_code: ["too_large"] } }, + }); + }); + + it("同じ制御盤で進行中 → 429 export_busy", async () => { + expect((await exportVia(10)).status).toBe(201); + const res = await exportRequest("DS-002", JSON.stringify({ file_ids: ["f3"] })); + expect(res.status).toBe(429); + expect(await errorCode(res)).toBe("export_busy"); + }); + + it("prepared が来ない → 504 agent_timeout", async () => { + const poll = fake.relay.poll("DS-002", "1.0.0", 25, signal()); + const res = exportRequest("DS-002", JSON.stringify({ file_ids: ["f1"] })); + await poll; + await fake.at(fake.limits.exportPrepareTimeoutSec + 0.001); + expect((await res).status).toBe(504); + }); + + it.each(["null", "[]", "1", '"x"', "{"])("V8・V9 本体が %s → 422「JSONの形式が不正です」", async (raw) => { + const res = await exportRequest("DS-002", raw); + expect(res.status).toBe(422); + expect(await res.json()).toEqual({ + error: { code: "validation_failed", message: "JSONの形式が不正です", details: null }, + }); + expect(exists).not.toHaveBeenCalled(); + }); + + it("V7 file_ids が空 → 422", async () => { + const res = await exportRequest("DS-002", JSON.stringify({ file_ids: [] })); + expect(res.status).toBe(422); + }); + + it("K7-4 file_ids の合計が上限超 → 422・exists は呼ばれない", async () => { + const fileIds = fileIdsOfJsonBytes(EXPORT_FILE_IDS_MAX_BYTES + 1); + const res = await exportRequest("DS-002", JSON.stringify({ file_ids: fileIds })); + expect(res.status).toBe(422); + expect(await res.json()).toEqual({ + error: { + code: "validation_failed", + message: EXPORT_FILE_IDS_TOO_LARGE_MESSAGE, + details: { + file_ids: [ + `ファイル ID の合計が ${EXPORT_FILE_IDS_MAX_BYTES} バイト(JSON・UTF-8)以内になるように選んでください`, + ], + }, + }, + }); + expect(exists).not.toHaveBeenCalled(); + }); + + it("一覧に出ない制御盤 → 404", async () => { + exists.mockResolvedValue(false); + const res = await exportRequest("DS-999", JSON.stringify({ file_ids: ["f1"] })); + expect(res.status).toBe(404); + }); +}); + +describe("GET /ds-devices/{serial_number}/logs/exports/{export_id}(非 fixtures)", () => { + it("X2 export_id が長すぎる → 422(入力値をエコーしない)", async () => { + const tooLong = "Q".repeat(PATH_EXPORT_ID_MAX_LENGTH + 1); + const res = await downloadRequest("DS-002", tooLong); + expect(res.status).toBe(422); + expect(await res.text()).not.toContain(tooLong); + }); + + it("無い export_id → 404 export_not_found", async () => { + const res = await downloadRequest("DS-002", "abc"); + expect(res.status).toBe(404); + expect(await errorCode(res)).toBe("export_not_found"); + }); + + it("最初のチャンクが来ない → 504 agent_timeout(まだ JSON で返せる)", async () => { + const poll = fake.relay.poll("DS-002", "1.0.0", 25, signal()); + const created = exportRequest("DS-002", JSON.stringify({ file_ids: ["f1"] })); + const command = (await poll) as DsAgentCommand; + fake.relay.submitPrepared("DS-002", command.command_id, { total_bytes: 4, sha256: "a".repeat(64), file_count: 1 }); + const { export_id } = (await (await created).json()) as DsLogExportResponse; + + const res = downloadRequest("DS-002", export_id); + // ★ルートが claimExport まで進んでから時計を進める★(先に進めると最初のチャンクの待ちのタイマーが後から作られる) + await flush(); + await fake.at(fake.limits.exportFirstChunkTimeoutSec + 0.001); + const done = await res; + expect(done.status).toBe(504); + expect(await errorCode(done)).toBe("agent_timeout"); + }); +}); diff --git "a/app/api/ds-devices/\133serial_number\135/logs/exports/\133export_id\135/route.ts" "b/app/api/ds-devices/\133serial_number\135/logs/exports/\133export_id\135/route.ts" new file mode 100644 index 0000000..dcf9157 --- /dev/null +++ "b/app/api/ds-devices/\133serial_number\135/logs/exports/\133export_id\135/route.ts" @@ -0,0 +1,60 @@ +/** + * GET /ds-devices/{serial_number}/logs/exports/{export_id} — 準備した zip を受け取る(1回だけ。指示書AY 段階7-1。D14・D-179) + * + * 仕様の正は `d-watch-api.yaml` の同パス。制御盤に送信を指示し、最初のチャンクを最大30秒待ってから 200 で流す。 + * ★最初のチャンクが届くまで Response を返さない★(それまでの失敗は JSON の 504 / 502。4-4)。 + * ★流し始めた後の失敗はストリームの error★(ステータスは200のまま)。★サーバーには保存しない★。 + * ★応答ヘッダは5つとも付ける(4-5)★ — nginx に応答をバッファさせない(一時ファイルに書かせない)。 + */ +import { NextResponse } from "next/server"; +import { parsePathSerialNumber } from "@/lib/api/path-params"; +import { USE_FIXTURES } from "@/lib/db/use-fixtures"; +import { jsonError } from "@/lib/ds-agent/agent-request"; +import { ensureFixtureAgent } from "@/lib/ds-agent/fixture-agent"; +import { getRelay } from "@/lib/ds-agent/relay"; +import { ensureDsAgentLogging } from "@/lib/ds-agent/relay-log"; +import { exportFileName } from "@/lib/ds-logs/file-name"; +import { parsePathExportId } from "@/lib/ds-logs/validate"; +import { logEvent, withRequestId } from "@/lib/logger"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ serial_number: string; export_id: string }> } +) { + return withRequestId(request, async () => { + ensureDsAgentLogging(); + const { serial_number: serialRaw, export_id: exportIdRaw } = await params; + const parsedSerial = parsePathSerialNumber(serialRaw); + if (!parsedSerial.ok) return NextResponse.json(parsedSerial.error, { status: parsedSerial.status }); + const parsedExportId = parsePathExportId(exportIdRaw); + if (!parsedExportId.ok) return NextResponse.json(parsedExportId.error, { status: parsedExportId.status }); + const serial = parsedSerial.query; + + const relay = getRelay(); + if (USE_FIXTURES) ensureFixtureAgent(relay, serial); + + const result = await relay.claimExport(serial, parsedExportId.query, request.signal); + switch (result.kind) { + case "not_found": + return jsonError(404, "export_not_found", "エクスポートが見つかりません"); + case "timeout": + return jsonError(504, "agent_timeout", "制御盤から応答がありません"); + case "agent_error": + return jsonError(502, "agent_error", "制御盤でエラーが発生しました", { agent_code: [result.code] }); + case "stream": { + logEvent("info", { event: "ds_logs.export_started", serial_number: serial, file_count: result.fileCount }); + const fileName = exportFileName(serial, new Date()); + return new Response(result.body, { + status: 200, + headers: { + "Content-Type": "application/zip", + "Content-Disposition": `attachment; filename="${fileName}"; filename*=UTF-8''${encodeURIComponent(fileName)}`, + "Cache-Control": "no-store", + "X-Accel-Buffering": "no", + "Content-Length": String(result.totalBytes), + }, + }); + } + } + }); +} diff --git "a/app/api/ds-devices/\133serial_number\135/logs/exports/route.ts" "b/app/api/ds-devices/\133serial_number\135/logs/exports/route.ts" new file mode 100644 index 0000000..d977a8f --- /dev/null +++ "b/app/api/ds-devices/\133serial_number\135/logs/exports/route.ts" @@ -0,0 +1,87 @@ +/** + * POST /ds-devices/{serial_number}/logs/exports — ログのエクスポートの準備(指示書AY 段階7-1。D14・D-179) + * + * 仕様の正は `d-watch-api.yaml` の /ds-devices/{serial_number}/logs/exports。 + * 制御盤に zip を作らせ、prepared が届くまで最大50秒待ってから 201(受け取りは `download_url`)。 + * ★サーバーには保存しない★。★POST が切れたら(`request.signal`)エクスポートを取り消す(4-9)★。 + */ +import { NextResponse } from "next/server"; +import { parsePathSerialNumber } from "@/lib/api/path-params"; +import { db } from "@/lib/db"; +import { USE_FIXTURES } from "@/lib/db/use-fixtures"; +import { errorBody, jsonError } from "@/lib/ds-agent/agent-request"; +import { ensureFixtureAgent } from "@/lib/ds-agent/fixture-agent"; +import { getRelay } from "@/lib/ds-agent/relay"; +import { ensureDsAgentLogging } from "@/lib/ds-agent/relay-log"; +import { parseExportRequest } from "@/lib/ds-logs/validate"; +import { logEvent, withRequestId } from "@/lib/logger"; +import { logApiError } from "@/lib/logger/api"; +import type { DsLogExportResponse } from "@/lib/types"; + +const ROUTE = "/api/ds-devices/[serial_number]/logs/exports"; + +export async function POST( + request: Request, + { params }: { params: Promise<{ serial_number: string }> } +) { + return withRequestId(request, async () => { + ensureDsAgentLogging(); + const { serial_number: serialRaw } = await params; + const parsedSerial = parsePathSerialNumber(serialRaw); + if (!parsedSerial.ok) return NextResponse.json(parsedSerial.error, { status: parsedSerial.status }); + const serial = parsedSerial.query; + + // ★JSON でない・オブジェクトでない本体は 422「JSONの形式が不正です」(note と同じ。V8・V9)★ + const parsedBody = parseExportRequest(await request.text()); + if (!parsedBody.ok) return NextResponse.json(parsedBody.error, { status: parsedBody.status }); + + let exists: boolean; + try { + exists = await db.dsDeviceExists(serial); + } catch (e) { + const body = errorBody("internal_error", "データの取得に失敗しました"); + logApiError({ route: ROUTE, method: "POST", body, status: 500, err: e }); + return NextResponse.json(body, { status: 500 }); + } + if (!exists) return jsonError(404, "not_found", "デバイスが見つかりません"); + + const relay = getRelay(); + if (USE_FIXTURES) ensureFixtureAgent(relay, serial); + + const started = Date.now(); + const result = await relay.createExport(serial, parsedBody.query.fileIds, request.signal); + switch (result.kind) { + case "offline": { + const lastSeen = relay.agentStatus(serial).last_seen_at; + return jsonError(409, "agent_offline", "制御盤が接続されていません", { + last_seen_at: lastSeen ? [lastSeen] : [], + }); + } + case "busy": + return result.code === "export_busy" + ? jsonError(429, "export_busy", "この制御盤は別のエクスポートを処理中です") + : jsonError(429, "agent_busy", "制御盤が混み合っています"); + case "timeout": + return jsonError(504, "agent_timeout", "制御盤から応答がありません"); + case "agent_error": + return jsonError(502, "agent_error", "制御盤でエラーが発生しました", { agent_code: [result.code] }); + case "prepared": { + logEvent("info", { + event: "ds_logs.export_prepared", + serial_number: serial, + file_count: result.fileCount, + total_bytes: result.totalBytes, + duration_ms: Date.now() - started, + }); + const body: DsLogExportResponse = { + export_id: result.exportId, + download_url: `/api/ds-devices/${encodeURIComponent(serial)}/logs/exports/${result.exportId}`, + total_bytes: result.totalBytes, + file_count: result.fileCount, + expires_in_sec: result.expiresInSec, + }; + return NextResponse.json(body, { status: 201 }); + } + } + }); +} diff --git "a/app/api/ds-devices/\133serial_number\135/logs/route.ts" "b/app/api/ds-devices/\133serial_number\135/logs/route.ts" new file mode 100644 index 0000000..24b93f8 --- /dev/null +++ "b/app/api/ds-devices/\133serial_number\135/logs/route.ts" @@ -0,0 +1,100 @@ +/** + * GET /ds-devices/{serial_number}/logs — 制御盤のログファイルの一覧(指示書AY 段階7-1。D-179) + * + * 仕様の正は `d-watch-api.yaml` の /ds-devices/{serial_number}/logs。 + * 一覧は毎回制御盤に問い合わせ(中継。最大15秒)、絞り込み・並べ替え・ページ分けは `queryDeviceLogs()` で行う(D8)。 + * ★サーバーには保存しない★。★検証の順序は シリアル(422)→ クエリ(422)→ 実在(404)→ 制御盤★。 + */ +import { NextResponse } from "next/server"; +import { parsePathSerialNumber } from "@/lib/api/path-params"; +import { db } from "@/lib/db"; +import { USE_FIXTURES } from "@/lib/db/use-fixtures"; +import { errorBody, jsonError } from "@/lib/ds-agent/agent-request"; +import { ensureFixtureAgent } from "@/lib/ds-agent/fixture-agent"; +import { getRelay } from "@/lib/ds-agent/relay"; +import { ensureDsAgentLogging, truncateAgentCode } from "@/lib/ds-agent/relay-log"; +import { queryDeviceLogs } from "@/lib/ds-logs/query"; +import { parseDeviceLogQuery } from "@/lib/ds-logs/validate"; +import { logEvent, withRequestId } from "@/lib/logger"; +import { logApiError } from "@/lib/logger/api"; +import type { DsLogListResponse } from "@/lib/types"; + +const ROUTE = "/api/ds-devices/[serial_number]/logs"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ serial_number: string }> } +) { + return withRequestId(request, async () => { + ensureDsAgentLogging(); + const { serial_number: serialRaw } = await params; + const parsedSerial = parsePathSerialNumber(serialRaw); + if (!parsedSerial.ok) return NextResponse.json(parsedSerial.error, { status: parsedSerial.status }); + const serial = parsedSerial.query; + + const parsedQuery = parseDeviceLogQuery(new URL(request.url).searchParams); + if (!parsedQuery.ok) return NextResponse.json(parsedQuery.error, { status: parsedQuery.status }); + const query = parsedQuery.query; + + let exists: boolean; + try { + exists = await db.dsDeviceExists(serial); + } catch (e) { + const body = errorBody("internal_error", "データの取得に失敗しました"); + logApiError({ route: ROUTE, method: "GET", body, status: 500, err: e }); + return NextResponse.json(body, { status: 500 }); + } + if (!exists) return jsonError(404, "not_found", "デバイスが見つかりません"); + + const relay = getRelay(); + if (USE_FIXTURES) ensureFixtureAgent(relay, serial); + + const started = Date.now(); + const result = await relay.requestList(serial); + const duration_ms = Date.now() - started; + const logList = ( + r: "ok" | "offline" | "timeout" | "busy" | "agent_error", + extra: { item_count?: number; truncated?: boolean; agent_code?: string } = {} + ) => + logEvent("info", { + event: "ds_logs.list", + serial_number: serial, + item_count: extra.item_count ?? 0, + truncated: extra.truncated ?? false, + duration_ms, + result: r, + ...(extra.agent_code !== undefined ? { agent_code: truncateAgentCode(extra.agent_code) } : {}), + }); + + switch (result.kind) { + case "offline": { + logList("offline"); + const lastSeen = relay.agentStatus(serial).last_seen_at; + return jsonError(409, "agent_offline", "制御盤が接続されていません", { + last_seen_at: lastSeen ? [lastSeen] : [], + }); + } + case "timeout": + logList("timeout"); + return jsonError(504, "agent_timeout", "制御盤から応答がありません"); + case "busy": + logList("busy"); + return jsonError(429, "agent_busy", "制御盤が混み合っています"); + case "agent_error": + logList("agent_error", { agent_code: result.code }); + return jsonError(502, "agent_error", "制御盤でエラーが発生しました", { agent_code: [result.code] }); + case "ok": { + const page = queryDeviceLogs(result.result.types, result.result.items, query); + logList("ok", { item_count: result.result.items.length, truncated: result.result.truncated }); + const body: DsLogListResponse = { + agent: relay.agentStatus(serial), + types: result.result.types, + items: page.items, + truncated: result.result.truncated, + meta: { total: page.total, page: query.page, per_page: query.perPage }, + }; + return NextResponse.json(body); + } + } + }); +} diff --git a/components/features/ds-logs/DeviceLogsModal.module.css b/components/features/ds-logs/DeviceLogsModal.module.css new file mode 100644 index 0000000..2e03621 --- /dev/null +++ b/components/features/ds-logs/DeviceLogsModal.module.css @@ -0,0 +1,383 @@ +.overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + padding: 16px; +} + +.panel { + background: var(--sf); + border: 1px solid var(--bd); + border-radius: 16px; + width: 920px; + max-width: 100%; + max-height: 90vh; + display: flex; + flex-direction: column; + color: var(--tx); +} + +.header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 16px 20px; + border-bottom: 1px solid var(--bd); +} + +.headerMain { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 12px 24px; + flex: 1; + min-width: 0; +} + +.title { + font-size: 16px; + font-weight: 700; + color: var(--tx); + margin: 0; +} + +.meta { + font-size: 13px; + color: var(--tx-d); + margin-top: 4px; +} + +.connection { + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 2px; + font-size: 12px; +} + +.connectionStatus { + font-weight: 600; +} + +.connectionSeen { + color: var(--tx-m); +} + +.close { + font-size: 18px; + color: var(--tx-m); + background: none; + border: none; + cursor: pointer; + padding: 4px 8px; + flex-shrink: 0; +} + +.body { + overflow-y: auto; + flex: 1; + padding: 12px 20px 16px; + font-size: 13px; + color: var(--tx-d); + line-height: 1.5; +} + +.section { + margin-bottom: 12px; +} + +.sectionLabel { + font-size: 11px; + font-weight: 600; + color: var(--tx-m); + text-transform: uppercase; + letter-spacing: 0.4px; + margin-bottom: 6px; +} + +.typeList, +.rowWrap { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +/** JP/EN で位置と幅が動かないよう、長いほう+余白で固定(指示書BJ J4) */ +.fixedSlotPeriod > button { + box-sizing: border-box; + min-width: 112px; +} + +.fixedSlotSort > button { + box-sizing: border-box; + min-width: 100px; +} + +.checkLabel { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 13px; + color: var(--tx); + cursor: pointer; +} + +.dateInput { + font-size: 13px; + color: var(--tx); + background: var(--sf); + border: 1px solid var(--bd); + border-radius: 6px; + padding: 6px 8px; +} + +.dateSep { + color: var(--tx-m); +} + +.hint { + font-size: 12px; + color: var(--tx-m); +} + +.notice { + margin-bottom: 8px; + padding: 8px 10px; + font-size: 12px; + color: var(--wn); + background: color-mix(in srgb, var(--wn) 12%, transparent); + border-radius: 6px; +} + +.message { + padding: 20px; + text-align: center; + font-size: 13px; + color: var(--tx-m); +} + +.error { + padding: 8px 0; + font-size: 13px; + color: var(--ng); +} + +.success { + padding: 8px 0; + font-size: 13px; + color: var(--ok); +} + +.warn { + color: var(--ng); + font-size: 12px; +} + +.tableWrap { + overflow-x: auto; + border: 1px solid var(--bd); + border-radius: 8px; +} + +.table { + width: 100%; + border-collapse: collapse; + table-layout: fixed; + min-width: 640px; +} + +.th, +.thCheck { + padding: 8px 10px; + font-size: 11px; + color: var(--tx-m); + text-transform: uppercase; + letter-spacing: 0.4px; + border-bottom: 1px solid var(--bd); + text-align: left; + font-weight: 600; + white-space: nowrap; +} + +/* チェックの列はチェックボックスだけの幅(左右 10px の余白込み。2026-11-06 に 180px から縮めた) */ +.thCheck { + width: 36px; +} + +.td, +.tdCheck, +.tdName, +.tdSize, +.tdDate { + padding: 8px 10px; + font-size: 12px; + color: var(--tx); + border-bottom: 1px solid color-mix(in srgb, var(--bd) 40%, transparent); + vertical-align: middle; +} + +.tdCheck { + width: 36px; +} + +.tdName { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tdSize, +.tdDate { + white-space: nowrap; + color: var(--tx-d); +} + +.pagerRow { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-top: 8px; +} + +.perPage { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--tx-m); +} + +.perPage select { + font-size: 12px; + color: var(--tx); + background: var(--sf); + border: 1px solid var(--bd); + border-radius: 6px; + padding: 4px 6px; +} + +.selectionBar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--bd); + font-size: 13px; + color: var(--tx); +} + +.exportButton { + margin-left: auto; + box-sizing: border-box; + width: 168px; + font-size: 12px; + font-weight: 600; + padding: 8px 12px; + border-radius: 6px; + border: 1px solid transparent; + background: var(--pr-d); + color: var(--pr); + cursor: pointer; + white-space: nowrap; + text-align: center; +} + +.exportButton:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.tokenSection { + margin-top: 16px; + padding-top: 12px; + border-top: 1px solid var(--bd); +} + +.tokenHeader { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 8px 16px; + margin-bottom: 8px; +} + +.tokenActions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.tokenButton { + box-sizing: border-box; + min-width: 140px; + font-size: 12px; + font-weight: 600; + padding: 6px 12px; + border-radius: 6px; + border: 1px solid var(--bd); + background: transparent; + color: var(--tx); + cursor: pointer; + white-space: nowrap; + text-align: center; +} + +.tokenButton:disabled { + opacity: 0.55; + cursor: not-allowed; +} + +.tokenReveal { + margin-top: 10px; + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; +} + +.tokenInput { + flex: 1 1 280px; + min-width: 0; + font-size: 12px; + font-family: var(--font-geist-mono), ui-monospace, monospace; + color: var(--tx); + background: var(--sf-h); + border: 1px solid var(--bd); + border-radius: 6px; + padding: 6px 8px; +} + +.tokenWarn { + flex: 1 1 100%; + margin: 0; + font-size: 12px; + color: var(--wn); +} + +.footer { + padding: 12px 20px; + border-top: 1px solid var(--bd); + display: flex; + justify-content: flex-end; +} + +.closeBtn { + font-size: 13px; + padding: 8px 20px; + border-radius: 6px; + border: 1px solid var(--bd); + background: transparent; + color: var(--tx-d); + cursor: pointer; +} diff --git a/components/features/ds-logs/DeviceLogsModal.tsx b/components/features/ds-logs/DeviceLogsModal.tsx new file mode 100644 index 0000000..d3537d8 --- /dev/null +++ b/components/features/ds-logs/DeviceLogsModal.tsx @@ -0,0 +1,796 @@ +"use client"; + +import { + useEffect, + useId, + useMemo, + useRef, + useState, + type RefObject, +} from "react"; +import { openDatePicker } from "@/components/ui/date-picker"; +import { FilterButton } from "@/components/ui/FilterButton"; +import { Pagination } from "@/components/ui/Pagination"; +import { useTheme } from "@/components/theme/ThemeProvider"; +import { apiFetch, ApiFetchError } from "@/lib/api/client"; +import { + DS_LOG_DEFAULT_PER_PAGE, + DS_LOG_PER_PAGE_OPTIONS, + EXPORT_MAX_BYTES, + EXPORT_MAX_FILES, +} from "@/lib/config/ds-agent"; +import { + apiErrorMessage, + detailStrings, + formatDateTime, + formatFileSize, +} from "@/lib/ds-logs/modal-text"; +import { + dateRangeError, + periodFromDates, + periodFromPreset, + type DsLogPeriodPreset, +} from "@/lib/ds-logs/presets"; +import { deviceLogTypesParam } from "@/lib/ds-logs/query"; +import { THEME_TOKENS } from "@/lib/theme"; +import type { + DsAgentTokenIssued, + DsAgentTokenStatus, + DsLogExportResponse, + DsLogFile, + DsLogListResponse, + DsLogSort, + DsLogType, +} from "@/lib/types"; +import { shouldCloseOnBackdrop, trapFocusIndex } from "@/lib/ui/modal"; +import styles from "./DeviceLogsModal.module.css"; + +interface DeviceLogsModalProps { + serialNumber: string; + facilityName: string; + /** 閉じたときにフォーカスを戻す「ログ」ボタン */ + restoreFocusRef: RefObject; + onClose: () => void; +} + +/** Tab の閉じ込めに使う、窓の中のフォーカスできる要素(ErrorContentPicker と同じ) */ +const FOCUSABLE_SELECTOR = [ + "button:not([disabled])", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + "a[href]", + '[tabindex]:not([tabindex="-1"])', +].join(", "); + +type PeriodMode = + | { kind: "preset"; preset: DsLogPeriodPreset } + | { kind: "dates"; start: string; end: string }; + +/** + * 制御盤のログのモーダル(指示書AY 段階9-2。D-179)。 + * 見た目は AreaModal、フォーカス・Tab・背景クリックは ErrorContentPicker に揃える。 + */ +export function DeviceLogsModal({ + serialNumber, + facilityName, + restoreFocusRef, + onClose, +}: DeviceLogsModalProps) { + const { t, theme } = useTheme(); + const c = THEME_TOKENS[theme]; + const titleId = useId(); + const panelRef = useRef(null); + const initialFocusRef = useRef(null); + const downOnOverlayRef = useRef(false); + const exportAbortRef = useRef(null); + + const [period, setPeriod] = useState({ kind: "preset", preset: "24h" }); + const [sort, setSort] = useState("date_desc"); + const [page, setPage] = useState(1); + const [perPage, setPerPage] = useState(DS_LOG_DEFAULT_PER_PAGE); + /** null = まだ種類が来ていない。空 Set = 1つも選んでいない */ + const [selectedTypes, setSelectedTypes] = useState | null>(null); + const [types, setTypes] = useState([]); + const [items, setItems] = useState([]); + const [total, setTotal] = useState(0); + const [truncated, setTruncated] = useState(false); + const [connected, setConnected] = useState(false); + const [lastSeenAt, setLastSeenAt] = useState(null); + const [loading, setLoading] = useState(true); + const [listError, setListError] = useState(null); + /** file_id → size_bytes。ページ送り・並べ替えでは保持、種類・期間変更で消す(D23) */ + const [selection, setSelection] = useState>(() => new Map()); + const [exporting, setExporting] = useState(false); + const [exportMessage, setExportMessage] = useState(null); + const [exportError, setExportError] = useState(null); + + const [tokenIssuedAt, setTokenIssuedAt] = useState(null); + const [tokenValue, setTokenValue] = useState(null); + const [tokenBusy, setTokenBusy] = useState(false); + const [tokenError, setTokenError] = useState(null); + const [tokenLoading, setTokenLoading] = useState(true); + + const typeLabels = useMemo(() => { + const map = new Map(); + for (const type of types) map.set(type.key, type.label); + return map; + }, [types]); + + const periodRange = useMemo(() => { + if (period.kind === "preset") return periodFromPreset(period.preset, new Date()); + return periodFromDates( + period.start === "" ? null : period.start, + period.end === "" ? null : period.end + ); + }, [period]); + + const rangeError = useMemo(() => { + if (period.kind !== "dates") return null; + return dateRangeError( + period.start === "" ? null : period.start, + period.end === "" ? null : period.end + ); + }, [period]); + + /** 制御盤に種類があるときだけ「選んでください」(指示書BJ J10) */ + const noTypeSelected = + selectedTypes !== null && selectedTypes.size === 0 && types.length > 0; + + /** null = 取得しない(種類0件・日付不正)。全種類/未初期化は types パラメータ無し(指示書BJ J10) */ + const queryString = useMemo(() => { + if (noTypeSelected || periodRange === null) return null; + const params = new URLSearchParams(); + const typesParam = deviceLogTypesParam( + types.map((x) => x.key), + selectedTypes + ); + if (typesParam !== null) params.set("types", typesParam); + if (periodRange.from) params.set("from", periodRange.from); + if (periodRange.to) params.set("to", periodRange.to); + params.set("sort", sort); + params.set("page", String(page)); + params.set("per_page", String(perPage)); + return params.toString(); + }, [noTypeSelected, selectedTypes, types, periodRange, sort, page, perPage]); + + // Esc・フォーカス復帰・背景スクロール停止 + useEffect(() => { + const prevOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + initialFocusRef.current?.focus(); + const opener = restoreFocusRef.current; + return () => { + document.body.style.overflow = prevOverflow; + opener?.focus(); + exportAbortRef.current?.abort(); + }; + }, [restoreFocusRef]); + + useEffect(() => { + const onKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + return; + } + if (e.key !== "Tab") return; + const panel = panelRef.current; + if (!panel) return; + const focusables = Array.from(panel.querySelectorAll(FOCUSABLE_SELECTOR)); + const active = document.activeElement; + const activeIndex = active instanceof HTMLElement ? focusables.indexOf(active) : -1; + const next = trapFocusIndex(focusables.length, activeIndex, e.shiftKey); + if (focusables.length === 0) { + e.preventDefault(); + return; + } + if (next !== null) { + e.preventDefault(); + focusables[next]?.focus(); + } + }; + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [onClose]); + + // 制御盤トークンの発行日時(未接続でも取得する) + useEffect(() => { + let cancelled = false; + // eslint-disable-next-line react-hooks/set-state-in-effect -- モーダル表示時のトークン状態の取り直し + setTokenLoading(true); + setTokenError(null); + apiFetch( + `/api/ds-devices/${encodeURIComponent(serialNumber)}/agent-token` + ) + .then( + (body) => { + if (cancelled) return; + setTokenIssuedAt(body.issued_at); + }, + (e: unknown) => { + if (cancelled) return; + setTokenError(apiErrorMessage(e, t) ?? t("データの取得に失敗しました")); + } + ) + .finally(() => { + if (!cancelled) setTokenLoading(false); + }); + return () => { + cancelled = true; + }; + }, [serialNumber, t]); + + // 一覧の取得(BC 段階1 の作法。古い応答は cancelled で捨てる) + useEffect(() => { + if (queryString === null) { + // eslint-disable-next-line react-hooks/set-state-in-effect -- 種類0件・日付不正では取得せず文言だけ出す + setLoading(false); + setListError( + noTypeSelected + ? t("種類を1つ以上選んでください") + : rangeError === "reversed" + ? t("開始日は終了日以前にしてください") + : t("日付の範囲が不正です") + ); + setItems([]); + setTotal(0); + setTruncated(false); + return; + } + + let cancelled = false; + setLoading(true); + setListError(null); + apiFetch( + `/api/ds-devices/${encodeURIComponent(serialNumber)}/logs?${queryString}` + ) + .then( + (body) => { + if (cancelled) return; + setTypes(body.types); + setSelectedTypes((prev) => { + if (prev === null) return new Set(body.types.map((x) => x.key)); + return prev; + }); + setItems(body.items); + setTotal(body.meta.total); + setTruncated(body.truncated); + setConnected(body.agent.connected); + setLastSeenAt(body.agent.last_seen_at); + const pages = Math.max(1, Math.ceil(body.meta.total / body.meta.per_page) || 1); + if (body.meta.page > pages) setPage(pages); + }, + (e: unknown) => { + if (cancelled) return; + const message = apiErrorMessage(e, t); + if (message === null) return; + setListError(message); + setItems([]); + setTotal(0); + setTruncated(false); + if (e instanceof ApiFetchError && e.code === "agent_offline") { + setConnected(false); + const last = detailStrings(e.details, "last_seen_at")[0] ?? null; + setLastSeenAt(last); + } else if (e instanceof ApiFetchError && e.code === "agent_error") { + // ★制御盤は結果を返しているので接続中(指示書BJ J2)★ + setConnected(true); + } + } + ) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, [serialNumber, queryString, noTypeSelected, rangeError, t]); + + const selectedCount = selection.size; + const selectedBytes = useMemo(() => { + let sum = 0; + for (const size of selection.values()) sum += size; + return sum; + }, [selection]); + const overMaxBytes = selectedBytes > EXPORT_MAX_BYTES; + const overMaxFiles = selectedCount > EXPORT_MAX_FILES; + const canExport = + selectedCount > 0 && !overMaxBytes && !overMaxFiles && !exporting && !loading; + + const pageAllSelected = + items.length > 0 && items.every((item) => selection.has(item.file_id)); + + const clearSelection = () => setSelection(new Map()); + + const changePeriodPreset = (preset: DsLogPeriodPreset) => { + setPeriod({ kind: "preset", preset }); + setPage(1); + clearSelection(); + }; + + const changeDate = (which: "start" | "end", value: string) => { + setPeriod((prev) => { + const start = which === "start" ? value : prev.kind === "dates" ? prev.start : ""; + const end = which === "end" ? value : prev.kind === "dates" ? prev.end : ""; + return { kind: "dates", start, end }; + }); + setPage(1); + clearSelection(); + }; + + const toggleType = (key: string) => { + setSelectedTypes((prev) => { + const next = new Set(prev ?? []); + if (next.has(key)) next.delete(key); + else next.add(key); + return next; + }); + setPage(1); + clearSelection(); + }; + + const toggleFile = (file: DsLogFile) => { + setSelection((prev) => { + const next = new Map(prev); + if (next.has(file.file_id)) next.delete(file.file_id); + else next.set(file.file_id, file.size_bytes); + return next; + }); + }; + + const togglePageAll = () => { + setSelection((prev) => { + const next = new Map(prev); + if (pageAllSelected) { + for (const item of items) next.delete(item.file_id); + } else { + for (const item of items) next.set(item.file_id, item.size_bytes); + } + return next; + }); + }; + + const startExport = async () => { + if (!canExport) return; + setExporting(true); + setExportError(null); + setExportMessage(null); + const ac = new AbortController(); + exportAbortRef.current = ac; + try { + const body = await apiFetch( + `/api/ds-devices/${encodeURIComponent(serialNumber)}/logs/exports`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ file_ids: [...selection.keys()] }), + signal: ac.signal, + } + ); + const anchor = document.createElement("a"); + anchor.href = body.download_url; + anchor.download = ""; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + setExportMessage( + t("ダウンロードを開始しました({size})").replace( + "{size}", + formatFileSize(body.total_bytes) + ) + ); + } catch (e: unknown) { + const message = apiErrorMessage(e, t); + if (message !== null) setExportError(message); + } finally { + if (exportAbortRef.current === ac) exportAbortRef.current = null; + setExporting(false); + } + }; + + const issueToken = async () => { + if (tokenBusy) return; + if ( + !window.confirm( + t("トークンを発行します。発行済みのトークンは使えなくなります。よろしいですか?") + ) + ) { + return; + } + setTokenBusy(true); + setTokenError(null); + try { + const body = await apiFetch( + `/api/ds-devices/${encodeURIComponent(serialNumber)}/agent-token`, + { method: "POST" } + ); + setTokenValue(body.token); + setTokenIssuedAt(body.issued_at); + } catch (e: unknown) { + const message = apiErrorMessage(e, t); + if (message !== null) setTokenError(message); + } finally { + setTokenBusy(false); + } + }; + + const revokeToken = async () => { + if (tokenBusy) return; + if ( + !window.confirm( + t("トークンを失効させます。この制御盤はログを送れなくなります。よろしいですか?") + ) + ) { + return; + } + setTokenBusy(true); + setTokenError(null); + try { + // ★204 は本文が無いので apiFetch を使わない(4-23・M5)★ + const response = await fetch( + `/api/ds-devices/${encodeURIComponent(serialNumber)}/agent-token`, + { method: "DELETE" } + ); + if (!response.ok) { + let message = t("データの取得に失敗しました"); + try { + const body = (await response.json()) as { error?: { message?: string } }; + if (typeof body.error?.message === "string") message = body.error.message; + } catch { + /* 本文が読めなければ既定 */ + } + setTokenError(message); + return; + } + setTokenIssuedAt(null); + setTokenValue(null); + } catch { + setTokenError(t("データの取得に失敗しました")); + } finally { + setTokenBusy(false); + } + }; + + const copyToken = async () => { + if (!tokenValue) return; + try { + await navigator.clipboard.writeText(tokenValue); + } catch { + /* コピー失敗は黙る(権限拒否など) */ + } + }; + + const lastSeenLabel = lastSeenAt + ? t("最終接続 {日時}").replace("{日時}", formatDateTime(lastSeenAt)) + : t("最終接続 -"); + + return ( +
{ + downOnOverlayRef.current = e.target === e.currentTarget; + }} + onClick={(e) => { + const downOnOverlay = downOnOverlayRef.current; + downOnOverlayRef.current = false; + if (shouldCloseOnBackdrop(downOnOverlay, e.target === e.currentTarget)) onClose(); + }} + > +
e.stopPropagation()} + > +
+
+
+

+ {t("制御盤のログ")} +

+
+ {serialNumber} | {facilityName} +
+
+
+ + {connected ? t("接続中") : t("未接続")} + + {lastSeenLabel} +
+
+ +
+ +
+
+
{t("種類")}
+
+ {types.length === 0 && !loading ? ( + {t("種類がまだありません")} + ) : ( + types.map((type) => ( + + )) + )} +
+
+ +
+
{t("期間")}
+
+ {( + [ + ["1h", "直近1時間"], + ["3h", "直近3時間"], + ["24h", "直近24時間"], + ["all", "全て"], + ] as const + ).map(([preset, label]) => ( + + changePeriodPreset(preset)} + > + {t(label)} + + + ))} + changeDate("start", e.target.value)} + onClick={openDatePicker} + /> + – + changeDate("end", e.target.value)} + onClick={openDatePicker} + /> +
+
+ +
+
{t("並べ替え")}
+
+ + { + setSort("date_desc"); + setPage(1); + }} + > + {t("日付")} + + + + { + setSort("type_date_desc"); + setPage(1); + }} + > + {t("種類+日付")} + + +
+
+ + {truncated && !loading && !listError && ( +
+ {t("件数が多いため、新しいものから一部だけを表示しています")} +
+ )} + + {loading &&
{t("読み込み中…")}
} + {!loading && listError &&
{t(listError)}
} + {!loading && !listError && items.length === 0 && ( +
{t("該当するファイルはありません")}
+ )} + + {!loading && !listError && items.length > 0 && ( +
+ + + + {/* ★チェックの列はチェックボックスだけの幅★(ユーザー指示 2026-11-06「チェックボックスとファイル名が間があきすぎ」)。 + 「このページをすべて選択」は見出しに文字で出さず、読み上げ(aria-label)とマウスを載せたときの title で出す */} + + + + + + + + + {items.map((item) => ( + + + + + + + + ))} + +
+ + {t("ファイル名")}{t("種類")}{t("大きさ")}{t("更新日時")}
+ toggleFile(item)} + aria-label={item.name} + /> + + {item.name} + + {typeLabels.get(item.type) ?? item.type} + {formatFileSize(item.size_bytes)}{formatDateTime(item.modified_at)}
+
+ )} + + {!loading && !listError && ( +
+ + +
+ )} + +
+ + {t("選択: {n}件・合計 {size}") + .replace("{n}", String(selectedCount)) + .replace("{size}", formatFileSize(selectedBytes))} + + {overMaxBytes && ( + + {t("1回に取り出せるのは {max} までです").replace( + "{max}", + formatFileSize(EXPORT_MAX_BYTES) + )} + + )} + {overMaxFiles && ( + + {t("1回に選べるのは {n} 件までです").replace( + "{n}", + String(EXPORT_MAX_FILES) + )} + + )} + +
+ {exportMessage &&
{exportMessage}
} + {exportError &&
{exportError}
} + +
+
+ {t("制御盤トークン")} + {tokenLoading ? ( + {t("読み込み中…")} + ) : tokenIssuedAt ? ( + + {t("発行日時 {日時}").replace("{日時}", formatDateTime(tokenIssuedAt))} + + ) : ( + {t("未発行")} + )} +
+
+ + +
+ {tokenValue !== null && ( +
+ + +

+ {t("この値は二度と表示されません。制御盤の設定ファイルに入れてください")} +

+
+ )} + {tokenError &&
{tokenError}
} +
+
+ +
+ +
+
+
+ ); +} diff --git a/components/features/ds-logs/DsLogScreen.module.css b/components/features/ds-logs/DsLogScreen.module.css index dab9230..252ddce 100644 --- a/components/features/ds-logs/DsLogScreen.module.css +++ b/components/features/ds-logs/DsLogScreen.module.css @@ -196,13 +196,13 @@ overflow-x: auto; } -/* 最小幅 800px = 全列の幅の合計(施設名 200px + 固定4列 600px)。★列を広げたら min-width も同じだけ広げること★。 +/* 最小幅 872px = 全列の幅の合計(施設名 200px + 固定5列 672px)。★列を広げたら min-width も同じだけ広げること★。 画面幅1440px 時の親の幅は約1150px(縦スクロールバーが出ると約1139px)なので、横スクロールは出ない。 - ★余った幅(約350px)は全列に幅の比で配られる★(全列に幅があるときの table-layout: fixed の既定の動き。D-167 追記) */ + ★余った幅(約278px)は全列に幅の比で配られる★(全列に幅があるときの table-layout: fixed の既定の動き。D-167 追記) */ .table { width: 100%; border-collapse: collapse; - min-width: 800px; + min-width: 872px; /* ★列幅を言語によらず固定する★(2026-10-01 ユーザー指摘「言語切り替え時に表などの位置も動く」)。 既定の auto は見出しの文字で列幅が決まるため、JP/EN を切り替えると列ごとに幅が動く。 幅は TSX の に付けた .col* で決める */ @@ -248,6 +248,12 @@ width: 228px; } +/* ログ(指示書AY 段階9-1。M1)。列幅 72px。余りは比で配られるので少し広がるのは認める。 + ボタンは固定幅(.logsButton)で列の中央に置く */ +.colLogs { + width: 72px; +} + .th { padding: 10px 12px; font-size: 11px; @@ -376,6 +382,32 @@ cursor: progress; } +/* 「ログ」列(指示書AY 段階9-1)。ボタンは固定幅・中央寄せ(JP/EN で動かない) */ +.tdLogs { + padding: 12px; + text-align: center; +} + +.logsButton { + box-sizing: border-box; + width: 56px; + font-size: 12px; + font-weight: 600; + padding: 4px 0; + border-radius: 6px; + border: 1px solid var(--bd); + background: transparent; + color: var(--tx); + cursor: pointer; + white-space: nowrap; + text-align: center; +} + +.logsButton:disabled { + opacity: 0.55; + cursor: not-allowed; +} + /* 保存に失敗した行(その行の直後の )(`ErrorsScreen.module.css` と同じ。D-183) */ .rowError { padding: 4px 12px 10px; diff --git a/components/features/ds-logs/DsLogScreen.tsx b/components/features/ds-logs/DsLogScreen.tsx index 6e46a0f..0134cb0 100644 --- a/components/features/ds-logs/DsLogScreen.tsx +++ b/components/features/ds-logs/DsLogScreen.tsx @@ -1,6 +1,6 @@ "use client"; -import { Fragment, useEffect, useMemo, useState } from "react"; +import { Fragment, useEffect, useMemo, useRef, useState } from "react"; import { FilterButton } from "@/components/ui/FilterButton"; import { Pagination } from "@/components/ui/Pagination"; import { Pill } from "@/components/ui/Pill"; @@ -29,6 +29,7 @@ DsDeviceStatusFilter, SummaryListResponse, } from "@/lib/types"; +import { DeviceLogsModal } from "./DeviceLogsModal"; import styles from "./DsLogScreen.module.css"; type ThemeTokens = (typeof THEME_TOKENS)[ThemeName]; @@ -63,7 +64,7 @@ } /** 表の列数(保存に失敗した行の colSpan)。★ / / と揃えること★ */ -const COLUMN_COUNT = 5; +const COLUMN_COUNT = 6; /** 1行を送る(PATCH /ds-devices/{serial_number}/note)。★返ってきた備考(トリム済み・空なら null)を返す★。失敗は例外のまま */ async function sendDeviceNote(device: DsDevice, note: string): Promise { @@ -143,6 +144,13 @@ const [saving, setSaving] = useState(false); const draftCount = drafts.size; + /** + * 「ログ」モーダル(指示書AY 段階9-1)。 + * 閉じたら押したボタンへフォーカスを戻すため、開いたときのボタンを保持する。 + */ + const [logsTarget, setLogsTarget] = useState(null); + const logsButtonRef = useRef(null); + // ★下書きがあるあいだはページを離れる前に確認する★(D-165) useEffect(() => { if (draftCount === 0) return; @@ -467,6 +475,7 @@ + @@ -475,6 +484,7 @@ {t("接続状態")} {t("最終通信")} {t("備考")} + {t("ログ")} @@ -517,6 +527,21 @@ )} + + {/* ★編集中は押せない(U6)★。表は fieldset の外なので disabled を直接付ける。 + ★幅は固定★(JP/EN で位置が動かない。M1) */} + + {/* ★保存に失敗した行は直後に文言を出す★(下書きは残っている)。サーバーの文言は辞書に無ければ原文のまま */} {rowError !== undefined && ( @@ -547,6 +572,15 @@ )} + + {logsTarget && ( + setLogsTarget(null)} + /> + )} ); } diff --git a/d-watch-api.yaml b/d-watch-api.yaml index 434955e..23ce6e0 100644 --- a/d-watch-api.yaml +++ b/d-watch-api.yaml @@ -55,6 +55,10 @@ description: D-Secログ(D-S制御盤稼働状況) - name: ds-alerts description: D-Secアラート + - name: ds-agent + description: | + 制御盤(ラズパイ)の常駐プログラム向け。認証は制御盤ごとの Bearer トークン(agentBearer)だけで、cookie では通らない(D-179)。 + ログのファイルは D-Watch に保存せず、メモリで受けて待っているブラウザの応答へ流すだけ。 - name: facilities description: 施設ロボットマスタ(Viewer) - name: schedules @@ -1809,6 +1813,385 @@ application/json: schema: { $ref: "#/components/schemas/Error" } + /ds-devices/{serial_number}/logs: + get: + tags: [ds-devices] + summary: 制御盤のログファイルの一覧(制御盤に問い合わせ、D-Watch が絞る) + description: | + ★サーバーには保存しない(D-179)★。一覧は毎回制御盤からもらい(最大15秒待つ)、 + 種類・期間の絞り込み・並べ替え・ページ分けは D-Watch 側で行う(D8。`lib/ds-logs/query.ts`)。 + 制御盤との中継はメモリ内で、★1プロセス前提・再起動で進行中の問い合わせは失われる★。 + + 期間は `from ≤ modified_at < to`(瞬間で比べる。オフセットの違いは同じ瞬間として扱う)。 + `modified_at` は JST(+09:00)に揃えて返す。 + 制御盤は更新日時の新しい順に2万件または JSON 4MB で打ち切り、そのとき `truncated: true`(D18)。 + `types` に無いキーを指定したときは0件(200)。 + parameters: + - $ref: "#/components/parameters/DsSerialNumber" + - name: types + in: query + schema: { type: string, example: "api_client,send_start_sig" } + description: | + 種類のキー(カンマ区切り)。省略・空(空白だけ)なら全種類。 + 空でない並びの中の空の要素(`app,,sys`・`app,`)は422。 + - name: from + in: query + schema: { type: string, format: date-time, example: "2026-10-19T11:00:00+09:00" } + description: | + ISO 8601。★オフセット必須★(`Z` や `+00:00` も可)。日付だけ・オフセットなし・存在しない日付(2/30 など)は422。 + 空は省略と同じ。`from` > `to` は422(`from` = `to` は0件)。 + - name: to + in: query + schema: { type: string, format: date-time, example: "2026-10-19T12:00:00+09:00" } + description: "`from` と同じ規則。この瞬間ちょうどのファイルは含まない。" + - name: sort + in: query + schema: + type: string + enum: [date_desc, type_date_desc] + default: date_desc + description: | + ★この API の `sort` は向きを持たない1引数★。清掃詳細ログ等の `sort` / `order` とは別(M9)。 + date_desc = 新しい順(同時刻は name 昇順)。type_date_desc = 制御盤の `types` の順 → 新しい順 → name 昇順。 + 空は省略と同じ。それ以外の値は422。 + - name: page + in: query + schema: { type: integer, minimum: 1, default: 1 } + description: 不正値は422にせず1へ丸める(既存の一覧と同じ) + - name: per_page + in: query + schema: { type: integer, minimum: 1, maximum: 200, default: 200 } + description: | + 1〜200。不正値は422にせず既定値(200)・上限へ丸める(既存の一覧と同じ)。 + 画面の選択肢は 50 / 100 / 200(M7)。 + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/DsLogListResponse" } + "404": { $ref: "#/components/responses/DsDeviceNotFound" } + "409": { $ref: "#/components/responses/DsAgentOffline" } + "422": { $ref: "#/components/responses/ValidationError" } + "429": + description: "`agent_busy`(制御盤への指示の待ち行列が満杯)" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "502": { $ref: "#/components/responses/DsAgentError" } + "504": { $ref: "#/components/responses/DsAgentTimeout" } + + /ds-devices/{serial_number}/logs/exports: + post: + tags: [ds-devices] + summary: ログのエクスポートの準備(制御盤が zip を作り終えるまで待つ) + description: | + ★エクスポートは「準備 → 送信」の2段(D14)★。このPOSTは制御盤に zip を作らせ、 + 制御盤が作り終えて大きさ・SHA-256 を報告するまで ★最大50秒★ 待ってから201を返す。 + 受け取りは `download_url`(GET。1回だけ・`expires_in_sec` 秒以内)。 + ★サーバーには保存しない(D-179)★。★1プロセス前提・再起動で進行中のエクスポートは失われる★。 + 上限の判定は zip のバイト数(100MB。D15)。同時に進められるのは制御盤ごとに1本・全体で3本。 + parameters: + - $ref: "#/components/parameters/DsSerialNumber" + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/DsLogExportRequest" } + responses: + "201": + description: 準備完了 + content: + application/json: + schema: { $ref: "#/components/schemas/DsLogExportResponse" } + "404": { $ref: "#/components/responses/DsDeviceNotFound" } + "409": { $ref: "#/components/responses/DsAgentOffline" } + "422": + description: | + バリデーションエラー(file_ids が 1〜500 件・重複なし・各 1〜8192 文字でない)。 + `file_ids` を JSON にした大きさ(UTF-8)が 49152 バイトを超える → message「選んだファイルが多すぎます。数を減らしてください」(制御盤が応答を 64KB までしか読まないため。指示書BK K7)。 + 本文が JSON でない・JSON のオブジェクトでない(null・配列・数値・文字列)も422「JSONの形式が不正です」(note と同じ。M7 (d)) + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "429": + description: "`export_busy`(この制御盤か全体の同時エクスポートの上限)/ `agent_busy`(指示の待ち行列が満杯)" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "502": + description: | + `agent_error`。制御盤が準備に失敗した(`details.agent_code` に `too_large` / `file_not_allowed` / `file_not_found` / `io_error` 等)。 + zip が100MBを超えたときも `too_large`(D15) + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "504": { $ref: "#/components/responses/DsAgentTimeout" } + + /ds-devices/{serial_number}/logs/exports/{export_id}: + get: + tags: [ds-devices] + summary: 準備した zip を受け取る(1回だけ) + description: | + 制御盤に送信を指示し、最初のチャンクを最大30秒待ってから200で流し始める。 + 制御盤は zip を 4MB 以下のチャンクに分けて送る(★proxy が本体を10MBまでしかバッファしないため。D1★)。 + D-Watch はチャンクをメモリで受けてこの応答へ流すだけで、★サーバーには保存しない(D-179)★。 + ★1回だけ使える★。2回目・失効後・パスのシリアルが違うときは404 `export_not_found`。 + ★流し始めた後の失敗(制御盤の切断・ハッシュ不一致など)はダウンロードの失敗になる(ステータスは200のまま)★。 + ★JSON のエラーはまれ(制御盤の切断など)で、ブラウザによってはファイルとして保存される(D21)★。 + parameters: + - $ref: "#/components/parameters/DsSerialNumber" + - $ref: "#/components/parameters/DsExportId" + responses: + "200": + description: zip のバイト列 + headers: + Content-Type: { schema: { type: string, example: application/zip } } + Content-Disposition: + schema: { type: string, example: "attachment; filename=\"DS-002_logs_20261019-123456.zip\"; filename*=UTF-8''DS-002_logs_20261019-123456.zip" } + description: "ファイル名は `{シリアル}_logs_{JSTのYYYYMMDD-HHmmss}.zip`。シリアルの `[A-Za-z0-9._-]` 以外は `_`" + Cache-Control: { schema: { type: string, example: no-store } } + X-Accel-Buffering: { schema: { type: string, example: "no" }, description: nginx に応答をバッファさせない(一時ファイルに書かせない) } + Content-Length: { schema: { type: integer }, description: 準備のときに制御盤が報告した zip の大きさ } + content: + application/zip: + schema: { type: string, format: binary } + "404": + description: "`export_not_found`(無い・失効・使用済み・パスのシリアルと違う)" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "422": { $ref: "#/components/responses/ValidationError" } + "502": { $ref: "#/components/responses/DsAgentError" } + "504": { $ref: "#/components/responses/DsAgentTimeout" } + + /ds-devices/{serial_number}/agent-token: + get: + tags: [ds-devices] + summary: 制御盤トークンの発行日時(P1) + description: | + ★トークンの値・ハッシュは返さない★。未発行なら `issued_at: null`。 + 制御盤が未接続でも使える(初めて設定するときは必ず未接続のため)。 + parameters: + - $ref: "#/components/parameters/DsSerialNumber" + responses: + "200": + description: OK + content: + application/json: + schema: { $ref: "#/components/schemas/DsAgentTokenStatus" } + "404": { $ref: "#/components/responses/DsDeviceNotFound" } + "422": { $ref: "#/components/responses/ValidationError" } + post: + tags: [ds-devices] + summary: 制御盤トークンを発行する(作り直し) + description: | + ★生の値はこの応答で1回だけ★。既にあれば作り直し、古いトークンは無効になる。 + 保存は SHA-256 だけ(DFA 独自ストア。メインDBには書かない。D16)。本文なし。応答は `Cache-Control: no-store`。 + parameters: + - $ref: "#/components/parameters/DsSerialNumber" + responses: + "201": + description: 発行した + content: + application/json: + schema: { $ref: "#/components/schemas/DsAgentTokenIssued" } + "404": { $ref: "#/components/responses/DsDeviceNotFound" } + "422": { $ref: "#/components/responses/ValidationError" } + delete: + tags: [ds-devices] + summary: 制御盤トークンを失効させる + description: ★未発行でも204(冪等。P2)★。以後この制御盤はログを送れない。 + parameters: + - $ref: "#/components/parameters/DsSerialNumber" + responses: + "204": { description: 失効した(未発行だったときも) } + "404": { $ref: "#/components/responses/DsDeviceNotFound" } + "422": { $ref: "#/components/responses/ValidationError" } + + # --------------------------------------------------------------- ds-agent + /ds-agent/commands: + get: + tags: [ds-agent] + summary: 指示の取得(ロングポーリング) + description: | + 接続はすべて制御盤から開く(店舗のネットワークに入口を作らない。D2)。 + 指示があればすぐ200、無ければ `wait` 秒で204。同じ制御盤の2本目のポーリングが来たら古い方を204で返す。 + 認証の通ったすべての制御盤向けリクエストで最終接続日時を更新し、60秒を超えて途切れたら未接続とする(D19)。 + 中継はメモリ内で、★1プロセス前提・再起動で待ち行列は失われる★。 + security: + - agentBearer: [] + parameters: + - name: wait + in: query + schema: { type: integer, minimum: 1, maximum: 25, default: 25 } + description: 待つ秒数。範囲外・数でない値は422(★丸めない★ — 制御盤のプログラムの誤りを早く見つけるため) + - $ref: "#/components/parameters/XAgentVersion" + responses: + "200": + description: 指示 + content: + application/json: + schema: { $ref: "#/components/schemas/DsAgentCommand" } + "204": { description: 指示なし } + "401": { $ref: "#/components/responses/Unauthorized" } + "422": { $ref: "#/components/responses/ValidationError" } + + /ds-agent/commands/{command_id}/result: + post: + tags: [ds-agent] + summary: 一覧の結果(list の指示への応答) + description: | + 本体は4MB以下(読んだ長さで判定)・2万件以下。1件でも形が違えば結果全体を不正とする(400)。 + ★ブラウザの一覧は 502 `agent_error`(`agent_code` は `result_invalid`)で終わり、その指示は消える(その後の結果は 404)★。 + 失敗は `{ error: { code, message } }` で報告する(ブラウザには502 `agent_error`)。 + security: + - agentBearer: [] + parameters: + - $ref: "#/components/parameters/DsCommandId" + - $ref: "#/components/parameters/XAgentVersion" + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/DsAgentListResult" } + responses: + "204": { description: 受け取った } + "400": { $ref: "#/components/responses/DsResultInvalid" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/DsCommandNotFound" } + "410": + description: "`command_expired`(ブラウザ側の待ちが時間切れになった後)" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "422": { $ref: "#/components/responses/ValidationError" } + + /ds-agent/commands/{command_id}/prepared: + post: + tags: [ds-agent] + summary: エクスポートの準備完了(export の指示への応答) + description: | + zip を作り終えたら大きさ・SHA-256・ファイル数を報告する。D-Watch は `total_bytes` が100MB以下かを確かめ、 + 超えていればエクスポートを失敗にする(この応答は204のまま。後の send は来ない。D15)。 + `send` が来たら同じ zip を送る(作り直さない)。 + security: + - agentBearer: [] + parameters: + - $ref: "#/components/parameters/DsCommandId" + - $ref: "#/components/parameters/XAgentVersion" + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/DsAgentPrepared" } + responses: + "204": { description: 受け取った } + "400": { $ref: "#/components/responses/DsResultInvalid" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/DsCommandNotFound" } + "410": + description: "`command_expired`(ブラウザ側の待ちが時間切れになった後)" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "422": { $ref: "#/components/responses/ValidationError" } + + /ds-agent/commands/{command_id}/chunks/{seq}: + post: + tags: [ds-agent] + summary: zip のチャンク(send の指示を受けて送る) + description: | + ★URL の command_id は export の指示の id(D25 ①)★。send の指示の id へ送ると404。 + ★1チャンク 4MB 以下★(proxy が本体を10MBまでしかバッファせず、超えた分を黙って切り捨てるため。D1)。 + `seq` は0から連番。D-Watch は長さと `X-Chunk-Sha256` を検証し、最後に `complete` で合計と全体の SHA-256 を照合する。 + ★204 はブラウザ側に空きができてから返す(流量制御。最大45秒。超えたら410)★。 + 409 / 410 が返ったら送信をやめる。チャンクはメモリで中継するだけで、★サーバーには保存しない(D-179)★。 + security: + - agentBearer: [] + parameters: + - $ref: "#/components/parameters/DsCommandId" + - $ref: "#/components/parameters/DsChunkSeq" + - $ref: "#/components/parameters/XAgentVersion" + - name: X-Chunk-Sha256 + in: header + required: true + schema: { type: string, pattern: "^[0-9a-f]{64}$" } + description: このチャンクの SHA-256(小文字の16進64桁) + requestBody: + required: true + content: + application/octet-stream: + schema: { type: string, format: binary } + responses: + "204": { description: 受け取った(ブラウザ側に空きができた) } + "400": + description: "`chunk_invalid`(長さが0・4MB超・ハッシュ不一致・合計が準備のときの大きさを超えた)" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/DsCommandNotFound" } + "409": + description: "`chunk_out_of_order`(seq が連番でない・send の前・★前のチャンクの 204 を待たずに次を送った(J8)。このとき保留中だった前のチャンクの POST は 410 `export_cancelled` で返る★)。エクスポートは失敗になる" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + "410": { $ref: "#/components/responses/DsExportCancelled" } + "422": { $ref: "#/components/responses/ValidationError" } + + /ds-agent/commands/{command_id}/complete: + post: + tags: [ds-agent] + summary: zip の送信完了 + description: | + ★URL の command_id は export の指示の id(D25 ①)★。 + 受け取った合計 = 準備のときの `total_bytes` = この `total_bytes`、かつ受け取ったバイト列の SHA-256 = 準備のときの `sha256` = この `sha256` を確かめる(D15)。 + 一致しなければ400で、ブラウザ側のダウンロードは失敗になる。 + security: + - agentBearer: [] + parameters: + - $ref: "#/components/parameters/DsCommandId" + - $ref: "#/components/parameters/XAgentVersion" + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/DsAgentComplete" } + responses: + "204": { description: 完了した } + "400": { $ref: "#/components/responses/DsResultInvalid" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/DsCommandNotFound" } + "410": { $ref: "#/components/responses/DsExportCancelled" } + "422": { $ref: "#/components/responses/ValidationError" } + + /ds-agent/commands/{command_id}/fail: + post: + tags: [ds-agent] + summary: 指示の失敗の報告 + description: | + 準備中の失敗(`too_large` / `file_not_allowed` / `file_not_found` / `io_error` 等)は export の指示の id へ。 + ★送信中の失敗も export の指示の id へ★。`send` が来たのに zip が無いときは `export_not_found`(D25 ②)。 + `message` はログに載せない(画面にも出さない)。 + security: + - agentBearer: [] + parameters: + - $ref: "#/components/parameters/DsCommandId" + - $ref: "#/components/parameters/XAgentVersion" + requestBody: + required: true + content: + application/json: + schema: { $ref: "#/components/schemas/DsAgentFail" } + responses: + "204": { description: 受け取った } + "400": { $ref: "#/components/responses/DsResultInvalid" } + "401": { $ref: "#/components/responses/Unauthorized" } + "404": { $ref: "#/components/responses/DsCommandNotFound" } + "410": { $ref: "#/components/responses/DsExportCancelled" } + "422": { $ref: "#/components/responses/ValidationError" } + # -------------------------------------------------------------- ds-alerts /ds-alerts: get: @@ -2111,6 +2494,14 @@ `dwatch_session` cookie が発行される(httpOnly / Secure / SameSite=Lax / path=/)。 cookie の値は設定値 `AUTH_TOKEN` と定数時間比較で突き合わせる(D-072)。 判定は `proxy.ts` が全ルートで行う。 + agentBearer: + type: http + scheme: bearer + description: | + 制御盤ごとのトークン(32バイトの乱数を base64url にした43文字。D-179)。★`/ds-agent/` 配下だけ★で使い、cookie では通らない。 + ★それ以外のパスで Bearer を出されても無視する(cookie が要る)★。 + 発行・失効は画面から(`POST` / `DELETE /ds-devices/{serial_number}/agent-token`)。D-Watch は SHA-256 だけを保存する。 + IP 制限は他のパスと同じくかかる(D6。リリース時に扱いを決める)。 parameters: Page: @@ -2127,6 +2518,37 @@ required: true schema: { type: integer, minimum: 1 } description: 1以上の整数。0・負値・非整数・安全整数超は422で弾く(D-123) + DsSerialNumber: + name: serial_number + in: path + required: true + schema: { type: string, maxLength: 100 } + description: | + D-S制御盤のシリアル番号。100文字を超えると422。文字種は絞らない(D-123)。 + `/ds-devices/{serial_number}/note` と同じ規則。 + DsExportId: + name: export_id + in: path + required: true + schema: { type: string, minLength: 1, maxLength: 64 } + description: 準備の201で返した値。空と64文字超だけ422(文字種は絞らない。M8) + DsCommandId: + name: command_id + in: path + required: true + schema: { type: string, minLength: 1, maxLength: 64 } + description: 指示の id。空と64文字超だけ422(文字種は絞らない。M8) + DsChunkSeq: + name: seq + in: path + required: true + schema: { type: integer, minimum: 0, maximum: 999999999 } + description: 0から連番。0以上の整数で、先頭の0・符号・小数・空白・10桁以上は422(M8) + XAgentVersion: + name: X-Agent-Version + in: header + schema: { type: string, pattern: "^[0-9A-Za-z.+-]{1,32}$" } + description: 接続の記録に使う。形の合わない値は無視する(拒否しない) GroupId: name: group_id in: query @@ -2264,6 +2686,41 @@ content: application/json: schema: { $ref: "#/components/schemas/Error" } + DsDeviceNotFound: + description: 一覧に出ない制御盤(シリアルが無い・論理削除・controls.status が 0/1 以外。D-182) + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + DsAgentOffline: + description: "`agent_offline`。制御盤が未接続(最後のリクエストから60秒を超えた)。`details.last_seen_at` に最終接続日時(無ければ空配列)" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + DsAgentError: + description: "`agent_error`。制御盤がエラーを返した・制御盤の結果の形が不正。`details.agent_code` に制御盤の code(形の不正は `result_invalid`)" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + DsAgentTimeout: + description: "`agent_timeout`。制御盤から時間内に応答が無い(一覧15秒・準備50秒・最初のチャンク30秒。どれも nginx の60秒より短い)" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + DsResultInvalid: + description: "`result_invalid`。本体の形の誤り(JSON でない・オブジェクトでない・必須の項目が無い・型が違う)と値の不一致。★形の誤りは、`/result` ではブラウザの一覧を 502 で終わらせ(指示書BJ)、prepared・complete・fail では状態を変えない(D25 ④)。complete の値の不一致(合計・SHA-256)はエクスポートを失敗にする★" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + DsCommandNotFound: + description: 指示が無い・別の制御盤の指示・種類の違う指示(list の id に prepared 等)。403 にはしない + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } + DsExportCancelled: + description: "`export_cancelled`。エクスポートが取り消し・失敗・失効した後(ブラウザの切断・時間切れ等)。送信をやめる" + content: + application/json: + schema: { $ref: "#/components/schemas/Error" } schemas: @@ -2561,7 +3018,7 @@ precheck_minutes: type: integer example: 60 - description: 清掃開始予定時刻の何分前のチェックか(clean_timetables.precheck の生値)。null にならない(旧: launch_failed のとき null) + description: "清掃開始予定時刻の何分前のチェックか(clean_timetables.precheck の生値)。null にならない(旧: launch_failed のとき null)" serial_number: type: string example: "8111R5628060011" @@ -3190,6 +3647,161 @@ items: { $ref: "#/components/schemas/DsDevice" } meta: { $ref: "#/components/schemas/Meta" } + DsLogType: + type: object + description: ログの種類。値は制御盤の設定ファイルが決め、D-Watch は持たない(U5)。ブラウザ向けの `DsLogListResponse` と制御盤の一覧結果で共有し、制御盤から来た値をそのまま返す + required: [key, label] + properties: + key: + type: string + minLength: 1 + pattern: "^[^,\\s](?:[^,]*[^,\\s])?$" + example: "api_client" + description: 1文字以上・`,` を含まない・前後に空白が無い + label: { type: string, example: "API 通信" } + + DsLogFile: + type: object + description: ログファイル1件。ブラウザ向けの `DsLogListResponse` と制御盤の一覧結果で共有し、制御盤から来た値をそのまま返す + required: [file_id, name, type, size_bytes, modified_at] + properties: + file_id: { type: string, minLength: 1, maxLength: 8192, description: 制御盤が付けた id(D-Watch は解析しない) } + name: + type: string + example: "api_client/api_client.log.2026-10-18" + description: "`{root の name}/{root からの相対パス}`(zip の中のパスと同じ。D24)。D-Watch は解析しない" + type: { type: string, example: "api_client", description: "`DsLogType.key` のどれか" } + size_bytes: { type: integer, minimum: 0, example: 1048576 } + modified_at: { type: string, format: date-time, example: "2026-10-19T11:00:00+09:00" } + + DsAgentStatus: + type: object + required: [connected, last_seen_at, agent_version] + properties: + connected: { type: boolean } + last_seen_at: { type: string, format: date-time, nullable: true, description: 最終接続日時。一度も接続していなければ null } + agent_version: { type: string, nullable: true, example: "1.0.0" } + + DsLogListResponse: + type: object + required: [agent, types, items, truncated, meta] + properties: + agent: { $ref: "#/components/schemas/DsAgentStatus" } + types: + type: array + description: 制御盤から来た順 + items: { $ref: "#/components/schemas/DsLogType" } + items: + type: array + items: { $ref: "#/components/schemas/DsLogFile" } + truncated: + type: boolean + description: 制御盤が件数(2万件)か大きさ(4MB)で一覧を打ち切ったか。true なら新しいものから一部だけ(D18) + meta: { $ref: "#/components/schemas/Meta" } + + DsLogExportRequest: + type: object + required: [file_ids] + properties: + file_ids: + type: array + minItems: 1 + maxItems: 500 + uniqueItems: true + description: | + 各要素は 1〜8192 文字。加えて、配列を JSON にした大きさ(UTF-8)が 49152 バイト以内であること + (制御盤が poll の応答を 64KB までしか読まないため。指示書BK K7。JSON Schema では表せない) + items: { type: string, minLength: 1, maxLength: 8192 } + + DsLogExportResponse: + type: object + required: [export_id, download_url, total_bytes, file_count, expires_in_sec] + properties: + export_id: { type: string } + download_url: { type: string, example: "/api/ds-devices/DS-002/logs/exports/0b6f3c1e-8d2a-4f5e-9c7b-1a2b3c4d5e6f" } + total_bytes: { type: integer, minimum: 1, description: zip の大きさ } + file_count: { type: integer, minimum: 1 } + expires_in_sec: { type: integer, example: 60, description: この秒数以内に download_url を開かないと失効する } + + DsAgentTokenStatus: + type: object + required: [issued_at] + properties: + issued_at: { type: string, format: date-time, nullable: true, description: 未発行なら null } + + DsAgentTokenIssued: + type: object + required: [token, issued_at] + properties: + token: { type: string, pattern: "^[A-Za-z0-9_-]{43}$", description: ★この応答で1回だけ★。制御盤の設定ファイルに入れる } + issued_at: { type: string, format: date-time } + + DsAgentCommand: + type: object + required: [command_id, kind, params] + description: | + list: params は `{}`。 + export: params は `{ file_ids, max_bytes, chunk_bytes }`(zip を作って prepared を送る)。 + send: params は `{ export_command_id }`(準備した zip をチャンクで送る。チャンク・complete・fail の URL は export_command_id。D25 ①)。 + properties: + command_id: { type: string } + kind: { type: string, enum: [list, export, send] } + params: + type: object + properties: + file_ids: { type: array, items: { type: string } } + max_bytes: { type: integer, example: 104857600 } + chunk_bytes: { type: integer, example: 4194304 } + export_command_id: { type: string } + + DsAgentListResult: + description: 一覧の結果、または失敗の報告 + oneOf: + - type: object + required: [types, items, truncated] + properties: + types: + type: array + description: key は重複させない + items: { $ref: "#/components/schemas/DsLogType" } + items: + type: array + maxItems: 20000 + description: 更新日時の新しい順。file_id は重複させない。type は types のどれか。modified_at は必ずオフセット付き + items: { $ref: "#/components/schemas/DsLogFile" } + truncated: { type: boolean } + - type: object + required: [error] + properties: + error: + type: object + required: [code, message] + properties: + code: { type: string, minLength: 1, maxLength: 64 } + message: { type: string, minLength: 1, maxLength: 500 } + + DsAgentPrepared: + type: object + required: [total_bytes, sha256, file_count] + properties: + total_bytes: { type: integer, minimum: 1 } + sha256: { type: string, pattern: "^[0-9a-f]{64}$" } + file_count: { type: integer, minimum: 1 } + + DsAgentComplete: + type: object + required: [total_bytes, sha256] + properties: + total_bytes: { type: integer, minimum: 1 } + sha256: { type: string, pattern: "^[0-9a-f]{64}$" } + + DsAgentFail: + type: object + required: [code, message] + properties: + code: { type: string, minLength: 1, maxLength: 64, example: "export_not_found" } + message: { type: string, minLength: 1, maxLength: 500 } + DsAlert: type: object description: モックのDSA(type/dev/fac/tm/det/st/mm)に対応。トークン期限種別は除外 diff --git a/docs/code-map.md b/docs/code-map.md index 480ff57..284e11e 100644 --- a/docs/code-map.md +++ b/docs/code-map.md @@ -25,15 +25,15 @@ ★直した箇所・新しく書いた箇所の行番号は `a961829`〜`04f974a`(HEAD。`a961829` 以降はテスト1本と docs だけの変更)で `git show HEAD:` から確かめました★(D-171 までの節で `f2b91fc` と書いてある行番号は、D-173 で動かなかったファイルのもの)。それ以外は上のとおりです(★`lib/config/index.ts` は AU 以降で後ろにずれています。例: `MAX_CSV_ROWS` は `:469` → `:494`(`a961829`)★。D-161〜D-173 で触っていない行は直していません)。 - ★**第4版改訂5: 2026-10-19**(**D-180** / 指示書AX の実装 `d282e07` まで)★ — 指示書AX で古くなった D-Sec アラートの記述だけを直しました(取得元を `robot_error_reports` に・編集モード・`PATCH /ds-alerts/{id}/status` の削除・fixtures の `ds-alerts.ts` の削除・削除済みの施設・清掃エリアの行を出さない)。★直した箇所の行番号は `d282e07` で確かめました★。それ以外は上のとおりです - ★**第4版改訂6: 2026-10-19**(**D-181** / 指示書BA の実装 `b09e352` まで)★ — 編集モードのある3画面の見た目と D-Sec の文言を揃えた分だけ直しました(解消日時の判定 `showsResolvedTime()`・対応状況 120px・D-Sec の備考の入力欄の位置・変えた行の目印・アラートの「編集」の位置・D-Sec の状態ボタン「未解消のみ / 全件 / 解消済み」)。★直した箇所の行番号は `b09e352` で確かめました★。それ以外は上のとおりです -- ★**第4版改訂7: 2026-10-19**(**D-182** / 指示書BB)★ — データ層・API の止まる経路と削除済みの扱いを揃えた分だけ直しました(`lib/db/visibility.ts` を足した・ロボット0台/状態が 0/1/2 以外/心拍0件/直近モードのメタ欠けで例外にしない・`dsAlerts({ open_only })`・`ConsecutiveDbRange.facility_ids`・`parseDate()` の存在しない日付・`resolveTargetCustomer()` の AND・`weekStart()` / `cleanErrorLabel()` / `CLEAN_ERROR_LABELS` の削除・`/ds-devices` の `last_reported_at` が nullable)。★関数・型・設定の行番号と、本書で変わったファイルの行数は `86fea53`(コード)で引き直しました★(JSON ストアの書き込みの直列化 `c5fe519` を含む。各所の「行番号は `xxxx`」という注記よりこちらが新しい)。★関数名を伴わない位置の参照(`:N` だけのもの)は、その文を最後に書いた版から差分で写しただけ★で、それより前からずれていたものはずれたままです(関数名で探すこと)。D-182 の追記は他のセッションの未コミットの差分があるため監督待ち +- ★**第4版改訂7: 2026-10-19**(**D-182** / 指示書BB)★ — データ層・API の止まる経路と削除済みの扱いを揃えた分だけ直しました(`lib/db/visibility.ts` を足した・ロボット0台/状態が 0/1/2 以外/心拍0件/直近モードのメタ欠けで例外にしない・`dsAlerts({ open_only })`・`ConsecutiveDbRange.facility_ids`・`parseDate()` の存在しない日付・`resolveTargetCustomer()` の AND・`weekStart()` / `cleanErrorLabel()` / `CLEAN_ERROR_LABELS` の削除・`/ds-devices` の `last_reported_at` が nullable)。★関数・型・設定の行番号と、本書で変わったファイルの行数は `86fea53`(コード)で引き直しました★(JSON ストアの書き込みの直列化 `c5fe519` を含む。各所の「行番号は `xxxx`」という注記よりこちらが新しい)。★関数名を伴わない位置の参照(`:N` だけのもの)は、その文を最後に書いた版から差分で写しただけ★で、それより前からずれていたものはずれたままです(関数名で探すこと)。D-182 は 2026-11-05 に `docs/decisions.md` へ追記済み 期待値表(D-171 の N1〜N5・M1〜M6、D-173 の表1・表2)の中身は `docs/decisions.md` とテストを見てください。★D-172(第12回の候補への回答)のうち、レポートの書き込みの遅れ(30分を超えうる)は D-173 で解決済み★。★D-174(アドバイス B2 の根拠をエラーコードが決まったら本清掃のエラー内容に切り替える)は 5.8 に書きました★ -- ★**第4版改訂8: 2026-10-19**(**D-183** / 指示書BC の実装 `50a3082` まで)★ — 画面の古い応答・失敗の表示・表示設定の保存・D-Sec ログの備考の編集モードの分だけ直しました。★直した箇所の行番号は `50a3082` で確かめました★(画面のファイルの `:N` は `77f2f1b` → `50a3082` の差分で写し、変わった行を指すものは関数名・文言で引き直した)。それ以外は上のとおりです。D-183 の追記は他のセッションの未コミットの差分があるため監督待ち +- ★**第4版改訂8: 2026-10-19**(**D-183** / 指示書BC の実装 `50a3082` まで)★ — 画面の古い応答・失敗の表示・表示設定の保存・D-Sec ログの備考の編集モードの分だけ直しました。★直した箇所の行番号は `50a3082` で確かめました★(画面のファイルの `:N` は `77f2f1b` → `50a3082` の差分で写し、変わった行を指すものは関数名・文言で引き直した)。それ以外は上のとおりです。D-183 は 2026-11-05 に `docs/decisions.md` へ追記済み - ★**第4版改訂9: 2026-10-27**(**D-184** / 指示書BD の実装。★未コミットの作業ツリー★(`bb79de9` の上))★ — エラー一覧の「エラー内容」(`error_lists` の id)を API で選べるようにした分だけ直しました (新しい API ★`GET /api/error-lists`★(選択肢)・`PATCH /api/errors/{error_id}/status` が ★5列目の `error_list_id`★ も書く(★この経路だけ。事前チェックと D-Sec は4列のまま★)・ `ErrorReportItem` の3項目(`error_list_id` / `error_list` / `error_list_selectable`)・新しいファイル(`lib/db/error-list-source.ts`・`lib/errors/status-update.ts`・`lib/fixtures/error-lists.ts`)・ `DataSource.errorLists()`・設定2つ(`ERROR_LIST_NOTE_REQUIRED_IDS` / `ERROR_LIST_UNSELECTABLE_ERROR_TYPES`)・★`prisma/schema.prisma` の `ErrorList` と `error_list_id` は db pull できないための手書き(D-056 の例外)★)。 - ★直した箇所の行番号は 2026-10-27 の作業ツリーで `grep -n` して確かめました★。それ以外は上のとおりです。PDF は作り直していません。D-184 の追記は `docs/decisions.md` に他のセッションの未コミットの差分があるため監督待ち -- ★**第4版改訂10: 2026-10-27**(指示書BE。エラー一覧の画面でエラー内容を選ぶ)★ — 「種類」の2行目と選択窓(`ErrorContentPicker`)・エラー一覧専用の下書き(`lib/errors/edit-drafts.ts`)・その他の備考必須。★D-185 は `docs/decisions.md` に他のセッションの未コミット差分があるため書いていない★(案は指示書BE §11) + ★直した箇所の行番号は 2026-10-27 の作業ツリーで `grep -n` して確かめました★。それ以外は上のとおりです。PDF は作り直していません。D-184 は 2026-11-05 に `docs/decisions.md` へ追記済み +- ★**第4版改訂10: 2026-10-27**(指示書BE。エラー一覧の画面でエラー内容を選ぶ)★ — 「種類」の2行目と選択窓(`ErrorContentPicker`)・エラー一覧専用の下書き(`lib/errors/edit-drafts.ts`)・その他の備考必須。D-185 は 2026-11-05 に `docs/decisions.md` へ追記済み(案は指示書BE §11) - **この文書は「正」ではありません。** ルールの正は [CLAUDE.md](../CLAUDE.md)、構成と依存の向きの正は [docs/architecture.md](architecture.md)、決定の理由の正は [docs/decisions.md](decisions.md) です。 食い違ったら向こうが正しいと考え、この文書を直してください @@ -66,7 +66,7 @@ | ★`customers` の見出し語★ | — | 屋号 | ★**顧客**(EN "Customer" / "All Customers")★ | **D-158**(第11回 P-15=B) | | ★ダッシュボードの数値カード・表の母集団★ | 当日 | 当日 | ★**各清掃エリアの直近の清掃1件**(7日以内・★予定の終了 ≤ 現在★の予定のみ・出発失敗と結果待ちを含む)★。★旧(10/06): 終了+30分(`settleMinutes`)たった予定のみ。D-173 で廃止★。`?date=` 指定時は従来どおりその日。★スケジュール進捗・要レスキューは当日のまま★ | **D-159**(指示書AV)→ **D-173** | | ★清掃詳細ログの既定の期間★ | 今日1日 | 今日1日 | ★**期間を指定しない(全期間)**★ | **D-159** 追記(ユーザー指示「C」) | -| 予定外のタスク(`clean_timetable_id` が NULL) | — | (扱いなし) | ★完了率・実施回数には**入れない**。総清掃面積には**入れる**★ | D-155 項3 | +| 予定外のタスク(`clean_timetable_id` が NULL か 0) | — | (扱いなし) | ★**1件として数える**(予定 +1・開始 +1・`status = 4` なら正常終了 +1・1回ごとの完了率を平均に入れる。★今の期間だけ。確定期間は TeamWorks のサマリのまま★)。総清掃面積にも入れる★。旧(10/02〜10/29): 完了率・実施回数には入れない | D-155 項3 → **D-187**(指示書BG・A案) | | 清掃実績の取得元 | `clean_status_histories` | `clean_task_reports` | `clean_task_reports` | D-151 | | 清掃結果(`clean_task_reports.status`) | — | 数字のまま | ★表示名(0 清掃開始前〜5 清掃異常終了)★。**判定には使わない** | D-155 項2 | | ★接続先DBの呼び名★ | (外部DBは未接続。ローカルの MySQL のみ) | 「本番DBそのもの」 | ★**ステージング**(本番DBは別にある)★ | D-148 → **D-152** | @@ -436,7 +436,7 @@ | 出ないもの | 理由 | 根拠 | |---|---|---| -| ★予定外のタスク★(`clean_task_reports.clean_timetable_id` が NULL の行) | ①の予定から引くので、予定を持たない清掃は③の `in` の条件に一致しません。**欠損ではなく実在する清掃**ですが、この画面は予定が起点です | ユーザー回答 2026-10-01(D-152 3-3)。予定起点・実績起点・両方のどれにするかは、データが入ってから検討(D-151 項7) | +| ~~★予定外のタスク★~~ ★(2026-10-29 から出る。D-187)★ | 旧: ①の予定から引くので、予定を持たない清掃は③の `in` の条件に一致せず出なかった。★今は `loadUnscheduledCleanLogRows()`(`lib/db/unscheduled-clean-log-source.ts`)で `clean_timetable_id` が 0 か NULL の行を別に読み、1件1行で足す(期間は `started_at`)★ | D-152 3-3・D-151 項7 → **D-187**(指示書BG) | | まだ予定時刻が来ていない清掃 | `isFutureSchedule()`(`clean-log-mapping.ts:308`)で外します。外さないと、まだレポートが無い未来の清掃が全部「出発失敗」になります | D-107 設計3-C(`lib/db/index.ts:1110-1127` のコメント) | #### いまステージングで画面を開くとどう見えるか @@ -579,7 +579,7 @@ 2. `/detail-log` / `app/(dashboard)/detail-log/page.tsx` 3. `components/features/detail-log/DetailLogScreen.tsx`(★587行。`50a3082`★)。子部品: `CleanLogTable.tsx`(★394行。`50a3082`★。表)/ `AreaModal.tsx`(185行。エリア別完了率のモーダル) 4. `GET /api/clean-logs`(`:205`)、`GET /api/clean-logs/export`(CSV。`:516` のリンク)、`GET /api/clean-logs/{id}/advice`(`:253`)、`GET /api/clean-logs/{id}/areas`(`:297`)(★行番号は `f2b91fc`★) -5. **この画面の数字はどこから**: 1行=`clean_timetables`(★と `clean_timetable_histories`。2026-10-05 から★)の本清掃1件。結果は `clean_task_reports`(`clean_timetable_id` で突き合わせ)、事前チェックの状態は `robot_error_reports.error_type`。★完了率は `clean_task_reports.clean_area ÷ task_area`(上限100・小数第1位切り捨て)★で、`clean_ratio` 列は使いません(D-155。★第11回 P-2=A で確定。D-158★)。列ごとの対応表は[章2](#2-まず1本だけ追う--清掃詳細ログの数字が画面に出るまで)の 2.4。エリア別完了率のモーダルも同じ `clean_task_reports` の面積と `sections.section_name` から作ります(`lib/db/index.ts:1358`) +5. **この画面の数字はどこから**: 1行=`clean_timetables`(★と `clean_timetable_histories`。2026-10-05 から★)の本清掃1件、★または予定外のタスク(`clean_task_reports` の `clean_timetable_id` が NULL か 0)1件(2026-10-29 から。D-187)★。結果は `clean_task_reports`(`clean_timetable_id` で突き合わせ)、事前チェックの状態は `robot_error_reports.error_type`。★完了率は `clean_task_reports.clean_area ÷ task_area`(上限100・小数第1位切り捨て)★で、`clean_ratio` 列は使いません(D-155。★第11回 P-2=A で確定。D-158★)。列ごとの対応表は[章2](#2-まず1本だけ追う--清掃詳細ログの数字が画面に出るまで)の 2.4。エリア別完了率のモーダルも同じ `clean_task_reports` の面積と `sections.section_name` から作ります(`lib/db/index.ts:1358`) 6. 固有の注意点: - ★ページ送りあり★ - ★日付範囲の上限は **731日(2年)**★(`MAX_DATE_RANGE_DAYS`)。超えると画面側で止めます(`dateRangeError()` `:165`。★両方入っているときだけ★)。★初期表示は期間を指定しない(全期間)★(D-159 追記。旧: 今日1日)。★片方だけ入れたら入っている側だけを送ります★(5b 以前と同じ挙動。`lib/clean-logs/detail-log-date-range.ts`)。★期間なしの取得は Prisma 版で保持期間の予定を全部読むので、リリース前に見直す★ @@ -592,7 +592,7 @@ - ★「清掃結果」列は `clean_task_reports.status` の**表示名**です★(D-155 項2)。0 清掃開始前 / 1 清掃中 / 2 清掃中断 / 3 清掃キャンセル / 4 清掃正常終了 / 5 清掃異常終了。★表に無い値(6 など)は数字のまま★、レポート行が無ければ「出発失敗」(★ただし結果待ちの行は「結果待ち」。D-173★)。組み立ては `formatCleanTaskStatus()`(`lib/format/clean-result.ts:61`)を `cleanLogRowView()`(`:98`)経由で使い、画面では `t()` に通して英語にも切り替わります(`CleanLogTable.tsx:287`。`a961829`)。★判定には使いません★(中断・異常終了でも完了率で ◎○△× を出す) - ★**結果待ち**(D-173)★: 予定の開始を過ぎてレポートがまだ無い清掃のうち、★予定の終了から `RESULT_WAIT_MINUTES`(120分)以内のもの★(清掃中も含む)は、出発「…」・清掃結果「結果待ち」・完了率「-」・判定なしで出し、★行を開けません★(`result_pending`。見え方は `cleanLogRowView()` `lib/format/clean-result.ts:98` の1か所。`CleanLogTable.tsx:212-293`)。★「問題あり(△×)」「×のみ」の絞り込みからは外れ、「全件」にだけ出ます★(`lib/clean-logs/query.ts:186`)。CSV も出発・清掃結果が「結果待ち」、完了率・判定は空のセルです。120分を過ぎたら今までどおり「出発失敗」。詳しくは [5.9b](#59b-結果待ち--レポートがまだ届いていない清掃d-173) - ★`task_area` が 0 の清掃は完了率 0%(判定 ×)です★(`runCompletionRate()`。第11回 P-5=B。D-158)。旧(10/02〜10/05): 出発していても「-」 - - ★予定外のタスク(`clean_timetable_id` が NULL)はこの画面に出ません★(予定が起点のため。D-151 項7 で起点を検討中) + - ★予定外のタスク(`clean_timetable_id` が NULL か 0)も1行として出ます★(2026-10-29 から。指示書BG・D-187。旧: 予定が起点のため出なかった)。行の `id` は null・`row_key` は `rp-`(`report_id` は19桁の文字列)。予定の列は「予定なし」(D-188)。△× の行の ▼ は `GET /clean-logs/unscheduled/{report_id}/advice`(D-189)。エリア別詳細のボタンは予定のある行だけ - ★取得中に絞り込みを変えたら、遅れて届いた古い応答は捨てます★(取得の effect の片付けで `cancelled` にする。エラー一覧と同じ。D-183)。★アドバイスの取得に失敗したら「アドバイスの取得に失敗しました」★(「該当なし」と区別。`adviceFailedIds`)、★予定ありの行では、「エリア別詳細を見る →」はアドバイスの有無・成否によらず出します。予定なしの行には出しません★(指示書BI / D-183)。★CSV は期間の誤り・読み込み中は押せません★(`csvUnavailable`。D-183)。エリア別モーダルの★結果待ちの行は完了率「-」・札の代わりに「結果待ち」・赤字にしない★(`result_pending`。D-182 / D-183) --- @@ -653,7 +653,7 @@ - ★対応状況・備考・エラー内容は**編集モード**でだけ変えられます★(D-165 / 指示書BE。★対応状況と備考の動きは `/alerts`(3.5)と同じ★。エラー内容はエラー一覧だけ)。通常は表示だけ → 見出しの右(件数の右)の「編集」(読み込み中・0件・エラー表示中は押せない。`canStartEdit`)で入る → 対応状況はプルダウン(D-161。解消のときは下に解消日時(下書きで未解消・対応中に戻すと隠れる。D-181))、備考は入力欄、詳細の列はエラー内容(10/27 に種類の2行目から独立した列へ。長い文字は折り返す。指示書BE §12。選べる行はボタン → `ErrorContentPicker`)→ ★「保存(N件)」で変えた行だけを1行ずつ順に PATCH★(`saveEdit()` → `saveErrorDrafts()`)→ ★全部成功で通常モードに戻る・失敗した行は下書きを残して行の下に理由★。★その他(`note_required`)で備考が空の行は送らず、同じ件数に数える★。★編集中は絞り込みとページ送りを使えません★(`filtersDisabled`)。★下書きがあればページを離れる前に確認★(`beforeunload`)、「キャンセル」も確認してから捨てます(`cancelEdit()`)。★日時の規則は `/alerts` と同じ★。書けるのは4列に `error_list_id` を足した5列([章8](#8-データの出どころ) 8.12。D-184)。★保存に成功した行は返ってきた1行で差し替え、解消にした行も取り直すまで消えません★ - ★備考は1行で表示し、はみ出す分は「…」。全文は `title`(マウスを乗せる)★(`:635`。D-162)。★枠は列幅いっぱいの固定の大きさ★(D-164。旧: 入力した文字の長さの幅しかなかった)。編集欄も `resize: none`。★解消日時は 12px★(`.statusTime`。D-162) - ★書けるのは `robot_error_reports` に行があるものすべて★(事前チェック以外・`robot_id` が NULL の行も)。サーバー側は行が無い id・大小文字だけ違う id・★削除済みの施設・清掃エリアの行(U8。D-180 項11)★に404 - - ★表は `table-layout: fixed`+``(`:611`)で JP/EN の切り替えで列幅が動きません★。★列幅は D-166 → 2026-10-27 に「詳細」の列を足して改めた(指示書BE §12)★: 検知時刻 120 / シリアル 140 / レベル 90 / 紐づく予定 110 / 対応状況 150(中のバッジ・プルダウンは 120px 固定。D-181) / 備考 120px を固定し、★施設名と種類は表の幅の 9%、詳細は残りの幅すべて(最小 196px)★。★表の最小幅 ≒1129px★((730 + 196) ÷ 0.82。旧 1130px・1156px。狭い画面では表だけ横スクロール)。値は `ErrorsScreen.module.css` の `.table`。[3.15](#315-一覧画面に共通の見た目--分類の幅期間の入力表の列幅)。ただし★ツールバーは JP で「解消済みも表示」が2行目に回ります★(EN は1行) + - ★表は `table-layout: fixed`+``(`:611`)で JP/EN の切り替えで列幅が動きません★。★列幅は D-166 → 2026-10-27 に「詳細」の列を足して改めた(指示書BE §12)★: 検知時刻 120 / シリアル 140 / レベル 90 / 紐づく予定 110 / 対応状況 150(中のバッジ・プルダウンは 120px 固定。D-181) / 備考 223px を固定し、★施設名は表の幅の 9%・種類は 12%、詳細は残りの幅すべて(最小 60px)★。★表の最小幅 ≒1130px★((833 + 60) ÷ 0.79。旧 1132・1129・1156px。狭い画面では表だけ横スクロール。★割合を変えたら 0.79 も直す★。2026-10-27 の4回の手直しの経緯は CSS のコメント)。値は `ErrorsScreen.module.css` の `.table`。[3.15](#315-一覧画面に共通の見た目--分類の幅期間の入力表の列幅)。ただし★ツールバーは JP で「解消済みも表示」が2行目に回ります★(EN は1行) - ★期間の入力欄は枠のどこを押してもカレンダーが開き、ホールディングス・顧客のプルダウンは 180px 固定★(D-162) - ★i18n に「備考」→ "Note" を足したので、D-Secログの備考列の見出しも EN で "NOTE" になりました★(辞書は全画面で共有。7-8。★`f2b91fc` 時点でもそのまま★) - ★契約によるメニューの出し分けはありません★(全メニューと同じ。3.0) @@ -1166,7 +1166,7 @@ |---|---|---| | 〜2026-09-29 | Σ清掃面積 ÷ Σ清掃対象面積(期間の合計どうしの面積比) | 旧 `CLAUDE.md` §4.2 | | 2026-09-30〜10-01 | `clean_task_reports.clean_ratio` 列の単純平均 | D-151(指示書AQ 決定4) | -| ★2026-10-02〜(現在)★ | ★**1回ごとの完了率**(`min(clean_area ÷ task_area × 100, 100)` を小数第1位まで切り捨て)の単純平均。予定外のタスクは除く★。★2026-10-05 から `task_area = 0` は 0%★ | ★D-155(ユーザー判断「一旦B」2026-10-02)/ 指示書AS → ★D-158 で確定(第11回 P-2=A・P-5=B)/ 指示書AU★ | +| ★2026-10-02〜(現在)★ | ★**1回ごとの完了率**(`min(clean_area ÷ task_area × 100, 100)` を小数第1位まで切り捨て)の単純平均。予定外のタスク(0・NULL)も1件として入れる(2026-10-29 から。D-187。旧: 除く)★。★2026-10-05 から `task_area = 0` は 0%★ | ★D-155(ユーザー判断「一旦B」2026-10-02)/ 指示書AS → ★D-158 で確定(第11回 P-2=A・P-5=B)/ 指示書AU★ | あわせて ◎○△× の境界も 2026-09-16 に変わっています(「超」→「以上」。D-139。章5-2)。 @@ -1325,15 +1325,18 @@ | 条件 | 扱い | 根拠 | |---|---|---| | ★本清掃(`clean_timetables.precheck = 0`)に対応する行だけ★ | 事前チェック(`precheck` が 60 / 30 など)の予定に紐づく行は数えない | TeamWorks 第10回 N-3「事前確認の結果は含めない。`precheck` が0のデータのみ集計対象」(`TeamWorks回答_第10回_20261002.md:75`)。`CLEAN_TIMETABLE_PRECHECK.main = 0`(`lib/config/index.ts:272-275`) | -| ★`clean_timetable_id` が NULL の行(予定外のタスク)は完了率にも実施回数にも入れない★ | ただし★総清掃面積の面積の合計には入れる★ | D-155 項3。サマリが NULL を除く(N-3「当然 clean_timetable_id が NULL のデータも集計対象外」)のにそろえた。面積は実際に清掃した広さなので含める(ユーザー判断 2026-10-02) | +| ★予定外(`clean_timetable_id` が NULL か 0)も1件として入れる(今の期間だけ)★ | 予定 +1・開始 +1・`status = 4` なら正常終了 +1・1回ごとの完了率を平均に入れる。総清掃面積の面積の合計にも入る。★確定期間は TeamWorks のサマリのまま(サマリは予定外を除く)★ | D-155 項3 → **D-187**(指示書BG・A案。2026-10-29)。旧: 完了率・実施回数には入れない | | ★`task_area = 0` の行は1回ごとの完了率を 0%★ | 分子に 0 を足し、分母(件数)にも入れる。「実施」にも数える | ★第11回 P-5=B(D-158)/ 指示書AU 段階2★。旧: `null`(分子にも分母にも入れない。D-155 項1 / 指示書AS D4) | -★予定外を外している仕組みは画面によって違います★: +★予定外を数える仕組みは画面によって違います★(2026-10-29 から。D-187。旧: 予定から引くので「もともと入らない」): -| 画面 | 予定外を外している仕組み | +| 画面 | 予定外を数える仕組み | |---|---| -| 清掃詳細ログ・ダッシュボード・アラート・エリア別完了率・レポート(当期)・連続日数・要レスキュー | ★もともと入らない★ — どれも本清掃の予定(`clean_timetables` の `precheck = 0`)の id で `clean_task_reports` を引くので、NULL の行は `in` に一致しない。`latestReportByTimetableId()`(`lib/db/clean-log-mapping.ts:241-255`)も NULL の行を Map に入れない(`:247`) | -| ★総清掃面積(`/clean-area`)★ | ★面積は全行の `groupBy`、完了率・実施回数は別に取り寄せた行のうち本清掃に紐づくものだけ★(`cleanAreaStatsFromRows()` `lib/db/clean-area-source.ts:287-327`。`:308-309` の2行が判定) | +| 清掃詳細ログ・ダッシュボード | 予定から引いた行に、`loadUnscheduledCleanLogRows()`(`lib/db/unscheduled-clean-log-source.ts`)で読んだ予定外の行を足す(期間は `started_at`)。ダッシュボードの直近の候補は、同じ清掃エリアで開始が同時刻なら予定のある行を採る(`preferOnTie()` `lib/dashboard/latest-run.ts:59`) | +| 連続日数・要レスキュー・連続問題施設 | `lib/db/consecutive-source.ts` が `UNSCHEDULED_TIMETABLE_ID_WHERE` で予定外を読み、施設×日付の結果に入れる(予定外だけの日もスケジュール存在日。`CLAUDE.md` §4.5) | +| レポート(今の期間)・エラー内訳 | `findUnscheduledReports()` / `findUnscheduledErrors()`(`lib/db/unscheduled-report-source.ts`)で足す。★確定期間はサマリのまま★ | +| ★総清掃面積(`/clean-area`)★ | 面積は全行の `groupBy`。完了率・実施回数は `scheduledTimetableId()` が null(0・NULL)の行を予定外1件=実施1回として数える(`cleanAreaStatsFromRows()` `lib/db/clean-area-source.ts`) | +| アラート | ★出ない★ — 事前チェックの異常だけを出す(D-191) | ★予定は `clean_timetables` と `clean_timetable_histories` の両方から読みます★(TeamWorks 第10回 N-3「`clean_timetables` もしくは `clean_timetable_histories` の precheck が 0」/ 第11回 P-7=C。D-158 項4。指示書AU 段階3b。2026-10-05 から)。同じ id は `clean_timetables` 側を採ります(読み口は `lib/db/timetable-source.ts` の1か所。章2 の 2.4 の注)。 @@ -1634,7 +1637,7 @@ - 本清掃だけに絞り、★未来の予定を外します★(`consecutiveInputsFromRows()` `:235-265`) - ★清掃詳細ログ・施設別パフォーマンス・連続問題施設・要レスキューの4経路が同じ1本を使います★。以前は2箇所に似たコードがあり、同じ「連続3日」が画面によって違う数になりました(D-103 / D-109) -★**知っておくべき挙動(直すものではない)**★: `clean_task_reports.clean_timetable_id` が NULL の行(予定外のタスク)は、予定の id の `in` に一致しないので、ここには返って来ません(`:211-226` / `:373-377`)。予定外なので、どの予定の結果でもなく、完了率・実施回数に入らないのが正しい扱いです(D-155 項3)。ただし、予定された清掃の結果が何かの理由で NULL のまま届くと、その予定は「レポート行が無い」=★未実施に化けます★。例外もテスト失敗も出ないまま稼働率だけが静かに下がるので、未実施が多すぎるときは NULL の行数を疑ってください。 +★**知っておくべき挙動(直すものではない)**★: `clean_task_reports.clean_timetable_id` が NULL か 0 の行(予定外のタスク)は、予定の id の `in` に一致しないので、ここには返って来ません(`:211-226` / `:373-377`)。★予定外は別の読み口(`lib/db/unscheduled-clean-log-source.ts`・`consecutive-source.ts` の `UNSCHEDULED_TIMETABLE_ID_WHERE`)で足します★(2026-10-29 から。D-187。旧: 完了率・実施回数に入らないのが正しい扱いとしていた。D-155 項3)。ただし、予定された清掃の結果が何かの理由で NULL のまま届くと、その予定は「レポート行が無い」=★未実施に化けます★。例外もテスト失敗も出ないまま稼働率だけが静かに下がるので、未実施が多すぎるときは NULL の行数を疑ってください。 **変えると何が起きるか**: `isFutureSchedule()` の除外を外すと、まだ来ていない1週間ぶんの予定(`clean_timetables` には1週間分がまとめて登録される。D-106)が全部「出発失敗」になり、★全施設が常時 L3★ になります(D-106 / D-107 / D-110)。 diff --git a/docs/decisions.md b/docs/decisions.md index ca85fcf..73f4253 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -13035,6 +13035,56 @@ 6. ★M8 トークンは D-Watch の API と画面で発行・失効する★(既存の cookie 認証。生の値は発行の応答で1回だけ)。 ★本番のサーバーは TeamWorks が構築保守する(D-143)ので、サーバーでスクリプトを動かす前提を置かない★(`scripts/ds-agent-token.ts` は作らない) 7. ★指示書AY の Q14・Q15 を採用★: 発行日時の読み取り `GET /api/ds-devices/{serial_number}/agent-token` → 200 `{ issued_at: string | null }`(★トークンの値は返さない★)/未発行の `DELETE` も 204(冪等)。★ブラウザ向け API は6本★(一覧・エクスポートの準備・ダウンロード・トークンの読み取り/発行/失効) +- ★追記(2026-10-19 同日・4回目。制御盤の実測・更新の仕組み・第13回の提出・指示書AY の見直し)★: + 1. ★第13回を TeamWorks に提出した★(2026-10-19。ユーザー申告)。送ったのは `docs/handoff/pdf/DWatch_確認事項_第13回_20261019.pdf`(原稿 `docs/handoff/TeamWorks確認事項_第13回_20261019_送付版.md`・S-1〜S-6。生成・点検のスクリプトは `cb0241a`)。★回答が来たら受領日をここに書く★ + 2. ★制御盤の開発機(T000000002)と本番機(例 DFAS010003_アピタ金沢文庫)を読み取りだけで確かめた★(設計書 §1.6・§1.6.1。本番機は Claude のツールからは安全確認で止まるため、ユーザーが打った結果)。 + ログは `/home/dfa/log/<プログラム名>/` に4種類(約1.2MB)。本番機の `~/log` 直下の6ファイルは古い版の名残で対象外 + 3. ★本番機の更新は毎日12:00 に root で `git fetch origin master` → `git reset --hard origin/master`(/home/dfa)・再起動なし★。届くのは master に push したもの + 4. ★更新スクリプトの改訂案★(設計書 §5.13.1。ユーザー要望「差分があって更新したら再起動、差分がなければそのまま」・再起動はラズパイごと)。 + ★更新日の 12:00 過ぎに出うる生死報告なしのアラートは許容する★(ユーザー判断「この時間帯のアラートであればなんの問題もないのでスルーでいい」)。本番機への導入はラズパイの担当(未実施) + 5. ★指示書AY を `99e5818`(指示書AX・AZ・BA の実装の後)に合わせて見直した★(指示書AY §11-3)。D-Sec ログ画面(`DsLogScreen.tsx`)は AY の作成時から変わっていない。行番号のずれ・`request.signal` の参照先・モーダルの Esc/フォーカス/スクロールの扱い・「ログ」列の幅・fixtures の種類を実機に合わせることを直した。★AY は新しい D 番号を取らず、実装の記録は本項の後ろに足す★(★2026-11-05 訂正: 10-19 の BC 段階4(`bce6a93`)で編集モードが入った★) + - ★記録の事故★: 本項は一度書いたが、別のセッションの `cc09649`(D-181)のコミットで作業ツリーの未コミットの追記ごと上書きされて消えたので、書き直した(2026-10-19) +- ★追記(2026-11-05。指示書AY を `7492cc5` に合わせて見直した)★: 詳細は指示書AY §11-4。★AY は新しい D 番号を取らない★(実装の記録は本項の後ろに足す)。 + 1. ★前提が崩れていた4件★: ① D-Sec ログ画面に備考の編集モードが入った(BC 段階4 `bce6a93`。編集/保存(N件)/キャンセル・`
`・`COLUMN_COUNT`)② AreaModal に Esc が入った(BC 5-4)③ Next.js 16.3.3 → 16.4.2(proxy の 10MB の切り捨ては同じ)④ `json-store` の書き込みが直列化された(AY の使い方は崩れない) + 2. ★ユーザー回答(2026-11-05)U1★: ★「ログ」ボタンは編集モード中は押せない★(「編集中は押せない」。絞り込み・ページ送りと同じ扱いで下書きを守る。AY の本文では §2-1 U6) + 3. ★監督の裁定 M1〜M20(要約)★: M1 「ログ」列は D-167 を崩さず、列が少し広がるのは認めてボタンを固定幅に・最小幅 800 → 872px・`COLUMN_COUNT` を 6 に/M2 Tab の閉じ込めと背景のクリックの判定関数を `lib/ui/modal.ts` に移して共有(`lib/errors/error-content-picker.ts` は再 export)・Esc は AreaModal、フォーカスは ErrorContentPicker に倣い、背景のスクロールの停止は自前/M3 `全て: "All"` と0件の文言を辞書に足す(既存の「全て」ボタンも EN で "All")/M4 古い応答は `cancelled = true` の作法で捨てる/M5 失効の DELETE(204)は素の `fetch`・取り消しはエラー表示しない/M6 一覧の `name` は `{root の name}/{相対パス}`(2026-10-20 の設計書の変更)/M7 空のクエリは省略と同じ・`page` / `per_page` は丸める・存在しない日付は 422・オブジェクトでない JSON はブラウザ向け 422・制御盤向け 400 `result_invalid`/M8 `export_id`・`command_id` は `parsePathReportId()` と同じ作法、`seq` は 0 を許す専用の検証/M9 並べ替えはボタン2つのまま(`sort` は向きを持たない)/M10 `agent_code` をログの表に・`ds_agent.auth_failed` に `method`・`docs/logging-design.md` への追記は実装担当/M11 404 の description は note と同じ文(D-182)/M12 agent の前処理は lib に切り出してよい/M13 トークンのストアはストアとパスを組で差し替える/M14 段階11-2 は起動時にも `USE_FIXTURES=true`/M15 段階11-3 はシードとアプリの両方の接続先をローカルに向ける/M16 本物の常駐プログラムでの通し確認を足す/M17 ダウンロードの確認は `fetch` で行い、画面での保存はユーザーの許可を取って1回だけ/M18 設計書と常駐プログラムの README の `:3000` を `:3100` に(設計書の担当)・ステージングのコンテナを作り直すと `data/settings` が消える恐れを TeamWorks に確認(監督)/M19 Next 16.4.2 の参照を直す/M20 並べ替えに BH の `SortableTh` は使わない + 4. ★コードの変更なし(記録のみ)★ + +- ★実装の記録(2026-11-05。指示書AY)★: + 1. ★コミット★: 段階1 `3c7bcc8`/段階2 `7189ae1`/段階3 `c2812ae`/段階4 `ab77624`/段階5 `dee8006`/段階6 `466b4fa`/段階8 `2b5415d`/段階7 `6aa669b`/段階9 `ff85e4c`/段階10 `bfc2fe2`/偽制御盤スクリプト `e3669fa`/実装の記録 `9e928bf`/lint 修正 `3b98f71` + 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. ★確認★: テスト件数 ★前 157ファイル・3,855件(`7492cc5`)→ 後 169ファイル・4,260件(`e3669fa` 時点。`npm test` で確認)★/★fixtures(:3100・`next start`・当方の fixtures 由来)★: DS-002 接続中・種類4つ・`name` が `<種類>/<ファイル名>`・Esc で閉じて「ログ」へフォーカス復帰・編集モード中は「ログ」disabled・POST exports→GET でヘッダ5つ・SHA-256 が `FIXTURE_EXPORT_ZIP_BASE64` と一致・2回目 GET は 404・DS-001 は未接続表示でもトークン発行/失効可/★非 fixtures(偽制御盤・本物 `agent.py`)★: ★未実施★(`MySQL84` が Stopped。管理者で起動後に段階11-3) + 7. ★本書と違う判断をしたところ★: なし。一覧の待ち(R2)だけは表どおり ★ちょうど `LIST_TIMEOUT_SEC` で時間切れ(inclusive)★、他の待ち(未接続 R4・準備・最初のチャンク・間隔 等)は ★ちょうどはまだ有効・1ms 超えたら期限切れ(exclusive)★ — R2 を字義どおり採り、他行は exclusive のまま + +- ★点検の指摘の直し(2026-11-05。指示書BJ)★: + 1. ★制御盤から形の崩れた一覧が来たら、ブラウザの一覧をすぐ 502 `agent_error`(`agent_code` `result_invalid`)で終わらせる★(ユーザー回答 2026-11-05「A」)。制御盤には今までどおり 400。指示書AY の段階7-2(502)と表5 R43(状態は変えない)の食い違いは、一覧については段階7-2 を採る。prepared・complete・fail は R43 のまま + - 却下: B(15秒待って 504「応答がありません」)— 原因が制御盤側の形の誤りだと分からない + 2. 画面は取り消し(`AbortError`)だけ黙り、通信の失敗は文言を出して一覧を空にする + 3. 制御盤の一覧の `key` は1文字以上で `,` を含まず前後に空白が無い・`file_id` は1〜1024文字・prepared(とブラウザ向けの応答・complete)の `total_bytes` は1以上(指示書AY 段階7-2 の「0以上」を取り消す) + 4. チャンクの POST が保留中に次のチャンクが来たら `chunk_out_of_order` でエクスポートを失敗にする(並列送信は約束違反。孤立したタイマーで後から落ちるのを防ぐ) + 5. 接続のイベントの版は認証の時点で `X-Agent-Version` から入れる + 6. モーダルを開いたときの一覧の問い合わせは1回(全種類のときは `types` を付けない)。制御盤に種類が無いときは「種類を1つ以上選んでください」を出さない。JP/EN でモーダルのボタンの位置と幅を動かさない(期間・並べ替え・トークンのボタンを固定幅に) + 6b. 一覧が `agent_error` で失敗したときは見出しを「接続中」にする(制御盤は結果を返している) + 7. ★残したもの★: `eslint-disable` が2行(指示書AY 4-19 は1行。どちらも `set-state-in-effect` で実害なし)・段階11-1 の grep がテストのファイル(スパイと `mkdtemp`)と型のコメント(「`validation_error` は足さない」)に当たる(指示書AY の grep の書き方の問題。テストが必要とする記述)・種類を素早く切り替えたときに制御盤への一覧の依頼が溜まりうる(推測。待ち行列5件を超えると 429。実機で起きたら直す) + 8. ★実装の記録★: コミット 段階1 `d99725d`/段階2 `332e54c`/段階3 `aa08dfd`/段階4 `a3e3934`/段階5 `f52e935`/段階6 `93804b6`/テスト件数 ★前 169ファイル・4,260件(`ae30f4a`)→ 後 170ファイル・4,306件(`f52e935`。`vitest` 全件を3回とも通過)★/★fixtures(:3100・`next start`・当方の fixtures 由来)★: J4 ボタン x/y/幅の JP−EN ずれ0(期間112・並べ替え100・トークン140)/J10 開いた直後の一覧 GET が1回(`types` なし)・種類を外すと `types=` 付き1回/J12 逆順で「開始日は終了日以前にしてください」(EN 英文)/J1 通信失敗で文言+一覧空→復帰可/本書と違う判断: なし + +- ★指示書BJ の点検の直し(2026-11-05。指示書BK)★: + 1. ★制御盤のファイル ID の上限を 1024 → 8192 にした★(ユーザー回答 2026-11-05「A」)。制御盤の常駐プログラムが受け取れる上限(`agent.py` の `FILE_ID_MAX_LEN`。作る側には上限が無い。8192 を超えるには相対パスが約6.1KB 要るので、Linux のパスの上限(4096)からまず起きない — 推測)とそろえる。1024 のままだと、長いパス(約766バイト超)のファイルが1つあるだけで一覧全体が「制御盤の結果の形が不正です」になる + - 却下: B(1024 のまま、制御盤側で長いものを一覧から外す)— 別リポジトリの改修が要り、外したファイルは画面から取り出せない + - 帰結: エクスポートの本体は最大 500件 × 8192文字 ≒ 4MB(proxy の 10MB より小さい)。本番の nginx の `client_max_body_size` は TeamWorks の設定で、ブラウザの POST(`/api/ds-devices/…/exports`)は既定(1MB)のままと思われる(第13回で 5MB を頼んだのは `/api/ds-agent/` 配下だけ)ので、長い ID を大量に選んだときだけ 413 になりうる + - ★先に効く上限★: 制御盤は poll の応答を 64KB までしか読まない(`agent.py` の `RESPONSE_READ_MAX_BYTES`)。エクスポートの指示に入る `file_ids` の合計が約64KB を超えると指示が壊れ、ブラウザは 50秒後に 504。1024 のときからある問題(1024文字なら約64件・8192文字なら約8件で超える)。今の ID(30〜40文字)なら500件でも約20KB。★項6 で手当てした★ + - BJ の記録の項3 の「`file_id` は1〜1024文字」を本項で改める + 2. complete の `total_bytes: 0` を 400 にすること(BJ §2-3 の YAML・本記録の BJ の項3 のとおり)にテストが無かったので足した。★BJ §2-3 の括弧書き(「0 は来ても必ず失敗する」)が想定した『照合の不一致でエクスポートを即失敗』ではなく、『400・状態は変えない』(エクスポートは待ちの時間切れまで残る)になっている★。正しい流れでは来ないので、このままとする + 3. テストの見張りを効かせた: `key` の不正(R45〜R46b)は items の種類も同じ key にしてメッセージまで見る/「ディスクに書かない」は静的な検査でモジュール名の文字列と `require(` を禁じる(★指示書BJ の G3 に監督が書いた壊し方 `await import("node:fs")` はスパイでも旧い静的な検査でも捕まらない壊し方だった★)/E6 は「100 MB」を固定/R43′ の 404 は `not_found` まで見る/ブラウザの 502(`result_invalid`)をルートを通して見る + 4. ★実装の記録★: コミット 段階1 `fc4544f`/段階2 `5828dcb`/段階3 `888c9e0`/段階4 `99ba9e8`/段階4b `412dd11`/段階5 `71ec125`/テスト件数 ★前 170ファイル・4,306件(`53f46e0`)→ 後 170ファイル・4,315件(`71ec125`。`vitest` 全件を3回とも通過)★/G3 の赤の確認(K1・K2×3・K3・K4・K5・K6・K7×3)済/本書と違う判断: なし(K7-6 の英語は `apiErrorMessage(e, translate("en"))` 経由で見る — G3 (c) が赤になるため) + 5. ★指示書BJ の画面確認の補い(監督。2026-11-05。当方の fixtures 由来)★: BJ の記録の項8 に無かった次を監督が確かめた — J10 の「種類を戻すと `types` なしで1回」・Esc で閉じて「ログ」ボタンにフォーカスが戻る・D-Sec ログの表に横スクロールが出ない(1440×900・JP)・エクスポートの `fetch` 確認(201 → 200・SHA-256 一致・2回目 404・`X-Accel-Buffering: no`・`Content-Encoding` なし)。★未確認★: ダーク/ライト・トークンを表示したときのコピーのボタンの折り返し・DS-001 の未接続・Tab の閉じ込め・背景のクリック・100MB の警告(いずれも BJ で触っていない部分か、BJ 前の AY の点検で確認済み) + 6. ★エクスポートで選ぶ `file_ids` の合計に上限を足した★(ユーザー回答 2026-11-05「A」): `file_ids` を JSON にした大きさ(UTF-8)が `EXPORT_FILE_IDS_MAX_BYTES`(48KB)を超えたら 422「選んだファイルが多すぎます。数を減らしてください」。制御盤は D-Watch の応答を 64KB までしか読まず(`agent.py` の `RESPONSE_READ_MAX_BYTES`)、エクスポートの指示は poll の応答に `file_ids` を全部入れて届くため。超えると指示が途中で切れ、ブラウザは 50秒待って原因の分からない 504 になっていた(1024 のときからあった) + - 却下: B(制御盤の読み取りの上限を上げる)— 別リポジトリの改修と本番機への反映が要る。C(何もしない)— 今の ID(30〜40文字)では起きないが、起きたときに原因が画面から分からない + - 却下: 画面で選択中に先回りしてボタンを止める — 今の ID では500件でも約21,500バイトで効かないので、サーバーの 422 と文言だけにした + - 帰結: 8192文字の ID なら1回5件まで。平均95文字以下の ID なら先に500件の上限が効く。★制御盤の上限(64KB)を変えるときは、この値も見直す★ --- @@ -13114,3 +13164,335 @@ - ★見出しの下の余白(段階0-4)★: 測れず。アラートは段階0未計測のため 2-6c で 16px を採用。エラー一覧・D-Sec もアプリ内ブラウザ未使用のため未計測(監督予想は 16 / 16 / 20) - ★画面確認★: 監督に任せる(アプリ内ブラウザ未使用。当方の fixtures 由来での確認は未実施) - ★指示書と違う判断★: (1) 見出し余白未計測のためアラート `.header` の `margin-bottom` を 16px とした(2-6c)。(2) 段階5 の grep(`components` 内に「未解消・対応中」0件)を通すため、コメント表記を言い換えるコミット `99e5818` を計画外で足した(ロジック変更なし)。(3) 資料コミットは段階5の確認後に1つにまとめた + + +--- + +### D-182 `[決定]` 削除済みの施設・清掃エリアを全画面で出さない/1行の想定外のデータで画面を止めない(データ層・API の総点検) + +**日付**: 2026-10-19 / 実装: Claude(指示書BB。★実装担当はユーザー決定 2026-10-19★) + +★根拠★: 監督のデータ層・API の総点検(2026-10-19・`2fc3bbb`。読み取りのみ・当方のコード)/ユーザー決定 2026-10-19(指示書BB §2-1 の U1〜U4・A1〜A20。★指示書には要約だけで原文の引用は無い★)/本書の設計判断 D1〜D24(指示書BB §2-2。★監督の承認の記録は無い — 指示書の表記は「確認待ち」のまま★)。下書きは指示書BB 付録A。 + +1. ★U1: 削除済み(`deleted_at` が NULL でない)の施設・清掃エリアの行は全画面で出さない★(★D-180 項11(エラー一覧と D-Sec アラートだけ)を全画面に広げる★)。対象: 清掃詳細ログ・CSV・`/clean-logs/{id}/*`・ダッシュボード・アラート(★見えない行への `PATCH /prechecks/{id}/status` は404★)・スケジュール・レポート(当期/エラー種別内訳)・連続日数・総清掃面積・エリア別完了率。 + ★規則は `lib/db/visibility.ts` の1か所★(`isVisibleFacilitySection()` が正・`FACILITY_SECTION_VISIBLE_WHERE` が写し。`isVisibleErrorRow()` は名前を残して委ねる)。`deleted_at` を運ばない表(予定2表・`clean_schedules`・`clean_task_reports`・サマリ)は SQL のリレーション条件だけで外す(`IS NULL` は SQL と JS で同じ・fixtures に削除の概念が無い)。 + ★施設名・分類 → 施設ID の解決は絞らない(罠7)★ — 理由を「解決は行の可視性と別の責務」に書き直した(旧理由「削除済みのログは `cleanLogs()` に出ている」は U1 で失効)。★連続日数の入力からも外す★(表示から消えた行が生きている施設の L1〜L3 を押し上げないため。値が短くなりうるのは意図した結果) +2. ★制約(D5)★: 確定期間のサマリは再計算しない(D-102)— `customer_summaries` は何も外せない/`facility_summaries`・`facility_error_summaries` は施設の削除だけ外せる。★当期と確定期間で母集団が違いうる★(TeamWorks への質問の候補。指示書BB §7-4) +3. ★U2: 有効なロボットがいない清掃エリアで500にしない★ — `serial_number` / `model_name` を `null`(API を nullable に)・画面は「-」(D-183)・CSV は空のセル・`data.anomaly`。★`robots` の並びは `id` 昇順★(作成日時の新しい順=新しい機体、と読むのは推測なので採らない) +4. ★U3: `robot_error_reports.status` が 0/1/2 以外の行は一覧から外し、1件読みは404★(`isKnownRobotErrorStatus()` の1本)。★指示書AW §11 Q3 の「PATCH の1件読みは500(fail-loud)」を上書き★ +5. ★U4: 清掃ログの行を開くとき(`/advice`・`/areas`)の連続日数の全期間読みは直さない★ — TeamWorks が `clean_task_reports` / `robot_error_reports` の `clean_timetable_id` に索引を足してから(依頼文の下書きは指示書BB §7-1) +6. `/ds-devices`: 心拍がまだ無い制御盤は `last_reported_at: null` で出す/`controls.status` が 0/1 以外は出さない(備考の保存も404)。★「異常」に寄せるのは推測なので採らない★ +7. その他: 直近モードのメタ欠けは選ばずにログ(重複は今どおり例外)/エリア別完了率は結果待ち(D-173)を問題に数えず `result_pending` を足す/CSV の日付は `YYYY/MM/DD`/存在しない日付・その月に無い週・`/prechecks` の逆の期間は422/★`/reports/kpi` の `customer_id` と `group_id` は AND(傘下でなければ 200・空。★指示書AK §2-1 の「customer_id が勝つ」を上書き★)/`try` の外の例外・オブジェクトでない本文・`rule: "constructor"` を標準の 500 / 422 に/連続日数の入力を `facility_ids` で絞る/ダッシュボードの `dsAlerts({ open_only: true })`/R1 の閾値を `< level2Days` に/未使用の `weekStart()`・`cleanErrorLabel()`・`CLEAN_ERROR_LABELS` を削除/`engines: node >=22.12`・`@types/node ^22`・`@next/env` を `next` と同じ版に +- ★却下★: U2 の表示「対象なし」(判断を含む新しい語で訳も要る。「-」は既存の約束)/API に「-」の文字列を入れる(CSV で数式対策の `'-` になる。D-164 と同じ事故)/`dsAlerts()` を読まない(バナーの絞り込みのテストと D-180 項7 の「コードを残す」を壊す)/連続日数の「1リクエストで1回」(`DataSource` の契約を変える大きさの割に得が小さい。指示書BB §7-2)/`@types/node ^24`(22 に無い API を書いても `tsc` が通る) + +#### 実装の記録 + +- ★2026-10-19 指示書BB(Claude)★: + - ★コミット★: `370ac96`(段階1)/`28d008d`(段階2)/`d9ac19a`・`4c9642b`(段階3)/`a16ffd2`(段階4)/`33c5506`・`afb7b7a`(段階5)/`5e49979`(段階6 資料)/`95d5299`(段階7 の grep)/`c5fe519`(JSON ストアの直列化)/`86fea53`(表13・表8 H・表2 R9)/`77f2f1b`(YAML・code-map の引き直し)。指示書は `842be25` + - ★テスト★: BB 前 130ファイル / 3,083件(`2fc3bbb`。指示書BB:66 の作成者の実測)→ BB 後 ★135ファイル(`77f2f1b` の `git ls-tree` で数えた。構造)・件数は記録なし★(指示書BC の見込み「BC で +40件」と BC 後 3,284件から逆算すると 3,244件(推測)) + - ★ステージングの影響件数(段階7-6)・ローカルDB(段階7-7)★: 記録なし + - ★指示書と違う判断★: (1) ★JSON ストア(D20)★ — 指示書は「一時ファイル名を一意にする・直列化はしない」。Windows で同時5回の rename が5回とも EPERM になったため(`a16ffd2` では J1 の試験を入れず)、`c5fe519` で ★ファイルパスごとのプロセス内の直列化を足した★(一意の一時ファイル名も残す。`lib/settings/json-store.ts:12` に「監督の判断 2026-10-19」)。(2) ★表13(A11)★ — `86fea53` で `data.anomaly`(`customer_summary_rows`)に顧客・期間キーを載せ、★終わった期間だけ出す★(`isPastPeriod()` を `lib/format/period.ts` に新設)ように変え、★期待値表の L1 を更新・L4 を追加した★(期待値表は正のはずだが、変更の承認の記録は無い)。(3) A10 で `lib/settings/advice.ts` の `assertKnownRule()` も `Object.hasOwn` に(同じ穴)。(4) YAML に `/dashboard` の 422・500、advice の 500、`date_to` の存在しない日付の422 を足した(`77f2f1b`)。(5) D-182 は `docs/decisions.md` に他のセッションの未コミットの差分があったため追記せず監督待ち(`5e49979` のメッセージ)→ 本項は 2026-11-05 に後から記録した + - ★後の変更★: `@next/env` は `bb79de9` で 16.4.2(`next` と揃える原則は同じ)/`6046ab0`(2026-10-29)でアラートが出発失敗の行を出さなくなり、項3 のアラートの「-」は出番が無い(推測。コードは未確認)/`771fb97`(指示書BG)で予定外タスクを集計に入れた + +--- + +### D-183 `[決定]` 画面の古い応答を捨て、失敗・空の表示を分け、表示設定を保存し、D-Sec ログの備考を編集モードにする + +**日付**: 2026-10-19 / 実装: Claude(指示書BC。★ユーザー決定で Claude★) + +★根拠★: 監督の画面側の見直し(2026-10-19・`2fc3bbb`)/ユーザー決定 2026-10-19 U1〜U4(指示書BC §1。★要約だけで原文の引用は無い★)/監督の承認 2026-10-19(指示書BC §10 Q1・Q2・Q3・Q6・Q7・Q9)。★データ層・API・型は D-182★で、本項はその型(`null`・`result_pending`)を画面で出す(指示書BB §8 B1〜B5)。下書きは指示書BC 6-2。 + +1. ★U4 取得中に条件を変えたら、遅れて届いた古い応答は捨てる★(清掃詳細ログ・アラート・D-Sec ログ・施設マスタ・スケジュール・レポート)。5画面は effect の片付けで `cancelled`、レポートは ★結果にクエリを持たせる★形(エラー一覧と同じ)。レポートは期間・分類が変わったときだけ画面全体を「読み込み中…」に、清掃面積は粒度タブで表だけ薄くする +2. ★アドバイスの取得失敗は「アドバイスの取得に失敗しました」★(「該当なし」と区別)・「エリア別詳細を見る →」は常に出す/CSV は期間の誤り・読み込み中は押せない/アラートの期間はエラー一覧と同じ検証と「指定なし(全期間)」/レポートの全件表示が上限(200)で切れたら注記 +3. ★U1 ダーク/ライトと JP/EN は localStorage(`dwatch.theme` / `dwatch.lang`。`lib/ui-prefs.ts:14-15`)に保存★。★`` のインラインスクリプトで描く前に `` の `data-theme` / `lang` を入れ、`ThemeProvider` は既定で始めて `useLayoutEffect` で読む★(案B。Next.js 16 のガイド「preventing-flash-before-hydration」)。★残るもの★: ハイドレーションより前に描かれたとき、JS で色・文字を決めている部分が一瞬ダーク・日本語。★CSP を入れるならこのスクリプトに nonce が要る★(`CLAUDE.md` §3 のリリース前の一覧に `e3d8b33` で追加) +4. ★U3 D-Sec ログの備考は、他の3画面と同じ編集モード★(編集 / 保存(N件)/ キャンセル・変えた行だけ順に保存・失敗した行は下書きと文言を残す)。保存先は今までどおり `data/settings/ds-device-notes.json`(API は変えない)。下書きの規則は `lib/ds-devices/note-drafts.ts`(行に対応状況が無いので `lib/prechecks/edit-drafts.ts` とは別に持つ)。★通常モードの空の備考は何も出さない★(旧「-」)。★旧: セルを押して入力・blur で1件ずつ保存(D-171 まで)は廃止★ +5. ★U2 清掃詳細ログの凡例「(閾値はシステム設定で変更可能)」は残す★(あとで閾値を編集できるようにする。今は表示だけ — 事実と違う状態は承知のうえ。指示書BC §7-1) +6. KPI カードの前期比は丸めて0なら「→」・色なし(`kpiDeltaView()`)/ログインの失敗は 401 / 422 / 403 / 429 / それ以外で分ける(`loginErrorMessage()`)/押せるカードと開ける行をキーボードで操作・エリア別モーダルは Esc で閉じる/D-182 の `null` は「-」(`t()` に通さない)・`result_pending` のエリアは「結果待ち」 +- ★却下★: 案A `useEffect` で読むだけ(再読み込みのたびに画面全体がちらつく)/案C cookie に保存しルートの layout で `cookies()`(ユーザー決定は localStorage・全ページが動的になる。ちらつきが困るなら再提案)/`useState(() => localStorage…)`(23ファイルの文字と `style` でハイドレーションの不一致)/レポートの「変わった本だけ取り直す」(ブロックの決定表の入力を変える別の指示書の大きさ。指示書BC §10 Q4) + +#### 実装の記録 + +- ★2026-10-19 指示書BC(Claude)★: + - ★コミット★: `52e23e5`(段階1)/`6c2848c`(段階2)/`aa61852`(段階3)/`bce6a93`(段階4)/`50a3082`(段階5)/`899ac7e`(段階6 code-map・spec)/`46e1903`(後の直し: エリア別モーダルの札と「結果待ち」を幅48pxの枠に・CSV の上限超過の文言を読み込み中に出さない・押せるカードの Space を keyup で。★BC の名は無いが同じ夜の追い直し(推測)★)/`e3d8b33`(`CLAUDE.md` に CSP の nonce) + - ★テスト★: BC 前 135ファイル・件数は記録なし(D-182 の BB 後)→ BC 後 ★139ファイル / 3,284件★(出所: `bb79de9` のメッセージ「vitest 139ファイル 3284件すべて通過…は上げる前と同じ」と指示書BD §1 G4 の基準。`50a3082`〜`bb79de9` の間にテストファイルの変更は無い — `git diff` で確認)。見込みは +4ファイル / +40件(指示書BC §1) + - ★古い応答の再現・画面確認★: 記録なし + - ★指示書と違う判断★: D-183 は `docs/decisions.md` に他のセッションの未コミットの差分があったため追記せず監督へ(`899ac7e` のメッセージ)→ 本項は 2026-11-05 に後から記録した。それ以外は記録なし + - ★後の変更★: ★指示書BC §10 Q3「編集モード中も『ログ』ボタンを押せる」は、2026-11-05 のユーザー回答「編集中は押せない」で上書き★(D-179 追記。`docs/decisions.md:13049`)。指示書AY は BC の時点で未実装(2026-11-05 に実装) + - ★未決★: 結果待ちのエリアだけが残ったときのモーダルの「問題エリアはありません(全エリア◎○)」が事実と違う(モックの文言。指示書BC §7-11・§10 Q10) + +--- + +### D-184 `[決定]` エラー一覧の各行にエラー内容(`error_lists` の id)を登録できる API を作り、`robot_error_reports.error_list_id` を書ける列に足す + +**日付**: 2026-10-27 / 実装: Claude Code(指示書BD。サブエージェントで実装し監督がレビュー) + +★根拠★: ユーザー要望 2026-10-27(エラー情報を統計的に取るため、エラー一覧でエラー内容を選択リストから選ぶ。選択肢はスプレッドシート「PUDUエラー一覧_20261016」の「PUDUエラー 決定稿」131行)/テーブル定義書の変更(新表 `error_lists`・`robot_error_reports.error_list_id`)/ユーザー回答 2026-10-27 ①〜⑤(指示書BD §0)。原文: 「とりあえず API を実装して」/「すでに更新権限があると思う」(`CLAUDE.md` §3)/「ローカル環境でテストするのでダミーのDB作成」(指示書BD §8)。 + +1. ★ID で保存する★(`robot_error_reports.error_list_id`)。★名前は ID で `error_lists` から引く★(先方が自動で入れる列ではない。回答①)。★未選択は NULL★(回答③)。★解除(NULL に戻す)は作らない★(実DDLの NULL 可否が未確認のため。null・0・負数・小数・文字列は422) +2. ★書ける列は `robot_error_reports` の5列★(4カラム+`error_list_id`)。★`error_list_id` を書くのは `PATCH /errors/{error_id}/status` だけ★ — `/prechecks` と D-Sec の保存は4列のまま(テストで固定)。`control_error_histories` には無い列。歯止め(5列以外を含めば例外)は `lib/db/robot-error-write.ts` +3. ★選べる行は `D-S_CONTROL_FAILURE`・`SECURITY_ON` 以外★(`ERROR_LIST_UNSELECTABLE_ERROR_TYPES`。完全一致)。★清掃開始失敗(`NOT_STARTED`)は選べる★(監督の提案「ロボットのエラーの行だけ」に清掃開始失敗を足したユーザー判断と読む)。★画面は `error_type` を見ず `error_list_selectable` だけを見る★ +4. ★その他(id 131。`ERROR_LIST_NOTE_REQUIRED_IDS`)は備考必須★。ただし ★要求がエラー内容か備考を変えるときだけ★・★適用後の値で★判定し、★状態だけの保存と選べない行には掛けない★(監督判断 2026-10-27。レビュー指摘) +5. 選択肢 `GET /error-lists` は ★`inactive = 0` かつ `deleted_at IS NULL`・id 昇順★。★行の名前解決(`error_list`)は無効・削除済みでも絞らない★(施設名と同じ)。現在値と同じ id は検証しない(無効化済みの id の行で状態だけ保存しても422 にしない) +6. ★`prisma/schema.prisma` の `ErrorList` と `error_list_id` は定義書ベースの手書き★(ステージングに接続できず db pull できなかった。D-056 の例外。接続回復後に db pull で置き換える)。`error_list_id` は定義書の必須に反して NULL 可(回答③) +- ★却下★: 備考(`comment`)に決定稿の G列の文字列を書く — 権限の変更は要らないが、手入力の備考と混ざり統計が文字列一致頼みになる(指示書BD §9)/状態だけの保存も「その他・備考なし」で422 にする — `/prechecks` は `error_list_id` を見ずに備考を消せるので、触っていない備考が理由で止まりアラート画面と食い違う(§3-3 手順4) + +#### 実装の記録 + +- ★2026-10-27 指示書BD(Claude Code)★: + - ★コミット★: `cc004a3`(API・fixtures・schema・テスト)/`94c0e15`(ローカルDB に表と列を足す `scripts/local-db/` とシード。指示書BD §8 のユーザー指示)/`708b52f`(シードのスキップ件数の想定 27 → 2。同じ作業で判明)/`d13d9f4`(指示書BD・BE と `CLAUDE.md` §3・§7・code-map の書ける列) + - ★テスト★: BD 前 139ファイル / 3,284件(指示書BD §1 G4・`bb79de9`)→ BD 後 ★145ファイル(`cc004a3` の `git ls-tree`。構造)・件数は記録なし★ + - ★指示書と違う判断★: 記録なし(ローカルDB の手順はユーザー指示で §8 に足した範囲) + - ★未了(接続回復後)★: `SHOW GRANTS` で UPDATE 権限の確認・`information_schema` で `error_lists` と `error_list_id` の型・NULL 可否・db pull と手書きの差分・`error_lists` が決定稿 131行と一致するか(指示書BD §8。`CLAUDE.md` §3 は 2026-11-05 時点でも「確認は未了」)。★2026-10-29 に監督がステージングの `information_schema` を読んでいる(`abfecdb`)が、その時にこの列を確かめたかは未確認★ + - ★関連する後の変更★: `abfecdb`(2026-10-29)— 障害サマリ2表(`customer_error_summaries` / `facility_error_summaries`)の `error_detail` が `error_list_id`(NOT NULL・`error_lists` への FK)に変わった DDL に合わせた。★アプリはこの列を読まない★(内訳は従来どおり `error_type` で束ねる) + +--- + +### D-185 `[決定]` エラー一覧でエラー内容を選ぶ画面(「詳細」の列と選択用の窓) + +**日付**: 2026-10-27 / 実装: Cursor(指示書BE。レビュー指摘6件は Claude Code が修正) + +★根拠★: ユーザー要望 2026-10-27(指示書BE §0。★要約だけで原文の引用は無い★。見た目の参考は https://copipe-de-ui.net/examples/data-list-2 )/ユーザー指示 2026-10-27(指示書BE §12。列にする・折り返す)/API は D-184。 + +1. ★エラー内容は独立した列「詳細」(EN "Details")として備考のすぐ左に出す★(`COLUMN_COUNT = 9`)。「種類」のセルは `error_type` だけ。★当初の「種類のセルの2行目(列は足さない)」は同日のユーザー指示で取り消し★(指示書BE §12。`74925f5`) +2. ★セルの文字は1つの純粋関数★(`lib/errors/error-content-label.ts` の `errorContentView()`): 選べない行「-」/未選択「未選択」/`error_lists` に無い id「不明({id})」/★その他(`note_required`)は大分類名★(詳細の「該当するエラーはありません」は一覧の0件の文言と同じで、EN では意味が逆になるため)/それ以外は詳細。★分類名・詳細は `t()` に通さず `lang` で `_ja` / `_en` を出し分ける★(辞書に同じ綴りのキーがあり誤訳される) +3. ★長い文字は折り返す(「…」で切らない)★。英語・識別子は `overflow-wrap: anywhere`。★1440px で横スクロールを出さない★(D-180 項10 を保つ) +4. ★編集モードでは、詳細のセルのボタン → 選択用の窓(この画面専用の部品 `ErrorContentPicker`)で選ぶ★。窓は `role="dialog"`・`aria-modal`・Escape / 背景 / 「閉じる」で何も変えずに閉じる・閉じたら押したボタンへフォーカスを戻す。上段は分類の select・キーワード・「{n}件 / {total}件」・クリア(条件は AND)、下段は「大分類 / 小分類」の見出しと詳細のボタン(その他に「備考必須」の印)。選択肢は編集モードに初めて入ったときに1回だけ取る(失敗しても対応状況と備考の編集は使える) +5. ★下書きはエラー一覧専用★(`lib/errors/edit-drafts.ts`)。★3画面で共通の `lib/prechecks/edit-drafts.ts` は変えない★(アラートと D-Sec が `error_list_id` を送らないことを既存のテストが守る)。エラー内容だけを変えた行も「変えた行」に数える +6. ★保存前の検証★: 下書きがエラー内容か備考を変え、適用後がその他で、適用後の備考が空なら ★その行は送らず下書きを残して行エラー★。ほかの行は送る。★状態だけの変更・選べない行には掛けない★(サーバーの D-184 項4 と同じ条件) +7. ★未選択に戻す操作は作らない★(API が受け付けない)。統計の画面・CSV・エラー内容での絞り込み・アラート / D-Sec への追加は今回やらない +- ★却下★: セルの中に浮かせるパネル(`.tableWrap` / `.card` の overflow で表の端で切れる)/2段の `` + `openDatePicker()`(`components/ui/date-picker.ts:11`)。 - - API: `/ds-devices`(`d-watch-api.yaml:1376`)・`/ds-devices/{serial_number}/note`(`:1413`。path の `maxLength: 100`・文字種は絞らない)。パスの検証は `parsePathSerialNumber()`(`lib/api/path-params.ts:70-79`。★422 `validation_failed`★)。実在は `db.dsDeviceExists()`(`lib/db/index.ts:370`。fixtures `:680`・Prisma `:1944`)。 + - 画面: `components/features/ds-logs/DsLogScreen.tsx`(表の `` `:464-470`・見出し `:471-479`(`` `:473-477`)・日時の整形 `formatDateTime()` `:47`)。 + ★**10-19 の BC 段階4(`bce6a93`。D-183)で、備考が他の3画面と同じ「編集モード」になった**★: `editing` / `drafts` / `rowErrors` / `saving`(`:140-143`)・下書きがある間の離脱確認(`:146-154`)・「編集」→「保存(N件)」「キャンセル」・まとめて保存(`saveNoteDrafts()` `:252`)・★編集中は絞り込みとページ送りを `
` で無効にする★(`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 の選択は持たない★)。日付は `` + `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()`)。 --- @@ -76,6 +99,7 @@ | 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) @@ -91,20 +115,20 @@ | D8 | ★一覧は制御盤からもらい、絞り込み(種類・期間)・並べ替え・ページ分けは D-Watch 側の純粋関数で行う★(`lib/ds-logs/query.ts`) | 期待値表でテストできる場所に置く | | D9 | ★設定値は `lib/config/ds-agent.ts`(新規・独立ファイル)★。値は 段階3-1 の表。★ハードコード禁止★(`CLAUDE.md` §7) | `lib/config/index.ts` に足すと proxy のバンドルに judge 系を引き込む(`allowed-ips.ts:4-8` と同じ理由) | | D10 | ★fixtures の偽制御盤は relay の同じ経路(待ち行列 → 結果 → 準備完了 → チャンク → 完了)を通す★。fixtures の制御盤のうち1台(★`DS-001`★)は未接続にして 409 を出す | `CLAUDE.md` §9.5 | -| D11 | ★画面は D-Sec ログの表の各行の「ログ」ボタン → モーダル★(`AreaModal.tsx` の作りに倣う)。★モックに無い画面なので、文言・配置は本書 段階9 を正とする★ | ユーザー要望(PUDU の画面) | +| 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`★(既存の流儀。M2。ブリーフの 400 `validation_error` は誤り)。★制御盤向けの形の誤りは 400 `result_invalid` / `chunk_invalid`★ | §11 Q1・Q10 | +| 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 | ★ダウンロードは `` を作って `click()`★(M1)。★`location.href` は使わない★。★GET が JSON のエラーになるのは制御盤の切断などまれな場合だけで、そのときは JSON がファイルとして保存されうる(既知の制約)★ | 準備の失敗は POST で返る(D14) | | D22 | ★proxy を `async` にし、agent パスの判定を `authenticateAgent()`(新規・async)に分ける★。`authenticate()` は agent パスなら cookie があっても `unauthenticated`。★トークンのストアは mtime を見てメモリにキャッシュしてよい★ | §11 Q8 | | D23 | ★本書の暫定の採用(M10)★: 期間ボタンの `to` は省略(Q4)/未知の種類キーは0件(Q5)/zip のファイル名のシリアルは `[A-Za-z0-9._-]` 以外を `_`・`filename*` も付ける(Q6)/順番違いのチャンクはエクスポートも失敗(Q9)/待ち行列が満杯なら 429 `agent_busy`・410 `command_expired`・400 `result_invalid`(Q10)/偽制御盤スクリプト `scripts/ds-agent-fake.ts` をコミット(Q11)/同じ制御盤の2本目のポーリングは古い方を 204(Q12)/期間の既定は直近24時間・種類や期間を変えたら選択を消す(Q13)/404 の判定は `db.dsDeviceExists()`(DB は読むだけ) | §11 | -| D24 | ★zip の中のパスは `{root の name}/{root からの相対パス}`(制御盤側。M6)★。D-Watch は zip の中身を見ない。★偽制御盤スクリプトと fixtures の zip は root の名前つきで作る★ | 本物と同じ形で画面確認する | +| 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 を採用★) @@ -120,6 +144,7 @@ ★表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件) @@ -154,7 +179,7 @@ | Q14 | Q1 を `perPage=3, page=1` | f8, f5, f1 | 8 | ★純粋関数には任意の perPage を渡せる★(ルートの 50/100/200 は検証側) | | Q15 | Q1 を `perPage=3, page=3` | f4, f6 | 8 | 最終ページの端数 | | Q16 | Q1 を `perPage=3, page=4` | (空) | ★8★ | ★範囲外のページは空配列、total は正しい★ | -| Q17 | 出力の `modified_at` | f7 は ★`2026-10-19T02:00:00+09:00`★、他は入力どおり | — | ★出力は `toJstIso()`(`lib/format/date.ts:37`)で JST に揃える★(`CLAUDE.md` §9.3) | +| 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`) @@ -186,7 +211,7 @@ | K9 | `DS-002` を再発行 | ★古い値は `null`、新しい値は `"DS-002"`★ | D16 | | K10 | `DS-002` と `DS-003` を発行 | それぞれの値がそれぞれのシリアルを返す | | | K11 | ストアの1件の `token_sha256` が壊れている(64桁の16進でない) | ★その1件だけ無視し例外にしない★。他の制御盤は通る | | -| K12 | 検証した後に ★別のストアのインスタンスから★ 失効させる(ファイルの mtime が変わる) | 次の検証は `null`(★キャッシュは mtime で無効になる★) | D22 | +| 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 | @@ -261,6 +286,7 @@ | 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)`) @@ -277,7 +303,8 @@ - 表5 の 4MB・9MB は ★`limits.chunkMaxBytes` を小さく(例 16 バイト)差し替えて★ 同じ規則を確かめてよい。★ただし R13・R24 の境界(ちょうど・+1)と R11・R14・R18・R31・R32 の秒の境界は、差し替えた値で必ず書く★ - ★期待値は設定値から作る★(`CHUNK_MAX_BYTES + 1`・`EXPORT_PREPARE_TIMEOUT_SEC` 等)。★R21〜R23・R27 の「わざと違う値」だけは直書きし、コメントで理由を書く★ - 表4 の「正しい Bearer」は `deps.verifyAgentToken` のスタブで作る(ストアに触らない)。★A7 はスタブが呼ばれていないこと(`toHaveBeenCalledTimes(0)`)を見る★ -- 表3 は `__setAgentTokenStoreForTesting()`(新規。`ds-device-notes.ts` の `__setDeviceNotesStoreForTesting()` と同じ形)で `mkdtemp` のストアに差し替える。★`data/settings/` の実物に触らない★。KA1〜KA8 はルートのテスト(段階7-4) +- 表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 のとおり --- @@ -294,28 +321,33 @@ | 4-6 | ★サーバーのディスクに書く★(一時ファイル・`os.tmpdir()`・`fs.mkdtemp`・`createWriteStream`) | ★禁止★(D3)。`lib/ds-agent/relay.ts`・`lib/ds-logs/**`・`app/api/ds-agent/**`・`app/api/ds-devices/[serial_number]/logs/**` は ★`node:fs` / `node:os` を import しない★(段階7-4 で確かめる)。★トークンのストア(`tokens.ts`)だけは書いてよい★(発行・失効のときだけ) | | 4-7 | ★認証の取り違え★ | ★agent パスは cookie で通さない。通常パスは Bearer で通さない★(表4)。★ヘッダで serial を受け取らない★。ルートは `requireAgent()` で再検証し、★トークンから serial を得る★。★command_id が別の制御盤のものなら 404★(表5 R35)。★トークンの発行・失効は cookie のパス★(表4 A11) | | 4-8 | ★トークン・中身をログやストアに残す★ | ★生のトークンは保存しない(SHA-256 だけ)★。★出すのは発行の POST の応答の1回だけ★(画面の state にも閉じたら残さない・`localStorage` に入れない)。★ファイルの中身・ファイル名の一覧全体をログに出さない★。イベントのキー名に `token` / `authorization` / `note` / `detail` を使わない(`lib/logger/redact.ts` が本番で消す) | -| 4-9 | ★ブラウザ・制御盤の切断に気づかない★ | ★`ReadableStream` の `cancel()` と `request.signal` の `abort` の両方★でエクスポートを取り消す(次のチャンクは 410)。★ポーリングと準備の POST の `request.signal` も見る★(ポーリングが切れたら待ちを外す。準備の POST が切れたらエクスポートを取り消す)。★実際の切断で効くかは段階11 で確かめる★ | +| 4-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 で落ちる。`閉じる`・`キャンセル`・`読み込み中…`・`種類`・`日付` は既にある)。★色は CSS 変数★。★`components/` はテスト対象外なので画面は目視で確かめる★ | +| 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)。★他のセッションの未コミットの変更を含めない★ | +| 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(全列に幅があり、余りは幅の比で全列に配られる。ユーザーが決めた作り)を崩さない★ — 「ログ」列にも `` で幅(例 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}`。表の行は `
` の外なので ★ボタンに直接付ける★)。★下書きを守るため★。保存かキャンセルで編集モードを抜けた後に押せる。段階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` で最新にし、★指示書AX の実装コミットが入っていること★を `git log` で確かめる。入っていなければ着手しない。基点のコミットを冒頭の欄に書く。 -2. `git status` を控える。★自分が作っていない変更・未追跡ファイルは他のセッションの作業★。触らない・コミットに含めない。 -3. ★`docs/decisions.md` に D-179 がコミット済みか★を確かめる。未コミットなら ★§13 の追記は D-179 のコミットを待つ★(`git add -p` で自分の塊だけを選ぶのでもよい)。 -4. ★本書に書いた行番号を develop の先端ですべて確かめ直す★(特に `d-watch-api.yaml`・`lib/types/index.ts`・`lib/i18n.ts`・`lib/fixtures/index.ts`・`DsLogScreen.tsx` / `.module.css` — AX が触る)。ずれは作業報告に書く。 -5. `npx prisma generate` → `npx tsc --noEmit -p .` → `npm test` が通ることを確かめ、★テストの件数を記録する★。通らなければ着手前に報告。 -6. ★Next.js は版が新しい★。次を読む(`CLAUDE.md` 末尾): `node_modules/next/dist/docs/01-app/03-api-reference/03-file-conventions/proxy.md`(`async` にできること `:31`)・`…/05-config/01-next-config-js/proxyClientMaxBodySize.md`・`…/01-getting-started/15-route-handlers.md`(★GET はキャッシュされない★。`app/api/settings/display-limits/route.ts:27-35`)・`…/03-file-conventions/route.md`(ストリーミング・`request.signal`)。 +1. ★`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)。 --- @@ -329,8 +361,8 @@ 3. `lib/ds-logs/__tests__/file-name.test.ts`(新規): 表6 N1〜N4 4. `lib/ds-agent/__tests__/tokens.test.ts`(新規): 表3 K1〜K12(`mkdtemp`。`afterEach` で消す) 5. `lib/proxy/__tests__/authenticate.test.ts`(★足す★): 表4 A1〜A11。★既存のテストは無修正で通ること★ -6. `lib/ds-agent/__tests__/relay.test.ts`(新規): 表5 R1〜R43 -7. `lib/ds-logs/__tests__/validate.test.ts`(新規): 段階3-3 の V1〜V7 と、段階7-2 の W1〜W9 +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 で書く) @@ -340,24 +372,24 @@ ### 2-1. 足すもの -1. `components.securitySchemes`(`:1734` 付近)に ★`agentBearer`★(`type: http`・`scheme: bearer`。description: 制御盤ごとのトークン。`/ds-agent/` 配下だけ。cookie では通らない。D-179) +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` の後ろ・`/ds-alerts` の前に置く)。★検証エラーはすべて `ValidationError`(422 `validation_failed`。D17)★: +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`)・`page`(1〜)・`per_page`(★50 / 100 / 200。既定 200★)。200 は `DsLogListResponse`(★`truncated` を含む。D18★)。409 `agent_offline`(`details.last_seen_at` に最終接続。無ければ空配列)・504 `agent_timeout`・502 `agent_error`(`details.agent_code`)・429 `agent_busy`・404 `not_found`・422 | -| `/ds-devices/{serial_number}/logs/exports` | POST | cookieAuth | 本体 `{ file_ids: string[] }`(1〜`EXPORT_MAX_FILES` 件・重複なし・各 1〜1024 文字)。★201★ `{ export_id, download_url, total_bytes, file_count, expires_in_sec }`。★最大 50秒待つ(制御盤が zip を作る)★。502 `agent_error`(`too_large` / `file_not_allowed` / `file_not_found` / `io_error` 等)・504・409・429 `export_busy` / `agent_busy`・404・422 | -| `/ds-devices/{serial_number}/logs/exports/{export_id}` | GET | cookieAuth | ★1回だけ使える★。200 は `application/zip`(`format: binary`)と ★5つのヘッダ(4-5)★。504 `agent_timeout`(30秒)・502 `agent_error`・404 `export_not_found`(無い・失効・使用済み・★パスのシリアルと違う★)。description に ★流し始めた後の失敗はダウンロードの失敗になる(ステータスは200のまま)★・★JSON のエラーはまれ(制御盤の切断など)で、ブラウザによってはファイルとして保存される(D21)★ | -| `/ds-devices/{serial_number}/agent-token` | GET | cookieAuth | ★P1(Q14 採用)★。200 `{ issued_at: string \| null }`。★トークン・ハッシュは返さない★。404・422 | -| 同上 | POST | cookieAuth | 本体なし。★201★ `{ token, issued_at }`。description: ★生の値はこの応答で1回だけ・既にあれば作り直して古いものは無効・保存は SHA-256 だけ(D16)★。404・422 | -| 同上 | DELETE | cookieAuth | ★204★(★未発行でも 204(冪等)。P2(Q15 採用)★)。404・422 | +| `/ds-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` / 404 / 410 `command_expired` | -| `/ds-agent/commands/{command_id}/prepared` | POST | agentBearer | ★本体 `{ total_bytes, sha256, file_count }`★(export の指示だけ)。204 / 400 `result_invalid` / 404 / 410 `command_expired` | -| `/ds-agent/commands/{command_id}/chunks/{seq}` | POST | agentBearer | ★export の指示の command_id(D25)★。`application/octet-stream`・ヘッダ `X-Chunk-Sha256`(小文字の16進64桁)。204(★ブラウザ側に空きができてから。最大45秒★)/ 400 `chunk_invalid` / 409 `chunk_out_of_order` / 410 `export_cancelled` / 404 | -| `/ds-agent/commands/{command_id}/complete` | POST | agentBearer | ★export の指示の command_id(D25)★。`{ total_bytes, sha256 }` → 204 / 400 `result_invalid`(形の誤り・値の不一致)/ 410 `export_cancelled` / 404 | -| `/ds-agent/commands/{command_id}/fail` | POST | agentBearer | `{ code, message }`(code は 1〜64 文字・message は 1〜500 文字。★送信中の失敗は export の指示の command_id。制御盤に zip が無いときは `export_not_found`(D25)★)→ 204 / 400 `result_invalid` / 404 / 410 `export_cancelled` | +| `/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 | 4. スキーマ: `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 }`・ @@ -367,8 +399,8 @@ ### 2-2. 型とエラーコード — `lib/types/index.ts` -- 上のスキーマと同名の型を足す(`CLAUDE.md` §9.2。`DsDevice`(`:1024` 付近)の近く) -- ★`ErrorCode`(`:31-45`)に足す★: `agent_offline` / `agent_timeout` / `agent_error` / `agent_busy` / `export_busy` / `export_not_found` / `chunk_invalid` / `chunk_out_of_order` / `export_cancelled` / `command_expired` / `result_invalid`。★`validation_error` は足さない(D17)★ +- 上のスキーマと同名の型を足す(`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つ)。 @@ -395,8 +427,10 @@ | `LIST_RESULT_MAX_BYTES` | ★`4 * 1024 * 1024`★ | 一覧の結果の本体の上限(D18) | | `LIST_RESULT_MAX_ITEMS` | 20000 | 一覧の件数の上限(制御盤はここで打ち切り `truncated`) | | `PENDING_COMMANDS_PER_DEVICE` | 5 | 制御盤ごとの待ち行列の上限 | -| `DS_LOG_PER_PAGE_OPTIONS` | `[50, 100, 200]` | 画面のページあたり件数(★`PAGINATION.maxPerPage`(`lib/config/index.ts:438`)と同じ 200 が上限★) | -| `DS_LOG_DEFAULT_PER_PAGE` | 200 | 既定 | +| `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秒)より短くすること」★ と書く。 @@ -420,17 +454,40 @@ - ★`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`★) +### 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=app,,sys`(空の要素) | 422 | +| 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` / `page=0` / `page=1.5` | 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 の上限より十分大きい★) | コミット。 @@ -445,11 +502,17 @@ export async function agentTokenIssuedAt(serialNumber: string): Promise // P1(Q14 採用) /** `Authorization` ヘッダの生値 → serial または null。★全件と timingSafeEqual で比べ、途中で抜けない★ */ export async function verifyAgentAuthorization(header: string | null): Promise -export function __setAgentTokenStoreForTesting(store: JsonStore | null): void +/** ★ストアとパスを組で持つ★(`JsonStore` はパスを返さないので、mtime を見るためにパスを別に持つ。M13) */ +export interface AgentTokenStoreTarget { store: JsonStore; 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)は監督が `docs/deploy.md` に書く(§12-2)★ -- ★`lib/logger` を import しない★(proxy から呼ぶため。`json-store.ts:15-17` と同じ考え方)。壊れたストアの警告は `onWarn` で `settings.store_corrupt` を出す(呼び出し側。前例 `ds-device-notes.ts:65-73`) +- 保存先は ★呼び出し時に★ `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)。★発行・失効の後は自分のキャッシュを捨てる★ @@ -469,7 +532,7 @@ 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`。★ヘッダの値は出さない★)。 + ★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)★ @@ -531,12 +594,13 @@ | ファイル | 流れ | |---|---| | `logs/route.ts`(GET) | `parsePathSerialNumber()` → クエリの検証(3-3)→ ★`db.dsDeviceExists()`(404)★ → `requestList()` → 結果の検証(7-2)→ `queryDeviceLogs()` → 200(`agent`・`types`・`items`・★`truncated`★・`meta`) | -| `logs/exports/route.ts`(POST) | 同じ順で検証・404 → ★`createExport(serial, fileIds, request.signal)`(最大50秒)★ → ★201★(`download_url` は `/api/ds-devices/${encodeURIComponent(serial)}/logs/exports/${exportId}`) | -| `logs/exports/[export_id]/route.ts`(GET) | `claimExport(serial, exportId, request.signal)` → `stream` なら `new Response(body, { headers })`(★4-5 の5つ★。ファイル名は `exportFileName(serial, now)`)→ それ以外は JSON のエラー | +| `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`) @@ -548,13 +612,17 @@ | 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 ④)★。 +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/`) @@ -568,17 +636,19 @@ | `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` 3つ・`truncated` false)・絞り込みが効く/★`DS-001` は 409★/`DS-999` は 404/★POST → 201(`total_bytes` が fixtures の zip の長さ)→ GET で zip のバイト列が fixtures の zip と一致・ヘッダ5つ★/★2回目の GET は 404★ | -| `app/api/ds-devices/[serial_number]/logs/__tests__/route.test.ts` | ★非 fixtures★(`vi.mock("@/lib/db/use-fixtures", () => ({ USE_FIXTURES: false }))`・`db.dsDeviceExists` を `vi.spyOn`)。relay は `__setRelayForTesting(createRelay(偽の時計))`。表5 の HTTP の対応(409 / 504 / 502 / 429 / 404 / 422)と、表1 の絞り込みがルートを通って効くこと | +| `app/api/ds-devices/[serial_number]/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`。★Bearer なし・違う → 401(`requireAgent()` の再検証)★・R7(どのルートでも `last_seen_at` が進む)・R35・R36・チャンクの 400 / 409 / 410・W1〜W9・prepared の検証 | +| `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` を読み、`from "node:fs"` / `from "fs"` / `from "node:os"` / `tmpdir` / `mkdtemp` の文字列が無いこと | +| ★ディスクに書かないこと★(同じ 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` の冒頭コメント)。コミット。 @@ -587,11 +657,14 @@ ## 段階8 — fixtures の偽制御盤 1. `lib/fixtures/ds-device-logs.ts`(新規): - - `fixtureDeviceLogTypes`: 種類3つ(★ダミーの名前★。例 `app` / `comm` / `sys`、ラベル「アプリ」「通信」「システム」) - - `fixtureDeviceLogItems(now: Date)`: ★数百件(例 300)★を now から決まった間隔で作る。★now ちょうど・1時間前ちょうど・3時間前ちょうど・24時間前ちょうど・その1秒前★ の項目を必ず含める(画面の目視用。境界の正しさはテストが持つ)。大きさは 1KB〜50MB(★選択の合計が 100MB を超える組み合わせが作れること★) - - `FIXTURE_EXPORT_ZIP_BASE64`: ★中身がダミーの文字だけの小さな正しい zip★。★中のパスは root の名前つき(例 `fixture-logs/app_20261019_1100.log`。D24)★(フォルダ `fixture-logs` を PowerShell の `Compress-Archive` で包み base64 にする。★実在の店名・ログを入れない★) + - `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";` + - `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`★(流量制御の経路を通す) @@ -604,12 +677,18 @@ ## 段階9 — 画面(モーダル) -★見た目は `AreaModal.tsx` / `.module.css` から借りる。新しい共通部品は作らない★(`components/ui/` に Modal を作らない)。 +★見た目は `AreaModal.tsx` / `.module.css` から借りる。新しい共通の画面部品は作らない★(`components/ui/` に Modal を作らない)。★判定の純粋関数だけを `lib/ui/modal.ts` に移して共有する(9-2。M2)★。 ### 9-1. `components/features/ds-logs/DsLogScreen.tsx` -- 表の右端に ★「ログ」列★(`` に `.colLogs` を足す・幅は固定 ★例 80px★、`DsLogScreen.module.css` の `min-width` も同じだけ広げる(`:82` のコメントの規則))。各行に `ログ` ボタン → モーダルを開く(`serial_number` と `facility_name` を渡す) -- ★モーダルの開閉以外は今の画面を変えない★ +- 表の右端に ★「ログ」列★: ``(`:464-470`)に `` を、見出し(`:471-479`)に `` を、各行(`` の最後の `` の後)に `` を足す(★`` の上のコメント(`:455-463`)のとおり、`` と `` と `` を同じ順に★)。 + - ★列幅(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`)。★表は `
` の外なのでボタンに直接付ける★ +- ★並べ替えに 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` @@ -617,24 +696,31 @@ |---|---| | 見出し | 「制御盤のログ」+シリアル・施設名。右に ★接続状態★: 「接続中」(ok の色)/「未接続」(ng の色)+「最終接続 {日時}」(無ければ「最終接続 -」)。`閉じる` | | 種類 | 制御盤から来た `types` のチェックボックス(★ラベルは生値。`t()` に通さない★)。★既定は全部★。★1つも選ばれていなければ取得せず「種類を1つ以上選んでください」★ | -| 期間 | ボタン「直近1時間」「直近3時間」「直近24時間」「全て」(`presets.ts`)+ `` 2つ(`openDatePicker`。表2 T5〜T9)。★既定は「直近24時間」★(D23) | -| 並べ替え | 「日付」(`date_desc`)「種類+日付」(`type_date_desc`) | -| 一覧 | チェックボックス・ファイル名・種類(ラベル)・大きさ(KB / MB)・更新日時。見出しに「このページをすべて選択」。下に `Pagination` + ページあたり件数のセレクト(`DS_LOG_PER_PAGE_OPTIONS`)。★`truncated` のときは一覧の上に「件数が多いため、新しいものから一部だけを表示しています」(D18)★ | +| 期間 | ボタン「直近1時間」「直近3時間」「直近24時間」「全て」(`presets.ts`。★「全て」は辞書に `全て: "All"` を足す。M3★)+ `` 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` を ★`
` を作って `click()`(D21)★ → 「ダウンロードを開始しました({size})」 | | エラー | 409「制御盤が接続されていません(最終接続: {日時})」/504「制御盤から応答がありません。しばらくしてから再度お試しください」/429 `export_busy`「この制御盤は別のエクスポートを処理中です」/429 `agent_busy`「制御盤が混み合っています。しばらくしてから再度お試しください」/502「制御盤でエラーが発生しました({code})」(★`too_large` は「選んだファイルが大きすぎます(1回 {max} まで)」★) | -| ★制御盤トークン★(下部。D16) | 「制御盤トークン」+「発行日時 {日時}」/「未発行」(`GET …/agent-token`。P1(Q14 採用))。ボタン「発行(作り直し)」「失効」。
発行: ★確認「トークンを発行します。発行済みのトークンは使えなくなります。よろしいですか?」★ → POST → ★トークンを読み取り専用の欄に1回だけ表示・「コピー」ボタン・注意「この値は二度と表示されません。制御盤の設定ファイルに入れてください」★。★モーダルを閉じたら state から消す★。
失効: ★確認「トークンを失効させます。この制御盤はログを送れなくなります。よろしいですか?」★ → DELETE。
★この部分は制御盤が未接続(409)でも使える★(初めて設定するときは未接続のため) | +| ★制御盤トークン★(下部。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秒)ので、★前の応答が後から返って上書きしないよう、要求ごとの番号で古い応答を捨てる★ -- ★`role="dialog" aria-modal="true"`・`Esc` で閉じる・背景のスクロールを止める★(`AreaModal.tsx` の作りを確かめて揃える)。★準備中に閉じたら POST を `AbortController` で取り消す★(サーバー側はエクスポートを取り消す。4-9) +- 取得中は「読み込み中…」(`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:41`)と同じ形 +- 日時の表示は既存の `formatDateTime()`(`DsLogScreen.tsx:47`)と同じ形 ### 9-3. `lib/i18n.ts` -★足す前に grep する★(`閉じる`・`キャンセル`・`読み込み中…`・`種類`・`日付` は既にある)。EN は ★暫定★。 -例: 「ログ」"Logs"/「制御盤のログ」"Controller logs"/「接続中」"Connected"/「未接続」"Not connected"/「直近1時間」"Last 1 hour"/「直近24時間」"Last 24 hours"/「種類+日付」"Type + date"/「このページをすべて選択」"Select all on this page"/「エクスポート」"Export"/「制御盤で準備中…」"Preparing on the controller…"/「制御盤トークン」"Controller token"/「発行(作り直し)」"Issue (regenerate)"/「失効」"Revoke"/「コピー」"Copy"/ほか 9-2 の文言すべて。 +★足す前に 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 で見る)。 @@ -645,16 +731,17 @@ | event | level | フィールド(★これ以外を載せない★) | どこで | |---|---|---|---| | `ds_agent.connected` / `ds_agent.disconnected` | info | `serial_number`・`agent_version` | relay の `onEvent`(状態が変わったときだけ。表5 R39) | -| `ds_agent.auth_failed` | warn | `route`・`reason`(`missing` / `invalid`) | proxy(段階5)。★ヘッダの値・トークンを出さない★ | +| `ds_agent.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`) | GET logs | +| `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` | 失敗・取り消し・失効・タイムアウト | +| `ds_logs.export_failed` | warn | `serial_number`・`stage`(`prepare` / `send`)・`reason`・`bytes_sent`・★`agent_code`(制御盤の code があるときだけ。M10)★ | 失敗・取り消し・失効・タイムアウト | -- `LogEvent` の union(`:456-471`)に足す。★`reason` は固定の文字列の union★(制御盤から来た `message` を載せない。制御盤の `code` は `agent_code` として固定の長さで切って載せてよい) +- `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ファイル)を。★それ以外の節は変えない★ コミット。 @@ -680,36 +767,59 @@ ### 11-2. fixtures(`:3100`) -1. 本体で `USE_FIXTURES=true npm run build`(PowerShell: `$env:USE_FIXTURES='true'; npm run build`) -2. ★テスト用の `AUTH_TOKEN` をその場で生成★し、★環境変数で渡して★ `npx next start -p 3100`。★トークンを文書・コミット・報告に書かない★ -3. `preview_start` の `url` で `http://localhost:3100` を開き、ログインする -4. 確かめること: - - D-Sec ログの各行に「ログ」がある。`DS-002` で開く → 接続中・種類3つ・既定の期間(直近24時間)で一覧が出る - - 種類を外す・期間のボタン・日付の範囲・並べ替え・ページ送り・ページあたり件数で一覧が変わる。種類・期間を変えると選択が消える +★`.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 を超えると警告とボタンの無効★ - - ★エクスポート → 「制御盤で準備中…」→ zip が落ちる・開ける・中が `fixture-logs/…`★ + - ★エクスポート(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回だけ表示・コピー → 閉じて開き直すと値は出ず、発行日時だけ → 作り直し → 失効 → 「未発行」(★ここで発行した値は確認後に失効させ、どこにも書かない★) - - ★JP/EN 切替★でボタン・列の位置が動かない/★ダーク/ライト★で崩れない/`Esc` で閉じる - - D-Sec ログの表で横スクロールが増えていない(`resize_window` 1440×900・JP/EN)。終わったら `desktop` に戻す -5. ★終わったら :3100 の listen プロセスを止める★(自分で起動したものだけ) + - ★制御盤トークン★: 発行 → 確認 → 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` が `` の数と一致することを見る。終わったら `desktop` に戻す +6. ★終わったら :3100 の listen プロセスを止める★(M14): ★`Get-NetTCPConnection -LocalPort 3100 -State Listen` の `OwningProcess` のコマンド行(`Get-CimInstance Win32_Process -Filter "ProcessId="` の `CommandLine`)が ★自分が起動した `next start -p 3100` であることを確かめてから★ 止める。★`:3000`(ユーザーの `next dev`)は止めない★ ### 11-3. 非 fixtures の通し確認(★偽制御盤スクリプト★。ローカルDB `dwatch_local`) -1. `npm run seed:local`(★ローカル以外を拒否する★)。`DS-002` が `controls` にあること -2. `USE_FIXTURES=false` で ★ホストが localhost・DB 名が `dwatch_local` であることを確かめてから★ `npm run build` → `next start -p 3100`。★`ALLOW_DIRECT_ACCESS=true` を環境変数で★(XFF が無いため) +★ローカル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` には ★固定の一覧(種類2つ・10件程度)★ → `export` には ★`--zip <パス>` のファイルの長さ・SHA-256・件数で `prepared`★ → `send` には ★そのファイルを 4MB ごとに送り `complete`★(★`file_ids` は見ない。中身の正しさは制御盤(Python)の仕様★) - - zip は ★9〜10MB 程度★(★4MB を超えて3チャンクになる大きさ★)。★root の名前つき(D24)★: 乱数の中身のファイルを入れたフォルダ(例 `fake-root`)を `Compress-Archive` で包む(中のパスが `fake-root/…`) + - ポーリング → `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. 画面から一覧 → 選択 → エクスポート。★落ちたファイルの SHA-256 が `--zip` のファイルと一致★(`Get-FileHash` / `certutil -hashfile`)・★ブラウザに進捗(大きさ)が出る(`Content-Length`)★。サーバーのログに `ds_logs.export_prepared` と `ds_logs.export_completed`(`total_bytes` が一致) -6. ★準備の失敗★: 偽制御盤の `--fail-prepare too_large` で、画面に「選んだファイルが大きすぎます」(POST の 502) -7. ★切断★: エクスポートの途中でブラウザのダウンロードを取り消す → 偽制御盤に 410 が返り止まる・`ds_logs.export_failed`(`stage: send`) +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` が付いていない★こと(`read_network_requests`) -10. ★終わったら★ 画面で ★トークンを失効★、:3100 と偽制御盤を止める +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)。 @@ -722,7 +832,7 @@ | `lib/ds-logs/__tests__/query.test.ts`(新規) | 表1 | 1 | | `lib/ds-logs/__tests__/presets.test.ts`(新規) | 表2 | 1 | | `lib/ds-logs/__tests__/file-name.test.ts`(新規) | 表6 | 1 | -| `lib/ds-logs/__tests__/validate.test.ts`(新規) | V1〜V7・W1〜W9・prepared の本体 | 1 | +| `lib/ds-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 | @@ -732,7 +842,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__/*` | — | +| ★無修正で通ること★ | `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)★ | — | --- @@ -744,17 +854,22 @@ `lib/proxy/authenticate.ts`・`proxy.ts`(段階5 の範囲だけ)・`scripts/ds-agent-fake.ts`(新規。開発用)・ `app/api/ds-devices/[serial_number]/logs/**`(新規)・★`app/api/ds-devices/[serial_number]/agent-token/**`(新規)★・`app/api/ds-agent/**`(新規)・`lib/fixtures/ds-device-logs.ts`(新規)・`lib/fixtures/index.ts`(1行)・ `components/features/ds-logs/**`・`lib/i18n.ts`(足すだけ)・`lib/logger/events.ts`(足すだけ)・ -`docs/decisions.md`(★D-179 の末尾に「実装の記録」を足すだけ★)・§8 のテスト +★`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★) +- ★`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` の追加 @@ -769,12 +884,14 @@ - ★顧客への公開★(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)で確定したもの @@ -806,6 +923,62 @@ | 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件)/キャンセル・`
`。旧の「備考は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. 申し送り @@ -818,25 +991,35 @@ 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 の制御盤のログ(種類3つ・約300件・境界の項目・未接続 `DS-001`・ダミーの zip(`fixture-logs/…`))を足す(ブリーフ §7) -3. `docs/deploy.md` に ★1プロセス前提★(PM2 / cluster にしない・再起動で進行中のエクスポートは失われる)と ★`data/settings/ds-agent-tokens.json` が再デプロイで消えないこと(Docker なら volume。M8)★ +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 番号は取らない★。 + ```markdown - ★実装の記録(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)を監督判断で採用★ + 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. ★本書と違う判断をしたところ★: (あれば) ``` @@ -853,17 +1036,21 @@ ## 15. 完了条件 -- [ ] 着手前に指示書AX の実装コミットが develop に入っていた。基点のコミットを冒頭に記入し、★本書の行番号を先端で確かめ直した★ +- [ ] 基点のコミットを冒頭に記入し(`7492cc5` 以降)、★本書の行番号を先端で確かめ直した★ - [ ] 検証エラーは 422 `validation_failed`(D17)。§11 Q14・Q15 の採用(P1 `GET …/agent-token`・P2 未発行の DELETE も 204)を実装した - [ ] `tsc` 0件・`lint` 0件・`npm run build` 成功(足したルートが `ƒ`) -- [ ] `npm test` 全件通過: 前 ____ 件 → 後 ____ 件 +- [ ] `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)で: 一覧 → 絞り込み → 選択 → ★「制御盤で準備中…」→ zip が落ちる★/★`DS-001` で 409 の表示(トークンの部分は使える)★/トークンの発行・作り直し・失効/JP/EN・ダーク/ライト +- [ ] 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 の申し送り diff --git "a/docs/handoff/\346\214\207\347\244\272\346\233\270BB_\343\203\207\343\203\274\343\202\277\345\261\244\343\201\256\346\255\242\343\201\276\343\202\213\347\265\214\350\267\257\343\201\250\345\211\212\351\231\244\346\270\210\343\201\277\343\201\256\346\211\261\343\201\204\343\202\222\346\217\203\343\201\210\343\202\213_20261019.md" "b/docs/handoff/\346\214\207\347\244\272\346\233\270BB_\343\203\207\343\203\274\343\202\277\345\261\244\343\201\256\346\255\242\343\201\276\343\202\213\347\265\214\350\267\257\343\201\250\345\211\212\351\231\244\346\270\210\343\201\277\343\201\256\346\211\261\343\201\204\343\202\222\346\217\203\343\201\210\343\202\213_20261019.md" index 66911c4..8e03036 100644 --- "a/docs/handoff/\346\214\207\347\244\272\346\233\270BB_\343\203\207\343\203\274\343\202\277\345\261\244\343\201\256\346\255\242\343\201\276\343\202\213\347\265\214\350\267\257\343\201\250\345\211\212\351\231\244\346\270\210\343\201\277\343\201\256\346\211\261\343\201\204\343\202\222\346\217\203\343\201\210\343\202\213_20261019.md" +++ "b/docs/handoff/\346\214\207\347\244\272\346\233\270BB_\343\203\207\343\203\274\343\202\277\345\261\244\343\201\256\346\255\242\343\201\276\343\202\213\347\265\214\350\267\257\343\201\250\345\211\212\351\231\244\346\270\210\343\201\277\343\201\256\346\211\261\343\201\204\343\202\222\346\217\203\343\201\210\343\202\213_20261019.md" @@ -122,7 +122,7 @@ | D17 | ★`dsAlerts()` に必須の引数 `{ open_only: boolean }` を足す★。ダッシュボードは `true`(SQL で `status IN (0,1)`・純粋関数でも解消を外す)、`/ds-alerts` は `false` | ★バナーは「解消でないもの」しか見ない★(`computeSecurityBanner()`)。読むのをやめる案は、バナーの絞り込みを固定しているテスト(`app/api/__tests__/master-filter-dashboard-reports.test.ts:174-198`)と U7(コードを残す)の意味を壊す。★引数は必須★(指示書AC C-4 項4: 任意引数は渡し忘れても動いてしまう) | | D18 | `try` の外の例外は ★500・`internal_error`・「データの取得に失敗しました」・`logApiError()`★ | 文言は辞書にある(`lib/i18n.ts:381`)。他の GET の500と同じ | | D19 | JSON の本文が ★オブジェクトでない(`null`・配列・数値・文字列)★なら ★422・`validation_failed`・「JSONの形式が不正です」★(4ルートとも今の「JSON が読めない」と同じ文言) | `lib/prechecks/status-update.ts:103` と同じ判定。★本文が数値・文字列のときの文言が今(例「tokenを指定してください」)から変わる★ — 承知のうえ | -| D20 | JSON ストア: ★一時ファイル名を `${filePath}.${process.pid}.${randomUUID()}.tmp` にする★(直列化はしない) | 呼び出しごとに `createJsonStore()` を作り直す(`lib/settings/advice.ts:77` 等)ので、インスタンスごとの直列化は効かない。★同時保存の後勝ち(読み→書きの間の上書き)は今までどおり★(ファイル冒頭の「ロックは入れません」) | +| D20 | JSON ストア: ★一時ファイル名を `${filePath}.${process.pid}.${randomUUID()}.tmp` にする★(直列化はしない)。★【2026-11-05 追記】実装では直列化も足した★(Windows で同時保存の rename が5回中5回 `EPERM` になったため。ファイルパスごとにプロセス内で直列化。`c5fe519`・監督の判断 2026-10-19・D-182) | 呼び出しごとに `createJsonStore()` を作り直す(`lib/settings/advice.ts:77` 等)ので、インスタンスごとの直列化は効かない。★同時保存の後勝ち(読み→書きの間の上書き)は今までどおり★(ファイル冒頭の「ロックは入れません」) | | D21 | `matchAdviceRules(input, thresholds = ESCALATION_THRESHOLDS)` の第2引数を足し、R1・R3・R4 がそれを使う | `level2Days = 3` のテストを `vi.mock` なしで書くため(`isResultPending()` の `waitMinutes` と同じ作法) | | D22 | `weekStart()`・`weekStartMs()` は ★消す★(テストだけが使う)。`CLEAN_ERROR_LABELS`・`cleanErrorLabel()` は ★消す★(テストだけが使う。`CLEAN_ERROR` はシード `scripts/seed-local-db.ts:35` 以降が使うので残す) | A14 | | D23 | `"engines": { "node": ">=22.12" }`・★`@types/node` は `^22`★・`"@next/env": "16.3.3"`(devDependency) | ★型は「動く版のうち一番古いもの」に合わせる★(`^24` の型だと 22 に無い API を書いても `tsc` が通ってしまう)。★`>=22.12` はユーザー指定★(Prisma 7 の要件の下限 22.12 と合う)。`@next/env` は `next`(16.3.3)と同じ版に固定(`prisma7.config.ts:3`・`scripts/check-env.ts:21` が直接 import している。今は `next` の依存として入っているだけ) | diff --git "a/docs/handoff/\346\214\207\347\244\272\346\233\270BJ_\345\210\266\345\276\241\347\233\244\343\201\256\343\203\255\343\202\260\343\201\256\347\202\271\346\244\234\343\201\247\345\207\272\343\201\237\346\214\207\346\221\230\343\202\222\347\233\264\343\201\231_20261105.md" "b/docs/handoff/\346\214\207\347\244\272\346\233\270BJ_\345\210\266\345\276\241\347\233\244\343\201\256\343\203\255\343\202\260\343\201\256\347\202\271\346\244\234\343\201\247\345\207\272\343\201\237\346\214\207\346\221\230\343\202\222\347\233\264\343\201\231_20261105.md" new file mode 100644 index 0000000..b40d595 --- /dev/null +++ "b/docs/handoff/\346\214\207\347\244\272\346\233\270BJ_\345\210\266\345\276\241\347\233\244\343\201\256\343\203\255\343\202\260\343\201\256\347\202\271\346\244\234\343\201\247\345\207\272\343\201\237\346\214\207\346\221\230\343\202\222\347\233\264\343\201\231_20261105.md" @@ -0,0 +1,249 @@ +# 指示書BJ — 制御盤のログ(指示書AY)の点検で出た指摘を直す(2026-11-05) + +**担当**: Cursor。監督(Claude Code)がレビューする。 +**前提**: develop の `ae30f4a` 以降(指示書AY の実装 `3c7bcc8`〜`ae30f4a` が入った版)。 +**着手時の先端**: `git log --oneline -1` の ID を書く: `ae30f4a`。`ae30f4a` と違っても、その間のコミットが資料(`docs/`・`.claude/`)だけなら、本書の行番号はそのまま使える。 +確かめ方: `git diff --stat ae30f4a..HEAD -- . ':!docs' ':!.claude'` が★何も出さない★こと。何か出たら、そのファイルの行番号を開いて確かめ直す。 +★行番号はすべて `ae30f4a` 時点の実物で監督が確かめた値★。作業中にずれたら、書いてある文言で探す。 +★作業の前に必ず読むこと★: +- `docs/handoff/指示書AY_制御盤のログをD-Secログ画面から取り出す_20261019.md` の §3 表5(R43・R44)・段階7-2・段階9-2・§4 の 4-23 +- `components/features/ds-logs/DeviceLogsModal.tsx` の `apiErrorMessage()`(:81-115)・一覧の取得(:260-319)・`startExport()`(:385-421) +- `lib/ds-agent/relay.ts` の `submitListResult()`(:457-465)・`pushChunk()`(:512-563)・`touch()`(:424-426)・`markSeen()`(:307-315) + +--- + +## 0. ユーザーの指示(原文)と回答 + +- 「完了しました、確認してください」(指示書AY の実装の確認)。監督の点検(査読2観点・fixtures の画面・ローカルDB での偽制御盤・本物の `agent.py`)で、下の指摘が出た +- 指摘のうち「制御盤から形の崩れた一覧が来たとき、画面はどうするか」を問うた。選択肢は2つ: + - A: すぐ 502 にして「制御盤の結果の形が不正です」と出す + - B: 今のまま、15秒待ってから「応答がありません」と出す + - ユーザーの回答: ★「Aで、指示書を書いてください」★(2026-11-05) + +## 1. 何を直すか(全体像) + +| # | 重さ | 何が起きているか(監督が確かめた事実) | ★直した後★ | +|---|---|---|---| +| J1 | 中 | モーダルが `status 0` をすべて「取り消し」とみなして黙る(`DeviceLogsModal.tsx:89`)。`apiFetch` はネットワークの失敗でも `status 0` を返す(`lib/api/client.ts:112-121`)。種類を変えた瞬間に通信が切れると、エラーは出ず、★前の条件の一覧が新しい結果のように残る★ | ★取り消し(`AbortError`)だけ黙る★。それ以外の `status 0` は「通信に失敗しました。時間をおいて再度お試しください」を出し、一覧を空にする | +| J2 | 中 | 制御盤が形の崩れた一覧を `…/result` に送ると、制御盤には 400 が返るだけ。ブラウザは15秒待って 504「制御盤から応答がありません」になり、原因が分からない。指示書AY は段階7-2(502)と表5 R43(状態は変えない)が食い違っていて、Cursor は R43 を採った | ★ユーザー回答 A★: 制御盤には今までどおり 400 `result_invalid`。★同時にブラウザの一覧を 502 `agent_error`(`details.agent_code = ["result_invalid"]`)で終わらせる★。画面は「制御盤の結果の形が不正です」 | +| J3 | 中 | `app/api/ds-agent/__tests__/routes.test.ts:197` が、全テストを同時に走らせたときだけ落ちる(2026-11-05 監督の実行: 4,260件中1件。単独では3回とも通過)。`waitUntilConnected()` が「`flush()` を100回」で打ち切るため、負荷で I/O が遅れると間に合わない | 実時間の上限(5秒)で待つ。`end-to-end.test.ts:91-96` の同じ関数も | +| J4 | 中 | JP/EN を切り替えると、モーダルのボタンが最大33px 動く(fixtures の画面、1440×900 で監督が実測。下の表)。指示書AY 段階11-2 の「モーダルのボタンの位置が動かない」を満たさない | 期間4つ・並べ替え2つ・トークン2つのボタンを ★JP と EN の長いほうの幅で固定★し、位置も幅も動かない | +| J5 | 低 | 制御盤が種類の `key` に空文字や `,` を入れても受理する(`lib/ds-logs/validate.ts:235-237`)。`key: ""` なら画面は `types=,app` で取り直して毎回 422、`key: "a,b"` なら `a` と `b` の2種類に分かれてその種類のファイルが黙って消える | `key` は ★1文字以上・`,` を含まない・前後に空白が無い★。違えば一覧全体を不正(J2 の 502) | +| J6 | 低 | 一覧の `file_id` に長さの上限が無い(`validate.ts:242-243`)。エクスポートの本体は各1024文字まで(`:39`・`:176`)なので、1025文字以上だと一覧には出るのにエクスポートで 422 になる | 一覧でも ★1〜1024文字★(同じ定数 `FILE_ID_MAX_LENGTH`)。違えば一覧全体を不正 | +| J7 | 低 | prepared の `total_bytes: 0` を受理するが、0バイトのチャンクは不正なので、そのエクスポートは決して完了しない(何も送られなければ30秒で 504。0バイトのチャンクや complete が来ればすぐ 502。本物の `agent.py` はチャンク0個なら complete を送る) | `total_bytes` は ★1以上★(本物の常駐プログラムのテスト用サーバー `fake_dwatch.py:631` も1以上) | +| J8 | 低 | 前のチャンクの POST が保留中に次のチャンクが来ると、`pendingPush` と `flowTimer` を上書きする(`relay.ts:556-562`)。先の POST は返らず、45秒後に孤立したタイマーが、まだ流れ中のエクスポートを `flow_timeout` で落とす(45秒以内に完了していれば実害なし。査読役がスクリプトで再現) | 保留中に次のチャンクが来たら ★`chunk_out_of_order` でエクスポートを失敗にする★。先の POST は `export_cancelled`(410)で返る | +| J9 | 低 | `ds_agent.connected` のログの `agent_version` がいつも null(2026-11-05 の通し確認で偽制御盤・本物とも)。認証(`requireAgent()` → `touch()`)が先に「接続」を記録し、版は後の `poll()` で入るため | `requireAgent()` が `X-Agent-Version` を読んで `touch()` に渡す。本物の常駐プログラムは全リクエストにこのヘッダを付けている(`agent.py:796`) | +| J10 | 低 | モーダルを開くたびに一覧の問い合わせが2回飛ぶ。1回目は `types` なしで取り、`selectedTypes` を全種類で埋めると `queryString` が変わって取り直すため(`DeviceLogsModal.tsx:185`・`:286-289`) | ★全種類が選ばれているときは `types` を付けない★(API は省略=全種類)。開いたときの問い合わせは1回 | +| J11 | 低 | テストの抜け: (a) 「ディスクに書かない」のスパイが一覧の往復だけ(`end-to-end.test.ts:267-289`)。エクスポートの中継(prepared → chunks → complete → 受け取り)の間を見ていない (b) ログのイベントに `file_id`・ファイル名・トークンが入らないことを、手書きのサンプルでしか見ていない(`lib/logger/__tests__/ds-agent-events.test.ts`) (c) zip の中のパスが `<種類>/<ファイル名>` であることのテストが無い(指示書AY M17) | 3つともテストを足す(§4 表T) | +| J12 | 低 | 開始日が終了日より後のとき、画面は「日付の範囲が不正です」を出す(`DeviceLogsModal.tsx:268`)。指示書AY 表2 T9 は「開始日は終了日以前にしてください」 | 逆順のときは「開始日は終了日以前にしてください」。それ以外の不正(日付として読めない)は今のまま | + +### J4 の実測(監督。fixtures・1440×900・DS-002 のモーダル。JP の位置 − EN の位置) + +| ボタン(JP / EN) | x のずれ | 幅のずれ | EN の幅 | +|---|---|---|---| +| 直近1時間 / Last 1 hour | 0 | −7 | 85 | +| 直近3時間 / Last 3 hours | −7 | −13 | 94 | +| 直近24時間 / Last 24 hours | −20 | −13 | 101 | +| 全て / All | −33 | +7 | 40 | +| 日付 / Date | 0 | −1 | 51 | +| 種類+日付 / Type + date | −2 | −3 | 89 | +| 発行(作り直し) / Issue (regenerate) | 0 | −9 | 129 | +| 失効 / Revoke | −9 | 0 | 120 | +| ✕・エクスポート・閉じる | 0 | 0〜1 | — | + +## 2. API(★先に `d-watch-api.yaml` を直す。CLAUDE.md §6-1★) + +### 2-1. `POST /ds-agent/commands/{command_id}/result`(J2) + +- 本体の形が誤っていたら(`parseAgentListResult()` が `ok: false` になるもの全部。W2〜W8・W9 の error の形の誤り・W10・R44): + 1. ★relay の新しいメソッド `rejectListResult(serial, commandId)`★ を呼ぶ。中身は `failCommand()` の list の分岐(`relay.ts:500-505`)と同じで、コードは `"result_invalid"`。★対象は kind が list の指示だけ★(export の指示や見つからないときは何もせず `"not_found"`)。期限切れは `"gone"` + 2. ★戻り値によらず★、制御盤への応答は今までどおり 400 `result_invalid`(`parsed.error`) +- ブラウザの `GET /ds-devices/{serial_number}/logs` は ★502 `agent_error`(`details.agent_code = ["result_invalid"]`)★ で終わる(今の `agent_error` の経路そのまま。ルートは変えない) +- ★その後に正しい結果を送っても受け取らない★: 指示は消えているので 404 `not_found`(`commandNotFound()`。`lib/ds-agent/agent-request.ts:47-49`。★新しいコードを作らない★) +- ★prepared・complete・fail の本体の形の誤りは今のまま★(400・状態は変えない。表5 R43)。今回ユーザーに問うたのは一覧だけ。準備中に形の崩れた prepared が来たときの動き(50秒で 504)は変えない + +### 2-2. 検証の強化(J5・J6・J7) + +- 一覧の `types[].key`: ★1文字以上・`,` を含まない・前後に空白が無い(`key.trim() === key`)文字列★。`label` は今のまま(文字列)。空白だけの `key`(`" "`)は、画面が `types= ` を送ってサーバーの trim で「省略=全種類」になり、絞り込めなくなるため +- 一覧の `items[].file_id`: ★1〜1024文字★(`FILE_ID_MAX_LENGTH` を共有する。数字を直書きしない) +- prepared の `total_bytes`: ★1以上の整数★(今は0以上) + +### 2-3. `d-watch-api.yaml` で直す箇所(G1: ★grep で関係する語を全部探す★) + +- `POST /ds-agent/commands/{command_id}/result`(:2046 付近の説明): 「1件でも形が違えば結果全体を不正とする(400)」の後に「★ブラウザの一覧は 502 `agent_error`(`agent_code` は `result_invalid`)で終わり、その指示は消える(その後の結果は 404)★」を足す +- `DsResultInvalid`(:2701): 「状態は変えない(D25 ④)」を「★形の誤りは、`/result` ではブラウザの一覧を 502 で終わらせ(指示書BJ)、prepared・complete・fail では状態を変えない(D25 ④)。complete の値の不一致(合計・SHA-256)はエクスポートを失敗にする★」に直す(★今の文も complete の値の不一致について誤っている★ — `completeExport()` は `endExport()` でエクスポートを失敗にする) +- `GET /ds-devices/{serial_number}/logs` の 502: `DsAgentError`(:2691)に「形の不正は `result_invalid`」が既にあるので★足さない★ +- 一覧のスキーマ `DsLogType` / `DsLogFile`(:3641・:3649 付近): `key` に `minLength: 1` と `pattern: "^[^,\s](?:[^,]*[^,\s])?$"`(1文字以上・`,` を含まない・前後に空白が無い。J5)、`file_id` に `maxLength: 1024`。★この2つのスキーマはブラウザ向けの `DsLogListResponse` と共有している★ — 矛盾はしない(制御盤から来た値をそのまま返すので)。その旨を `description` に1文足す +- prepared のスキーマ `DsAgentPrepared`(:3769 付近)の `total_bytes`: `minimum: 1`。★ブラウザ向けの `DsLogExportResponse.total_bytes`(:3704 付近)も `minimum: 1`★(prepared の値をそのまま返す)。`DsAgentComplete.total_bytes`(:3777 付近)も `minimum: 1` にそろえる(prepared と一致しないと失敗するので、0 は来ても必ず失敗する) +- チャンクの 409 の説明(:2112 付近「seq が連番でない・send の前」)に「★前のチャンクの 204 を待たずに次を送った(J8)。このとき保留中だった前のチャンクの POST は 410 `export_cancelled` で返る★」を足す +- `X-Agent-Version`: 今は `GET /ds-agent/commands` にしか定義が無い。J9 で認証の通る6本すべてが読むので、★残りの5本(result・prepared・chunks・complete・fail)にも同じパラメータを足す★(`$ref` で共有してよい)。説明は「接続の記録に使う。形の合わない値は無視する(拒否しない)」 +- 確かめ方: `grep -n "result_invalid\|total_bytes\|file_id\|key:" d-watch-api.yaml` の該当行を報告に貼る + +## 3. 画面と中継の直し方 + +### 3-1. エラーの文言(J1・J2・J12)— ★純粋関数に移して表Eでテストする★ + +- `DeviceLogsModal.tsx` の `apiErrorMessage()`・`detailStrings()`・`formatFileSize()`・`formatDateTime()`(:60-115)を ★新しいファイル `lib/ds-logs/modal-text.ts` に移す★(中身は下の変更以外そのまま)。モーダルはそこから import する +- `apiErrorMessage()` の `status 0` の扱い: + - ★`e.cause` が `name === "AbortError"` のもの(`DOMException` か `Error`)なら null★(モーダルを閉じて準備の POST を取り消したとき。指示書AY 4-23・M5) + - それ以外の `status 0` は ★`t("通信に失敗しました。時間をおいて再度お試しください")`★(`lib/i18n.ts:551` に EN もある既存の文言。新しく作らない) +- `agent_error` の分岐に ★`code === "result_invalid"` → `t("制御盤の結果の形が不正です")`★ を足す(`too_large` と同じ形)。`lib/i18n.ts` に EN `"The controller returned a malformed result"` を足す +- J12: 一覧の取得の effect の日付の分岐(:262-274)で、`queryString === null` かつ日付が理由のとき、★開始日と終了日の両方が読めて開始 > 終了なら「開始日は終了日以前にしてください」★、それ以外は今の「日付の範囲が不正です」。i18n に EN `"The start date must be on or before the end date"` を足す。判定は `parseDate()`(定義は `lib/clean-logs/query.ts:63`。`presets.ts:7` が import 済み)を使い、モーダルに日付の比較を直書きしない(関数 `dateRangeError(start, end): "reversed" | "invalid" | null` を `lib/ds-logs/presets.ts` に足し、表Dで固定する)。★モーダルでは `dateRangeError()` の結果を `useMemo` で作り、一覧の effect の依存に入れる★(effect の中で `period` を直接読むと `react-hooks/exhaustive-deps` に当たる) +- ★一覧の失敗で文言が出るときは、今のコード(:302-305)のとおり一覧を空にする★(J1 で通信の失敗もここに入る) +- ★`agent_error`(J2 の `result_invalid` を含む)のときは見出しを「接続中」にする★(`setConnected(true)`。制御盤は結果を返しているので接続している)。今は `agent_offline` のときしか見出しを直さない(:306-310)ため、開いて最初の一覧が 502 だと、見出しは初期値の「未接続/最終接続 -」のまま本文だけ「制御盤の結果の形が不正です」になる。`last_seen_at` は分からないので今の値のまま。通信の失敗(J1)・`agent_timeout` では見出しを変えない(直前の値のまま) +- ★4MB を超える一覧(W8)も「制御盤の結果の形が不正です」になる★(原因は大きさだが、制御盤側の不具合であることは同じなので許容。文言を分けない) + +### 3-2. 開いたときの問い合わせを1回にする(J10) + +- `queryString`(:181-192)で、★`selectedTypes` が `types` の全部と一致するとき(件数が同じで、全部含む)は `types` を付けない★。`selectedTypes === null`(まだ種類を知らない)も付けない(今のまま)。★`useMemo` の依存に `types` を足す★ +- ★判定は純粋関数にして表Q で固定する(必須)★: `lib/ds-logs/query.ts`(既存。サーバー側の `queryDeviceLogs()` と同じファイル)に `deviceLogTypesParam(knownTypeKeys: readonly string[], selected: ReadonlySet | null): string | null`(`null` = `types` を付けない)を足し、`queryString` から使う +- ★制御盤に種類が1つも無いとき(`types` が空配列)は「種類を1つ以上選んでください」を出さない★: `noTypeSelected` を `selectedTypes !== null && selectedTypes.size === 0 && types.length > 0` にする(今は種類が無いだけで「選んでください」が出る)。種類が無いときは `types` を付けずに取り、0件の表示になる +- 帰結: 開く → 1回目(`types` なし)→ 全種類で埋める → `queryString` は変わらない → 取り直さない +- ★種類を1つ外して戻したとき(全種類に戻る)も `types` なしになる★。API の応答は同じ(省略=全種類) + +### 3-3. ボタンの幅を固定する(J4) + +- ★`components/ui/FilterButton.tsx` は触らない★(他の6画面と共有。§8) +- モーダルの期間・並べ替えの `FilterButton` を、モーダルの CSS(`DeviceLogsModal.module.css`)の入れ物(例 ``)で包み、`.fixedSlot > button { min-width: …; }` で幅を固定する。トークンのボタンは既存の `.tokenButton`(:317-330。今は `min-width: 120px`)を直す。★`.tokenButton` は「コピー」(`DeviceLogsModal.tsx:801-807`)にも付いている★ので、コピーも同じ幅に広がる。それでよいが、トークンの表示欄(`.tokenReveal`)の中で折り返さないことを §6 で見る +- 幅は ★JP と EN の長いほう+余白★ にする。§1 の実測から、★期間4つ 112px・並べ替え2つ 100px・トークン(コピーを含む3つ)140px★(実測 `Last 24 hours` 101px・`Type + date` 89px・`Issue (regenerate)` 129px に、拡大率やフォントの差で動かないよう約10px の余裕)。★4つの期間ボタンを同じ幅にそろえてよい★(そのほうが位置が動かない) +- 確かめ方は §6 の J4(JS で測ってずれ0) + +### 3-4. 中継(J2・J8・J9) + +- J2: `lib/ds-agent/relay.ts` に `rejectListResult(serial: string, commandId: string): SubmitResult` を足す(`Relay` の型と `relay.ts:423` の実装。`lib/ds-agent/__tests__/fake-relay.ts` は本物を偽の時計で包むだけなので直す箇所は無い)。`submitListResult()` の隣に置き、★list の指示だけ★を `{ kind: "agent_error", code: "result_invalid" }` で解決して `commands` から消す。`ds_logs.list` のイベントは今の `agent_error` の経路で `agent_code: "result_invalid"` が出る(★新しいイベントを作らない★) +- J8: `pushChunk()` の `seq` の照合(:520)の★前★に、`rec.pendingPush !== null` なら `endExport(rec, "failed", "chunk_out_of_order", { agentCode: "chunk_out_of_order" })` して `"out_of_order"` を返す。`endExport()` が先の POST を `"cancelled"` で返し、`flowTimer` も消す(:370-371・:388-389。今のまま) +- J9: + - `app/api/ds-agent/commands/route.ts:16`・`:27-28` の版の読み取り(正規表現 `/^[0-9A-Za-z.+-]{1,32}$/`)を ★`lib/ds-agent/require-agent.ts` の関数 `agentVersionOf(request): string | null`★ に移し、commands ルートもそこから import する。★`agent-request.ts` には置かない★(`agent-request.ts:8` が既に `require-agent.ts` を import しているので、置くと import が循環する) + - `requireAgent()`(`lib/ds-agent/require-agent.ts:21`)は `getRelay().touch(serial, agentVersionOf(request))` を呼ぶ。`touch()` の型を `touch(serial: string, agentVersion: string | null): void` にし、中で `markSeen(device(serial), agentVersion)` + - ★形の合わない版は今までどおり無視する(null。拒否しない)★ + - ★`touch()` の `agentVersion` は省略可(`?`)にしない★ — 省略すると `undefined` が `markSeen()` の `!== null` を通って版に入る。必須にして、呼んでいる箇所を全部直す(罠7) + +## 4. 期待値表(★監督が確定。テストはこれをそのまま使う★) + +### 表E — `lib/ds-logs/modal-text.ts`(新規テスト `lib/ds-logs/__tests__/modal-text.test.ts`。`t` は渡した文字列をそのまま返す関数) + +| # | 入力 | 期待 | +|---|---|---| +| E1 | `ApiFetchError`(status 0・cause は `new DOMException("x", "AbortError")`) | `null` | +| E2 | `ApiFetchError`(status 0・cause は `Object.assign(new Error("x"), { name: "AbortError" })`) | `null` | +| E3 | `ApiFetchError`(status 0・cause は `new TypeError("Failed to fetch")`) | `"通信に失敗しました。時間をおいて再度お試しください"` | +| E4 | `ApiFetchError`(status 0・cause なし) | E3 と同じ | +| E5 | 502 `agent_error`・`details: { agent_code: ["result_invalid"] }` | `"制御盤の結果の形が不正です"` | +| E6 | 502 `agent_error`・`agent_code: ["too_large"]` | 今の文言(`{max}` は `100 MB`) | +| E7 | 502 `agent_error`・`agent_code: ["x"]` | `"制御盤でエラーが発生しました(x)"` | +| E8 | 409 `agent_offline`・`details: { last_seen_at: [] }` | `"制御盤が接続されていません(最終接続: -)"` | +| E9 | `ApiFetchError` でない値(`new Error("x")`) | `"データの取得に失敗しました"` | + +`ApiFetchError` の作り方は `lib/api/client.ts:47-60` のコンストラクタ(`{ status, code, serverMessage, details?, cause? }`)。 + +### 表D — `dateRangeError(start, end)`(`lib/ds-logs/presets.ts`。`lib/ds-logs/__tests__/presets.test.ts` に足す) + +| # | start | end | 期待 | +|---|---|---|---| +| D1 | `"2026-10-19"` | `"2026-10-18"` | `"reversed"` | +| D2 | `"2026-10-18"` | `"2026-10-19"` | `null` | +| D3 | `"2026-10-18"` | `"2026-10-18"` | `null`(同じ日はよい) | +| D4 | `"2026-02-30"` | `"2026-03-01"` | `"invalid"`(存在しない日付) | +| D5 | `null` | `"2026-10-18"` | `null` | +| D6 | `"2026-10-19"` | `null` | `null` | + +★`periodFromDates()` の T5〜T9 は変えない★(T9 は今までどおり null)。 + +### 表R — 中継とルート(`lib/ds-agent/__tests__/relay.test.ts`・`app/api/ds-agent/__tests__/routes.test.ts`) + +| # | 手順 | 期待 | +|---|---|---| +| R43′ | 一覧を依頼 → 制御盤が `/result` に `types` の無い本体を送る | 制御盤には 400 `result_invalid`。★ブラウザの一覧は `{ kind: "agent_error", code: "result_invalid" }`(ルート経由なら 502・`details.agent_code = ["result_invalid"]`)★。その後に正しい結果を送ると 404 `not_found`(`commandNotFound()`。`lib/ds-agent/agent-request.ts:47-49`。★新しいコードを作らない★) | +| R43″ | `routes.test.ts:228-249` の `it.each` の全行(W2〜W4b の :229-232 を含む)(今「状態は変えない(後から正しい結果を受け取れる)」) | ★期待を R43′ に変える★(テストの名前も「ブラウザの一覧は 502 で終わる」に) | +| R43‴ | export の指示の id で `/result` に形の崩れた本体 | 400 `result_invalid`。★エクスポートは続く★(`rejectListResult()` は list だけ) | +| R45 | `key: ""` の種類を含む一覧 | `parseAgentListResult()` が 400 `result_invalid`(→ R43′ の流れ) | +| R46 | `key: "a,b"` の種類を含む一覧 | R45 と同じ | +| R46b | `key: " "`・`key: " app"` の種類を含む一覧 | R45 と同じ。`key: "a b"`(中の空白)はよい | +| R47 | `file_id` が1025文字の項目を含む一覧 | R45 と同じ。1024文字はよい | +| R48 | prepared の `total_bytes: 0` | 400 `result_invalid`。`1` はよい | +| R49 | エクスポートで seq0 を送る → ブラウザが読まないうちに seq1 を送る | seq1 は 409 `chunk_out_of_order`。★seq0 の POST は `"cancelled"`(ルートなら 410 `export_cancelled`)で返る★。ブラウザのストリームはエラー。`ds_logs.export_failed` の `reason` は `chunk_out_of_order`。★45秒後に `flow_timeout` のイベントが出ない★ | +| R50 | 認証の通ったリクエスト(`/result` など poll 以外)に `X-Agent-Version: 1.2.3` → 未接続から接続へ | `ds_agent.connected` の `agent_version` が `"1.2.3"` | +| R51 | R50 で `X-Agent-Version: bad version!` | `agent_version` は null(拒否しない) | + +### 表T — テストの補強(J3・J11) + +| # | 何を | どう | +|---|---|---| +| T-a | `waitUntilConnected()`(`routes.test.ts:106-112`・`end-to-end.test.ts:91-96`) | ★`vi.waitFor(() => { if (!connected) throw … }, { timeout: 4000, interval: 5 })`★ のように実時間の上限で待つ(★テストの既定の時間切れが 5000ms(`vitest.config.ts` に `testTimeout` の指定なし)なので、待ちは 4000ms にする★)。偽の時計(`fake.at()`)は進めない | +| T-b | 「ディスクに書かない」(`end-to-end.test.ts:267-289`) | 同じスパイのまま、★一覧の往復の後に、エクスポート(`POST …/exports` → prepared → `GET download_url` → chunks 2つ以上 → complete → 本文を読み切る)まで通してから★ 0回を確かめる | +| T-c | ログのイベント | `end-to-end.test.ts` の一連(一覧 → エクスポート成功 → もう1回エクスポートして途中で失敗させる。例: 制御盤が `fail` を送る)で出たイベントを★2か所から集める★: relay のイベント(`fake.events`。`ds_agent.connected`・`export_completed`・`export_failed` はここ)と、ルートのログ(`vi.mocked(logEvent).mock.calls`。`ds_logs.list`・`export_prepared`・`export_started` はここ)。(1) ★列挙した6種類がそれぞれ1回以上出た★こと (2) どのイベントの `JSON.stringify` にも、使った `file_id`・ファイル名・トークン・SHA-256 が★含まれない★こと。★`file_id` と名前は長い目印の値にする★(例 `FILEID-SENTINEL-7f3a…`・`SENTINEL-DIR/SENTINEL-NAME.log`。短い `"f1"` は偶然一致・見逃しが起きる) | +| T-d | zip の中のパス(指示書AY :784 の「段階7-4 のテストで見る」) | `lib/fixtures/ds-device-logs.ts` の `FIXTURE_EXPORT_ZIP_BASE64` を読んで中央ディレクトリの名前を取り出し、`["api_client/api_client.log", "postcondition_security/postcondition_security.log.2026-10-18"]` と一致する(監督が node で確認した値。順番も) | +| T-e | J10 | ★必須★。表Q(下)を `lib/ds-logs/__tests__/query.test.ts` に足す。画面でも §6 で確かめる | + +### 表Q — `deviceLogTypesParam(knownTypeKeys, selected)`(`lib/ds-logs/query.ts`。`lib/ds-logs/__tests__/query.test.ts` に足す) + +| # | knownTypeKeys | selected | 期待 | +|---|---|---|---| +| Q1 | `["a","b","c"]` | `null` | `null`(付けない) | +| Q2 | `["a","b","c"]` | `{a,b,c}` | `null`(全種類) | +| Q3 | `["a","b","c"]` | `{a,b}` | `"a,b"`(★`selected` を `knownTypeKeys` の順に並べる★) | +| Q4 | `["a","b","c"]` | `{c,a}` | `"a,c"` | +| Q5 | `["a","b"]` | `{a,x}`(件数は同じだが `b` を含まない) | `"a,x"`(全種類と誤認しない。罠5。知らない key は `knownTypeKeys` にある key の後ろに `selected` の順で並べる。サーバーは知らない種類を0件として返す) | +| Q6 | `[]` | `{}` | `null`(種類が無い。モーダルの `noTypeSelected` も `types.length > 0` の条件で偽になる) | + +★G3: 足したテストは、一度わざと壊して赤になるのを確かめてから戻す★(例: J8 の判定を消す → R49 が赤、`rejectListResult()` を呼ばない → R43′ が赤、T-b は `relay.ts` の `pushChunk()` に一時的に `(await import("node:fs")).writeFileSync("sabotage.tmp", "")` を足す → ★T-b のテスト名で赤★になること(ソースの静的検査のテストも赤になるのは想定内)。★T-b のスパイは `mockImplementation(() => undefined)` にして本物を呼ばない★(`vi.spyOn` だけだと本物が呼ばれてファイルが書かれる)。壊した変更はコミットしない。 + +## 5. 段階(この順で。段階ごとにコミット) + +0. 着手前: 冒頭の「着手時の先端」を書く。`git status --short` で ★他のセッションの未コミットの変更★(今は `components/features/errors/ErrorContentPicker.tsx`・`lib/errors/error-content-label.ts` とそのテスト)を確かめ、★触らない・コミットに含めない★。`npx vitest run` の件数(前)を控える(`ae30f4a` で 169ファイル・4,260件) +1. テストの土台: T-a(J3)。★全件を3回走らせて3回とも全件通過★を確かめてからコミット +2. `d-watch-api.yaml`(§2-3) +3. 検証と中継: J5・J6・J7(`validate.ts`)、J2(`relay.ts`・`/result` ルート)、J8、J9(`agent-request.ts`・`require-agent.ts`・commands ルート・`relay.ts`)+表R +4. 画面: `lib/ds-logs/modal-text.ts`(新規)+表E、`dateRangeError()`+表D、`deviceLogTypesParam()`+表Q、`DeviceLogsModal.tsx`(J1・J2 の文言と見出し・J10・J12)、`DeviceLogsModal.module.css`(J4)、`lib/i18n.ts`(EN 2つ) +5. テストの補強: T-b・T-c・T-d(T-e=表Q は段階4 で) +6. 資料: `docs/logging-design.md`(`ds_agent.connected` の `agent_version` が認証の時点で入ること。`ds_logs.list` の `agent_code` に `result_invalid` が出ること)、`docs/decisions.md` の D-179 の末尾(指示書AY の「実装の記録」の後ろ・D-180 の見出しの前。★D-179 の本文と AY の記録は書き換えない★)に §9 を足す +7. tsc・lint・`npm run build`・vitest 全件(★3回★)の件数と、段階ごとのコミットを報告(★報告は日本語★) + +## 6. 画面で確かめること(fixtures) + +手順は指示書AY 段階11-2 と同じ(`USE_FIXTURES=true npm run build` → `USE_FIXTURES=true` と試験用の `AUTH_TOKEN` を環境変数で渡して `npx next start -p 3100`。★:3000 は触らない★。終わったら :3100 を止める。★止める前に、`Get-NetTCPConnection -LocalPort 3100 -State Listen` の `OwningProcess` のコマンド行が自分の `next start -p 3100` であることを確かめる★ — 2026-11-05、親を止めても子の node が :3100 に残った。コマンド行は `next\dist\bin\next" start -p 3100` のように `next` の後に引用符が入るので、`'next start'` で照合すると外れる)。アプリ内ブラウザを使う(Edge は使わない)。 + +- J4: `resize_window` 1440×900。★測る条件をそろえる: 開いた直後(期間は直近24時間・1ページだけ)・トークンの値は表示していない状態★(ページ送りの「前へ/次へ」は `components/ui/Pagination.tsx` で触れない約束なので、2ページ以上だと JP/EN で幅が変わる。測る対象に入れない)。DS-002 のモーダルを JP で開き、`javascript_tool` で `[role=dialog]` の中の `tbody` 以外のボタンの `getBoundingClientRect()` の x・y・幅を控える → 閉じて EN に切り替えて開き直し、同じく測る → ★全ボタンで x・y・幅のずれが 0★(1px 以内は可)。ダーク/ライトでも崩れない。D-Sec ログの表に横スクロールが出ない +- J10: モーダルを開いた直後の `GET /api/ds-devices/DS-002/logs` が★1回★(`read_network_requests` で数える)。種類を1つ外すと `types=` 付きで1回、戻すと `types` なしで1回 +- J12: 開始日 2026-10-19・終了日 2026-10-18 で「開始日は終了日以前にしてください」。EN で英文 +- J1: `javascript_tool` で `window.fetch` を差し替えて一覧の取得だけ `Promise.reject(new TypeError("Failed to fetch"))` にし、種類を1つ外す → 「通信に失敗しました。時間をおいて再度お試しください」が出て、一覧が空になる。差し替えを戻して種類を戻すと一覧が出る +- 今までの動き(一覧・絞り込み・0件の表示・全選択と 100MB の警告・エクスポートの `fetch` 確認・トークンの発行と失効・DS-001 の未接続・編集中は「ログ」が押せない・Esc・Tab・背景のクリック)が変わらない + +★J2・J8・J9 は fixtures の画面では起きない★(fixtures の偽制御盤は形の正しい一覧しか返さない)。単体テスト(表R)で確かめ、画面での確認はしない。 + +## 7. 既知の罠 + +1. ★`rejectListResult()` を `failCommand()` で代用しない★ — `failCommand()` は export の指示ならエクスポートを終わらせる。`/result` に export の id が来たときにエクスポートを壊す(R43‴ が赤になる) +2. ★`/result` の 400 の応答本文は今の `parsed.error` のまま★。`rejectListResult()` の戻り値(`not_found` など)で 404 や 410 に変えない(制御盤には「本体が悪い」ことだけを返す) +3. ★`AbortError` の判定は `cause.name` で行う★。`instanceof DOMException` だけだと、Node のテスト環境やブラウザの実装差で外れる(E2) +4. ★`status 0` をすべて文言にしてしまうと、モーダルを閉じたときの取り消しで、閉じた後に state を触る★(4-23)。E1・E2 が null であること +5. ★J10 で「全種類」を `types.length === selectedTypes.size` だけで判定しない★ — 種類の一覧が変わった(制御盤の設定が変わった)ときに、知らない key を含む集合を全種類と誤認する。★件数が同じ、かつ `types` の key を全部含む★で判定する +6. ★J4 で `FilterButton` に `className` の引数を足さない★(§8 で触らない約束。入れ物の CSS で包む) +7. ★J9 で `touch()` の引数を足すと、`touch()` を呼んでいる箇所(`require-agent.ts` とテスト)が型エラーになる★。`grep -rn "\.touch(" lib app` で全部直す(★`relay.test.ts` に7か所・`app/api/ds-devices` の `route.test.ts` に2か所★)。★vitest は型を検査しないので、直し漏れは `tsc` でしか分からない★ +8. ★J7 で `total_bytes` を1以上にすると、既存のテスト `lib/ds-logs/__tests__/validate.test.ts:409`(「total_bytes=0・file_count=1(下限)」は通る)が赤になる★。指示書AY 段階7-2 の「0以上」(`fake_dwatch.py` との差は実害なし)を本書で取り消す。そのテストの期待を R48 に合わせて直し、報告に書く +9. ★J3 の `vi.waitFor` で偽の時計を進めない★(`fake.at()` を待ちの中で呼ぶと、タイマーが登録される前に時刻が進み、別の理由で落ちる。`routes.test.ts:103-104` のコメントのとおり) +10. ★T-d の zip の名前の順は、zip の中央ディレクトリの順★。並べ替えて比べない(順番も監督の確認値どおり) + +## 8. 共通ルール + +- G1〜G4(指示書AZ §2): API の挙動が変わったら YAML の影響を受ける箇所を全部直す/古い挙動を書いたコメントを残さない(★少なくとも次の全部★: `result/route.ts:5` の「状態は変えない」、`relay.ts:125` の `touch` の説明、`DeviceLogsModal.tsx:81` の「status 0(取り消し)は null」、`lib/ds-logs/validate.ts:305` の「`total_bytes` は0以上の整数」と `:2-3` の見出しの「W1〜W11」(R45〜R48 を足す)、`lib/ds-agent/__tests__/relay.test.ts` の `:2` の「R1〜R43」・`:52` の表の R43・`:953` のテスト名・`:976` のコメントの「状態は変えない(正しい結果を受け取れる)」(★relay を直接呼ぶので緑のままだが、記述が J2 と食い違う★)、`lib/ds-agent/require-agent.ts:6`(版も入れる)、`lib/logger/events.ts:512` の `agent_code` の説明(`result_invalid` は D-Watch が付けるコード)。`prepared/route.ts:5`・`fail/route.ts:5` の「状態は変えない」は正しいので直さない)/テストは壊して赤を確かめる/決定の記録にコミット・件数の前後・画面確認(出所つき)・本書と違う判断を書く +- 期待値表(§4)を先にテストにする。数字・順番を変えない。合わないと思ったら実装を変えずに監督に報告 +- `git add -A` 禁止。★ファイルパスを明示してコミットする★(段階ごと)。PR とマージは daichi のレビューの後 +- 触らない: `components/ui/`(`FilterButton.tsx` を含む)・`docs/handoff/指示書AY_*`・`docs/handoff/設計_制御盤ログの取り出し_*`・`components/features/errors/ErrorContentPicker.tsx`・`lib/errors/error-content-label.ts` とそのテスト・`prisma/`・`CLAUDE.md`・`C:\Users\daich\dev\dwatch-log-agent\`(制御盤のプログラム。読むだけ) +- DB に書き込まない。:3000 を起動・停止しない + +## 9. 決定の記録(段階6 で `docs/decisions.md` の D-179 の末尾に足す。★新しい D 番号は取らない★) + +```markdown +- ★点検の指摘の直し(2026-11-05。指示書BJ)★: + 1. ★制御盤から形の崩れた一覧が来たら、ブラウザの一覧をすぐ 502 `agent_error`(`agent_code` `result_invalid`)で終わらせる★(ユーザー回答 2026-11-05「A」)。制御盤には今までどおり 400。指示書AY の段階7-2(502)と表5 R43(状態は変えない)の食い違いは、一覧については段階7-2 を採る。prepared・complete・fail は R43 のまま + - 却下: B(15秒待って 504「応答がありません」)— 原因が制御盤側の形の誤りだと分からない + 2. 画面は取り消し(`AbortError`)だけ黙り、通信の失敗は文言を出して一覧を空にする + 3. 制御盤の一覧の `key` は1文字以上で `,` を含まず前後に空白が無い・`file_id` は1〜1024文字・prepared(とブラウザ向けの応答・complete)の `total_bytes` は1以上(指示書AY 段階7-2 の「0以上」を取り消す) + 4. チャンクの POST が保留中に次のチャンクが来たら `chunk_out_of_order` でエクスポートを失敗にする(並列送信は約束違反。孤立したタイマーで後から落ちるのを防ぐ) + 5. 接続のイベントの版は認証の時点で `X-Agent-Version` から入れる + 6. モーダルを開いたときの一覧の問い合わせは1回(全種類のときは `types` を付けない)。制御盤に種類が無いときは「種類を1つ以上選んでください」を出さない。JP/EN でモーダルのボタンの位置と幅を動かさない(期間・並べ替え・トークンのボタンを固定幅に) + 6b. 一覧が `agent_error` で失敗したときは見出しを「接続中」にする(制御盤は結果を返している) + 7. ★残したもの★: `eslint-disable` が2行(指示書AY 4-19 は1行。どちらも `set-state-in-effect` で実害なし)・段階11-1 の grep がテストのファイル(スパイと `mkdtemp`)と型のコメント(「`validation_error` は足さない」)に当たる(指示書AY の grep の書き方の問題。テストが必要とする記述)・種類を素早く切り替えたときに制御盤への一覧の依頼が溜まりうる(推測。待ち行列5件を超えると 429。実機で起きたら直す) + 8. ★実装の記録★: コミット/テスト件数(前 169ファイル・4,260件 → 後)/fixtures の画面確認(当方の fixtures 由来)/本書と違う判断 +``` diff --git "a/docs/handoff/\346\214\207\347\244\272\346\233\270BK_\346\214\207\347\244\272\346\233\270BJ\343\201\256\347\202\271\346\244\234\343\201\256\347\233\264\343\201\227_20261105.md" "b/docs/handoff/\346\214\207\347\244\272\346\233\270BK_\346\214\207\347\244\272\346\233\270BJ\343\201\256\347\202\271\346\244\234\343\201\256\347\233\264\343\201\227_20261105.md" new file mode 100644 index 0000000..c7f1e09 --- /dev/null +++ "b/docs/handoff/\346\214\207\347\244\272\346\233\270BK_\346\214\207\347\244\272\346\233\270BJ\343\201\256\347\202\271\346\244\234\343\201\256\347\233\264\343\201\227_20261105.md" @@ -0,0 +1,182 @@ +# 指示書BK — 指示書BJ の点検で出た指摘を直す(2026-11-05) + +**担当**: Cursor。監督(Claude Code)がレビューする。 +**前提**: develop の `53f46e0` 以降(指示書BJ の実装 `d99725d`〜`53f46e0` が入った版)。 +**着手時の先端**: `git log --oneline -1` の ID を書く: `53f46e0`。`53f46e0` と違っても、その間のコミットが資料(`docs/`・`.claude/`)だけなら、本書の行番号はそのまま使える(確かめ方: `git diff --stat 53f46e0..HEAD -- . ':!docs' ':!.claude'` が何も出さない)。 +★行番号はすべて `53f46e0` 時点の実物で監督が確かめた値★。ずれたら書いてある文言で探す。 +★作業の前に読むこと★: `docs/handoff/指示書BJ_制御盤のログの点検で出た指摘を直す_20261105.md` の §4 表R(R45〜R48)・表T(T-b)・表E(E6)。 + +--- + +## 0. ユーザーの指示(原文)と回答 + +- 「確認して」(指示書BJ の実装の確認)→ 監督の点検(tsc・lint・全テスト3回・fixtures の画面・査読2観点)で、★本物の制御盤で困る不具合は無い★が、テストが見張りになっていない箇所と、上限の値の食い違いが見つかった +- ファイル ID の長さの上限について、監督が選択肢を示した: A=D-Watch を 8192 にして制御盤とそろえる/B=1024 のまま、制御盤側で長いものを外す +- ユーザーの回答: ★「Aで、指示書BKを書いてください」★(2026-11-05) +- 本書を書いた後、監督が「制御盤は応答を 64KB までしか読まない」ことを報告し、選択肢を示した: A=D-Watch でエクスポートの ID の合計を約48KB までに制限し、超えたら 422「選んだファイルが多すぎます」/B=制御盤側の読み取りの上限を上げる(別リポジトリ・本番機への反映が要る)/C=何もしない(記録だけ) +- ユーザーの回答: ★「Aで、指示書BKに書き足してください」★(2026-11-05)→ K7 + +## 1. 何を直すか + +| # | 重さ | いま(監督・査読役が確かめた事実) | ★直した後★ | +|---|---|---|---| +| K1 | 中 | R45・R46・R46b(`key` の不正4行)のテストは、★J5 の検査を消しても緑のまま★。`lib/ds-logs/__tests__/validate.test.ts:403-406` は `types[0]` の key だけを壊し、items の `type` は `"app"` のまま。`app/api/ds-agent/__tests__/routes.test.ts:245-248` は `types` を丸ごと差し替え、`VALID_LIST.items[0].type` は `"app"` のまま。どちらも「items の種類が types に無い」(`itemsの形式が不正です`)で 400 になる。正しい key(例 `"VALIDKEY"`)でも同じ形なら 400(査読役が tsx で確認)。`expectResultInvalid()`(`validate.test.ts:77-82`)は status と code しか見ない | 壊した key を ★items の `type` にも使う★。さらに ★メッセージ `"typesの形式が不正です"` まで検査する★ | +| K2 | 中 | 「ディスクに書かない」の見張りが ★`import("node:fs")`・`require("fs")`・`"node:fs/promises"` を見逃す★。T-b のスパイ(`end-to-end.test.ts:354-387`)は `fs` の既定オブジェクトのプロパティだけに効き、名前付き import や動的 import の書き込みは捕まえない(査読役が試して確認: 実際にファイルが書かれて緑)。静的な検査(`:389-425`)の禁止の正規表現(`:418`)は `from "node:fs"` の形だけで、`from "node:fs/promises"`・`import("node:fs")`・`require(…)` に当たらない。★指示書BJ の G3 に書いた壊し方(`await import("node:fs")`)は、そもそも赤にならない壊し方だった(監督の誤り)★ | 静的な検査で ★fs / os のモジュール名の文字列そのもの★と `require(` を禁じる。T-b のスパイは補助として残し、説明を正す | +| K3 | 低 | `parseAgentComplete()` が `total_bytes: 0` を 400 にしている。これは ★指示書BJ §2-3(`DsAgentComplete.total_bytes` も `minimum: 1`)と D-179 の BJ の記録の項3(`docs/decisions.md:13066`)のとおり★で、実装は正しい。★テストだけが無い★(監督は点検の報告で一度「BJ に無い変更」と誤って書いた — BJ §2-2 だけを見ていた) | 挙動はこのまま。★テストを1行足す★ | +| K4 | 低 | E6(`lib/ds-logs/__tests__/modal-text.test.ts:57-67`)の期待値を `formatFileSize(EXPORT_MAX_BYTES)` で作っている。表示の関数や上限が変わっても赤にならない | 期待値を ★文字列 `"選んだファイルが大きすぎます(1回 100 MB まで)"` で固定★ | +| K5 | 低 | R43′ の「その後の 404」(`routes.test.ts:256`)が status しか見ない。★ブラウザの `GET …/logs` が 502・`details.agent_code = ["result_invalid"]` になることを、ルートを通して見るテストが無い★(relay の Promise の値までしか見ていない) | 404 のコード `not_found` も見る。`end-to-end.test.ts` に「一覧 → 形の崩れた結果 → ブラウザは 502」を1本足す | +| K6 | 中(値の変更) | D-Watch の `FILE_ID_MAX_LENGTH` は 1024(`lib/ds-logs/validate.ts:39`)。★本物の制御盤が受け取れる ID の上限は 8192 文字★(`C:\Users\daich\dev\dwatch-log-agent\agent.py:64` `FILE_ID_MAX_LEN = 8192` を、受け取った ID を読む `decode_file_id()`(:367)が使う)。★作る側(`encode_file_id()`(:353-359)・それを呼ぶ `list_files()`(:518))には上限が無く★、長いものを一覧から外す処理も無い。ID は「番号:相対パス」の UTF-8 を base64url にしたもの。相対パスが約766バイトを超えるファイルが1つあると、★一覧全体が 400 → 画面は「制御盤の結果の形が不正です」でどのログも見られない★ | ★ユーザー回答 A★: `FILE_ID_MAX_LENGTH` を ★8192★ にする(一覧とエクスポートの本体の両方。同じ定数) | +| K7 | 中(上限の追加) | ★制御盤は D-Watch の応答を 64KB までしか読まない★(`C:\Users\daich\dev\dwatch-log-agent\agent.py:62` `RESPONSE_READ_MAX_BYTES = 64 * 1024`・`:820` `resp.read(RESPONSE_READ_MAX_BYTES)`)。エクスポートの指示は poll の応答に選んだ `file_ids` を全部入れて届く(`lib/ds-agent/relay.ts:703`)。★ID の合計が約64KB を超えると JSON が途中で切れ★、制御盤は「poll returned a body that is not JSON」(`agent.py:1028-1033`)でやり過ごし、★ブラウザは 50秒後に 504「制御盤から応答がありません」★(原因が分からない)。1024 のときからある(1024文字なら約64件・8192文字なら約8件で超える)。今の実際の ID(30〜40文字)なら500件でも約20KB で起きない | ★ユーザー回答 A★: ブラウザの `POST …/logs/exports` で、★`file_ids` を JSON にした大きさ(UTF-8 のバイト数)が `EXPORT_FILE_IDS_MAX_BYTES`(48KB = 49,152)を超えたら 422★。画面は「選んだファイルが多すぎます。数を減らしてください」。★制御盤(`agent.py`)は直さない★ | + +## 2. 直し方 + +### K1 — `key` の不正のテストを効かせる + +- `validate.test.ts:403-406` の4行(R45・R46・R46b×2): 本体を ★壊した key `bad` を `types[0].key` に入れ、items のうち `type: "app"` のものを全部 `type: bad` にした形★ で作る(補助関数を1つ足してよい。例 `withTypeKey(bad)`)。R47(:407。`file_id` の1025文字)は K6 で値が変わる(下) +- 期待: `expectResultInvalid()` に ★メッセージの引数(省略可)を足し★、この4行は `"typesの形式が不正です"` を検査する(`lib/ds-logs/validate.ts:284` の文言)。★この4行は R47(:407。メッセージは `itemsの形式が不正です`)と同じ `it.each` に入っているので、別の `it.each` に分ける★(または期待メッセージの列を足す) +- `routes.test.ts:245-248` の4行も同じ: `types: [{ key: bad, label: "x" }]` と ★`items: [{ ...VALID_LIST.items[0], type: bad }]`★。応答の本文の `error.message` が `"typesの形式が不正です"` であることも見る。★この4行は W2〜W7・R47 と同じ `it.each`(:231-257)に入っていて、期待メッセージが行ごとに違うので、別の `it.each` に分ける★。★`errorCode()`(:98-100)は `res.json()` で本文を読み切るので、あとから `error.message` を読めない★ — 本文を1回だけ読んで code と message を両方返す補助関数(例 `errorBody()`)を足して使う +- ★G3(壊して赤)★: `lib/ds-logs/validate.ts:236-238` の `isLogTypeKey()` を一時的に `typeof value === "string"` だけにする → ★この8行(validate 4・routes 4)が赤★になることを確かめて戻す。★戻す前の赤の出力(テスト名と件数)を報告に貼る★ + +### K2 — 「ディスクに書かない」の見張りを効かせる + +- `end-to-end.test.ts:418` の `banned` を次にする(`tmpdir`・`mkdtemp` は残す): + ```ts + const banned = [ + /["'](?:node:)?(?:fs|os)(?:\/promises)?["']/, // import・export・動的 import・require のどれで書いても、モジュール名の文字列で当たる + /\brequire\s*\(/, + /\bimport\s*\(/, // 動的 import はモジュール名によらず禁じる(テンプレート文字列の import(`node:fs`) も塞ぐ) + /\btmpdir\b/, + /\bmkdtemp\b/, + ]; + ``` + ★今の対象ファイルにこの3つの新しい正規表現に当たる記述は無い★(`import(` も0件。`requireAgent(`・`"@/lib/ds-agent/require-agent"` には当たらない)(監督が `grep -rnE "[\"'](node:)?(fs|os)(/promises)?[\"']"` で確認。0件)。★コメントの中でもモジュール名を引用符で囲んで書かない★(書くと赤になる。書くならバッククォートで)。裏返しで、1つ目の正規表現はバッククォートのテンプレート文字列(`` import(`node:fs`) ``)には当たらない — ★それは3つ目の `import(` の禁止で塞ぐ★ +- テスト名(:389)を「relay / ds-logs / agent ルート / logs ルート / agent-request に fs・os のモジュール名と require が無い」に直す +- T-b(:354 の `describe`)の冒頭に1行コメントを足す: 「スパイは `fs` の既定オブジェクト経由の書き込みしか捕まえない。名前付き import・動的 import・require は下の静的な検査で捕まえる」 +- ★G3(壊して赤)★を3通り試し、★3通りとも静的な検査のテストが赤★になることを確かめて戻す(出力を報告に貼る): + 1. `lib/ds-agent/relay.ts` の先頭に `import { writeFileSync } from "node:fs";` + 2. `pushChunk()` の中に `await import("node:fs/promises");` + 3. `pushChunk()` の中に `require("fs");`(型エラーになるなら `// @ts-expect-error` を付けてよい。試すだけ) + ★試した変更はコミットしない★ + +### K3 — complete の `total_bytes: 0` + +- `validate.test.ts` の `parseAgentComplete()` の不正の表(:457 の `describe` の中、:463 の `it.each`)に ★`["total_bytes が0(指示書BK K3)", JSON.stringify({ total_bytes: 0, sha256: HEX64 })]`★ を足す(他の行と同じ形。complete の本体の項目は `validate.ts:329` 以降で確かめる) +- 挙動(400 `result_invalid`・状態は変えない)は変えない。prepared が 0 を拒むので、正しい流れでは complete に 0 は来ない + +### K4 — E6 を固定 + +- `modal-text.test.ts:64-66` の期待値を `"選んだファイルが大きすぎます(1回 100 MB まで)"` にする。`formatFileSize`・`EXPORT_MAX_BYTES` の import が使われなくなったら消す(lint) + +### K5 — 404 のコードと、ルートを通した 502 + +- `routes.test.ts:256`: status 404 に加えて `errorCode(…)` が `"not_found"` +- `end-to-end.test.ts` に1本足す(既存の補助関数 `agentPoll()`・`browserLogs()`・`agentPostResult()`(:138・:116・:142)を使う): + - 制御盤が待つ → ブラウザが一覧を求める → 制御盤が `/result` に `{ types: [], items: "x" }` のような形の崩れた本体を送る → ★制御盤は 400 `result_invalid`★、★ブラウザの応答は 502・`error.code` が `agent_error`・`error.details.agent_code` が `["result_invalid"]`★ + - ★G3★: `app/api/ds-agent/commands/[command_id]/result/route.ts` の `rejectListResult()` の呼び出しを一時的に消す → ブラウザ側の応答が返らず、★vitest の既定の時間切れ(5秒)で赤★になることを確かめて戻す(e2e の relay は偽の時計で、`at()` を呼ばない限りタイマーは発火しないので、504 にはならない。制御盤側の 400 は緑のまま) + +### K6 — ファイル ID の上限を 8192 に(ユーザー回答 A) + +- `lib/ds-logs/validate.ts:39` の `FILE_ID_MAX_LENGTH` を ★8192★ にし、コメントに理由を書く(「制御盤の常駐プログラムが受け取れる上限 `FILE_ID_MAX_LEN`(`agent.py:64`。`decode_file_id()` が使う)とそろえる。指示書BK K6」)。一覧(:248)とエクスポートの本体(:172・:176)は同じ定数なので両方 8192 になる +- テストの値を直す(★1024 / 1025 を直書きしている行を全部★。`grep -rn "1024\|1025" lib/ds-logs/__tests__ app/api/ds-agent/__tests__ "app/api/ds-devices"` で探す。`4 * 1024 * 1024` のような大きさの式は対象外): + - R47(`validate.test.ts:407`・`routes.test.ts:249`): 8193 文字は不正。「1024文字はよい」(`validate.test.ts:421-423`)は ★8192 文字はよい★ に + - エクスポートの本体(V7): `validate.test.ts:17`(表の説明の「1025文字」)・`:183`(テスト名の「1024文字」)・`:191`(`"x".repeat(1024)`)・`:200`(`"1025文字"` の行)を 8192 / 8193 に(`:183-192` のテストは本体を2つ作る。500件の本体は短い ID(`id-${i}`)で、長い ID は1件だけの別の本体なので、8192 にしても遅くならない) +- `d-watch-api.yaml`(G1。★`grep -n "1024" d-watch-api.yaml` で全部★): `:1911` の「各 1〜1024 文字」・`:3667` の `DsLogFile.file_id` の `maxLength`・`:3710` の `DsLogExportRequest.file_ids.items` の `maxLength` を 8192 に。★大きさ(`1024 * 1024` 系の説明)は変えない★ +- ★本体の大きさの確認★(監督が計算済み。変えなくてよいが、記録に書く): エクスポートの本体は最大 `EXPORT_MAX_FILES`(500。`lib/config/ds-agent.ts:69`)× 8192 文字 ≒ 4MB で、proxy が本体を読む上限の 10MB(指示書AY 4-1)より小さい。★本番の nginx の `client_max_body_size`(TeamWorks の設定。既定は 1MB)を超えうる★が、実際の ID は短い(`agent.py` の ID は「番号:相対パス」の base64url。今のログのフォルダ名なら40〜80文字程度)ので、500件選んでも 40KB 程度。★長い ID を大量に選んだときだけ nginx が 413 を返しうる★ — 実害は小さいので記録に残すだけ +- 一覧の本体の上限(`LIST_RESULT_MAX_BYTES` 4MB)は変えない +- ★もう1つの上限(監督が確認。K6 では直さない・K7 で直す)★: 制御盤は D-Watch の応答を ★64KB までしか読まない★(`agent.py:62` `RESPONSE_READ_MAX_BYTES = 64 * 1024`・`:820`)。エクスポートの指示は poll の応答に選んだ `file_ids` を全部入れて届く(`lib/ds-agent/relay.ts:703`)ので、★ID の合計が約64KB を超えると JSON が途中で切れ★、制御盤は「poll returned a body that is not JSON」(`agent.py:1028-1033`)でやり過ごし、ブラウザは 50秒後に 504「制御盤から応答がありません」になる。★1024 のときからある問題★(1024文字の ID なら約64件、8192文字なら約8件で超える)。今の実際の ID(「番号:相対パス」の base64url。30〜40文字程度)なら500件でも約20KB で収まる。★手当ては K7★(ユーザー回答 A。D-Watch の POST で ID の合計を制限して 422 にする) + +### K7 — エクスポートで選んだ ID の合計の上限(ユーザー回答 A) + +- ★なぜ 48KB か★: poll の応答は `{"command_id":"<36文字>","kind":"export","params":{"file_ids":[…],"max_bytes":104857600,"chunk_bytes":4194304}}`(`relay.ts:700-704`。`NextResponse.json` は `JSON.stringify` そのまま)。`file_ids` の外側は約140バイト(監督が `JSON.stringify` で測って 136〜138)なので、48KB なら 64KB まで約16KB の余裕がある。★`file_ids` の部分を JSON のまま数えるので、引用符・カンマ・`\uXXXX` の逃がし・UTF-8 の多バイトも含めて、実際に届くバイト数と一致する★ +- ★帰結(監督の計算)★: 8192文字の ID なら5件まで(6件で 49,171 バイト)。平均 95文字以下の ID なら、先に500件の上限が効く(49,152 ÷ 500 − 3 ≒ 95)。今の実際の ID(30〜40文字)では500件でも約21,500 バイトで、★今の使い方では一度も効かない★ +- `lib/config/ds-agent.ts` の `EXPORT_MAX_FILES`(:69)の後ろに足す: + ```ts + /** + * 1回のエクスポートで選ぶ `file_ids` を JSON にした大きさ(UTF-8 のバイト数)の上限(指示書BK K7)。 + * ★制御盤は D-Watch の応答を 64KB までしか読まない★(`agent.py` の `RESPONSE_READ_MAX_BYTES`)。 + * エクスポートの指示は poll の応答に `file_ids` を全部入れて届くので、外側の項目(約140バイト)の分を残して 48KB にする。 + * ★64KB に近づけない★(超えると指示が途中で切れ、ブラウザは 50秒待って 504 になる) + */ + export const EXPORT_FILE_IDS_MAX_BYTES = 48 * 1024; + ``` +- `lib/ds-logs/validate.ts` の `parseExportRequest()`(:164-178): 今の3つの検査(件数・各 ID の長さ・重複)を全部通った後に、★`new TextEncoder().encode(JSON.stringify(fileIds)).length > EXPORT_FILE_IDS_MAX_BYTES` なら 422★: + - `fail(422, "validation_failed", EXPORT_FILE_IDS_TOO_LARGE_MESSAGE, { file_ids: [`ファイル ID の合計が ${EXPORT_FILE_IDS_MAX_BYTES} バイト(JSON・UTF-8)以内になるように選んでください`] })` + - `export const EXPORT_FILE_IDS_TOO_LARGE_MESSAGE = "選んだファイルが多すぎます。数を減らしてください";`(validate.ts に置く。画面の文言の関数と共有する) + - ★`fileIds.join("")` の長さや文字数で数えない★(罠7)。`Buffer` でなく `TextEncoder`(どちらでも動くが、画面側からも import されるファイルなので Node の型に寄せない) + - 関数の説明のコメント(:160-163)に「★ID の合計の上限(K7)★」を一言足す +- `lib/ds-logs/modal-text.ts` の `apiErrorMessage()`: `switch (e.code)` に ★`case "validation_failed":`★ を足し、`e.serverMessage === EXPORT_FILE_IDS_TOO_LARGE_MESSAGE` なら `t(EXPORT_FILE_IDS_TOO_LARGE_MESSAGE)`、それ以外は今の `default` と同じ(`e.serverMessage ?? t("データの取得に失敗しました")`)。★他の 422 の出方は変えない★ +- `lib/i18n.ts`: `"選んだファイルが大きすぎます(1回 {max} まで)"`(:598)の後ろに `"選んだファイルが多すぎます。数を減らしてください": "Too many files selected. Please select fewer files"` を足す +- `d-watch-api.yaml`(★先に直す★。G1): `POST /ds-devices/{serial_number}/logs/exports` の 422 の説明(:1910-1912)に「`file_ids` を JSON にした大きさ(UTF-8)が 49152 バイトを超える → message「選んだファイルが多すぎます。数を減らしてください」(制御盤が応答を 64KB までしか読まないため。指示書BK K7)」を足す。`DsLogExportRequest.file_ids`(:3705-3710)に `description` で同じ条件を書く(JSON Schema では表せないので説明だけ) +- ★画面(`components/`)は直さない★。選択中に先回りしてボタンを止めることはしない(今の ID では起きないので、サーバーの 422 と文言だけにする。記録の項6 に却下として書く) +- テスト(★期待値表★。K7-1〜K7-7): + + | # | どこ | 入力 | 期待 | + |---|---|---|---| + | K7-1 | `validate.test.ts`(V7 の近く) | `file_ids` の JSON が ★ちょうど 49,152 バイト★ | 通る | + | K7-2 | 同上 | ★49,153 バイト★ | 422・message `選んだファイルが多すぎます。数を減らしてください`・`details.file_ids` が1件 | + | K7-3 | 同上 | `["あ".repeat(8192), "い".repeat(8192)]`(文字数では 16,391 だが UTF-8 では 49,159 バイト) | 422(★バイトで数えている★ことの見張り) | + | K7-4 | `app/api/ds-devices/[serial_number]/logs/__tests__/route.test.ts`(V7 の近く :256) | K7-2 の本体 | 422・本文が `{ error: { code: "validation_failed", message: 上の文, details: { file_ids: [1件] } } }`・★`exists` が呼ばれない★(V8 の行と同じ見方) | + | K7-5 | `app/api/ds-agent/__tests__/end-to-end.test.ts` | 制御盤が待つ → ブラウザが K7-1 の本体(ちょうど上限)でエクスポート → 制御盤の poll の応答 | ★応答の本文(`await res.text()` の UTF-8 バイト数)が 64 × 1024 以下★、かつ `JSON.parse` でき、`params.file_ids` が送った配列と同じ。後片付けは既存のテスト(:448 の `agentPostFail()` など)と同じ作法 | + | K7-6 | `lib/ds-logs/__tests__/modal-text.test.ts`(表E) | 422・`validation_failed`・serverMessage が上の文 | `"選んだファイルが多すぎます。数を減らしてください"`(t は恒等で可)。★英語の辞書で `"Too many files selected. Please select fewer files"` になること★も1行 | + | K7-7 | 同上 | 422・`validation_failed`・serverMessage が `file_idsの形式が不正です` | `"file_idsの形式が不正です"`(今のまま) | + + - K7-1・K7-2・K7-5 の本体は ★`EXPORT_FILE_IDS_MAX_BYTES` から作る補助関数★で作る(数字の直書きにしない。G3 の (b) が赤になるため)。例 `fileIdsOfJsonBytes(n)`: 1件の JSON は「ID の長さ+3」(引用符2つとカンマ。配列の括弧で+1)なので、`k = Math.ceil((n - 1) / (FILE_ID_MAX_LENGTH + 3))` 件にし、先頭 k−1 件は `FILE_ID_MAX_LENGTH` 文字(★先頭に番号を付けて重複させない★。例 `` `${i}-`.padEnd(FILE_ID_MAX_LENGTH, "x") ``。`"a"`〜`"z"` の1文字の繰り返しは26件で尽きるので使わない)、最後の1件は `n − 1 − (k − 1) × (FILE_ID_MAX_LENGTH + 3) − 3` 文字。49,152 なら 6件(8192 × 5 + 8173)。★補助関数の結果を `TextEncoder` で測って n と一致することもテストの中で確かめる★。置き場所は `lib/ds-logs/__tests__/`(例 `file-ids-of-bytes.ts`。`.test.ts` にしない)で、e2e からも import する + - ★K6 を先に入れてから K7★(`FILE_ID_MAX_LENGTH` が 8192 でないと、49,152 バイトを作る件数が変わる。補助関数は定数から計算するので値は直書きしない) +- ★G3(壊して赤)★: + - (a) `parseExportRequest()` の K7 の検査を一時的に消す → ★K7-2・K7-3・K7-4 が赤★ + - (b) `EXPORT_FILE_IDS_MAX_BYTES` を一時的に `64 * 1024` にする → ★K7-5 が赤★(上限ちょうどの本体に外側の約140バイトが足されて 64KB を超える) + - (c) `modal-text.ts` の `case "validation_failed":` を消す → ★K7-6 の英語の行が赤★ + - 戻す前の赤の出力を報告に貼る + +## 3. 段階(この順で。段階ごとにコミット) + +0. 着手前: 冒頭の「着手時の先端」を書く。`git status --short` で他のセッションの未コミットの変更(`components/features/errors/ErrorContentPicker.tsx`・`lib/errors/error-content-label.ts` とそのテスト・指示書AY・指示書BJ・本書)を確かめ、★触らない・コミットに含めない★。`npx vitest run` の件数(前)を控える(`53f46e0` で 170ファイル・4,306件) +1. K1(テストだけ)+G3 の赤の確認 +2. K2(テストだけ)+G3 の3通りの赤の確認 +3. K3・K4・K5(テストだけ)+K5 の G3 +4. K6(★`d-watch-api.yaml` を先に直してから★ `validate.ts`・テスト。CLAUDE.md §6-1) +4b. K7(★`d-watch-api.yaml` を先に★ → `lib/config/ds-agent.ts`・`validate.ts`・`modal-text.ts`・`lib/i18n.ts`・テスト)+G3 の (a)〜(c) +5. 記録(§4) +6. tsc・lint・vitest 全件(★3回★)の件数と、段階ごとのコミット、G3 の赤の出力(★K1・K2×3・K3・K4・K5・K6・K7×3★)を報告(★日本語★)。K3 は `parseAgentComplete()` の `total_bytes` の下限を外す → 足した行が赤。K4 は `formatFileSize()` の MB の表示を一時的に変える → E6 が赤。K6 は定数を 1024 に戻す → 「8192 文字はよい」の行が赤 + +★画面の確認は要らない★(画面の動きは変わらない。K6 で長い ID が通るようになるだけで、fixtures の ID は短い。K7 の 422 は fixtures の短い ID では画面から起こせないので、文言は表E(K7-6)のテストで見る)。 + +## 4. 決定の記録(段階5 で `docs/decisions.md` の D-179 に足す。★新しい D 番号は取らない★) + +足す場所: 指示書BJ の記録の項8「★実装の記録★」(`docs/decisions.md:13072`)の後ろ・`---` と D-180 の見出し(:13076)の前。★BJ の記録の項1〜8 は書き換えない★(項8 の「本書と違う判断: なし」も消さず、下の項で補う)。 + +```markdown +- ★指示書BJ の点検の直し(2026-11-05。指示書BK)★: + 1. ★制御盤のファイル ID の上限を 1024 → 8192 にした★(ユーザー回答 2026-11-05「A」)。制御盤の常駐プログラムが受け取れる上限(`agent.py` の `FILE_ID_MAX_LEN`。作る側には上限が無い。8192 を超えるには相対パスが約6.1KB 要るので、Linux のパスの上限(4096)からまず起きない — 推測)とそろえる。1024 のままだと、長いパス(約766バイト超)のファイルが1つあるだけで一覧全体が「制御盤の結果の形が不正です」になる + - 却下: B(1024 のまま、制御盤側で長いものを一覧から外す)— 別リポジトリの改修が要り、外したファイルは画面から取り出せない + - 帰結: エクスポートの本体は最大 500件 × 8192文字 ≒ 4MB(proxy の 10MB より小さい)。本番の nginx の `client_max_body_size` は TeamWorks の設定で、ブラウザの POST(`/api/ds-devices/…/exports`)は既定(1MB)のままと思われる(第13回で 5MB を頼んだのは `/api/ds-agent/` 配下だけ)ので、長い ID を大量に選んだときだけ 413 になりうる + - ★先に効く上限★: 制御盤は poll の応答を 64KB までしか読まない(`agent.py` の `RESPONSE_READ_MAX_BYTES`)。エクスポートの指示に入る `file_ids` の合計が約64KB を超えると指示が壊れ、ブラウザは 50秒後に 504。1024 のときからある問題(1024文字なら約64件・8192文字なら約8件で超える)。今の ID(30〜40文字)なら500件でも約20KB。★項6 で手当てした★ + - BJ の記録の項3 の「`file_id` は1〜1024文字」を本項で改める + 2. complete の `total_bytes: 0` を 400 にすること(BJ §2-3 の YAML・本記録の BJ の項3 のとおり)にテストが無かったので足した。★BJ §2-3 の括弧書き(「0 は来ても必ず失敗する」)が想定した『照合の不一致でエクスポートを即失敗』ではなく、『400・状態は変えない』(エクスポートは待ちの時間切れまで残る)になっている★。正しい流れでは来ないので、このままとする + 3. テストの見張りを効かせた: `key` の不正(R45〜R46b)は items の種類も同じ key にしてメッセージまで見る/「ディスクに書かない」は静的な検査でモジュール名の文字列と `require(` を禁じる(★指示書BJ の G3 に監督が書いた壊し方 `await import("node:fs")` はスパイでも旧い静的な検査でも捕まらない壊し方だった★)/E6 は「100 MB」を固定/R43′ の 404 は `not_found` まで見る/ブラウザの 502(`result_invalid`)をルートを通して見る + 4. ★実装の記録★: コミット/テスト件数(前 170ファイル・4,306件 → 後)/G3 の赤の確認(K1・K2×3・K3・K4・K5・K6・K7×3)/本書と違う判断 + 5. ★指示書BJ の画面確認の補い(監督。2026-11-05。当方の fixtures 由来)★: BJ の記録の項8 に無かった次を監督が確かめた — J10 の「種類を戻すと `types` なしで1回」・Esc で閉じて「ログ」ボタンにフォーカスが戻る・D-Sec ログの表に横スクロールが出ない(1440×900・JP)・エクスポートの `fetch` 確認(201 → 200・SHA-256 一致・2回目 404・`X-Accel-Buffering: no`・`Content-Encoding` なし)。★未確認★: ダーク/ライト・トークンを表示したときのコピーのボタンの折り返し・DS-001 の未接続・Tab の閉じ込め・背景のクリック・100MB の警告(いずれも BJ で触っていない部分か、BJ 前の AY の点検で確認済み) + 6. ★エクスポートで選ぶ `file_ids` の合計に上限を足した★(ユーザー回答 2026-11-05「A」): `file_ids` を JSON にした大きさ(UTF-8)が `EXPORT_FILE_IDS_MAX_BYTES`(48KB)を超えたら 422「選んだファイルが多すぎます。数を減らしてください」。制御盤は D-Watch の応答を 64KB までしか読まず(`agent.py` の `RESPONSE_READ_MAX_BYTES`)、エクスポートの指示は poll の応答に `file_ids` を全部入れて届くため。超えると指示が途中で切れ、ブラウザは 50秒待って原因の分からない 504 になっていた(1024 のときからあった) + - 却下: B(制御盤の読み取りの上限を上げる)— 別リポジトリの改修と本番機への反映が要る。C(何もしない)— 今の ID(30〜40文字)では起きないが、起きたときに原因が画面から分からない + - 却下: 画面で選択中に先回りしてボタンを止める — 今の ID では500件でも約21,500バイトで効かないので、サーバーの 422 と文言だけにした + - 帰結: 8192文字の ID なら1回5件まで。平均95文字以下の ID なら先に500件の上限が効く。★制御盤の上限(64KB)を変えるときは、この値も見直す★ +``` + +## 5. 既知の罠 + +1. ★K1 で items の `type` を変え忘れると、テストは緑のまま★(今と同じ)。メッセージまで見るので、変え忘れたら `itemsの形式が不正です` で赤になる — それで気づける +2. ★K2 の新しい正規表現は、コメントの中の `"node:fs"`(引用符つき)にも当たる★。対象のソースのコメントにモジュール名を書くならバッククォートで +3. ★K2 の G3 で `require("fs")` を試すとき、ESM の実行で `require is not defined` になっても、静的な検査はその前にファイルの文字列で赤になる★。実行時の失敗と取り違えない(静的な検査のテスト名が赤であることを見る) +4. ★K6 で `1024` を機械的に全部置き換えない★ — `4 * 1024 * 1024`(4MB)・`100 * 1024 * 1024`(100MB)・`formatFileSize` の `1024` は大きさの計算 +5. ★K6 で `agent.py` を直さない★(別リポジトリ。読むだけ) +6. ★K5 の e2e で偽の時計を進めて 504 を作らない★。形の崩れた結果を送った時点でブラウザの応答が 502 で返ることを見る(R43′ と同じ順序。待ちは `waitUntilConnected()` の作法) +7. ★K7 は文字数でなくバイト数で、しかも JSON にした形で数える★。`fileIds.join("").length` や各 ID の `length` の和では、引用符・カンマ(1件3バイト)と多バイト文字(`あ` は3バイト)を数え落とし、上限ちょうどの本体で 64KB を超えうる(K7-3 が見張る) +8. ★K7 の 422 は今の3つの検査の後に置く★。先に置くと、501件や重複の本体が「多すぎます」の文言になり、V7 の既存の行の期待(`file_idsの形式が不正です`)と食い違う +9. ★K7 で `agent.py` の `RESPONSE_READ_MAX_BYTES` を変えない★(別リポジトリ。読むだけ)。K7-5 のテストの 64KB は、テストの中の定数として `agent.py:62` を引いて書く + +## 6. 共通ルール + +- G1〜G4(指示書AZ §2): YAML の影響を受ける箇所を全部直す(K6 の `1024` は `grep` で全部)/古いコメントを残さない(`validate.ts:38` の `FILE_ID_MAX_LENGTH` の説明、`end-to-end.test.ts:389` のテスト名)/★足した・直したテストは壊して赤を確かめ、その出力を報告に貼る★/記録にコミット・件数の前後・本書と違う判断を書く +- `git add -A` 禁止。ファイルパスを明示して段階ごとにコミット。PR とマージは daichi のレビューの後 +- 触らない: `components/`(今回は画面を変えない)・`docs/handoff/指示書AY_*`・`docs/handoff/指示書BJ_*`・`docs/handoff/設計_制御盤ログの取り出し_*`・`components/features/errors/ErrorContentPicker.tsx`・`lib/errors/error-content-label.ts` とそのテスト・`prisma/`・`CLAUDE.md`・`C:\Users\daich\dev\dwatch-log-agent\`(読むだけ) +- DB に書き込まない。:3000 を起動・停止しない。:3100 は監督が fixtures で動かしていることがある — ★今回は画面の確認が無いので起動しない★ diff --git "a/docs/handoff/\350\250\255\350\250\210_\345\210\266\345\276\241\347\233\244\343\203\255\343\202\260\343\201\256\345\217\226\343\202\212\345\207\272\343\201\227_20261019.md" "b/docs/handoff/\350\250\255\350\250\210_\345\210\266\345\276\241\347\233\244\343\203\255\343\202\260\343\201\256\345\217\226\343\202\212\345\207\272\343\201\227_20261019.md" index f247cc7..075c2e0 100644 --- "a/docs/handoff/\350\250\255\350\250\210_\345\210\266\345\276\241\347\233\244\343\203\255\343\202\260\343\201\256\345\217\226\343\202\212\345\207\272\343\201\227_20261019.md" +++ "b/docs/handoff/\350\250\255\350\250\210_\345\210\266\345\276\241\347\233\244\343\203\255\343\202\260\343\201\256\345\217\226\343\202\212\345\207\272\343\201\227_20261019.md" @@ -588,7 +588,7 @@ | code | どこで | 意味 | |---|---|---| | `file_not_allowed` | export の `fail` | `file_id` が壊れている・root の外・通常のファイルでない・シンボリックリンク・patterns に合わない(§5.6) | -| `file_not_found` | export の `fail` | 一覧のあとでファイルが消えた。★`os.lstat` が `FileNotFoundError` のときだけ★。それ以外の検査落ちは `file_not_allowed`(M1・監督判断 o) | +| `file_not_found` | export の `fail` | 一覧のあとでファイルが消えた。★`os.lstat` が `FileNotFoundError` のときだけ★。それ以外の検査落ちは `file_not_allowed`(M1・監督判断 o)。★検査の後、zip を作る途中でファイルを開こうとして無かった(`FileNotFoundError`)ときも `file_not_found`★(2026-10-20 監督判断。それ以外の読み取りの失敗は `io_error`) | | `too_large` | export の `fail` | 元のファイルの合計、または ★作った zip の大きさ★が `max_bytes` を超える(M1) | | `io_error` | list の `result`・export / send の `fail` | 読み取り・zip 作成・空き容量不足など(M1) | | `export_not_found` | send の `fail`(URL は export の id) | `export_command_id` の zip が無い(120秒を過ぎて消した後・作っていない)(監督判断 n) | @@ -864,7 +864,7 @@ for mtime, i, rel, t, st in found: item = { "file_id": b64url(f"{i}:{rel}"), - "name": rel, + "name": f"{root.name}/{rel}", # zip の中のパスと同じ(2026-10-20 監督判断) "type": t.key, "size_bytes": st.st_size, "modified_at": iso8601_with_tz(mtime), # 例 2026-10-19T11:58:03+09:00 @@ -881,7 +881,7 @@ | 対象 | ★通常のファイルだけ★(`os.lstat` で判定。★シンボリックリンクは飛ばす★)。フォルダを歩くときも ★シンボリックリンクのフォルダには入らない★(`os.walk(followlinks=False)` 相当) | | 種類 | `patterns` に合うものだけ。★どの種類にも合わないファイルは返さない★ | | `file_id` | ★「roots の番号:root からの相対パス」を base64url にしたもの★(UTF-8。例: `0:app/app-20261019.log` → `MDphcHAvYXBwLTIwMjYxMDE5LmxvZw`。パディングの `=` は付けない(補い))。★D-Watch は中身を見ない★ | -| `name` | root からの相対パス(★§5.2 の例は `recursive: false` なのでファイル名だけ★。どのフォルダのものかは `type` で分かる) | +| `name` | ★`{root の name}/{root からの相対パス}`★(例 `postcondition_security/postcondition_security.log.2026-10-18`)。★zip の中のパスと同じ形★にして、画面の一覧と落としたファイルの名前を揃える(2026-10-20 監督判断。常駐プログラムの実装と合わせた。旧: root からの相対パスだけ)。★D-Watch は中身を解析しない★(表示と name 昇順の並べ替えにだけ使う) | | `modified_at` | ★タイムゾーン付き ISO 8601★。ラズパイの時計(★Asia/Tokyo の前提★)。秒まで | | `types` | 設定ファイルの全種類を設定の順に返す(0件の種類も返す。補い)。★D-Watch の「種類+日付」の並びはこの順★ | | 上限 | ★更新日時の新しい順に並べてから、2万件または JSON 4MB のどちらかに達したら打ち切り、`truncated: true`★(M3) | diff --git a/docs/logging-design.md b/docs/logging-design.md index 97ced99..b02e834 100644 --- a/docs/logging-design.md +++ b/docs/logging-design.md @@ -114,6 +114,8 @@ | `inspection.*` | 点検記録ストアの操作 | | `data.anomaly` | 期待値と実測の乖離を見つけたが原因は不明、という記録。★ルート層から出すときは `route` / `method` を埋める★。データ層から出すとき(清掃詳細ログの事前チェックが3つ以上 `precheck_rounds_per_main_run`。D-178)は持たない(型は D-178 で任意にした) | | `settings.store_corrupt` | 独自ストア(JSON)のファイル破損を検知した時 | +| `ds_agent.connected` / `ds_agent.disconnected` / `ds_agent.auth_failed` / `ds_agent.token_issued` / `ds_agent.token_revoked` | 制御盤の接続変化(relay の `onEvent`)・Bearer 認証失敗(proxy)・トークンの発行/失効(`agent-token` ルート)。★`serial_number` や `route` / `method` / `reason` だけ。ファイル名・トークンは載せない★(指示書AY 段階10。D-179)。★`ds_agent.connected` の `agent_version` は認証の時点で `X-Agent-Version` から入る(指示書BJ J9。`requireAgent()` → `touch(serial, version)`)★ | +| `ds_logs.list` / `ds_logs.export_prepared` / `ds_logs.export_started` / `ds_logs.export_completed` / `ds_logs.export_failed` | 制御盤ログの一覧・エクスポート準備/開始/完了/失敗。★件数・バイト数・固定の理由だけ。`file_name` / `file_ids` / `token` / `authorization` は載せない★(指示書AY 段階10。D-179)。★`ds_logs.list` の `agent_code` に `result_invalid` が出る(制御盤から形の崩れた一覧。指示書BJ J2。ユーザー回答 A)★ | ### 共通フィールド(全イベント必須) @@ -490,6 +492,17 @@ app/api/ds-devices/route.ts app/api/ds-alerts/route.ts app/api/prechecks/route.ts +# 制御盤のログ(指示書AY。ブラウザ向け4 + 制御盤向け6) +app/api/ds-devices/[serial_number]/logs/route.ts +app/api/ds-devices/[serial_number]/logs/exports/route.ts +app/api/ds-devices/[serial_number]/logs/exports/[export_id]/route.ts +app/api/ds-devices/[serial_number]/agent-token/route.ts +app/api/ds-agent/commands/route.ts +app/api/ds-agent/commands/[command_id]/result/route.ts +app/api/ds-agent/commands/[command_id]/prepared/route.ts +app/api/ds-agent/commands/[command_id]/chunks/[seq]/route.ts +app/api/ds-agent/commands/[command_id]/complete/route.ts +app/api/ds-agent/commands/[command_id]/fail/route.ts ``` ### 関数コメント(全社ルール7) diff --git a/docs/spec.md b/docs/spec.md index 20b27ec..599c2d3 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -77,7 +77,7 @@ #### F-03 補足 — エラー一覧(`/errors`)★2026-10-06 追加。番号は振らない★ - ★`robot_error_reports` の全行★(ロボット・制御盤・セキュリティ・清掃開始失敗。事前チェックかどうかを問わない)を一覧する。★削除済み(`deleted_at` が NULL でない)の施設・清掃エリアの行は出さず(件数にも数えない)、その行への `PATCH /errors/{error_id}/status` は404(U8。D-180 項11)★。既定は未解消・対応中、「解消済みも表示」で解消も(D-160) -- カラム: 検知時刻 / 施設名 / シリアル / 種類 / レベル / 紐づく予定 / 対応状況 / 備考。★種類・レベル・シリアルは DB の値のまま★(区分を作らない。D-077 / D-160 項3)。★列は足さない★。種類の2行目にエラー内容(未選択 / 詳細 / その他は大分類名 / 選べない行は `-`)。編集モードではボタンから窓(キーワード・分類・件数・クリア)で `error_list_id` を選ぶ。その他(`note_required`)は備考必須。分類名・詳細は `t()` に通さない(D-185 / 指示書BE) +- カラム: 検知時刻 / 施設名 / シリアル / 種類 / レベル / 紐づく予定 / 対応状況 / ★詳細★ / 備考。★種類・レベル・シリアルは DB の値のまま★(区分を作らない。D-077 / D-160 項3)。★エラー内容は「詳細」の列に出す★(未選択 / 詳細 / その他は大分類名 / 選べない行は `-`。2026-10-27 のユーザー指示で、指示書BE の「種類の2行目」から列に変えた。BE §12・D-185)。編集モードではボタンから窓(キーワード・分類・件数・クリア)で `error_list_id` を選ぶ。その他(`note_required`)は備考必須。分類名・詳細は `t()` に通さない(D-185 / 指示書BE) - 対応状況と備考は F-03 と同じ規則(プルダウン・編集モードと保存。`responded_at` / `resolved_at` を記録。D-161 / D-165)。★エラー一覧の PATCH だけ `error_list_id` も書く★(アラートと D-Sec は4カラムのまま。D-184) - フィルタ: 日付範囲(検知時刻)、グループ、会社、施設名、種類(部分一致) - ★モックに無い画面★。F-03 のアラート画面は事前チェック+出発失敗のまま diff --git a/lib/config/ds-agent.ts b/lib/config/ds-agent.ts new file mode 100644 index 0000000..953c276 --- /dev/null +++ b/lib/config/ds-agent.ts @@ -0,0 +1,110 @@ +/** + * 制御盤(ラズパイ)のログの中継の設定値(指示書AY 段階3-1。D-179・監督判断 第2ラウンド M1〜M5・D9)。 + * + * ★`lib/config/index.ts` に足さないこと・`index.ts` を import しないこと★ + * `index.ts` は `lib/judge/` から広く import されており、ここを `proxy` から読むと + * proxy のバンドルに judge 系を引き込む(`lib/config/allowed-ips.ts` の冒頭と同じ理由)。 + * ★`node:` の import を持たないこと★ — 画面(`components/`)からも import する。 + * ★ハードコード禁止(CLAUDE.md §7)★ — 中継・検証・画面はここから読む。 + * + * ★リクエストを待たせる値(15 / 25 / 30 / 45 / 50秒)は、どれも nginx の `proxy_read_timeout`(既定60秒)より + * 短くすること★。60秒以上にすると nginx が先に接続を切り、ブラウザ・制御盤には 504 しか見えない(4-10)。 + */ + +/** + * ポーリング(`GET /api/ds-agent/commands?wait=`)の待ちの上限(秒)。`wait` の最大かつ既定。 + * ★nginx の `proxy_read_timeout`(既定60秒)より短くすること★。出所: D-179・D2。 + */ +export const AGENT_POLL_WAIT_SEC = 25; + +/** + * 最後のリクエストからこれを★超えたら★未接続(秒)。ちょうどは接続中(表5 R4)。 + * 認証の通った制御盤のすべてのリクエストで最終接続日時を更新する(D19。M4)。 + * ポーリングの待ち(25秒)の2回分より長くし、ポーリングの切れ目で未接続にならないようにする。 + */ +export const AGENT_OFFLINE_AFTER_SEC = 60; + +/** + * ブラウザの一覧の依頼から、制御盤の結果を待つ上限(秒)。 + * ★nginx の `proxy_read_timeout`(既定60秒)より短くすること★。出所: D-179。 + */ +export const LIST_TIMEOUT_SEC = 15; + +/** + * 制御盤が zip を作り終えて prepared を送るまで、エクスポートの準備の POST が待つ上限(秒。D14。M1)。 + * ★nginx の `proxy_read_timeout`(既定60秒)より短くすること★。 + */ +export const EXPORT_PREPARE_TIMEOUT_SEC = 50; + +/** + * 準備の 201 から、ダウンロード URL を開く(GET)までの上限(秒)。応答の `expires_in_sec`。 + * 過ぎたらエクスポートを失効させて進行中から外す(D25 ⑤)。制御盤は120秒で zip を消すので、それより短い。 + */ +export const EXPORT_CLAIM_TIMEOUT_SEC = 60; + +/** + * ダウンロード URL を開いてから最初のチャンクが届くまでの上限(秒。D14)。それまでは JSON のエラーで返せる。 + * ★nginx の `proxy_read_timeout`(既定60秒)より短くすること★。 + */ +export const EXPORT_FIRST_CHUNK_TIMEOUT_SEC = 30; + +/** + * 流し始めた後のチャンクの間隔の上限、かつチャンクの POST に 204 を返すまで保留する上限(秒。流量制御。D20・M5)。 + * 超えたら 410 `export_cancelled` にしてエクスポートを失敗にする。制御盤のチャンクの POST のタイムアウトは60秒(設計書)。 + * ★nginx の `proxy_read_timeout`(既定60秒)より短くすること★。 + */ +export const EXPORT_IDLE_TIMEOUT_SEC = 45; + +/** + * 1チャンクの上限(バイト。D1)。 + * ★proxy は本体を10MBまでメモリにバッファし、超えた分を黙って切り捨てる★ので、それより十分小さくする + * (`proxyClientMaxBodySize` を上げて解決しない。4-1)。 + */ +export const CHUNK_MAX_BYTES = 4 * 1024 * 1024; + +/** 1回の zip の上限(バイト。★仮★。D-179 追記 項5)。上限の判定は zip のバイト数に一本化する(D15) */ +export const EXPORT_MAX_BYTES = 100 * 1024 * 1024; + +/** 1回に選べるファイル数の上限(D-179。制御盤側の上限と同じ値) */ +export const EXPORT_MAX_FILES = 500; + +/** + * 1回のエクスポートで選ぶ `file_ids` を JSON にした大きさ(UTF-8 のバイト数)の上限(指示書BK K7)。 + * ★制御盤は D-Watch の応答を 64KB までしか読まない★(`agent.py` の `RESPONSE_READ_MAX_BYTES`)。 + * エクスポートの指示は poll の応答に `file_ids` を全部入れて届くので、外側の項目(約140バイト)の分を残して 48KB にする。 + * ★64KB に近づけない★(超えると指示が途中で切れ、ブラウザは 50秒待って 504 になる) + */ +export const EXPORT_FILE_IDS_MAX_BYTES = 48 * 1024; + +/** 制御盤ごとの同時エクスポートの上限(準備中〜流れ中を進行中に数える。D25 ⑤) */ +export const EXPORT_CONCURRENCY_PER_DEVICE = 1; + +/** 全体の同時エクスポートの上限(チャンクはメモリで中継するので、メモリの使い方を抑える。D-179) */ +export const EXPORT_CONCURRENCY_TOTAL = 3; + +/** 制御盤から来る一覧の結果の本体の上限(バイト。D18・M3)。制御盤はここで打ち切り `truncated: true` にする */ +export const LIST_RESULT_MAX_BYTES = 4 * 1024 * 1024; + +/** 一覧の件数の上限(D18・M3)。制御盤は更新日時の新しい順に並べてから、ここで打ち切り `truncated: true` にする */ +export const LIST_RESULT_MAX_ITEMS = 20000; + +/** 制御盤ごとの指示の待ち行列の上限。満杯なら 429 `agent_busy`(D23) */ +export const PENDING_COMMANDS_PER_DEVICE = 5; + +/** + * ★画面の★ページあたり件数の選択肢(M7)。上限は `PAGINATION.maxPerPage`(`lib/config/index.ts`)と同じ 200。 + * ★API の検証には使わない★(API は `per_page` を 1〜200 に丸める)。 + */ +export const DS_LOG_PER_PAGE_OPTIONS: readonly number[] = [50, 100, 200]; + +/** ページあたり件数の既定(`per_page` が省略・空・数でないときの値。M7) */ +export const DS_LOG_DEFAULT_PER_PAGE = 200; + +/** + * パスの `export_id` の長さの上限(M8)。`crypto.randomUUID()` の36文字を受けられる長さ。 + * ★文字種は絞らない★(`parsePathReportId()` と同じ作法。見つからなければ 404)。 + */ +export const PATH_EXPORT_ID_MAX_LENGTH = 64; + +/** パスの `command_id` の長さの上限(M8)。`PATH_EXPORT_ID_MAX_LENGTH` と同じ考え方 */ +export const PATH_COMMAND_ID_MAX_LENGTH = 64; diff --git a/lib/ds-agent/__tests__/fake-relay.ts b/lib/ds-agent/__tests__/fake-relay.ts new file mode 100644 index 0000000..0006324 --- /dev/null +++ b/lib/ds-agent/__tests__/fake-relay.ts @@ -0,0 +1,51 @@ +/** + * ルートのテスト用: 偽の時計で動く relay(指示書AY 段階7-4。★本物のタイマーで待たない★)。 + * `at(t)` で BASE から t 秒まで時計を進め、その間に期限の来たタイマーを順に発火させる。 + */ +import { createRelay, DEFAULT_RELAY_LIMITS, type RelayEvent, type RelayLimits } from "@/lib/ds-agent/relay"; + +export const FAKE_BASE = Date.parse("2026-10-19T12:00:00+09:00"); + +export async function flush(): Promise { + for (let i = 0; i < 20; i++) await new Promise((resolve) => setImmediate(resolve)); +} + +export function createFakeRelay(overrides: Partial = {}) { + let nowMs = FAKE_BASE; + let nextHandle = 1; + const timers = new Map void }>(); + const events: RelayEvent[] = []; + const limits: RelayLimits = { ...DEFAULT_RELAY_LIMITS, ...overrides }; + const relay = createRelay({ + now: () => nowMs, + setTimer: (fn, ms) => { + const handle = nextHandle++; + timers.set(handle, { at: nowMs + ms, fn }); + return handle; + }, + clearTimer: (handle) => { + timers.delete(handle as number); + }, + limits, + onEvent: (e) => events.push(e), + }); + + async function at(t: number): Promise { + const target = FAKE_BASE + Math.round(t * 1000); + for (;;) { + let next: [number, { at: number; fn: () => void }] | undefined; + for (const entry of timers) { + if (entry[1].at <= target && (!next || entry[1].at < next[1].at)) next = entry; + } + if (!next) break; + timers.delete(next[0]); + nowMs = Math.max(nowMs, next[1].at); + next[1].fn(); + await flush(); + } + nowMs = target; + await flush(); + } + + return { relay, limits, events, at }; +} diff --git a/lib/ds-agent/__tests__/relay.test.ts b/lib/ds-agent/__tests__/relay.test.ts new file mode 100644 index 0000000..daa4a4a --- /dev/null +++ b/lib/ds-agent/__tests__/relay.test.ts @@ -0,0 +1,1049 @@ +/** + * 指示書AY §3 表5 R1〜R43′〜R51(`lib/ds-agent/relay.ts`。★時刻とタイマーを注入★。t は秒)。 + * ブリーフ §5 と監督判断 第2ラウンドから本書で具体化。★P1・P2 の行は §11 Q14・Q15 の採用で確定★。 + * ★R43′ は指示書BJ(一覧の形不正はブラウザを 502)。prepared・complete・fail の形の誤りは状態を変えない(R43)。★R44 はルートで判定するので段階7-4 のルートのテスト★。 + * + * エクスポートの状態: 準備中(POST が待っている)→ 準備完了(201。受け取り待ち)→ 送信待ち(GET。send を入れた)→ 流れ中 + * → 完了 / 失敗 / 取り消し / 失効。★準備中〜流れ中を「進行中」と数える★。 + * + * | # | 操作 | 期待(relay の結果 → ルートの HTTP) | + * | R1 | t=0 poll(DS-002, wait=25)。t=1 requestList | poll が即座に { command_id, kind: "list", params: {} }。t=2 submitListResult → 一覧 | + * | R2 | R1 で結果が来ない | t=16(依頼から15秒)で timeout(504)。t=15.999 ではまだ待っている | + * | R3 | R2 の後 t=17 に submitListResult | gone(410 command_expired) | + * | R4 | poll が t=0〜t=25(指示なしで 204)。以後なし | t=85 は接続中(ちょうど60秒)、t=85.001 は未接続(409)。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(touch) | last_seen_at は t=80。t=140 は接続中、t=140.001 は未接続 | + * | R8 | wait が 0 / 26 / abc | 422 validation_failed | + * | R9 | 同じ制御盤の2本目の poll | 1本目が即座に null(204)、2本目が待つ | + * | R10 | t=0 createExport(DS-002, [f1, f2]) | poll に { kind: "export", params: { file_ids, max_bytes, chunk_bytes } }。t=10 prepared → createExport が prepared(201) | + * | R11 | prepared が来ない | t=50 はまだ待っている、t=50.001 で timeout(504)。その後の prepared は gone(410) | + * | R12 | 準備中に fail { code: too_large ほか } | createExport が agent_error(502。agent_code は制御盤の code) | + * | R13 | prepared の total_bytes が ちょうど EXPORT_MAX_BYTES / +1 | 201 / 502 agent_error(too_large)。後者は失敗にする(prepared の応答は 204 のまま) | + * | R14 | prepared から t=+60 に claim / t=+60.001 に claim | 受理 / not_found(404。失効) | + * | R15 | 同じ export_id で2回目の claim | not_found | + * | R16 | パスのシリアルが違う claim | not_found | + * | R17 | claim → send → seq 0〜2 → complete | claim が seq 0 の到着でストリーム(200・Content-Length)。読んだバイト列が一致。閉じる | + * | R18 | claim から t=+30 に seq 0 / 来ないまま t=+30.001 | 受理 / timeout(504)。その後のチャンクは 410 | + * | R19 | send の後・最初のチャンクの前に fail(export_not_found を含む) | claim が agent_error(502) | + * | R20 | prepared の後・send の前にチャンク | 409 chunk_out_of_order | + * | R21 | seq 0 の後に seq 2 | 409。ストリームを error | + * | R22 | ハッシュ不一致(流し始めた後) | 400 chunk_invalid。ストリームを error | + * | R23 | 最初のチャンクのハッシュ不一致 | チャンクは 400。claim は agent_error(chunk_invalid) | + * | R24 | チャンクの長さ ちょうど CHUNK_MAX_BYTES / +1 / 0 | 受理 / 400 / 400 | + * | R25 | 受け取った合計が prepared の total_bytes を超えた | 400。ストリームを error | + * | R26 | complete の total_bytes が違う/受け取った合計が足りない | 400。ストリームを error | + * | R27 | complete の sha256 が prepared と違う/受け取ったバイト列の SHA-256 が prepared と違う | 400。ストリームを error | + * | R28 | 流し始めた後に fail(URL は export の指示の command_id) | ストリームを error。fail は 204 | + * | R29 | 流し始めた後にブラウザが切断(cancel / abort) | 次のチャンクは cancelled(410) | + * | R30 | seq 0 を受けたがブラウザが読んでいない | pushChunk は保留。reader.read() で解決。relay が持つチャンクは常に2つ以下 | + * | R31 | R30 の保留が t=+45 / t=+45.001 まで続く | まだ保留 / 410 export_cancelled・失敗(ストリームを error) | + * | R32 | 流し始めた後、次のチャンクが来ないまま t=+45 / t=+45.001 | 流れたまま / ストリームを error。その後のチャンクは 410 | + * | R33 | 進行中(準備中・受け取り待ち・送信待ち・流れ中)に2本目の createExport | busy(429 export_busy)。完了・失敗・失効の後は受理 | + * | R34 | 3台で進行中に4台目 | busy(429) | + * | R35 | DS-003 で DS-002 の command_id に result・prepared・チャンク | not_found(404)。403 にしない | + * | R36 | list の command_id に prepared | not_found | + * | R37 | 待ち行列に PENDING_COMMANDS_PER_DEVICE 件 | busy(429 agent_busy) | + * | R38 | 一覧の結果に truncated: true | そのままブラウザへ | + * | R39 | 状態の変化 | connected / disconnected を変化のときだけ1回 | + * | R40 | t=0 prepared。GET が来ない → t=60 / t=60.001 | 進行中のまま(429)/ 失効: 進行中から外れ受理。claim は 404。その後の fail / チャンクは gone(410) | + * | R41 | エクスポートの流れ中(または準備中)に requestList | 待ち行列に入れ、poll に list が渡る。一覧が返る | + * | R42 | send の指示の command_id にチャンク・complete | not_found | + * | R43 | prepared / complete / fail の本体の形の誤り | 400 result_invalid。状態は変えない(正しい結果を受け取れる) | + * | R43′ | 一覧の形の誤り → rejectListResult | ブラウザは agent_error(result_invalid)。その後の submit は not_found | + * | R49 | 保留中に次のチャンク | out_of_order。先の POST は cancelled。flow_timeout は出ない | + * | R50 | touch(serial, "1.2.3") で未接続から接続 | connected の agent_version が "1.2.3" | + * | R51 | touch(serial, null) | agent_version は null | + * + * ★書き方(§3 のテストの書き方の注意)★: 本物のタイマーで待たない(偽の時計の `at(t)` で進める)。 + * 4MB・9MB は `limits.chunkMaxBytes` を 16 バイトに差し替えて同じ規則を確かめる。境界(ちょうど・+1)は差し替えた値で書く。 + * 期待値は設定値から作る。★わざと違う値(R21〜R23・R27)だけは直書きし、理由をコメントに書く★。 + */ +import { createHash } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { + AGENT_OFFLINE_AFTER_SEC, + CHUNK_MAX_BYTES, + EXPORT_CLAIM_TIMEOUT_SEC, + EXPORT_CONCURRENCY_PER_DEVICE, + EXPORT_CONCURRENCY_TOTAL, + EXPORT_FIRST_CHUNK_TIMEOUT_SEC, + EXPORT_IDLE_TIMEOUT_SEC, + EXPORT_MAX_BYTES, + EXPORT_PREPARE_TIMEOUT_SEC, + LIST_TIMEOUT_SEC, + PENDING_COMMANDS_PER_DEVICE, +} from "@/lib/config/ds-agent"; +import { createRelay, type RelayEvent, type RelayLimits } from "@/lib/ds-agent/relay"; +import { + parseAgentComplete, + parseAgentFail, + parseAgentListResult, + parseAgentPollWait, + parseAgentPrepared, +} from "@/lib/ds-logs/validate"; +import { toJstIso } from "@/lib/format/date"; +import type { DsAgentListResult } from "@/lib/types"; + +// ── 偽の時計 ── + +const BASE = Date.parse("2026-10-19T12:00:00+09:00"); + +const DEFAULT_LIMITS: RelayLimits = { + offlineAfterSec: AGENT_OFFLINE_AFTER_SEC, + listTimeoutSec: LIST_TIMEOUT_SEC, + exportPrepareTimeoutSec: EXPORT_PREPARE_TIMEOUT_SEC, + exportClaimTimeoutSec: EXPORT_CLAIM_TIMEOUT_SEC, + exportFirstChunkTimeoutSec: EXPORT_FIRST_CHUNK_TIMEOUT_SEC, + exportIdleTimeoutSec: EXPORT_IDLE_TIMEOUT_SEC, + chunkMaxBytes: CHUNK_MAX_BYTES, + exportMaxBytes: EXPORT_MAX_BYTES, + exportConcurrencyPerDevice: EXPORT_CONCURRENCY_PER_DEVICE, + exportConcurrencyTotal: EXPORT_CONCURRENCY_TOTAL, + pendingCommandsPerDevice: PENDING_COMMANDS_PER_DEVICE, +}; + +/** 送信の試験では 4MB の代わりに 16 バイトで同じ規則を確かめる(§3 のテストの書き方の注意) */ +const SMALL_CHUNK = 16; + +async function flush(): Promise { + for (let i = 0; i < 20; i++) await new Promise((resolve) => setImmediate(resolve)); +} + +function createHarness(overrides: Partial = {}) { + let nowMs = BASE; + let nextHandle = 1; + const timers = new Map void }>(); + const events: RelayEvent[] = []; + const limits: RelayLimits = { ...DEFAULT_LIMITS, ...overrides }; + const relay = createRelay({ + now: () => nowMs, + setTimer: (fn, ms) => { + const handle = nextHandle++; + timers.set(handle, { at: nowMs + ms, fn }); + return handle; + }, + clearTimer: (handle) => { + timers.delete(handle as number); + }, + limits, + onEvent: (e) => events.push(e), + }); + + /** 時計を t 秒(BASE からの経過)まで進め、その間に期限の来たタイマーを順に発火させる */ + async function at(t: number): Promise { + const target = BASE + Math.round(t * 1000); + for (;;) { + let next: [number, { at: number; fn: () => void }] | undefined; + for (const entry of timers) { + if (entry[1].at <= target && (!next || entry[1].at < next[1].at)) next = entry; + } + if (!next) break; + timers.delete(next[0]); + nowMs = Math.max(nowMs, next[1].at); + next[1].fn(); + await flush(); + } + nowMs = target; + await flush(); + } + + return { relay, limits, events, at }; +} + +type Harness = ReturnType; + +function track(promise: Promise) { + const state: { settled: boolean; value?: T; rejected: boolean } = { settled: false, rejected: false }; + promise.then( + (value) => { + state.settled = true; + state.value = value; + }, + () => { + state.settled = true; + state.rejected = true; + } + ); + return state; +} + +const signal = () => new AbortController().signal; +const jstAt = (t: number) => toJstIso(new Date(BASE + t * 1000)); +const sha = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex"); +const filled = (length: number, value: number) => new Uint8Array(length).fill(value); + +function concat(chunks: readonly Uint8Array[]): Uint8Array { + const out = new Uint8Array(chunks.reduce((n, c) => n + c.length, 0)); + let offset = 0; + for (const c of chunks) { + out.set(c, offset); + offset += c.length; + } + return out; +} + +async function expectErrored(reader: ReadableStreamDefaultReader): Promise { + const outcome = await reader.read().then( + () => "resolved", + () => "rejected" + ); + expect(outcome).toBe("rejected"); +} + +const LIST_RESULT = { + types: [{ key: "app", label: "アプリ" }], + items: [ + { file_id: "f1", name: "app_20261019_1100.log", type: "app", size_bytes: 1000, modified_at: "2026-10-19T11:00:00+09:00" }, + ], + truncated: false, +} satisfies DsAgentListResult; + +// ── 段取り ── + +/** poll を待たせた状態で createExport し、export の指示を受けて prepared を返す */ +async function prepareExport( + h: Harness, + prepared: { total_bytes: number; sha256: string; file_count: number }, + serial = "DS-002", + fileIds: string[] = ["f1", "f2"] +) { + const poll = h.relay.poll(serial, "1.0.0", 25, signal()); + const created = h.relay.createExport(serial, fileIds, signal()); + const command = await poll; + if (!command || command.kind !== "export") throw new Error("export の指示が来ない"); + expect(h.relay.submitPrepared(serial, command.command_id, prepared)).toBe("ok"); + const result = await created; + if (result.kind !== "prepared") throw new Error(`prepared にならない: ${result.kind}`); + return { exportCommandId: command.command_id, exportId: result.exportId, result }; +} + +/** claim して send の指示を受け取る */ +async function startSend(h: Harness, exportId: string, claimSignal: AbortSignal = signal(), serial = "DS-002") { + const poll = h.relay.poll(serial, "1.0.0", 25, signal()); + const claim = h.relay.claimExport(serial, exportId, claimSignal); + const sendCommand = await poll; + if (!sendCommand || sendCommand.kind !== "send") throw new Error("send の指示が来ない"); + return { claim, sendCommand }; +} + +/** 3チャンク(16・16・4 バイト)= 4MB・4MB・1MB の縮小版 */ +const CHUNKS = [filled(SMALL_CHUNK, 1), filled(SMALL_CHUNK, 2), filled(4, 3)]; +const PAYLOAD = concat(CHUNKS); + +/** 送信を始め、seq 0 を流してストリームを受け取る(seq 0 の pushChunk はまだ保留のまま返す) */ +async function startStream( + h: Harness, + opts: { chunks?: Uint8Array[]; prepared?: { total_bytes: number; sha256: string }; claimSignal?: AbortSignal } = {} +) { + const chunks = opts.chunks ?? CHUNKS; + const payload = concat(chunks); + const preparedBody = opts.prepared ?? { total_bytes: payload.length, sha256: sha(payload) }; + const { exportCommandId, exportId } = await prepareExport(h, { ...preparedBody, file_count: 2 }); + const { claim, sendCommand } = await startSend(h, exportId, opts.claimSignal); + const push0 = h.relay.pushChunk("DS-002", exportCommandId, 0, chunks[0], sha(chunks[0])); + const claimed = await claim; + if (claimed.kind !== "stream") throw new Error(`stream にならない: ${claimed.kind}`); + const reader = claimed.body.getReader(); + return { exportCommandId, exportId, sendCommand, push0, claimed, reader, payload }; +} + +/** startStream の後、seq 0 を読んで pushChunk の 204 を受け取る */ +async function startStreamAndReadFirst(h: Harness, opts: Parameters[1] = {}) { + const s = await startStream(h, opts); + const first = await s.reader.read(); + await expect(s.push0).resolves.toBe("ok"); + return { ...s, first }; +} + +async function expectExportAccepted(h: Harness, serial = "DS-002") { + const poll = h.relay.poll(serial, "1.0.0", 25, signal()); + const created = track(h.relay.createExport(serial, ["f9"], signal())); + const command = await poll; + expect(command?.kind).toBe("export"); + expect(created.settled).toBe(false); +} + +// ── 一覧・接続 ── + +describe("relay — 一覧と接続(表5 R1〜R9・R37〜R39・R41)", () => { + it("R1 poll が即座に list を受け、submitListResult で一覧が返る", async () => { + const h = createHarness(); + const poll = track(h.relay.poll("DS-002", "1.0.0", 25, signal())); + await h.at(1); + const list = h.relay.requestList("DS-002"); + await flush(); + expect(poll.settled).toBe(true); + expect(poll.value).toEqual({ command_id: expect.any(String), kind: "list", params: {} }); + await h.at(2); + expect(h.relay.submitListResult("DS-002", poll.value!.command_id, LIST_RESULT)).toBe("ok"); + await expect(list).resolves.toEqual({ kind: "ok", result: LIST_RESULT }); + }); + + it("R2 結果が来なければ t=16(依頼から LIST_TIMEOUT_SEC)で timeout。t=15.999 ではまだ待っている", async () => { + const h = createHarness(); + const poll = h.relay.poll("DS-002", "1.0.0", 25, signal()); + await h.at(1); + const list = track(h.relay.requestList("DS-002")); + await poll; + await h.at(1 + LIST_TIMEOUT_SEC - 0.001); + expect(list.settled).toBe(false); + await h.at(1 + LIST_TIMEOUT_SEC); + expect(list.value).toEqual({ kind: "timeout" }); + }); + + it("R3 timeout の後の submitListResult は gone", async () => { + const h = createHarness(); + const poll = h.relay.poll("DS-002", "1.0.0", 25, signal()); + await h.at(1); + const list = h.relay.requestList("DS-002"); + const command = await poll; + await h.at(1 + LIST_TIMEOUT_SEC); + await expect(list).resolves.toEqual({ kind: "timeout" }); + await h.at(17); + expect(h.relay.submitListResult("DS-002", command!.command_id, LIST_RESULT)).toBe("gone"); + }); + + it("R4 poll が t=25 に null で返った後、t=85 は接続中・t=85.001 は未接続(409)", async () => { + const h = createHarness(); + const poll = track(h.relay.poll("DS-002", "1.0.0", 25, signal())); + await h.at(25); + expect(poll.settled).toBe(true); + expect(poll.value).toBeNull(); + + await h.at(25 + AGENT_OFFLINE_AFTER_SEC); + expect(h.relay.agentStatus("DS-002")).toEqual({ + connected: true, + last_seen_at: jstAt(25), + agent_version: "1.0.0", + }); + await h.at(25 + AGENT_OFFLINE_AFTER_SEC + 0.001); + expect(h.relay.agentStatus("DS-002")).toEqual({ + connected: false, + last_seen_at: jstAt(25), + agent_version: "1.0.0", + }); + await expect(h.relay.requestList("DS-002")).resolves.toEqual({ kind: "offline" }); + }); + + it("R5 一度もリクエストしていない制御盤は未接続", async () => { + const h = createHarness(); + expect(h.relay.agentStatus("DS-002")).toEqual({ connected: false, last_seen_at: null, agent_version: null }); + await expect(h.relay.requestList("DS-002")).resolves.toEqual({ kind: "offline" }); + }); + + it("R6 poll が待っている間は、last_seen_at が未接続の閾値より前でも接続中", async () => { + const h = createHarness({ offlineAfterSec: 10 }); + h.relay.poll("DS-002", "1.0.0", 25, signal()); + await h.at(20); + expect(h.relay.agentStatus("DS-002")).toEqual({ + connected: true, + last_seen_at: jstAt(0), + agent_version: "1.0.0", + }); + }); + + it("R7 ポーリングが途切れても touch(チャンク等の POST)で last_seen_at が進む(D19)", async () => { + const h = createHarness(); + h.relay.poll("DS-002", "1.0.0", 25, signal()); + await h.at(25); + await h.at(80); + h.relay.touch("DS-002", null); + expect(h.relay.agentStatus("DS-002").last_seen_at).toBe(jstAt(80)); + await h.at(80 + AGENT_OFFLINE_AFTER_SEC); + expect(h.relay.agentStatus("DS-002").connected).toBe(true); + await h.at(80 + AGENT_OFFLINE_AFTER_SEC + 0.001); + expect(h.relay.agentStatus("DS-002").connected).toBe(false); + }); + + it.each(["0", "26", "abc"])("R8 wait=%s → 422 validation_failed(ルートの検証。段階3-3 V10)", (value) => { + const parsed = parseAgentPollWait(value); + expect(parsed.ok).toBe(false); + if (!parsed.ok) { + expect(parsed.status).toBe(422); + expect(parsed.error.error.code).toBe("validation_failed"); + } + }); + + it("R9 同じ制御盤の2本目の poll は、1本目を即座に null にして自分が待つ", async () => { + const h = createHarness(); + const first = track(h.relay.poll("DS-002", "1.0.0", 25, signal())); + const second = track(h.relay.poll("DS-002", "1.0.0", 25, signal())); + await flush(); + expect(first.settled).toBe(true); + expect(first.value).toBeNull(); + expect(second.settled).toBe(false); + + h.relay.requestList("DS-002"); + await flush(); + expect(second.value).toMatchObject({ kind: "list" }); + }); + + it("R37 待ち行列に PENDING_COMMANDS_PER_DEVICE 件あれば requestList / createExport は busy(429 agent_busy)", async () => { + const h = createHarness(); + h.relay.touch("DS-002", null); + for (let i = 0; i < PENDING_COMMANDS_PER_DEVICE; i++) { + track(h.relay.requestList("DS-002")); + } + await flush(); + await expect(h.relay.requestList("DS-002")).resolves.toEqual({ kind: "busy" }); + await expect(h.relay.createExport("DS-002", ["f1"], signal())).resolves.toEqual({ + kind: "busy", + code: "agent_busy", + }); + }); + + it("R38 一覧の結果の truncated: true はそのままブラウザへ", async () => { + const h = createHarness(); + const poll = h.relay.poll("DS-002", "1.0.0", 25, signal()); + const list = h.relay.requestList("DS-002"); + const command = await poll; + const truncated = { ...LIST_RESULT, truncated: true }; + expect(h.relay.submitListResult("DS-002", command!.command_id, truncated)).toBe("ok"); + await expect(list).resolves.toEqual({ kind: "ok", result: truncated }); + }); + + it("一覧の結果が { error } なら agent_error(agent_code は制御盤の code。段階7-2 W9)", async () => { + const h = createHarness(); + const poll = h.relay.poll("DS-002", "1.0.0", 25, signal()); + const list = h.relay.requestList("DS-002"); + const command = await poll; + expect( + h.relay.submitListResult("DS-002", command!.command_id, { error: { code: "io_error", message: "m" } }) + ).toBe("ok"); + await expect(list).resolves.toEqual({ kind: "agent_error", code: "io_error" }); + }); + + it("R39 connected / disconnected は状態が変わったときだけ1回(リクエストのたびには出さない)", async () => { + const h = createHarness(); + const connected = () => h.events.filter((e) => e.event === "ds_agent.connected"); + const disconnected = () => h.events.filter((e) => e.event === "ds_agent.disconnected"); + + h.relay.poll("DS-002", "1.0.0", 25, signal()); + await h.at(5); + h.relay.touch("DS-002", null); + await h.at(10); + h.relay.touch("DS-002", null); + await h.at(25); + expect(connected()).toHaveLength(1); + expect(connected()[0]).toMatchObject({ serial_number: "DS-002" }); + expect(disconnected()).toHaveLength(0); + + await h.at(25 + AGENT_OFFLINE_AFTER_SEC + 1); + expect(disconnected()).toHaveLength(1); + expect(disconnected()[0]).toMatchObject({ serial_number: "DS-002" }); + expect(connected()).toHaveLength(1); + }); + + it.each(["準備中", "流れ中"])("R41 エクスポートの%sでも requestList は待ち行列に入り、一覧が返る(D25 ③)", async (state) => { + const h = createHarness({ chunkMaxBytes: SMALL_CHUNK }); + if (state === "準備中") { + const poll = h.relay.poll("DS-002", "1.0.0", 25, signal()); + track(h.relay.createExport("DS-002", ["f1"], signal())); + await poll; + } else { + await startStreamAndReadFirst(h); + } + const poll = h.relay.poll("DS-002", "1.0.0", 25, signal()); + const list = h.relay.requestList("DS-002"); + const command = await poll; + expect(command).toMatchObject({ kind: "list" }); + expect(h.relay.submitListResult("DS-002", command!.command_id, LIST_RESULT)).toBe("ok"); + await expect(list).resolves.toEqual({ kind: "ok", result: LIST_RESULT }); + }); +}); + +// ── エクスポートの準備 ── + +describe("relay — エクスポートの準備(表5 R10〜R13)", () => { + it("R10 export の指示が渡り、prepared で createExport が prepared(201)になる", async () => { + const h = createHarness(); + const poll = h.relay.poll("DS-002", "1.0.0", 25, signal()); + const created = h.relay.createExport("DS-002", ["f1", "f2"], signal()); + const command = await poll; + expect(command).toEqual({ + command_id: expect.any(String), + kind: "export", + params: { file_ids: ["f1", "f2"], max_bytes: EXPORT_MAX_BYTES, chunk_bytes: CHUNK_MAX_BYTES }, + }); + await h.at(10); + expect( + h.relay.submitPrepared("DS-002", command!.command_id, { total_bytes: 9437184, sha256: "a".repeat(64), file_count: 2 }) + ).toBe("ok"); + await expect(created).resolves.toEqual({ + kind: "prepared", + exportId: expect.any(String), + totalBytes: 9437184, + fileCount: 2, + expiresInSec: EXPORT_CLAIM_TIMEOUT_SEC, + }); + }); + + it("R11 prepared が来なければ t=50 はまだ待ち、t=50.001 で timeout。その後の prepared は gone", async () => { + const h = createHarness(); + const poll = h.relay.poll("DS-002", "1.0.0", 25, signal()); + const created = track(h.relay.createExport("DS-002", ["f1"], signal())); + const command = await poll; + await h.at(EXPORT_PREPARE_TIMEOUT_SEC); + expect(created.settled).toBe(false); + await h.at(EXPORT_PREPARE_TIMEOUT_SEC + 0.001); + expect(created.value).toEqual({ kind: "timeout" }); + expect( + h.relay.submitPrepared("DS-002", command!.command_id, { total_bytes: 1, sha256: "a".repeat(64), file_count: 1 }) + ).toBe("gone"); + }); + + it.each(["too_large", "file_not_allowed", "file_not_found", "io_error"])( + "R12 準備中に fail { code: %s } → createExport が agent_error", + async (code) => { + const h = createHarness(); + const poll = h.relay.poll("DS-002", "1.0.0", 25, signal()); + const created = h.relay.createExport("DS-002", ["f1"], signal()); + const command = await poll; + expect(h.relay.failCommand("DS-002", command!.command_id, code, "失敗しました")).toBe("ok"); + await expect(created).resolves.toEqual({ kind: "agent_error", code }); + } + ); + + it("R13 prepared の total_bytes がちょうど EXPORT_MAX_BYTES なら prepared", async () => { + const h = createHarness(); + const { result } = await prepareExport(h, { total_bytes: h.limits.exportMaxBytes, sha256: "a".repeat(64), file_count: 1 }); + expect(result.totalBytes).toBe(h.limits.exportMaxBytes); + }); + + it("R13 EXPORT_MAX_BYTES + 1 なら agent_error(too_large)。prepared の応答は ok のまま、エクスポートは失敗(進行中から外れる)", async () => { + const h = createHarness(); + const poll = h.relay.poll("DS-002", "1.0.0", 25, signal()); + const created = h.relay.createExport("DS-002", ["f1"], signal()); + const command = await poll; + expect( + h.relay.submitPrepared("DS-002", command!.command_id, { + total_bytes: h.limits.exportMaxBytes + 1, + sha256: "a".repeat(64), + file_count: 1, + }) + ).toBe("ok"); + await expect(created).resolves.toEqual({ kind: "agent_error", code: "too_large" }); + await expectExportAccepted(h); + }); +}); + +// ── 受け取り(claim)── + +describe("relay — 受け取り(表5 R14〜R16・R40)", () => { + const PREPARED = { total_bytes: 36, sha256: "a".repeat(64), file_count: 2 }; + + it("R14 prepared から t=+60 の claim は受理(send の指示が渡る)", async () => { + const h = createHarness(); + const { exportCommandId, exportId } = await prepareExport(h, PREPARED); + await h.at(EXPORT_CLAIM_TIMEOUT_SEC); + const { claim, sendCommand } = await startSend(h, exportId); + expect(sendCommand).toEqual({ + command_id: expect.any(String), + kind: "send", + params: { export_command_id: exportCommandId }, + }); + expect(sendCommand.command_id).not.toBe(exportCommandId); + expect(track(claim).settled).toBe(false); + }); + + it("R14 prepared から t=+60.001 の claim は not_found(失効)", async () => { + const h = createHarness(); + const { exportId } = await prepareExport(h, PREPARED); + await h.at(EXPORT_CLAIM_TIMEOUT_SEC + 0.001); + await expect(h.relay.claimExport("DS-002", exportId, signal())).resolves.toEqual({ kind: "not_found" }); + }); + + it("R15 同じ export_id の2回目の claim は not_found", async () => { + const h = createHarness(); + const { exportId } = await prepareExport(h, PREPARED); + await startSend(h, exportId); + await expect(h.relay.claimExport("DS-002", exportId, signal())).resolves.toEqual({ kind: "not_found" }); + }); + + it("R16 パスのシリアルが違う claim は not_found", async () => { + const h = createHarness(); + const { exportId } = await prepareExport(h, PREPARED); + await expect(h.relay.claimExport("DS-003", exportId, signal())).resolves.toEqual({ kind: "not_found" }); + }); + + it("R40 prepared の後 GET が来ない: t=60 は進行中(429)、t=60.001 で失効して受理・claim は 404・その後の fail / チャンクは 410", async () => { + const h = createHarness(); + const { exportCommandId, exportId } = await prepareExport(h, PREPARED); + + await h.at(EXPORT_CLAIM_TIMEOUT_SEC); + h.relay.touch("DS-002", null); + await expect(h.relay.createExport("DS-002", ["f9"], signal())).resolves.toEqual({ + kind: "busy", + code: "export_busy", + }); + + await h.at(EXPORT_CLAIM_TIMEOUT_SEC + 0.001); + await expectExportAccepted(h); + await expect(h.relay.claimExport("DS-002", exportId, signal())).resolves.toEqual({ kind: "not_found" }); + expect(h.relay.failCommand("DS-002", exportCommandId, "io_error", "m")).toBe("gone"); + const chunk = filled(4, 1); + // ★pushChunk の戻り値に gone は無い(段階6-1)。cancelled も 410(export_cancelled)に写るので、どちらでも 410 になる★ + expect(["gone", "cancelled"]).toContain( + await h.relay.pushChunk("DS-002", exportCommandId, 0, chunk, sha(chunk)) + ); + }); +}); + +// ── 送信 ── + +describe("relay — 送信(表5 R17〜R32・R42)", () => { + it("R17 claim → send → seq 0〜2 → complete: seq 0 の到着でストリーム、バイト列が一致し、閉じる", async () => { + const h = createHarness({ chunkMaxBytes: SMALL_CHUNK }); + const s = await startStream(h); + expect(s.claimed.totalBytes).toBe(PAYLOAD.length); + + const received: Uint8Array[] = []; + const r0 = await s.reader.read(); + received.push(r0.value!); + await expect(s.push0).resolves.toBe("ok"); + + for (const seq of [1, 2]) { + const push = h.relay.pushChunk("DS-002", s.exportCommandId, seq, CHUNKS[seq], sha(CHUNKS[seq])); + const r = await s.reader.read(); + received.push(r.value!); + await expect(push).resolves.toBe("ok"); + } + + expect(h.relay.completeExport("DS-002", s.exportCommandId, PAYLOAD.length, sha(PAYLOAD))).toBe("ok"); + const end = await s.reader.read(); + expect(end.done).toBe(true); + expect(concat(received)).toEqual(PAYLOAD); + }); + + it("R18 claim から t=+30 の seq 0 は受理(ストリーム)", async () => { + const h = createHarness({ chunkMaxBytes: SMALL_CHUNK }); + const { exportCommandId, exportId } = await prepareExport(h, { + total_bytes: PAYLOAD.length, + sha256: sha(PAYLOAD), + file_count: 2, + }); + const { claim } = await startSend(h, exportId); + const claimed = track(claim); + await h.at(EXPORT_FIRST_CHUNK_TIMEOUT_SEC); + expect(claimed.settled).toBe(false); + h.relay.pushChunk("DS-002", exportCommandId, 0, CHUNKS[0], sha(CHUNKS[0])); + await flush(); + expect(claimed.value).toMatchObject({ kind: "stream", totalBytes: PAYLOAD.length }); + }); + + it("R18 seq 0 が来ないまま t=+30.001 で timeout(504)。その後のチャンクは 410", async () => { + const h = createHarness({ chunkMaxBytes: SMALL_CHUNK }); + const { exportCommandId, exportId } = await prepareExport(h, { + total_bytes: PAYLOAD.length, + sha256: sha(PAYLOAD), + file_count: 2, + }); + const { claim } = await startSend(h, exportId); + await h.at(EXPORT_FIRST_CHUNK_TIMEOUT_SEC + 0.001); + await expect(claim).resolves.toEqual({ kind: "timeout" }); + // ★pushChunk の戻り値に gone は無い(段階6-1)。410 は cancelled(export_cancelled)で返す★ + await expect( + h.relay.pushChunk("DS-002", exportCommandId, 0, CHUNKS[0], sha(CHUNKS[0])) + ).resolves.toBe("cancelled"); + }); + + it.each(["export_not_found", "io_error"])( + "R19 send の後・最初のチャンクの前に fail { code: %s } → claim が agent_error(D25 ②)", + async (code) => { + const h = createHarness({ chunkMaxBytes: SMALL_CHUNK }); + const { exportCommandId, exportId } = await prepareExport(h, { + total_bytes: PAYLOAD.length, + sha256: sha(PAYLOAD), + file_count: 2, + }); + const { claim } = await startSend(h, exportId); + expect(h.relay.failCommand("DS-002", exportCommandId, code, "zip がありません")).toBe("ok"); + await expect(claim).resolves.toEqual({ kind: "agent_error", code }); + } + ); + + it("R20 prepared の後・send の前のチャンクは out_of_order(409)", async () => { + const h = createHarness({ chunkMaxBytes: SMALL_CHUNK }); + const { exportCommandId } = await prepareExport(h, { + total_bytes: PAYLOAD.length, + sha256: sha(PAYLOAD), + file_count: 2, + }); + await expect( + h.relay.pushChunk("DS-002", exportCommandId, 0, CHUNKS[0], sha(CHUNKS[0])) + ).resolves.toBe("out_of_order"); + }); + + it("R21 seq 0 の後に seq 2 → out_of_order(409)。ストリームを error", async () => { + const h = createHarness({ chunkMaxBytes: SMALL_CHUNK }); + const s = await startStreamAndReadFirst(h); + // ★わざと seq を飛ばす(1 を送らずに 2)★ + await expect( + h.relay.pushChunk("DS-002", s.exportCommandId, 2, CHUNKS[2], sha(CHUNKS[2])) + ).resolves.toBe("out_of_order"); + await expectErrored(s.reader); + }); + + it("R22 流し始めた後のハッシュ不一致 → invalid(400 chunk_invalid)。ストリームを error", async () => { + const h = createHarness({ chunkMaxBytes: SMALL_CHUNK }); + const s = await startStreamAndReadFirst(h); + // ★わざと違うハッシュ(全部 0)。CHUNKS[1] の SHA-256 がこれになることはない★ + await expect( + h.relay.pushChunk("DS-002", s.exportCommandId, 1, CHUNKS[1], "0".repeat(64)) + ).resolves.toBe("invalid"); + await expectErrored(s.reader); + }); + + it("R23 最初のチャンクのハッシュ不一致 → チャンクは invalid、claim は agent_error(chunk_invalid)", async () => { + const h = createHarness({ chunkMaxBytes: SMALL_CHUNK }); + const { exportCommandId, exportId } = await prepareExport(h, { + total_bytes: PAYLOAD.length, + sha256: sha(PAYLOAD), + file_count: 2, + }); + const { claim } = await startSend(h, exportId); + // ★わざと違うハッシュ(全部 0)★ + await expect( + h.relay.pushChunk("DS-002", exportCommandId, 0, CHUNKS[0], "0".repeat(64)) + ).resolves.toBe("invalid"); + await expect(claim).resolves.toEqual({ kind: "agent_error", code: "chunk_invalid" }); + }); + + it("R24 チャンクの長さがちょうど chunkMaxBytes なら受理", async () => { + const h = createHarness({ chunkMaxBytes: SMALL_CHUNK }); + const chunk = filled(h.limits.chunkMaxBytes, 7); + const s = await startStream(h, { chunks: [chunk] }); + const r = await s.reader.read(); + expect(r.value).toEqual(chunk); + await expect(s.push0).resolves.toBe("ok"); + }); + + it.each<[string, (limits: RelayLimits) => number]>([ + ["chunkMaxBytes + 1", (l) => l.chunkMaxBytes + 1], + ["0バイト", () => 0], + ])("R24 チャンクの長さが %s なら invalid(400)", async (_label, lengthOf) => { + const h = createHarness({ chunkMaxBytes: SMALL_CHUNK }); + const chunk = filled(lengthOf(h.limits), 7); + const total = Math.max(chunk.length, 1); + const { exportCommandId, exportId } = await prepareExport(h, { + total_bytes: total, + sha256: sha(filled(total, 7)), + file_count: 1, + }); + const { claim } = await startSend(h, exportId); + await expect(h.relay.pushChunk("DS-002", exportCommandId, 0, chunk, sha(chunk))).resolves.toBe("invalid"); + await expect(claim).resolves.toMatchObject({ kind: "agent_error" }); + }); + + it("R25 受け取った合計が prepared の total_bytes を超えたら invalid。ストリームを error", async () => { + const h = createHarness({ chunkMaxBytes: SMALL_CHUNK }); + const intended = filled(20, 1); + const s = await startStreamAndReadFirst(h, { + chunks: [filled(SMALL_CHUNK, 1), filled(SMALL_CHUNK, 1)], + prepared: { total_bytes: intended.length, sha256: sha(intended) }, + }); + const over = filled(SMALL_CHUNK, 1); + await expect(h.relay.pushChunk("DS-002", s.exportCommandId, 1, over, sha(over))).resolves.toBe("invalid"); + await expectErrored(s.reader); + }); + + /** seq 0〜2 をすべて流し終えた状態にする(complete の前) */ + async function streamAll(h: Harness, prepared?: { total_bytes: number; sha256: string }) { + const s = await startStreamAndReadFirst(h, { prepared }); + for (const seq of [1, 2]) { + const push = h.relay.pushChunk("DS-002", s.exportCommandId, seq, CHUNKS[seq], sha(CHUNKS[seq])); + await s.reader.read(); + await expect(push).resolves.toBe("ok"); + } + return s; + } + + it("R26 complete の total_bytes が prepared と違えば invalid。ストリームを error", async () => { + const h = createHarness({ chunkMaxBytes: SMALL_CHUNK }); + const s = await streamAll(h); + expect(h.relay.completeExport("DS-002", s.exportCommandId, PAYLOAD.length - 1, sha(PAYLOAD))).toBe("invalid"); + await expectErrored(s.reader); + }); + + it("R26 受け取った合計が total_bytes に足りないまま complete → invalid。ストリームを error", async () => { + const h = createHarness({ chunkMaxBytes: SMALL_CHUNK }); + const s = await startStreamAndReadFirst(h); + expect(h.relay.completeExport("DS-002", s.exportCommandId, PAYLOAD.length, sha(PAYLOAD))).toBe("invalid"); + await expectErrored(s.reader); + }); + + it("R27 complete の sha256 が prepared の sha256 と違えば invalid。ストリームを error", async () => { + const h = createHarness({ chunkMaxBytes: SMALL_CHUNK }); + const s = await streamAll(h); + // ★わざと違うハッシュ(全部 f)。PAYLOAD の SHA-256 がこれになることはない★ + expect(h.relay.completeExport("DS-002", s.exportCommandId, PAYLOAD.length, "f".repeat(64))).toBe("invalid"); + await expectErrored(s.reader); + }); + + it("R27 受け取ったバイト列の SHA-256 が prepared の sha256 と違えば invalid(complete は prepared と同じ値でも)", async () => { + const h = createHarness({ chunkMaxBytes: SMALL_CHUNK }); + // ★わざと違うハッシュ(全部 0)を prepared に書く。チャンクごとのハッシュは正しい★ + const wrong = "0".repeat(64); + const s = await streamAll(h, { total_bytes: PAYLOAD.length, sha256: wrong }); + expect(h.relay.completeExport("DS-002", s.exportCommandId, PAYLOAD.length, wrong)).toBe("invalid"); + await expectErrored(s.reader); + }); + + it("R28 流し始めた後の fail(export の指示の command_id)→ fail は ok、ストリームを error(D25 ①②)", async () => { + const h = createHarness({ chunkMaxBytes: SMALL_CHUNK }); + const s = await startStreamAndReadFirst(h); + expect(h.relay.failCommand("DS-002", s.exportCommandId, "export_not_found", "zip がありません")).toBe("ok"); + await expectErrored(s.reader); + }); + + it.each(["cancel", "abort"] as const)( + "R29 流し始めた後にブラウザが切断(%s)→ 次のチャンクは cancelled(410)", + async (how) => { + const h = createHarness({ chunkMaxBytes: SMALL_CHUNK }); + const controller = new AbortController(); + const s = await startStreamAndReadFirst(h, { claimSignal: controller.signal }); + if (how === "cancel") { + await s.reader.cancel(); + } else { + controller.abort(); + } + await flush(); + await expect( + h.relay.pushChunk("DS-002", s.exportCommandId, 1, CHUNKS[1], sha(CHUNKS[1])) + ).resolves.toBe("cancelled"); + } + ); + + it("R30 流量制御: ブラウザが読むまで seq 0 の pushChunk は保留し、reader.read() で解決する", async () => { + const h = createHarness({ chunkMaxBytes: SMALL_CHUNK }); + const s = await startStream(h); + const push0 = track(s.push0); + await flush(); + expect(push0.settled).toBe(false); + await s.reader.read(); + await flush(); + expect(push0.value).toBe("ok"); + }); + + it("R31 保留が t=+45 まではまだ保留、t=+45.001 で cancelled(410)・ストリームを error(D20)", async () => { + const h = createHarness({ chunkMaxBytes: SMALL_CHUNK }); + const s = await startStream(h); + const push0 = track(s.push0); + await h.at(EXPORT_IDLE_TIMEOUT_SEC); + expect(push0.settled).toBe(false); + await h.at(EXPORT_IDLE_TIMEOUT_SEC + 0.001); + expect(push0.value).toBe("cancelled"); + await expectErrored(s.reader); + }); + + it("R32 次のチャンクが来ないまま t=+45 は流れたまま、t=+45.001 でストリームを error。その後のチャンクは 410", async () => { + const h = createHarness({ chunkMaxBytes: SMALL_CHUNK }); + const s = await startStreamAndReadFirst(h); + const pending = track(s.reader.read()); + await h.at(EXPORT_IDLE_TIMEOUT_SEC); + expect(pending.settled).toBe(false); + await h.at(EXPORT_IDLE_TIMEOUT_SEC + 0.001); + expect(pending.rejected).toBe(true); + await expect( + h.relay.pushChunk("DS-002", s.exportCommandId, 1, CHUNKS[1], sha(CHUNKS[1])) + ).resolves.toBe("cancelled"); + }); + + it("R42 send の指示の command_id にチャンク・complete を送ると not_found(D25 ①)", async () => { + const h = createHarness({ chunkMaxBytes: SMALL_CHUNK }); + const { exportId } = await prepareExport(h, { total_bytes: PAYLOAD.length, sha256: sha(PAYLOAD), file_count: 2 }); + const { sendCommand } = await startSend(h, exportId); + await expect( + h.relay.pushChunk("DS-002", sendCommand.command_id, 0, CHUNKS[0], sha(CHUNKS[0])) + ).resolves.toBe("not_found"); + expect(h.relay.completeExport("DS-002", sendCommand.command_id, PAYLOAD.length, sha(PAYLOAD))).toBe("not_found"); + }); +}); + +// ── 同時実行・取り違え ── + +describe("relay — 同時実行と取り違え(表5 R33〜R36・R43)", () => { + const PREPARED = () => ({ total_bytes: PAYLOAD.length, sha256: sha(PAYLOAD), file_count: 2 }); + + it.each(["準備中", "受け取り待ち", "送信待ち", "流れ中"] as const)( + "R33 同じ制御盤で%sに2本目の createExport → busy(429 export_busy)", + async (state) => { + const h = createHarness({ chunkMaxBytes: SMALL_CHUNK }); + if (state === "準備中") { + const poll = h.relay.poll("DS-002", "1.0.0", 25, signal()); + track(h.relay.createExport("DS-002", ["f1"], signal())); + await poll; + } else if (state === "受け取り待ち") { + await prepareExport(h, PREPARED()); + } else if (state === "送信待ち") { + const { exportId } = await prepareExport(h, PREPARED()); + await startSend(h, exportId); + } else { + await startStreamAndReadFirst(h); + } + h.relay.touch("DS-002", null); + await expect(h.relay.createExport("DS-002", ["f9"], signal())).resolves.toEqual({ + kind: "busy", + code: "export_busy", + }); + } + ); + + it("R33 1本目が完了した後は受理", async () => { + const h = createHarness({ chunkMaxBytes: SMALL_CHUNK }); + const s = await startStreamAndReadFirst(h); + for (const seq of [1, 2]) { + const push = h.relay.pushChunk("DS-002", s.exportCommandId, seq, CHUNKS[seq], sha(CHUNKS[seq])); + await s.reader.read(); + await push; + } + expect(h.relay.completeExport("DS-002", s.exportCommandId, PAYLOAD.length, sha(PAYLOAD))).toBe("ok"); + await expectExportAccepted(h); + }); + + it("R33 1本目が失敗した後は受理", async () => { + const h = createHarness(); + const poll = h.relay.poll("DS-002", "1.0.0", 25, signal()); + const created = h.relay.createExport("DS-002", ["f1"], signal()); + const command = await poll; + h.relay.failCommand("DS-002", command!.command_id, "io_error", "m"); + await created; + await expectExportAccepted(h); + }); + + it("R34 3台で進行中なら4台目は busy(429 export_busy)", async () => { + const h = createHarness(); + for (const serial of ["DS-002", "DS-003", "DS-004"]) { + const poll = h.relay.poll(serial, "1.0.0", 25, signal()); + track(h.relay.createExport(serial, ["f1"], signal())); + await poll; + } + h.relay.touch("DS-005", null); + await expect(h.relay.createExport("DS-005", ["f1"], signal())).resolves.toEqual({ + kind: "busy", + code: "export_busy", + }); + }); + + it("R35 DS-003 から DS-002 の command_id に result・prepared・チャンク・complete・fail → not_found(403 にしない)", async () => { + const h = createHarness({ chunkMaxBytes: SMALL_CHUNK }); + const pollList = h.relay.poll("DS-002", "1.0.0", 25, signal()); + track(h.relay.requestList("DS-002")); + const listCommand = await pollList; + const pollExport = h.relay.poll("DS-002", "1.0.0", 25, signal()); + track(h.relay.createExport("DS-002", ["f1"], signal())); + const exportCommand = await pollExport; + + expect(h.relay.submitListResult("DS-003", listCommand!.command_id, LIST_RESULT)).toBe("not_found"); + expect(h.relay.submitPrepared("DS-003", exportCommand!.command_id, PREPARED())).toBe("not_found"); + await expect( + h.relay.pushChunk("DS-003", exportCommand!.command_id, 0, CHUNKS[0], sha(CHUNKS[0])) + ).resolves.toBe("not_found"); + expect(h.relay.completeExport("DS-003", exportCommand!.command_id, PAYLOAD.length, sha(PAYLOAD))).toBe( + "not_found" + ); + expect(h.relay.failCommand("DS-003", exportCommand!.command_id, "io_error", "m")).toBe("not_found"); + }); + + it("R36 list の command_id に prepared → not_found", async () => { + const h = createHarness(); + const poll = h.relay.poll("DS-002", "1.0.0", 25, signal()); + track(h.relay.requestList("DS-002")); + const command = await poll; + expect(h.relay.submitPrepared("DS-002", command!.command_id, PREPARED())).toBe("not_found"); + }); + + it("R43 prepared / complete / fail の形の誤りは 400 result_invalid。状態は変えない(正しい結果を受け取れる)", async () => { + const h = createHarness(); + const poll = h.relay.poll("DS-002", "1.0.0", 25, signal()); + const list = h.relay.requestList("DS-002"); + const command = await poll; + + const rejected = [ + parseAgentPrepared(JSON.stringify({ total_bytes: "1", sha256: "a".repeat(64), file_count: 1 })), + parseAgentComplete(JSON.stringify({ total_bytes: 1 })), + parseAgentFail(JSON.stringify({ code: 1, message: "m" })), + ]; + for (const result of rejected) { + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.status).toBe(400); + expect(result.error.error.code).toBe("result_invalid"); + } + } + + // ★prepared・complete・fail の形の誤りは状態を変えない(指示書BJ: 一覧だけ rejectListResult)★ + expect(h.relay.submitListResult("DS-002", command!.command_id, LIST_RESULT)).toBe("ok"); + await expect(list).resolves.toEqual({ kind: "ok", result: LIST_RESULT }); + }); + + it("R43′ 一覧の形の誤りは rejectListResult → agent_error(result_invalid)。その後は not_found", async () => { + const h = createHarness(); + const poll = h.relay.poll("DS-002", "1.0.0", 25, signal()); + const list = h.relay.requestList("DS-002"); + const command = await poll; + + const bad = parseAgentListResult(JSON.stringify({ types: [], items: [] })); + expect(bad.ok).toBe(false); + expect(h.relay.rejectListResult("DS-002", command!.command_id)).toBe("ok"); + await expect(list).resolves.toEqual({ kind: "agent_error", code: "result_invalid" }); + expect(h.relay.submitListResult("DS-002", command!.command_id, LIST_RESULT)).toBe("not_found"); + }); + + it("R43‴ export の指示 id への rejectListResult は not_found(エクスポートは続く)", async () => { + const h = createHarness(); + const poll = h.relay.poll("DS-002", "1.0.0", 25, signal()); + const created = h.relay.createExport("DS-002", ["f1"], signal()); + const command = await poll; + expect(h.relay.rejectListResult("DS-002", command!.command_id)).toBe("not_found"); + expect( + h.relay.submitPrepared("DS-002", command!.command_id, { + total_bytes: 4, + sha256: sha(filled(4, 1)), + file_count: 1, + }) + ).toBe("ok"); + await expect(created).resolves.toMatchObject({ kind: "prepared", totalBytes: 4 }); + }); + + it("R50 touch に版を渡すと connected の agent_version が入る", async () => { + const h = createHarness(); + h.relay.touch("DS-002", "1.2.3"); + const connected = h.events.filter((e) => e.event === "ds_agent.connected"); + expect(connected).toHaveLength(1); + expect(connected[0]).toMatchObject({ serial_number: "DS-002", agent_version: "1.2.3" }); + expect(h.relay.agentStatus("DS-002").agent_version).toBe("1.2.3"); + }); + + it("R51 touch の版が null なら agent_version は null(拒否しない)", async () => { + const h = createHarness(); + h.relay.touch("DS-002", null); + const connected = h.events.filter((e) => e.event === "ds_agent.connected"); + expect(connected[0]).toMatchObject({ agent_version: null }); + expect(h.relay.agentStatus("DS-002").agent_version).toBeNull(); + }); +}); + +describe("relay — 流量制御の並列送信(表R R49)", () => { + it("R49 保留中に次のチャンク → out_of_order。先の POST は cancelled。flow_timeout は出ない", async () => { + const h = createHarness({ chunkMaxBytes: SMALL_CHUNK }); + const s = await startStream(h); + const push0 = track(s.push0); + await flush(); + expect(push0.settled).toBe(false); + + await expect( + h.relay.pushChunk("DS-002", s.exportCommandId, 1, CHUNKS[1], sha(CHUNKS[1])) + ).resolves.toBe("out_of_order"); + expect(push0.value).toBe("cancelled"); + await expectErrored(s.reader); + + const failed = h.events.filter((e) => e.event === "ds_logs.export_failed"); + expect(failed).toHaveLength(1); + expect(failed[0]).toMatchObject({ reason: "chunk_out_of_order", agent_code: "chunk_out_of_order" }); + + await h.at(EXPORT_IDLE_TIMEOUT_SEC + 1); + expect(h.events.filter((e) => e.event === "ds_logs.export_failed" && e.reason === "flow_timeout")).toHaveLength(0); + }); +}); diff --git a/lib/ds-agent/__tests__/tokens.test.ts b/lib/ds-agent/__tests__/tokens.test.ts new file mode 100644 index 0000000..e15d24d --- /dev/null +++ b/lib/ds-agent/__tests__/tokens.test.ts @@ -0,0 +1,161 @@ +/** + * 指示書AY §3 表3 K1〜K12(`lib/ds-agent/tokens.ts`。ストアは mkdtemp)。 + * ブリーフ §5 と監督判断 第2ラウンドから本書で具体化。★P1・P2 の行は §11 Q14・Q15 の採用で確定★。 + * (KA1〜KA8 はルートのテスト。段階7) + * + * | # | 入力 | 期待 | + * | K1 | issueAgentToken("DS-002") | token が /^[A-Za-z0-9_-]{43}$/・issued_at が +09:00 の ISO。ストアに DS-002: { token_sha256: 64桁の16進, issued_at }。ファイルに生のトークンが無い | + * | K2 | verifyAgentAuthorization("Bearer " + K1 の値) | "DS-002" | + * | K3 | "Bearer " + 形は正しいが違う43文字 | null | + * | K4 | null / "" | null | + * | K5 | K1 の値だけ(Bearer なし) | null | + * | K6 | "bearer " + 値 / "Bearer " + 値 / 末尾に空白 | null(厳密に Bearer +空白1つ) | + * | K7 | ストアが空 | null | + * | K8 | revokeAgentToken("DS-002") の後に K2 | null | + * | K9 | DS-002 を再発行 | 古い値は null、新しい値は "DS-002" | + * | K10 | DS-002 と DS-003 を発行 | それぞれの値がそれぞれのシリアルを返す | + * | K11 | ストアの1件の token_sha256 が壊れている | その1件だけ無視し例外にしない。他は通る | + * | K12 | 検証した後に別のストアのインスタンス(同じパス)から失効 | 次の検証は null(キャッシュは mtime で無効になる) | + * + * ★ストアとパスを一緒に差し替える★(§11-4 M13)。★`data/settings/` の実物に触らない★。 + */ +import { createHash } from "node:crypto"; +import { promises as fs } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + __setAgentTokenStoreForTesting, + createAgentTokenStore, + issueAgentToken, + revokeAgentToken, + verifyAgentAuthorization, +} from "@/lib/ds-agent/tokens"; + +let dir: string; +let filePath: string; + +beforeEach(async () => { + dir = await fs.mkdtemp(path.join(os.tmpdir(), "dwatch-agent-tokens-")); + filePath = path.join(dir, "ds-agent-tokens.json"); + __setAgentTokenStoreForTesting(createAgentTokenStore(filePath)); +}); + +afterEach(async () => { + __setAgentTokenStoreForTesting(null); + await fs.rm(dir, { recursive: true, force: true }); +}); + +async function readStoreFile(): Promise> { + return JSON.parse(await fs.readFile(filePath, "utf-8")); +} + +/** + * ファイルの mtime を確実に変える。 + * ★ファイルシステムによって mtime の分解能が粗い(FAT は2秒など)ので、書き込みだけに頼らず未来の時刻へ進める★ + */ +async function bumpMtime(): Promise { + const future = new Date(Date.now() + 10_000); + await fs.utimes(filePath, future, future); +} + +const sha256Hex = (s: string) => createHash("sha256").update(s).digest("hex"); + +describe("制御盤トークン — 表3 K1〜K12", () => { + it("K1 発行: 43文字の base64url・+09:00 の issued_at・ストアには SHA-256 だけ", async () => { + const { token, issued_at } = await issueAgentToken("DS-002"); + expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(issued_at).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\+09:00$/); + + const stored = await readStoreFile(); + expect(stored["DS-002"]).toEqual({ token_sha256: sha256Hex(token), issued_at }); + expect(stored["DS-002"].token_sha256).toMatch(/^[0-9a-f]{64}$/); + + const raw = await fs.readFile(filePath, "utf-8"); + expect(raw).not.toContain(token); + }); + + it("K2 正しい Bearer → シリアル", async () => { + const { token } = await issueAgentToken("DS-002"); + await expect(verifyAgentAuthorization(`Bearer ${token}`)).resolves.toBe("DS-002"); + }); + + it("K3 形は正しいが違う43文字 → null", async () => { + const { token } = await issueAgentToken("DS-002"); + const other = token[0] === "A" ? "B".repeat(43) : "A".repeat(43); + await expect(verifyAgentAuthorization(`Bearer ${other}`)).resolves.toBeNull(); + }); + + it.each<[string, string | null]>([ + ["null", null], + ["空文字", ""], + ])("K4 %s → null", async (_label, header) => { + await issueAgentToken("DS-002"); + await expect(verifyAgentAuthorization(header)).resolves.toBeNull(); + }); + + it("K5 Bearer なしの値だけ → null", async () => { + const { token } = await issueAgentToken("DS-002"); + await expect(verifyAgentAuthorization(token)).resolves.toBeNull(); + }); + + it.each<[string, (token: string) => string]>([ + ["小文字の bearer", (t) => `bearer ${t}`], + ["空白2つ", (t) => `Bearer ${t}`], + ["末尾に空白", (t) => `Bearer ${t} `], + ])("K6 %s → null(厳密に Bearer +空白1つ)", async (_label, build) => { + const { token } = await issueAgentToken("DS-002"); + await expect(verifyAgentAuthorization(build(token))).resolves.toBeNull(); + }); + + it("K7 ストアが空(未登録)→ null", async () => { + await expect(verifyAgentAuthorization(`Bearer ${"A".repeat(43)}`)).resolves.toBeNull(); + }); + + it("K8 失効の後は null", async () => { + const { token } = await issueAgentToken("DS-002"); + await expect(revokeAgentToken("DS-002")).resolves.toBe(true); + await expect(verifyAgentAuthorization(`Bearer ${token}`)).resolves.toBeNull(); + }); + + it("K9 再発行: 古い値は null、新しい値は DS-002", async () => { + const first = await issueAgentToken("DS-002"); + const second = await issueAgentToken("DS-002"); + expect(second.token).not.toBe(first.token); + await expect(verifyAgentAuthorization(`Bearer ${first.token}`)).resolves.toBeNull(); + await expect(verifyAgentAuthorization(`Bearer ${second.token}`)).resolves.toBe("DS-002"); + }); + + it("K10 DS-002 と DS-003 はそれぞれのシリアルを返す", async () => { + const a = await issueAgentToken("DS-002"); + const b = await issueAgentToken("DS-003"); + await expect(verifyAgentAuthorization(`Bearer ${a.token}`)).resolves.toBe("DS-002"); + await expect(verifyAgentAuthorization(`Bearer ${b.token}`)).resolves.toBe("DS-003"); + }); + + it("K11 壊れた1件だけ無視し例外にしない(他の制御盤は通る)", async () => { + const a = await issueAgentToken("DS-002"); + const b = await issueAgentToken("DS-003"); + const stored = await readStoreFile(); + stored["DS-003"].token_sha256 = "not-a-64-hex"; + await fs.writeFile(filePath, JSON.stringify(stored), "utf-8"); + await bumpMtime(); + + await expect(verifyAgentAuthorization(`Bearer ${a.token}`)).resolves.toBe("DS-002"); + await expect(verifyAgentAuthorization(`Bearer ${b.token}`)).resolves.toBeNull(); + }); + + it("K12 別のストアのインスタンス(同じパス)から失効 → キャッシュは mtime で無効になり null", async () => { + const { token } = await issueAgentToken("DS-002"); + await expect(verifyAgentAuthorization(`Bearer ${token}`)).resolves.toBe("DS-002"); + + const other = createAgentTokenStore(filePath); + expect(other.filePath).toBe(filePath); + const data = await other.store.read(); + delete data["DS-002"]; + await other.store.write(data); + await bumpMtime(); + + await expect(verifyAgentAuthorization(`Bearer ${token}`)).resolves.toBeNull(); + }); +}); diff --git a/lib/ds-agent/agent-request.ts b/lib/ds-agent/agent-request.ts new file mode 100644 index 0000000..53f05d6 --- /dev/null +++ b/lib/ds-agent/agent-request.ts @@ -0,0 +1,50 @@ +/** + * 制御盤のログの API のルートが共有する前処理と応答(指示書AY 段階7-3。M12)。 + * + * ★ルートは薄く保つ★。★ファイルシステム/OS のモジュールを import しない★(D3。段階7-4 の静的な検査の対象)。 + */ +import { NextResponse } from "next/server"; +import { parsePathCommandId } from "@/lib/ds-logs/validate"; +import { requireAgent } from "@/lib/ds-agent/require-agent"; +import type { ApiError, ErrorCode } from "@/lib/types"; + +export function errorBody( + code: ErrorCode, + message: string, + details: Record | null = null +): ApiError { + return { error: { code, message, details } }; +} + +export function jsonError( + status: number, + code: ErrorCode, + message: string, + details: Record | null = null +): Response { + return NextResponse.json(errorBody(code, message, details), { status }); +} + +export function noContent(): Response { + return new Response(null, { status: 204 }); +} + +/** + * `[command_id]` を持つ制御盤向けの5本の前処理: ★`requireAgent()`(再検証・最終接続日時の更新)→ `command_id` の検証★。 + * 本体はこの後にルートが `request.text()` で読む(JSON でない・オブジェクトでないは 400 `result_invalid`。R43・R44)。 + */ +export async function agentCommandPreamble( + request: Request, + commandIdRaw: string +): Promise<{ ok: true; serial: string; commandId: string } | { ok: false; response: Response }> { + const auth = await requireAgent(request); + if (!auth.ok) return auth; + const parsed = parsePathCommandId(commandIdRaw); + if (!parsed.ok) return { ok: false, response: NextResponse.json(parsed.error, { status: parsed.status }) }; + return { ok: true, serial: auth.serial, commandId: parsed.query }; +} + +/** 制御盤向け: 指示が無い・別の制御盤の指示・種類の違う指示(403 にしない。R35) */ +export function commandNotFound(): Response { + return jsonError(404, "not_found", "指示が見つかりません"); +} diff --git a/lib/ds-agent/fixture-agent.ts b/lib/ds-agent/fixture-agent.ts new file mode 100644 index 0000000..856baf8 --- /dev/null +++ b/lib/ds-agent/fixture-agent.ts @@ -0,0 +1,104 @@ +/** + * fixtures の偽制御盤(指示書AY 段階8-2。D10・`CLAUDE.md` §9.5)。 + * + * ★relay の同じ経路を通す★: ポーリング → 指示 → 結果(list)/準備完了(export)/チャンク → 完了(send)。 + * ルートは「fixtures のときは relay を呼ぶ前に `ensureFixtureAgent()`」だけを書き、それ以外の分岐を持たない。 + * + * - `FIXTURE_AGENT_OFFLINE_SERIALS` と fixtures に無いシリアルは何もしない(→ 409 / 404 が本物と同じ経路で出る) + * - ★1シリアル1本★(relay ごとの登録で二重起動を防ぐ。`globalThis` に置き HMR でも二重にしない) + * - 指示ごとの処理は待たずに次のポーリングへ進む(本物と同じく、一覧はエクスポートと並行して返す。D25 ③) + * - ★ディスクに書かない★(zip は base64 の定数から作る) + */ +import { createHash } from "node:crypto"; +import { AGENT_POLL_WAIT_SEC } from "@/lib/config/ds-agent"; +import { getRelay, type Relay } from "@/lib/ds-agent/relay"; +import { + FIXTURE_AGENT_OFFLINE_SERIALS, + FIXTURE_EXPORT_ZIP_BASE64, + fixtureDeviceLogItems, + fixtureDeviceLogTypes, +} from "@/lib/fixtures/ds-device-logs"; +import { dsDevices } from "@/lib/fixtures/ds-devices"; +import type { DsAgentCommand } from "@/lib/types"; + +/** 流量制御の経路を通すため、zip を小さく分けて送る(438バイト → 4チャンク) */ +const FIXTURE_CHUNK_BYTES = 128; + +declare global { + /** relay ごとに、偽制御盤を動かしているシリアル */ + var __dwatchFixtureAgents: WeakMap> | undefined; +} + +function running(relay: Relay): Set { + globalThis.__dwatchFixtureAgents ??= new WeakMap(); + let set = globalThis.__dwatchFixtureAgents.get(relay); + if (!set) { + set = new Set(); + globalThis.__dwatchFixtureAgents.set(relay, set); + } + return set; +} + +function fixtureZip(): Uint8Array { + return new Uint8Array(Buffer.from(FIXTURE_EXPORT_ZIP_BASE64, "base64")); +} + +const sha256Hex = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex"); + +async function handle(relay: Relay, serial: string, command: DsAgentCommand): Promise { + switch (command.kind) { + case "list": + relay.submitListResult(serial, command.command_id, { + types: [...fixtureDeviceLogTypes], + items: fixtureDeviceLogItems(new Date()), + truncated: false, + }); + return; + case "export": { + const zip = fixtureZip(); + relay.submitPrepared(serial, command.command_id, { + total_bytes: zip.length, + sha256: sha256Hex(zip), + file_count: command.params.file_ids.length, + }); + return; + } + case "send": { + const exportId = command.params.export_command_id; + const zip = fixtureZip(); + for (let seq = 0, offset = 0; offset < zip.length; seq++, offset += FIXTURE_CHUNK_BYTES) { + const chunk = zip.subarray(offset, offset + FIXTURE_CHUNK_BYTES); + const result = await relay.pushChunk(serial, exportId, seq, chunk, sha256Hex(chunk)); + if (result !== "ok") return; + } + relay.completeExport(serial, exportId, zip.length, sha256Hex(zip)); + return; + } + } +} + +async function loop(relay: Relay, serial: string): Promise { + const signal = new AbortController().signal; + try { + // ★relay が差し替えられたら(テスト)止める★ + while (getRelay() === relay) { + const command = await relay.poll(serial, "fixture", AGENT_POLL_WAIT_SEC, signal); + if (command) void handle(relay, serial, command); + } + } finally { + running(relay).delete(serial); + } +} + +/** + * fixtures のシリアルに偽制御盤を1本だけ動かす。★最初のポーリングは同期的に始まる★ので、 + * 呼んだ直後の `requestList()` / `createExport()` から接続中として扱われる。 + */ +export function ensureFixtureAgent(relay: Relay, serial: string): void { + if (FIXTURE_AGENT_OFFLINE_SERIALS.includes(serial)) return; + if (!dsDevices.some((d) => d.serial_number === serial)) return; + const set = running(relay); + if (set.has(serial)) return; + set.add(serial); + void loop(relay, serial); +} diff --git a/lib/ds-agent/relay-log.ts b/lib/ds-agent/relay-log.ts new file mode 100644 index 0000000..1f0fcfd --- /dev/null +++ b/lib/ds-agent/relay-log.ts @@ -0,0 +1,48 @@ +/** + * 中継(relay)とトークンのストアの出来事をログへ写す(指示書AY 段階10)。 + * + * ★relay・tokens は logger を import しない★(tokens は proxy から、relay は時計を注入してテストする)ので、 + * 受け口だけを `globalThis` に登録しておき、ルートがここを呼んでつなぐ。何度呼んでもよい。 + * ★載せるのは件数・バイト数・固定の理由だけ★(ファイル名・file_id・トークン・制御盤の message は載せない。D12)。 + */ +import { logEvent } from "@/lib/logger"; +import type { SettingsStoreCorruptLogEvent } from "@/lib/logger/events"; +import { setRelayEventSink, type RelayEvent } from "@/lib/ds-agent/relay"; +import { setAgentTokenStoreWarnHandler } from "@/lib/ds-agent/tokens"; + +/** 制御盤から来た code を載せるときの長さの上限(段階10) */ +export const AGENT_CODE_LOG_MAX_LENGTH = 64; + +export function truncateAgentCode(code: string): string { + return code.slice(0, AGENT_CODE_LOG_MAX_LENGTH); +} + +function logRelayEvent(e: RelayEvent): void { + switch (e.event) { + case "ds_agent.connected": + case "ds_agent.disconnected": + logEvent("info", e); + return; + case "ds_logs.export_completed": + logEvent("info", e); + return; + case "ds_logs.export_failed": + logEvent("warn", e.agent_code === undefined ? e : { ...e, agent_code: truncateAgentCode(e.agent_code) }); + return; + } +} + +export function ensureDsAgentLogging(): void { + if (globalThis.__dwatchDsAgentRelayEventSink !== logRelayEvent) setRelayEventSink(logRelayEvent); + if (globalThis.__dwatchAgentTokenStoreWarn === undefined) { + setAgentTokenStoreWarnHandler((info) => { + const event: SettingsStoreCorruptLogEvent = { + event: "settings.store_corrupt", + file_path: info.filePath, + backup_path: info.backupPath, + reason: info.reason, + }; + logEvent("warn", event); + }); + } +} diff --git a/lib/ds-agent/relay.ts b/lib/ds-agent/relay.ts new file mode 100644 index 0000000..67d5185 --- /dev/null +++ b/lib/ds-agent/relay.ts @@ -0,0 +1,778 @@ +/** + * 制御盤(ラズパイ)とブラウザの中継(指示書AY 段階6。D2〜D4・D14・D15・D19・D20・D25)。 + * 期待値の正は指示書AY §3 表5(`__tests__/relay.test.ts`)。 + * + * ★メモリ内だけで動く。サーバーのディスクに書かない★(一時ファイル・OS の一時領域も禁止。D3)。 + * チャンクはメモリで受けて、待っているブラウザの応答(`ReadableStream`)へ流すだけ。 + * ★1プロセス前提★(`globalThis.__dwatchDsAgentRelay` のシングルトン。D4)。`next start` を複数立てる・cluster にすると壊れる。 + * ★再起動で待ち行列と進行中のエクスポートは失われる★。 + * ★リクエストを待たせる時間(一覧15秒・ポーリング25秒・最初のチャンク30秒・チャンクの間隔と保留45秒・準備50秒)は + * どれも nginx の `proxy_read_timeout`(既定60秒)より短い★(設定値は `lib/config/ds-agent.ts`)。 + * ★時刻とタイマーは注入できる★(`RelayDeps`。テストは偽の時計を渡す)。既定のタイマーは `unref()` する。 + * ★`lib/logger` を import しない★。ログは `onEvent` で外に渡す(段階10)。 + * + * 期限の境界: ★一覧の待ちだけは「ちょうどで時間切れ」★(§3 R2。依頼から15秒ちょうどで timeout)。 + * それ以外(未接続 R4・準備 R11・受け取り R14・最初のチャンク R18・保留 R31・間隔 R32・失効 R40)は + * ★ちょうどはまだ有効、1ms 超えたら期限切れ★(タイマーを +1ms にする)。 + * + * エクスポートの状態: 準備中 → 準備完了(受け取り待ち)→ 送信待ち → 流れ中 → 完了 / 失敗 / 取り消し / 失効。 + * ★準備中〜流れ中を「進行中」と数える★(D25 ⑤)。チャンク・complete・送信中の fail は ★export の指示の id★ に来る(D25 ①)。 + */ +import { createHash, randomUUID, type Hash } from "node:crypto"; +import { + AGENT_OFFLINE_AFTER_SEC, + CHUNK_MAX_BYTES, + EXPORT_CLAIM_TIMEOUT_SEC, + EXPORT_CONCURRENCY_PER_DEVICE, + EXPORT_CONCURRENCY_TOTAL, + EXPORT_FIRST_CHUNK_TIMEOUT_SEC, + EXPORT_IDLE_TIMEOUT_SEC, + EXPORT_MAX_BYTES, + EXPORT_PREPARE_TIMEOUT_SEC, + LIST_TIMEOUT_SEC, + PENDING_COMMANDS_PER_DEVICE, +} from "@/lib/config/ds-agent"; +import { toJstIso } from "@/lib/format/date"; +import type { + DsAgentCommand, + DsAgentListResult, + DsAgentListResultData, + DsAgentPrepared, + DsAgentStatus, +} from "@/lib/types"; + +export interface RelayLimits { + offlineAfterSec: number; + listTimeoutSec: number; + exportPrepareTimeoutSec: number; + exportClaimTimeoutSec: number; + exportFirstChunkTimeoutSec: number; + exportIdleTimeoutSec: number; + chunkMaxBytes: number; + exportMaxBytes: number; + exportConcurrencyPerDevice: number; + exportConcurrencyTotal: number; + pendingCommandsPerDevice: number; +} + +export type ExportFailReason = + | "prepare_timeout" + | "first_chunk_timeout" + | "idle_timeout" + | "flow_timeout" + | "agent_error" + | "too_large" + | "chunk_invalid" + | "chunk_out_of_order" + | "complete_invalid" + | "cancelled" + | "expired"; + +/** 中継の出来事(段階10 でログへ写す)。★ファイル名・file_id・トークンを載せない★ */ +export type RelayEvent = + | { event: "ds_agent.connected" | "ds_agent.disconnected"; serial_number: string; agent_version: string | null } + | { + event: "ds_logs.export_failed"; + serial_number: string; + stage: "prepare" | "send"; + reason: ExportFailReason; + bytes_sent: number; + agent_code?: string; + } + | { + event: "ds_logs.export_completed"; + serial_number: string; + file_count: number; + total_bytes: number; + /** 準備の依頼(createExport)から complete まで */ + duration_ms: number; + }; + +export interface RelayDeps { + /** ms */ + now: () => number; + /** 既定は `setTimeout`(★`unref()` する★) */ + setTimer: (fn: () => void, ms: number) => unknown; + clearTimer: (handle: unknown) => void; + /** 既定は `lib/config/ds-agent.ts` */ + limits: RelayLimits; + /** ログの受け口(段階10)。relay は logger を import しない。既定は `setRelayEventSink()` で登録した関数 */ + onEvent?: (e: RelayEvent) => void; +} + +export type ListRequestResult = + | { kind: "ok"; result: DsAgentListResultData } + | { kind: "offline" | "timeout" | "busy" } + | { kind: "agent_error"; code: string }; + +export type CreateExportResult = + | { kind: "prepared"; exportId: string; totalBytes: number; fileCount: number; expiresInSec: number } + | { kind: "offline" | "timeout" } + | { kind: "busy"; code: "export_busy" | "agent_busy" } + | { kind: "agent_error"; code: string }; + +export type ClaimExportResult = + | { kind: "stream"; body: ReadableStream; totalBytes: number; fileCount: number } + | { kind: "not_found" | "timeout" } + | { kind: "agent_error"; code: string }; + +export type SubmitResult = "ok" | "not_found" | "gone"; +export type PushChunkResult = "ok" | "invalid" | "out_of_order" | "cancelled" | "gone" | "not_found"; +export type CompleteResult = "ok" | "invalid" | "cancelled" | "not_found"; + +export interface Relay { + // ── 制御盤側 ── + /** 認証の通ったすべての制御盤のリクエストで呼ぶ(D19)。`agentVersion` は接続の記録に使う(形の合わない値は呼び出し側で null) */ + touch(serial: string, agentVersion: string | null): void; + poll(serial: string, agentVersion: string | null, waitSec: number, signal: AbortSignal): Promise; + submitListResult(serial: string, commandId: string, result: DsAgentListResult): SubmitResult; + /** 一覧の本体の形が不正なとき、ブラウザの一覧を `result_invalid` で終わらせる(指示書BJ J2。list だけ) */ + rejectListResult(serial: string, commandId: string): SubmitResult; + /** ★`total_bytes` が上限を超えても "ok"★(エクスポート側を失敗にする。R13) */ + submitPrepared(serial: string, commandId: string, prepared: DsAgentPrepared): SubmitResult; + failCommand(serial: string, commandId: string, code: string, message: string): SubmitResult; + pushChunk(serial: string, exportCommandId: string, seq: number, bytes: Uint8Array, sha256Hex: string): Promise; + completeExport(serial: string, exportCommandId: string, totalBytes: number, sha256Hex: string): CompleteResult; + // ── ブラウザ側 ── + agentStatus(serial: string): DsAgentStatus; + requestList(serial: string): Promise; + createExport(serial: string, fileIds: readonly string[], signal: AbortSignal): Promise; + claimExport(serial: string, exportId: string, signal: AbortSignal): Promise; +} + +export const DEFAULT_RELAY_LIMITS: RelayLimits = { + offlineAfterSec: AGENT_OFFLINE_AFTER_SEC, + listTimeoutSec: LIST_TIMEOUT_SEC, + exportPrepareTimeoutSec: EXPORT_PREPARE_TIMEOUT_SEC, + exportClaimTimeoutSec: EXPORT_CLAIM_TIMEOUT_SEC, + exportFirstChunkTimeoutSec: EXPORT_FIRST_CHUNK_TIMEOUT_SEC, + exportIdleTimeoutSec: EXPORT_IDLE_TIMEOUT_SEC, + chunkMaxBytes: CHUNK_MAX_BYTES, + exportMaxBytes: EXPORT_MAX_BYTES, + exportConcurrencyPerDevice: EXPORT_CONCURRENCY_PER_DEVICE, + exportConcurrencyTotal: EXPORT_CONCURRENCY_TOTAL, + pendingCommandsPerDevice: PENDING_COMMANDS_PER_DEVICE, +}; + +function defaultSetTimer(fn: () => void, ms: number): unknown { + const handle = setTimeout(fn, ms); + // ★テストとプロセスの終了を止めない★ + handle.unref?.(); + return handle; +} + +function defaultClearTimer(handle: unknown): void { + clearTimeout(handle as ReturnType); +} + +// ── 内部の状態 ── + +interface WaitingPoll { + resolve: (command: DsAgentCommand | null) => void; +} + +interface Device { + serial: string; + lastSeenMs: number | null; + agentVersion: string | null; + /** 最後に onEvent で知らせた接続状態(変化のときだけ知らせる。R39) */ + announcedConnected: boolean; + offlineTimer: unknown; + waitingPoll: WaitingPoll | null; + /** まだ制御盤に渡していない指示 */ + queue: DsAgentCommand[]; +} + +interface ListRecord { + kind: "list"; + id: string; + serial: string; + state: "pending" | "expired"; + resolve: (r: ListRequestResult) => void; + timer: unknown; +} + +type ExportState = + | "preparing" + | "prepared" + | "sending" + | "streaming" + | "done" + | "failed" + | "cancelled" + | "expired" + | "prepare_timeout"; + +const IN_PROGRESS: ReadonlySet = new Set(["preparing", "prepared", "sending", "streaming"]); + +interface ExportRecord { + kind: "export"; + id: string; + exportId: string; + serial: string; + fileCount: number; + createdAtMs: number; + state: ExportState; + resolveCreate: ((r: CreateExportResult) => void) | null; + resolveClaim: ((r: ClaimExportResult) => void) | null; + /** 準備・受け取り・最初のチャンク・間隔の待ちのどれか1つ */ + timer: unknown; + sendId: string | null; + totalBytes: number; + sha256: string; + received: number; + nextSeq: number; + hasher: Hash | null; + stream: ReadableStream | null; + controller: ReadableStreamDefaultController | null; + /** 流量制御で保留中のチャンクの POST */ + pendingPush: ((r: PushChunkResult) => void) | null; + flowTimer: unknown; +} + +interface SendRecord { + kind: "send"; + id: string; + serial: string; +} + +type CommandRecord = ListRecord | ExportRecord | SendRecord; + +export function createRelay(partial: Partial = {}): Relay { + const now = partial.now ?? (() => Date.now()); + const setTimer = partial.setTimer ?? defaultSetTimer; + const clearTimer = partial.clearTimer ?? defaultClearTimer; + const limits = partial.limits ?? DEFAULT_RELAY_LIMITS; + const onEvent = partial.onEvent ?? ((e: RelayEvent) => globalThis.__dwatchDsAgentRelayEventSink?.(e)); + + const devices = new Map(); + const commands = new Map(); + const exportIds = new Map(); + + /** 終わった指示を消すまでの時間(その間は 410 を返せる墓標として残す) */ + const tombstoneMs = () => limits.exportIdleTimeoutSec * 1000; + /** ★ちょうどはまだ有効、1ms 超えたら期限切れ★ */ + const exclusiveMs = (sec: number) => sec * 1000 + 1; + + function emit(e: RelayEvent): void { + onEvent?.(e); + } + + function clear(handle: unknown): void { + if (handle !== null && handle !== undefined) clearTimer(handle); + } + + // ── 接続 ── + + function device(serial: string): Device { + let d = devices.get(serial); + if (!d) { + d = { + serial, + lastSeenMs: null, + agentVersion: null, + announcedConnected: false, + offlineTimer: null, + waitingPoll: null, + queue: [], + }; + devices.set(serial, d); + } + return d; + } + + function isConnected(d: Device | undefined): boolean { + if (!d) return false; + if (d.waitingPoll) return true; + return d.lastSeenMs !== null && now() - d.lastSeenMs <= limits.offlineAfterSec * 1000; + } + + function scheduleOfflineCheck(d: Device): void { + clear(d.offlineTimer); + d.offlineTimer = null; + if (d.lastSeenMs === null) return; + const delay = Math.max(0, d.lastSeenMs + exclusiveMs(limits.offlineAfterSec) - now()); + d.offlineTimer = setTimer(() => { + d.offlineTimer = null; + if (isConnected(d)) { + // ポーリングの最中は、終わったときに見直す + if (!d.waitingPoll) scheduleOfflineCheck(d); + return; + } + if (d.announcedConnected) { + d.announcedConnected = false; + emit({ event: "ds_agent.disconnected", serial_number: d.serial, agent_version: d.agentVersion }); + } + }, delay); + } + + function markSeen(d: Device, agentVersion: string | null): void { + d.lastSeenMs = now(); + if (agentVersion !== null) d.agentVersion = agentVersion; + if (!d.announcedConnected) { + d.announcedConnected = true; + emit({ event: "ds_agent.connected", serial_number: d.serial, agent_version: d.agentVersion }); + } + scheduleOfflineCheck(d); + } + + /** 制御盤に指示を渡す(ポーリングが待っていればすぐ、いなければ待ち行列へ) */ + function deliver(d: Device, command: DsAgentCommand): void { + if (d.waitingPoll) { + d.waitingPoll.resolve(command); + } else { + d.queue.push(command); + } + } + + function removeQueued(serial: string, commandId: string | null): void { + if (commandId === null) return; + const d = devices.get(serial); + if (!d) return; + d.queue = d.queue.filter((c) => c.command_id !== commandId); + } + + function lookup(serial: string, commandId: string): CommandRecord | undefined { + const rec = commands.get(commandId); + // ★別の制御盤の指示は「無い」と同じ★(403 にしない。R35)。照合は `===`(D-4) + return rec && rec.serial === serial ? rec : undefined; + } + + function scheduleRemoval(rec: ExportRecord | ListRecord): void { + setTimer(() => { + commands.delete(rec.id); + if (rec.kind === "export") { + exportIds.delete(rec.exportId); + if (rec.sendId) commands.delete(rec.sendId); + } + }, tombstoneMs()); + } + + // ── エクスポートの終わらせ方 ── + + function countInProgress(serial: string | null): number { + let n = 0; + for (const rec of commands.values()) { + if (rec.kind === "export" && IN_PROGRESS.has(rec.state) && (serial === null || rec.serial === serial)) n++; + } + return n; + } + + function endExport( + rec: ExportRecord, + state: "failed" | "cancelled" | "expired" | "prepare_timeout", + reason: ExportFailReason, + opts: { createResult?: CreateExportResult; claimResult?: ClaimExportResult; agentCode?: string } = {} + ): void { + if (!IN_PROGRESS.has(rec.state)) return; + const previous = rec.state; + rec.state = state; + clear(rec.timer); + rec.timer = null; + clear(rec.flowTimer); + rec.flowTimer = null; + removeQueued(rec.serial, rec.id); + removeQueued(rec.serial, rec.sendId); + + if (previous === "preparing") { + rec.resolveCreate?.(opts.createResult ?? { kind: "agent_error", code: opts.agentCode ?? reason }); + } else if (previous === "sending") { + rec.resolveClaim?.(opts.claimResult ?? { kind: "agent_error", code: opts.agentCode ?? reason }); + } else if (previous === "streaming") { + try { + rec.controller?.error(new Error(`export ${reason}`)); + } catch { + // 既にブラウザが取り消したストリーム + } + } + rec.resolveCreate = null; + rec.resolveClaim = null; + rec.pendingPush?.("cancelled"); + rec.pendingPush = null; + + emit({ + event: "ds_logs.export_failed", + serial_number: rec.serial, + stage: previous === "preparing" ? "prepare" : "send", + reason, + bytes_sent: rec.received, + ...(opts.agentCode !== undefined ? { agent_code: opts.agentCode } : {}), + }); + scheduleRemoval(rec); + } + + /** 流れ中: 次のチャンクを待つ(間隔の上限。R32) */ + function startIdleTimer(rec: ExportRecord): void { + clear(rec.timer); + rec.timer = setTimer(() => { + rec.timer = null; + endExport(rec, "failed", "idle_timeout"); + }, exclusiveMs(limits.exportIdleTimeoutSec)); + } + + function onPull(rec: ExportRecord): void { + if (!rec.pendingPush) return; + const resolve = rec.pendingPush; + rec.pendingPush = null; + clear(rec.flowTimer); + rec.flowTimer = null; + resolve("ok"); + if (rec.state === "streaming") startIdleTimer(rec); + } + + // ── Relay ── + + const relay: Relay = { + touch(serial, agentVersion) { + markSeen(device(serial), agentVersion); + }, + + poll(serial, agentVersion, waitSec, signal) { + const d = device(serial); + markSeen(d, agentVersion); + // ★同じ制御盤の2本目は古い方を 204★(R9) + d.waitingPoll?.resolve(null); + + const queued = d.queue.shift(); + if (queued) return Promise.resolve(queued); + + return new Promise((resolve) => { + let settled = false; + const timer = setTimer(() => finish(null), waitSec * 1000); + const onAbort = () => finish(null); + const waiting: WaitingPoll = { resolve: (command) => finish(command) }; + function finish(command: DsAgentCommand | null): void { + if (settled) return; + settled = true; + clear(timer); + signal.removeEventListener("abort", onAbort); + if (d.waitingPoll === waiting) d.waitingPoll = null; + markSeen(d, null); + resolve(command); + } + d.waitingPoll = waiting; + if (signal.aborted) finish(null); + else signal.addEventListener("abort", onAbort, { once: true }); + }); + }, + + submitListResult(serial, commandId, result) { + const rec = lookup(serial, commandId); + if (!rec || rec.kind !== "list") return "not_found"; + if (rec.state === "expired") return "gone"; + clear(rec.timer); + commands.delete(rec.id); + rec.resolve("error" in result ? { kind: "agent_error", code: result.error.code } : { kind: "ok", result }); + return "ok"; + }, + + rejectListResult(serial, commandId) { + const rec = lookup(serial, commandId); + if (!rec || rec.kind !== "list") return "not_found"; + if (rec.state === "expired") return "gone"; + clear(rec.timer); + commands.delete(rec.id); + rec.resolve({ kind: "agent_error", code: "result_invalid" }); + return "ok"; + }, + + submitPrepared(serial, commandId, prepared) { + const rec = lookup(serial, commandId); + if (!rec || rec.kind !== "export") return "not_found"; + if (rec.state !== "preparing") return "gone"; + if (prepared.total_bytes > limits.exportMaxBytes) { + // ★zip の大きさで断る(D15)。prepared の応答は 204 のまま★ + endExport(rec, "failed", "too_large", { agentCode: "too_large" }); + return "ok"; + } + clear(rec.timer); + rec.state = "prepared"; + rec.totalBytes = prepared.total_bytes; + rec.sha256 = prepared.sha256; + rec.fileCount = prepared.file_count; + rec.timer = setTimer(() => { + rec.timer = null; + endExport(rec, "expired", "expired"); + }, exclusiveMs(limits.exportClaimTimeoutSec)); + const resolve = rec.resolveCreate; + rec.resolveCreate = null; + resolve?.({ + kind: "prepared", + exportId: rec.exportId, + totalBytes: rec.totalBytes, + fileCount: rec.fileCount, + expiresInSec: limits.exportClaimTimeoutSec, + }); + return "ok"; + }, + + failCommand(serial, commandId, code) { + const rec = lookup(serial, commandId); + if (!rec || rec.kind === "send") return "not_found"; + if (rec.kind === "list") { + if (rec.state === "expired") return "gone"; + clear(rec.timer); + commands.delete(rec.id); + rec.resolve({ kind: "agent_error", code }); + return "ok"; + } + if (!IN_PROGRESS.has(rec.state)) return "gone"; + endExport(rec, "failed", "agent_error", { agentCode: code }); + return "ok"; + }, + + async pushChunk(serial, exportCommandId, seq, bytes, sha256Hex) { + const rec = lookup(serial, exportCommandId); + // ★send の指示の id には送られてこない約束(D25 ①)★ + if (!rec || rec.kind !== "export") return "not_found"; + if (rec.state === "expired") return "gone"; + if (!IN_PROGRESS.has(rec.state)) return "cancelled"; + if (rec.state === "preparing" || rec.state === "prepared") return "out_of_order"; + + // ★前のチャンクの 204 を待たずに次を送った(指示書BJ J8)★ + if (rec.pendingPush !== null) { + endExport(rec, "failed", "chunk_out_of_order", { agentCode: "chunk_out_of_order" }); + return "out_of_order"; + } + + if (seq !== rec.nextSeq) { + endExport(rec, "failed", "chunk_out_of_order", { agentCode: "chunk_out_of_order" }); + return "out_of_order"; + } + const actual = createHash("sha256").update(bytes).digest("hex"); + if ( + bytes.length === 0 || + bytes.length > limits.chunkMaxBytes || + actual !== sha256Hex || + rec.received + bytes.length > rec.totalBytes + ) { + endExport(rec, "failed", "chunk_invalid", { agentCode: "chunk_invalid" }); + return "invalid"; + } + + clear(rec.timer); + rec.timer = null; + rec.hasher ??= createHash("sha256"); + rec.hasher.update(bytes); + rec.received += bytes.length; + rec.nextSeq += 1; + + if (rec.state === "sending") { + rec.state = "streaming"; + const resolve = rec.resolveClaim; + rec.resolveClaim = null; + resolve?.({ kind: "stream", body: rec.stream!, totalBytes: rec.totalBytes, fileCount: rec.fileCount }); + } + + const controller = rec.controller!; + controller.enqueue(bytes); + if ((controller.desiredSize ?? 0) > 0) { + startIdleTimer(rec); + return "ok"; + } + // ★流量制御: ブラウザが読む(pull が呼ばれる)まで 204 を返さない(最大45秒。D20)★ + return new Promise((resolve) => { + rec.pendingPush = resolve; + rec.flowTimer = setTimer(() => { + rec.flowTimer = null; + endExport(rec, "failed", "flow_timeout"); + }, exclusiveMs(limits.exportIdleTimeoutSec)); + }); + }, + + completeExport(serial, exportCommandId, totalBytes, sha256Hex) { + const rec = lookup(serial, exportCommandId); + if (!rec || rec.kind !== "export") return "not_found"; + if (!IN_PROGRESS.has(rec.state)) return "cancelled"; + // ★受け取った合計 = prepared の total_bytes = complete の total_bytes、かつ SHA-256 も3つとも一致(D15)★ + const receivedSha = rec.hasher ? rec.hasher.digest("hex") : null; + rec.hasher = null; + if ( + rec.state !== "streaming" || + rec.received !== rec.totalBytes || + totalBytes !== rec.totalBytes || + sha256Hex !== rec.sha256 || + receivedSha !== rec.sha256 + ) { + endExport(rec, "failed", "complete_invalid", { agentCode: "complete_invalid" }); + return "invalid"; + } + rec.state = "done"; + clear(rec.timer); + rec.timer = null; + try { + rec.controller?.close(); + } catch { + // 既にブラウザが取り消したストリーム + } + emit({ + event: "ds_logs.export_completed", + serial_number: rec.serial, + file_count: rec.fileCount, + total_bytes: rec.totalBytes, + duration_ms: now() - rec.createdAtMs, + }); + scheduleRemoval(rec); + return "ok"; + }, + + agentStatus(serial) { + const d = devices.get(serial); + return { + connected: isConnected(d), + last_seen_at: d && d.lastSeenMs !== null ? toJstIso(new Date(d.lastSeenMs)) : null, + agent_version: d ? d.agentVersion : null, + }; + }, + + requestList(serial) { + const d = devices.get(serial); + if (!d || !isConnected(d)) return Promise.resolve({ kind: "offline" }); + if (d.queue.length >= limits.pendingCommandsPerDevice) return Promise.resolve({ kind: "busy" }); + + return new Promise((resolve) => { + const id = randomUUID(); + const rec: ListRecord = { kind: "list", id, serial, state: "pending", resolve, timer: null }; + // ★一覧の待ちだけは「ちょうどで時間切れ」(§3 R2)★ + rec.timer = setTimer(() => { + rec.timer = null; + rec.state = "expired"; + removeQueued(serial, id); + resolve({ kind: "timeout" }); + scheduleRemoval(rec); + }, limits.listTimeoutSec * 1000); + commands.set(id, rec); + // ★エクスポートの進行中でも断らない(D25 ③)★ + deliver(d, { command_id: id, kind: "list", params: {} }); + }); + }, + + createExport(serial, fileIds, signal) { + const d = devices.get(serial); + if (!d || !isConnected(d)) return Promise.resolve({ kind: "offline" }); + if ( + countInProgress(serial) >= limits.exportConcurrencyPerDevice || + countInProgress(null) >= limits.exportConcurrencyTotal + ) { + return Promise.resolve({ kind: "busy", code: "export_busy" }); + } + if (d.queue.length >= limits.pendingCommandsPerDevice) { + return Promise.resolve({ kind: "busy", code: "agent_busy" }); + } + + return new Promise((resolve) => { + const id = randomUUID(); + const rec: ExportRecord = { + kind: "export", + id, + exportId: randomUUID(), + serial, + fileCount: fileIds.length, + createdAtMs: now(), + state: "preparing", + resolveCreate: resolve, + resolveClaim: null, + timer: null, + sendId: null, + totalBytes: 0, + sha256: "", + received: 0, + nextSeq: 0, + hasher: null, + stream: null, + controller: null, + pendingPush: null, + flowTimer: null, + }; + rec.timer = setTimer(() => { + rec.timer = null; + endExport(rec, "prepare_timeout", "prepare_timeout", { createResult: { kind: "timeout" } }); + }, exclusiveMs(limits.exportPrepareTimeoutSec)); + commands.set(id, rec); + exportIds.set(rec.exportId, id); + // ★準備の POST が切れたらエクスポートを取り消す(4-9)★ + const onAbort = () => { + if (rec.state === "preparing") endExport(rec, "cancelled", "cancelled"); + }; + if (signal.aborted) onAbort(); + else signal.addEventListener("abort", onAbort, { once: true }); + if (rec.state !== "preparing") return; + deliver(d, { + command_id: id, + kind: "export", + params: { file_ids: [...fileIds], max_bytes: limits.exportMaxBytes, chunk_bytes: limits.chunkMaxBytes }, + }); + }); + }, + + claimExport(serial, exportId, signal) { + const id = exportIds.get(exportId); + const rec = id === undefined ? undefined : lookup(serial, id); + // ★1回だけ・失効後・シリアル違いは 404★(R14〜R16) + if (!rec || rec.kind !== "export" || rec.state !== "prepared") return Promise.resolve({ kind: "not_found" }); + + return new Promise((resolve) => { + clear(rec.timer); + rec.state = "sending"; + rec.resolveClaim = resolve; + rec.stream = new ReadableStream( + { + start(controller) { + rec.controller = controller; + }, + pull() { + onPull(rec); + }, + cancel() { + endExport(rec, "cancelled", "cancelled"); + }, + }, + { highWaterMark: 1 } + ); + rec.timer = setTimer(() => { + rec.timer = null; + endExport(rec, "failed", "first_chunk_timeout", { claimResult: { kind: "timeout" } }); + }, exclusiveMs(limits.exportFirstChunkTimeoutSec)); + + const sendId = randomUUID(); + rec.sendId = sendId; + commands.set(sendId, { kind: "send", id: sendId, serial }); + // ★ブラウザの切断で取り消す(`cancel()` と `request.signal` の両方。4-9)★ + const onAbort = () => endExport(rec, "cancelled", "cancelled", { claimResult: { kind: "not_found" } }); + if (signal.aborted) { + onAbort(); + return; + } + signal.addEventListener("abort", onAbort, { once: true }); + deliver(device(serial), { command_id: sendId, kind: "send", params: { export_command_id: rec.id } }); + }); + }, + }; + + return relay; +} + +declare global { + /** 中継のシングルトン(★1プロセス前提。D4★)。開発時の HMR で二重にしないため `globalThis` に置く */ + var __dwatchDsAgentRelay: Relay | undefined; + /** 中継の出来事の受け口(`setRelayEventSink()`)。ルートのバンドルが別でも同じ関数を見るよう `globalThis` に置く */ + var __dwatchDsAgentRelayEventSink: ((e: RelayEvent) => void) | undefined; +} + +/** + * `onEvent` を渡さずに作った relay(`getRelay()`)の出来事の受け口を登録する。 + * ★relay は logger を import しない★ので、ログへ写すのはルート側(`lib/ds-agent/relay-log.ts`)の仕事。 + */ +export function setRelayEventSink(sink: ((e: RelayEvent) => void) | undefined): void { + globalThis.__dwatchDsAgentRelayEventSink = sink; +} + +export function getRelay(): Relay { + globalThis.__dwatchDsAgentRelay ??= createRelay(); + return globalThis.__dwatchDsAgentRelay; +} + +/** ★テスト専用★。`null` で次の `getRelay()` が作り直す */ +export function __setRelayForTesting(relay: Relay | null): void { + globalThis.__dwatchDsAgentRelay = relay ?? undefined; +} diff --git a/lib/ds-agent/require-agent.ts b/lib/ds-agent/require-agent.ts new file mode 100644 index 0000000..4cc2be8 --- /dev/null +++ b/lib/ds-agent/require-agent.ts @@ -0,0 +1,31 @@ +/** + * 制御盤向けのルート(`app/api/ds-agent/**`)の先頭で必ず呼ぶ認証(指示書AY 段階5-5。D5・D19)。 + * + * proxy の `authenticateAgent()` が既に通しているが、★ルートでも Bearer を再検証して serial を得る★ + * (proxy から後段へ serial をヘッダで渡さない。ヘッダは詐称できるため。D5)。 + * 通ったら relay の `touch()` で最終接続日時と版を更新する(★すべての制御盤のリクエストで。表5 R7・D19・指示書BJ J9★)。 + */ +import { NextResponse } from "next/server"; +import { getRelay } from "@/lib/ds-agent/relay"; +import { verifyAgentAuthorization } from "@/lib/ds-agent/tokens"; +import type { ApiError } from "@/lib/types"; + +export type RequireAgentResult = { ok: true; serial: string } | { ok: false; response: Response }; + +/** 合わなければ `null`(拒否しない)。接続の記録に使う(指示書BJ J9) */ +const AGENT_VERSION = /^[0-9A-Za-z.+-]{1,32}$/; + +export function agentVersionOf(request: Request): string | null { + const raw = request.headers.get("x-agent-version"); + return raw !== null && AGENT_VERSION.test(raw) ? raw : null; +} + +export async function requireAgent(request: Request): Promise { + const serial = await verifyAgentAuthorization(request.headers.get("authorization")); + if (serial === null) { + const body: ApiError = { error: { code: "unauthorized", message: "認証が必要です", details: null } }; + return { ok: false, response: NextResponse.json(body, { status: 401 }) }; + } + getRelay().touch(serial, agentVersionOf(request)); + return { ok: true, serial }; +} diff --git a/lib/ds-agent/tokens.ts b/lib/ds-agent/tokens.ts new file mode 100644 index 0000000..0c857f4 --- /dev/null +++ b/lib/ds-agent/tokens.ts @@ -0,0 +1,185 @@ +import { createHash, randomBytes, timingSafeEqual } from "node:crypto"; +import { promises as fs } from "node:fs"; +import path from "node:path"; +import { toJstIso } from "@/lib/format/date"; +import { + createJsonStore, + type JsonStore, + type JsonStoreWarning, +} from "@/lib/settings/json-store"; + +/** + * 制御盤トークン(指示書AY 段階4。D7・D16・D22)。期待値の正は指示書AY §3 表3(`__tests__/tokens.test.ts`)。 + * + * - 制御盤ごとに32バイトの乱数を base64url(43文字)。★ストアには SHA-256 の16進だけを書く★。 + * ★生の値は `issueAgentToken()` の戻り値で1回だけ★(保存しない・ログに出さない)。 + * - 保存先は DFA 独自ストア `data/settings/ds-agent-tokens.json`。★メインDBには書かない★(`CLAUDE.md` §7)。 + * - ★`lib/logger` を import しない★(proxy から呼ぶため。`json-store.ts` と同じ考え方)。壊れたストアの警告は `onWarn` で外に渡す。 + * - 読みは `fs.stat` の `mtimeMs` を見てメモリにキャッシュする(D22。表3 K12)。発行・失効の後は自分のキャッシュを捨てる。 + * - ★シリアルの実在(DB)はここでは見ない★(ルートが `dsDeviceExists()` で見る)。照合は JS の `===`(D-4)。 + * - 書き込みは `json-store` の待ち行列で直列化されるが、★読み → 書きの後勝ちは防がない★ + * (同じ制御盤の発行と失効を同時に押すと、結果はどちらか一方になる。P1 は社内の少人数なので許容)。 + */ + +export interface AgentTokenEntry { + token_sha256: string; + issued_at: string; +} + +/** `serial_number` → トークンのハッシュと発行日時 */ +export type AgentTokens = Record; + +/** ★ストアとパスを組で持つ★(`JsonStore` はパスを返さないので、mtime を見るためにパスを別に持つ。M13) */ +export interface AgentTokenStoreTarget { + store: JsonStore; + filePath: string; +} + +const BEARER = /^Bearer ([A-Za-z0-9_-]{43})$/; +const SHA256_HEX = /^[0-9a-f]{64}$/; + +/** ★呼び出し時に組み立てる★(`ds-device-notes.ts` の既定パスと同じ理由) */ +function defaultAgentTokenStorePath(): string { + return path.join(process.cwd(), "data", "settings", "ds-agent-tokens.json"); +} + +declare global { + /** 壊れたストアの警告の受け口(`setAgentTokenStoreWarnHandler()`)。ルートのバンドルが別でも同じ関数を見るよう `globalThis` に置く */ + var __dwatchAgentTokenStoreWarn: ((info: JsonStoreWarning) => void) | undefined; +} + +/** + * 既定のストアの壊れた警告の受け口を登録する(★ここは logger を import しない★ので、 + * `settings.store_corrupt` を出すのはルート側の仕事。`lib/ds-agent/relay-log.ts`)。 + */ +export function setAgentTokenStoreWarnHandler(handler: ((info: JsonStoreWarning) => void) | undefined): void { + globalThis.__dwatchAgentTokenStoreWarn = handler; +} + +export function createAgentTokenStore( + filePath: string = defaultAgentTokenStorePath(), + onWarn: (info: JsonStoreWarning) => void = (info) => globalThis.__dwatchAgentTokenStoreWarn?.(info) +): AgentTokenStoreTarget { + return { + store: createJsonStore({ + filePath, + empty: () => Object.create(null) as AgentTokens, + onWarn, + }), + filePath, + }; +} + +let target: AgentTokenStoreTarget | null = null; + +interface VerifyCache { + filePath: string; + mtimeMs: number | null; + entries: ReadonlyArray; +} + +let cache: VerifyCache | null = null; + +function storeTarget(): AgentTokenStoreTarget { + target ??= createAgentTokenStore(); + return target; +} + +/** ★テスト専用★。★ストアとパスを一緒に差し替える★(`null` で既定へ戻す。キャッシュも捨てる) */ +export function __setAgentTokenStoreForTesting(next: AgentTokenStoreTarget | null): void { + target = next; + cache = null; +} + +function sha256Hex(value: string): string { + return createHash("sha256").update(value, "utf8").digest("hex"); +} + +/** 32バイトの乱数を base64url。ストアには SHA-256 の16進だけを書く。★生の値は戻り値で1回だけ★ */ +export async function issueAgentToken( + serialNumber: string +): Promise<{ token: string; issued_at: string }> { + const { store } = storeTarget(); + const token = randomBytes(32).toString("base64url"); + const issued_at = toJstIso(new Date()); + const tokens = await store.read(); + tokens[serialNumber] = { token_sha256: sha256Hex(token), issued_at }; + await store.write(tokens); + cache = null; + return { token, issued_at }; +} + +/** 失効させる。★無ければ false★(ルートは未発行でも 204。P2) */ +export async function revokeAgentToken(serialNumber: string): Promise { + const { store } = storeTarget(); + const tokens = await store.read(); + if (!Object.prototype.hasOwnProperty.call(tokens, serialNumber)) return false; + delete tokens[serialNumber]; + await store.write(tokens); + cache = null; + return true; +} + +/** 発行日時(P1)。未発行・壊れた行なら `null`。★トークン・ハッシュは返さない★ */ +export async function agentTokenIssuedAt(serialNumber: string): Promise { + const tokens = await storeTarget().store.read(); + if (!Object.prototype.hasOwnProperty.call(tokens, serialNumber)) return null; + const entry: unknown = tokens[serialNumber]; + if (!isValidEntry(entry)) return null; + return entry.issued_at; +} + +function isValidEntry(entry: unknown): entry is AgentTokenEntry { + return ( + typeof entry === "object" && + entry !== null && + typeof (entry as AgentTokenEntry).token_sha256 === "string" && + SHA256_HEX.test((entry as AgentTokenEntry).token_sha256) && + typeof (entry as AgentTokenEntry).issued_at === "string" + ); +} + +async function currentMtime(filePath: string): Promise { + try { + return (await fs.stat(filePath)).mtimeMs; + } catch (err) { + if (err instanceof Error && "code" in err && (err as NodeJS.ErrnoException).code === "ENOENT") return null; + throw err; + } +} + +/** mtime が変わっていなければメモリのキャッシュを返す(D22)。★壊れた1件は無視し例外にしない★(表3 K11) */ +async function loadEntries(): Promise { + const { store, filePath } = storeTarget(); + const mtimeMs = await currentMtime(filePath); + if (cache && cache.filePath === filePath && cache.mtimeMs === mtimeMs) return cache.entries; + + const tokens = mtimeMs === null ? (Object.create(null) as AgentTokens) : await store.read(); + const entries: Array = []; + for (const serial of Object.keys(tokens)) { + const entry: unknown = tokens[serial]; + if (!isValidEntry(entry)) continue; + entries.push([serial, Buffer.from(entry.token_sha256, "hex")]); + } + cache = { filePath, mtimeMs, entries }; + return entries; +} + +/** + * `Authorization` ヘッダの生値 → serial または `null`。 + * ★形は厳密に `Bearer ` + 43文字(表3 K5・K6)★。形が違えばストアを読まずに `null`。 + * ★入力の SHA-256 と全件を timingSafeEqual で比べ、見つかっても途中で抜けない★。 + */ +export async function verifyAgentAuthorization(header: string | null): Promise { + if (!header) return null; + const match = BEARER.exec(header); + if (!match) return null; + const inputHash = createHash("sha256").update(match[1], "utf8").digest(); + + const entries = await loadEntries(); + let found: string | null = null; + for (const [serial, hash] of entries) { + if (timingSafeEqual(inputHash, hash) && found === null) found = serial; + } + return found; +} diff --git a/lib/ds-logs/__tests__/file-ids-of-bytes.ts b/lib/ds-logs/__tests__/file-ids-of-bytes.ts new file mode 100644 index 0000000..c34447f --- /dev/null +++ b/lib/ds-logs/__tests__/file-ids-of-bytes.ts @@ -0,0 +1,17 @@ +/** + * 指示書BK K7 用。`file_ids` を JSON にしたときちょうど `n` バイト(UTF-8)になる配列を作る。 + * ★数字の直書きにしない★(G3 (b) が定数を変えたとき赤になるため)。 + */ +import { FILE_ID_MAX_LENGTH } from "@/lib/ds-logs/validate"; + +export function fileIdsOfJsonBytes(n: number): string[] { + const per = FILE_ID_MAX_LENGTH + 3; // 引用符2つ + カンマ。配列の括弧は先頭の −1 で吸収 + const k = Math.ceil((n - 1) / per); + const ids: string[] = []; + for (let i = 0; i < k - 1; i++) { + ids.push(`${i}-`.padEnd(FILE_ID_MAX_LENGTH, "x")); + } + const lastLen = n - 1 - (k - 1) * per - 3; + ids.push(`${k - 1}-`.padEnd(lastLen, "x")); + return ids; +} diff --git a/lib/ds-logs/__tests__/file-name.test.ts b/lib/ds-logs/__tests__/file-name.test.ts new file mode 100644 index 0000000..fcb7b5a --- /dev/null +++ b/lib/ds-logs/__tests__/file-name.test.ts @@ -0,0 +1,23 @@ +/** + * 指示書AY §3 表6(`lib/ds-logs/file-name.ts` の `exportFileName(serial, now)`)。 + * ブリーフ §5 と監督判断 第2ラウンドから本書で具体化。★P1・P2 の行は §11 Q14・Q15 の採用で確定★。 + * + * | # | 入力 | 期待 | + * | 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) | + */ +import { describe, expect, it } from "vitest"; +import { exportFileName } from "@/lib/ds-logs/file-name"; + +describe("exportFileName() — 表6", () => { + it.each<[string, string, string, string]>([ + ["N1", "DS-002", "2026-10-19T12:34:56+09:00", "DS-002_logs_20261019-123456.zip"], + ["N2 同じ瞬間を Z で(JST で名付ける)", "DS-002", "2026-10-19T03:34:56Z", "DS-002_logs_20261019-123456.zip"], + ["N3 日付が変わる", "DS-002", "2026-10-19T15:00:00Z", "DS-002_logs_20261020-000000.zip"], + ["N4 [A-Za-z0-9._-] 以外は _", 'DS 002/"x"', "2026-10-19T12:34:56+09:00", "DS_002__x__logs_20261019-123456.zip"], + ])("%s", (_label, serial, nowIso, expected) => { + expect(exportFileName(serial, new Date(nowIso))).toBe(expected); + }); +}); diff --git a/lib/ds-logs/__tests__/modal-text.test.ts b/lib/ds-logs/__tests__/modal-text.test.ts new file mode 100644 index 0000000..428192d --- /dev/null +++ b/lib/ds-logs/__tests__/modal-text.test.ts @@ -0,0 +1,114 @@ +/** + * 指示書BJ §4 表E(`lib/ds-logs/modal-text.ts`)。`t` は渡した文字列をそのまま返す。 + */ +import { describe, expect, it } from "vitest"; +import { ApiFetchError } from "@/lib/api/client"; +import { apiErrorMessage } from "@/lib/ds-logs/modal-text"; +import { EXPORT_FILE_IDS_TOO_LARGE_MESSAGE } from "@/lib/ds-logs/validate"; +import { translate } from "@/lib/i18n"; + +const t = (text: string) => text; + +describe("apiErrorMessage() — 表E", () => { + it("E1 status 0・cause は DOMException AbortError → null", () => { + const e = new ApiFetchError({ + status: 0, + code: null, + serverMessage: null, + cause: new DOMException("x", "AbortError"), + }); + expect(apiErrorMessage(e, t)).toBeNull(); + }); + + it("E2 status 0・cause は name=AbortError の Error → null", () => { + const e = new ApiFetchError({ + status: 0, + code: null, + serverMessage: null, + cause: Object.assign(new Error("x"), { name: "AbortError" }), + }); + expect(apiErrorMessage(e, t)).toBeNull(); + }); + + it("E3 status 0・cause は TypeError → 通信失敗", () => { + const e = new ApiFetchError({ + status: 0, + code: null, + serverMessage: null, + cause: new TypeError("Failed to fetch"), + }); + expect(apiErrorMessage(e, t)).toBe("通信に失敗しました。時間をおいて再度お試しください"); + }); + + it("E4 status 0・cause なし → E3 と同じ", () => { + const e = new ApiFetchError({ status: 0, code: null, serverMessage: null }); + expect(apiErrorMessage(e, t)).toBe("通信に失敗しました。時間をおいて再度お試しください"); + }); + + it("E5 502 agent_error・result_invalid", () => { + const e = new ApiFetchError({ + status: 502, + code: "agent_error", + serverMessage: "制御盤でエラーが発生しました", + details: { agent_code: ["result_invalid"] }, + }); + expect(apiErrorMessage(e, t)).toBe("制御盤の結果の形が不正です"); + }); + + it("E6 502 agent_error・too_large", () => { + const e = new ApiFetchError({ + status: 502, + code: "agent_error", + serverMessage: "制御盤でエラーが発生しました", + details: { agent_code: ["too_large"] }, + }); + expect(apiErrorMessage(e, t)).toBe("選んだファイルが大きすぎます(1回 100 MB まで)"); + }); + + it("E7 502 agent_error・未知の code", () => { + const e = new ApiFetchError({ + status: 502, + code: "agent_error", + serverMessage: "制御盤でエラーが発生しました", + details: { agent_code: ["x"] }, + }); + expect(apiErrorMessage(e, t)).toBe("制御盤でエラーが発生しました(x)"); + }); + + it("E8 409 agent_offline・last_seen_at 空", () => { + const e = new ApiFetchError({ + status: 409, + code: "agent_offline", + serverMessage: "制御盤が接続されていません", + details: { last_seen_at: [] }, + }); + expect(apiErrorMessage(e, t)).toBe("制御盤が接続されていません(最終接続: -)"); + }); + + it("E9 ApiFetchError でない → 既定文言", () => { + expect(apiErrorMessage(new Error("x"), t)).toBe("データの取得に失敗しました"); + }); + + it("K7-6 422 validation_failed・file_ids 合計超過", () => { + const e = new ApiFetchError({ + status: 422, + code: "validation_failed", + serverMessage: EXPORT_FILE_IDS_TOO_LARGE_MESSAGE, + details: { file_ids: ["x"] }, + }); + expect(apiErrorMessage(e, t)).toBe(EXPORT_FILE_IDS_TOO_LARGE_MESSAGE); + expect(apiErrorMessage(e, (s) => translate("en", s))).toBe( + "Too many files selected. Please select fewer files" + ); + }); + + it("K7-7 422 validation_failed・他の文言はそのまま", () => { + const e = new ApiFetchError({ + status: 422, + code: "validation_failed", + serverMessage: "file_idsの形式が不正です", + details: { file_ids: ["x"] }, + }); + expect(apiErrorMessage(e, t)).toBe("file_idsの形式が不正です"); + }); +}); diff --git a/lib/ds-logs/__tests__/presets.test.ts b/lib/ds-logs/__tests__/presets.test.ts new file mode 100644 index 0000000..7506b5b --- /dev/null +++ b/lib/ds-logs/__tests__/presets.test.ts @@ -0,0 +1,57 @@ +/** + * 指示書AY §3 表2(`lib/ds-logs/presets.ts`。now = 2026-10-19T12:00:00+09:00)。 + * ブリーフ §5 と監督判断 第2ラウンドから本書で具体化。★P1・P2 の行は §11 Q14・Q15 の採用で確定★。 + * + * | # | 入力 | from | to | + * | T1 | 1h(直近1時間) | 2026-10-19T11:00:00+09:00 | なし(D23) | + * | T2 | 3h(直近3時間) | 2026-10-19T09:00:00+09:00 | なし | + * | T3 | 24h(直近24時間) | 2026-10-18T12:00:00+09:00 | なし | + * | 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 で渡す(03:00Z)・1h | 2026-10-19T11:00:00+09:00 | なし(実行環境のタイムゾーンに依存しない) | + */ +import { describe, expect, it } from "vitest"; +import { dateRangeError, periodFromDates, periodFromPreset } from "@/lib/ds-logs/presets"; + +const NOW = new Date("2026-10-19T12:00:00+09:00"); + +describe("periodFromPreset() — 表2 T1〜T4・T10", () => { + it.each<[string, "1h" | "3h" | "24h" | "all", Date, string | null]>([ + ["T1 直近1時間", "1h", NOW, "2026-10-19T11:00:00+09:00"], + ["T2 直近3時間", "3h", NOW, "2026-10-19T09:00:00+09:00"], + ["T3 直近24時間", "24h", NOW, "2026-10-18T12:00:00+09:00"], + ["T4 全て", "all", NOW, null], + ["T10 now を UTC で渡しても JST で返す", "1h", new Date("2026-10-19T03:00:00Z"), "2026-10-19T11:00:00+09:00"], + ])("%s", (_label, preset, now, expectedFrom) => { + expect(periodFromPreset(preset, now)).toEqual({ from: expectedFrom, to: null }); + }); +}); + +describe("periodFromDates() — 表2 T5〜T9", () => { + it.each<[string, string | null, string | null, { from: string | null; to: string | null } | null]>([ + ["T5 終了日を含む", "2026-10-18", "2026-10-19", { from: "2026-10-18T00:00:00+09:00", to: "2026-10-20T00:00:00+09:00" }], + ["T6 月またぎ", "2026-10-31", "2026-10-31", { from: "2026-10-31T00:00:00+09:00", to: "2026-11-01T00:00:00+09:00" }], + ["T7 開始だけ", "2026-10-19", null, { from: "2026-10-19T00:00:00+09:00", to: null }], + ["T8 終了だけ", null, "2026-10-18", { from: null, to: "2026-10-19T00:00:00+09:00" }], + ["T9 逆(開始 > 終了)は不正", "2026-10-19", "2026-10-18", null], + ])("%s", (_label, start, end, expected) => { + expect(periodFromDates(start, end)).toEqual(expected); + }); +}); + +describe("dateRangeError() — 指示書BJ 表D", () => { + it.each<[string, string | null, string | null, "reversed" | "invalid" | null]>([ + ["D1 逆順", "2026-10-19", "2026-10-18", "reversed"], + ["D2 正常", "2026-10-18", "2026-10-19", null], + ["D3 同じ日", "2026-10-18", "2026-10-18", null], + ["D4 存在しない日付", "2026-02-30", "2026-03-01", "invalid"], + ["D5 開始 null", null, "2026-10-18", null], + ["D6 終了 null", "2026-10-19", null, null], + ])("%s", (_label, start, end, expected) => { + expect(dateRangeError(start, end)).toBe(expected); + }); +}); diff --git a/lib/ds-logs/__tests__/query.test.ts b/lib/ds-logs/__tests__/query.test.ts new file mode 100644 index 0000000..def50c0 --- /dev/null +++ b/lib/ds-logs/__tests__/query.test.ts @@ -0,0 +1,154 @@ +/** + * 指示書AY §3 表1(`lib/ds-logs/query.ts` の `queryDeviceLogs()`)。 + * ブリーフ §5 と監督判断 第2ラウンドから本書で具体化。★P1・P2 の行は §11 Q14・Q15 の採用で確定★。 + * + * ★表1 は純粋関数の入力なので架空の名前のまま★(§3 冒頭・§11-4 M6。実機の種類・name の形は fixtures でだけ使う)。 + * + * types(制御盤から来た順)= app「アプリ」/ comm「通信」/ sys「システム」。now = 2026-10-19T12:00:00+09:00 + * + * | file_id | name | type | size | 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 | + * | Q2 | types=app | f8, f1, f2, f7 | 4 | + * | Q3 | types=app,sys | f8, f5, f1, f2, f7, f6 | 6 | + * | Q4 | types=xyz | (空) | 0 | + * | Q5 | types=app,xyz | f8, f1, f2, f7 | 4 | + * | 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=10-18T12:00+09:00 & to=10-19T12:00+09:00 | f5, f1, f3, f2, f7, f4 | 6 | + * | Q9 | from=10-19T02:00:00+09:00 & to=10-19T02:00:01+09:00 | f7 | 1 |(比較は瞬間) + * | Q10 | from=2026-10-19T02:30:00Z | f8, f5 | 2 | + * | Q11 | sort=type_date_desc | f8, f1, f2, f7, f3, f4, f5, f6 | 8 | + * | 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(直近24時間) | f8, f5, f1, f3, f2, f7, f4 | 7 | + * | Q14 | Q1 を perPage=3, page=1 | f8, f5, f1 | 8 | + * | Q15 | Q1 を perPage=3, page=3 | f4, f6 | 8 | + * | Q16 | Q1 を perPage=3, page=4 | (空) | 8 | + * | Q17 | 出力の modified_at | f7 は 2026-10-19T02:00:00+09:00、他は入力どおり | — | + */ +import { describe, expect, it } from "vitest"; +import { deviceLogTypesParam, queryDeviceLogs, type DeviceLogQuery } from "@/lib/ds-logs/query"; +import type { DsLogFile, DsLogType } from "@/lib/types"; + +const TYPES: readonly DsLogType[] = [ + { key: "app", label: "アプリ" }, + { key: "comm", label: "通信" }, + { key: "sys", label: "システム" }, +]; + +const ITEMS: readonly DsLogFile[] = [ + { file_id: "f1", name: "app_20261019_1100.log", type: "app", size_bytes: 1000, modified_at: "2026-10-19T11:00:00+09:00" }, + { file_id: "f2", name: "app_20261019_0900.log", type: "app", size_bytes: 2000, modified_at: "2026-10-19T09:00:00+09:00" }, + { file_id: "f3", name: "comm_20261019_1100.log", type: "comm", size_bytes: 3000, modified_at: "2026-10-19T11:00:00+09:00" }, + { file_id: "f4", name: "comm_20261018_1200.log", type: "comm", size_bytes: 4000, modified_at: "2026-10-18T12:00:00+09:00" }, + { file_id: "f5", name: "sys_20261019_1130.log", type: "sys", size_bytes: 5000, modified_at: "2026-10-19T11:30:00+09:00" }, + { file_id: "f6", name: "sys_20261018_1159.log", type: "sys", size_bytes: 6000, modified_at: "2026-10-18T11:59:59+09:00" }, + { file_id: "f7", name: "app_20261019_0200.log", type: "app", size_bytes: 7000, modified_at: "2026-10-18T17:00:00Z" }, + { file_id: "f8", name: "app_20261019_1200.log", type: "app", size_bytes: 8000, modified_at: "2026-10-19T12:00:00+09:00" }, +]; + +/** 全件が1ページに収まる大きさ(表1 の Q1〜Q13 はページ分けを見ない) */ +const ALL = 1000; + +function q(overrides: Partial = {}): DeviceLogQuery { + return { + types: null, + from: null, + to: null, + sort: "date_desc", + page: 1, + perPage: ALL, + ...overrides, + }; +} + +const at = (iso: string): number => Date.parse(iso); + +describe("queryDeviceLogs() — 表1", () => { + it.each<[string, DeviceLogQuery, string[], number]>([ + ["Q1 指定なし(新しい順・同時刻は name 昇順)", q(), ["f8", "f5", "f1", "f3", "f2", "f7", "f4", "f6"], 8], + ["Q2 types=app", q({ types: ["app"] }), ["f8", "f1", "f2", "f7"], 4], + ["Q3 types=app,sys", q({ types: ["app", "sys"] }), ["f8", "f5", "f1", "f2", "f7", "f6"], 6], + ["Q4 types=xyz(未知のキーは0件。D23)", q({ types: ["xyz"] }), [], 0], + ["Q5 types=app,xyz(D23)", q({ types: ["app", "xyz"] }), ["f8", "f1", "f2", "f7"], 4], + ["Q6 ちょうど from は入る", q({ from: at("2026-10-19T11:00:00+09:00") }), ["f8", "f5", "f1", "f3"], 4], + ["Q7 ちょうど to は入らない", q({ to: at("2026-10-19T11:00:00+09:00") }), ["f2", "f7", "f4", "f6"], 4], + [ + "Q8 f4 は入る・f8 は入らない・f6 は前", + q({ from: at("2026-10-18T12:00:00+09:00"), to: at("2026-10-19T12:00:00+09:00") }), + ["f5", "f1", "f3", "f2", "f7", "f4"], + 6, + ], + [ + "Q9 比較は瞬間(Date.parse)。文字列で比べない", + q({ from: at("2026-10-19T02:00:00+09:00"), to: at("2026-10-19T02:00:01+09:00") }), + ["f7"], + 1, + ], + ["Q10 from 側の Z も瞬間で", q({ from: at("2026-10-19T02:30:00Z") }), ["f8", "f5"], 2], + [ + "Q11 type_date_desc(types 配列の順 → 新しい順 → name 昇順)", + q({ sort: "type_date_desc" }), + ["f8", "f1", "f2", "f7", "f3", "f4", "f5", "f6"], + 8, + ], + [ + "Q12 クエリの順ではなく制御盤の types の順", + q({ sort: "type_date_desc", types: ["comm", "app"] }), + ["f8", "f1", "f2", "f7", "f3", "f4"], + 6, + ], + [ + "Q13 直近24時間(f6 は入らない)", + q({ from: at("2026-10-18T12:00:00+09:00") }), + ["f8", "f5", "f1", "f3", "f2", "f7", "f4"], + 7, + ], + ["Q14 perPage=3, page=1", q({ perPage: 3, page: 1 }), ["f8", "f5", "f1"], 8], + ["Q15 perPage=3, page=3(最終ページの端数)", q({ perPage: 3, page: 3 }), ["f4", "f6"], 8], + ["Q16 perPage=3, page=4(範囲外は空配列・total は正しい)", q({ perPage: 3, page: 4 }), [], 8], + ])("%s", (_label, query, expectedIds, expectedTotal) => { + const result = queryDeviceLogs(TYPES, ITEMS, query); + expect(result.items.map((i) => i.file_id)).toEqual(expectedIds); + expect(result.total).toBe(expectedTotal); + }); + + it("Q17 出力の modified_at は toJstIso で JST に揃える(f7 だけ変わる)", () => { + const result = queryDeviceLogs(TYPES, ITEMS, q()); + const byId = new Map(result.items.map((i) => [i.file_id, i.modified_at])); + expect(byId.get("f7")).toBe("2026-10-19T02:00:00+09:00"); + for (const item of ITEMS) { + if (item.file_id === "f7") continue; + expect(byId.get(item.file_id)).toBe(item.modified_at); + } + }); + + it("入力の配列を書き換えない", () => { + const items = ITEMS.map((i) => ({ ...i })); + const snapshot = JSON.stringify(items); + queryDeviceLogs(TYPES, items, q({ sort: "type_date_desc" })); + expect(JSON.stringify(items)).toBe(snapshot); + }); +}); + +describe("deviceLogTypesParam() — 指示書BJ 表Q", () => { + it.each<[string, readonly string[], ReadonlySet | null, string | null]>([ + ["Q1 selected null", ["a", "b", "c"], null, null], + ["Q2 全種類", ["a", "b", "c"], new Set(["a", "b", "c"]), null], + ["Q3 一部(known の順)", ["a", "b", "c"], new Set(["a", "b"]), "a,b"], + ["Q4 飛び(known の順)", ["a", "b", "c"], new Set(["c", "a"]), "a,c"], + ["Q5 件数同じだが known を含まない", ["a", "b"], new Set(["a", "x"]), "a,x"], + ["Q6 種類が無い", [], new Set(), null], + ])("%s", (_label, known, selected, expected) => { + expect(deviceLogTypesParam(known, selected)).toBe(expected); + }); +}); diff --git a/lib/ds-logs/__tests__/validate.test.ts b/lib/ds-logs/__tests__/validate.test.ts new file mode 100644 index 0000000..ddb20eb --- /dev/null +++ b/lib/ds-logs/__tests__/validate.test.ts @@ -0,0 +1,539 @@ +/** + * 指示書AY 段階3-3 の V1〜V10・X1〜X6(クエリ・本体・パスの検証)と 段階7-2 の W1〜W11(制御盤の一覧の結果の検証)、 + * あわせて prepared / complete / fail の本体の検証(§8・段階7-2 の末尾・表5 R43)。 + * ブリーフ §5 と監督判断 第2ラウンドから本書で具体化。★P1・P2 の行は §11 Q14・Q15 の採用で確定★。 + * ★V・W は 2026-11-05 に監督が改めた後の表が正(§11-4 M7・M8)★。 + * + * ── 段階3-3(ブラウザ向けは 422 `validation_failed`)── + * | V1 | パラメータなし | { types: null, from: null, to: null, sort: "date_desc", page: 1, perPage: 200 } | + * | V2 | from=2026-10-19(日付だけ)/from=2026-10-19T11:00:00(オフセットなし)/from=abc | 422 | + * | V2b | from=2026-02-30T00:00:00+09:00/to=2026-02-29T00:00:00Z(平年)/from=2026-13-01T00:00:00+09:00 | 422 | + * | V2c | from=2026-10-19T02:30:00Z/from=2026-10-19T02:30:00+00:00 | 通る | + * | V3 | from > to | 422。from = to は通る(0件) | + * | V4 | types=・types=%20・from=・to=・sort=(空・空白だけ) | 省略と同じ(V1 と同じ値) | + * | V4b | types=app,,sys/types=app,(空でない並びの中の空の要素) | 422 | + * | V5 | sort=name | 422 | + * | V6 | per_page=30→30・500→200・0→1・abc→200・page=0→1・1.5→1・abc→1 | 422 にせず丸める | + * | V7 | POST の本体 { file_ids: [] } / 501件 / 重複あり / 要素が文字列でない / 8193文字 | 422 | + * | V8 | POST の本体が JSON だがオブジェクトでない(null・[]・1・"x") | 422「JSONの形式が不正です」 | + * | V9 | POST の本体が JSON でない({) | 422「JSONの形式が不正です」 | + * | V10 | wait=0・wait=26・wait=abc(制御盤向け) | 422(丸めない) | + * + * ── パスパラメータ(M8)── + * | X1 | export_id / command_id が randomUUID の形(36文字) | 通る | + * | X2 | 空文字・PATH_*_MAX_LENGTH + 1 文字 | 422(入力値を message / details に入れない) | + * | X3 | export_id=abc | 通る(文字種は絞らない) | + * | X4 | seq=0・1・10 | 通る | + * | X5 | seq=-1・01・1.0・abc・1e3・空 | 422 | + * | X6 | seq=1234567890(10桁) | 422(上限は9桁) | + * + * ── 段階7-2(制御盤の一覧の結果。不正なら制御盤には 400 `result_invalid`)── + * | W1 | 正しい(types 3・items 8・truncated: false) | 受理 | + * | W2 | types の key が重複 | 不正 | + * | W3 | items[].type が types に無い | 不正 | + * | W4 | modified_at にオフセットが無い | 不正 | + * | W4b | modified_at が存在しない日付(2026-02-30T00:00:00+09:00) | 不正 | + * | W5 | size_bytes が負・小数・文字列 | 不正 | + * | W6 | file_id が重複 | 不正 | + * | W7 | items が LIST_RESULT_MAX_ITEMS + 1 件 | 不正 | + * | W8 | 本体が LIST_RESULT_MAX_BYTES を超える | 不正(読んだ長さで判定) | + * | W9 | { error: { code, message } } | failCommand と同じ扱い | + * | W10 | 本体が JSON だがオブジェクトでない(null・[]・1・"x") | 不正 | + * | W11 | items[].name が api_client/api_client.log.2026-10-18 の形 | 受理(/ を理由に弾かない) | + */ +import { randomUUID } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { + DS_LOG_DEFAULT_PER_PAGE, + EXPORT_FILE_IDS_MAX_BYTES, + EXPORT_MAX_FILES, + LIST_RESULT_MAX_BYTES, + LIST_RESULT_MAX_ITEMS, + AGENT_POLL_WAIT_SEC, + PATH_COMMAND_ID_MAX_LENGTH, + PATH_EXPORT_ID_MAX_LENGTH, +} from "@/lib/config/ds-agent"; +import { fileIdsOfJsonBytes } from "@/lib/ds-logs/__tests__/file-ids-of-bytes"; +import { + EXPORT_FILE_IDS_TOO_LARGE_MESSAGE, + parseAgentComplete, + parseAgentFail, + parseAgentListResult, + parseAgentPollWait, + parseAgentPrepared, + parseDeviceLogQuery, + parseExportRequest, + parsePathCommandId, + parsePathExportId, + parsePathSeq, +} from "@/lib/ds-logs/validate"; +import type { QueryParseResult } from "@/lib/api/query-result"; +import type { DsAgentListResult } from "@/lib/types"; + +function expect422(result: QueryParseResult) { + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.status).toBe(422); + expect(result.error.error.code).toBe("validation_failed"); +} + +function expectResultInvalid(result: QueryParseResult, message?: string) { + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.status).toBe(400); + expect(result.error.error.code).toBe("result_invalid"); + if (message !== undefined) expect(result.error.error.message).toBe(message); +} + +function okQuery(result: QueryParseResult): T { + if (!result.ok) throw new Error(`期待は成功: ${JSON.stringify(result.error)}`); + return result.query; +} + +const params = (qs: string) => new URLSearchParams(qs); + +const V1_QUERY = { types: null, from: null, to: null, sort: "date_desc", page: 1, perPage: 200 }; + +describe("parseDeviceLogQuery() — 段階3-3 V1〜V6", () => { + it("V1 パラメータなし", () => { + expect(okQuery(parseDeviceLogQuery(params("")))).toEqual(V1_QUERY); + expect(DS_LOG_DEFAULT_PER_PAGE).toBe(200); + }); + + it("types はカンマ区切りの配列になる", () => { + expect(okQuery(parseDeviceLogQuery(params("types=app,sys"))).types).toEqual(["app", "sys"]); + }); + + it("from / to / sort を読む(from・to は瞬間の ms)", () => { + expect( + okQuery( + parseDeviceLogQuery( + params("from=2026-10-19T11:00:00%2B09:00&to=2026-10-19T12:00:00%2B09:00&sort=type_date_desc") + ) + ) + ).toEqual({ + ...V1_QUERY, + from: Date.parse("2026-10-19T11:00:00+09:00"), + to: Date.parse("2026-10-19T12:00:00+09:00"), + sort: "type_date_desc", + }); + }); + + it.each([ + ["日付だけ", "from=2026-10-19"], + ["オフセットなし", "from=2026-10-19T11:00:00"], + ["abc", "from=abc"], + ])("V2 %s → 422", (_label, qs) => { + expect422(parseDeviceLogQuery(params(qs))); + }); + + it.each([ + ["2/30", "from=2026-02-30T00:00:00%2B09:00"], + ["平年の 2/29(Z)", "to=2026-02-29T00:00:00Z"], + ["13月", "from=2026-13-01T00:00:00%2B09:00"], + ])("V2b 存在しない日付 %s → 422", (_label, qs) => { + expect422(parseDeviceLogQuery(params(qs))); + }); + + it.each([ + ["Z", "from=2026-10-19T02:30:00Z"], + ["+00:00", "from=2026-10-19T02:30:00%2B00:00"], + ])("V2c +09:00 以外のオフセット %s も通る", (_label, qs) => { + expect(okQuery(parseDeviceLogQuery(params(qs))).from).toBe(Date.parse("2026-10-19T02:30:00Z")); + }); + + it("V3 from > to → 422", () => { + expect422( + parseDeviceLogQuery(params("from=2026-10-19T12:00:00%2B09:00&to=2026-10-19T11:00:00%2B09:00")) + ); + }); + + it("V3 from = to は通る", () => { + const q = okQuery( + parseDeviceLogQuery(params("from=2026-10-19T12:00:00%2B09:00&to=2026-10-19T03:00:00Z")) + ); + expect(q.from).toBe(q.to); + }); + + it.each(["types=", "types=%20", "from=", "from=%20", "to=", "to=%20", "sort=", "sort=%20"])( + "V4 %s は省略と同じ", + (qs) => { + expect(okQuery(parseDeviceLogQuery(params(qs)))).toEqual(V1_QUERY); + } + ); + + it.each(["types=app,,sys", "types=app,"])("V4b %s → 422", (qs) => { + expect422(parseDeviceLogQuery(params(qs))); + }); + + it("V5 sort=name → 422", () => { + expect422(parseDeviceLogQuery(params("sort=name"))); + }); + + it.each<[string, "page" | "perPage", number]>([ + ["per_page=30", "perPage", 30], + ["per_page=500", "perPage", 200], + ["per_page=0", "perPage", 1], + ["per_page=abc", "perPage", 200], + ["page=0", "page", 1], + ["page=1.5", "page", 1], + ["page=abc", "page", 1], + ])("V6 %s は 422 にせず丸める", (qs, key, expected) => { + expect(okQuery(parseDeviceLogQuery(params(qs)))[key]).toBe(expected); + }); +}); + +describe("parseExportRequest() — 段階3-3 V7〜V9", () => { + it("正しい本体(file_ids 1件・EXPORT_MAX_FILES 件・8192文字)は通る", () => { + expect(okQuery(parseExportRequest(JSON.stringify({ file_ids: ["a", "b"] })))).toEqual({ + fileIds: ["a", "b"], + }); + const max = Array.from({ length: EXPORT_MAX_FILES }, (_, i) => `id-${i}`); + expect(okQuery(parseExportRequest(JSON.stringify({ file_ids: max }))).fileIds).toHaveLength( + EXPORT_MAX_FILES + ); + const long = "x".repeat(8192); + expect(okQuery(parseExportRequest(JSON.stringify({ file_ids: [long] }))).fileIds).toEqual([long]); + }); + + it.each<[string, unknown]>([ + ["空の配列", { file_ids: [] }], + ["EXPORT_MAX_FILES + 1 件", { file_ids: Array.from({ length: EXPORT_MAX_FILES + 1 }, (_, i) => `id-${i}`) }], + ["重複あり", { file_ids: ["a", "a"] }], + ["要素が文字列でない", { file_ids: ["a", 1] }], + ["8193文字", { file_ids: ["x".repeat(8193)] }], + ["空文字の要素", { file_ids: [""] }], + ["file_ids が無い", {}], + ["file_ids が配列でない", { file_ids: "a" }], + ])("V7 %s → 422", (_label, body) => { + expect422(parseExportRequest(JSON.stringify(body))); + }); + + it("K7-1 file_ids の JSON がちょうど EXPORT_FILE_IDS_MAX_BYTES → 通る", () => { + const fileIds = fileIdsOfJsonBytes(EXPORT_FILE_IDS_MAX_BYTES); + expect(new TextEncoder().encode(JSON.stringify(fileIds)).length).toBe(EXPORT_FILE_IDS_MAX_BYTES); + expect(okQuery(parseExportRequest(JSON.stringify({ file_ids: fileIds }))).fileIds).toEqual(fileIds); + }); + + it("K7-2 file_ids の JSON が EXPORT_FILE_IDS_MAX_BYTES + 1 → 422・多すぎます", () => { + const fileIds = fileIdsOfJsonBytes(EXPORT_FILE_IDS_MAX_BYTES + 1); + expect(new TextEncoder().encode(JSON.stringify(fileIds)).length).toBe(EXPORT_FILE_IDS_MAX_BYTES + 1); + const result = parseExportRequest(JSON.stringify({ file_ids: fileIds })); + expect422(result); + if (!result.ok) { + expect(result.error.error.message).toBe(EXPORT_FILE_IDS_TOO_LARGE_MESSAGE); + expect(result.error.error.details?.file_ids).toHaveLength(1); + } + }); + + it("K7-3 多バイト文字はバイトで数える(文字数では上限内でも 422)", () => { + const fileIds = ["あ".repeat(8192), "い".repeat(8192)]; + expect(fileIds.join("").length).toBeLessThan(EXPORT_FILE_IDS_MAX_BYTES); + expect(new TextEncoder().encode(JSON.stringify(fileIds)).length).toBeGreaterThan(EXPORT_FILE_IDS_MAX_BYTES); + const result = parseExportRequest(JSON.stringify({ file_ids: fileIds })); + expect422(result); + if (!result.ok) expect(result.error.error.message).toBe(EXPORT_FILE_IDS_TOO_LARGE_MESSAGE); + }); + + it.each(["null", "[]", "1", '"x"'])("V8 オブジェクトでない JSON(%s)→ 422「JSONの形式が不正です」", (text) => { + const result = parseExportRequest(text); + expect422(result); + if (!result.ok) expect(result.error.error.message).toBe("JSONの形式が不正です"); + }); + + it("V9 JSON でない({)→ 422「JSONの形式が不正です」", () => { + const result = parseExportRequest("{"); + expect422(result); + if (!result.ok) expect(result.error.error.message).toBe("JSONの形式が不正です"); + }); +}); + +describe("parseAgentPollWait() — 段階3-3 V10(表5 R8)", () => { + it("省略は AGENT_POLL_WAIT_SEC(25)", () => { + expect(okQuery(parseAgentPollWait(null))).toBe(AGENT_POLL_WAIT_SEC); + expect(AGENT_POLL_WAIT_SEC).toBe(25); + }); + + it.each<[string, number]>([ + ["1", 1], + ["25", 25], + ])("wait=%s → %d", (value, expected) => { + expect(okQuery(parseAgentPollWait(value))).toBe(expected); + }); + + it.each(["0", "26", "abc"])("V10 wait=%s → 422(丸めない)", (value) => { + expect422(parseAgentPollWait(value)); + }); +}); + +describe("パスパラメータ — 段階3-3 X1〜X6(M8)", () => { + const idParsers = [ + ["export_id", parsePathExportId, PATH_EXPORT_ID_MAX_LENGTH], + ["command_id", parsePathCommandId, PATH_COMMAND_ID_MAX_LENGTH], + ] as const; + + it.each(idParsers)("X1 %s が randomUUID の形(36文字)なら通る", (_name, parse) => { + const id = randomUUID(); + expect(okQuery(parse(id))).toBe(id); + }); + + it.each(idParsers)("X1 %s が上限ちょうどなら通る", (_name, parse, max) => { + const id = "a".repeat(max); + expect(okQuery(parse(id))).toBe(id); + }); + + it.each(idParsers)("X2 %s が空文字・上限 + 1 文字なら 422(入力値をエコーしない)", (_name, parse, max) => { + expect422(parse("")); + const tooLong = "Q".repeat(max + 1); + const result = parse(tooLong); + expect422(result); + if (!result.ok) expect(JSON.stringify(result.error)).not.toContain(tooLong); + }); + + it.each(idParsers)("X3 %s=abc(形は違うが長さは範囲内)は通る", (_name, parse) => { + expect(okQuery(parse("abc"))).toBe("abc"); + }); + + it.each<[string, number]>([ + ["0", 0], + ["1", 1], + ["10", 10], + ["123456789", 123456789], + ])("X4 seq=%s → %d", (value, expected) => { + expect(okQuery(parsePathSeq(value))).toBe(expected); + }); + + it.each(["-1", "01", "1.0", "abc", "1e3", "", " 1"])("X5 seq=%j → 422", (value) => { + expect422(parsePathSeq(value)); + }); + + it("X6 seq=1234567890(10桁)→ 422", () => { + expect422(parsePathSeq("1234567890")); + }); +}); + +// ── 段階7-2 W1〜W11 ── + +const W_TYPES = [ + { key: "app", label: "アプリ" }, + { key: "comm", label: "通信" }, + { key: "sys", label: "システム" }, +]; + +const W_ITEMS = [ + { file_id: "f1", name: "app_20261019_1100.log", type: "app", size_bytes: 1000, modified_at: "2026-10-19T11:00:00+09:00" }, + { file_id: "f2", name: "app_20261019_0900.log", type: "app", size_bytes: 2000, modified_at: "2026-10-19T09:00:00+09:00" }, + { file_id: "f3", name: "comm_20261019_1100.log", type: "comm", size_bytes: 3000, modified_at: "2026-10-19T11:00:00+09:00" }, + { file_id: "f4", name: "comm_20261018_1200.log", type: "comm", size_bytes: 4000, modified_at: "2026-10-18T12:00:00+09:00" }, + { file_id: "f5", name: "sys_20261019_1130.log", type: "sys", size_bytes: 5000, modified_at: "2026-10-19T11:30:00+09:00" }, + { file_id: "f6", name: "sys_20261018_1159.log", type: "sys", size_bytes: 6000, modified_at: "2026-10-18T11:59:59+09:00" }, + { file_id: "f7", name: "app_20261019_0200.log", type: "app", size_bytes: 7000, modified_at: "2026-10-18T17:00:00Z" }, + { file_id: "f8", name: "app_20261019_1200.log", type: "app", size_bytes: 8000, modified_at: "2026-10-19T12:00:00+09:00" }, +]; + +const W1_BODY = { types: W_TYPES, items: W_ITEMS, truncated: false }; + +function withItem(index: number, patch: Record) { + return { ...W1_BODY, items: W_ITEMS.map((item, i) => (i === index ? { ...item, ...patch } : item)) }; +} + +/** 壊した key を types[0] と items の type:"app" の両方に使う(指示書BK K1) */ +function withTypeKey(bad: string) { + return { + ...W1_BODY, + types: [{ key: bad, label: "x" }, ...W_TYPES.slice(1)], + items: W_ITEMS.map((item) => (item.type === "app" ? { ...item, type: bad } : item)), + }; +} + +function manyItems(count: number) { + return { + types: [{ key: "app", label: "app" }], + items: Array.from({ length: count }, (_, i) => ({ + file_id: `f${i}`, + name: `app/app.log.${i}`, + type: "app", + size_bytes: i, + modified_at: "2026-10-19T12:00:00+09:00", + })), + truncated: true, + }; +} + +const byteLength = (text: string) => new TextEncoder().encode(text).length; + +/** 成功の形(`{ types, items, truncated }`)の items。失敗の報告(`{ error }`)なら落とす */ +function listItems(result: QueryParseResult) { + const query = okQuery(result); + if ("error" in query) throw new Error("期待は一覧の結果"); + return query.items; +} + +describe("parseAgentListResult() — 段階7-2 W1〜W11", () => { + it("W1 正しい結果は受理(そのまま返す)", () => { + expect(okQuery(parseAgentListResult(JSON.stringify(W1_BODY)))).toEqual(W1_BODY); + }); + + it("truncated: true もそのまま返す(表5 R38・D18)", () => { + const body = { ...W1_BODY, truncated: true }; + expect(okQuery(parseAgentListResult(JSON.stringify(body)))).toEqual(body); + }); + + it.each<[string, unknown]>([ + ["W2 types の key が重複", { ...W1_BODY, types: [...W_TYPES, { key: "app", label: "重複" }] }], + ["W3 items[].type が types に無い", withItem(0, { type: "xyz" })], + ["W4 modified_at にオフセットが無い", withItem(0, { modified_at: "2026-10-19T11:00:00" })], + ["W4b modified_at が存在しない日付", withItem(0, { modified_at: "2026-02-30T00:00:00+09:00" })], + ["W5 size_bytes が負", withItem(0, { size_bytes: -1 })], + ["W5 size_bytes が小数", withItem(0, { size_bytes: 1.5 })], + ["W5 size_bytes が文字列", withItem(0, { size_bytes: "1000" })], + ["W6 file_id が重複", withItem(1, { file_id: "f1" })], + ["truncated が無い(必須の項目が無い。R43)", { types: W_TYPES, items: W_ITEMS }], + ["types が配列でない(型が違う。R43)", { ...W1_BODY, types: "app" }], + ["items[].name が文字列でない(型が違う。R43)", withItem(0, { name: 1 })], + ])("%s → 400 result_invalid", (_label, body) => { + expectResultInvalid(parseAgentListResult(JSON.stringify(body))); + }); + + it("W7 items が LIST_RESULT_MAX_ITEMS + 1 件 → 不正(ちょうどは受理)", () => { + expect(listItems(parseAgentListResult(JSON.stringify(manyItems(LIST_RESULT_MAX_ITEMS))))).toHaveLength( + LIST_RESULT_MAX_ITEMS + ); + expectResultInvalid(parseAgentListResult(JSON.stringify(manyItems(LIST_RESULT_MAX_ITEMS + 1)))); + }); + + it("W8 本体が LIST_RESULT_MAX_BYTES を超える → 不正(ちょうどは受理)", () => { + // ★ASCII だけの本体★(文字数とバイト数が一致するので、どちらで数えても境界が同じになる) + const body = JSON.stringify({ ...manyItems(3), truncated: false }); + const exact = body + " ".repeat(LIST_RESULT_MAX_BYTES - byteLength(body)); + expect(byteLength(exact)).toBe(LIST_RESULT_MAX_BYTES); + expect(listItems(parseAgentListResult(exact))).toHaveLength(3); + expectResultInvalid(parseAgentListResult(`${exact} `)); + }); + + it("W9 { error: { code, message } } は失敗の報告として受理(failCommand と同じ扱い)", () => { + const body = { error: { code: "io_error", message: "読めませんでした" } }; + expect(okQuery(parseAgentListResult(JSON.stringify(body)))).toEqual(body); + }); + + it.each<[string, unknown]>([ + ["code が空", { error: { code: "", message: "m" } }], + ["code が65文字", { error: { code: "c".repeat(65), message: "m" } }], + ["message が501文字", { error: { code: "io_error", message: "m".repeat(501) } }], + ["message が無い", { error: { code: "io_error" } }], + ])("W9 の error の形の誤り(%s)→ 不正", (_label, body) => { + expectResultInvalid(parseAgentListResult(JSON.stringify(body))); + }); + + it.each(["null", "[]", "1", '"x"'])("W10 オブジェクトでない JSON(%s)→ 400 result_invalid", (text) => { + expectResultInvalid(parseAgentListResult(text)); + }); + + it("JSON でない本体(R43)→ 400 result_invalid", () => { + expectResultInvalid(parseAgentListResult("{")); + }); + + it("W11 name に / を含む({root の name}/{相対パス})は受理", () => { + const body = withItem(0, { name: "api_client/api_client.log.2026-10-18" }); + expect(okQuery(parseAgentListResult(JSON.stringify(body)))).toEqual(body); + }); + + it.each<[string, unknown]>([ + ["R45 key が空", withTypeKey("")], + ["R46 key にカンマ", withTypeKey("a,b")], + ["R46b key が空白だけ", withTypeKey(" ")], + ["R46b key の先頭が空白", withTypeKey(" app")], + ])("%s → 400 result_invalid・typesの形式が不正です(指示書BK K1)", (_label, body) => { + expectResultInvalid(parseAgentListResult(JSON.stringify(body)), "typesの形式が不正です"); + }); + + it.each<[string, unknown]>([ + ["R47 file_id が8193文字", withItem(0, { file_id: "x".repeat(8193) })], + ])("%s → 400 result_invalid", (_label, body) => { + expectResultInvalid(parseAgentListResult(JSON.stringify(body))); + }); + + it("R46b key の中の空白(a b)はよい", () => { + const body = { + types: [{ key: "a b", label: "x" }], + items: [{ file_id: "f1", name: "n", type: "a b", size_bytes: 1, modified_at: "2026-10-19T11:00:00+09:00" }], + truncated: false, + }; + expect(okQuery(parseAgentListResult(JSON.stringify(body)))).toEqual(body); + }); + + it("R47 file_id が8192文字はよい", () => { + const body = withItem(0, { file_id: "x".repeat(8192) }); + expect(listItems(parseAgentListResult(JSON.stringify(body)))[0].file_id).toHaveLength(8192); + }); +}); + +// ── prepared / complete / fail の本体(段階7-2 の末尾・表5 R43。形の誤りはすべて 400 result_invalid)── + +const HEX64 = "a".repeat(64); + +describe("parseAgentPrepared()", () => { + it.each<[string, unknown]>([ + ["total_bytes=1・file_count=1(下限。指示書BJ R48)", { total_bytes: 1, sha256: HEX64, file_count: 1 }], + ["一般的な値", { total_bytes: 9437184, sha256: "0123456789abcdef".repeat(4), file_count: 2 }], + ])("%s は通る", (_label, body) => { + expect(okQuery(parseAgentPrepared(JSON.stringify(body)))).toEqual(body); + }); + + it.each<[string, string]>([ + ["JSON でない", "{"], + ["null", "null"], + ["配列", "[]"], + ["total_bytes が無い", JSON.stringify({ sha256: HEX64, file_count: 1 })], + ["total_bytes が0(指示書BJ R48)", JSON.stringify({ total_bytes: 0, sha256: HEX64, file_count: 1 })], + ["total_bytes が負", JSON.stringify({ total_bytes: -1, sha256: HEX64, file_count: 1 })], + ["total_bytes が小数", JSON.stringify({ total_bytes: 1.5, sha256: HEX64, file_count: 1 })], + ["total_bytes が文字列", JSON.stringify({ total_bytes: "1", sha256: HEX64, file_count: 1 })], + ["sha256 が大文字", JSON.stringify({ total_bytes: 1, sha256: "A".repeat(64), file_count: 1 })], + ["sha256 が63桁", JSON.stringify({ total_bytes: 1, sha256: "a".repeat(63), file_count: 1 })], + ["file_count が0", JSON.stringify({ total_bytes: 1, sha256: HEX64, file_count: 0 })], + ["file_count が無い", JSON.stringify({ total_bytes: 1, sha256: HEX64 })], + ])("%s → 400 result_invalid", (_label, text) => { + expectResultInvalid(parseAgentPrepared(text)); + }); +}); + +describe("parseAgentComplete()", () => { + it("正しい本体は通る", () => { + const body = { total_bytes: 36, sha256: HEX64 }; + expect(okQuery(parseAgentComplete(JSON.stringify(body)))).toEqual(body); + }); + + it.each<[string, string]>([ + ["JSON でない", "{"], + ["数値", "0"], + ["sha256 が無い", JSON.stringify({ total_bytes: 1 })], + ["total_bytes が負", JSON.stringify({ total_bytes: -1, sha256: HEX64 })], + ["total_bytes が0(指示書BK K3)", JSON.stringify({ total_bytes: 0, sha256: HEX64 })], + ["sha256 が16進でない", JSON.stringify({ total_bytes: 1, sha256: "g".repeat(64) })], + ])("%s → 400 result_invalid", (_label, text) => { + expectResultInvalid(parseAgentComplete(text)); + }); +}); + +describe("parseAgentFail()", () => { + it.each<[string, unknown]>([ + ["下限(1文字・1文字)", { code: "x", message: "m" }], + ["上限(64文字・500文字)", { code: "c".repeat(64), message: "m".repeat(500) }], + ["export_not_found(D25 ②)", { code: "export_not_found", message: "zip がありません" }], + ])("%s は通る", (_label, body) => { + expect(okQuery(parseAgentFail(JSON.stringify(body)))).toEqual(body); + }); + + it.each<[string, string]>([ + ["JSON でない", "{"], + ["文字列", '"x"'], + ["code が空", JSON.stringify({ code: "", message: "m" })], + ["code が65文字", JSON.stringify({ code: "c".repeat(65), message: "m" })], + ["message が空", JSON.stringify({ code: "x", message: "" })], + ["message が501文字", JSON.stringify({ code: "x", message: "m".repeat(501) })], + ["code が文字列でない", JSON.stringify({ code: 1, message: "m" })], + ])("%s → 400 result_invalid", (_label, text) => { + expectResultInvalid(parseAgentFail(text)); + }); +}); diff --git a/lib/ds-logs/file-name.ts b/lib/ds-logs/file-name.ts new file mode 100644 index 0000000..f9a1f75 --- /dev/null +++ b/lib/ds-logs/file-name.ts @@ -0,0 +1,17 @@ +/** + * エクスポートの zip のファイル名(指示書AY 段階3。D23)。期待値の正は指示書AY §3 表6(`__tests__/file-name.test.ts`)。 + * + * ★純粋関数★。日時は JST で名付ける(`toJstIso()`。実行環境のタイムゾーンに依存しない)。 + */ +import { toJstIso } from "@/lib/format/date"; + +/** + * `{シリアル}_logs_{YYYYMMDD-HHmmss}.zip`。 + * ★シリアルの `[A-Za-z0-9._-]` 以外は `_`★(`Content-Disposition` の `filename="…"` に引用符・パス区切りを入れない。D23)。 + */ +export function exportFileName(serialNumber: string, now: Date): string { + const safeSerial = serialNumber.replace(/[^A-Za-z0-9._-]/g, "_"); + const iso = toJstIso(now); + const stamp = `${iso.slice(0, 4)}${iso.slice(5, 7)}${iso.slice(8, 10)}-${iso.slice(11, 13)}${iso.slice(14, 16)}${iso.slice(17, 19)}`; + return `${safeSerial}_logs_${stamp}.zip`; +} diff --git a/lib/ds-logs/modal-text.ts b/lib/ds-logs/modal-text.ts new file mode 100644 index 0000000..4da10d5 --- /dev/null +++ b/lib/ds-logs/modal-text.ts @@ -0,0 +1,86 @@ +/** + * 制御盤のログモーダルの表示用文言・整形(指示書BJ 段階4。表E)。 + * ★純粋関数★。`DeviceLogsModal` から移した。 + */ +import { ApiFetchError } from "@/lib/api/client"; +import { EXPORT_MAX_BYTES } from "@/lib/config/ds-agent"; +import { EXPORT_FILE_IDS_TOO_LARGE_MESSAGE } from "@/lib/ds-logs/validate"; +import { formatHm, formatMd } from "@/lib/format/date"; +import type { ApiErrorDetails } from "@/lib/types"; + +export function formatDateTime(iso: string): string { + return `${formatMd(iso)} ${formatHm(iso)}`; +} + +/** 大きさの表示(一覧・選択合計・上限の文言)。1MB 未満は KB、以上は MB */ +export function formatFileSize(bytes: number): string { + if (bytes >= 1024 * 1024) { + const mb = bytes / (1024 * 1024); + const text = Number.isInteger(mb) ? String(mb) : mb.toFixed(1); + return `${text} MB`; + } + return `${Math.round(bytes / 1024)} KB`; +} + +export function detailStrings(details: ApiErrorDetails, key: string): string[] { + if (details === null || typeof details !== "object") return []; + const value = (details as Record)[key]; + if (!Array.isArray(value)) return []; + return value.filter((v): v is string => typeof v === "string"); +} + +function isAbortCause(cause: unknown): boolean { + return ( + cause !== null && + typeof cause === "object" && + "name" in cause && + (cause as { name: unknown }).name === "AbortError" + ); +} + +/** + * API 失敗を画面の文言に(指示書AY 段階9-2 の表・指示書BJ J1/J2)。 + * ★取り消し(`AbortError`)だけ null★。それ以外の status 0 は通信失敗の文言。 + */ +export function apiErrorMessage(e: unknown, t: (text: string) => string): string | null { + if (!(e instanceof ApiFetchError)) { + return t("データの取得に失敗しました"); + } + if (e.status === 0) { + if (isAbortCause(e.cause)) return null; + return t("通信に失敗しました。時間をおいて再度お試しください"); + } + switch (e.code) { + case "agent_offline": { + const last = detailStrings(e.details, "last_seen_at")[0]; + const when = last ? formatDateTime(last) : "-"; + return t("制御盤が接続されていません(最終接続: {日時})").replace("{日時}", when); + } + case "agent_timeout": + return t("制御盤から応答がありません。しばらくしてから再度お試しください"); + case "export_busy": + return t("この制御盤は別のエクスポートを処理中です"); + case "agent_busy": + return t("制御盤が混み合っています。しばらくしてから再度お試しください"); + case "agent_error": { + const code = detailStrings(e.details, "agent_code")[0] ?? ""; + if (code === "result_invalid") { + return t("制御盤の結果の形が不正です"); + } + if (code === "too_large") { + return t("選んだファイルが大きすぎます(1回 {max} まで)").replace( + "{max}", + formatFileSize(EXPORT_MAX_BYTES) + ); + } + return t("制御盤でエラーが発生しました({code})").replace("{code}", code); + } + case "validation_failed": + if (e.serverMessage === EXPORT_FILE_IDS_TOO_LARGE_MESSAGE) { + return t(EXPORT_FILE_IDS_TOO_LARGE_MESSAGE); + } + return e.serverMessage ?? t("データの取得に失敗しました"); + default: + return e.serverMessage ?? t("データの取得に失敗しました"); + } +} diff --git a/lib/ds-logs/presets.ts b/lib/ds-logs/presets.ts new file mode 100644 index 0000000..2995989 --- /dev/null +++ b/lib/ds-logs/presets.ts @@ -0,0 +1,66 @@ +/** + * D-Sec ログのモーダルの期間(ボタンと日付の範囲)→ 一覧の `from` / `to`(指示書AY 段階3。D23)。 + * 期待値の正は指示書AY §3 表2(`__tests__/presets.test.ts`)。 + * + * ★純粋関数★。★実行環境のタイムゾーンに依存しない★(`toJstIso()` と `parseDate()` だけで組み立てる。表2 T10)。 + */ +import { parseDate } from "@/lib/clean-logs/query"; +import { toJstIso } from "@/lib/format/date"; + +export type DsLogPeriodPreset = "1h" | "3h" | "24h" | "all"; + +export interface DsLogPeriod { + /** `+09:00` の ISO。`null` = 下限なし */ + from: string | null; + /** `+09:00` の ISO。`null` = 上限なし */ + to: string | null; +} + +const PRESET_HOURS: Record, number> = { + "1h": 1, + "3h": 3, + "24h": 24, +}; + +const HOUR_MS = 60 * 60 * 1000; +const DAY_MS = 24 * HOUR_MS; + +/** + * 期間ボタン(表2 T1〜T4・T10)。 + * ★`to` は付けない★(D23。制御盤の時計が進んでいても最新のファイルを外さない)。 + */ +export function periodFromPreset(preset: DsLogPeriodPreset, now: Date): DsLogPeriod { + if (preset === "all") return { from: null, to: null }; + return { from: toJstIso(new Date(now.getTime() - PRESET_HOURS[preset] * HOUR_MS)), to: null }; +} + +/** + * 日付の範囲(`YYYY-MM-DD`。表2 T5〜T9)。★終了日を含む★(`to` は終了日の翌日 00:00 JST)。 + * 開始 > 終了・存在しない日付は `null`(不正。画面は取得しない)。 + */ +export function periodFromDates(startDate: string | null, endDate: string | null): DsLogPeriod | null { + const start = startDate ? parseDate(startDate) : null; + const end = endDate ? parseDate(endDate) : null; + if ((startDate && !start) || (endDate && !end)) return null; + if (start && end && start.getTime() > end.getTime()) return null; + return { + from: start ? toJstIso(start) : null, + to: end ? toJstIso(new Date(end.getTime() + DAY_MS)) : null, + }; +} + +/** + * 日付の範囲の不正の種類(指示書BJ 表D・J12)。 + * ★両方とも読めて開始 > 終了なら `"reversed"`★。片方 null は範囲未確定なので `null`。 + */ +export function dateRangeError( + start: string | null, + end: string | null +): "reversed" | "invalid" | null { + if (start === null || end === null) return null; + const startDate = parseDate(start); + const endDate = parseDate(end); + if (startDate === null || endDate === null) return "invalid"; + if (startDate.getTime() > endDate.getTime()) return "reversed"; + return null; +} diff --git a/lib/ds-logs/query.ts b/lib/ds-logs/query.ts new file mode 100644 index 0000000..a8dbb8b --- /dev/null +++ b/lib/ds-logs/query.ts @@ -0,0 +1,93 @@ +/** + * 制御盤から受け取ったログの一覧の絞り込み・並べ替え・ページ分け(指示書AY 段階3-2。D8)。 + * 期待値の正は指示書AY §3 表1(`__tests__/query.test.ts`)。 + * + * ★純粋関数★(`CLAUDE.md` §9.4)。一覧は制御盤からもらい、D-Watch はここで絞るだけ。 + * ★`modified_at` は `Date.parse()` の瞬間で比べる・並べる★(文字列で比べるとオフセット違いで順序が狂う。4-17)。 + * 入力の `modified_at` は検証済み(オフセット付き・存在する日付。`lib/ds-logs/validate.ts`)である前提。 + */ +import { toJstIso } from "@/lib/format/date"; +import type { DsLogFile, DsLogSort, DsLogType } from "@/lib/types"; + +export interface DeviceLogQuery { + /** `null` = 全種類。★`types` に無いキーは0件★(D23) */ + types: readonly string[] | null; + /** 瞬間(ms)。`from ≤ t` */ + from: number | null; + /** 瞬間(ms)。`t < to` */ + to: number | null; + sort: DsLogSort; + /** 1〜 */ + page: number; + perPage: number; +} + +/** 名前の昇順。★`localeCompare` を使わない★(実行環境のロケールで順序を変えない) */ +function compareName(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +/** + * 表1。★入力の配列を書き換えない★。 + * - `date_desc`: 新しい順 → 同時刻は name 昇順 + * - `type_date_desc`: 制御盤の `types` 配列の順(★クエリの順ではない★)→ 新しい順 → name 昇順 + * 出力の `modified_at` は `toJstIso()` で JST に揃える(表1 Q17。`CLAUDE.md` §9.3)。 + */ +export function queryDeviceLogs( + types: readonly DsLogType[], + items: readonly DsLogFile[], + query: DeviceLogQuery +): { items: DsLogFile[]; total: number } { + const wanted = query.types === null ? null : new Set(query.types); + const typeOrder = new Map(types.map((t, index) => [t.key, index])); + + const filtered = items + .map((item) => ({ item, at: Date.parse(item.modified_at) })) + .filter(({ item, at }) => { + if (wanted !== null && !wanted.has(item.type)) return false; + if (query.from !== null && at < query.from) return false; + if (query.to !== null && at >= query.to) return false; + return true; + }); + + filtered.sort((a, b) => { + if (query.sort === "type_date_desc") { + const byType = + (typeOrder.get(a.item.type) ?? Number.MAX_SAFE_INTEGER) - + (typeOrder.get(b.item.type) ?? Number.MAX_SAFE_INTEGER); + if (byType !== 0) return byType; + } + if (a.at !== b.at) return b.at - a.at; + return compareName(a.item.name, b.item.name); + }); + + const start = (query.page - 1) * query.perPage; + const page = filtered.slice(start, start + query.perPage).map(({ item, at }) => ({ + ...item, + modified_at: toJstIso(new Date(at)), + })); + + return { items: page, total: filtered.length }; +} + +/** + * モーダルの `types` クエリ(指示書BJ 表Q・J10)。 + * `null` = パラメータを付けない(省略=全種類、または種類未初期化・種類0件)。 + * ★全種類は「件数同じかつ known を全部含む」★(件数だけで誤認しない。罠5)。 + */ +export function deviceLogTypesParam( + knownTypeKeys: readonly string[], + selected: ReadonlySet | null +): string | null { + if (selected === null) return null; + if (knownTypeKeys.length === 0) return null; + if ( + selected.size === knownTypeKeys.length && + knownTypeKeys.every((key) => selected.has(key)) + ) { + return null; + } + const knownSelected = knownTypeKeys.filter((key) => selected.has(key)); + const unknown = [...selected].filter((key) => !knownTypeKeys.includes(key)); + return [...knownSelected, ...unknown].join(","); +} diff --git a/lib/ds-logs/validate.ts b/lib/ds-logs/validate.ts new file mode 100644 index 0000000..b46fcfc --- /dev/null +++ b/lib/ds-logs/validate.ts @@ -0,0 +1,369 @@ +/** + * 制御盤のログの API の検証(指示書AY 段階3-3・段階7-2。§11-4 M7・M8)。 + * 期待値の正は段階3-3 の V1〜V10・X1〜X6 と段階7-2 の W1〜W11・指示書BJ の R45〜R48(`__tests__/validate.test.ts`)。 + * + * - ★ブラウザ向け(クエリ・本体・パス)の誤りは 422 `validation_failed`★(D17)。 + * ただし `page` / `per_page` は 422 にせず丸める・空文字のパラメータは省略と同じ(既存の一覧 API と同じ作法。M7)。 + * - ★制御盤から来た JSON(result / prepared / complete / fail)の形の誤りは、すべて 400 `result_invalid`★(D25 ④)。 + * ★本体は生の文字列で受け取る★ — JSON でない・オブジェクトでない(`null`・配列・数値・文字列)も同じ 400(R43・R44)。 + * + * ★純粋関数★(`CLAUDE.md` §9.4)。★入力値をエラー文言にエコーしない★(`lib/api/path-params.ts` と同じ考え方)。 + */ +import { isJsonObject } from "@/lib/api/json-body"; +import { fail, type QueryParseResult } from "@/lib/api/query-result"; +import { parseDate } from "@/lib/clean-logs/query"; +import { PAGINATION } from "@/lib/config"; +import { + AGENT_POLL_WAIT_SEC, + DS_LOG_DEFAULT_PER_PAGE, + EXPORT_FILE_IDS_MAX_BYTES, + EXPORT_MAX_FILES, + LIST_RESULT_MAX_BYTES, + LIST_RESULT_MAX_ITEMS, + PATH_COMMAND_ID_MAX_LENGTH, + PATH_EXPORT_ID_MAX_LENGTH, +} from "@/lib/config/ds-agent"; +import type { DeviceLogQuery } from "@/lib/ds-logs/query"; +import type { + DsAgentComplete, + DsAgentFail, + DsAgentListResult, + DsAgentPrepared, + DsLogFile, + DsLogSort, + DsLogType, +} from "@/lib/types"; + +const SORTS: readonly DsLogSort[] = ["date_desc", "type_date_desc"]; + +/** + * `file_id` の長さの上限(一覧・エクスポート共通)。 + * 制御盤の常駐プログラムが受け取れる上限 `FILE_ID_MAX_LEN`(`agent.py:64`。`decode_file_id()` が使う)とそろえる。指示書BK K6 + */ +export const FILE_ID_MAX_LENGTH = 8192; +/** 制御盤の失敗の報告の `code` / `message` の長さ(段階2 の `DsAgentFail`。制御盤側も同じ値) */ +const AGENT_CODE_MAX_LENGTH = 64; +const AGENT_MESSAGE_MAX_LENGTH = 500; + +const SHA256_HEX = /^[0-9a-f]{64}$/; +/** `YYYY-MM-DDTHH:MM:SS` + ★オフセット必須★(`Z` か `±HH:MM`)。小数秒は受けない */ +const ISO_WITH_OFFSET = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(Z|[+-]\d{2}:\d{2})$/; +/** 0 を許す整数。★先頭の 0・符号・小数・空白を通さない・9桁まで★(X4〜X6) */ +const SEQ = /^(0|[1-9][0-9]{0,8})$/; + +const INVALID_JSON_MESSAGE = "JSONの形式が不正です"; + +// ── 共通 ── + +/** 空白だけの値は省略と同じ(BH の作法。V4) */ +function presentParam(searchParams: URLSearchParams, name: string): string | null { + const value = searchParams.get(name); + if (value === null || value.trim() === "") return null; + return value; +} + +function toInt(value: string | null, fallback: number): number { + if (!value) return fallback; + const n = Number.parseInt(value, 10); + return Number.isNaN(n) ? fallback : n; +} + +/** + * オフセット付きの ISO 8601 を瞬間(ms)にする。形が違う・存在しない日付なら `null`。 + * ★`Date.parse("2026-02-30T00:00:00+09:00")` は NaN にならず 3/2 になる★ので、日付の部分(先頭10文字)を + * `parseDate()` に通して確かめる(4-24)。★`toJstIso()` で戻して全体を比べない★(`Z` など正しい入力まで弾く)。 + */ +function parseInstant(value: string): number | null { + if (!ISO_WITH_OFFSET.test(value)) return null; + if (parseDate(value.slice(0, 10)) === null) return null; + const hh = Number(value.slice(11, 13)); + const mm = Number(value.slice(14, 16)); + const ss = Number(value.slice(17, 19)); + if (hh > 23 || mm > 59 || ss > 59) return null; + const ms = Date.parse(value); + return Number.isNaN(ms) ? null : ms; +} + +function isNonNegativeInteger(value: unknown): value is number { + return typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +} + +function isStringOfLength(value: unknown, min: number, max: number): value is string { + return typeof value === "string" && value.length >= min && value.length <= max; +} + +function parseJsonObject(text: string): Record | null { + let body: unknown; + try { + body = JSON.parse(text); + } catch { + return null; + } + return isJsonObject(body) ? body : null; +} + +function resultInvalid(message: string) { + return fail(400, "result_invalid", message); +} + +// ── ブラウザ向け(段階3-3)── + +/** + * `GET /ds-devices/{serial_number}/logs` のクエリ(V1〜V6)。 + * `page` / `per_page` は `lib/ds-devices/query.ts` と同じく丸める(`per_page` の既定は `DS_LOG_DEFAULT_PER_PAGE`)。 + */ +export function parseDeviceLogQuery(searchParams: URLSearchParams): QueryParseResult { + let types: string[] | null = null; + const typesRaw = presentParam(searchParams, "types"); + if (typesRaw !== null) { + types = typesRaw.split(","); + // ★全体が空は省略と同じ(V4)だが、並びの中の空の要素は打ち間違いなので通さない(V4b)★ + if (types.some((t) => t === "")) { + return fail(422, "validation_failed", "typesの形式が不正です", { + types: ["カンマ区切りの種類のキーを指定してください(空の要素は不可)"], + }); + } + } + + const fromRaw = presentParam(searchParams, "from"); + const from = fromRaw === null ? null : parseInstant(fromRaw); + if (fromRaw !== null && from === null) { + return fail(422, "validation_failed", "fromの形式が不正です", { + from: ["オフセット付きの ISO 8601(例 2026-10-19T11:00:00+09:00)で、存在する日時を指定してください"], + }); + } + const toRaw = presentParam(searchParams, "to"); + const to = toRaw === null ? null : parseInstant(toRaw); + if (toRaw !== null && to === null) { + return fail(422, "validation_failed", "toの形式が不正です", { + to: ["オフセット付きの ISO 8601(例 2026-10-19T12:00:00+09:00)で、存在する日時を指定してください"], + }); + } + if (from !== null && to !== null && from > to) { + return fail(422, "validation_failed", "fromはto以前にしてください", { + from: ["to 以前の日時を指定してください"], + }); + } + + const sortRaw = presentParam(searchParams, "sort") ?? "date_desc"; + if (!SORTS.includes(sortRaw as DsLogSort)) { + return fail(422, "validation_failed", "sortの値が不正です", { + sort: [`${SORTS.join(" / ")} のいずれかを指定してください`], + }); + } + + const page = Math.max(1, toInt(searchParams.get("page"), 1)); + const perPage = Math.min( + PAGINATION.maxPerPage, + Math.max(1, toInt(searchParams.get("per_page"), DS_LOG_DEFAULT_PER_PAGE)) + ); + + return { ok: true, query: { types, from, to, sort: sortRaw as DsLogSort, page, perPage } }; +} + +/** エクスポートで選んだ ID の合計が大きすぎるときの message(指示書BK K7。画面の文言と共有) */ +export const EXPORT_FILE_IDS_TOO_LARGE_MESSAGE = "選んだファイルが多すぎます。数を減らしてください"; + +/** + * `POST /ds-devices/{serial_number}/logs/exports` の本体(V7〜V9)。生の文字列で受け取る。 + * JSON でない・オブジェクトでない本体は 422「JSONの形式が不正です」(note と同じ文。M7 (d))。 + * ★ID の合計の上限(K7)★: `file_ids` を JSON にした UTF-8 バイト数が `EXPORT_FILE_IDS_MAX_BYTES` を超えたら 422。 + */ +export function parseExportRequest(text: string): QueryParseResult<{ fileIds: string[] }> { + const body = parseJsonObject(text); + if (body === null) return fail(422, "validation_failed", INVALID_JSON_MESSAGE); + + const fileIds = body.file_ids; + const invalid = () => + fail(422, "validation_failed", "file_idsの形式が不正です", { + file_ids: [ + `1〜${EXPORT_MAX_FILES}件の重複しない文字列(各1〜${FILE_ID_MAX_LENGTH}文字)の配列で指定してください`, + ], + }); + if (!Array.isArray(fileIds) || fileIds.length < 1 || fileIds.length > EXPORT_MAX_FILES) return invalid(); + if (!fileIds.every((id) => isStringOfLength(id, 1, FILE_ID_MAX_LENGTH))) return invalid(); + if (new Set(fileIds).size !== fileIds.length) return invalid(); + // ★件数・各 ID・重複のあとで合計を見る★(先に見ると V7 の既存行の文言と食い違う) + if (new TextEncoder().encode(JSON.stringify(fileIds)).length > EXPORT_FILE_IDS_MAX_BYTES) { + return fail(422, "validation_failed", EXPORT_FILE_IDS_TOO_LARGE_MESSAGE, { + file_ids: [ + `ファイル ID の合計が ${EXPORT_FILE_IDS_MAX_BYTES} バイト(JSON・UTF-8)以内になるように選んでください`, + ], + }); + } + return { ok: true, query: { fileIds: fileIds as string[] } }; +} + +/** + * 制御盤向け `GET /ds-agent/commands` の `wait`(V10・表5 R8)。省略は `AGENT_POLL_WAIT_SEC`。 + * ★範囲外・数でない値は丸めずに 422★(制御盤のプログラムの誤りを早く見つけるため)。 + */ +export function parseAgentPollWait(value: string | null): QueryParseResult { + if (value === null) return { ok: true, query: AGENT_POLL_WAIT_SEC }; + if (/^[1-9][0-9]*$/.test(value)) { + const n = Number(value); + if (n <= AGENT_POLL_WAIT_SEC) return { ok: true, query: n }; + } + return fail(422, "validation_failed", "waitの値が不正です", { + wait: [`1〜${AGENT_POLL_WAIT_SEC}の整数で指定してください`], + }); +} + +// ── パスパラメータ(X1〜X6。M8)── + +/** + * `export_id`(X1〜X3)。★`parsePathReportId()` と同じ作法★ — 空と上限超だけ 422・文字種は絞らない・`Number()` を通さない。 + */ +export function parsePathExportId(value: string): QueryParseResult { + if (value.length === 0 || value.length > PATH_EXPORT_ID_MAX_LENGTH) { + return fail(422, "validation_failed", "export_idの形式が不正です", { + export_id: [`${PATH_EXPORT_ID_MAX_LENGTH}文字以内で指定してください`], + }); + } + return { ok: true, query: value }; +} + +/** `command_id`(X1〜X3)。`parsePathExportId()` と同じ作法 */ +export function parsePathCommandId(value: string): QueryParseResult { + if (value.length === 0 || value.length > PATH_COMMAND_ID_MAX_LENGTH) { + return fail(422, "validation_failed", "command_idの形式が不正です", { + command_id: [`${PATH_COMMAND_ID_MAX_LENGTH}文字以内で指定してください`], + }); + } + return { ok: true, query: value }; +} + +/** + * チャンクの `seq`(X4〜X6)。★0 を許す★ので `parsePathId()`(1以上)は使わない。 + * 9桁まで(4MB × 10億 は 100MB の上限より十分大きい)。 + */ +export function parsePathSeq(value: string): QueryParseResult { + if (!SEQ.test(value)) { + return fail(422, "validation_failed", "seqの形式が不正です", { + seq: ["0以上の整数(9桁まで・先頭の0なし)で指定してください"], + }); + } + return { ok: true, query: Number(value) }; +} + +// ── 制御盤から来た本体(段階7-2。形の誤りはすべて 400 result_invalid)── + +/** 一覧の `types[].key`: 1文字以上・`,` を含まない・前後に空白が無い(指示書BJ J5) */ +function isLogTypeKey(value: unknown): value is string { + return typeof value === "string" && value.length >= 1 && !value.includes(",") && value.trim() === value; +} + +function isLogType(value: unknown): value is DsLogType { + return isJsonObject(value) && isLogTypeKey(value.key) && typeof value.label === "string"; +} + +function isLogFile(value: unknown, typeKeys: ReadonlySet): value is DsLogFile { + if (!isJsonObject(value)) return false; + return ( + typeof value.file_id === "string" && + isStringOfLength(value.file_id, 1, FILE_ID_MAX_LENGTH) && + typeof value.name === "string" && + typeof value.type === "string" && + typeKeys.has(value.type) && + isNonNegativeInteger(value.size_bytes) && + typeof value.modified_at === "string" && + parseInstant(value.modified_at) !== null + ); +} + +/** + * 一覧の結果(W1〜W11)。★1件でも形が違えば結果全体を不正とする★(黙って落とさない)。 + * ★本体の大きさは読んだ文字列の UTF-8 のバイト数で判定する★(`Content-Length` が無くても。W8)。 + * `{ error: { code, message } }` は失敗の報告として受理する(W9。中継側で failCommand と同じ扱い)。 + * `name` は `{root の name}/{相対パス}` で、★`/` を理由に弾かない・中身を解析しない★(W11)。 + */ +export function parseAgentListResult(text: string): QueryParseResult { + if (new TextEncoder().encode(text).length > LIST_RESULT_MAX_BYTES) { + return resultInvalid("一覧の結果が大きすぎます"); + } + const body = parseJsonObject(text); + if (body === null) return resultInvalid(INVALID_JSON_MESSAGE); + + if ("error" in body) { + const error = body.error; + if ( + isJsonObject(error) && + isStringOfLength(error.code, 1, AGENT_CODE_MAX_LENGTH) && + isStringOfLength(error.message, 1, AGENT_MESSAGE_MAX_LENGTH) + ) { + return { ok: true, query: { error: { code: error.code, message: error.message } } }; + } + return resultInvalid("失敗の報告の形式が不正です"); + } + + const { types, items, truncated } = body; + if (!Array.isArray(types) || !types.every(isLogType)) return resultInvalid("typesの形式が不正です"); + const typeKeys = new Set(types.map((t) => t.key)); + if (typeKeys.size !== types.length) return resultInvalid("typesのkeyが重複しています"); + if (typeof truncated !== "boolean") return resultInvalid("truncatedの形式が不正です"); + if (!Array.isArray(items) || items.length > LIST_RESULT_MAX_ITEMS) return resultInvalid("itemsの形式が不正です"); + if (!items.every((item) => isLogFile(item, typeKeys))) return resultInvalid("itemsの形式が不正です"); + if (new Set(items.map((item) => item.file_id)).size !== items.length) { + return resultInvalid("itemsのfile_idが重複しています"); + } + + return { + ok: true, + query: { + types: types.map((t) => ({ key: t.key, label: t.label })), + items: items.map((item) => ({ + file_id: item.file_id, + name: item.name, + type: item.type, + size_bytes: item.size_bytes, + modified_at: item.modified_at, + })), + truncated, + }, + }; +} + +/** prepared の本体(`total_bytes` は1以上の整数・`sha256` は小文字の16進64桁・`file_count` は1以上の整数) */ +export function parseAgentPrepared(text: string): QueryParseResult { + const body = parseJsonObject(text); + if (body === null) return resultInvalid(INVALID_JSON_MESSAGE); + const { total_bytes, sha256, file_count } = body; + if ( + !isNonNegativeInteger(total_bytes) || + total_bytes < 1 || + typeof sha256 !== "string" || + !SHA256_HEX.test(sha256) || + !isNonNegativeInteger(file_count) || + file_count < 1 + ) { + return resultInvalid("preparedの形式が不正です"); + } + return { ok: true, query: { total_bytes, sha256, file_count } }; +} + +/** complete の本体(`total_bytes` は1以上の整数・`sha256` は小文字の16進64桁)。値の照合は中継側(D15) */ +export function parseAgentComplete(text: string): QueryParseResult { + const body = parseJsonObject(text); + if (body === null) return resultInvalid(INVALID_JSON_MESSAGE); + const { total_bytes, sha256 } = body; + if ( + !isNonNegativeInteger(total_bytes) || + total_bytes < 1 || + typeof sha256 !== "string" || + !SHA256_HEX.test(sha256) + ) { + return resultInvalid("completeの形式が不正です"); + } + return { ok: true, query: { total_bytes, sha256 } }; +} + +/** fail の本体(`code` は1〜64文字・`message` は1〜500文字) */ +export function parseAgentFail(text: string): QueryParseResult { + const body = parseJsonObject(text); + if (body === null) return resultInvalid(INVALID_JSON_MESSAGE); + const { code, message } = body; + if (!isStringOfLength(code, 1, AGENT_CODE_MAX_LENGTH) || !isStringOfLength(message, 1, AGENT_MESSAGE_MAX_LENGTH)) { + return resultInvalid("failの形式が不正です"); + } + return { ok: true, query: { code, message } }; +} diff --git a/lib/errors/error-content-picker.ts b/lib/errors/error-content-picker.ts index ed1f8fe..7a5c459 100644 --- a/lib/errors/error-content-picker.ts +++ b/lib/errors/error-content-picker.ts @@ -104,24 +104,5 @@ return groups; } -/** - * 背景のクリックで閉じてよいか(指示書BE §4-1)。 - * ★押した(mousedown)のも離した(click)のも背景そのもののときだけ★閉じる。 - * キーワード入力の中で文字を選び、背景の上で離したときに閉じないため。 - */ -export function shouldCloseOnBackdrop(downOnOverlay: boolean, clickOnOverlay: boolean): boolean { - return downOnOverlay && clickOnOverlay; -} - -/** - * Tab でフォーカスを窓の中に閉じ込める(指示書BE §4-1)。 - * `count` は押した時点のフォーカスできる要素の数(絞り込みで変わるので毎回数える)、 - * `activeIndex` は今のフォーカスの位置(窓の外なら -1)。 - * 戻り値はフォーカスを移す位置。null はブラウザの既定の移動に任せる。 - */ -export function trapFocusIndex(count: number, activeIndex: number, backward: boolean): number | null { - if (count <= 0) return null; - const last = count - 1; - if (backward) return activeIndex <= 0 || activeIndex > last ? last : null; - return activeIndex < 0 || activeIndex >= last ? 0 : null; -} +/** モーダル共通へ移した判定(指示書AY 段階9-2。M2)。import 元を変えないため再 export */ +export { shouldCloseOnBackdrop, trapFocusIndex } from "@/lib/ui/modal"; diff --git a/lib/fixtures/ds-device-logs.ts b/lib/fixtures/ds-device-logs.ts new file mode 100644 index 0000000..4717598 --- /dev/null +++ b/lib/fixtures/ds-device-logs.ts @@ -0,0 +1,85 @@ +import { toJstIso } from "@/lib/format/date"; +import type { DsLogFile, DsLogType } from "@/lib/types"; + +/** + * 制御盤(ラズパイ)のログの一覧のサンプルデータ(指示書AY 段階8-1。D10・D24)。 + * + * ★種類は実機に合わせる★(設計書 §1.6・§5.2)。★表示名は仮★なので、テストの期待値に表示名を埋め込まない(キーで比べる)。 + * ★`name` は実機の形★: `{root の name}/{root からの相対パス}` — + * `<種類>/<種類>.log`(各種類の最新)と `<種類>/<種類>.log.YYYY-MM-DD`(設計書 §5.5。zip の中のパスと同じ。M6)。 + * ★実在の店名・ログの中身は入れない★。 + */ +export const fixtureDeviceLogTypes: readonly DsLogType[] = [ + { key: "api_client", label: "API 通信(報告間隔の取得・通信の有効/無効)(仮)" }, + { key: "postcondition_security", label: "セキュリティ状態の報告(仮)" }, + { key: "postcondition_kick_action", label: "セコム状態の監視・清掃の起動(仮)" }, + { key: "send_start_sig", label: "清掃開始信号(仮)" }, +]; + +/** fixtures の制御盤のうち、未接続にしておくもの(→ 409 `agent_offline`。D10) */ +export const FIXTURE_AGENT_OFFLINE_SERIALS: readonly string[] = ["DS-001"]; + +const SECOND_MS = 1000; +const HOUR_MS = 60 * 60 * SECOND_MS; +const DAY_MS = 24 * HOUR_MS; +/** 日付つきのファイルを何日分さかのぼって作るか(4種類 × 74日 + 最新5件 ≒ 300件) */ +const ROTATED_DAYS = 74; + +/** + * 大きさは 1KB〜50MB(★選択の合計が 100MB を超える組み合わせが作れる★。実機は全部で 1.2MB 程度だが、 + * 画面の上限の確認のため大きくしてある)。決まった値になるよう添字から作る。 + */ +function sizeOf(index: number): number { + return 1024 + ((index * 7919) % 50) * 1024 * 1024; +} + +/** + * now から決まった間隔で約300件を作る。 + * ★now ちょうど・1時間前ちょうど・3時間前ちょうど・24時間前ちょうど・24時間前の1秒前★ の項目を必ず含める(画面の目視用。 + * 境界の正しさは `lib/ds-logs/__tests__/query.test.ts` が持つ)。 + */ +export function fixtureDeviceLogItems(now: Date): DsLogFile[] { + const nowMs = now.getTime(); + const items: DsLogFile[] = []; + const names = new Set(); + const push = (type: string, name: string, modifiedMs: number) => { + if (names.has(name)) return; + names.add(name); + items.push({ + file_id: `fx-${items.length + 1}`, + name, + type, + size_bytes: sizeOf(items.length + 1), + modified_at: toJstIso(new Date(modifiedMs)), + }); + }; + + // 各種類の最新(`<種類>.log`): now ちょうど・1時間前・3時間前・24時間前 + const latestOffsets = [0, HOUR_MS, 3 * HOUR_MS, DAY_MS]; + fixtureDeviceLogTypes.forEach((t, i) => push(t.key, `${t.key}/${t.key}.log`, nowMs - latestOffsets[i])); + + // 24時間前の1秒前 + const justBefore = nowMs - DAY_MS - SECOND_MS; + const kick = fixtureDeviceLogTypes[2].key; + push(kick, `${kick}/${kick}.log.${toJstIso(new Date(justBefore)).slice(0, 10)}`, justBefore); + + // 日付つき(`<種類>.log.YYYY-MM-DD`): その日の 23:59:59 JST に書き終えた形 + for (let day = 1; day <= ROTATED_DAYS; day++) { + const date = toJstIso(new Date(nowMs - day * DAY_MS)).slice(0, 10); + const endOfDay = Date.parse(`${date}T23:59:59+09:00`); + fixtureDeviceLogTypes.forEach((t, i) => { + push(t.key, `${t.key}/${t.key}.log.${date}`, Math.min(endOfDay - i * 60 * SECOND_MS, nowMs - SECOND_MS)); + }); + } + + return items; +} + +/** + * エクスポートの zip(★中身はダミーの文字だけの小さな正しい zip★。438バイト・無圧縮)。 + * ★中のパスは `<種類>/<ファイル名>`★(root はフォルダごと。設計書 §5.2・D24): + * `api_client/api_client.log`・`postcondition_security/postcondition_security.log.2026-10-18`。 + * ★実在の店名・ログを入れない★。 + */ +export const FIXTURE_EXPORT_ZIP_BASE64 = + "UEsDBBQAAAgAAABgUl1mcufuKQAAACkAAAAZAAAAYXBpX2NsaWVudC9hcGlfY2xpZW50LmxvZ2R1bW15IGxvZyBsaW5lIGZvciBmaXh0dXJlcyAoYXBpX2NsaWVudCkKUEsDBBQAAAgAAABgUl2WWj0hNQAAADUAAAA8AAAAcG9zdGNvbmRpdGlvbl9zZWN1cml0eS9wb3N0Y29uZGl0aW9uX3NlY3VyaXR5LmxvZy4yMDI2LTEwLTE4ZHVtbXkgbG9nIGxpbmUgZm9yIGZpeHR1cmVzIChwb3N0Y29uZGl0aW9uX3NlY3VyaXR5KQpQSwECFAAUAAAIAAAAYFJdZnLn7ikAAAApAAAAGQAAAAAAAAAAAAAAAAAAAAAAYXBpX2NsaWVudC9hcGlfY2xpZW50LmxvZ1BLAQIUABQAAAgAAABgUl2WWj0hNQAAADUAAAA8AAAAAAAAAAAAAAAAAGAAAABwb3N0Y29uZGl0aW9uX3NlY3VyaXR5L3Bvc3Rjb25kaXRpb25fc2VjdXJpdHkubG9nLjIwMjYtMTAtMThQSwUGAAAAAAIAAgCxAAAA7wAAAAAA"; diff --git a/lib/fixtures/index.ts b/lib/fixtures/index.ts index 3ea0193..1342e0e 100644 --- a/lib/fixtures/index.ts +++ b/lib/fixtures/index.ts @@ -16,6 +16,7 @@ export * from "./error-reports"; export * from "./reports"; export * from "./ds-devices"; +export * from "./ds-device-logs"; export * from "./groups"; export * from "./customers"; export * from "./facilities"; diff --git a/lib/i18n.ts b/lib/i18n.ts index 012f786..73153bb 100644 --- a/lib/i18n.ts +++ b/lib/i18n.ts @@ -549,6 +549,68 @@ "試行回数が多すぎます。しばらくしてから再度お試しください": "Too many attempts. Please try again later", "ログインできませんでした。時間をおいて再度お試しください": "Could not sign in. Please try again later", "通信に失敗しました。時間をおいて再度お試しください": "Network error. Please try again later", + + // ---- 制御盤のログモーダル(指示書AY 段階9-3。D-179。EN は暫定)---- + // ★`閉じる` / `キャンセル` / `読み込み中…` / `種類` / `日付` / `開始日` / `終了日` / + // `データの取得に失敗しました` / `選択` は上に既にあるため再掲しない★ + ログ: "Logs", + 制御盤のログ: "Controller logs", + 接続中: "Connected", + 未接続: "Not connected", + "最終接続 {日時}": "Last seen {日時}", + "最終接続 -": "Last seen -", + 種類を1つ以上選んでください: "Select at least one type", + 種類がまだありません: "No types yet", + 日付の範囲が不正です: "Invalid date range", + 開始日は終了日以前にしてください: "The start date must be on or before the end date", + 制御盤の結果の形が不正です: "The controller returned a malformed result", + 期間: "Period", + 直近1時間: "Last 1 hour", + 直近3時間: "Last 3 hours", + 直近24時間: "Last 24 hours", + // ★既存の D-Sec ログの「全て」ボタンも EN で "All" になる(M3)★ + 全て: "All", + 並べ替え: "Sort", + "種類+日付": "Type + date", + このページをすべて選択: "Select all on this page", + 該当するファイルはありません: "No matching files", + "件数が多いため、新しいものから一部だけを表示しています": + "Too many files; showing the newest only", + ファイル名: "File name", + 大きさ: "Size", + 更新日時: "Modified", + ページあたり: "Per page", + "選択: {n}件・合計 {size}": "Selected: {n} · {size}", + "1回に取り出せるのは {max} までです": "You can export up to {max} at a time", + "1回に選べるのは {n} 件までです": "You can select up to {n} files at a time", + エクスポート: "Export", + "制御盤で準備中…": "Preparing on the controller…", + "ダウンロードを開始しました({size})": "Download started ({size})", + "制御盤が接続されていません(最終接続: {日時})": + "Controller is offline (last seen: {日時})", + "制御盤から応答がありません。しばらくしてから再度お試しください": + "No response from the controller. Please try again later", + この制御盤は別のエクスポートを処理中です: + "This controller is busy with another export", + "制御盤が混み合っています。しばらくしてから再度お試しください": + "The controller is busy. Please try again later", + "制御盤でエラーが発生しました({code})": "Controller error ({code})", + "選んだファイルが大きすぎます(1回 {max} まで)": + "Selected files are too large (max {max} per export)", + "選んだファイルが多すぎます。数を減らしてください": + "Too many files selected. Please select fewer files", + 制御盤トークン: "Controller token", + "発行日時 {日時}": "Issued at {日時}", + 未発行: "Not issued", + "発行(作り直し)": "Issue (regenerate)", + 失効: "Revoke", + コピー: "Copy", + "トークンを発行します。発行済みのトークンは使えなくなります。よろしいですか?": + "Issue a token? Any existing token will stop working.", + "この値は二度と表示されません。制御盤の設定ファイルに入れてください": + "This value will not be shown again. Put it in the controller config file.", + "トークンを失効させます。この制御盤はログを送れなくなります。よろしいですか?": + "Revoke the token? This controller will no longer be able to send logs.", }; export function translate(lang: Lang, text: string): string { diff --git a/lib/logger/__tests__/ds-agent-events.test.ts b/lib/logger/__tests__/ds-agent-events.test.ts new file mode 100644 index 0000000..c50d06c --- /dev/null +++ b/lib/logger/__tests__/ds-agent-events.test.ts @@ -0,0 +1,183 @@ +/** + * 制御盤ログのイベント(指示書AY 段階10)に禁止キーが載らないことを確かめる。 + * ★`file_name` / `file_ids` / `file_id` / `token` / `authorization` をキーにも値にも出さない★(D12)。 + */ +import { describe, expect, test } from "vitest"; +import type { LogEvent } from "@/lib/logger/events"; +import { createLogger, emitTo } from "@/lib/logger"; +import { runWithRequestId } from "@/lib/logger/context"; + +/** イベントのキー・入れ子のキーに出てはいけない名前(段階10) */ +const FORBIDDEN_EVENT_KEYS = [ + "file_name", + "file_ids", + "file_id", + "token", + "authorization", +] as const; + +type KeysOfUnion = T extends unknown ? keyof T : never; +type EventKeys = KeysOfUnion; +type ForbiddenHit = Extract; + +/** + * LogEvent のキー集合に禁止キーが1つでも混ざると、この代入が型エラーになる。 + */ +const _noForbiddenKeysOnLogEvent: ForbiddenHit extends never ? true : never = true; +void _noForbiddenKeysOnLogEvent; + +/** オブジェクトのキーを再帰的に集める(配列の要素も辿る) */ +function collectKeys(value: unknown, into: Set): void { + if (value === null || typeof value !== "object") return; + if (Array.isArray(value)) { + for (const item of value) collectKeys(item, into); + return; + } + for (const [key, child] of Object.entries(value as Record)) { + into.add(key); + collectKeys(child, into); + } +} + +function emitJson(event: LogEvent, level: "info" | "warn" = "info"): Record { + const lines: string[] = []; + const target = createLogger({ + write(s: string) { + lines.push(s); + }, + }); + runWithRequestId("ds-agent-events-test", () => { + emitTo(target, level, event); + }); + expect(lines).toHaveLength(1); + return JSON.parse(lines[0]) as Record; +} + +/** 段階10の表にあるイベントを1つずつ(フィールドは表どおり。禁止キーは付けない) */ +const SAMPLE_EVENTS: Array<{ level: "info" | "warn"; event: LogEvent }> = [ + { + level: "info", + event: { + event: "ds_agent.connected", + serial_number: "DS-002", + agent_version: "1.0.0", + }, + }, + { + level: "info", + event: { + event: "ds_agent.disconnected", + serial_number: "DS-002", + agent_version: null, + }, + }, + { + level: "warn", + event: { + event: "ds_agent.auth_failed", + route: "/api/ds-agent/commands", + method: "GET", + reason: "missing", + }, + }, + { + level: "info", + event: { event: "ds_agent.token_issued", serial_number: "DS-002" }, + }, + { + level: "info", + event: { event: "ds_agent.token_revoked", serial_number: "DS-002" }, + }, + { + level: "info", + event: { + event: "ds_logs.list", + serial_number: "DS-002", + item_count: 3, + truncated: false, + duration_ms: 12, + result: "ok", + }, + }, + { + level: "info", + event: { + event: "ds_logs.list", + serial_number: "DS-002", + item_count: 0, + truncated: false, + duration_ms: 5, + result: "agent_error", + agent_code: "io_error", + }, + }, + { + level: "info", + event: { + event: "ds_logs.export_prepared", + serial_number: "DS-002", + file_count: 2, + total_bytes: 1024, + duration_ms: 40, + }, + }, + { + level: "info", + event: { + event: "ds_logs.export_started", + serial_number: "DS-002", + file_count: 2, + }, + }, + { + level: "info", + event: { + event: "ds_logs.export_completed", + serial_number: "DS-002", + file_count: 2, + total_bytes: 1024, + duration_ms: 90, + }, + }, + { + level: "warn", + event: { + event: "ds_logs.export_failed", + serial_number: "DS-002", + stage: "prepare", + reason: "too_large", + bytes_sent: 0, + agent_code: "too_large", + }, + }, +]; + +describe("ds_agent / ds_logs イベント(指示書AY 段階10)", () => { + test.each(SAMPLE_EVENTS)( + "$event.event の出力に禁止キー(file_name / file_ids / file_id / token / authorization)が無い", + ({ level, event }) => { + const out = emitJson(event, level); + const keys = new Set(); + collectKeys(out, keys); + for (const forbidden of FORBIDDEN_EVENT_KEYS) { + expect(keys.has(forbidden), `forbidden key present: ${forbidden}`).toBe(false); + } + // 値の文字列にもトークンっぽいキー名を埋め込まない(serial 等の通常値は除外) + const blob = JSON.stringify(out); + expect(blob).not.toMatch(/"file_name"/); + expect(blob).not.toMatch(/"file_ids"/); + expect(blob).not.toMatch(/"file_id"/); + expect(blob).not.toMatch(/"token"/); + expect(blob).not.toMatch(/"authorization"/); + } + ); + + test("token_issued / token_revoked は serial_number だけ(表3 KA8)", () => { + for (const name of ["ds_agent.token_issued", "ds_agent.token_revoked"] as const) { + const sample = SAMPLE_EVENTS.find((s) => s.event.event === name); + expect(sample).toBeDefined(); + const keys = Object.keys(sample!.event).sort(); + expect(keys).toEqual(["event", "serial_number"]); + } + }); +}); diff --git a/lib/logger/events.ts b/lib/logger/events.ts index 6ed6bf5..b24f7c0 100644 --- a/lib/logger/events.ts +++ b/lib/logger/events.ts @@ -471,6 +471,98 @@ } /** + * 制御盤のパス(`/api/ds-agent/`)の Bearer 認証の失敗(指示書AY 段階5・段階10。proxy が出す)。 + * ★ヘッダの値・トークンは載せない★(`authorization` / `token` は redact の禁止キーでもある)。 + * `AuthLogEvent` に合わせて `route` / `method` を持つ(M10)。 + */ +export interface DsAgentAuthFailedLogEvent { + event: "ds_agent.auth_failed"; + route: string; + method: string; + /** `missing` = Authorization ヘッダが無い / `invalid` = あるが通らない */ + reason: "missing" | "invalid"; +} + +/** + * 制御盤の接続状態の変化(指示書AY 段階10。relay の `onEvent`。★状態が変わったときだけ★。表5 R39)。 + */ +export interface DsAgentConnectionLogEvent { + event: "ds_agent.connected" | "ds_agent.disconnected"; + serial_number: string; + agent_version: string | null; +} + +/** 制御盤トークンの発行・失効(指示書AY 表3 KA8。★`serial_number` だけ★。トークン・ハッシュは載せない) */ +export interface DsAgentTokenLogEvent { + event: "ds_agent.token_issued" | "ds_agent.token_revoked"; + serial_number: string; +} + +/** + * 制御盤のログの一覧(`GET /ds-devices/{serial_number}/logs`。指示書AY 段階10)。 + * ★ファイル名・file_id は載せない★(件数だけ。D12)。 + */ +export interface DsLogsListLogEvent { + event: "ds_logs.list"; + serial_number: string; + item_count: number; + truncated: boolean; + duration_ms: number; + result: "ok" | "offline" | "timeout" | "busy" | "agent_error"; + /** `result` が `agent_error` のときだけ。制御盤の code、または D-Watch が付ける `result_invalid`(指示書BJ J2)を64文字で切ったもの */ + agent_code?: string; +} + +/** エクスポートの準備完了(POST exports の 201) */ +export interface DsLogsExportPreparedLogEvent { + event: "ds_logs.export_prepared"; + serial_number: string; + file_count: number; + total_bytes: number; + duration_ms: number; +} + +/** エクスポートを流し始めた(claim で最初のチャンクが届いた) */ +export interface DsLogsExportStartedLogEvent { + event: "ds_logs.export_started"; + serial_number: string; + file_count: number; +} + +/** エクスポートの完了(complete が照合を通った。relay の `onEvent`) */ +export interface DsLogsExportCompletedLogEvent { + event: "ds_logs.export_completed"; + serial_number: string; + file_count: number; + total_bytes: number; + duration_ms: number; +} + +/** + * エクスポートの失敗・取り消し・失効・タイムアウト(relay の `onEvent`)。 + * ★`reason` は固定の文字列★(制御盤から来た `message` は載せない)。`agent_code` は制御盤の code があるときだけ・64文字で切る。 + */ +export interface DsLogsExportFailedLogEvent { + event: "ds_logs.export_failed"; + serial_number: string; + stage: "prepare" | "send"; + reason: + | "prepare_timeout" + | "first_chunk_timeout" + | "idle_timeout" + | "flow_timeout" + | "agent_error" + | "too_large" + | "chunk_invalid" + | "chunk_out_of_order" + | "complete_invalid" + | "cancelled" + | "expired"; + bytes_sent: number; + agent_code?: string; +} + +/** * ロガー自身が出す内部イベント。本番で禁止フィールドを取り除いた事実を伝える。 * 載せるのはキー名のみ。値(施設名・電話番号・自由記述の中身)は絶対に載せない。 * 呼び出し側が偽装できないよう LogEvent の union には含めない。 @@ -498,4 +590,12 @@ | DataAnomalyLogEvent | SettingsStoreCorruptLogEvent | SettingsValueInvalidLogEvent - | AuthLogEvent; + | AuthLogEvent + | DsAgentAuthFailedLogEvent + | DsAgentConnectionLogEvent + | DsAgentTokenLogEvent + | DsLogsListLogEvent + | DsLogsExportPreparedLogEvent + | DsLogsExportStartedLogEvent + | DsLogsExportCompletedLogEvent + | DsLogsExportFailedLogEvent; diff --git a/lib/proxy/__tests__/authenticate.test.ts b/lib/proxy/__tests__/authenticate.test.ts index fcb1414..2debdc2 100644 --- a/lib/proxy/__tests__/authenticate.test.ts +++ b/lib/proxy/__tests__/authenticate.test.ts @@ -2,9 +2,11 @@ * 段階2 の判定(IP制限 → 認証)のテスト。 * ★Response は組み立てず判定だけを返す設計なので、素の値で全経路を固定できる★ */ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { authenticate, + authenticateAgent, + isAgentPath, isApiPath, isPublicPath, SESSION_COOKIE_NAME, @@ -169,3 +171,139 @@ expect(SESSION_COOKIE_NAME).toBe("dwatch_session"); }); }); + +/** + * 指示書AY §3 表4(`lib/proxy/authenticate.ts`。`deps` で IP・`AUTH_TOKEN`・トークン検証を差し替える)。 + * ブリーフ §5 と監督判断 第2ラウンドから本書で具体化。★P1・P2 の行は §11 Q14・Q15 の採用で確定★。 + * + * | # | パス | IP | cookie | Authorization | 期待 | + * | A1 | /api/ds-agent/commands | 許可 | なし | 正しい Bearer | allow | + * | A2 | 同上 | 許可 | 正しい | なし | unauthenticated(401) | + * | A3 | 同上 | 許可 | なし | 違う Bearer | unauthenticated | + * | A4 | 同上 | 許可 | 正しい | 違う Bearer | unauthenticated | + * | A5 | /api/ds-devices | 許可 | なし | 正しい Bearer | unauthenticated(Bearer を無視) | + * | A6 | /api/ds-devices | 許可 | 正しい | 正しい Bearer | allow(Bearer は無視) | + * | A7 | /api/ds-agent/commands | 拒否 | なし | 正しい Bearer | deny_ip(403)。トークン検証を呼ばない | + * | A8 | /api/ds-agents(似た名前) | 許可 | 正しい | なし | allow(agent パスではない) | + * | A9 | /ds-logs(画面) | 許可 | なし | 正しい Bearer | unauthenticated(redirect) | + * | A10 | /api/ds-agent / /api/ds-agent/ | 許可 | なし | 正しい Bearer | allow(agent パス) | + * | A11 | /api/ds-devices/DS-002/agent-token | 許可 | なし | 正しい Bearer | unauthenticated(制御盤のトークンでトークンを作り直せない) | + * + * ★proxy の振り分け(段階5-4)★: `isAgentPath()` なら `authenticateAgent()`(Bearer だけ)、それ以外は `authenticate()`(cookie だけ)。 + * 下の `decide()` はその振り分けを写したもの。「正しい Bearer」は `deps.verifyAgentToken` のスタブで作る(ストアに触らない)。 + */ +const GOOD_BEARER = `Bearer ${"G".repeat(43)}`; +const BAD_BEARER = `Bearer ${"B".repeat(43)}`; +const GOOD_COOKIE = "correct-token"; + +const ipAllowed = { + forwardedFor: null, + deps: { allowedIps: [] as readonly string[], allowAllWhenEmpty: true, allowDirectAccess: true, trustedProxyHops: 1 }, +}; +const ipDenied = { + forwardedFor: "1.2.3.4, 198.51.100.9", + deps: { + allowedIps: ["203.0.113.10"] as readonly string[], + allowAllWhenEmpty: false, + allowDirectAccess: false, + trustedProxyHops: 1, + }, +}; + +function verifyStub() { + return vi.fn(async (header: string | null) => (header === GOOD_BEARER ? "DS-002" : null)); +} + +async function decide( + pathname: string, + ip: "allow" | "deny", + cookie: string | null, + authorization: string | null, + verifyAgentToken: ReturnType +) { + const { forwardedFor, deps } = ip === "allow" ? ipAllowed : ipDenied; + if (isAgentPath(pathname)) { + return authenticateAgent(pathname, forwardedFor, authorization, { ...deps, verifyAgentToken }); + } + return authenticate(pathname, forwardedFor, cookie, { ...deps, expectedToken: GOOD_COOKIE }); +} + +describe("isAgentPath() — 表4 A8・A10", () => { + it.each<[string, boolean]>([ + ["/api/ds-agent", true], + ["/api/ds-agent/", true], + ["/api/ds-agent/commands", true], + ["/api/ds-agent/commands/abc/chunks/0", true], + ["/api/ds-agents", false], + ["/api/ds-agentx/commands", false], + ["/api/ds-devices", false], + ["/ds-logs", false], + ])("%s → %s", (pathname, expected) => { + expect(isAgentPath(pathname)).toBe(expected); + }); +}); + +describe("制御盤の認証(Bearer)と通常の認証(cookie)の分離 — 表4", () => { + it.each<[string, string, "allow" | "deny", string | null, string | null, string]>([ + ["A1", "/api/ds-agent/commands", "allow", null, GOOD_BEARER, "allow"], + ["A2", "/api/ds-agent/commands", "allow", GOOD_COOKIE, null, "unauthenticated"], + ["A3", "/api/ds-agent/commands", "allow", null, BAD_BEARER, "unauthenticated"], + ["A4", "/api/ds-agent/commands", "allow", GOOD_COOKIE, BAD_BEARER, "unauthenticated"], + ["A5", "/api/ds-devices", "allow", null, GOOD_BEARER, "unauthenticated"], + ["A6", "/api/ds-devices", "allow", GOOD_COOKIE, GOOD_BEARER, "allow"], + ["A7", "/api/ds-agent/commands", "deny", null, GOOD_BEARER, "deny_ip"], + ["A8", "/api/ds-agents", "allow", GOOD_COOKIE, null, "allow"], + ["A9", "/ds-logs", "allow", null, GOOD_BEARER, "unauthenticated"], + ["A10 /api/ds-agent", "/api/ds-agent", "allow", null, GOOD_BEARER, "allow"], + ["A10 /api/ds-agent/", "/api/ds-agent/", "allow", null, GOOD_BEARER, "allow"], + ["A11", "/api/ds-devices/DS-002/agent-token", "allow", null, GOOD_BEARER, "unauthenticated"], + ])("%s %s", async (_label, pathname, ip, cookie, authorization, expected) => { + const verify = verifyStub(); + await expect(decide(pathname, ip, cookie, authorization, verify)).resolves.toEqual({ kind: expected }); + }); + + it("A7 IP 拒否ならトークン検証を呼ばない", async () => { + const verify = verifyStub(); + await decide("/api/ds-agent/commands", "deny", null, GOOD_BEARER, verify); + expect(verify).toHaveBeenCalledTimes(0); + }); + + it.each<[string, string, string | null]>([ + ["A5", "/api/ds-devices", null], + ["A6", "/api/ds-devices", GOOD_COOKIE], + ["A9", "/ds-logs", null], + ["A11", "/api/ds-devices/DS-002/agent-token", null], + ])("%s agent パス以外では Bearer を見ない(トークン検証を呼ばない)", async (_label, pathname, cookie) => { + const verify = verifyStub(); + await decide(pathname, "allow", cookie, GOOD_BEARER, verify); + expect(verify).toHaveBeenCalledTimes(0); + }); + + it.each<[string]>([["/api/ds-agent/commands"], ["/api/ds-agent"], ["/api/ds-agent/"]])( + "A2 の保険: authenticate()(同期)は agent パスなら正しい cookie でも unauthenticated(%s)", + (pathname) => { + expect( + authenticate(pathname, null, GOOD_COOKIE, { ...ipAllowed.deps, expectedToken: GOOD_COOKIE }) + ).toEqual({ kind: "unauthenticated" }); + } + ); + + it("A7 の保険: authenticate()(同期)でも agent パスの IP 拒否は deny_ip", () => { + expect( + authenticate("/api/ds-agent/commands", ipDenied.forwardedFor, GOOD_COOKIE, { + ...ipDenied.deps, + expectedToken: GOOD_COOKIE, + }) + ).toEqual({ kind: "deny_ip" }); + }); + + it("authenticateAgent() の戻り値に serial を入れない(D5)", async () => { + const verify = verifyStub(); + const decision = await authenticateAgent("/api/ds-agent/commands", null, GOOD_BEARER, { + ...ipAllowed.deps, + verifyAgentToken: verify, + }); + expect(decision).toEqual({ kind: "allow" }); + expect(JSON.stringify(decision)).not.toContain("DS-002"); + }); +}); diff --git a/lib/proxy/authenticate.ts b/lib/proxy/authenticate.ts index fec8515..f8db1c8 100644 --- a/lib/proxy/authenticate.ts +++ b/lib/proxy/authenticate.ts @@ -17,6 +17,7 @@ ALLOWED_IPS, TRUSTED_PROXY_HOPS, } from "@/lib/config/allowed-ips"; +import { verifyAgentAuthorization } from "@/lib/ds-agent/tokens"; /** 発行する cookie の名前。定義は `lib/auth/cookie.ts`(発行・削除・判定でズレないように1箇所) */ export { SESSION_COOKIE_NAME }; @@ -48,6 +49,32 @@ } /** + * 制御盤(ラズパイ)向けのパスかどうか(指示書AY 段階5。D5・D22)。 + * ★このパスは Bearer だけで通し、cookie では通さない★。それ以外のパスは cookie だけで通し、Bearer を見ない(表4)。 + * ★前方一致で `/api/ds-agents` を拾わないこと★(表4 A8)。 + */ +export function isAgentPath(pathname: string): boolean { + return pathname === "/api/ds-agent" || pathname.startsWith("/api/ds-agent/"); +} + +interface IpDeps { + allowedIps?: readonly string[]; + allowAllWhenEmpty?: boolean; + allowDirectAccess?: boolean; + trustedProxyHops?: number; +} + +/** IP制限の判定。★`authenticate()` と `authenticateAgent()` で共有する(複製しない)★ */ +function ipAllowed(forwardedFor: string | null, deps: IpDeps): boolean { + const allowedIps = deps.allowedIps ?? ALLOWED_IPS; + const allowAllWhenEmpty = deps.allowAllWhenEmpty ?? ALLOW_ALL_WHEN_EMPTY; + const allowDirectAccess = deps.allowDirectAccess ?? ALLOW_DIRECT_ACCESS; + const trustedProxyHops = deps.trustedProxyHops ?? TRUSTED_PROXY_HOPS; + const ip = clientIp(forwardedFor, trustedProxyHops); + return isAllowedIp(ip, allowedIps, allowAllWhenEmpty, allowDirectAccess); +} + +/** * IP制限と認証を判定する。 * * @param pathname `request.nextUrl.pathname` @@ -60,31 +87,25 @@ pathname: string, forwardedFor: string | null, sessionCookieValue: string | null, - deps: { - allowedIps?: readonly string[]; - allowAllWhenEmpty?: boolean; - allowDirectAccess?: boolean; - trustedProxyHops?: number; + deps: IpDeps & { expectedToken?: string | undefined; } = {} ): AuthDecision { - const allowedIps = deps.allowedIps ?? ALLOWED_IPS; - const allowAllWhenEmpty = deps.allowAllWhenEmpty ?? ALLOW_ALL_WHEN_EMPTY; - const allowDirectAccess = deps.allowDirectAccess ?? ALLOW_DIRECT_ACCESS; - const trustedProxyHops = deps.trustedProxyHops ?? TRUSTED_PROXY_HOPS; const expectedToken = "expectedToken" in deps ? deps.expectedToken : readAuthToken(); // 1. IP制限。★/login も含め全パスにかける★ // ログイン画面だけ誰でも開けるのでは、IP制限をかけた意味が薄れる - const ip = clientIp(forwardedFor, trustedProxyHops); - if (!isAllowedIp(ip, allowedIps, allowAllWhenEmpty, allowDirectAccess)) { + if (!ipAllowed(forwardedFor, deps)) { return { kind: "deny_ip" }; } // 2. 認証。/login と /api/auth/login はここで通す(matcher では除外しない) if (isPublicPath(pathname)) return { kind: "allow" }; + // ★制御盤のパスは cookie では通さない★(proxy は `authenticateAgent()` を呼ぶので、ここは保険。表4 A2) + if (isAgentPath(pathname)) return { kind: "unauthenticated" }; + // ★cookie の値も必ず定数時間比較を通す。ここで === を書かないこと★ if (!verifyToken(sessionCookieValue, expectedToken)) { return { kind: "unauthenticated" }; @@ -92,3 +113,25 @@ return { kind: "allow" }; } + +/** + * 制御盤のパス(`isAgentPath()`)の判定(指示書AY 段階5。D5・D22)。順序は ★IP制限(403)→ Bearer(401)★。 + * ★IP で拒否したらトークン検証を呼ばない★(表4 A7)。★戻り値に serial を入れない★(後段へヘッダで渡さない。 + * ルートは `requireAgent()` で再検証して serial を得る)。 + * + * @param authorization `Authorization` ヘッダの生値 + * @param deps IP の設定と `verifyAgentToken`(★テストのために引数化★。省略時は `verifyAgentAuthorization`) + */ +export async function authenticateAgent( + pathname: string, + forwardedFor: string | null, + authorization: string | null, + deps: IpDeps & { + verifyAgentToken?: (authorization: string | null) => Promise; + } = {} +): Promise { + if (!ipAllowed(forwardedFor, deps)) return { kind: "deny_ip" }; + const verify = deps.verifyAgentToken ?? verifyAgentAuthorization; + const serial = await verify(authorization); + return serial === null ? { kind: "unauthenticated" } : { kind: "allow" }; +} diff --git a/lib/types/index.ts b/lib/types/index.ts index e7c21e0..eebb929 100644 --- a/lib/types/index.ts +++ b/lib/types/index.ts @@ -43,7 +43,30 @@ /** IP制限で拒否。proxy.ts が返す */ | "forbidden" /** ログイン時のトークン不一致。★これ以上の情報を message に書かないこと★ */ - | "invalid_token"; + | "invalid_token" + /* ── 制御盤のログ(指示書AY 段階2-2。D-179)。★`validation_error` は足さない(検証エラーは `validation_failed`。D17)★ ── */ + /** 制御盤が未接続(409) */ + | "agent_offline" + /** 制御盤から時間内に応答が無い(504) */ + | "agent_timeout" + /** 制御盤がエラーを返した・結果の形が不正(502。`details.agent_code`) */ + | "agent_error" + /** 制御盤への指示の待ち行列が満杯(429) */ + | "agent_busy" + /** 同時エクスポートの上限(429) */ + | "export_busy" + /** エクスポートが無い・失効・使用済み・シリアル違い(404) */ + | "export_not_found" + /** チャンクの長さ・ハッシュの誤り(400。制御盤向け) */ + | "chunk_invalid" + /** チャンクの順番違い(409。制御盤向け) */ + | "chunk_out_of_order" + /** エクスポートが取り消し・失敗・失効した後(410。制御盤向け) */ + | "export_cancelled" + /** ブラウザ側の待ちが時間切れになった後の結果(410。制御盤向け) */ + | "command_expired" + /** 制御盤から来た JSON の形の誤り(400。制御盤向け。D25 ④) */ + | "result_invalid"; export interface ApiError { error: { @@ -1139,6 +1162,108 @@ note: string | null; } +// ---------------------------------------------------------------- 制御盤のログ(指示書AY。D-179) + +/** ログの種類。★値は制御盤の設定ファイルが決める(U5)★。D-Watch は持たない・直書きしない */ +export interface DsLogType { + key: string; + label: string; +} + +export interface DsLogFile { + file_id: string; + /** `{root の name}/{root からの相対パス}`(zip の中のパスと同じ。D24)。★D-Watch は解析しない★ */ + name: string; + /** `DsLogType.key` のどれか */ + type: string; + size_bytes: number; + modified_at: string; +} + +export interface DsAgentStatus { + connected: boolean; + /** 最終接続日時。一度も接続していなければ `null` */ + last_seen_at: string | null; + agent_version: string | null; +} + +export type DsLogSort = "date_desc" | "type_date_desc"; + +export interface DsLogListResponse { + agent: DsAgentStatus; + /** 制御盤から来た順 */ + types: DsLogType[]; + items: DsLogFile[]; + /** 制御盤が2万件か4MBで一覧を打ち切ったか(D18) */ + truncated: boolean; + meta: Meta; +} + +export interface DsLogExportRequest { + file_ids: string[]; +} + +export interface DsLogExportResponse { + export_id: string; + download_url: string; + total_bytes: number; + file_count: number; + expires_in_sec: number; +} + +/** `GET /ds-devices/{serial_number}/agent-token`。★トークン・ハッシュは返さない★(P1) */ +export interface DsAgentTokenStatus { + issued_at: string | null; +} + +/** `POST /ds-devices/{serial_number}/agent-token` の 201。★`token` はこの応答で1回だけ★(D16) */ +export interface DsAgentTokenIssued { + token: string; + issued_at: string; +} + +export type DsAgentCommandKind = "list" | "export" | "send"; + +export type DsAgentCommand = + | { command_id: string; kind: "list"; params: Record } + | { + command_id: string; + kind: "export"; + params: { file_ids: string[]; max_bytes: number; chunk_bytes: number }; + } + /** チャンク・complete・fail の URL は `export_command_id`(D25 ①) */ + | { command_id: string; kind: "send"; params: { export_command_id: string } }; + +/** 一覧の結果(制御盤 → D-Watch)。`DsAgentListResult` の成功の形 */ +export interface DsAgentListResultData { + types: DsLogType[]; + items: DsLogFile[]; + truncated: boolean; +} + +/** 一覧の失敗の報告(制御盤 → D-Watch)。ブラウザには 502 `agent_error`(段階7-2 W9) */ +export interface DsAgentListResultError { + error: { code: string; message: string }; +} + +export type DsAgentListResult = DsAgentListResultData | DsAgentListResultError; + +export interface DsAgentPrepared { + total_bytes: number; + sha256: string; + file_count: number; +} + +export interface DsAgentComplete { + total_bytes: number; + sha256: string; +} + +export interface DsAgentFail { + code: string; + message: string; +} + export type DsAlertType = "security_off" | "disconnected"; /** `DataSource.dsAlerts()` の引数(指示書BB A6・D17・D-182。★必須★ — 任意引数は渡し忘れても動いてしまう。指示書AC C-4 項4) */ diff --git a/lib/ui/modal.ts b/lib/ui/modal.ts new file mode 100644 index 0000000..bad5a11 --- /dev/null +++ b/lib/ui/modal.ts @@ -0,0 +1,25 @@ +/** + * モーダル共通の純粋関数(指示書AY 段階9-2。M2)。 + * React / DOM を import しない。画面部品はここから読む。 + */ + +/** + * 背景のクリックで閉じてよいか。 + * ★押した(mousedown)のも離した(click)のも背景そのもののときだけ★閉じる。 + * 入力の中で文字を選び、背景の上で離したときに閉じないため。 + */ +export function shouldCloseOnBackdrop(downOnOverlay: boolean, clickOnOverlay: boolean): boolean { + return downOnOverlay && clickOnOverlay; +} + +/** + * Tab でフォーカスを窓の中に閉じ込める。 + * `count` は押した時点のフォーカスできる要素の数、`activeIndex` は今のフォーカスの位置(窓の外なら -1)。 + * 戻り値はフォーカスを移す位置。null はブラウザの既定の移動に任せる。 + */ +export function trapFocusIndex(count: number, activeIndex: number, backward: boolean): number | null { + if (count <= 0) return null; + const last = count - 1; + if (backward) return activeIndex <= 0 || activeIndex > last ? last : null; + return activeIndex < 0 || activeIndex >= last ? 0 : null; +} diff --git a/proxy.ts b/proxy.ts index 1f6dbbd..60681c9 100644 --- a/proxy.ts +++ b/proxy.ts @@ -15,6 +15,7 @@ * (`docs/logging-design.md` §9 の申し送り) * 2. IP制限 … 拒否なら 403 * 3. 認証 … 未認証なら 画面は /login へリダイレクト、APIは 401 + * (★`/api/ds-agent/` 配下は cookie ではなく制御盤の Bearer で判定する(`authenticateAgent()`)。指示書AY D5・D22★) * * ★`runtime` を指定しないこと★ — proxy は Node.js ランタイム固定で、 * `runtime` を設定するとエラーになります(Edge は proxy では非対応)。 @@ -26,6 +27,8 @@ import { logEvent, runWithRequestId } from "@/lib/logger"; import { authenticate, + authenticateAgent, + isAgentPath, isApiPath, SESSION_COOKIE_NAME, } from "@/lib/proxy/authenticate"; @@ -34,7 +37,7 @@ REQUEST_ID_HEADER, } from "@/lib/proxy/request-id"; -export function proxy(request: NextRequest) { +export async function proxy(request: NextRequest) { // 1. 相関IDの発行(★認証より先★)。クライアント指定の値は信頼せず常に上書き const { requestId, headers } = issueRequestHeaders(request.headers); @@ -42,14 +45,18 @@ // こうしないと、弾いたリクエストのログだけ request_id が "-" になり、 // 「認証失敗もログで追跡できるようにする」という順序の意図(logging-design.md §9)が // 半分しか達成できない - return runWithRequestId(requestId, () => { + return runWithRequestId(requestId, async () => { const { pathname } = request.nextUrl; const forwardedFor = request.headers.get("x-forwarded-for"); - const decision = authenticate( - pathname, - forwardedFor, - request.cookies.get(SESSION_COOKIE_NAME)?.value ?? null - ); + const agentPath = isAgentPath(pathname); + const authorization = agentPath ? request.headers.get("authorization") : null; + const decision = agentPath + ? await authenticateAgent(pathname, forwardedFor, authorization) + : authenticate( + pathname, + forwardedFor, + request.cookies.get(SESSION_COOKIE_NAME)?.value ?? null + ); // 2. IP制限 if (decision.kind === "deny_ip") { @@ -79,6 +86,16 @@ // 画面を開くたびに出るとノイズになる。追跡したいログイン試行は // /api/auth/login 側で auth.login_success / auth.login_failed として記録する if (decision.kind === "unauthenticated") { + if (agentPath) { + // ★制御盤の認証の失敗は残す★(画面と違いノイズにならず、トークンの設定漏れ・失効の切り分けに要る)。 + // ★ヘッダの値は出さない★ + logEvent("warn", { + event: "ds_agent.auth_failed", + route: pathname, + method: request.method, + reason: authorization ? "invalid" : "missing", + }); + } if (isApiPath(pathname)) { return NextResponse.json( { error: { code: "unauthorized", message: "認証が必要です" } }, diff --git a/scripts/ds-agent-fake.ts b/scripts/ds-agent-fake.ts new file mode 100644 index 0000000..db159d2 --- /dev/null +++ b/scripts/ds-agent-fake.ts @@ -0,0 +1,461 @@ +/** + * 開発用の偽制御盤(指示書AY 段階11-3。D23・D24)。 + * + * D-Watch の `/api/ds-agent/` をポーリングし、list / export / send に固定の応答を返す。 + * ★本物の常駐プログラム(`dwatch-log-agent`)の代わりにはならない★ — 非 fixtures の通し確認用。 + * + * 実行例(トークンは画面で発行した値。★値をログに出さない★): + * $env:DS_AGENT_TOKEN='…' + * npx tsx scripts/ds-agent-fake.ts --server http://localhost:3100 --zip .\scratch\logs.zip + * npx tsx scripts/ds-agent-fake.ts --server http://localhost:3100 --zip .\scratch\logs.zip --fail-prepare too_large + * + * Args: --server --zip [--fail-prepare ] + * Env: DS_AGENT_TOKEN(必須。Bearer) + */ +import { createHash } from "node:crypto"; +import { readFileSync, statSync } from "node:fs"; +import { resolve } from "node:path"; + +const AGENT_VERSION = "fake-0.1.0"; +const POLL_WAIT_SEC = 25; +/** チャンク POST のタイムアウト(秒。D20。設計書と同じ) */ +const CHUNK_POST_TIMEOUT_SEC = 60; +const CHUNK_MAX_BYTES = 4 * 1024 * 1024; +const USER_AGENT = `dwatch-ds-agent-fake/${AGENT_VERSION}`; + +const LOG_TYPES = [ + { key: "api_client", label: "API 通信(報告間隔の取得・通信の有効/無効)(仮)" }, + { key: "postcondition_security", label: "セキュリティ状態の報告(仮)" }, + { key: "postcondition_kick_action", label: "セコム状態の監視・清掃の起動(仮)" }, + { key: "send_start_sig", label: "清掃開始信号(仮)" }, +] as const; + +type Command = + | { command_id: string; kind: "list"; params: Record } + | { + command_id: string; + kind: "export"; + params: { file_ids?: string[]; max_bytes?: number; chunk_bytes?: number }; + } + | { command_id: string; kind: "send"; params: { export_command_id?: string } }; + +type Args = { + server: string; + zipPath: string; + failPrepare: string | null; +}; + +function usage(message?: string): never { + if (message) console.error(`[ds-agent-fake] ${message}`); + console.error( + "Usage: npx tsx scripts/ds-agent-fake.ts --server --zip [--fail-prepare ]\n" + + " Env: DS_AGENT_TOKEN (required; never logged)" + ); + process.exit(1); +} + +function parseArgs(argv: string[]): Args { + let server: string | null = null; + let zipPath: string | null = null; + let failPrepare: string | null = null; + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + const next = argv[i + 1]; + if (a === "--server" && next) { + server = next; + i++; + } else if (a === "--zip" && next) { + zipPath = next; + i++; + } else if (a === "--fail-prepare" && next) { + failPrepare = next; + i++; + } else if (a === "--help" || a === "-h") { + usage(); + } else { + usage(`unknown argument: ${a}`); + } + } + if (!server) usage("--server is required"); + if (!zipPath) usage("--zip is required"); + return { server: server.replace(/\/$/, ""), zipPath: resolve(zipPath), failPrepare }; +} + +/** JST 壁時計の ISO(+09:00)。modified_at はオフセット必須 */ +function toJstIso(date: Date): string { + const jst = new Date(date.getTime() + 9 * 60 * 60 * 1000); + const y = jst.getUTCFullYear(); + const m = String(jst.getUTCMonth() + 1).padStart(2, "0"); + const d = String(jst.getUTCDate()).padStart(2, "0"); + const hh = String(jst.getUTCHours()).padStart(2, "0"); + const mm = String(jst.getUTCMinutes()).padStart(2, "0"); + const ss = String(jst.getUTCSeconds()).padStart(2, "0"); + return `${y}-${m}-${d}T${hh}:${mm}:${ss}+09:00`; +} + +function sha256Hex(bytes: Uint8Array | Buffer): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +/** 例外メッセージにトークンが混ざっていたら伏せる(値は絶対に出さない) */ +function safeError(err: unknown, token: string): string { + const text = err instanceof Error ? `${err.name}: ${err.message}` : String(err); + return token && text.includes(token) ? text.split(token).join("***") : text; +} + +/** + * 固定の一覧(約10件。実機の種類・name の形。B-9・M6)。 + * name は `{root の name}/{相対パス}` = `<種類>/<種類>.log` と日付つき。 + */ +function fixedListResult(now: Date) { + const nowMs = now.getTime(); + const day = 24 * 60 * 60 * 1000; + const items: Array<{ + file_id: string; + name: string; + type: string; + size_bytes: number; + modified_at: string; + }> = []; + let n = 0; + const push = (type: string, name: string, modifiedMs: number, size: number) => { + n += 1; + items.push({ + file_id: `fake-${n}`, + name, + type, + size_bytes: size, + modified_at: toJstIso(new Date(modifiedMs)), + }); + }; + + // 各種類の最新 + for (const t of LOG_TYPES) { + push(t.key, `${t.key}/${t.key}.log`, nowMs - items.length * 60_000, 4096 + items.length * 512); + } + // 日付つき(各種類1日分 + 追加2件で約10件) + const dates = [1, 2].map((d) => toJstIso(new Date(nowMs - d * day)).slice(0, 10)); + push( + "api_client", + `api_client/api_client.log.${dates[0]}`, + nowMs - day, + 8192 + ); + push( + "postcondition_security", + `postcondition_security/postcondition_security.log.${dates[0]}`, + nowMs - day - 60_000, + 12288 + ); + push( + "postcondition_kick_action", + `postcondition_kick_action/postcondition_kick_action.log.${dates[0]}`, + nowMs - day - 120_000, + 16384 + ); + push( + "send_start_sig", + `send_start_sig/send_start_sig.log.${dates[0]}`, + nowMs - day - 180_000, + 20480 + ); + push( + "api_client", + `api_client/api_client.log.${dates[1]}`, + nowMs - 2 * day, + 3072 + ); + push( + "postcondition_security", + `postcondition_security/postcondition_security.log.${dates[1]}`, + nowMs - 2 * day - 60_000, + 6144 + ); + + return { + types: LOG_TYPES.map((t) => ({ key: t.key, label: t.label })), + items, + truncated: false as const, + }; +} + +class FakeAgent { + private readonly base: string; + private readonly token: string; + private readonly zipBytes: Buffer; + private readonly zipSha256: string; + private readonly failPrepare: string | null; + /** export の command_id → 使った chunk_bytes(send で参照) */ + private readonly preparedChunkBytes = new Map(); + private stopping = false; + + constructor(opts: { server: string; token: string; zipPath: string; failPrepare: string | null }) { + this.base = opts.server; + this.token = opts.token; + this.failPrepare = opts.failPrepare; + const st = statSync(opts.zipPath); + if (!st.isFile()) throw new Error(`--zip is not a file: ${opts.zipPath}`); + this.zipBytes = readFileSync(opts.zipPath); + this.zipSha256 = sha256Hex(this.zipBytes); + console.log( + `[ds-agent-fake] zip loaded path=${opts.zipPath} bytes=${this.zipBytes.length} sha256=${this.zipSha256}` + ); + if (opts.failPrepare) { + console.log(`[ds-agent-fake] --fail-prepare ${opts.failPrepare} (export will fail)`); + } + } + + private headers(extra?: Record): Record { + return { + Authorization: `Bearer ${this.token}`, + "X-Agent-Version": AGENT_VERSION, + "User-Agent": USER_AGENT, + ...extra, + }; + } + + private commandUrl(commandId: string, ...parts: Array): string { + const enc = encodeURIComponent(commandId); + const rest = parts.map((p) => String(p)).join("/"); + return `${this.base}/api/ds-agent/commands/${enc}/${rest}`; + } + + private async request( + method: string, + url: string, + init?: { body?: BodyInit | null; headers?: Record; timeoutMs?: number } + ): Promise { + const signal = + init?.timeoutMs !== undefined ? AbortSignal.timeout(init.timeoutMs) : undefined; + return fetch(url, { + method, + headers: this.headers(init?.headers), + body: init?.body ?? null, + redirect: "manual", + signal, + }); + } + + private async postJson(commandId: string, action: "result" | "prepared" | "complete" | "fail", body: unknown) { + const res = await this.request("POST", this.commandUrl(commandId, action), { + body: JSON.stringify(body), + headers: { "Content-Type": "application/json" }, + timeoutMs: 30_000, + }); + return res.status; + } + + async handle(cmd: Command): Promise { + switch (cmd.kind) { + case "list": + await this.doList(cmd.command_id); + return; + case "export": + await this.doExport(cmd); + return; + case "send": + await this.doSend(cmd); + return; + default: { + const unknown = cmd as { command_id?: string; kind?: string }; + if (unknown.command_id) { + await this.postJson(unknown.command_id, "fail", { + code: "unsupported", + message: `unknown kind: ${String(unknown.kind)}`, + }); + } + } + } + } + + private async doList(commandId: string): Promise { + const status = await this.postJson(commandId, "result", fixedListResult(new Date())); + console.log(`[ds-agent-fake] list result status=${status} items≈10`); + } + + private async doExport(cmd: Extract): Promise { + if (this.failPrepare) { + const status = await this.postJson(cmd.command_id, "fail", { + code: this.failPrepare, + message: `fake prepare failed: ${this.failPrepare}`, + }); + console.log(`[ds-agent-fake] export fail code=${this.failPrepare} status=${status}`); + return; + } + + const fileIds = Array.isArray(cmd.params.file_ids) ? cmd.params.file_ids : []; + const fileCount = Math.max(1, fileIds.length); + const chunkBytes = + typeof cmd.params.chunk_bytes === "number" && cmd.params.chunk_bytes > 0 + ? Math.min(cmd.params.chunk_bytes, CHUNK_MAX_BYTES) + : CHUNK_MAX_BYTES; + + const status = await this.postJson(cmd.command_id, "prepared", { + total_bytes: this.zipBytes.length, + sha256: this.zipSha256, + file_count: fileCount, + }); + if (status === 204) { + this.preparedChunkBytes.set(cmd.command_id, chunkBytes); + } + console.log( + `[ds-agent-fake] export prepared status=${status} bytes=${this.zipBytes.length} files=${fileCount}` + ); + } + + private async doSend(cmd: Extract): Promise { + const exportId = cmd.params.export_command_id; + if (typeof exportId !== "string" || exportId.length === 0) { + await this.postJson(cmd.command_id, "fail", { + code: "export_not_found", + message: "export_command_id missing", + }); + console.log("[ds-agent-fake] send fail: missing export_command_id"); + return; + } + + const chunkBytes = this.preparedChunkBytes.get(exportId) ?? CHUNK_MAX_BYTES; + let seq = 0; + for (let offset = 0; offset < this.zipBytes.length; offset += chunkBytes, seq++) { + const chunk = Uint8Array.from(this.zipBytes.subarray(offset, offset + chunkBytes)); + const chunkSha = sha256Hex(chunk); + let res: Response; + try { + res = await this.request("POST", this.commandUrl(exportId, "chunks", seq), { + body: chunk, + headers: { + "Content-Type": "application/octet-stream", + "X-Chunk-Sha256": chunkSha, + }, + timeoutMs: CHUNK_POST_TIMEOUT_SEC * 1000, + }); + } catch (err) { + console.error(`[ds-agent-fake] chunk ${seq} network error: ${safeError(err, this.token)}`); + await this.postJson(exportId, "fail", { + code: "io_error", + message: `chunk ${seq} upload failed`, + }); + return; + } + + if (res.status === 204) { + console.log(`[ds-agent-fake] chunk seq=${seq} bytes=${chunk.length} ok`); + continue; + } + if (res.status === 410 || res.status === 409) { + console.log(`[ds-agent-fake] chunk seq=${seq} stop status=${res.status}`); + return; + } + console.error(`[ds-agent-fake] chunk seq=${seq} rejected status=${res.status}`); + await this.postJson(exportId, "fail", { + code: "io_error", + message: `chunk ${seq} status ${res.status}`, + }); + return; + } + + const status = await this.postJson(exportId, "complete", { + total_bytes: this.zipBytes.length, + sha256: this.zipSha256, + }); + this.preparedChunkBytes.delete(exportId); + console.log(`[ds-agent-fake] complete status=${status} chunks=${seq}`); + } + + async run(): Promise { + console.log(`[ds-agent-fake] polling ${this.base}/api/ds-agent/commands (version=${AGENT_VERSION})`); + let backoffSec = 1; + while (!this.stopping) { + const url = `${this.base}/api/ds-agent/commands?wait=${POLL_WAIT_SEC}`; + let res: Response; + try { + res = await this.request("GET", url, { + timeoutMs: (POLL_WAIT_SEC + 10) * 1000, + }); + } catch (err) { + if (this.stopping) return; + console.warn( + `[ds-agent-fake] poll failed: ${safeError(err, this.token)} (retry in ${backoffSec}s)` + ); + await sleep(backoffSec * 1000); + backoffSec = Math.min(backoffSec * 2, 30); + continue; + } + + if (res.status === 200) { + backoffSec = 1; + let cmd: Command; + try { + cmd = (await res.json()) as Command; + } catch (err) { + console.error(`[ds-agent-fake] poll body not JSON: ${safeError(err, this.token)}`); + await sleep(backoffSec * 1000); + backoffSec = Math.min(backoffSec * 2, 30); + continue; + } + console.log(`[ds-agent-fake] command kind=${cmd.kind} id=${cmd.command_id}`); + // list はエクスポートと並行して返す(本物と同じ。D25 ③) + void this.handle(cmd).catch((err) => { + console.error(`[ds-agent-fake] handle error: ${safeError(err, this.token)}`); + }); + continue; + } + + if (res.status === 204) { + backoffSec = 1; + continue; + } + + if (res.status === 401 || res.status === 403) { + console.error( + `[ds-agent-fake] poll rejected status=${res.status} (token wrong/revoked or IP). retry in 30s` + ); + await sleep(30_000); + continue; + } + + console.warn(`[ds-agent-fake] poll status=${res.status} (retry in ${backoffSec}s)`); + await sleep(backoffSec * 1000); + backoffSec = Math.min(backoffSec * 2, 30); + } + } + + stop(): void { + this.stopping = true; + } +} + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +async function main(): Promise { + const args = parseArgs(process.argv.slice(2)); + const token = process.env.DS_AGENT_TOKEN; + if (!token || token.trim() === "") { + usage("DS_AGENT_TOKEN env is required"); + } + + const agent = new FakeAgent({ + server: args.server, + token, + zipPath: args.zipPath, + failPrepare: args.failPrepare, + }); + + const onSignal = () => { + console.log("[ds-agent-fake] stopping…"); + agent.stop(); + }; + process.on("SIGINT", onSignal); + process.on("SIGTERM", onSignal); + + await agent.run(); +} + +main().catch((err) => { + // 起動前にトークンを読む前の失敗もあるので、値は出さない + const msg = err instanceof Error ? err.message : String(err); + console.error(`[ds-agent-fake] fatal: ${msg}`); + process.exit(1); +});