UNPKG

@tangle-network/tcloud

Version:

TypeScript SDK and CLI for Tangle Router, Sandbox, model routing, and agent service calls

232 lines (231 loc) • 7.24 kB
import { n as TCloudClient } from "./client-BnSLiyHL.js"; import { randomUUID } from "node:crypto"; import { spawn } from "node:child_process"; import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { tmpdir } from "node:os"; //#region src/instance.ts /** * Instance — programmatic harness for spinning up a local Tangle dev environment. * * Wraps `cargo tangle harness up` as a child process and exposes health * checks, log streaming, and a pre-configured {@link TCloudClient} pointed * at the local stack. * * NODE-ONLY. This module uses `child_process` and `fs` and must not be * imported in browser or edge runtime bundles. Always import from * `@tangle-network/tcloud/instance`, never the package root. * * ## Example * * ```ts * import { Instance } from '@tangle-network/tcloud/instance' * * const instance = await Instance.start({ * config: './harness.dev.toml', * only: ['llm'], * }) * * const client = instance.client({ model: 'llama-3.1-8b' }) * const res = await client.chat({ * messages: [{ role: 'user', content: 'hello' }], * }) * console.log(res.choices[0].message.content) * * await instance.stop() * ``` */ const MAX_LOG_LINES = 1e4; /** * Append a line to a bounded log buffer, evicting the oldest entry once the * buffer exceeds `maxLines`. Exported for unit testing. */ function appendLogLine(buffer, line, maxLines = MAX_LOG_LINES) { buffer.push(line); if (buffer.length > maxLines) buffer.shift(); } /** * A running Tangle dev environment. Hold one per test file or dev session. */ var Instance = class Instance { child; _config; _stopped = false; logBuffer; maxLogLines = MAX_LOG_LINES; constructor(child, config, logBuffer) { this.child = child; this._config = config; this.logBuffer = logBuffer; } /** * Start a new harness instance. Resolves once `cargo tangle harness up` * prints its "Harness up" marker, indicating all blueprints are healthy. * * Throws on timeout, process exit before ready, or missing `cargo-tangle`. */ static async start(options = {}) { const cargoBinary = options.cargoBinary ?? "cargo-tangle"; const timeoutMs = options.timeoutMs ?? 3e5; const routerUrl = options.routerUrl ?? "http://localhost:3000"; const args = [ "tangle", "harness", "up" ]; if (options.config) args.push("--config", options.config); if (options.only && options.only.length > 0) args.push("--only", options.only.join(",")); if (options.includeAnvilLogs) args.push("--include-anvil-logs"); const child = spawn(cargoBinary, args, { cwd: options.cwd ?? process.cwd(), env: process.env, stdio: [ "ignore", "pipe", "pipe" ] }); const logBuffer = []; const pushLog = (line) => { appendLogLine(logBuffer, line, MAX_LOG_LINES); if (!options.quiet) process.stdout.write(line + "\n"); }; let stdoutTail = ""; let stderrTail = ""; child.stdout?.on("data", (chunk) => { stdoutTail += chunk.toString(); const lines = stdoutTail.split("\n"); stdoutTail = lines.pop() ?? ""; for (const line of lines) pushLog(line); }); child.stderr?.on("data", (chunk) => { stderrTail += chunk.toString(); const lines = stderrTail.split("\n"); stderrTail = lines.pop() ?? ""; for (const line of lines) pushLog(line); }); const blueprintNames = []; try { await new Promise((resolve, reject) => { let settled = false; let timer; let pollInterval; const cleanup = () => { if (settled) return; settled = true; if (pollInterval !== void 0) clearInterval(pollInterval); if (timer !== void 0) clearTimeout(timer); child.removeListener("exit", onExit); child.removeListener("error", onError); }; const onExit = (code) => { if (settled) return; cleanup(); reject(/* @__PURE__ */ new Error(`cargo tangle harness exited with code ${code} before becoming ready. Last 20 lines:\n${logBuffer.slice(-20).join("\n")}`)); }; const onError = (err) => { if (settled) return; cleanup(); reject(/* @__PURE__ */ new Error(`Failed to spawn ${cargoBinary}: ${err.message}`)); }; timer = setTimeout(() => { if (settled) return; cleanup(); reject(/* @__PURE__ */ new Error(`Timed out after ${timeoutMs}ms waiting for harness to start. Last 20 lines:\n${logBuffer.slice(-20).join("\n")}`)); }, timeoutMs); child.once("exit", onExit); child.once("error", onError); pollInterval = setInterval(() => { for (const line of logBuffer) { if (line.match(/Harness up\.\s+(\d+)\s+blueprint/i)) { if (settled) return; cleanup(); resolve(); return; } const bpMatch = line.match(/Starting blueprint-manager for '([^']+)'/); if (bpMatch) blueprintNames.push(bpMatch[1]); } }, 100); }); } catch (err) { if (!child.killed) child.kill("SIGTERM"); throw err; } return new Instance(child, { routerUrl, blueprints: blueprintNames }, logBuffer); } /** URL of the router serving this instance */ get routerUrl() { return this._config.routerUrl; } /** Names of blueprints that were started */ get blueprints() { return [...this._config.blueprints]; } /** * Create a pre-configured {@link TCloudClient} pointed at this instance's router. * Merges any passed config with the instance's routerUrl (instance wins). */ client(config = {}) { return new TCloudClient({ ...config, baseURL: this._config.routerUrl }); } /** Return the last N log lines from the harness */ logs(lines = 100) { return this.logBuffer.slice(-lines); } /** Whether the harness process is still running */ get isRunning() { return !this._stopped && !this.child.killed && this.child.exitCode === null; } /** * Stop the harness. Sends SIGTERM, waits up to 10s for clean shutdown, * then SIGKILL. */ async stop(timeoutMs = 1e4) { if (this._stopped) return; this._stopped = true; if (this.child.exitCode !== null) return; this.child.kill("SIGTERM"); await new Promise((resolve) => { const timer = setTimeout(() => { if (this.child.exitCode === null) this.child.kill("SIGKILL"); resolve(); }, timeoutMs); this.child.once("exit", () => { clearTimeout(timer); resolve(); }); }); } }; /** * Write a temporary harness.toml with the given blueprints, for inline test configs. * Returns the path to the created file. The caller is responsible for cleanup. */ function writeTempHarnessConfig(blueprints) { const dir = join(tmpdir(), `tangle-harness-${process.pid}-${Date.now()}-${randomUUID()}`); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); const file = join(dir, "harness.toml"); const lines = []; lines.push("[chain]"); lines.push("anvil = true"); lines.push(""); for (const bp of blueprints) { lines.push("[[blueprint]]"); lines.push(`name = "${bp.name}"`); lines.push(`path = "${bp.path}"`); if (bp.port !== void 0) lines.push(`port = ${bp.port}`); if (bp.env) for (const [k, v] of Object.entries(bp.env)) lines.push(`env.${k} = "${v.replace(/"/g, "\\\"")}"`); lines.push(""); } writeFileSync(file, lines.join("\n")); return file; } //#endregion export { Instance, appendLogLine, writeTempHarnessConfig };