488 lines
16 KiB
JavaScript
Executable File
488 lines
16 KiB
JavaScript
Executable File
#!/usr/bin/env node
|
|
|
|
import { spawn } from "node:child_process";
|
|
import { createServer } from "node:http";
|
|
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { executeBrowserControlTask } from "../local-agent/browser-control-task-runner.mjs";
|
|
import { executeComputerUseTask } from "../local-agent/computer-use-task-runner.mjs";
|
|
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
|
|
function parseArgs(argv) {
|
|
const options = {
|
|
chainTasks: 80,
|
|
runtimeTasks: 240,
|
|
runtimeConcurrency: 24,
|
|
pollMs: 5,
|
|
timeoutMs: 45_000,
|
|
skipChain: false,
|
|
skipRuntime: false,
|
|
};
|
|
|
|
for (const arg of argv) {
|
|
if (arg === "--skip-chain") options.skipChain = true;
|
|
else if (arg === "--skip-runtime") options.skipRuntime = true;
|
|
else if (arg.startsWith("--chain-tasks=")) options.chainTasks = positiveInt(arg.split("=")[1], options.chainTasks);
|
|
else if (arg.startsWith("--runtime-tasks=")) options.runtimeTasks = positiveInt(arg.split("=")[1], options.runtimeTasks);
|
|
else if (arg.startsWith("--runtime-concurrency=")) {
|
|
options.runtimeConcurrency = positiveInt(arg.split("=")[1], options.runtimeConcurrency);
|
|
} else if (arg.startsWith("--poll-ms=")) options.pollMs = positiveInt(arg.split("=")[1], options.pollMs);
|
|
else if (arg.startsWith("--timeout-ms=")) options.timeoutMs = positiveInt(arg.split("=")[1], options.timeoutMs);
|
|
else if (arg === "--help" || arg === "-h") {
|
|
options.help = true;
|
|
}
|
|
}
|
|
return options;
|
|
}
|
|
|
|
function positiveInt(value, fallback) {
|
|
const parsed = Number.parseInt(String(value || ""), 10);
|
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
|
}
|
|
|
|
function percentile(values, p) {
|
|
return values[Math.min(values.length - 1, Math.floor(values.length * p))] || 0;
|
|
}
|
|
|
|
function listen(server, host = "127.0.0.1") {
|
|
return new Promise((resolve, reject) => {
|
|
server.once("error", reject);
|
|
server.listen(0, host, () => {
|
|
server.off("error", reject);
|
|
resolve(server.address().port);
|
|
});
|
|
});
|
|
}
|
|
|
|
function closeServer(server) {
|
|
return new Promise((resolve) => server.close(resolve));
|
|
}
|
|
|
|
function readJsonBody(request) {
|
|
return new Promise((resolve, reject) => {
|
|
let raw = "";
|
|
request.setEncoding("utf8");
|
|
request.on("data", (chunk) => {
|
|
raw += chunk;
|
|
});
|
|
request.on("end", () => {
|
|
try {
|
|
resolve(raw ? JSON.parse(raw) : {});
|
|
} catch (error) {
|
|
reject(error);
|
|
}
|
|
});
|
|
request.on("error", reject);
|
|
});
|
|
}
|
|
|
|
async function waitFor(predicate, timeoutMs) {
|
|
const started = Date.now();
|
|
while (Date.now() - started < timeoutMs) {
|
|
if (await predicate()) return;
|
|
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
}
|
|
throw new Error(`stress timeout after ${timeoutMs}ms`);
|
|
}
|
|
|
|
async function writeChainRuntimeFixtures(root) {
|
|
const browserRuntime = path.join(root, "browser-runtime.mjs");
|
|
await writeFile(
|
|
browserRuntime,
|
|
`
|
|
let input = "";
|
|
process.stdin.setEncoding("utf8");
|
|
process.stdin.on("data", chunk => input += chunk);
|
|
process.stdin.on("end", () => {
|
|
const payload = JSON.parse(input || "{}");
|
|
const url = (payload.objective.match(/https?:\\/\\/\\S+/) || [])[0] || "https://example.com";
|
|
process.stdout.write(JSON.stringify({
|
|
status: "completed",
|
|
requestId: payload.requestId,
|
|
replyBody: "browser ok " + payload.requestId,
|
|
targetUrl: url,
|
|
executionSummary: "stress-browser-ok"
|
|
}) + "\\n");
|
|
});
|
|
`,
|
|
"utf8",
|
|
);
|
|
|
|
const computerRuntime = path.join(root, "computer-runtime.mjs");
|
|
await writeFile(
|
|
computerRuntime,
|
|
`
|
|
let input = "";
|
|
process.stdin.setEncoding("utf8");
|
|
process.stdin.on("data", chunk => input += chunk);
|
|
process.stdin.on("end", () => {
|
|
const payload = JSON.parse(input || "{}");
|
|
if (String(payload.objective || "").includes("dialog")) {
|
|
process.stdout.write(JSON.stringify({
|
|
status: "needs_user_action",
|
|
requestId: payload.requestId,
|
|
kind: "dialog_intervention_required",
|
|
dialogId: "stress-dialog-" + payload.requestId,
|
|
appName: "System Settings",
|
|
platform: "darwin",
|
|
risk: "high",
|
|
summary: "stress dialog requires user action " + payload.requestId,
|
|
recommendedAction: "handled_on_device",
|
|
availableActions: ["handled_on_device", "cancel_task"]
|
|
}) + "\\n");
|
|
return;
|
|
}
|
|
process.stdout.write(JSON.stringify({
|
|
status: "completed",
|
|
requestId: payload.requestId,
|
|
replyBody: "desktop ok " + payload.requestId,
|
|
targetApp: "Finder",
|
|
executionSummary: "stress-desktop-ok"
|
|
}) + "\\n");
|
|
});
|
|
`,
|
|
"utf8",
|
|
);
|
|
|
|
return { browserRuntime, computerRuntime };
|
|
}
|
|
|
|
function buildChainTasks(totalTasks) {
|
|
return Array.from({ length: totalTasks }, (_, index) => {
|
|
const n = index + 1;
|
|
const isDialog = n % 10 === 0;
|
|
const isBrowser = !isDialog && n % 2 === 0;
|
|
return {
|
|
taskId: `stress-task-${String(n).padStart(3, "0")}`,
|
|
taskType: isBrowser ? "browser_control" : "desktop_control",
|
|
projectId: "master-agent",
|
|
requestText: isDialog
|
|
? `open system settings dialog ${n}`
|
|
: isBrowser
|
|
? `open https://example.com/stress/${n}`
|
|
: `open Finder action ${n}`,
|
|
executionPrompt: "",
|
|
requestedByAccount: "krisolo",
|
|
deviceId: "mac-studio",
|
|
dispatchExecutionId: `stress-dispatch-${n}`,
|
|
targetThreadId: `stress-thread-${n}`,
|
|
requestedAt: new Date().toISOString(),
|
|
riskLevel: isDialog ? "high" : "medium",
|
|
};
|
|
});
|
|
}
|
|
|
|
async function runChainStress(options) {
|
|
const runtimeRoot = await mkdtemp(path.join(os.tmpdir(), "boss-remote-control-stress-"));
|
|
const skillsDir = path.join(runtimeRoot, "skills");
|
|
await mkdir(skillsDir, { recursive: true });
|
|
const { browserRuntime, computerRuntime } = await writeChainRuntimeFixtures(runtimeRoot);
|
|
const tasks = buildChainTasks(options.chainTasks);
|
|
const pending = [...tasks];
|
|
const claimedAt = new Map();
|
|
const completions = [];
|
|
const appLogs = [];
|
|
let claimRequests = 0;
|
|
let heartbeatRequests = 0;
|
|
let skillRequests = 0;
|
|
|
|
const controlPlane = createServer(async (request, response) => {
|
|
const url = request.url || "";
|
|
try {
|
|
if (request.method === "POST" && url === "/api/v1/master-agent/tasks/claim") {
|
|
claimRequests += 1;
|
|
const task = pending.shift() || null;
|
|
if (task) claimedAt.set(task.taskId, Date.now());
|
|
response.writeHead(200, { "content-type": "application/json" });
|
|
response.end(JSON.stringify({ ok: true, task }));
|
|
return;
|
|
}
|
|
const completeMatch = url.match(/^\/api\/v1\/master-agent\/tasks\/([^/]+)\/complete$/);
|
|
if (request.method === "POST" && completeMatch) {
|
|
const body = await readJsonBody(request);
|
|
completions.push({ taskId: completeMatch[1], body, receivedAt: Date.now() });
|
|
response.writeHead(200, { "content-type": "application/json" });
|
|
response.end(JSON.stringify({ ok: true }));
|
|
return;
|
|
}
|
|
if (request.method === "POST" && url === "/api/device-heartbeat") {
|
|
heartbeatRequests += 1;
|
|
response.writeHead(200, { "content-type": "application/json" });
|
|
response.end(JSON.stringify({ ok: true, token: "stress-server-token" }));
|
|
return;
|
|
}
|
|
if (request.method === "POST" && url === "/api/v1/devices/mac-studio/skills") {
|
|
skillRequests += 1;
|
|
response.writeHead(200, { "content-type": "application/json" });
|
|
response.end(JSON.stringify({ ok: true }));
|
|
return;
|
|
}
|
|
if (request.method === "POST" && url === "/api/v1/app-logs") {
|
|
appLogs.push(await readJsonBody(request));
|
|
response.writeHead(200, { "content-type": "application/json" });
|
|
response.end(JSON.stringify({ ok: true }));
|
|
return;
|
|
}
|
|
response.writeHead(404, { "content-type": "application/json" });
|
|
response.end(JSON.stringify({ ok: false, url }));
|
|
} catch (error) {
|
|
response.writeHead(500, { "content-type": "application/json" });
|
|
response.end(JSON.stringify({ ok: false, error: error.message }));
|
|
}
|
|
});
|
|
|
|
const controlPort = await listen(controlPlane);
|
|
const probe = createServer();
|
|
const agentPort = await listen(probe);
|
|
await closeServer(probe);
|
|
|
|
const configPath = path.join(runtimeRoot, "local-agent-config.json");
|
|
await writeFile(
|
|
configPath,
|
|
JSON.stringify({
|
|
port: agentPort,
|
|
bindHost: "127.0.0.1",
|
|
controlPlaneUrl: `http://127.0.0.1:${controlPort}`,
|
|
deviceId: "mac-studio",
|
|
token: "stress-local-token",
|
|
name: "Mac Studio Stress",
|
|
account: "krisolo",
|
|
status: "online",
|
|
codexSessionDiscoveryEnabled: false,
|
|
projects: ["master-agent"],
|
|
skillsDir,
|
|
masterAgentEnabled: true,
|
|
masterAgentPollIntervalMs: options.pollMs,
|
|
heartbeatIntervalMs: 60_000,
|
|
skillLifecycleEnabled: false,
|
|
browserControlEnabled: true,
|
|
browserControlCommand: process.execPath,
|
|
browserControlArgs: [browserRuntime],
|
|
browserControlWorkdir: repoRoot,
|
|
browserControlTimeoutMs: 5_000,
|
|
computerUseEnabled: true,
|
|
computerUseCommand: process.execPath,
|
|
computerUseArgs: [computerRuntime],
|
|
computerUseWorkdir: repoRoot,
|
|
computerUseTimeoutMs: 5_000,
|
|
}),
|
|
);
|
|
|
|
const child = spawn(process.execPath, ["local-agent/server.mjs", configPath], {
|
|
cwd: repoRoot,
|
|
env: process.env,
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
let stderr = "";
|
|
child.stderr.setEncoding("utf8");
|
|
child.stderr.on("data", (chunk) => {
|
|
stderr += chunk;
|
|
});
|
|
|
|
const started = Date.now();
|
|
try {
|
|
await waitFor(() => completions.length === options.chainTasks, options.timeoutMs);
|
|
const durationMs = Date.now() - started;
|
|
return summarizeChainStress({
|
|
totalTasks: options.chainTasks,
|
|
durationMs,
|
|
completions,
|
|
tasks,
|
|
claimedAt,
|
|
claimRequests,
|
|
heartbeatRequests,
|
|
skillRequests,
|
|
appLogs,
|
|
stderr,
|
|
});
|
|
} finally {
|
|
child.kill("SIGTERM");
|
|
await closeServer(controlPlane).catch(() => null);
|
|
await rm(runtimeRoot, { recursive: true, force: true }).catch(() => null);
|
|
}
|
|
}
|
|
|
|
function summarizeChainStress(input) {
|
|
const completed = input.completions.filter((item) => item.body.status === "completed");
|
|
const waiting = input.completions.filter((item) => item.body.status === "needs_user_action");
|
|
const failed = input.completions.filter((item) => item.body.status === "failed");
|
|
const missing = input.tasks.filter(
|
|
(task) => !input.completions.some((item) => item.taskId === task.taskId),
|
|
);
|
|
const duplicateCount =
|
|
input.completions.length - new Set(input.completions.map((item) => item.taskId)).size;
|
|
const latencies = input.completions
|
|
.map((item) => item.receivedAt - (input.claimedAt.get(item.taskId) || item.receivedAt))
|
|
.sort((a, b) => a - b);
|
|
const invalidDialog = waiting.filter(
|
|
(item) =>
|
|
item.body.kind !== "dialog_intervention_required" ||
|
|
!Array.isArray(item.body.availableActions),
|
|
);
|
|
const invalidCompleted = completed.filter((item) => !item.body.replyBody);
|
|
|
|
return {
|
|
name: "chain",
|
|
totalTasks: input.totalTasks,
|
|
durationMs: input.durationMs,
|
|
throughputPerSec: Number((input.totalTasks / (input.durationMs / 1000)).toFixed(2)),
|
|
completed: completed.length,
|
|
waitingUserAction: waiting.length,
|
|
failed: failed.length,
|
|
missing: missing.length,
|
|
duplicateCount,
|
|
claimRequests: input.claimRequests,
|
|
heartbeatRequests: input.heartbeatRequests,
|
|
skillRequests: input.skillRequests,
|
|
appLogs: input.appLogs.length,
|
|
latencyMs: {
|
|
min: latencies[0] || 0,
|
|
p50: percentile(latencies, 0.5),
|
|
p95: percentile(latencies, 0.95),
|
|
max: latencies.at(-1) || 0,
|
|
},
|
|
invalidDialog: invalidDialog.length,
|
|
invalidCompleted: invalidCompleted.length,
|
|
stderrTail: input.stderr.trim().slice(-500),
|
|
};
|
|
}
|
|
|
|
async function runRuntimeStress(options) {
|
|
const total = options.runtimeTasks;
|
|
const concurrency = Math.min(options.runtimeConcurrency, total);
|
|
let next = 0;
|
|
let active = 0;
|
|
const results = [];
|
|
const started = Date.now();
|
|
|
|
async function runOne(index) {
|
|
const n = index + 1;
|
|
if (n % 3 === 0) {
|
|
return executeComputerUseTask(
|
|
{
|
|
taskId: `runtime-desktop-${n}`,
|
|
taskType: "desktop_control",
|
|
requestText: `open Finder ${n}`,
|
|
projectId: "master-agent",
|
|
targetThreadId: `thread-${n}`,
|
|
requestedByAccount: "krisolo",
|
|
},
|
|
{
|
|
computerUseEnabled: true,
|
|
computerUseCommand: process.execPath,
|
|
computerUseArgs: ["tests/fixtures/computer-use-runtime.mjs"],
|
|
computerUseWorkdir: repoRoot,
|
|
computerUseTimeoutMs: 5000,
|
|
},
|
|
);
|
|
}
|
|
return executeBrowserControlTask(
|
|
{
|
|
taskId: `runtime-browser-${n}`,
|
|
taskType: "browser_control",
|
|
requestText: `open https://example.com/runtime/${n}`,
|
|
projectId: "master-agent",
|
|
targetThreadId: `thread-${n}`,
|
|
requestedByAccount: "krisolo",
|
|
},
|
|
{
|
|
browserControlEnabled: true,
|
|
browserControlCommand: process.execPath,
|
|
browserControlArgs: ["tests/fixtures/browser-control-runtime.mjs"],
|
|
browserControlWorkdir: repoRoot,
|
|
browserControlTimeoutMs: 5000,
|
|
},
|
|
);
|
|
}
|
|
|
|
await new Promise((resolve) => {
|
|
const pump = () => {
|
|
while (active < concurrency && next < total) {
|
|
const index = next;
|
|
next += 1;
|
|
active += 1;
|
|
const taskStarted = Date.now();
|
|
runOne(index)
|
|
.then((result) => results.push({ ok: true, result, latencyMs: Date.now() - taskStarted }))
|
|
.catch((error) => results.push({ ok: false, error: error.message, latencyMs: Date.now() - taskStarted }))
|
|
.finally(() => {
|
|
active -= 1;
|
|
if (results.length === total) resolve();
|
|
else pump();
|
|
});
|
|
}
|
|
};
|
|
pump();
|
|
});
|
|
|
|
const durationMs = Date.now() - started;
|
|
const failed = results.filter((item) => !item.ok || item.result?.status === "failed");
|
|
const completed = results.filter((item) => item.ok && item.result?.status === "completed");
|
|
const latencies = results.map((item) => item.latencyMs).sort((a, b) => a - b);
|
|
return {
|
|
name: "runtime",
|
|
total,
|
|
concurrency,
|
|
durationMs,
|
|
throughputPerSec: Number((total / (durationMs / 1000)).toFixed(2)),
|
|
completed: completed.length,
|
|
failed: failed.length,
|
|
latencyMs: {
|
|
min: latencies[0] || 0,
|
|
p50: percentile(latencies, 0.5),
|
|
p95: percentile(latencies, 0.95),
|
|
max: latencies.at(-1) || 0,
|
|
},
|
|
firstFailure: failed[0] || null,
|
|
};
|
|
}
|
|
|
|
function hasFailure(summary) {
|
|
if (summary.name === "chain") {
|
|
return (
|
|
summary.failed > 0 ||
|
|
summary.missing > 0 ||
|
|
summary.duplicateCount > 0 ||
|
|
summary.invalidDialog > 0 ||
|
|
summary.invalidCompleted > 0
|
|
);
|
|
}
|
|
return summary.failed > 0;
|
|
}
|
|
|
|
function printHelp() {
|
|
console.log(`Usage: node scripts/stress-remote-control.mjs [options]
|
|
|
|
Options:
|
|
--chain-tasks=N local-agent chain tasks, default 80
|
|
--runtime-tasks=N direct runtime tasks, default 240
|
|
--runtime-concurrency=N direct runtime concurrency, default 24
|
|
--poll-ms=N local-agent task poll interval, default 5
|
|
--timeout-ms=N chain stress timeout, default 45000
|
|
--skip-chain skip local-agent chain stress
|
|
--skip-runtime skip direct runtime stress
|
|
`);
|
|
}
|
|
|
|
const options = parseArgs(process.argv.slice(2));
|
|
if (options.help) {
|
|
printHelp();
|
|
process.exit(0);
|
|
}
|
|
|
|
const summaries = [];
|
|
if (!options.skipChain) {
|
|
summaries.push(await runChainStress(options));
|
|
}
|
|
if (!options.skipRuntime) {
|
|
summaries.push(await runRuntimeStress(options));
|
|
}
|
|
|
|
console.log(JSON.stringify({ ok: summaries.every((summary) => !hasFailure(summary)), summaries }, null, 2));
|
|
if (summaries.some(hasFailure)) {
|
|
process.exitCode = 1;
|
|
}
|