@cutleryapp/agent 1.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +465 -0
- package/dist/cli.js +873 -0
- package/dist/executor.js +420 -0
- package/dist/mcp-executor.js +308 -0
- package/package.json +61 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,873 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
4
|
+
if (k2 === undefined) k2 = k;
|
|
5
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
6
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
7
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
8
|
+
}
|
|
9
|
+
Object.defineProperty(o, k2, desc);
|
|
10
|
+
}) : (function(o, m, k, k2) {
|
|
11
|
+
if (k2 === undefined) k2 = k;
|
|
12
|
+
o[k2] = m[k];
|
|
13
|
+
}));
|
|
14
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
15
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
16
|
+
}) : function(o, v) {
|
|
17
|
+
o["default"] = v;
|
|
18
|
+
});
|
|
19
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
20
|
+
var ownKeys = function(o) {
|
|
21
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
22
|
+
var ar = [];
|
|
23
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
24
|
+
return ar;
|
|
25
|
+
};
|
|
26
|
+
return ownKeys(o);
|
|
27
|
+
};
|
|
28
|
+
return function (mod) {
|
|
29
|
+
if (mod && mod.__esModule) return mod;
|
|
30
|
+
var result = {};
|
|
31
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
32
|
+
__setModuleDefault(result, mod);
|
|
33
|
+
return result;
|
|
34
|
+
};
|
|
35
|
+
})();
|
|
36
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
37
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
38
|
+
};
|
|
39
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
40
|
+
exports.runConnect = runConnect;
|
|
41
|
+
const chalk_1 = __importDefault(require("chalk"));
|
|
42
|
+
const commander_1 = require("commander");
|
|
43
|
+
const dotenv_1 = __importDefault(require("dotenv"));
|
|
44
|
+
const fs_1 = require("fs");
|
|
45
|
+
const ora_1 = __importDefault(require("ora"));
|
|
46
|
+
const path_1 = require("path");
|
|
47
|
+
const http = __importStar(require("http"));
|
|
48
|
+
const os = __importStar(require("os"));
|
|
49
|
+
const child_process_1 = require("child_process");
|
|
50
|
+
const ws_1 = __importDefault(require("ws"));
|
|
51
|
+
const playwright_1 = require("playwright");
|
|
52
|
+
const mcp_executor_js_1 = require("./mcp-executor.js");
|
|
53
|
+
const AGENT_VERSION = "1.0.0";
|
|
54
|
+
// First-run browser bootstrap. Playwright ships its driver via npm but the
|
|
55
|
+
// browser binaries are downloaded separately (~300MB for chromium). Without
|
|
56
|
+
// this, the first `connect` from a fresh `npx @cutleryapp/agent` install fails
|
|
57
|
+
// with "Executable doesn't exist" the moment it tries to launch a test.
|
|
58
|
+
async function ensureBrowsersInstalled() {
|
|
59
|
+
if (process.env.CUTLERY_SKIP_BROWSER_INSTALL === "1")
|
|
60
|
+
return;
|
|
61
|
+
// Containers (our Docker image) bake browsers into the layer — skip the check.
|
|
62
|
+
if ((0, fs_1.existsSync)("/.dockerenv"))
|
|
63
|
+
return;
|
|
64
|
+
try {
|
|
65
|
+
if ((0, fs_1.existsSync)(playwright_1.chromium.executablePath()))
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
catch { /* path lookup failed — fall through to install */ }
|
|
69
|
+
const spinner = (0, ora_1.default)("Installing Playwright Chromium (one-time, ~300MB)…").start();
|
|
70
|
+
await new Promise((resolveInstall, rejectInstall) => {
|
|
71
|
+
const child = (0, child_process_1.spawn)("npx", ["--yes", "playwright", "install", "chromium"], {
|
|
72
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
73
|
+
env: process.env,
|
|
74
|
+
});
|
|
75
|
+
let stderr = "";
|
|
76
|
+
child.stderr.on("data", (chunk) => { stderr += chunk.toString(); });
|
|
77
|
+
child.on("error", rejectInstall);
|
|
78
|
+
child.on("close", (code) => {
|
|
79
|
+
if (code === 0)
|
|
80
|
+
resolveInstall();
|
|
81
|
+
else
|
|
82
|
+
rejectInstall(new Error(`playwright install exited ${code}: ${stderr.slice(-400)}`));
|
|
83
|
+
});
|
|
84
|
+
})
|
|
85
|
+
.then(() => spinner.succeed("Playwright Chromium installed"))
|
|
86
|
+
.catch((err) => {
|
|
87
|
+
spinner.fail("Failed to install Playwright Chromium");
|
|
88
|
+
console.error(chalk_1.default.red(` ${err.message}`));
|
|
89
|
+
console.error(chalk_1.default.gray(" Run manually: npx playwright install chromium"));
|
|
90
|
+
console.error(chalk_1.default.gray(" Or set CUTLERY_SKIP_BROWSER_INSTALL=1 if browsers live elsewhere."));
|
|
91
|
+
process.exit(1);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
function buildIdentity(args) {
|
|
95
|
+
return {
|
|
96
|
+
version: AGENT_VERSION,
|
|
97
|
+
hostname: os.hostname(),
|
|
98
|
+
platform: process.platform,
|
|
99
|
+
arch: process.arch,
|
|
100
|
+
nodeVersion: process.version,
|
|
101
|
+
capabilities: {
|
|
102
|
+
// Today the executor only drives Chromium. When that changes, advertise
|
|
103
|
+
// the actual list — the server's capability matcher consumes this.
|
|
104
|
+
browsers: args.browsers ?? ["chromium"],
|
|
105
|
+
// "Can it run headed?" — false in containers (no display server).
|
|
106
|
+
headless: !args.headless ? true : !process.env.CUTLERY_NO_DISPLAY,
|
|
107
|
+
maxConcurrency: args.maxConcurrency ?? 1,
|
|
108
|
+
tags: args.tags ?? [],
|
|
109
|
+
},
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
// Load environment variables
|
|
113
|
+
dotenv_1.default.config();
|
|
114
|
+
const program = new commander_1.Command();
|
|
115
|
+
program
|
|
116
|
+
.name("cutlery-agent")
|
|
117
|
+
.description("Standalone AI-powered test execution agent")
|
|
118
|
+
.version("1.0.0");
|
|
119
|
+
program
|
|
120
|
+
.command("run")
|
|
121
|
+
.description("Run a test case from a JSON file")
|
|
122
|
+
.argument("<file>", "Path to test case JSON file")
|
|
123
|
+
.option("-h, --headless", "Run in headless mode", false)
|
|
124
|
+
.option("-b, --browser <type>", "Browser type (chromium, firefox, webkit)", "chromium")
|
|
125
|
+
.option("-u, --base-url <url>", "Base URL for the application", "http://localhost:3000")
|
|
126
|
+
.option("-o, --output <path>", "Output directory for screenshots/videos", "./test-results")
|
|
127
|
+
.option("-v, --verbose", "Verbose output", false)
|
|
128
|
+
.option("--openai-key <key>", "OpenAI API key (or set OPENAI_API_KEY env var)")
|
|
129
|
+
.action(async (file, options) => {
|
|
130
|
+
const spinner = (0, ora_1.default)("Loading test case...").start();
|
|
131
|
+
try {
|
|
132
|
+
// Resolve file path
|
|
133
|
+
const filePath = (0, path_1.resolve)(process.cwd(), file);
|
|
134
|
+
if (!(0, fs_1.existsSync)(filePath)) {
|
|
135
|
+
spinner.fail(chalk_1.default.red(`Test case file not found: ${filePath}`));
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
// Read test case
|
|
139
|
+
const testCaseContent = (0, fs_1.readFileSync)(filePath, "utf-8");
|
|
140
|
+
const testCase = JSON.parse(testCaseContent);
|
|
141
|
+
spinner.succeed(chalk_1.default.green(`Loaded test case: ${testCase.name || "Unnamed"}`));
|
|
142
|
+
// Validate test case
|
|
143
|
+
if (!testCase.automated_steps || testCase.automated_steps.length === 0) {
|
|
144
|
+
console.log(chalk_1.default.red("❌ No automated_steps found in test case"));
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
console.log(chalk_1.default.blue(`\n📊 Test Case: ${testCase.name}`));
|
|
148
|
+
console.log(chalk_1.default.gray(` Steps: ${testCase.automated_steps.length}`));
|
|
149
|
+
console.log(chalk_1.default.gray(` Browser: ${options.browser}`));
|
|
150
|
+
console.log(chalk_1.default.gray(` Headless: ${options.headless}`));
|
|
151
|
+
console.log(chalk_1.default.gray(` Base URL: ${options.baseUrl}\n`));
|
|
152
|
+
// Get OpenAI key
|
|
153
|
+
const openaiKey = options.openaiKey || process.env.OPENAI_API_KEY;
|
|
154
|
+
if (!openaiKey) {
|
|
155
|
+
console.log(chalk_1.default.yellow("⚠️ No OpenAI API key provided. AI features will be limited."));
|
|
156
|
+
console.log(chalk_1.default.gray(" Set OPENAI_API_KEY environment variable or use --openai-key option\n"));
|
|
157
|
+
}
|
|
158
|
+
// Create executor
|
|
159
|
+
const executor = new mcp_executor_js_1.TestExecutor({
|
|
160
|
+
headless: options.headless,
|
|
161
|
+
browserType: options.browser,
|
|
162
|
+
baseUrl: options.baseUrl,
|
|
163
|
+
outputDir: options.output,
|
|
164
|
+
verbose: options.verbose,
|
|
165
|
+
openaiKey: openaiKey,
|
|
166
|
+
});
|
|
167
|
+
// Run test
|
|
168
|
+
spinner.start("Executing test case...");
|
|
169
|
+
const result = await executor.execute(testCase);
|
|
170
|
+
if (result.success) {
|
|
171
|
+
spinner.succeed(chalk_1.default.green("✅ Test PASSED"));
|
|
172
|
+
console.log(chalk_1.default.green(`\n📊 Test Results:`));
|
|
173
|
+
console.log(chalk_1.default.gray(` Total Steps: ${result.steps.length}`));
|
|
174
|
+
console.log(chalk_1.default.green(` Passed: ${result.steps.filter((s) => !s.error).length}`));
|
|
175
|
+
if (result.steps.some((s) => s.error)) {
|
|
176
|
+
console.log(chalk_1.default.red(` Failed: ${result.steps.filter((s) => s.error).length}`));
|
|
177
|
+
}
|
|
178
|
+
if (result.screenshots && result.screenshots.length > 0) {
|
|
179
|
+
console.log(chalk_1.default.gray(` Screenshots: ${result.screenshots.length}`));
|
|
180
|
+
}
|
|
181
|
+
if (options.verbose) {
|
|
182
|
+
console.log(chalk_1.default.blue("\n📝 Step Details:"));
|
|
183
|
+
result.steps.forEach((step, i) => {
|
|
184
|
+
const icon = step.error ? "❌" : "✅";
|
|
185
|
+
console.log(chalk_1.default.gray(` ${i + 1}. ${icon} ${step.step || step.action || "Unknown"}`));
|
|
186
|
+
if (step.error) {
|
|
187
|
+
console.log(chalk_1.default.red(` Error: ${step.error}`));
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
process.exit(0);
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
spinner.fail(chalk_1.default.red("❌ Test FAILED"));
|
|
195
|
+
console.log(chalk_1.default.red(`\n📊 Test Results:`));
|
|
196
|
+
console.log(chalk_1.default.gray(` Total Steps: ${result.steps.length}`));
|
|
197
|
+
console.log(chalk_1.default.green(` Passed: ${result.steps.filter((s) => !s.error).length}`));
|
|
198
|
+
console.log(chalk_1.default.red(` Failed: ${result.steps.filter((s) => s.error).length}`));
|
|
199
|
+
console.log(chalk_1.default.red("\n❌ Failed Steps:"));
|
|
200
|
+
result.steps.forEach((step, i) => {
|
|
201
|
+
if (step.error) {
|
|
202
|
+
console.log(chalk_1.default.red(` ${i + 1}. ${step.step || step.action || "Unknown"}`));
|
|
203
|
+
console.log(chalk_1.default.red(` Error: ${step.error}`));
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
if (result.error) {
|
|
207
|
+
console.log(chalk_1.default.red(`\n💥 Error: ${result.error}`));
|
|
208
|
+
}
|
|
209
|
+
process.exit(1);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
catch (error) {
|
|
213
|
+
spinner.fail(chalk_1.default.red("Error executing test"));
|
|
214
|
+
console.error(chalk_1.default.red(error.message));
|
|
215
|
+
if (options.verbose && error.stack) {
|
|
216
|
+
console.error(chalk_1.default.gray(error.stack));
|
|
217
|
+
}
|
|
218
|
+
process.exit(1);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
program
|
|
222
|
+
.command("validate")
|
|
223
|
+
.description("Validate a test case JSON file")
|
|
224
|
+
.argument("<file>", "Path to test case JSON file")
|
|
225
|
+
.action((file) => {
|
|
226
|
+
try {
|
|
227
|
+
const filePath = (0, path_1.resolve)(process.cwd(), file);
|
|
228
|
+
if (!(0, fs_1.existsSync)(filePath)) {
|
|
229
|
+
console.log(chalk_1.default.red(`❌ File not found: ${filePath}`));
|
|
230
|
+
process.exit(1);
|
|
231
|
+
}
|
|
232
|
+
const content = (0, fs_1.readFileSync)(filePath, "utf-8");
|
|
233
|
+
const testCase = JSON.parse(content);
|
|
234
|
+
console.log(chalk_1.default.blue("📋 Validating test case...\n"));
|
|
235
|
+
// Check required fields
|
|
236
|
+
const checks = [
|
|
237
|
+
{ field: "name", exists: !!testCase.name, message: "Test case name" },
|
|
238
|
+
{
|
|
239
|
+
field: "automated_steps",
|
|
240
|
+
exists: !!testCase.automated_steps,
|
|
241
|
+
message: "Automated steps array",
|
|
242
|
+
},
|
|
243
|
+
{
|
|
244
|
+
field: "automated_steps.length",
|
|
245
|
+
exists: testCase.automated_steps?.length > 0,
|
|
246
|
+
message: "At least one step",
|
|
247
|
+
},
|
|
248
|
+
];
|
|
249
|
+
let valid = true;
|
|
250
|
+
checks.forEach((check) => {
|
|
251
|
+
if (check.exists) {
|
|
252
|
+
console.log(chalk_1.default.green(`✅ ${check.message}`));
|
|
253
|
+
}
|
|
254
|
+
else {
|
|
255
|
+
console.log(chalk_1.default.red(`❌ ${check.message}`));
|
|
256
|
+
valid = false;
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
if (valid) {
|
|
260
|
+
console.log(chalk_1.default.green(`\n✅ Test case is valid!`));
|
|
261
|
+
console.log(chalk_1.default.gray(` Name: ${testCase.name}`));
|
|
262
|
+
console.log(chalk_1.default.gray(` Steps: ${testCase.automated_steps.length}`));
|
|
263
|
+
process.exit(0);
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
console.log(chalk_1.default.red(`\n❌ Test case validation failed`));
|
|
267
|
+
process.exit(1);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
catch (error) {
|
|
271
|
+
console.log(chalk_1.default.red(`❌ Error: ${error.message}`));
|
|
272
|
+
process.exit(1);
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
program
|
|
276
|
+
.command("example")
|
|
277
|
+
.description("Create an example test case JSON file")
|
|
278
|
+
.argument("[output]", "Output file path", "example-test.json")
|
|
279
|
+
.action((output) => {
|
|
280
|
+
const { writeFileSync } = require("fs");
|
|
281
|
+
const example = {
|
|
282
|
+
name: "Login Test Example",
|
|
283
|
+
test_case_id: "TC_EXAMPLE_001",
|
|
284
|
+
automated_steps: [
|
|
285
|
+
"Navigate to https://example.com",
|
|
286
|
+
"Wait 1 second",
|
|
287
|
+
'Fill "user@example.com" in "input[name=\'email\']"',
|
|
288
|
+
'Fill "password123" in "input[name=\'password\']"',
|
|
289
|
+
"Click \"button[type='submit']\"",
|
|
290
|
+
'Wait for "h1" to be visible',
|
|
291
|
+
'Check if "Welcome" is visible',
|
|
292
|
+
],
|
|
293
|
+
test_variables: {
|
|
294
|
+
email: "user@example.com",
|
|
295
|
+
password: "password123",
|
|
296
|
+
},
|
|
297
|
+
};
|
|
298
|
+
writeFileSync(output, JSON.stringify(example, null, 2));
|
|
299
|
+
console.log(chalk_1.default.green(`✅ Created example test case: ${output}`));
|
|
300
|
+
console.log(chalk_1.default.gray(`\nRun it with: cutlery-agent run ${output}`));
|
|
301
|
+
});
|
|
302
|
+
function runConnect(opts) {
|
|
303
|
+
const agentName = opts.name;
|
|
304
|
+
const webAppUrl = opts.url.replace(/\/$/, "");
|
|
305
|
+
const wsUrl = webAppUrl.replace(/^http/, "ws") + "/api/test-agent";
|
|
306
|
+
const startedAt = new Date().toISOString();
|
|
307
|
+
const log = (msg) => { if (!opts.silent)
|
|
308
|
+
console.log(msg); };
|
|
309
|
+
if (!opts.silent) {
|
|
310
|
+
console.log(chalk_1.default.blue("🚀 Cutlery Test Agent"));
|
|
311
|
+
console.log(chalk_1.default.gray(` Agent Name: ${agentName}`));
|
|
312
|
+
console.log(chalk_1.default.gray(` Web App: ${webAppUrl}`));
|
|
313
|
+
console.log(chalk_1.default.gray(` WebSocket: ${wsUrl}`));
|
|
314
|
+
console.log(chalk_1.default.gray(` Browser: ${opts.headless ? 'headless' : 'visible (use --headless to hide)'}\n`));
|
|
315
|
+
}
|
|
316
|
+
let stopping = false;
|
|
317
|
+
let reconnectDelay = 2000;
|
|
318
|
+
let attempt = 0;
|
|
319
|
+
let currentWs = null;
|
|
320
|
+
let registered = false;
|
|
321
|
+
let activeExecutor = null;
|
|
322
|
+
let reconnectTimer = null;
|
|
323
|
+
const identity = buildIdentity({
|
|
324
|
+
headless: opts.headless,
|
|
325
|
+
maxConcurrency: opts.maxConcurrency,
|
|
326
|
+
tags: opts.tags,
|
|
327
|
+
});
|
|
328
|
+
const running = new Map();
|
|
329
|
+
let lastErrorAt = 0;
|
|
330
|
+
const snapshot = () => ({
|
|
331
|
+
status: lastErrorAt > Date.now() - 30_000
|
|
332
|
+
? "error"
|
|
333
|
+
: running.size > 0
|
|
334
|
+
? "busy"
|
|
335
|
+
: "idle",
|
|
336
|
+
currentTests: Array.from(running.values()).map(t => ({
|
|
337
|
+
testCaseId: t.testCaseId,
|
|
338
|
+
testCaseName: t.testCaseName,
|
|
339
|
+
startedAt: t.startedAt,
|
|
340
|
+
currentStep: t.currentStep,
|
|
341
|
+
totalSteps: t.totalSteps,
|
|
342
|
+
currentStepText: t.currentStepText,
|
|
343
|
+
elapsedMs: Date.now() - t.startedAtMs,
|
|
344
|
+
})),
|
|
345
|
+
queueDepth: 0, // single-test executor today; bump when we add a queue
|
|
346
|
+
});
|
|
347
|
+
// Coalesce burst updates — multiple step events in the same tick produce one
|
|
348
|
+
// status message. Keeps the wire quiet during fast-running tests.
|
|
349
|
+
let statusFlushScheduled = false;
|
|
350
|
+
const sendStatus = () => {
|
|
351
|
+
if (statusFlushScheduled)
|
|
352
|
+
return;
|
|
353
|
+
statusFlushScheduled = true;
|
|
354
|
+
setImmediate(() => {
|
|
355
|
+
statusFlushScheduled = false;
|
|
356
|
+
if (currentWs?.readyState !== ws_1.default.OPEN)
|
|
357
|
+
return;
|
|
358
|
+
try {
|
|
359
|
+
currentWs.send(JSON.stringify({
|
|
360
|
+
type: "agent-status",
|
|
361
|
+
agentName,
|
|
362
|
+
timestamp: new Date().toISOString(),
|
|
363
|
+
snapshot: snapshot(),
|
|
364
|
+
}));
|
|
365
|
+
}
|
|
366
|
+
catch { /* ignore */ }
|
|
367
|
+
});
|
|
368
|
+
};
|
|
369
|
+
// Periodic status keepalive — keeps elapsedMs fresh in the UI even if the
|
|
370
|
+
// agent is idle. 5s is plenty for an ops dashboard; cheap on the wire.
|
|
371
|
+
const statusInterval = setInterval(() => {
|
|
372
|
+
if (currentWs?.readyState === ws_1.default.OPEN && registered)
|
|
373
|
+
sendStatus();
|
|
374
|
+
}, 5000);
|
|
375
|
+
const connect = () => {
|
|
376
|
+
if (stopping)
|
|
377
|
+
return;
|
|
378
|
+
attempt++;
|
|
379
|
+
const spinner = !opts.silent && attempt === 1 ? (0, ora_1.default)("Connecting to Cutlery...").start() : null;
|
|
380
|
+
if (!opts.silent && !spinner)
|
|
381
|
+
process.stdout.write(chalk_1.default.gray(` Reconnecting… (attempt ${attempt})\r`));
|
|
382
|
+
registered = false;
|
|
383
|
+
const ws = new ws_1.default(wsUrl);
|
|
384
|
+
currentWs = ws;
|
|
385
|
+
ws.on("open", () => {
|
|
386
|
+
reconnectDelay = 2000;
|
|
387
|
+
attempt = 0;
|
|
388
|
+
if (spinner) {
|
|
389
|
+
spinner.succeed(chalk_1.default.green("✅ Connected to Cutlery"));
|
|
390
|
+
}
|
|
391
|
+
else if (!opts.silent) {
|
|
392
|
+
process.stdout.write("\n");
|
|
393
|
+
console.log(chalk_1.default.green("✅ Reconnected to Cutlery"));
|
|
394
|
+
}
|
|
395
|
+
registered = false;
|
|
396
|
+
ws.send(JSON.stringify({
|
|
397
|
+
type: "agent-register",
|
|
398
|
+
agentName,
|
|
399
|
+
timestamp: new Date().toISOString(),
|
|
400
|
+
identity,
|
|
401
|
+
initialStatus: snapshot(),
|
|
402
|
+
}));
|
|
403
|
+
log(chalk_1.default.green(`\n✓ Agent "${agentName}" is ready to receive tests`));
|
|
404
|
+
if (!opts.silent)
|
|
405
|
+
console.log(chalk_1.default.gray(" Press Ctrl+C to disconnect\n"));
|
|
406
|
+
});
|
|
407
|
+
ws.on("message", async (data) => {
|
|
408
|
+
try {
|
|
409
|
+
const message = JSON.parse(data.toString());
|
|
410
|
+
if (opts.verbose) {
|
|
411
|
+
log(chalk_1.default.gray(`📨 Received: ${JSON.stringify(message, null, 2)}`));
|
|
412
|
+
}
|
|
413
|
+
if (message.type === "registered") {
|
|
414
|
+
registered = true;
|
|
415
|
+
log(chalk_1.default.green(`✓ Registration confirmed by server`));
|
|
416
|
+
opts.onRegistered?.();
|
|
417
|
+
}
|
|
418
|
+
else if (message.type === "evicted") {
|
|
419
|
+
log(chalk_1.default.red(`\n✗ Server evicted this agent: ${message.reason || "duplicate name"}\n` +
|
|
420
|
+
` Re-run with a unique --name to connect multiple agents from the same machine.`));
|
|
421
|
+
stopping = true;
|
|
422
|
+
try {
|
|
423
|
+
ws.close();
|
|
424
|
+
}
|
|
425
|
+
catch { /* ignore */ }
|
|
426
|
+
opts.onEvicted?.(message.reason || "duplicate name");
|
|
427
|
+
}
|
|
428
|
+
else if (message.type === "ping") {
|
|
429
|
+
ws.send(JSON.stringify({ type: "pong" }));
|
|
430
|
+
}
|
|
431
|
+
else if (message.type === "cancel-test") {
|
|
432
|
+
if (activeExecutor) {
|
|
433
|
+
log(chalk_1.default.yellow(`\n⏹ Cancelling test ${message.testCaseId}…`));
|
|
434
|
+
activeExecutor.cancel();
|
|
435
|
+
activeExecutor = null;
|
|
436
|
+
running.delete(message.testCaseId);
|
|
437
|
+
sendStatus();
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
else if (message.type === "execute-test" || message.type === "run-test") {
|
|
441
|
+
// Normalise both message formats:
|
|
442
|
+
// execute-test: { testCaseId, testCaseName, steps[], variables, baseUrl, ... }
|
|
443
|
+
// run-test (legacy): { testCase: { id, name, parsedSteps[], steps[], ... } }
|
|
444
|
+
const isFlat = message.type === "execute-test";
|
|
445
|
+
const testCaseId = isFlat ? message.testCaseId : (message.testCase?.id || message.testCase?.testCaseId);
|
|
446
|
+
const testCaseName = isFlat ? (message.testCaseName || testCaseId) : message.testCase?.name;
|
|
447
|
+
const baseUrl = isFlat ? message.baseUrl : message.testCase?.baseUrl;
|
|
448
|
+
const automated_steps = isFlat
|
|
449
|
+
? (message.steps || [])
|
|
450
|
+
: (message.testCase?.parsedSteps?.map((s) => s.description) || message.testCase?.steps || []);
|
|
451
|
+
const variables = isFlat
|
|
452
|
+
? (message.variables || {})
|
|
453
|
+
: (message.testCase?.test_variables || {});
|
|
454
|
+
log(chalk_1.default.blue(`\n📋 Received test: ${testCaseName}`));
|
|
455
|
+
log(chalk_1.default.gray(` Test ID: ${testCaseId}`));
|
|
456
|
+
log(chalk_1.default.gray(` Steps: ${automated_steps.length}`));
|
|
457
|
+
const testSpinner = !opts.silent ? (0, ora_1.default)("Executing test...").start() : null;
|
|
458
|
+
// Track this test in the live status snapshot — the UI's agent
|
|
459
|
+
// card / live viewer reads from here.
|
|
460
|
+
const startedAtMs = Date.now();
|
|
461
|
+
running.set(testCaseId, {
|
|
462
|
+
testCaseId,
|
|
463
|
+
testCaseName,
|
|
464
|
+
startedAt: new Date(startedAtMs).toISOString(),
|
|
465
|
+
startedAtMs,
|
|
466
|
+
currentStep: 0,
|
|
467
|
+
totalSteps: automated_steps.length,
|
|
468
|
+
currentStepText: "starting…",
|
|
469
|
+
});
|
|
470
|
+
sendStatus();
|
|
471
|
+
try {
|
|
472
|
+
const executor = new mcp_executor_js_1.TestExecutor({
|
|
473
|
+
headless: opts.headless,
|
|
474
|
+
browserType: "chromium",
|
|
475
|
+
baseUrl: baseUrl || "http://localhost:3000",
|
|
476
|
+
outputDir: "./test-results",
|
|
477
|
+
verbose: opts.verbose,
|
|
478
|
+
onProgress: (update) => {
|
|
479
|
+
// Update the running-test snapshot so the UI sees real-time
|
|
480
|
+
// step progression on the agent card without having to
|
|
481
|
+
// subscribe to per-test progress events.
|
|
482
|
+
const entry = running.get(testCaseId);
|
|
483
|
+
if (entry) {
|
|
484
|
+
if (typeof update.step === "number")
|
|
485
|
+
entry.currentStep = update.step;
|
|
486
|
+
if (typeof update.total === "number")
|
|
487
|
+
entry.totalSteps = update.total;
|
|
488
|
+
if (update.message)
|
|
489
|
+
entry.currentStepText = update.message;
|
|
490
|
+
sendStatus();
|
|
491
|
+
}
|
|
492
|
+
ws.send(JSON.stringify({
|
|
493
|
+
type: "test-update",
|
|
494
|
+
testCaseId,
|
|
495
|
+
data: {
|
|
496
|
+
type: "progress",
|
|
497
|
+
step: update.step,
|
|
498
|
+
total: update.total,
|
|
499
|
+
message: update.message,
|
|
500
|
+
status: update.type === "step-start"
|
|
501
|
+
? "running"
|
|
502
|
+
: update.type === "step-complete"
|
|
503
|
+
? "success"
|
|
504
|
+
: "failed",
|
|
505
|
+
error: update.error,
|
|
506
|
+
screenshot: update.screenshot,
|
|
507
|
+
},
|
|
508
|
+
}));
|
|
509
|
+
},
|
|
510
|
+
});
|
|
511
|
+
activeExecutor = executor;
|
|
512
|
+
const result = await executor.execute({
|
|
513
|
+
name: testCaseName,
|
|
514
|
+
test_case_id: testCaseId,
|
|
515
|
+
automated_steps,
|
|
516
|
+
test_variables: variables,
|
|
517
|
+
});
|
|
518
|
+
activeExecutor = null;
|
|
519
|
+
if (result.success) {
|
|
520
|
+
testSpinner?.succeed(chalk_1.default.green("✅ Test PASSED"));
|
|
521
|
+
ws.send(JSON.stringify({
|
|
522
|
+
type: "test-result",
|
|
523
|
+
testCaseId,
|
|
524
|
+
success: true,
|
|
525
|
+
steps: result.steps,
|
|
526
|
+
screenshots: result.screenshots,
|
|
527
|
+
timestamp: new Date().toISOString(),
|
|
528
|
+
}));
|
|
529
|
+
log(chalk_1.default.green(` Passed: ${result.steps.filter((s) => !s.error).length}/${result.steps.length} steps`));
|
|
530
|
+
}
|
|
531
|
+
else {
|
|
532
|
+
testSpinner?.fail(chalk_1.default.red("❌ Test FAILED"));
|
|
533
|
+
ws.send(JSON.stringify({
|
|
534
|
+
type: "test-result",
|
|
535
|
+
testCaseId,
|
|
536
|
+
success: false,
|
|
537
|
+
steps: result.steps,
|
|
538
|
+
error: result.error,
|
|
539
|
+
screenshots: result.screenshots,
|
|
540
|
+
timestamp: new Date().toISOString(),
|
|
541
|
+
}));
|
|
542
|
+
log(chalk_1.default.red(` Failed: ${result.steps.filter((s) => s.error).length}/${result.steps.length} steps`));
|
|
543
|
+
}
|
|
544
|
+
log(chalk_1.default.gray("\n Waiting for next test...\n"));
|
|
545
|
+
running.delete(testCaseId);
|
|
546
|
+
sendStatus();
|
|
547
|
+
}
|
|
548
|
+
catch (error) {
|
|
549
|
+
testSpinner?.fail(chalk_1.default.red("❌ Test execution error"));
|
|
550
|
+
if (!opts.silent)
|
|
551
|
+
console.error(chalk_1.default.red(` Error: ${error.message}`));
|
|
552
|
+
ws.send(JSON.stringify({
|
|
553
|
+
type: "test-result",
|
|
554
|
+
testCaseId,
|
|
555
|
+
success: false,
|
|
556
|
+
error: error.message,
|
|
557
|
+
timestamp: new Date().toISOString(),
|
|
558
|
+
}));
|
|
559
|
+
running.delete(testCaseId);
|
|
560
|
+
lastErrorAt = Date.now();
|
|
561
|
+
sendStatus();
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
catch (error) {
|
|
566
|
+
if (!opts.silent)
|
|
567
|
+
console.error(chalk_1.default.red(`Error processing message: ${error.message}`));
|
|
568
|
+
}
|
|
569
|
+
});
|
|
570
|
+
ws.on("error", (error) => {
|
|
571
|
+
const code = error.code || "";
|
|
572
|
+
const hint = code === "ECONNREFUSED"
|
|
573
|
+
? `Connection refused — is the Cutlery app running at ${webAppUrl}?`
|
|
574
|
+
: code === "ENOTFOUND"
|
|
575
|
+
? `Host not found — check the URL: ${webAppUrl}`
|
|
576
|
+
: code === "ETIMEDOUT"
|
|
577
|
+
? `Connection timed out — check the URL and network`
|
|
578
|
+
: error.message || code || String(error);
|
|
579
|
+
if (spinner?.isSpinning) {
|
|
580
|
+
spinner.fail(chalk_1.default.red(hint));
|
|
581
|
+
}
|
|
582
|
+
// subsequent attempts: close handler will log the retry
|
|
583
|
+
});
|
|
584
|
+
ws.on("close", (code) => {
|
|
585
|
+
currentWs = null;
|
|
586
|
+
opts.onClose?.(code);
|
|
587
|
+
if (stopping)
|
|
588
|
+
return;
|
|
589
|
+
if (registered) {
|
|
590
|
+
log(chalk_1.default.yellow(`\n⚠️ Disconnected (code ${code}).`));
|
|
591
|
+
}
|
|
592
|
+
const delay = reconnectDelay;
|
|
593
|
+
reconnectDelay = Math.min(reconnectDelay * 1.5, 30000);
|
|
594
|
+
reconnectTimer = setTimeout(connect, delay);
|
|
595
|
+
});
|
|
596
|
+
};
|
|
597
|
+
connect();
|
|
598
|
+
return {
|
|
599
|
+
agentName,
|
|
600
|
+
url: webAppUrl,
|
|
601
|
+
startedAt,
|
|
602
|
+
isConnected: () => currentWs?.readyState === ws_1.default.OPEN,
|
|
603
|
+
isRegistered: () => registered,
|
|
604
|
+
stop: async () => {
|
|
605
|
+
stopping = true;
|
|
606
|
+
clearInterval(statusInterval);
|
|
607
|
+
if (reconnectTimer) {
|
|
608
|
+
clearTimeout(reconnectTimer);
|
|
609
|
+
reconnectTimer = null;
|
|
610
|
+
}
|
|
611
|
+
if (activeExecutor) {
|
|
612
|
+
try {
|
|
613
|
+
activeExecutor.cancel();
|
|
614
|
+
}
|
|
615
|
+
catch { /* ignore */ }
|
|
616
|
+
activeExecutor = null;
|
|
617
|
+
}
|
|
618
|
+
if (currentWs) {
|
|
619
|
+
try {
|
|
620
|
+
currentWs.close(1000, "stop");
|
|
621
|
+
}
|
|
622
|
+
catch { /* ignore */ }
|
|
623
|
+
}
|
|
624
|
+
},
|
|
625
|
+
};
|
|
626
|
+
}
|
|
627
|
+
program
|
|
628
|
+
.command("connect")
|
|
629
|
+
.description("Connect to Cutlery web app and execute tests remotely")
|
|
630
|
+
.option("-u, --url <url>", "Cutlery web app URL", process.env.CUTLERY_URL || "http://localhost:3002")
|
|
631
|
+
.option("-n, --name <name>", "Agent name (must be unique per connection)", process.env.AGENT_NAME || `agent-${require("os").hostname()}-${process.pid}`)
|
|
632
|
+
.option("--headless", "Run tests in headless mode (default: browser is visible)", false)
|
|
633
|
+
.option("-t, --tag <tag...>", "Capability tag(s) for routing (repeatable). Also reads AGENT_TAGS (comma-separated).")
|
|
634
|
+
.option("--max-concurrency <n>", "Max tests this agent will accept at once (default: 1)", (v) => parseInt(v, 10))
|
|
635
|
+
.option("-v, --verbose", "Verbose output", false)
|
|
636
|
+
.action(async (options) => {
|
|
637
|
+
const envTags = (process.env.AGENT_TAGS || "")
|
|
638
|
+
.split(",")
|
|
639
|
+
.map((s) => s.trim())
|
|
640
|
+
.filter(Boolean);
|
|
641
|
+
const tags = [...(options.tag || []), ...envTags];
|
|
642
|
+
await ensureBrowsersInstalled();
|
|
643
|
+
const handle = runConnect({
|
|
644
|
+
url: options.url,
|
|
645
|
+
name: options.name,
|
|
646
|
+
headless: !!options.headless,
|
|
647
|
+
verbose: !!options.verbose,
|
|
648
|
+
tags: tags.length ? tags : undefined,
|
|
649
|
+
maxConcurrency: options.maxConcurrency,
|
|
650
|
+
onEvicted: () => setTimeout(() => process.exit(1), 200),
|
|
651
|
+
});
|
|
652
|
+
process.on("SIGINT", () => {
|
|
653
|
+
console.log(chalk_1.default.yellow("\n\n⏸️ Disconnecting agent..."));
|
|
654
|
+
handle.stop().finally(() => setTimeout(() => process.exit(0), 200));
|
|
655
|
+
});
|
|
656
|
+
});
|
|
657
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
658
|
+
// `serve` — local supervisor.
|
|
659
|
+
//
|
|
660
|
+
// Starts a tiny HTTP API on http://localhost:<port> that the Cutlery web UI
|
|
661
|
+
// can call to spawn / stop / list `connect` child processes. This lets QAs
|
|
662
|
+
// click "Start Agent" in Settings instead of pasting commands into a terminal.
|
|
663
|
+
//
|
|
664
|
+
// Usage:
|
|
665
|
+
// ./cutlery-agent-macos serve # default port 39007
|
|
666
|
+
// ./cutlery-agent-macos serve --port 39007 -v
|
|
667
|
+
//
|
|
668
|
+
// Endpoints (all CORS-enabled, including PNA preflight for HTTPS origins):
|
|
669
|
+
// GET /health → { ok, version, agents: [...] }
|
|
670
|
+
// GET /list → { agents: [{ name, pid, ... }] }
|
|
671
|
+
// POST /spawn { name, url, headless? } → { ok, name, pid }
|
|
672
|
+
// POST /stop { name } → { ok }
|
|
673
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
674
|
+
program
|
|
675
|
+
.command("serve")
|
|
676
|
+
.description("Run a local supervisor HTTP API so the Cutlery UI can start/stop agents")
|
|
677
|
+
.option("-p, --port <port>", "Local port to listen on", "39007")
|
|
678
|
+
.option("-v, --verbose", "Verbose logs", false)
|
|
679
|
+
.action(async (options) => {
|
|
680
|
+
const port = parseInt(options.port, 10);
|
|
681
|
+
const agents = new Map();
|
|
682
|
+
const log = (msg) => console.log(chalk_1.default.gray(`[serve ${new Date().toISOString()}] `) + msg);
|
|
683
|
+
// Per-agent log buffer for the /logs endpoint. We patch console while the
|
|
684
|
+
// agent is starting so any banner/error output is attributed to it; once
|
|
685
|
+
// running, the runConnect callbacks feed status lines in here too.
|
|
686
|
+
const LINE_BUFFER = 100;
|
|
687
|
+
const lastOutput = new Map();
|
|
688
|
+
const pushLine = (name, tag, text) => {
|
|
689
|
+
const trimmed = String(text).replace(/\r?\n$/, "");
|
|
690
|
+
if (!trimmed)
|
|
691
|
+
return;
|
|
692
|
+
const buf = lastOutput.get(name) || [];
|
|
693
|
+
buf.push(`[${new Date().toISOString()}] [${tag}] ${trimmed}`);
|
|
694
|
+
while (buf.length > LINE_BUFFER)
|
|
695
|
+
buf.shift();
|
|
696
|
+
lastOutput.set(name, buf);
|
|
697
|
+
if (options.verbose) {
|
|
698
|
+
console.log(chalk_1.default.gray(` ${name} ${tag}: `) + trimmed);
|
|
699
|
+
}
|
|
700
|
+
};
|
|
701
|
+
const spawnAgent = (name, url, headless) => {
|
|
702
|
+
if (agents.has(name)) {
|
|
703
|
+
return { ok: false, error: `Agent "${name}" is already running here.` };
|
|
704
|
+
}
|
|
705
|
+
try {
|
|
706
|
+
lastOutput.set(name, []);
|
|
707
|
+
pushLine(name, "info", `starting agent → ${url} (headless=${headless})`);
|
|
708
|
+
const handle = runConnect({
|
|
709
|
+
url,
|
|
710
|
+
name,
|
|
711
|
+
headless,
|
|
712
|
+
verbose: !!options.verbose,
|
|
713
|
+
silent: true, // suppress banner/spinner; we capture status via callbacks
|
|
714
|
+
onRegistered: () => {
|
|
715
|
+
pushLine(name, "ok", `registered with ${url}`);
|
|
716
|
+
log(chalk_1.default.green(`✓ agent "${name}" registered with server`));
|
|
717
|
+
},
|
|
718
|
+
onClose: (code) => {
|
|
719
|
+
pushLine(name, "warn", `websocket closed (code=${code}); will retry`);
|
|
720
|
+
},
|
|
721
|
+
onEvicted: (reason) => {
|
|
722
|
+
pushLine(name, "err", `evicted by server: ${reason}`);
|
|
723
|
+
log(chalk_1.default.red(`✗ agent "${name}" evicted: ${reason}`));
|
|
724
|
+
// Drop the agent — runConnect.stop() also flips its internal stopping flag.
|
|
725
|
+
const a = agents.get(name);
|
|
726
|
+
if (a) {
|
|
727
|
+
a.handle.stop().catch(() => { });
|
|
728
|
+
agents.delete(name);
|
|
729
|
+
}
|
|
730
|
+
},
|
|
731
|
+
});
|
|
732
|
+
agents.set(name, {
|
|
733
|
+
name,
|
|
734
|
+
url,
|
|
735
|
+
headless,
|
|
736
|
+
startedAt: new Date().toISOString(),
|
|
737
|
+
handle,
|
|
738
|
+
});
|
|
739
|
+
log(chalk_1.default.green(`▶ started agent "${name}" (in-process)`));
|
|
740
|
+
return { ok: true };
|
|
741
|
+
}
|
|
742
|
+
catch (err) {
|
|
743
|
+
pushLine(name, "err", `failed to start: ${err.message || err}`);
|
|
744
|
+
return { ok: false, error: err.message || String(err) };
|
|
745
|
+
}
|
|
746
|
+
};
|
|
747
|
+
const stopAgent = async (name) => {
|
|
748
|
+
const agent = agents.get(name);
|
|
749
|
+
if (!agent)
|
|
750
|
+
return { ok: false, error: `No agent "${name}" running here.` };
|
|
751
|
+
try {
|
|
752
|
+
await agent.handle.stop();
|
|
753
|
+
agents.delete(name);
|
|
754
|
+
pushLine(name, "info", "stopped by user");
|
|
755
|
+
log(chalk_1.default.yellow(`⏹ stopped agent "${name}"`));
|
|
756
|
+
return { ok: true };
|
|
757
|
+
}
|
|
758
|
+
catch (err) {
|
|
759
|
+
return { ok: false, error: err.message || String(err) };
|
|
760
|
+
}
|
|
761
|
+
};
|
|
762
|
+
const listAgents = () => Array.from(agents.values()).map(({ handle, ...rest }) => ({
|
|
763
|
+
...rest,
|
|
764
|
+
connected: handle.isConnected(),
|
|
765
|
+
registered: handle.isRegistered(),
|
|
766
|
+
}));
|
|
767
|
+
const sendJson = (res, status, body, origin) => {
|
|
768
|
+
res.writeHead(status, {
|
|
769
|
+
"Content-Type": "application/json",
|
|
770
|
+
// Permissive CORS — supervisor binds to localhost only, so this is safe.
|
|
771
|
+
"Access-Control-Allow-Origin": origin || "*",
|
|
772
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
773
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
774
|
+
"Access-Control-Allow-Private-Network": "true",
|
|
775
|
+
Vary: "Origin",
|
|
776
|
+
});
|
|
777
|
+
res.end(JSON.stringify(body));
|
|
778
|
+
};
|
|
779
|
+
const readBody = (req) => new Promise((resolve, reject) => {
|
|
780
|
+
let data = "";
|
|
781
|
+
req.on("data", (chunk) => (data += chunk));
|
|
782
|
+
req.on("end", () => {
|
|
783
|
+
if (!data)
|
|
784
|
+
return resolve({});
|
|
785
|
+
try {
|
|
786
|
+
resolve(JSON.parse(data));
|
|
787
|
+
}
|
|
788
|
+
catch (e) {
|
|
789
|
+
reject(e);
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
req.on("error", reject);
|
|
793
|
+
});
|
|
794
|
+
const server = http.createServer(async (req, res) => {
|
|
795
|
+
const origin = req.headers.origin;
|
|
796
|
+
// CORS preflight — must include PNA header for HTTPS → http://localhost.
|
|
797
|
+
if (req.method === "OPTIONS") {
|
|
798
|
+
res.writeHead(204, {
|
|
799
|
+
"Access-Control-Allow-Origin": origin || "*",
|
|
800
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
801
|
+
"Access-Control-Allow-Headers": "Content-Type, Authorization",
|
|
802
|
+
"Access-Control-Allow-Private-Network": "true",
|
|
803
|
+
"Access-Control-Max-Age": "600",
|
|
804
|
+
Vary: "Origin",
|
|
805
|
+
});
|
|
806
|
+
res.end();
|
|
807
|
+
return;
|
|
808
|
+
}
|
|
809
|
+
const url = req.url || "/";
|
|
810
|
+
try {
|
|
811
|
+
if (req.method === "GET" && (url === "/health" || url === "/")) {
|
|
812
|
+
return sendJson(res, 200, {
|
|
813
|
+
ok: true,
|
|
814
|
+
service: "cutlery-agent-supervisor",
|
|
815
|
+
version: program.version() || "1.0.0",
|
|
816
|
+
agents: listAgents(),
|
|
817
|
+
}, origin);
|
|
818
|
+
}
|
|
819
|
+
if (req.method === "GET" && url === "/list") {
|
|
820
|
+
return sendJson(res, 200, { agents: listAgents() }, origin);
|
|
821
|
+
}
|
|
822
|
+
// GET /logs/<agent-name> — tail of captured stdout/stderr for that agent.
|
|
823
|
+
if (req.method === "GET" && url.startsWith("/logs/")) {
|
|
824
|
+
const name = decodeURIComponent(url.slice("/logs/".length));
|
|
825
|
+
const lines = lastOutput.get(name) || [];
|
|
826
|
+
return sendJson(res, 200, { name, running: agents.has(name), lines }, origin);
|
|
827
|
+
}
|
|
828
|
+
if (req.method === "POST" && url === "/spawn") {
|
|
829
|
+
const body = await readBody(req);
|
|
830
|
+
const name = String(body.name || "").trim();
|
|
831
|
+
const targetUrl = String(body.url || "").trim();
|
|
832
|
+
const headless = !!body.headless;
|
|
833
|
+
if (!name)
|
|
834
|
+
return sendJson(res, 400, { ok: false, error: "name required" }, origin);
|
|
835
|
+
if (!targetUrl)
|
|
836
|
+
return sendJson(res, 400, { ok: false, error: "url required" }, origin);
|
|
837
|
+
const result = spawnAgent(name, targetUrl, headless);
|
|
838
|
+
return sendJson(res, result.ok ? 200 : 409, { ...result, name }, origin);
|
|
839
|
+
}
|
|
840
|
+
if (req.method === "POST" && url === "/stop") {
|
|
841
|
+
const body = await readBody(req);
|
|
842
|
+
const name = String(body.name || "").trim();
|
|
843
|
+
if (!name)
|
|
844
|
+
return sendJson(res, 400, { ok: false, error: "name required" }, origin);
|
|
845
|
+
const result = await stopAgent(name);
|
|
846
|
+
return sendJson(res, result.ok ? 200 : 404, result, origin);
|
|
847
|
+
}
|
|
848
|
+
return sendJson(res, 404, { error: "Not found" }, origin);
|
|
849
|
+
}
|
|
850
|
+
catch (err) {
|
|
851
|
+
return sendJson(res, 500, { error: err.message || String(err) }, origin);
|
|
852
|
+
}
|
|
853
|
+
});
|
|
854
|
+
server.listen(port, "127.0.0.1", () => {
|
|
855
|
+
console.log(chalk_1.default.blue("🛰 Cutlery Agent Supervisor"));
|
|
856
|
+
console.log(chalk_1.default.gray(` Listening on http://localhost:${port}`));
|
|
857
|
+
console.log(chalk_1.default.gray(` Open Cutlery → Settings → Local Agent → Agent Profiles to start agents.`));
|
|
858
|
+
console.log(chalk_1.default.gray(" Press Ctrl+C to stop the supervisor."));
|
|
859
|
+
});
|
|
860
|
+
const shutdown = () => {
|
|
861
|
+
console.log(chalk_1.default.yellow("\n⏸ Shutting down supervisor…"));
|
|
862
|
+
for (const agent of agents.values()) {
|
|
863
|
+
agent.handle.stop().catch(() => { });
|
|
864
|
+
}
|
|
865
|
+
server.close(() => process.exit(0));
|
|
866
|
+
// Hard-exit if children don't release the port.
|
|
867
|
+
setTimeout(() => process.exit(0), 2000);
|
|
868
|
+
};
|
|
869
|
+
process.on("SIGINT", shutdown);
|
|
870
|
+
process.on("SIGTERM", shutdown);
|
|
871
|
+
});
|
|
872
|
+
program.parse();
|
|
873
|
+
//# sourceMappingURL=cli.js.map
|