Newer
Older
client_dwatch / lib / ds-agent / __tests__ / relay.test.ts
/**
 * 指示書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<void> {
  for (let i = 0; i < 20; i++) await new Promise<void>((resolve) => setImmediate(resolve));
}

function createHarness(overrides: Partial<RelayLimits> = {}) {
  let nowMs = BASE;
  let nextHandle = 1;
  const timers = new Map<number, { at: number; fn: () => 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<void> {
    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<typeof createHarness>;

function track<T>(promise: Promise<T>) {
  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<Uint8Array>): Promise<void> {
  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<typeof startStream>[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);
  });
});