runJob

tcmts.cli.mcp.runJob · #suptvhpi · term · runs on the server

uses process

A background job's own process, run from its source (node -e, by startJob), so it uses only deps. It runs spec.cmd, its output going to spec.log as it comes, and says how it ended in spec.exit (a JobEnd; stopped if a SIGTERM, stopJob's, stopped it). With call, cmd is an MCP server (tcm mcp) asked that tool call: the tool's progress goes to the log, and what it answers to text. (Run from its source, it isn't seen running by the coverage that caches end-to-end tests: its own tests, testJobs…, run when it changes.)

import type { spawn as Spawn } from "node:child_process";
import type nodeFs from "node:fs";

export function runJob(
  spec: {
    cmd: string[];
    cwd: string;
    log: string;
    exit: string;
    call?: { name: string; arguments: Record<string, unknown> };
  },
  deps: { spawn: typeof Spawn; fs: typeof nodeFs },
): void {
  const { spawn, fs } = deps;
  const log = fs.openSync(spec.log, "a");
  let stopped = false;
  let ended = false;
  const end = (code: number, text?: string) => {
    if (ended) return;
    ended = true;
    // (written whole, then put in place: a reader never sees half of it)
    const tmp = `${spec.exit}.${process.pid}`;
    try {
      fs.writeFileSync(
        tmp,
        JSON.stringify({
          code,
          at: Date.now(),
          ...(stopped ? { stopped } : {}),
          ...(text !== undefined ? { text } : {}),
        }),
      );
      fs.renameSync(tmp, spec.exit);
    } catch {}
  };
  // stopped: the command is too (the signal is to the process group), and this says so once it has ended
  process.on("SIGTERM", () => {
    stopped = true;
  });
  const child = spawn(spec.cmd[0]!, spec.cmd.slice(1), {
    cwd: spec.cwd,
    stdio: spec.call ? ["pipe", "pipe", log] : ["ignore", log, log],
  });
  child.on("error", (e) => {
    fs.writeSync(log, `${e.message}\n`);
    end(127, spec.call ? e.message : undefined);
  });
  // (a server that ends without answering: why is in what it printed)
  child.on("close", (code) => {
    if (!spec.call) return end(code ?? 1);
    let printed = "";
    try {
      printed = fs.readFileSync(spec.log, "utf8").trim();
    } catch {}
    end(
      code ?? 1,
      `the MCP server answering ${spec.call.name} ended (exit ${code}) without answering${printed ? `:\n${printed}` : ""}`,
    );
  });
  const call = spec.call;
  if (!call) return;
  child.stdin!.on("error", () => {});
  let buf = "";
  child.stdout!.setEncoding("utf8").on("data", (d: string) => {
    buf += d;
    for (let nl = buf.indexOf("\n"); nl >= 0; nl = buf.indexOf("\n")) {
      const line = buf.slice(0, nl);
      buf = buf.slice(nl + 1);
      let m: {
        id?: number;
        method?: string;
        params?: { message?: string };
        result?: { content?: { text?: string }[]; isError?: boolean };
        error?: { message?: string };
      };
      try {
        m = JSON.parse(line) as typeof m;
      } catch {
        continue;
      }
      if (m.method === "notifications/progress" && m.params?.message !== undefined)
        fs.writeSync(log, `${m.params.message}\n`);
      if (m.id !== 2) continue;
      child.stdin!.end();
      end(
        m.result ? (m.result.isError ? 1 : 0) : 1,
        m.result
          ? (m.result.content?.[0]?.text ?? "")
          : (m.error?.message ?? `${call.name} failed`),
      );
    }
  });
  const send = (msg: unknown) => child.stdin!.write(`${JSON.stringify(msg)}\n`);
  send({
    jsonrpc: "2.0",
    id: 1,
    method: "initialize",
    params: {
      protocolVersion: "2025-06-18",
      capabilities: {},
      clientInfo: { name: "tcm job", version: "0" },
    },
  });
  send({
    jsonrpc: "2.0",
    id: 2,
    method: "tools/call",
    params: { ...call, _meta: { progressToken: "job" } },
  });
}

used by