Newer
Older
client_dwatch / lib / ds-agent / relay.ts
/**
 * 制御盤(ラズパイ)とブラウザの中継(指示書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<Uint8Array>; 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<DsAgentCommand | null>;
  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<PushChunkResult>;
  completeExport(serial: string, exportCommandId: string, totalBytes: number, sha256Hex: string): CompleteResult;
  // ── ブラウザ側 ──
  agentStatus(serial: string): DsAgentStatus;
  requestList(serial: string): Promise<ListRequestResult>;
  createExport(serial: string, fileIds: readonly string[], signal: AbortSignal): Promise<CreateExportResult>;
  claimExport(serial: string, exportId: string, signal: AbortSignal): Promise<ClaimExportResult>;
}

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<typeof setTimeout>);
}

// ── 内部の状態 ──

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<ExportState> = 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<Uint8Array> | null;
  controller: ReadableStreamDefaultController<Uint8Array> | 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<RelayDeps> = {}): 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<string, Device>();
  const commands = new Map<string, CommandRecord>();
  const exportIds = new Map<string, string>();

  /** 終わった指示を消すまでの時間(その間は 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<DsAgentCommand | null>((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<PushChunkResult>((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<ListRequestResult>((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<CreateExportResult>((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<ClaimExportResult>((resolve) => {
        clear(rec.timer);
        rec.state = "sending";
        rec.resolveClaim = resolve;
        rec.stream = new ReadableStream<Uint8Array>(
          {
            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;
}