@tangle-network/tcloud
Version:
TypeScript SDK and CLI for Tangle Router, Sandbox, model routing, and agent service calls
235 lines (234 loc) • 7.5 kB
JavaScript
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
const require_client = require("./client-BWymW7iu.cjs");
let node_crypto = require("node:crypto");
let node_child_process = require("node:child_process");
let node_fs = require("node:fs");
let node_path = require("node:path");
let node_os = require("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 = (0, node_child_process.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 require_client.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 = (0, node_path.join)((0, node_os.tmpdir)(), `tangle-harness-${process.pid}-${Date.now()}-${(0, node_crypto.randomUUID)()}`);
if (!(0, node_fs.existsSync)(dir)) (0, node_fs.mkdirSync)(dir, { recursive: true });
const file = (0, node_path.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("");
}
(0, node_fs.writeFileSync)(file, lines.join("\n"));
return file;
}
//#endregion
exports.Instance = Instance;
exports.appendLogLine = appendLogLine;
exports.writeTempHarnessConfig = writeTempHarnessConfig;