@frumu/tandem-panel 0.3.27
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/.env.example +42 -0
- package/README.md +131 -0
- package/bin/init-env.js +89 -0
- package/bin/setup.js +1369 -0
- package/dist/assets/index-BGpNZLeu.js +916 -0
- package/dist/assets/index-TZfxUYN2.css +1 -0
- package/dist/favicon.svg +11 -0
- package/dist/index.html +21 -0
- package/package.json +58 -0
package/bin/setup.js
ADDED
|
@@ -0,0 +1,1369 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from "child_process";
|
|
4
|
+
import { createServer } from "http";
|
|
5
|
+
import { readFileSync, existsSync, createReadStream, createWriteStream } from "fs";
|
|
6
|
+
import { mkdir, readdir, stat, rm, readFile, writeFile } from "fs/promises";
|
|
7
|
+
import { randomBytes } from "crypto";
|
|
8
|
+
import { join, dirname, extname, normalize, resolve, basename } from "path";
|
|
9
|
+
import { Transform } from "stream";
|
|
10
|
+
import { pipeline } from "stream/promises";
|
|
11
|
+
import { fileURLToPath } from "url";
|
|
12
|
+
import { createRequire } from "module";
|
|
13
|
+
import { homedir } from "os";
|
|
14
|
+
import { ensureEnv } from "./init-env.js";
|
|
15
|
+
|
|
16
|
+
function parseDotEnv(content) {
|
|
17
|
+
const out = {};
|
|
18
|
+
for (const raw of String(content || "").split(/\r?\n/)) {
|
|
19
|
+
const line = raw.trim();
|
|
20
|
+
if (!line || line.startsWith("#")) continue;
|
|
21
|
+
const idx = line.indexOf("=");
|
|
22
|
+
if (idx <= 0) continue;
|
|
23
|
+
const key = line.slice(0, idx).trim();
|
|
24
|
+
let value = line.slice(idx + 1).trim();
|
|
25
|
+
if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
|
|
26
|
+
value = value.slice(1, -1);
|
|
27
|
+
}
|
|
28
|
+
out[key] = value;
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function loadDotEnvFile(pathname) {
|
|
34
|
+
if (!existsSync(pathname)) return false;
|
|
35
|
+
const parsed = parseDotEnv(readFileSync(pathname, "utf8"));
|
|
36
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
37
|
+
if (process.env[key] === undefined) process.env[key] = value;
|
|
38
|
+
}
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function parseCliArgs(argv) {
|
|
43
|
+
const flags = new Set();
|
|
44
|
+
const values = new Map();
|
|
45
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
46
|
+
const raw = String(argv[i] || "").trim();
|
|
47
|
+
if (!raw) continue;
|
|
48
|
+
if (!raw.startsWith("--")) {
|
|
49
|
+
flags.add(raw);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const eq = raw.indexOf("=");
|
|
53
|
+
if (eq > 2) {
|
|
54
|
+
values.set(raw.slice(2, eq), raw.slice(eq + 1));
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const key = raw.slice(2);
|
|
58
|
+
const next = String(argv[i + 1] || "").trim();
|
|
59
|
+
if (next && !next.startsWith("-")) {
|
|
60
|
+
values.set(key, next);
|
|
61
|
+
i += 1;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
flags.add(raw);
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
flags,
|
|
68
|
+
values,
|
|
69
|
+
has(flag) {
|
|
70
|
+
return flags.has(flag) || flags.has(`--${flag}`) || values.has(flag);
|
|
71
|
+
},
|
|
72
|
+
value(key) {
|
|
73
|
+
return values.get(key);
|
|
74
|
+
},
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const cli = parseCliArgs(process.argv.slice(2));
|
|
79
|
+
const rawArgs = process.argv.slice(2);
|
|
80
|
+
const initRequested = cli.has("init");
|
|
81
|
+
const resetTokenRequested = cli.has("reset-token");
|
|
82
|
+
const installServicesRequested = cli.has("install-services");
|
|
83
|
+
const serviceModeRaw = String(cli.value("service-mode") || "both")
|
|
84
|
+
.trim()
|
|
85
|
+
.toLowerCase();
|
|
86
|
+
const serviceMode = ["both", "engine", "panel"].includes(serviceModeRaw) ? serviceModeRaw : "both";
|
|
87
|
+
const serviceUserArg = String(cli.value("service-user") || "").trim();
|
|
88
|
+
const serviceSetupOnly = rawArgs.length > 0 && rawArgs.every((arg) => {
|
|
89
|
+
if (arg === "--install-services") return true;
|
|
90
|
+
if (arg.startsWith("--service-mode")) return true;
|
|
91
|
+
if (arg.startsWith("--service-user")) return true;
|
|
92
|
+
return false;
|
|
93
|
+
});
|
|
94
|
+
const cwdEnvPath = resolve(process.cwd(), ".env");
|
|
95
|
+
|
|
96
|
+
if (initRequested) {
|
|
97
|
+
const result = ensureEnv({ overwrite: resetTokenRequested });
|
|
98
|
+
console.log("[Tandem Control Panel] Environment initialized.");
|
|
99
|
+
console.log(`[Tandem Control Panel] .env: ${result.envPath}`);
|
|
100
|
+
console.log(`[Tandem Control Panel] Engine URL: ${result.engineUrl}`);
|
|
101
|
+
console.log(`[Tandem Control Panel] Panel URL: http://localhost:${result.panelPort}`);
|
|
102
|
+
console.log(`[Tandem Control Panel] Token: ${result.token}`);
|
|
103
|
+
if (process.argv.slice(2).length === 1 || (process.argv.slice(2).length === 2 && resetTokenRequested)) {
|
|
104
|
+
process.exit(0);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
loadDotEnvFile(cwdEnvPath);
|
|
109
|
+
|
|
110
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
111
|
+
const DIST_DIR = join(__dirname, "..", "dist");
|
|
112
|
+
const REPO_ROOT = resolve(__dirname, "..", "..", "..");
|
|
113
|
+
|
|
114
|
+
function resolveDefaultChannelUploadsRoot() {
|
|
115
|
+
const explicitStateDir = String(process.env.TANDEM_STATE_DIR || "").trim();
|
|
116
|
+
if (explicitStateDir) return resolve(explicitStateDir, "channel_uploads");
|
|
117
|
+
|
|
118
|
+
const xdgDataHome = String(process.env.XDG_DATA_HOME || "").trim();
|
|
119
|
+
if (xdgDataHome) return resolve(xdgDataHome, "tandem", "data", "channel_uploads");
|
|
120
|
+
|
|
121
|
+
const appData = String(process.env.APPDATA || "").trim();
|
|
122
|
+
if (appData) return resolve(appData, "tandem", "data", "channel_uploads");
|
|
123
|
+
|
|
124
|
+
return resolve(homedir(), ".tandem", "data", "channel_uploads");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const PORTAL_PORT = Number.parseInt(process.env.TANDEM_CONTROL_PANEL_PORT || "39732", 10);
|
|
128
|
+
const ENGINE_HOST = (process.env.TANDEM_ENGINE_HOST || "127.0.0.1").trim();
|
|
129
|
+
const ENGINE_PORT = Number.parseInt(process.env.TANDEM_ENGINE_PORT || "39731", 10);
|
|
130
|
+
const ENGINE_URL = (process.env.TANDEM_ENGINE_URL || `http://${ENGINE_HOST}:${ENGINE_PORT}`).replace(/\/+$/, "");
|
|
131
|
+
const AUTO_START_ENGINE = (process.env.TANDEM_CONTROL_PANEL_AUTO_START_ENGINE || "1") !== "0";
|
|
132
|
+
const CONFIGURED_ENGINE_TOKEN = (
|
|
133
|
+
process.env.TANDEM_CONTROL_PANEL_ENGINE_TOKEN ||
|
|
134
|
+
process.env.TANDEM_API_TOKEN ||
|
|
135
|
+
""
|
|
136
|
+
).trim();
|
|
137
|
+
const SESSION_TTL_MS =
|
|
138
|
+
Number.parseInt(process.env.TANDEM_CONTROL_PANEL_SESSION_TTL_MINUTES || "1440", 10) * 60 * 1000;
|
|
139
|
+
const FILES_ROOT = resolve(process.env.TANDEM_CONTROL_PANEL_FILES_ROOT || resolveDefaultChannelUploadsRoot());
|
|
140
|
+
const FILES_SCOPE = String(process.env.TANDEM_CONTROL_PANEL_FILES_SCOPE || "control-panel")
|
|
141
|
+
.trim()
|
|
142
|
+
.replace(/\\/g, "/")
|
|
143
|
+
.replace(/^\/+/, "")
|
|
144
|
+
.replace(/\/+$/, "");
|
|
145
|
+
const MAX_UPLOAD_BYTES = Math.max(
|
|
146
|
+
1,
|
|
147
|
+
Number.parseInt(process.env.TANDEM_CONTROL_PANEL_MAX_UPLOAD_BYTES || `${250 * 1024 * 1024}`, 10) ||
|
|
148
|
+
250 * 1024 * 1024
|
|
149
|
+
);
|
|
150
|
+
const require = createRequire(import.meta.url);
|
|
151
|
+
const SETUP_ENTRYPOINT = fileURLToPath(import.meta.url);
|
|
152
|
+
|
|
153
|
+
const log = (msg) => console.log(`[Tandem Control Panel] ${msg}`);
|
|
154
|
+
const err = (msg) => console.error(`[Tandem Control Panel] ERROR: ${msg}`);
|
|
155
|
+
|
|
156
|
+
if (!Number.isFinite(PORTAL_PORT) || PORTAL_PORT <= 0) {
|
|
157
|
+
err(`Invalid TANDEM_CONTROL_PANEL_PORT: ${process.env.TANDEM_CONTROL_PANEL_PORT || ""}`);
|
|
158
|
+
process.exit(1);
|
|
159
|
+
}
|
|
160
|
+
if (!Number.isFinite(ENGINE_PORT) || ENGINE_PORT <= 0) {
|
|
161
|
+
err(`Invalid TANDEM_ENGINE_PORT: ${process.env.TANDEM_ENGINE_PORT || ""}`);
|
|
162
|
+
process.exit(1);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const MIME_TYPES = {
|
|
166
|
+
".html": "text/html",
|
|
167
|
+
".js": "text/javascript",
|
|
168
|
+
".css": "text/css",
|
|
169
|
+
".png": "image/png",
|
|
170
|
+
".svg": "image/svg+xml",
|
|
171
|
+
".json": "application/json",
|
|
172
|
+
".ico": "image/x-icon",
|
|
173
|
+
".txt": "text/plain",
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const sessions = new Map();
|
|
177
|
+
let engineProcess = null;
|
|
178
|
+
let server = null;
|
|
179
|
+
let managedEngineToken = "";
|
|
180
|
+
|
|
181
|
+
const swarmState = {
|
|
182
|
+
status: "idle",
|
|
183
|
+
process: null,
|
|
184
|
+
logs: [],
|
|
185
|
+
reasons: [],
|
|
186
|
+
monitorTimer: null,
|
|
187
|
+
registryCache: null,
|
|
188
|
+
startedAt: null,
|
|
189
|
+
stoppedAt: null,
|
|
190
|
+
objective: "",
|
|
191
|
+
workspaceRoot: REPO_ROOT,
|
|
192
|
+
maxTasks: 3,
|
|
193
|
+
lastError: "",
|
|
194
|
+
};
|
|
195
|
+
const swarmSseClients = new Set();
|
|
196
|
+
|
|
197
|
+
const sleep = (ms) => new Promise((resolveFn) => setTimeout(resolveFn, ms));
|
|
198
|
+
|
|
199
|
+
function shellEscape(token) {
|
|
200
|
+
const text = String(token || "");
|
|
201
|
+
if (/^[A-Za-z0-9_./:@-]+$/.test(text)) return text;
|
|
202
|
+
return `"${text.replace(/(["\\$`])/g, "\\$1")}"`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function runCmd(bin, args = [], options = {}) {
|
|
206
|
+
return new Promise((resolveFn, reject) => {
|
|
207
|
+
const child = spawn(bin, args, {
|
|
208
|
+
stdio: options.stdio || "pipe",
|
|
209
|
+
env: options.env || process.env,
|
|
210
|
+
});
|
|
211
|
+
let stdout = "";
|
|
212
|
+
let stderr = "";
|
|
213
|
+
if (child.stdout) {
|
|
214
|
+
child.stdout.on("data", (chunk) => {
|
|
215
|
+
stdout += chunk.toString("utf8");
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
if (child.stderr) {
|
|
219
|
+
child.stderr.on("data", (chunk) => {
|
|
220
|
+
stderr += chunk.toString("utf8");
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
child.on("error", reject);
|
|
224
|
+
child.on("close", (code) => {
|
|
225
|
+
if (code === 0) {
|
|
226
|
+
resolveFn({ stdout, stderr });
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
reject(new Error(`${bin} ${args.join(" ")} exited ${code}: ${stderr || stdout}`));
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function installServices() {
|
|
235
|
+
if (process.platform !== "linux") {
|
|
236
|
+
throw new Error("--install-services currently supports Linux/systemd only.");
|
|
237
|
+
}
|
|
238
|
+
if (typeof process.getuid === "function" && process.getuid() !== 0) {
|
|
239
|
+
throw new Error("Service installation needs root privileges. Re-run with sudo.");
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const serviceUser = serviceUserArg || String(process.env.SUDO_USER || process.env.USER || "root").trim();
|
|
243
|
+
if (!serviceUser) throw new Error("Could not determine service user.");
|
|
244
|
+
const serviceGroup = serviceUser;
|
|
245
|
+
const installEngine = serviceMode === "both" || serviceMode === "engine";
|
|
246
|
+
const installPanel = serviceMode === "both" || serviceMode === "panel";
|
|
247
|
+
const stateDir = String(process.env.TANDEM_STATE_DIR || "/srv/tandem").trim();
|
|
248
|
+
const engineEnvPath = "/etc/tandem/engine.env";
|
|
249
|
+
const panelEnvPath = "/etc/tandem/control-panel.env";
|
|
250
|
+
const engineServiceName = "tandem-engine";
|
|
251
|
+
const panelServiceName = "tandem-control-panel";
|
|
252
|
+
const engineBin = String(process.env.TANDEM_ENGINE_BIN || "tandem-engine").trim();
|
|
253
|
+
const token =
|
|
254
|
+
CONFIGURED_ENGINE_TOKEN ||
|
|
255
|
+
(existsSync(engineEnvPath) ? parseDotEnv(readFileSync(engineEnvPath, "utf8")).TANDEM_API_TOKEN || "" : "") ||
|
|
256
|
+
`tk_${randomBytes(16).toString("hex")}`;
|
|
257
|
+
|
|
258
|
+
await mkdir("/etc/tandem", { recursive: true });
|
|
259
|
+
await mkdir(stateDir, { recursive: true });
|
|
260
|
+
try {
|
|
261
|
+
await runCmd("chown", ["-R", `${serviceUser}:${serviceGroup}`, stateDir]);
|
|
262
|
+
} catch (e) {
|
|
263
|
+
log(`Warning: could not chown ${stateDir} to ${serviceUser}:${serviceGroup}: ${e.message}`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const existingEngineEnv = existsSync(engineEnvPath) ? parseDotEnv(readFileSync(engineEnvPath, "utf8")) : {};
|
|
267
|
+
const engineEnv = {
|
|
268
|
+
...existingEngineEnv,
|
|
269
|
+
TANDEM_API_TOKEN: token,
|
|
270
|
+
TANDEM_STATE_DIR: stateDir,
|
|
271
|
+
TANDEM_MEMORY_DB_PATH: existingEngineEnv.TANDEM_MEMORY_DB_PATH || `${stateDir}/memory.sqlite`,
|
|
272
|
+
TANDEM_ENABLE_GLOBAL_MEMORY: existingEngineEnv.TANDEM_ENABLE_GLOBAL_MEMORY || "1",
|
|
273
|
+
TANDEM_DISABLE_TOOL_GUARD_BUDGETS:
|
|
274
|
+
existingEngineEnv.TANDEM_DISABLE_TOOL_GUARD_BUDGETS || "1",
|
|
275
|
+
TANDEM_TOOL_ROUTER_ENABLED:
|
|
276
|
+
existingEngineEnv.TANDEM_TOOL_ROUTER_ENABLED || "0",
|
|
277
|
+
TANDEM_PROMPT_CONTEXT_HOOK_TIMEOUT_MS:
|
|
278
|
+
existingEngineEnv.TANDEM_PROMPT_CONTEXT_HOOK_TIMEOUT_MS || "5000",
|
|
279
|
+
TANDEM_PROVIDER_STREAM_CONNECT_TIMEOUT_MS:
|
|
280
|
+
existingEngineEnv.TANDEM_PROVIDER_STREAM_CONNECT_TIMEOUT_MS || "30000",
|
|
281
|
+
TANDEM_PROVIDER_STREAM_IDLE_TIMEOUT_MS:
|
|
282
|
+
existingEngineEnv.TANDEM_PROVIDER_STREAM_IDLE_TIMEOUT_MS || "90000",
|
|
283
|
+
TANDEM_PERMISSION_WAIT_TIMEOUT_MS:
|
|
284
|
+
existingEngineEnv.TANDEM_PERMISSION_WAIT_TIMEOUT_MS || "15000",
|
|
285
|
+
TANDEM_TOOL_EXEC_TIMEOUT_MS:
|
|
286
|
+
existingEngineEnv.TANDEM_TOOL_EXEC_TIMEOUT_MS || "45000",
|
|
287
|
+
TANDEM_BASH_TIMEOUT_MS: existingEngineEnv.TANDEM_BASH_TIMEOUT_MS || "30000",
|
|
288
|
+
};
|
|
289
|
+
const engineEnvBody = Object.entries(engineEnv)
|
|
290
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
291
|
+
.join("\n");
|
|
292
|
+
await writeFile(engineEnvPath, `${engineEnvBody}\n`, "utf8");
|
|
293
|
+
await runCmd("chmod", ["640", engineEnvPath]);
|
|
294
|
+
|
|
295
|
+
const panelAutoStart = serviceMode === "panel" ? "1" : "0";
|
|
296
|
+
const existingPanelEnv = existsSync(panelEnvPath) ? parseDotEnv(readFileSync(panelEnvPath, "utf8")) : {};
|
|
297
|
+
const panelEnv = {
|
|
298
|
+
...existingPanelEnv,
|
|
299
|
+
TANDEM_CONTROL_PANEL_PORT: String(PORTAL_PORT),
|
|
300
|
+
TANDEM_ENGINE_URL: ENGINE_URL,
|
|
301
|
+
TANDEM_CONTROL_PANEL_AUTO_START_ENGINE: panelAutoStart,
|
|
302
|
+
TANDEM_CONTROL_PANEL_ENGINE_TOKEN: token,
|
|
303
|
+
};
|
|
304
|
+
const panelEnvBody = Object.entries(panelEnv)
|
|
305
|
+
.map(([k, v]) => `${k}=${v}`)
|
|
306
|
+
.join("\n");
|
|
307
|
+
await writeFile(panelEnvPath, `${panelEnvBody}\n`, "utf8");
|
|
308
|
+
await runCmd("chmod", ["640", panelEnvPath]);
|
|
309
|
+
|
|
310
|
+
if (installEngine) {
|
|
311
|
+
const engineExec = [
|
|
312
|
+
engineBin,
|
|
313
|
+
"serve",
|
|
314
|
+
"--hostname",
|
|
315
|
+
ENGINE_HOST,
|
|
316
|
+
"--port",
|
|
317
|
+
String(ENGINE_PORT),
|
|
318
|
+
]
|
|
319
|
+
.map(shellEscape)
|
|
320
|
+
.join(" ");
|
|
321
|
+
const engineUnit = `[Unit]
|
|
322
|
+
Description=Tandem Engine
|
|
323
|
+
After=network.target
|
|
324
|
+
|
|
325
|
+
[Service]
|
|
326
|
+
Type=simple
|
|
327
|
+
User=${serviceUser}
|
|
328
|
+
Group=${serviceGroup}
|
|
329
|
+
EnvironmentFile=-${engineEnvPath}
|
|
330
|
+
ExecStart=${engineExec}
|
|
331
|
+
Restart=always
|
|
332
|
+
RestartSec=2
|
|
333
|
+
WorkingDirectory=${REPO_ROOT}
|
|
334
|
+
|
|
335
|
+
[Install]
|
|
336
|
+
WantedBy=multi-user.target
|
|
337
|
+
`;
|
|
338
|
+
await writeFile(`/etc/systemd/system/${engineServiceName}.service`, engineUnit, "utf8");
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
if (installPanel) {
|
|
342
|
+
const panelExec = [process.execPath, SETUP_ENTRYPOINT].map(shellEscape).join(" ");
|
|
343
|
+
const unitDependencies = installEngine
|
|
344
|
+
? `After=network.target ${engineServiceName}.service\nWants=${engineServiceName}.service`
|
|
345
|
+
: "After=network.target";
|
|
346
|
+
const panelUnit = `[Unit]
|
|
347
|
+
Description=Tandem Control Panel
|
|
348
|
+
${unitDependencies}
|
|
349
|
+
|
|
350
|
+
[Service]
|
|
351
|
+
Type=simple
|
|
352
|
+
User=${serviceUser}
|
|
353
|
+
Group=${serviceGroup}
|
|
354
|
+
EnvironmentFile=-${panelEnvPath}
|
|
355
|
+
ExecStart=${panelExec}
|
|
356
|
+
Restart=always
|
|
357
|
+
RestartSec=2
|
|
358
|
+
WorkingDirectory=${REPO_ROOT}
|
|
359
|
+
|
|
360
|
+
[Install]
|
|
361
|
+
WantedBy=multi-user.target
|
|
362
|
+
`;
|
|
363
|
+
await writeFile(`/etc/systemd/system/${panelServiceName}.service`, panelUnit, "utf8");
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
await runCmd("systemctl", ["daemon-reload"], { stdio: "inherit" });
|
|
367
|
+
if (installEngine) {
|
|
368
|
+
await runCmd("systemctl", ["enable", "--now", `${engineServiceName}.service`], { stdio: "inherit" });
|
|
369
|
+
}
|
|
370
|
+
if (installPanel) {
|
|
371
|
+
await runCmd("systemctl", ["enable", "--now", `${panelServiceName}.service`], { stdio: "inherit" });
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
log("Services installed.");
|
|
375
|
+
log(`Mode: ${serviceMode}`);
|
|
376
|
+
log(`Service user: ${serviceUser}`);
|
|
377
|
+
log(`Engine env: ${engineEnvPath}`);
|
|
378
|
+
log(`Panel env: ${panelEnvPath}`);
|
|
379
|
+
if (installEngine) log(`Engine service: ${engineServiceName}.service`);
|
|
380
|
+
if (installPanel) log(`Panel service: ${panelServiceName}.service`);
|
|
381
|
+
log(`Token: ${token}`);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function isLocalEngineUrl(url) {
|
|
385
|
+
try {
|
|
386
|
+
const u = new URL(url);
|
|
387
|
+
const h = (u.hostname || "").toLowerCase();
|
|
388
|
+
return h === "localhost" || h === "127.0.0.1" || h === "::1";
|
|
389
|
+
} catch {
|
|
390
|
+
return false;
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
function pruneExpiredSessions() {
|
|
395
|
+
const now = Date.now();
|
|
396
|
+
for (const [sid, rec] of sessions.entries()) {
|
|
397
|
+
if (now - rec.lastSeenAt > SESSION_TTL_MS) sessions.delete(sid);
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function parseCookies(req) {
|
|
402
|
+
const header = req.headers.cookie || "";
|
|
403
|
+
const out = {};
|
|
404
|
+
for (const part of header.split(";")) {
|
|
405
|
+
const trimmed = part.trim();
|
|
406
|
+
if (!trimmed) continue;
|
|
407
|
+
const idx = trimmed.indexOf("=");
|
|
408
|
+
if (idx <= 0) continue;
|
|
409
|
+
out[trimmed.slice(0, idx)] = decodeURIComponent(trimmed.slice(idx + 1));
|
|
410
|
+
}
|
|
411
|
+
return out;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function getSession(req) {
|
|
415
|
+
pruneExpiredSessions();
|
|
416
|
+
const sid = parseCookies(req).tcp_sid;
|
|
417
|
+
if (!sid) return null;
|
|
418
|
+
const rec = sessions.get(sid);
|
|
419
|
+
if (!rec) return null;
|
|
420
|
+
rec.lastSeenAt = Date.now();
|
|
421
|
+
return { sid, ...rec };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function setSessionCookie(res, sid) {
|
|
425
|
+
const attrs = [
|
|
426
|
+
`tcp_sid=${encodeURIComponent(sid)}`,
|
|
427
|
+
"HttpOnly",
|
|
428
|
+
"SameSite=Lax",
|
|
429
|
+
"Path=/",
|
|
430
|
+
`Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}`,
|
|
431
|
+
];
|
|
432
|
+
res.setHeader("Set-Cookie", attrs.join("; "));
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function clearSessionCookie(res) {
|
|
436
|
+
res.setHeader("Set-Cookie", "tcp_sid=; HttpOnly; SameSite=Lax; Path=/; Max-Age=0");
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
async function readJsonBody(req) {
|
|
440
|
+
const chunks = [];
|
|
441
|
+
for await (const chunk of req) chunks.push(chunk);
|
|
442
|
+
if (chunks.length === 0) return {};
|
|
443
|
+
const raw = Buffer.concat(chunks).toString("utf8").trim();
|
|
444
|
+
if (!raw) return {};
|
|
445
|
+
return JSON.parse(raw);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function sendJson(res, code, payload) {
|
|
449
|
+
if (res.headersSent || res.writableEnded || res.destroyed) return;
|
|
450
|
+
const body = JSON.stringify(payload);
|
|
451
|
+
res.writeHead(code, {
|
|
452
|
+
"content-type": "application/json",
|
|
453
|
+
"content-length": Buffer.byteLength(body),
|
|
454
|
+
});
|
|
455
|
+
res.end(body);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function pushSwarmEvent(kind, payload = {}) {
|
|
459
|
+
const event = {
|
|
460
|
+
kind,
|
|
461
|
+
ts: Date.now(),
|
|
462
|
+
...payload,
|
|
463
|
+
};
|
|
464
|
+
const line = `data: ${JSON.stringify(event)}\n\n`;
|
|
465
|
+
for (const client of [...swarmSseClients]) {
|
|
466
|
+
try {
|
|
467
|
+
client.write(line);
|
|
468
|
+
} catch {
|
|
469
|
+
swarmSseClients.delete(client);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function appendSwarmLog(stream, text) {
|
|
475
|
+
const lines = String(text || "").split(/\r?\n/).filter(Boolean);
|
|
476
|
+
for (const line of lines) {
|
|
477
|
+
swarmState.logs.push({ at: Date.now(), stream, line });
|
|
478
|
+
if (swarmState.logs.length > 800) swarmState.logs.shift();
|
|
479
|
+
pushSwarmEvent("log", { stream, line });
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function appendSwarmReason(reason) {
|
|
484
|
+
const item = { at: Date.now(), ...reason };
|
|
485
|
+
swarmState.reasons.push(item);
|
|
486
|
+
if (swarmState.reasons.length > 500) swarmState.reasons.shift();
|
|
487
|
+
pushSwarmEvent("reason", item);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function compareRegistryTransitions(previousRegistry, nextRegistry) {
|
|
491
|
+
const prevTasks = previousRegistry?.tasks || {};
|
|
492
|
+
const nextTasks = nextRegistry?.tasks || {};
|
|
493
|
+
const transitions = [];
|
|
494
|
+
|
|
495
|
+
for (const [taskId, next] of Object.entries(nextTasks)) {
|
|
496
|
+
const prev = prevTasks[taskId];
|
|
497
|
+
if (!prev) {
|
|
498
|
+
transitions.push({
|
|
499
|
+
kind: "task_transition",
|
|
500
|
+
taskId,
|
|
501
|
+
from: "new",
|
|
502
|
+
to: next.status || "unknown",
|
|
503
|
+
role: next.ownerRole || "",
|
|
504
|
+
reason: next.statusReason || "task registered",
|
|
505
|
+
});
|
|
506
|
+
continue;
|
|
507
|
+
}
|
|
508
|
+
if ((prev.status || "") !== (next.status || "")) {
|
|
509
|
+
transitions.push({
|
|
510
|
+
kind: "task_transition",
|
|
511
|
+
taskId,
|
|
512
|
+
from: prev.status || "unknown",
|
|
513
|
+
to: next.status || "unknown",
|
|
514
|
+
role: next.ownerRole || "",
|
|
515
|
+
reason: next.statusReason || `${prev.status || "unknown"} -> ${next.status || "unknown"}`,
|
|
516
|
+
});
|
|
517
|
+
} else if ((prev.statusReason || "") !== (next.statusReason || "") && next.statusReason) {
|
|
518
|
+
transitions.push({
|
|
519
|
+
kind: "task_reason",
|
|
520
|
+
taskId,
|
|
521
|
+
from: next.status || "unknown",
|
|
522
|
+
to: next.status || "unknown",
|
|
523
|
+
role: next.ownerRole || "",
|
|
524
|
+
reason: next.statusReason,
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
return transitions;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
async function monitorSwarmRegistry(token) {
|
|
533
|
+
try {
|
|
534
|
+
const latest = await readSwarmRegistry(token);
|
|
535
|
+
const latestValue = latest?.value || { tasks: {} };
|
|
536
|
+
const previous = swarmState.registryCache?.value || { tasks: {} };
|
|
537
|
+
const transitions = compareRegistryTransitions(previous, latestValue);
|
|
538
|
+
if (transitions.length > 0) {
|
|
539
|
+
for (const t of transitions) appendSwarmReason(t);
|
|
540
|
+
pushSwarmEvent("registry_update", { count: transitions.length });
|
|
541
|
+
}
|
|
542
|
+
swarmState.registryCache = latest;
|
|
543
|
+
} catch (e) {
|
|
544
|
+
appendSwarmLog("stderr", `[swarm-monitor] ${e instanceof Error ? e.message : String(e)}`);
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
function clearSwarmMonitor() {
|
|
549
|
+
if (swarmState.monitorTimer) {
|
|
550
|
+
clearInterval(swarmState.monitorTimer);
|
|
551
|
+
swarmState.monitorTimer = null;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
async function engineHealth(token = "") {
|
|
556
|
+
try {
|
|
557
|
+
const response = await fetch(`${ENGINE_URL}/global/health`, {
|
|
558
|
+
headers: token
|
|
559
|
+
? {
|
|
560
|
+
authorization: `Bearer ${token}`,
|
|
561
|
+
"x-tandem-token": token,
|
|
562
|
+
}
|
|
563
|
+
: {},
|
|
564
|
+
signal: AbortSignal.timeout(1800),
|
|
565
|
+
});
|
|
566
|
+
if (!response.ok) return null;
|
|
567
|
+
return await response.json();
|
|
568
|
+
} catch {
|
|
569
|
+
return null;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async function validateEngineToken(token) {
|
|
574
|
+
try {
|
|
575
|
+
const response = await fetch(`${ENGINE_URL}/config/providers`, {
|
|
576
|
+
headers: {
|
|
577
|
+
authorization: `Bearer ${token}`,
|
|
578
|
+
"x-tandem-token": token,
|
|
579
|
+
},
|
|
580
|
+
signal: AbortSignal.timeout(1800),
|
|
581
|
+
});
|
|
582
|
+
return response.ok;
|
|
583
|
+
} catch {
|
|
584
|
+
return false;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
async function ensureEngineRunning() {
|
|
589
|
+
if (!AUTO_START_ENGINE || !isLocalEngineUrl(ENGINE_URL)) return;
|
|
590
|
+
|
|
591
|
+
const healthy = await engineHealth();
|
|
592
|
+
if (healthy?.ready || healthy?.healthy) {
|
|
593
|
+
log(`Detected existing Tandem Engine at ${ENGINE_URL} (v${healthy.version || "unknown"}).`);
|
|
594
|
+
if (CONFIGURED_ENGINE_TOKEN) {
|
|
595
|
+
log(
|
|
596
|
+
"Note: TANDEM_CONTROL_PANEL_ENGINE_TOKEN is only applied when control panel starts a new engine process."
|
|
597
|
+
);
|
|
598
|
+
log("Use the existing engine's token, or stop that engine to let control panel start one with your configured token.");
|
|
599
|
+
}
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
let engineEntrypoint;
|
|
604
|
+
try {
|
|
605
|
+
engineEntrypoint = require.resolve("@frumu/tandem/bin/tandem-engine.js");
|
|
606
|
+
} catch (e) {
|
|
607
|
+
err("Could not resolve @frumu/tandem binary entrypoint.");
|
|
608
|
+
err("Reinstall with: npm i -g @frumu/tandem-panel");
|
|
609
|
+
throw e;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const url = new URL(ENGINE_URL);
|
|
613
|
+
managedEngineToken = CONFIGURED_ENGINE_TOKEN || `tk_${randomBytes(16).toString("hex")}`;
|
|
614
|
+
|
|
615
|
+
log(`Starting Tandem Engine at ${ENGINE_URL}...`);
|
|
616
|
+
engineProcess = spawn(
|
|
617
|
+
process.execPath,
|
|
618
|
+
[engineEntrypoint, "serve", "--hostname", url.hostname, "--port", String(url.port || ENGINE_PORT)],
|
|
619
|
+
{
|
|
620
|
+
env: {
|
|
621
|
+
...process.env,
|
|
622
|
+
TANDEM_API_TOKEN: managedEngineToken,
|
|
623
|
+
TANDEM_DISABLE_TOOL_GUARD_BUDGETS:
|
|
624
|
+
process.env.TANDEM_DISABLE_TOOL_GUARD_BUDGETS || "1",
|
|
625
|
+
TANDEM_TOOL_ROUTER_ENABLED:
|
|
626
|
+
process.env.TANDEM_TOOL_ROUTER_ENABLED || "0",
|
|
627
|
+
TANDEM_PROMPT_CONTEXT_HOOK_TIMEOUT_MS:
|
|
628
|
+
process.env.TANDEM_PROMPT_CONTEXT_HOOK_TIMEOUT_MS || "5000",
|
|
629
|
+
TANDEM_PROVIDER_STREAM_CONNECT_TIMEOUT_MS:
|
|
630
|
+
process.env.TANDEM_PROVIDER_STREAM_CONNECT_TIMEOUT_MS || "30000",
|
|
631
|
+
TANDEM_PROVIDER_STREAM_IDLE_TIMEOUT_MS:
|
|
632
|
+
process.env.TANDEM_PROVIDER_STREAM_IDLE_TIMEOUT_MS || "90000",
|
|
633
|
+
TANDEM_PERMISSION_WAIT_TIMEOUT_MS:
|
|
634
|
+
process.env.TANDEM_PERMISSION_WAIT_TIMEOUT_MS || "15000",
|
|
635
|
+
TANDEM_TOOL_EXEC_TIMEOUT_MS:
|
|
636
|
+
process.env.TANDEM_TOOL_EXEC_TIMEOUT_MS || "45000",
|
|
637
|
+
TANDEM_BASH_TIMEOUT_MS: process.env.TANDEM_BASH_TIMEOUT_MS || "30000",
|
|
638
|
+
},
|
|
639
|
+
stdio: "inherit",
|
|
640
|
+
}
|
|
641
|
+
);
|
|
642
|
+
log(`Engine API token for this process: ${managedEngineToken}`);
|
|
643
|
+
if (!CONFIGURED_ENGINE_TOKEN) {
|
|
644
|
+
log("Token was auto-generated. Set TANDEM_CONTROL_PANEL_ENGINE_TOKEN (or TANDEM_API_TOKEN) to keep it stable.");
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
engineProcess.on("error", (e) => err(`Failed to start engine: ${e.message}`));
|
|
648
|
+
|
|
649
|
+
for (let i = 0; i < 30; i += 1) {
|
|
650
|
+
const probe = await engineHealth();
|
|
651
|
+
if (probe?.ready || probe?.healthy) {
|
|
652
|
+
log(`Engine ready (v${probe.version || "unknown"}).`);
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
655
|
+
await sleep(300);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
err("Engine did not become healthy in time.");
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
function sanitizeStaticPath(rawUrl) {
|
|
662
|
+
const url = new URL(rawUrl || "/", `http://127.0.0.1:${PORTAL_PORT}`);
|
|
663
|
+
const decoded = decodeURIComponent(url.pathname || "/");
|
|
664
|
+
const relative = decoded === "/" ? "index.html" : decoded.replace(/^\/+/, "");
|
|
665
|
+
const full = normalize(join(DIST_DIR, relative));
|
|
666
|
+
if (!full.startsWith(DIST_DIR + "/") && full !== DIST_DIR) return null;
|
|
667
|
+
return full;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
function toSafeRelPath(raw) {
|
|
671
|
+
const normalized = String(raw || "")
|
|
672
|
+
.trim()
|
|
673
|
+
.replace(/\\/g, "/")
|
|
674
|
+
.replace(/^\/+/, "");
|
|
675
|
+
if (!normalized) return "";
|
|
676
|
+
if (normalized.includes("\0")) return null;
|
|
677
|
+
const full = resolve(FILES_ROOT, normalized);
|
|
678
|
+
if (full !== FILES_ROOT && !full.startsWith(`${FILES_ROOT}/`)) return null;
|
|
679
|
+
if (FILES_SCOPE && normalized !== FILES_SCOPE && !normalized.startsWith(`${FILES_SCOPE}/`)) return null;
|
|
680
|
+
return normalized;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function toSafeRelFileName(rawName) {
|
|
684
|
+
const cleaned = basename(String(rawName || "").trim()).replace(/[\0]/g, "");
|
|
685
|
+
if (!cleaned || cleaned === "." || cleaned === "..") return null;
|
|
686
|
+
return cleaned;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
async function ensureUniqueRelPath(relativePath) {
|
|
690
|
+
const ext = extname(relativePath);
|
|
691
|
+
const stem = ext ? relativePath.slice(0, -ext.length) : relativePath;
|
|
692
|
+
let candidate = relativePath;
|
|
693
|
+
let counter = 1;
|
|
694
|
+
while (true) {
|
|
695
|
+
const full = resolve(FILES_ROOT, candidate);
|
|
696
|
+
try {
|
|
697
|
+
await stat(full);
|
|
698
|
+
counter += 1;
|
|
699
|
+
candidate = `${stem}-${counter}${ext}`;
|
|
700
|
+
} catch {
|
|
701
|
+
return candidate;
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
async function handleFilesApi(req, res, _session) {
|
|
707
|
+
const url = new URL(req.url, `http://127.0.0.1:${PORTAL_PORT}`);
|
|
708
|
+
const pathname = url.pathname;
|
|
709
|
+
|
|
710
|
+
if (pathname === "/api/files/list" && req.method === "GET") {
|
|
711
|
+
const incomingDir = url.searchParams.get("dir") || "";
|
|
712
|
+
const defaultDir = FILES_SCOPE || "";
|
|
713
|
+
const dirRelRaw = toSafeRelPath(incomingDir || defaultDir);
|
|
714
|
+
if (dirRelRaw === null) {
|
|
715
|
+
sendJson(res, 400, { ok: false, error: "Invalid directory path." });
|
|
716
|
+
return true;
|
|
717
|
+
}
|
|
718
|
+
const dirRel = dirRelRaw || "";
|
|
719
|
+
const dirFull = resolve(FILES_ROOT, dirRel);
|
|
720
|
+
try {
|
|
721
|
+
await mkdir(dirFull, { recursive: true });
|
|
722
|
+
const entries = await readdir(dirFull, { withFileTypes: true });
|
|
723
|
+
const files = [];
|
|
724
|
+
for (const entry of entries) {
|
|
725
|
+
const childRel = dirRel ? `${dirRel}/${entry.name}` : entry.name;
|
|
726
|
+
if (!entry.isFile()) continue;
|
|
727
|
+
const info = await stat(resolve(FILES_ROOT, childRel)).catch(() => null);
|
|
728
|
+
files.push({
|
|
729
|
+
name: entry.name,
|
|
730
|
+
path: childRel,
|
|
731
|
+
size: info?.size || 0,
|
|
732
|
+
updatedAt: info?.mtimeMs || 0,
|
|
733
|
+
});
|
|
734
|
+
}
|
|
735
|
+
files.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
736
|
+
sendJson(res, 200, { ok: true, root: FILES_ROOT, files });
|
|
737
|
+
} catch (e) {
|
|
738
|
+
sendJson(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) });
|
|
739
|
+
}
|
|
740
|
+
return true;
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
if (pathname === "/api/files/upload" && req.method === "POST") {
|
|
744
|
+
const nameHeader = req.headers["x-file-name"];
|
|
745
|
+
const rawName = decodeURIComponent(
|
|
746
|
+
Array.isArray(nameHeader) ? String(nameHeader[0] || "") : String(nameHeader || "")
|
|
747
|
+
);
|
|
748
|
+
const safeName = toSafeRelFileName(rawName);
|
|
749
|
+
if (!safeName) {
|
|
750
|
+
sendJson(res, 400, { ok: false, error: "Missing or invalid x-file-name header." });
|
|
751
|
+
return true;
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const incomingDir = url.searchParams.get("dir") || "";
|
|
755
|
+
const defaultDir = FILES_SCOPE || "";
|
|
756
|
+
const dirRelRaw = toSafeRelPath(incomingDir || defaultDir);
|
|
757
|
+
if (dirRelRaw === null) {
|
|
758
|
+
sendJson(res, 400, { ok: false, error: "Invalid upload directory." });
|
|
759
|
+
return true;
|
|
760
|
+
}
|
|
761
|
+
const dirRel = dirRelRaw || "";
|
|
762
|
+
let relPath = dirRel ? `${dirRel}/${safeName}` : safeName;
|
|
763
|
+
relPath = await ensureUniqueRelPath(relPath);
|
|
764
|
+
const fullPath = resolve(FILES_ROOT, relPath);
|
|
765
|
+
const folder = dirname(fullPath);
|
|
766
|
+
|
|
767
|
+
try {
|
|
768
|
+
await mkdir(folder, { recursive: true });
|
|
769
|
+
let bytes = 0;
|
|
770
|
+
const guard = new Transform({
|
|
771
|
+
transform(chunk, _enc, cb) {
|
|
772
|
+
bytes += chunk.length;
|
|
773
|
+
if (bytes > MAX_UPLOAD_BYTES) {
|
|
774
|
+
cb(new Error(`Upload exceeds limit of ${MAX_UPLOAD_BYTES} bytes.`));
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
cb(null, chunk);
|
|
778
|
+
},
|
|
779
|
+
});
|
|
780
|
+
await pipeline(req, guard, createWriteStream(fullPath, { flags: "wx" }));
|
|
781
|
+
const meta = await stat(fullPath);
|
|
782
|
+
sendJson(res, 200, {
|
|
783
|
+
ok: true,
|
|
784
|
+
root: FILES_ROOT,
|
|
785
|
+
name: safeName,
|
|
786
|
+
path: relPath,
|
|
787
|
+
absPath: fullPath,
|
|
788
|
+
size: meta.size,
|
|
789
|
+
downloadUrl: `/api/files/download?path=${encodeURIComponent(relPath)}`,
|
|
790
|
+
});
|
|
791
|
+
} catch (e) {
|
|
792
|
+
if (e && typeof e === "object" && "code" in e && e.code === "EEXIST") {
|
|
793
|
+
sendJson(res, 409, { ok: false, error: "File already exists." });
|
|
794
|
+
} else {
|
|
795
|
+
sendJson(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) });
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
return true;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
if (pathname === "/api/files/read" && req.method === "GET") {
|
|
802
|
+
const rel = toSafeRelPath(url.searchParams.get("path") || "");
|
|
803
|
+
if (!rel) {
|
|
804
|
+
sendJson(res, 400, { ok: false, error: "Missing or invalid file path." });
|
|
805
|
+
return true;
|
|
806
|
+
}
|
|
807
|
+
const full = resolve(FILES_ROOT, rel);
|
|
808
|
+
try {
|
|
809
|
+
const info = await stat(full);
|
|
810
|
+
if (!info.isFile()) throw new Error("Not a file");
|
|
811
|
+
if (info.size > MAX_UPLOAD_BYTES) {
|
|
812
|
+
sendJson(res, 413, { ok: false, error: "File too large to read through API." });
|
|
813
|
+
return true;
|
|
814
|
+
}
|
|
815
|
+
const text = await readFile(full, "utf8");
|
|
816
|
+
sendJson(res, 200, {
|
|
817
|
+
ok: true,
|
|
818
|
+
root: FILES_ROOT,
|
|
819
|
+
path: rel,
|
|
820
|
+
absPath: full,
|
|
821
|
+
size: info.size,
|
|
822
|
+
text,
|
|
823
|
+
});
|
|
824
|
+
} catch {
|
|
825
|
+
sendJson(res, 404, { ok: false, error: "File not found." });
|
|
826
|
+
}
|
|
827
|
+
return true;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
if (pathname === "/api/files/write" && req.method === "POST") {
|
|
831
|
+
try {
|
|
832
|
+
const body = await readJsonBody(req);
|
|
833
|
+
const rel = toSafeRelPath(body?.path || "");
|
|
834
|
+
const text = String(body?.text ?? "");
|
|
835
|
+
const overwrite = body?.overwrite !== false;
|
|
836
|
+
if (!rel) {
|
|
837
|
+
sendJson(res, 400, { ok: false, error: "Missing or invalid file path." });
|
|
838
|
+
return true;
|
|
839
|
+
}
|
|
840
|
+
if (Buffer.byteLength(text, "utf8") > MAX_UPLOAD_BYTES) {
|
|
841
|
+
sendJson(res, 413, { ok: false, error: "Text payload exceeds max upload bytes limit." });
|
|
842
|
+
return true;
|
|
843
|
+
}
|
|
844
|
+
const full = resolve(FILES_ROOT, rel);
|
|
845
|
+
await mkdir(dirname(full), { recursive: true });
|
|
846
|
+
await writeFile(full, text, { encoding: "utf8", flag: overwrite ? "w" : "wx" });
|
|
847
|
+
const info = await stat(full);
|
|
848
|
+
sendJson(res, 200, {
|
|
849
|
+
ok: true,
|
|
850
|
+
root: FILES_ROOT,
|
|
851
|
+
path: rel,
|
|
852
|
+
absPath: full,
|
|
853
|
+
size: info.size,
|
|
854
|
+
});
|
|
855
|
+
} catch (e) {
|
|
856
|
+
if (e && typeof e === "object" && "code" in e && e.code === "EEXIST") {
|
|
857
|
+
sendJson(res, 409, { ok: false, error: "File already exists." });
|
|
858
|
+
} else {
|
|
859
|
+
sendJson(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) });
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
return true;
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
if (pathname === "/api/files/download" && req.method === "GET") {
|
|
866
|
+
const rel = toSafeRelPath(url.searchParams.get("path") || "");
|
|
867
|
+
if (!rel) {
|
|
868
|
+
sendJson(res, 400, { ok: false, error: "Missing or invalid file path." });
|
|
869
|
+
return true;
|
|
870
|
+
}
|
|
871
|
+
const full = resolve(FILES_ROOT, rel);
|
|
872
|
+
try {
|
|
873
|
+
const info = await stat(full);
|
|
874
|
+
if (!info.isFile()) throw new Error("Not a file");
|
|
875
|
+
const ext = extname(full);
|
|
876
|
+
const mime = MIME_TYPES[ext] || "application/octet-stream";
|
|
877
|
+
res.writeHead(200, {
|
|
878
|
+
"content-type": mime,
|
|
879
|
+
"content-length": String(info.size),
|
|
880
|
+
"content-disposition": `attachment; filename="${basename(full).replace(/"/g, "")}"`,
|
|
881
|
+
});
|
|
882
|
+
createReadStream(full).pipe(res);
|
|
883
|
+
} catch {
|
|
884
|
+
sendJson(res, 404, { ok: false, error: "File not found." });
|
|
885
|
+
}
|
|
886
|
+
return true;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
if (pathname === "/api/files/delete" && req.method === "POST") {
|
|
890
|
+
try {
|
|
891
|
+
const body = await readJsonBody(req);
|
|
892
|
+
const rel = toSafeRelPath(body?.path || "");
|
|
893
|
+
if (!rel) {
|
|
894
|
+
sendJson(res, 400, { ok: false, error: "Missing or invalid file path." });
|
|
895
|
+
return true;
|
|
896
|
+
}
|
|
897
|
+
await rm(resolve(FILES_ROOT, rel), { force: true });
|
|
898
|
+
sendJson(res, 200, { ok: true, path: rel });
|
|
899
|
+
} catch (e) {
|
|
900
|
+
sendJson(res, 500, { ok: false, error: e instanceof Error ? e.message : String(e) });
|
|
901
|
+
}
|
|
902
|
+
return true;
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
return false;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
async function handleAuthLogin(req, res) {
|
|
909
|
+
try {
|
|
910
|
+
const body = await readJsonBody(req);
|
|
911
|
+
const token = String(body?.token || "").trim();
|
|
912
|
+
if (!token) {
|
|
913
|
+
sendJson(res, 400, { ok: false, error: "Token required" });
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
const health = await engineHealth();
|
|
917
|
+
if (!health) {
|
|
918
|
+
sendJson(res, 502, { ok: false, error: "Engine unavailable" });
|
|
919
|
+
return;
|
|
920
|
+
}
|
|
921
|
+
if (health.apiTokenRequired) {
|
|
922
|
+
const valid = await validateEngineToken(token);
|
|
923
|
+
if (!valid) {
|
|
924
|
+
sendJson(res, 401, { ok: false, error: "Invalid engine API token" });
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
const sid = randomBytes(24).toString("hex");
|
|
929
|
+
sessions.set(sid, { token, createdAt: Date.now(), lastSeenAt: Date.now() });
|
|
930
|
+
setSessionCookie(res, sid);
|
|
931
|
+
sendJson(res, 200, {
|
|
932
|
+
ok: true,
|
|
933
|
+
requiresToken: !!health.apiTokenRequired,
|
|
934
|
+
engine: { url: ENGINE_URL, version: health.version || "unknown", local: isLocalEngineUrl(ENGINE_URL) },
|
|
935
|
+
});
|
|
936
|
+
} catch (e) {
|
|
937
|
+
sendJson(res, 400, { ok: false, error: e instanceof Error ? e.message : String(e) });
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
function requireSession(req, res) {
|
|
942
|
+
const session = getSession(req);
|
|
943
|
+
if (!session) {
|
|
944
|
+
sendJson(res, 401, { ok: false, error: "Unauthorized" });
|
|
945
|
+
return null;
|
|
946
|
+
}
|
|
947
|
+
return session;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
async function proxyEngineRequest(req, res, session) {
|
|
951
|
+
const incoming = new URL(req.url, `http://127.0.0.1:${PORTAL_PORT}`);
|
|
952
|
+
const targetPath = incoming.pathname.replace(/^\/api\/engine/, "") || "/";
|
|
953
|
+
const targetUrl = `${ENGINE_URL}${targetPath}${incoming.search}`;
|
|
954
|
+
|
|
955
|
+
const headers = new Headers();
|
|
956
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
957
|
+
if (!value) continue;
|
|
958
|
+
const lower = key.toLowerCase();
|
|
959
|
+
if (["host", "content-length", "cookie", "authorization", "x-tandem-token"].includes(lower)) {
|
|
960
|
+
continue;
|
|
961
|
+
}
|
|
962
|
+
if (Array.isArray(value)) headers.set(key, value.join(", "));
|
|
963
|
+
else headers.set(key, value);
|
|
964
|
+
}
|
|
965
|
+
headers.set("authorization", `Bearer ${session.token}`);
|
|
966
|
+
headers.set("x-tandem-token", session.token);
|
|
967
|
+
|
|
968
|
+
const hasBody = !["GET", "HEAD"].includes(req.method || "GET");
|
|
969
|
+
|
|
970
|
+
let upstream;
|
|
971
|
+
try {
|
|
972
|
+
upstream = await fetch(targetUrl, {
|
|
973
|
+
method: req.method,
|
|
974
|
+
headers,
|
|
975
|
+
body: hasBody ? req : undefined,
|
|
976
|
+
duplex: hasBody ? "half" : undefined,
|
|
977
|
+
});
|
|
978
|
+
} catch (e) {
|
|
979
|
+
sendJson(res, 502, { ok: false, error: `Engine unreachable: ${e instanceof Error ? e.message : String(e)}` });
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const responseHeaders = {};
|
|
984
|
+
upstream.headers.forEach((value, key) => {
|
|
985
|
+
const lower = key.toLowerCase();
|
|
986
|
+
if (["content-encoding", "transfer-encoding", "connection"].includes(lower)) return;
|
|
987
|
+
responseHeaders[key] = value;
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
try {
|
|
991
|
+
res.writeHead(upstream.status, responseHeaders);
|
|
992
|
+
if (!upstream.body) {
|
|
993
|
+
res.end();
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
for await (const chunk of upstream.body) {
|
|
997
|
+
if (res.writableEnded || res.destroyed) break;
|
|
998
|
+
res.write(chunk);
|
|
999
|
+
}
|
|
1000
|
+
if (!res.writableEnded && !res.destroyed) {
|
|
1001
|
+
res.end();
|
|
1002
|
+
}
|
|
1003
|
+
} catch (e) {
|
|
1004
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
1005
|
+
if (res.headersSent) {
|
|
1006
|
+
const lower = message.toLowerCase();
|
|
1007
|
+
// SSE/streaming upstream can terminate normally from the engine side.
|
|
1008
|
+
if (lower.includes("terminated") || lower.includes("aborted")) {
|
|
1009
|
+
if (!res.writableEnded && !res.destroyed) {
|
|
1010
|
+
res.end();
|
|
1011
|
+
}
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
if (!res.destroyed && !res.writableEnded) {
|
|
1015
|
+
res.destroy(e instanceof Error ? e : undefined);
|
|
1016
|
+
}
|
|
1017
|
+
return;
|
|
1018
|
+
}
|
|
1019
|
+
sendJson(res, 502, {
|
|
1020
|
+
ok: false,
|
|
1021
|
+
error: `Engine proxy stream failed: ${message}`,
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
async function readSwarmRegistry(token) {
|
|
1027
|
+
const keys = ["swarm.active_tasks", "project/swarm.active_tasks"];
|
|
1028
|
+
for (const key of keys) {
|
|
1029
|
+
try {
|
|
1030
|
+
const response = await fetch(`${ENGINE_URL}/resource/${encodeURIComponent(key)}`, {
|
|
1031
|
+
headers: {
|
|
1032
|
+
authorization: `Bearer ${token}`,
|
|
1033
|
+
"x-tandem-token": token,
|
|
1034
|
+
},
|
|
1035
|
+
signal: AbortSignal.timeout(1200),
|
|
1036
|
+
});
|
|
1037
|
+
if (!response.ok) continue;
|
|
1038
|
+
const record = await response.json();
|
|
1039
|
+
if (record?.value && typeof record.value === "object") {
|
|
1040
|
+
return { key, value: record.value };
|
|
1041
|
+
}
|
|
1042
|
+
} catch {
|
|
1043
|
+
// ignore
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
return { key: "swarm.active_tasks", value: { version: 1, updatedAtMs: Date.now(), tasks: {} } };
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
function stopSwarm() {
|
|
1050
|
+
if (!swarmState.process) return;
|
|
1051
|
+
swarmState.status = "stopping";
|
|
1052
|
+
clearSwarmMonitor();
|
|
1053
|
+
swarmState.process.kill("SIGTERM");
|
|
1054
|
+
pushSwarmEvent("status", { status: swarmState.status });
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
function startSwarm(session, config = {}) {
|
|
1058
|
+
if (!isLocalEngineUrl(ENGINE_URL)) {
|
|
1059
|
+
throw new Error("Swarm orchestration is disabled when using a remote engine URL.");
|
|
1060
|
+
}
|
|
1061
|
+
if (swarmState.process) {
|
|
1062
|
+
throw new Error("Swarm runtime is already running.");
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
const objective = String(config.objective || "Ship a small feature end-to-end").trim();
|
|
1066
|
+
const workspaceRoot = String(config.workspaceRoot || REPO_ROOT).trim();
|
|
1067
|
+
const maxTasks = Math.max(1, Number.parseInt(String(config.maxTasks || 3), 10) || 3);
|
|
1068
|
+
|
|
1069
|
+
const managerPath = join(REPO_ROOT, "examples", "agent-swarm", "src", "manager.mjs");
|
|
1070
|
+
if (!existsSync(managerPath)) {
|
|
1071
|
+
throw new Error(`Missing swarm manager at ${managerPath}`);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
swarmState.logs = [];
|
|
1075
|
+
swarmState.reasons = [];
|
|
1076
|
+
swarmState.status = "starting";
|
|
1077
|
+
swarmState.startedAt = Date.now();
|
|
1078
|
+
swarmState.stoppedAt = null;
|
|
1079
|
+
swarmState.objective = objective;
|
|
1080
|
+
swarmState.workspaceRoot = workspaceRoot;
|
|
1081
|
+
swarmState.maxTasks = maxTasks;
|
|
1082
|
+
swarmState.lastError = "";
|
|
1083
|
+
swarmState.registryCache = null;
|
|
1084
|
+
|
|
1085
|
+
pushSwarmEvent("status", { status: swarmState.status, objective, workspaceRoot, maxTasks });
|
|
1086
|
+
|
|
1087
|
+
const child = spawn(process.execPath, [managerPath, objective], {
|
|
1088
|
+
cwd: workspaceRoot,
|
|
1089
|
+
env: {
|
|
1090
|
+
...process.env,
|
|
1091
|
+
TANDEM_BASE_URL: ENGINE_URL,
|
|
1092
|
+
TANDEM_API_TOKEN: session.token,
|
|
1093
|
+
SWARM_MAX_TASKS: String(maxTasks),
|
|
1094
|
+
SWARM_OBJECTIVE: objective,
|
|
1095
|
+
},
|
|
1096
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1097
|
+
});
|
|
1098
|
+
|
|
1099
|
+
swarmState.process = child;
|
|
1100
|
+
|
|
1101
|
+
child.stdout.on("data", (chunk) => appendSwarmLog("stdout", chunk));
|
|
1102
|
+
child.stderr.on("data", (chunk) => appendSwarmLog("stderr", chunk));
|
|
1103
|
+
|
|
1104
|
+
child.on("spawn", () => {
|
|
1105
|
+
swarmState.status = "running";
|
|
1106
|
+
pushSwarmEvent("status", { status: swarmState.status });
|
|
1107
|
+
void monitorSwarmRegistry(session.token);
|
|
1108
|
+
swarmState.monitorTimer = setInterval(() => {
|
|
1109
|
+
if (swarmState.status === "running") void monitorSwarmRegistry(session.token);
|
|
1110
|
+
}, 2000);
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
child.on("error", (e) => {
|
|
1114
|
+
swarmState.status = "error";
|
|
1115
|
+
swarmState.lastError = e.message;
|
|
1116
|
+
clearSwarmMonitor();
|
|
1117
|
+
appendSwarmLog("stderr", e.message);
|
|
1118
|
+
pushSwarmEvent("status", { status: swarmState.status, error: e.message });
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
child.on("exit", (code, signal) => {
|
|
1122
|
+
const failed = code && code !== 0;
|
|
1123
|
+
swarmState.status = failed ? "error" : "idle";
|
|
1124
|
+
swarmState.stoppedAt = Date.now();
|
|
1125
|
+
swarmState.lastError = failed ? `Exited with code ${code}` : "";
|
|
1126
|
+
swarmState.process = null;
|
|
1127
|
+
clearSwarmMonitor();
|
|
1128
|
+
pushSwarmEvent("status", {
|
|
1129
|
+
status: swarmState.status,
|
|
1130
|
+
code,
|
|
1131
|
+
signal,
|
|
1132
|
+
error: swarmState.lastError || undefined,
|
|
1133
|
+
});
|
|
1134
|
+
});
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
async function handleSwarmApi(req, res, session) {
|
|
1138
|
+
const pathname = new URL(req.url, `http://127.0.0.1:${PORTAL_PORT}`).pathname;
|
|
1139
|
+
|
|
1140
|
+
if (pathname === "/api/swarm/status" && req.method === "GET") {
|
|
1141
|
+
sendJson(res, 200, {
|
|
1142
|
+
ok: true,
|
|
1143
|
+
status: swarmState.status,
|
|
1144
|
+
objective: swarmState.objective,
|
|
1145
|
+
workspaceRoot: swarmState.workspaceRoot,
|
|
1146
|
+
maxTasks: swarmState.maxTasks,
|
|
1147
|
+
startedAt: swarmState.startedAt,
|
|
1148
|
+
stoppedAt: swarmState.stoppedAt,
|
|
1149
|
+
localEngine: isLocalEngineUrl(ENGINE_URL),
|
|
1150
|
+
lastError: swarmState.lastError || null,
|
|
1151
|
+
});
|
|
1152
|
+
return true;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
if (pathname === "/api/swarm/start" && req.method === "POST") {
|
|
1156
|
+
try {
|
|
1157
|
+
const body = await readJsonBody(req);
|
|
1158
|
+
startSwarm(session, body || {});
|
|
1159
|
+
sendJson(res, 200, { ok: true });
|
|
1160
|
+
} catch (e) {
|
|
1161
|
+
sendJson(res, 400, { ok: false, error: e instanceof Error ? e.message : String(e) });
|
|
1162
|
+
}
|
|
1163
|
+
return true;
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
if (pathname === "/api/swarm/stop" && req.method === "POST") {
|
|
1167
|
+
stopSwarm();
|
|
1168
|
+
sendJson(res, 200, { ok: true, status: swarmState.status });
|
|
1169
|
+
return true;
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
if (pathname === "/api/swarm/snapshot" && req.method === "GET") {
|
|
1173
|
+
const registry = await readSwarmRegistry(session.token);
|
|
1174
|
+
sendJson(res, 200, {
|
|
1175
|
+
ok: true,
|
|
1176
|
+
status: swarmState.status,
|
|
1177
|
+
registry,
|
|
1178
|
+
logs: swarmState.logs.slice(-300),
|
|
1179
|
+
reasons: swarmState.reasons.slice(-250),
|
|
1180
|
+
startedAt: swarmState.startedAt,
|
|
1181
|
+
stoppedAt: swarmState.stoppedAt,
|
|
1182
|
+
lastError: swarmState.lastError || null,
|
|
1183
|
+
localEngine: isLocalEngineUrl(ENGINE_URL),
|
|
1184
|
+
});
|
|
1185
|
+
return true;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
if (pathname === "/api/swarm/events" && req.method === "GET") {
|
|
1189
|
+
res.writeHead(200, {
|
|
1190
|
+
"content-type": "text/event-stream",
|
|
1191
|
+
"cache-control": "no-cache",
|
|
1192
|
+
connection: "keep-alive",
|
|
1193
|
+
});
|
|
1194
|
+
res.write(`data: ${JSON.stringify({ kind: "hello", ts: Date.now(), status: swarmState.status })}\n\n`);
|
|
1195
|
+
swarmSseClients.add(res);
|
|
1196
|
+
req.on("close", () => swarmSseClients.delete(res));
|
|
1197
|
+
return true;
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
return false;
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
async function handleApi(req, res) {
|
|
1204
|
+
const pathname = new URL(req.url, `http://127.0.0.1:${PORTAL_PORT}`).pathname;
|
|
1205
|
+
|
|
1206
|
+
if (pathname === "/api/system/health" && req.method === "GET") {
|
|
1207
|
+
const health = await engineHealth();
|
|
1208
|
+
sendJson(res, 200, {
|
|
1209
|
+
ok: true,
|
|
1210
|
+
engineUrl: ENGINE_URL,
|
|
1211
|
+
engine: health || null,
|
|
1212
|
+
localEngine: isLocalEngineUrl(ENGINE_URL),
|
|
1213
|
+
autoStartEngine: AUTO_START_ENGINE,
|
|
1214
|
+
});
|
|
1215
|
+
return true;
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
if (pathname === "/api/auth/login" && req.method === "POST") {
|
|
1219
|
+
await handleAuthLogin(req, res);
|
|
1220
|
+
return true;
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
if (pathname === "/api/auth/logout" && req.method === "POST") {
|
|
1224
|
+
const current = getSession(req);
|
|
1225
|
+
if (current?.sid) sessions.delete(current.sid);
|
|
1226
|
+
clearSessionCookie(res);
|
|
1227
|
+
sendJson(res, 200, { ok: true });
|
|
1228
|
+
return true;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
if (pathname === "/api/auth/me" && req.method === "GET") {
|
|
1232
|
+
const session = requireSession(req, res);
|
|
1233
|
+
if (!session) return true;
|
|
1234
|
+
const health = await engineHealth(session.token);
|
|
1235
|
+
if (!health) {
|
|
1236
|
+
sessions.delete(session.sid);
|
|
1237
|
+
clearSessionCookie(res);
|
|
1238
|
+
sendJson(res, 401, { ok: false, error: "Session token is no longer valid for the configured engine." });
|
|
1239
|
+
return true;
|
|
1240
|
+
}
|
|
1241
|
+
sendJson(res, 200, {
|
|
1242
|
+
ok: true,
|
|
1243
|
+
engineUrl: ENGINE_URL,
|
|
1244
|
+
localEngine: isLocalEngineUrl(ENGINE_URL),
|
|
1245
|
+
engine: health,
|
|
1246
|
+
});
|
|
1247
|
+
return true;
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
if (pathname.startsWith("/api/swarm")) {
|
|
1251
|
+
const session = requireSession(req, res);
|
|
1252
|
+
if (!session) return true;
|
|
1253
|
+
return handleSwarmApi(req, res, session);
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
if (pathname.startsWith("/api/files")) {
|
|
1257
|
+
const session = requireSession(req, res);
|
|
1258
|
+
if (!session) return true;
|
|
1259
|
+
return handleFilesApi(req, res, session);
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
if (pathname.startsWith("/api/engine")) {
|
|
1263
|
+
const session = requireSession(req, res);
|
|
1264
|
+
if (!session) return true;
|
|
1265
|
+
await proxyEngineRequest(req, res, session);
|
|
1266
|
+
return true;
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
return false;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
function serveStatic(req, res) {
|
|
1273
|
+
const filePath = sanitizeStaticPath(req.url);
|
|
1274
|
+
if (!filePath) {
|
|
1275
|
+
res.writeHead(403);
|
|
1276
|
+
res.end("Forbidden");
|
|
1277
|
+
return;
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
let target = filePath;
|
|
1281
|
+
if (!existsSync(target)) {
|
|
1282
|
+
if (!extname(target)) target = join(DIST_DIR, "index.html");
|
|
1283
|
+
else {
|
|
1284
|
+
res.writeHead(404);
|
|
1285
|
+
res.end("Not Found");
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
const ext = extname(target);
|
|
1291
|
+
const mime = MIME_TYPES[ext] || "application/octet-stream";
|
|
1292
|
+
res.writeHead(200, { "content-type": mime });
|
|
1293
|
+
createReadStream(target).pipe(res);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
function shutdown(signal) {
|
|
1297
|
+
log(`Shutting down (${signal})...`);
|
|
1298
|
+
if (server) {
|
|
1299
|
+
try {
|
|
1300
|
+
server.close();
|
|
1301
|
+
} catch {}
|
|
1302
|
+
}
|
|
1303
|
+
if (swarmState.process && !swarmState.process.killed) {
|
|
1304
|
+
try {
|
|
1305
|
+
clearSwarmMonitor();
|
|
1306
|
+
swarmState.process.kill("SIGTERM");
|
|
1307
|
+
} catch {}
|
|
1308
|
+
}
|
|
1309
|
+
if (engineProcess && !engineProcess.killed) {
|
|
1310
|
+
try {
|
|
1311
|
+
engineProcess.kill(signal);
|
|
1312
|
+
} catch {}
|
|
1313
|
+
}
|
|
1314
|
+
process.exit(0);
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
process.on("SIGINT", () => shutdown("SIGINT"));
|
|
1318
|
+
process.on("SIGTERM", () => shutdown("SIGTERM"));
|
|
1319
|
+
|
|
1320
|
+
async function main() {
|
|
1321
|
+
if (installServicesRequested) {
|
|
1322
|
+
await installServices();
|
|
1323
|
+
if (serviceSetupOnly) {
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
if (!existsSync(DIST_DIR)) {
|
|
1329
|
+
err(`Missing build output at ${DIST_DIR}`);
|
|
1330
|
+
err("Run: npm run build");
|
|
1331
|
+
process.exit(1);
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
await ensureEngineRunning();
|
|
1335
|
+
|
|
1336
|
+
server = createServer(async (req, res) => {
|
|
1337
|
+
try {
|
|
1338
|
+
if (await handleApi(req, res)) return;
|
|
1339
|
+
serveStatic(req, res);
|
|
1340
|
+
} catch (e) {
|
|
1341
|
+
err(e instanceof Error ? e.stack || e.message : String(e));
|
|
1342
|
+
if (!res.headersSent && !res.writableEnded && !res.destroyed) {
|
|
1343
|
+
sendJson(res, 500, { ok: false, error: "Internal server error" });
|
|
1344
|
+
} else if (!res.destroyed) {
|
|
1345
|
+
res.destroy(e instanceof Error ? e : undefined);
|
|
1346
|
+
}
|
|
1347
|
+
}
|
|
1348
|
+
});
|
|
1349
|
+
|
|
1350
|
+
server.on("error", (e) => {
|
|
1351
|
+
err(`Failed to bind control panel port ${PORTAL_PORT}: ${e.message}`);
|
|
1352
|
+
process.exit(1);
|
|
1353
|
+
});
|
|
1354
|
+
|
|
1355
|
+
server.listen(PORTAL_PORT, () => {
|
|
1356
|
+
log("=========================================");
|
|
1357
|
+
log(`Control Panel: http://localhost:${PORTAL_PORT}`);
|
|
1358
|
+
log(`Engine URL: ${ENGINE_URL}`);
|
|
1359
|
+
log(`Engine mode: ${isLocalEngineUrl(ENGINE_URL) ? "local" : "remote"}`);
|
|
1360
|
+
log(`Files root: ${FILES_ROOT}`);
|
|
1361
|
+
log(`Files scope: ${FILES_SCOPE || "(full root)"}`);
|
|
1362
|
+
log("=========================================");
|
|
1363
|
+
});
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
main().catch((e) => {
|
|
1367
|
+
err(e instanceof Error ? e.message : String(e));
|
|
1368
|
+
process.exit(1);
|
|
1369
|
+
});
|