Newer
Older
client_dwatch / lib / ds-agent / fixture-agent.ts
/**
 * fixtures の偽制御盤(指示書AY 段階8-2。D10・`CLAUDE.md` §9.5)。
 *
 * ★relay の同じ経路を通す★: ポーリング → 指示 → 結果(list)/準備完了(export)/チャンク → 完了(send)。
 * ルートは「fixtures のときは relay を呼ぶ前に `ensureFixtureAgent()`」だけを書き、それ以外の分岐を持たない。
 *
 * - `FIXTURE_AGENT_OFFLINE_SERIALS` と fixtures に無いシリアルは何もしない(→ 409 / 404 が本物と同じ経路で出る)
 * - ★1シリアル1本★(relay ごとの登録で二重起動を防ぐ。`globalThis` に置き HMR でも二重にしない)
 * - 指示ごとの処理は待たずに次のポーリングへ進む(本物と同じく、一覧はエクスポートと並行して返す。D25 ③)
 * - ★ディスクに書かない★(zip は base64 の定数から作る)
 */
import { createHash } from "node:crypto";
import { AGENT_POLL_WAIT_SEC } from "@/lib/config/ds-agent";
import { getRelay, type Relay } from "@/lib/ds-agent/relay";
import {
  FIXTURE_AGENT_OFFLINE_SERIALS,
  FIXTURE_EXPORT_ZIP_BASE64,
  fixtureDeviceLogItems,
  fixtureDeviceLogTypes,
} from "@/lib/fixtures/ds-device-logs";
import { dsDevices } from "@/lib/fixtures/ds-devices";
import type { DsAgentCommand } from "@/lib/types";

/** 流量制御の経路を通すため、zip を小さく分けて送る(438バイト → 4チャンク) */
const FIXTURE_CHUNK_BYTES = 128;

declare global {
  /** relay ごとに、偽制御盤を動かしているシリアル */
  var __dwatchFixtureAgents: WeakMap<Relay, Set<string>> | undefined;
}

function running(relay: Relay): Set<string> {
  globalThis.__dwatchFixtureAgents ??= new WeakMap();
  let set = globalThis.__dwatchFixtureAgents.get(relay);
  if (!set) {
    set = new Set();
    globalThis.__dwatchFixtureAgents.set(relay, set);
  }
  return set;
}

function fixtureZip(): Uint8Array {
  return new Uint8Array(Buffer.from(FIXTURE_EXPORT_ZIP_BASE64, "base64"));
}

const sha256Hex = (bytes: Uint8Array) => createHash("sha256").update(bytes).digest("hex");

async function handle(relay: Relay, serial: string, command: DsAgentCommand): Promise<void> {
  switch (command.kind) {
    case "list":
      relay.submitListResult(serial, command.command_id, {
        types: [...fixtureDeviceLogTypes],
        items: fixtureDeviceLogItems(new Date()),
        truncated: false,
      });
      return;
    case "export": {
      const zip = fixtureZip();
      relay.submitPrepared(serial, command.command_id, {
        total_bytes: zip.length,
        sha256: sha256Hex(zip),
        file_count: command.params.file_ids.length,
      });
      return;
    }
    case "send": {
      const exportId = command.params.export_command_id;
      const zip = fixtureZip();
      for (let seq = 0, offset = 0; offset < zip.length; seq++, offset += FIXTURE_CHUNK_BYTES) {
        const chunk = zip.subarray(offset, offset + FIXTURE_CHUNK_BYTES);
        const result = await relay.pushChunk(serial, exportId, seq, chunk, sha256Hex(chunk));
        if (result !== "ok") return;
      }
      relay.completeExport(serial, exportId, zip.length, sha256Hex(zip));
      return;
    }
  }
}

async function loop(relay: Relay, serial: string): Promise<void> {
  const signal = new AbortController().signal;
  try {
    // ★relay が差し替えられたら(テスト)止める★
    while (getRelay() === relay) {
      const command = await relay.poll(serial, "fixture", AGENT_POLL_WAIT_SEC, signal);
      if (command) void handle(relay, serial, command);
    }
  } finally {
    running(relay).delete(serial);
  }
}

/**
 * fixtures のシリアルに偽制御盤を1本だけ動かす。★最初のポーリングは同期的に始まる★ので、
 * 呼んだ直後の `requestList()` / `createExport()` から接続中として扱われる。
 */
export function ensureFixtureAgent(relay: Relay, serial: string): void {
  if (FIXTURE_AGENT_OFFLINE_SERIALS.includes(serial)) return;
  if (!dsDevices.some((d) => d.serial_number === serial)) return;
  const set = running(relay);
  if (set.has(serial)) return;
  set.add(serial);
  void loop(relay, serial);
}