All files / lib/prepare workspace.ts

100% Statements 211/211
96.87% Branches 31/32
100% Functions 6/6
100% Lines 211/211

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 2121x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 2x 2x 2x 2x 2x 1x 1x 2x 2x 2x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 2x 2x 2x 2x 2x 1x 1x 2x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 20x 20x 20x 20x 20x 1x 1x 20x 1957x 1953x 1953x 1957x 4x 4x 4x 4x 4x 4x 3x 2x 1957x 2x 2x 1957x 1x 1x 1x 1x 1957x 20x 1x 1x 20x 20x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 18x 18x 18x 18x 18x 18x 18x 18x 18x 18x 18x 18x 18x 18x 1x 1x 1x 18x 18x 19x 1x 1x 19x 18x 18x 18x 18x 18x 18x 18x 19x 1x 1x 1x 1x 1x 1x 19x 18x 18x 1x 1x 1x 1x 1x 1x 1x 1x 8x 8x 80x 80x 80x 80x 80x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 8x 29x 8x 30x 30x 30x 30x 30x 30x 8x 8x 8x  
"use strict";
/**
 * The private preparation workspace: a temporary directory owned by one
 * oag-mcp process that holds the prepared SDK bundle, declarations and the
 * sandbox runner for the lifetime of the server. Users never manage it: it
 * is removed on shutdown and on start-up failure, and stale workspaces of
 * processes that have died are swept on start-up.
 *
 * @module prepare/workspace
 */
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
 
import type { Logs } from "../logging/logger.js";
import type { MethodRecord } from "../types.js";
 
/** Prefix of workspace directory names under the temporary directory. */
export const WORKSPACE_PREFIX = "oag-mcp-ws-";
 
/** Owner marker written into every workspace. */
const OWNER_FILE = ".oag-mcp-owner.json";
 
/** Relative paths inside a workspace. */
export const WORKSPACE_PATHS = {
  bundle: "sdk/sdk.bundle.js",
  types: "sdk/types",
  binding: "sdk/sdk-binding.d.ts",
  tsLib: "sdk/ts-lib",
  runner: "executor/runner.js",
  /** Extracted SDK archives. */
  input: "input",
  /** Generated entry shims for CommonJS SDKs. */
  shim: "shim",
} as const;
 
/** Workspaces of this process not yet removed (for the exit safety net). */
const LIVE = new Set<string>();
let exitHookInstalled = false;
 
/**
 * Last-resort synchronous removal when the process exits without a graceful
 * shutdown; anything left is swept by the next start-up.
 *
 * @private
 */
function _onExit(): void {
  for (const dir of LIVE) {
    try {
      fs.rmSync(dir, { recursive: true, force: true });
    } catch {
      // Swept by the next start-up.
    }
  }
  LIVE.clear();
}
 
/** A private workspace directory. */
export interface Workspace {
  dir: string;
  /** Remove the workspace; never throws. Returns whether it succeeded. */
  cleanup(): boolean;
}
 
/**
 * Whether a process exists.
 *
 * @private
 */
function _alive(pid: number): boolean {
  try {
    process.kill(pid, 0);
    return true;
  } catch (err) {
    return (err as NodeJS.ErrnoException).code === "EPERM";
  }
}
 
/**
 * Remove workspaces left behind by oag-mcp processes on this host that no
 * longer exist (e.g. killed with SIGKILL). Only directories carrying an
 * owner marker for a dead process are removed.
 *
 * @param {String} root - parent directory of workspaces
 * @param {Object} logs - logs
 * @return {Number} number of workspaces removed
 */
export function sweepStaleWorkspaces(root: string, logs: Logs): number {
  let removed = 0;
  let entries: string[];
  try {
    entries = fs.readdirSync(root);
  } catch {
    return 0;
  }
  for (const name of entries) {
    if (!name.startsWith(WORKSPACE_PREFIX)) {
      continue;
    }
    const dir = path.join(root, name);
    try {
      const owner = JSON.parse(
        fs.readFileSync(path.join(dir, OWNER_FILE), "utf8"),
      ) as { pid?: number; hostname?: string };
      if (
        typeof owner.pid !== "number" ||
        owner.hostname !== os.hostname() ||
        _alive(owner.pid)
      ) {
        continue;
      }
      fs.rmSync(dir, { recursive: true, force: true });
      removed++;
    } catch {
      // Not ours, unreadable or concurrently removed: leave it alone.
    }
  }
  if (removed > 0) {
    logs.op("info", "prepare.workspace.stale_removed", { count: removed });
  }
  return removed;
}
 
/**
 * Create a private workspace (mode 0700) under `root`.
 *
 * @param {Object} opts - options
 * @param {String} [opts.root] - parent directory, default: OS temp directory
 * @param {Object} opts.logs - logs
 * @return {Object} workspace
 */
export function createWorkspace(opts: {
  root?: string;
  logs: Logs;
}): Workspace {
  const root = path.resolve(opts.root ?? os.tmpdir());
  fs.mkdirSync(root, { recursive: true });
  sweepStaleWorkspaces(root, opts.logs);
  const dir = fs.mkdtempSync(path.join(root, WORKSPACE_PREFIX));
  fs.chmodSync(dir, 0o700);
  fs.writeFileSync(
    path.join(dir, OWNER_FILE),
    JSON.stringify({ pid: process.pid, hostname: os.hostname() }),
  );
  LIVE.add(dir);
  if (!exitHookInstalled) {
    process.once("exit", _onExit);
    exitHookInstalled = true;
  }
  let removed = false;
  const cleanup = (): boolean => {
    if (removed) {
      return true;
    }
    try {
      fs.rmSync(dir, { recursive: true, force: true, maxRetries: 3 });
      removed = true;
      LIVE.delete(dir);
      opts.logs.op("debug", "prepare.workspace.removed", {
        outcome: "success",
      });
      return true;
    } catch (err) {
      opts.logs.op("warn", "prepare.workspace.cleanup_failed", {
        error: (err as NodeJS.ErrnoException).code ?? (err as Error).name,
        outcome: "failure",
      });
      return false;
    }
  };
  return { dir, cleanup };
}
 
/**
 * Generate the TypeScript declaration of the `sdk` binding.
 *
 * @param {Array} records - method records
 * @return {String} declaration source
 */
export function bindingDeclaration(records: MethodRecord[]): string {
  const byClass = new Map<string, string[]>();
  for (const r of records) {
    byClass.set(r.className, [
      ...(byClass.get(r.className) ?? []),
      r.methodName,
    ]);
  }
  const lines = [
    "// Generated by oag-mcp. Declares the `sdk` binding available to execute code.",
    'import type * as __sdk from "./types/index";',
    "",
    "declare global {",
    "  /**",
    "   * SDK binding provided by oag-mcp: `sdk.<ApiClass>.<method>(...)` calls the",
    "   * generated typescript-fetch SDK. Base URL and credentials are configured",
    "   * by the server; do not pass them.",
    "   */",
    "  const sdk: {",
  ];
  for (const [cls, methods] of [...byClass.entries()].sort(([a], [b]) =>
    a.localeCompare(b),
  )) {
    const names = methods
      .sort()
      .map((m) => JSON.stringify(m))
      .join(" | ");
    lines.push(`    readonly ${cls}: Pick<__sdk.${cls}, ${names}>;`);
  }
  lines.push("  };", "}", "", "export {};", "");
  return lines.join("\n");
}