/**
* 指示書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);
});
});