@maintainer-pro/ai-bridge 0.1.1 → 0.1.2
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/package.json +4 -1
- package/src/daemon.mjs +1215 -111
package/src/daemon.mjs
CHANGED
|
@@ -7,18 +7,20 @@
|
|
|
7
7
|
* npx @maintainer-pro/ai-bridge
|
|
8
8
|
* npx @maintainer-pro/ai-bridge --admin-url https://… --pair ABCD-EF01
|
|
9
9
|
*/
|
|
10
|
-
import { spawn
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
11
|
import { randomBytes } from "node:crypto";
|
|
12
12
|
import fs from "node:fs";
|
|
13
13
|
import http from "node:http";
|
|
14
|
+
import net from "node:net";
|
|
14
15
|
import os from "node:os";
|
|
15
16
|
import path from "node:path";
|
|
16
17
|
import readline from "node:readline";
|
|
17
|
-
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
18
19
|
|
|
19
20
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
20
21
|
const PACKAGE_VERSION = readPackageVersion();
|
|
21
22
|
const HEARTBEAT_MS = 15_000;
|
|
23
|
+
const ACTION_POLL_MS = 2_000;
|
|
22
24
|
|
|
23
25
|
const log = (msg) => console.log(`[bridge] ${msg}`);
|
|
24
26
|
const warn = (msg) => console.warn(`[bridge] ${msg}`);
|
|
@@ -85,7 +87,7 @@ Flags:
|
|
|
85
87
|
--pair <code> Claim a pair code from admin → Bridges page
|
|
86
88
|
--admin-url <url> Maintainer Pro base URL (required for first pair)
|
|
87
89
|
--offer-folder <path> Suggest this folder in admin (repeatable via config)
|
|
88
|
-
--no-ai-server Do not
|
|
90
|
+
--no-ai-server Do not open ai-server terminals for workspaces
|
|
89
91
|
--help
|
|
90
92
|
`);
|
|
91
93
|
}
|
|
@@ -161,26 +163,161 @@ async function api(baseUrl, token, method, pathname, body) {
|
|
|
161
163
|
return data;
|
|
162
164
|
}
|
|
163
165
|
|
|
164
|
-
|
|
166
|
+
/** @type {Promise<typeof import("@maintainer-pro/ai-cli")> | null} */
|
|
167
|
+
let aiCliModule = null;
|
|
168
|
+
|
|
169
|
+
async function loadAiCli() {
|
|
170
|
+
if (!aiCliModule) {
|
|
171
|
+
aiCliModule = (async () => {
|
|
172
|
+
try {
|
|
173
|
+
return await import("@maintainer-pro/ai-cli");
|
|
174
|
+
} catch {
|
|
175
|
+
const local = path.resolve(__dirname, "..", "..", "ai-cli", "dist", "index.js");
|
|
176
|
+
if (fs.existsSync(local)) {
|
|
177
|
+
return await import(pathToFileURL(local).href);
|
|
178
|
+
}
|
|
179
|
+
throw new Error("@maintainer-pro/ai-cli is not installed");
|
|
180
|
+
}
|
|
181
|
+
})();
|
|
182
|
+
}
|
|
183
|
+
return aiCliModule;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async function detectCliProviders() {
|
|
165
187
|
try {
|
|
166
|
-
|
|
167
|
-
|
|
188
|
+
const { resolveProvider } = await loadAiCli();
|
|
189
|
+
const provider = await resolveProvider({ preference: "auto" });
|
|
190
|
+
return [provider.id];
|
|
191
|
+
} catch {
|
|
192
|
+
return [];
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function applyProjectInfo(ws, info, cfg) {
|
|
197
|
+
if (!ws || !info) return;
|
|
198
|
+
ws.projectInfo = {
|
|
199
|
+
kind: info.kind,
|
|
200
|
+
name: info.name,
|
|
201
|
+
summary: info.summary,
|
|
202
|
+
scripts: info.scripts || {},
|
|
203
|
+
ports: info.ports || {},
|
|
204
|
+
issues: info.issues || [],
|
|
205
|
+
fixes: info.fixes || [],
|
|
206
|
+
ready: Boolean(info.ready),
|
|
207
|
+
provider: info.provider,
|
|
208
|
+
};
|
|
209
|
+
if (info.kind) ws.clientKind = info.kind;
|
|
210
|
+
const uiPort = Number(info.ports?.ui || info.ports?.app);
|
|
211
|
+
if (
|
|
212
|
+
uiPort &&
|
|
213
|
+
(!ws.appUrl || isLocalAppUrl(ws.appUrl))
|
|
214
|
+
) {
|
|
215
|
+
ws.appUrl = urlWithPort(ws.appUrl || `http://localhost:${uiPort}`, uiPort);
|
|
216
|
+
}
|
|
217
|
+
persistWorkspaceEntry(cfg, ws);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function inspectProjectWithAiCli(ws, opts = {}) {
|
|
221
|
+
const folder = path.resolve(ws.folderPath || "");
|
|
222
|
+
const label = ws.sandboxName || ws.applicationName || "this sandbox";
|
|
223
|
+
try {
|
|
224
|
+
const { inspectAndRepairWorkspace } = await loadAiCli();
|
|
225
|
+
log(`asking ai-cli to inspect ${folder}`);
|
|
226
|
+
const info = await inspectAndRepairWorkspace({
|
|
227
|
+
workspaceDir: folder,
|
|
228
|
+
appName: ws.applicationName || ws.sandboxName || undefined,
|
|
229
|
+
problem: opts.problem,
|
|
230
|
+
extraContext: opts.extraContext,
|
|
231
|
+
});
|
|
232
|
+
applyProjectInfo(ws, info, opts.cfg);
|
|
233
|
+
if (info.issues?.length) {
|
|
234
|
+
recordProcessProblem({
|
|
235
|
+
sandboxId: ws.sandboxId,
|
|
236
|
+
code: "project_issue",
|
|
237
|
+
role: "inspect",
|
|
238
|
+
title: `Project needs attention (${label})`,
|
|
239
|
+
message: info.issues.join(" "),
|
|
240
|
+
resolution: info.fixes?.length
|
|
241
|
+
? info.fixes.join(" ")
|
|
242
|
+
: "Fix the project in that folder, then use Start chat server.",
|
|
243
|
+
});
|
|
168
244
|
} else {
|
|
169
|
-
|
|
245
|
+
clearProcessProblem(ws.sandboxId, "project_issue", "inspect");
|
|
170
246
|
}
|
|
171
|
-
|
|
247
|
+
if (info.summary) log(`ai-cli: ${info.summary}`);
|
|
248
|
+
if (info.fixes?.length) log(`ai-cli fixes: ${info.fixes.join("; ")}`);
|
|
249
|
+
return info;
|
|
250
|
+
} catch (err) {
|
|
251
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
252
|
+
warn(`ai-cli inspect failed: ${message}`);
|
|
253
|
+
recordProcessProblem({
|
|
254
|
+
sandboxId: ws.sandboxId,
|
|
255
|
+
code: "project_issue",
|
|
256
|
+
role: "inspect",
|
|
257
|
+
title: `Could not inspect the project (${label})`,
|
|
258
|
+
message,
|
|
259
|
+
resolution:
|
|
260
|
+
"Install and authenticate a coding-agent CLI (agent, claude, or agy), then Check connection.",
|
|
261
|
+
actionCode: "recheck",
|
|
262
|
+
});
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function isPortFree(port) {
|
|
268
|
+
return new Promise((resolve) => {
|
|
269
|
+
const server = net.createServer();
|
|
270
|
+
server.unref();
|
|
271
|
+
server.once("error", () => resolve(false));
|
|
272
|
+
server.once("listening", () => {
|
|
273
|
+
server.close(() => resolve(true));
|
|
274
|
+
});
|
|
275
|
+
server.listen(port);
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/**
|
|
280
|
+
* Prefer `preferred` if it is free and not already reserved this launch.
|
|
281
|
+
* Walks upward so bind failures are avoided without a central port service.
|
|
282
|
+
*/
|
|
283
|
+
async function findFreePort(preferred = 3100, reserved = new Set()) {
|
|
284
|
+
let port = Math.max(1024, Number(preferred) || 3100);
|
|
285
|
+
while (port <= 49151) {
|
|
286
|
+
if (!reserved.has(port) && (await isPortFree(port))) {
|
|
287
|
+
reserved.add(port);
|
|
288
|
+
return port;
|
|
289
|
+
}
|
|
290
|
+
port += 1;
|
|
291
|
+
}
|
|
292
|
+
throw new Error("No free TCP port found");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function urlWithPort(url, port) {
|
|
296
|
+
try {
|
|
297
|
+
const parsed = new URL(url);
|
|
298
|
+
parsed.port = String(port);
|
|
299
|
+
return parsed.toString().replace(/\/$/, "");
|
|
172
300
|
} catch {
|
|
173
|
-
return
|
|
301
|
+
return `http://localhost:${port}`;
|
|
174
302
|
}
|
|
175
303
|
}
|
|
176
304
|
|
|
177
|
-
function
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
305
|
+
function isLocalAppUrl(url) {
|
|
306
|
+
try {
|
|
307
|
+
const host = new URL(url).hostname;
|
|
308
|
+
return host === "localhost" || host === "127.0.0.1";
|
|
309
|
+
} catch {
|
|
310
|
+
return true;
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function persistWorkspaceEntry(cfg, ws) {
|
|
315
|
+
if (!cfg || !ws?.sandboxId) return;
|
|
316
|
+
cfg.workspaces = cfg.workspaces || [];
|
|
317
|
+
const index = cfg.workspaces.findIndex((row) => row.sandboxId === ws.sandboxId);
|
|
318
|
+
if (index >= 0) cfg.workspaces[index] = { ...cfg.workspaces[index], ...ws };
|
|
319
|
+
else cfg.workspaces.push(ws);
|
|
320
|
+
saveConfig(cfg);
|
|
184
321
|
}
|
|
185
322
|
|
|
186
323
|
function probeUrl(url, timeoutMs = 2500) {
|
|
@@ -207,10 +344,46 @@ function probeUrl(url, timeoutMs = 2500) {
|
|
|
207
344
|
});
|
|
208
345
|
}
|
|
209
346
|
|
|
347
|
+
function listDriveRoots() {
|
|
348
|
+
if (process.platform !== "win32") return ["/"];
|
|
349
|
+
const roots = [];
|
|
350
|
+
for (const letter of "CDEFGHIJKLMNOPQRSTUVWXYZAB") {
|
|
351
|
+
const root = `${letter}:\\`;
|
|
352
|
+
try {
|
|
353
|
+
if (fs.existsSync(root)) roots.push(root);
|
|
354
|
+
} catch {
|
|
355
|
+
/* skip */
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return roots.length ? roots : ["C:\\"];
|
|
359
|
+
}
|
|
360
|
+
|
|
210
361
|
function listDirEntries(dirPath) {
|
|
211
|
-
const
|
|
362
|
+
const raw = String(dirPath || "").trim();
|
|
363
|
+
const home = os.homedir();
|
|
364
|
+
if (!raw || raw === "roots") {
|
|
365
|
+
const roots = listDriveRoots();
|
|
366
|
+
return {
|
|
367
|
+
path: "",
|
|
368
|
+
parent: null,
|
|
369
|
+
home,
|
|
370
|
+
entries: roots.map((root) => ({
|
|
371
|
+
name: root,
|
|
372
|
+
path: root,
|
|
373
|
+
isDir: true,
|
|
374
|
+
})),
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const resolved = path.resolve(raw);
|
|
212
379
|
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) {
|
|
213
|
-
return {
|
|
380
|
+
return {
|
|
381
|
+
error: "Not a directory",
|
|
382
|
+
path: resolved,
|
|
383
|
+
parent: path.dirname(resolved),
|
|
384
|
+
home,
|
|
385
|
+
entries: [],
|
|
386
|
+
};
|
|
214
387
|
}
|
|
215
388
|
const names = fs.readdirSync(resolved);
|
|
216
389
|
const entries = [];
|
|
@@ -232,7 +405,13 @@ function listDirEntries(dirPath) {
|
|
|
232
405
|
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
|
233
406
|
return a.name.localeCompare(b.name);
|
|
234
407
|
});
|
|
235
|
-
|
|
408
|
+
const parent = path.dirname(resolved);
|
|
409
|
+
return {
|
|
410
|
+
path: resolved,
|
|
411
|
+
parent: parent === resolved ? null : parent,
|
|
412
|
+
home,
|
|
413
|
+
entries: entries.slice(0, 400),
|
|
414
|
+
};
|
|
236
415
|
}
|
|
237
416
|
|
|
238
417
|
function mergeEnvFile(file, values) {
|
|
@@ -665,26 +844,389 @@ function collectOfferedFolders(cfg) {
|
|
|
665
844
|
return folders;
|
|
666
845
|
}
|
|
667
846
|
|
|
668
|
-
/**
|
|
669
|
-
const
|
|
847
|
+
/** Prevents opening a new window on every heartbeat while a process is starting. */
|
|
848
|
+
const launchedAt = new Map();
|
|
849
|
+
|
|
850
|
+
/** Last process problems to send on heartbeat. Key: sandboxId::code::role */
|
|
851
|
+
const processProblems = new Map();
|
|
670
852
|
|
|
671
|
-
function
|
|
672
|
-
const
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
853
|
+
function forgetLaunch(sandboxId) {
|
|
854
|
+
for (const key of [...launchedAt.keys()]) {
|
|
855
|
+
if (key === sandboxId || key.startsWith(`${sandboxId}:`)) {
|
|
856
|
+
launchedAt.delete(key);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
for (const key of [...processProblems.keys()]) {
|
|
860
|
+
if (key.startsWith(`${sandboxId}::`)) processProblems.delete(key);
|
|
676
861
|
}
|
|
862
|
+
stopCloudflare(sandboxId);
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function problemKey(sandboxId, code, role = "") {
|
|
866
|
+
return `${sandboxId || ""}::${code}::${role}`;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
function clipIssueText(text, max) {
|
|
870
|
+
const value = String(text || "").trim();
|
|
871
|
+
if (value.length <= max) return value;
|
|
872
|
+
return `${value.slice(0, max - 1)}…`;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function processRoleLabel(role) {
|
|
876
|
+
if (role === "ui") return "app UI";
|
|
877
|
+
if (role === "backend") return "backend";
|
|
878
|
+
if (role === "ai") return "chat script";
|
|
879
|
+
return "app";
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function recordProcessProblem(issue) {
|
|
883
|
+
const sandboxId = issue.sandboxId || "";
|
|
884
|
+
const role = issue.role || "";
|
|
885
|
+
processProblems.set(problemKey(sandboxId, issue.code, role), {
|
|
886
|
+
code: issue.code,
|
|
887
|
+
role,
|
|
888
|
+
severity: issue.severity || "error",
|
|
889
|
+
title: clipIssueText(issue.title, 200),
|
|
890
|
+
message: clipIssueText(issue.message, 2000),
|
|
891
|
+
resolution: clipIssueText(issue.resolution || "", 2000),
|
|
892
|
+
actionCode: issue.actionCode ?? "start_ai_server",
|
|
893
|
+
sandboxId: sandboxId || null,
|
|
894
|
+
});
|
|
895
|
+
warn(`${issue.title}: ${issue.message}`);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function clearProcessProblem(sandboxId, code, role = "") {
|
|
899
|
+
processProblems.delete(problemKey(sandboxId, code, role));
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function issuesForSandbox(sandboxId) {
|
|
903
|
+
return [...processProblems.values()].filter(
|
|
904
|
+
(issue) => issue.sandboxId === sandboxId
|
|
905
|
+
);
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
function recentlyLaunched(launchKey) {
|
|
909
|
+
const last = launchedAt.get(launchKey) ?? 0;
|
|
910
|
+
return last > 0 && Date.now() - last < 60_000;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function readPackageJson(dir) {
|
|
677
914
|
try {
|
|
678
|
-
|
|
915
|
+
return JSON.parse(fs.readFileSync(path.join(dir, "package.json"), "utf8"));
|
|
679
916
|
} catch {
|
|
680
|
-
|
|
917
|
+
return null;
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
function isChatScript(name, command) {
|
|
922
|
+
const text = `${name} ${command}`;
|
|
923
|
+
return /\b(ai-server|ai-cli|dev:chat|start:chat)\b/i.test(text);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function isUiCommand(command) {
|
|
927
|
+
return /\b(vite|next|nuxt|astro|remix|react-scripts|webpack-dev-server|parcel)\b/i.test(
|
|
928
|
+
command
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function portFromText(text, fallback) {
|
|
933
|
+
const match =
|
|
934
|
+
String(text || "").match(/--port(?:\s+|=)(\d{2,5})/i) ||
|
|
935
|
+
String(text || "").match(/\bPORT[=:]\s*(\d{2,5})/i) ||
|
|
936
|
+
String(text || "").match(/:(\d{2,5})\b/);
|
|
937
|
+
return match ? Number(match[1]) : fallback;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
/**
|
|
941
|
+
* @returns {Array<{ role: string, script: string, command: string, preferredPort: number, probeUrl: string | null }>}
|
|
942
|
+
*/
|
|
943
|
+
function planHostJobs(dir, appUrl, hints = null) {
|
|
944
|
+
const pkg = readPackageJson(dir);
|
|
945
|
+
const scripts = pkg?.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {};
|
|
946
|
+
const hinted = hints?.scripts && typeof hints.scripts === "object" ? hints.scripts : {};
|
|
947
|
+
const hintedPorts = hints?.ports && typeof hints.ports === "object" ? hints.ports : {};
|
|
948
|
+
const pickHint = (name) =>
|
|
949
|
+
name && scripts[name] && !isChatScript(name, scripts[name]) ? name : null;
|
|
950
|
+
const pick = (names) =>
|
|
951
|
+
names.find((name) => scripts[name] && !isChatScript(name, scripts[name]));
|
|
952
|
+
|
|
953
|
+
const uiScript =
|
|
954
|
+
pickHint(hinted.ui) ||
|
|
955
|
+
pick([
|
|
956
|
+
"dev:client",
|
|
957
|
+
"dev:ui",
|
|
958
|
+
"dev:web",
|
|
959
|
+
"dev:frontend",
|
|
960
|
+
"client",
|
|
961
|
+
"start:client",
|
|
962
|
+
]);
|
|
963
|
+
const apiScript =
|
|
964
|
+
pickHint(hinted.backend) ||
|
|
965
|
+
pick([
|
|
966
|
+
"dev:server",
|
|
967
|
+
"dev:backend",
|
|
968
|
+
"dev:api",
|
|
969
|
+
"server",
|
|
970
|
+
"start:server",
|
|
971
|
+
]);
|
|
972
|
+
|
|
973
|
+
/** @type {Array<{ role: string, script: string, command: string, preferredPort: number, probeUrl: string | null }>} */
|
|
974
|
+
const jobs = [];
|
|
975
|
+
const addJob = (role, script, fallbackPort, probe) => {
|
|
976
|
+
if (!script || jobs.some((job) => job.script === script)) return;
|
|
977
|
+
const port = portFromText(scripts[script] || probe, fallbackPort);
|
|
978
|
+
jobs.push({
|
|
979
|
+
role,
|
|
980
|
+
script,
|
|
981
|
+
command: `npm run ${script}`,
|
|
982
|
+
preferredPort: port,
|
|
983
|
+
probeUrl: probe || `http://127.0.0.1:${port}`,
|
|
984
|
+
});
|
|
985
|
+
};
|
|
986
|
+
|
|
987
|
+
addJob("ui", uiScript, Number(hintedPorts.ui) || 5173, appUrl || null);
|
|
988
|
+
addJob("backend", apiScript, Number(hintedPorts.backend) || 4100, null);
|
|
989
|
+
if (jobs.length === 0 && pickHint(hinted.app)) {
|
|
990
|
+
addJob(
|
|
991
|
+
isUiCommand(scripts[hinted.app]) ? "ui" : "app",
|
|
992
|
+
hinted.app,
|
|
993
|
+
Number(hintedPorts.app) || 3000,
|
|
994
|
+
appUrl || null
|
|
995
|
+
);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
if (scripts.dev && !isChatScript("dev", scripts.dev)) {
|
|
999
|
+
const nested = [
|
|
1000
|
+
...String(scripts.dev).matchAll(/npm run ([a-zA-Z0-9:_-]+)/g),
|
|
1001
|
+
]
|
|
1002
|
+
.map((match) => match[1])
|
|
1003
|
+
.filter(
|
|
1004
|
+
(name) =>
|
|
1005
|
+
name !== "dev" && scripts[name] && !isChatScript(name, scripts[name])
|
|
1006
|
+
);
|
|
1007
|
+
if (nested.length >= 2) {
|
|
1008
|
+
for (const name of nested) {
|
|
1009
|
+
const role = /client|ui|web|frontend/i.test(name)
|
|
1010
|
+
? "ui"
|
|
1011
|
+
: /server|backend|api/i.test(name)
|
|
1012
|
+
? "backend"
|
|
1013
|
+
: name;
|
|
1014
|
+
addJob(role, name, 3000, role === "ui" ? appUrl || null : null);
|
|
1015
|
+
}
|
|
1016
|
+
} else if (jobs.length === 0) {
|
|
1017
|
+
addJob(isUiCommand(scripts.dev) ? "ui" : "app", "dev", 3000, appUrl || null);
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
return jobs;
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
function friendlyLaunchError(raw, title) {
|
|
1024
|
+
const text = String(raw || "").trim();
|
|
1025
|
+
if (/ENOENT/i.test(text)) {
|
|
1026
|
+
return `Could not find a program to open a terminal for "${title}". ${text}`;
|
|
1027
|
+
}
|
|
1028
|
+
return text || `Could not open a terminal for "${title}".`;
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
function runLauncher(command, args, extra = {}) {
|
|
1032
|
+
return new Promise((resolve) => {
|
|
1033
|
+
let settled = false;
|
|
1034
|
+
const done = (result) => {
|
|
1035
|
+
if (settled) return;
|
|
1036
|
+
settled = true;
|
|
1037
|
+
resolve(result);
|
|
1038
|
+
};
|
|
1039
|
+
let child;
|
|
1040
|
+
try {
|
|
1041
|
+
child = spawn(command, args, {
|
|
1042
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1043
|
+
windowsHide: extra.windowsHide,
|
|
1044
|
+
});
|
|
1045
|
+
} catch (err) {
|
|
1046
|
+
done({
|
|
1047
|
+
ok: false,
|
|
1048
|
+
error: err instanceof Error ? err.message : String(err),
|
|
1049
|
+
});
|
|
1050
|
+
return;
|
|
1051
|
+
}
|
|
1052
|
+
let stderr = "";
|
|
1053
|
+
child.stderr.on("data", (chunk) => {
|
|
1054
|
+
stderr += String(chunk);
|
|
1055
|
+
});
|
|
1056
|
+
child.once("error", (err) => {
|
|
1057
|
+
done({ ok: false, error: err.message });
|
|
1058
|
+
});
|
|
1059
|
+
child.once("exit", (code) => {
|
|
1060
|
+
if (code === 0 || code === null) done({ ok: true });
|
|
1061
|
+
else {
|
|
1062
|
+
const detail = stderr.trim().replace(/\s+/g, " ");
|
|
1063
|
+
done({
|
|
1064
|
+
ok: false,
|
|
1065
|
+
error: detail || `${command} exited with code ${code}`,
|
|
1066
|
+
});
|
|
1067
|
+
}
|
|
1068
|
+
});
|
|
1069
|
+
child.once("spawn", () => {
|
|
1070
|
+
setTimeout(() => done({ ok: true }), 400);
|
|
1071
|
+
});
|
|
1072
|
+
});
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
async function openInNewTerminal(opts) {
|
|
1076
|
+
const { title, folder, command, env = {}, launchKey } = opts;
|
|
1077
|
+
if (launchKey) {
|
|
1078
|
+
if (recentlyLaunched(launchKey)) return { ok: true, skipped: true };
|
|
1079
|
+
launchedAt.set(launchKey, Date.now());
|
|
1080
|
+
}
|
|
1081
|
+
if (!fs.existsSync(folder)) {
|
|
1082
|
+
const error = `Folder is missing: ${folder}`;
|
|
1083
|
+
warn(`cannot open terminal: ${error}`);
|
|
1084
|
+
return { ok: false, error };
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
const envWin = Object.entries(env)
|
|
1088
|
+
.map(([key, value]) => `set ${key}=${value}`)
|
|
1089
|
+
.join("&& ");
|
|
1090
|
+
const envUnix = Object.entries(env)
|
|
1091
|
+
.map(([key, value]) => `export ${key}=${JSON.stringify(String(value))}`)
|
|
1092
|
+
.join(" && ");
|
|
1093
|
+
|
|
1094
|
+
log(`opening separate terminal [${title}] in ${folder}: ${command}`);
|
|
1095
|
+
|
|
1096
|
+
try {
|
|
1097
|
+
if (process.platform === "win32") {
|
|
1098
|
+
const inner = `cd /d "${folder}" && ${envWin ? `${envWin}&& ` : ""}title ${title}&& ${command}`;
|
|
1099
|
+
const escaped = inner.replace(/'/g, "''");
|
|
1100
|
+
const opened = await runLauncher(
|
|
1101
|
+
"powershell.exe",
|
|
1102
|
+
[
|
|
1103
|
+
"-NoProfile",
|
|
1104
|
+
"-WindowStyle",
|
|
1105
|
+
"Hidden",
|
|
1106
|
+
"-Command",
|
|
1107
|
+
`Start-Process -FilePath $env:ComSpec -WorkingDirectory ${JSON.stringify(folder)} -ArgumentList @('/k', '${escaped}')`,
|
|
1108
|
+
],
|
|
1109
|
+
{ windowsHide: true }
|
|
1110
|
+
);
|
|
1111
|
+
if (!opened.ok) {
|
|
1112
|
+
return { ok: false, error: friendlyLaunchError(opened.error, title) };
|
|
1113
|
+
}
|
|
1114
|
+
return { ok: true };
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
const script = `cd ${JSON.stringify(folder)} && ${envUnix ? `${envUnix} && ` : ""}${command}`;
|
|
1118
|
+
if (process.platform === "darwin") {
|
|
1119
|
+
const opened = await runLauncher("osascript", [
|
|
1120
|
+
"-e",
|
|
1121
|
+
`tell application "Terminal" to do script ${JSON.stringify(script)}`,
|
|
1122
|
+
]);
|
|
1123
|
+
if (!opened.ok) {
|
|
1124
|
+
return { ok: false, error: friendlyLaunchError(opened.error, title) };
|
|
1125
|
+
}
|
|
1126
|
+
return { ok: true };
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
const opened = await runLauncher(process.env.TERMINAL || "x-terminal-emulator", [
|
|
1130
|
+
"-e",
|
|
1131
|
+
"bash",
|
|
1132
|
+
"-lc",
|
|
1133
|
+
`${script}; exec bash`,
|
|
1134
|
+
]);
|
|
1135
|
+
if (!opened.ok) {
|
|
1136
|
+
return { ok: false, error: friendlyLaunchError(opened.error, title) };
|
|
1137
|
+
}
|
|
1138
|
+
return { ok: true };
|
|
1139
|
+
} catch (err) {
|
|
1140
|
+
return {
|
|
1141
|
+
ok: false,
|
|
1142
|
+
error: friendlyLaunchError(
|
|
1143
|
+
err instanceof Error ? err.message : String(err),
|
|
1144
|
+
title
|
|
1145
|
+
),
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
function commandWithPort(job, scripts, port) {
|
|
1151
|
+
const raw = String(scripts?.[job.script] || "");
|
|
1152
|
+
if (
|
|
1153
|
+
job.role === "ui" ||
|
|
1154
|
+
job.role === "app" ||
|
|
1155
|
+
isUiCommand(raw) ||
|
|
1156
|
+
/--port\b/i.test(raw)
|
|
1157
|
+
) {
|
|
1158
|
+
return `${job.command} -- --port ${port}`;
|
|
681
1159
|
}
|
|
682
|
-
|
|
1160
|
+
return job.command;
|
|
683
1161
|
}
|
|
684
1162
|
|
|
685
|
-
function startAiServerForWorkspace(ws) {
|
|
686
|
-
const
|
|
687
|
-
|
|
1163
|
+
async function startAiServerForWorkspace(ws, opts = {}) {
|
|
1164
|
+
const reserved = opts.reserved instanceof Set ? opts.reserved : new Set();
|
|
1165
|
+
const cfg = opts.cfg || null;
|
|
1166
|
+
const folder = path.resolve(ws.folderPath);
|
|
1167
|
+
const preferred = Number(ws.port) || 3100;
|
|
1168
|
+
const launchKey = `${ws.sandboxId}:${folder}:ai`;
|
|
1169
|
+
const label = ws.sandboxName || "this sandbox";
|
|
1170
|
+
|
|
1171
|
+
if (!fs.existsSync(folder)) {
|
|
1172
|
+
recordProcessProblem({
|
|
1173
|
+
sandboxId: ws.sandboxId,
|
|
1174
|
+
code: "ai_server_launch",
|
|
1175
|
+
role: "ai",
|
|
1176
|
+
title: `Could not start the chat script (${label})`,
|
|
1177
|
+
message: `The project folder is missing: ${folder}`,
|
|
1178
|
+
resolution: "Attach the folder again from Local setup.",
|
|
1179
|
+
});
|
|
1180
|
+
return { port: preferred, up: false, launched: false };
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
if (await probeUrl(`http://127.0.0.1:${preferred}/embed-config.js`)) {
|
|
1184
|
+
reserved.add(preferred);
|
|
1185
|
+
clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
|
|
1186
|
+
return { port: preferred, up: true, launched: false };
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
if (recentlyLaunched(launchKey)) {
|
|
1190
|
+
reserved.add(preferred);
|
|
1191
|
+
return { port: preferred, up: false, launched: false, starting: true };
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
let port = preferred;
|
|
1195
|
+
try {
|
|
1196
|
+
if (reserved.has(preferred)) {
|
|
1197
|
+
if (!(await isPortFree(preferred))) {
|
|
1198
|
+
reserved.delete(preferred);
|
|
1199
|
+
port = await findFreePort(preferred, reserved);
|
|
1200
|
+
}
|
|
1201
|
+
} else {
|
|
1202
|
+
port = await findFreePort(preferred, reserved);
|
|
1203
|
+
}
|
|
1204
|
+
} catch (err) {
|
|
1205
|
+
recordProcessProblem({
|
|
1206
|
+
sandboxId: ws.sandboxId,
|
|
1207
|
+
code: "ai_server_launch",
|
|
1208
|
+
role: "ai",
|
|
1209
|
+
title: `Could not start the chat script (${label})`,
|
|
1210
|
+
message: `No free port found (tried from ${preferred}). ${
|
|
1211
|
+
err instanceof Error ? err.message : String(err)
|
|
1212
|
+
}`,
|
|
1213
|
+
resolution: "Close other local servers, then use Start chat server.",
|
|
1214
|
+
});
|
|
1215
|
+
return { port: preferred, up: false, launched: false };
|
|
1216
|
+
}
|
|
1217
|
+
if (port !== preferred) {
|
|
1218
|
+
log(`port ${preferred} busy; using ${port} for ai-server`);
|
|
1219
|
+
ws.port = port;
|
|
1220
|
+
const envPath = path.join(folder, ".env");
|
|
1221
|
+
if (fs.existsSync(envPath)) {
|
|
1222
|
+
mergeEnvFile(envPath, {
|
|
1223
|
+
PORT: String(port),
|
|
1224
|
+
AI_SERVER_URL: `http://localhost:${port}`,
|
|
1225
|
+
NEXT_PUBLIC_AI_SERVER_URL: `http://localhost:${port}`,
|
|
1226
|
+
});
|
|
1227
|
+
}
|
|
1228
|
+
persistWorkspaceEntry(cfg, ws);
|
|
1229
|
+
}
|
|
688
1230
|
|
|
689
1231
|
const localCli = path.resolve(
|
|
690
1232
|
__dirname,
|
|
@@ -695,42 +1237,371 @@ function startAiServerForWorkspace(ws) {
|
|
|
695
1237
|
"cli.js"
|
|
696
1238
|
);
|
|
697
1239
|
const useLocal = fs.existsSync(localCli);
|
|
698
|
-
const
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
1240
|
+
const run = useLocal
|
|
1241
|
+
? process.platform === "win32"
|
|
1242
|
+
? `"${process.execPath}" "${localCli}" --port ${port}`
|
|
1243
|
+
: `${JSON.stringify(process.execPath)} ${JSON.stringify(localCli)} --port ${port}`
|
|
1244
|
+
: `npx --yes @maintainer-pro/ai-server --port ${port}`;
|
|
1245
|
+
|
|
1246
|
+
const opened = await openInNewTerminal({
|
|
1247
|
+
title: `MP-ai-${port}`,
|
|
1248
|
+
folder,
|
|
1249
|
+
command: run,
|
|
1250
|
+
env: {
|
|
1251
|
+
PORT: String(port),
|
|
1252
|
+
AI_SERVER_URL: `http://localhost:${port}`,
|
|
1253
|
+
NEXT_PUBLIC_AI_SERVER_URL: `http://localhost:${port}`,
|
|
1254
|
+
},
|
|
1255
|
+
launchKey,
|
|
1256
|
+
});
|
|
1257
|
+
if (opened.skipped) {
|
|
1258
|
+
return { port, up: false, launched: false, starting: true };
|
|
1259
|
+
}
|
|
1260
|
+
if (!opened.ok) {
|
|
1261
|
+
recordProcessProblem({
|
|
1262
|
+
sandboxId: ws.sandboxId,
|
|
1263
|
+
code: "ai_server_launch",
|
|
1264
|
+
role: "ai",
|
|
1265
|
+
title: `Could not start the chat script (${label})`,
|
|
1266
|
+
message: opened.error,
|
|
1267
|
+
resolution:
|
|
1268
|
+
"Allow the bridge to open terminal windows, or start the script manually in that folder.",
|
|
1269
|
+
});
|
|
1270
|
+
return { port, up: false, launched: false };
|
|
1271
|
+
}
|
|
1272
|
+
return { port, up: false, launched: true, starting: true };
|
|
1273
|
+
}
|
|
704
1274
|
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
1275
|
+
function sleep(ms) {
|
|
1276
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
/** @type {Map<string, { child: import("node:child_process").ChildProcess, url: string | null, localUrl: string }>} */
|
|
1280
|
+
const cloudflareTunnels = new Map();
|
|
1281
|
+
|
|
1282
|
+
function stopCloudflare(sandboxId) {
|
|
1283
|
+
const row = cloudflareTunnels.get(sandboxId);
|
|
1284
|
+
if (row?.child && !row.child.killed) {
|
|
1285
|
+
try {
|
|
1286
|
+
row.child.kill();
|
|
1287
|
+
} catch {
|
|
1288
|
+
/* ignore */
|
|
718
1289
|
}
|
|
1290
|
+
}
|
|
1291
|
+
cloudflareTunnels.delete(sandboxId);
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
function stopAllCloudflare() {
|
|
1295
|
+
for (const id of [...cloudflareTunnels.keys()]) stopCloudflare(id);
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
function localUrlForWorkspace(ws) {
|
|
1299
|
+
if (ws.appUrl && isLocalAppUrl(ws.appUrl)) return ws.appUrl;
|
|
1300
|
+
return `http://127.0.0.1:${Number(ws.port) || 3100}`;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
1303
|
+
function parseTryCloudflareUrl(text) {
|
|
1304
|
+
const match = String(text || "").match(
|
|
1305
|
+
/https:\/\/[a-z0-9-]+\.trycloudflare\.com/i
|
|
719
1306
|
);
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
1307
|
+
return match ? match[0].replace(/\/$/, "") : null;
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
function spawnCloudflared(localUrl) {
|
|
1311
|
+
const args = ["tunnel", "--url", localUrl, "--no-autoupdate"];
|
|
1312
|
+
const trySpawn = (file, argv, extra = {}) =>
|
|
1313
|
+
new Promise((resolve, reject) => {
|
|
1314
|
+
const child = spawn(file, argv, {
|
|
1315
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
1316
|
+
windowsHide: true,
|
|
1317
|
+
...extra,
|
|
1318
|
+
});
|
|
1319
|
+
const onError = (err) => reject(err);
|
|
1320
|
+
child.once("error", onError);
|
|
1321
|
+
child.once("spawn", () => {
|
|
1322
|
+
child.off("error", onError);
|
|
1323
|
+
resolve(child);
|
|
1324
|
+
});
|
|
1325
|
+
});
|
|
1326
|
+
|
|
1327
|
+
return trySpawn("cloudflared", args).catch(() =>
|
|
1328
|
+
trySpawn(
|
|
1329
|
+
process.platform === "win32" ? "npx.cmd" : "npx",
|
|
1330
|
+
["--yes", "cloudflared", ...args],
|
|
1331
|
+
{ shell: process.platform === "win32" }
|
|
1332
|
+
)
|
|
1333
|
+
);
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
function waitForTunnelUrl(child, timeoutMs = 90_000) {
|
|
1337
|
+
return new Promise((resolve, reject) => {
|
|
1338
|
+
let buffer = "";
|
|
1339
|
+
let settled = false;
|
|
1340
|
+
const done = (err, url) => {
|
|
1341
|
+
if (settled) return;
|
|
1342
|
+
settled = true;
|
|
1343
|
+
clearTimeout(timer);
|
|
1344
|
+
if (err) reject(err);
|
|
1345
|
+
else resolve(url);
|
|
1346
|
+
};
|
|
1347
|
+
const onData = (chunk) => {
|
|
1348
|
+
buffer += String(chunk);
|
|
1349
|
+
const url = parseTryCloudflareUrl(buffer);
|
|
1350
|
+
if (url) done(null, url);
|
|
1351
|
+
};
|
|
1352
|
+
child.stdout?.on("data", onData);
|
|
1353
|
+
child.stderr?.on("data", onData);
|
|
1354
|
+
child.once("exit", (code) => {
|
|
1355
|
+
done(
|
|
1356
|
+
new Error(
|
|
1357
|
+
`cloudflared exited ${code ?? "early"} before it published a URL`
|
|
1358
|
+
)
|
|
1359
|
+
);
|
|
1360
|
+
});
|
|
1361
|
+
const timer = setTimeout(() => {
|
|
1362
|
+
done(
|
|
1363
|
+
new Error(
|
|
1364
|
+
"Cloudflare did not publish a URL in time. Is cloudflared installed and online?"
|
|
1365
|
+
)
|
|
1366
|
+
);
|
|
1367
|
+
}, timeoutMs);
|
|
728
1368
|
});
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
async function configureCloudflareForWorkspace(ws, cfg) {
|
|
1372
|
+
const sandboxId = ws.sandboxId;
|
|
1373
|
+
const label = ws.sandboxName || "this sandbox";
|
|
1374
|
+
const localUrl = localUrlForWorkspace(ws);
|
|
1375
|
+
const existing = cloudflareTunnels.get(sandboxId);
|
|
1376
|
+
if (existing?.url && existing.localUrl === localUrl && existing.child && !existing.child.killed) {
|
|
1377
|
+
return {
|
|
1378
|
+
sandboxId,
|
|
1379
|
+
folderPath: ws.folderPath,
|
|
1380
|
+
appUrl: existing.url,
|
|
1381
|
+
origins: [existing.url],
|
|
1382
|
+
localUrl,
|
|
1383
|
+
cloudflare: true,
|
|
1384
|
+
reused: true,
|
|
1385
|
+
};
|
|
1386
|
+
}
|
|
1387
|
+
if (existing) stopCloudflare(sandboxId);
|
|
1388
|
+
|
|
1389
|
+
log(`starting Cloudflare tunnel for ${label} → ${localUrl}`);
|
|
1390
|
+
let child;
|
|
1391
|
+
try {
|
|
1392
|
+
child = await spawnCloudflared(localUrl);
|
|
1393
|
+
} catch (err) {
|
|
1394
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1395
|
+
recordProcessProblem({
|
|
1396
|
+
sandboxId,
|
|
1397
|
+
code: "cloudflare_launch",
|
|
1398
|
+
role: "tunnel",
|
|
1399
|
+
title: `Could not start Cloudflare (${label})`,
|
|
1400
|
+
message,
|
|
1401
|
+
resolution:
|
|
1402
|
+
"Install cloudflared (https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/) or allow npx to download it, then try again.",
|
|
1403
|
+
actionCode: "configure_cloudflare",
|
|
1404
|
+
});
|
|
1405
|
+
throw new Error(
|
|
1406
|
+
`Could not start cloudflared. Install it or allow npx to download it. ${message}`
|
|
1407
|
+
);
|
|
1408
|
+
}
|
|
1409
|
+
|
|
1410
|
+
cloudflareTunnels.set(sandboxId, { child, url: null, localUrl });
|
|
1411
|
+
child.once("exit", () => {
|
|
1412
|
+
const row = cloudflareTunnels.get(sandboxId);
|
|
1413
|
+
if (row?.child === child) cloudflareTunnels.delete(sandboxId);
|
|
732
1414
|
});
|
|
733
|
-
|
|
1415
|
+
|
|
1416
|
+
const publicUrl = await waitForTunnelUrl(child);
|
|
1417
|
+
cloudflareTunnels.set(sandboxId, { child, url: publicUrl, localUrl });
|
|
1418
|
+
child.unref();
|
|
1419
|
+
|
|
1420
|
+
ws.cloudflareUrl = publicUrl;
|
|
1421
|
+
ws.appUrl = publicUrl;
|
|
1422
|
+
const folder = ws.folderPath ? path.resolve(ws.folderPath) : null;
|
|
1423
|
+
if (folder && fs.existsSync(folder)) {
|
|
1424
|
+
fs.writeFileSync(
|
|
1425
|
+
path.join(folder, ".cloudflare-tunnel-url"),
|
|
1426
|
+
`${publicUrl}\n`,
|
|
1427
|
+
"utf8"
|
|
1428
|
+
);
|
|
1429
|
+
const envPath = path.join(folder, ".env");
|
|
1430
|
+
const envValues = {
|
|
1431
|
+
APP_URL: publicUrl,
|
|
1432
|
+
PUBLIC_URL: publicUrl,
|
|
1433
|
+
CORS_ORIGIN: publicUrl,
|
|
1434
|
+
};
|
|
1435
|
+
if (isAiServerTarget(ws, localUrl)) {
|
|
1436
|
+
envValues.AI_SERVER_URL = publicUrl;
|
|
1437
|
+
envValues.NEXT_PUBLIC_AI_SERVER_URL = publicUrl;
|
|
1438
|
+
}
|
|
1439
|
+
if (fs.existsSync(envPath)) mergeEnvFile(envPath, envValues);
|
|
1440
|
+
}
|
|
1441
|
+
persistWorkspaceEntry(cfg, ws);
|
|
1442
|
+
clearProcessProblem(sandboxId, "cloudflare_launch", "tunnel");
|
|
1443
|
+
log(`Cloudflare URL for ${label}: ${publicUrl}`);
|
|
1444
|
+
return {
|
|
1445
|
+
sandboxId,
|
|
1446
|
+
folderPath: ws.folderPath,
|
|
1447
|
+
appUrl: publicUrl,
|
|
1448
|
+
origins: [publicUrl],
|
|
1449
|
+
localUrl,
|
|
1450
|
+
cloudflare: true,
|
|
1451
|
+
reused: false,
|
|
1452
|
+
};
|
|
1453
|
+
}
|
|
1454
|
+
|
|
1455
|
+
function isAiServerTarget(ws, localUrl) {
|
|
1456
|
+
try {
|
|
1457
|
+
const port = Number(new URL(localUrl).port);
|
|
1458
|
+
return port === Number(ws.port);
|
|
1459
|
+
} catch {
|
|
1460
|
+
return true;
|
|
1461
|
+
}
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
async function ensureHostProcesses(ws, opts = {}) {
|
|
1465
|
+
const reserved = opts.reserved instanceof Set ? opts.reserved : new Set();
|
|
1466
|
+
const cfg = opts.cfg || null;
|
|
1467
|
+
const folder = path.resolve(ws.folderPath);
|
|
1468
|
+
const scripts = readPackageJson(folder)?.scripts || {};
|
|
1469
|
+
const jobs = planHostJobs(folder, ws.appUrl || null, ws.projectInfo);
|
|
1470
|
+
const started = [];
|
|
1471
|
+
let persisted = false;
|
|
1472
|
+
const label = ws.sandboxName || "this sandbox";
|
|
1473
|
+
|
|
1474
|
+
if (jobs.length > 0 && !fs.existsSync(folder)) {
|
|
1475
|
+
recordProcessProblem({
|
|
1476
|
+
sandboxId: ws.sandboxId,
|
|
1477
|
+
code: "host_process_launch",
|
|
1478
|
+
role: "app",
|
|
1479
|
+
title: `Could not start the app (${label})`,
|
|
1480
|
+
message: `The project folder is missing: ${folder}`,
|
|
1481
|
+
resolution: "Attach the folder again from Local setup.",
|
|
1482
|
+
});
|
|
1483
|
+
return started;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
for (const job of jobs) {
|
|
1487
|
+
const preferred = Number(job.preferredPort) || 3000;
|
|
1488
|
+
const probe = (job.probeUrl || `http://127.0.0.1:${preferred}`).replace(
|
|
1489
|
+
"localhost",
|
|
1490
|
+
"127.0.0.1"
|
|
1491
|
+
);
|
|
1492
|
+
if (await probeUrl(probe)) {
|
|
1493
|
+
reserved.add(portFromText(probe, preferred));
|
|
1494
|
+
clearProcessProblem(ws.sandboxId, "host_process_launch", job.role);
|
|
1495
|
+
clearProcessProblem(ws.sandboxId, "host_process_down", job.role);
|
|
1496
|
+
log(`${job.role} already running at ${job.probeUrl || probe}`);
|
|
1497
|
+
continue;
|
|
1498
|
+
}
|
|
1499
|
+
const launchKey = `${ws.sandboxId}:${folder}:${job.script}`;
|
|
1500
|
+
if (recentlyLaunched(launchKey)) {
|
|
1501
|
+
reserved.add(preferred);
|
|
1502
|
+
continue;
|
|
1503
|
+
}
|
|
1504
|
+
|
|
1505
|
+
let port = preferred;
|
|
1506
|
+
try {
|
|
1507
|
+
port = await findFreePort(preferred, reserved);
|
|
1508
|
+
} catch (err) {
|
|
1509
|
+
recordProcessProblem({
|
|
1510
|
+
sandboxId: ws.sandboxId,
|
|
1511
|
+
code: "host_process_launch",
|
|
1512
|
+
role: job.role,
|
|
1513
|
+
title: `Could not start the ${processRoleLabel(job.role)} (${label})`,
|
|
1514
|
+
message: `No free port found for "${job.script}" (tried from ${preferred}). ${
|
|
1515
|
+
err instanceof Error ? err.message : String(err)
|
|
1516
|
+
}`,
|
|
1517
|
+
resolution: "Close other local servers, then use Start chat server.",
|
|
1518
|
+
});
|
|
1519
|
+
continue;
|
|
1520
|
+
}
|
|
1521
|
+
if (port !== preferred) {
|
|
1522
|
+
log(`port ${preferred} busy; using ${port} for ${job.role}`);
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
const needsInstall =
|
|
1526
|
+
fs.existsSync(path.join(folder, "package.json")) &&
|
|
1527
|
+
!fs.existsSync(path.join(folder, "node_modules"));
|
|
1528
|
+
const run = commandWithPort(job, scripts, port);
|
|
1529
|
+
const command = needsInstall ? `npm install && ${run}` : run;
|
|
1530
|
+
const opened = await openInNewTerminal({
|
|
1531
|
+
title: `MP-${job.role}-${port}`,
|
|
1532
|
+
folder,
|
|
1533
|
+
command,
|
|
1534
|
+
env: { PORT: String(port) },
|
|
1535
|
+
launchKey,
|
|
1536
|
+
});
|
|
1537
|
+
if (opened.skipped) continue;
|
|
1538
|
+
if (!opened.ok) {
|
|
1539
|
+
recordProcessProblem({
|
|
1540
|
+
sandboxId: ws.sandboxId,
|
|
1541
|
+
code: "host_process_launch",
|
|
1542
|
+
role: job.role,
|
|
1543
|
+
title: `Could not start the ${processRoleLabel(job.role)} (${label})`,
|
|
1544
|
+
message: `${opened.error} Command: ${command}`,
|
|
1545
|
+
resolution:
|
|
1546
|
+
"Allow the bridge to open terminal windows, or run that command manually in the folder.",
|
|
1547
|
+
});
|
|
1548
|
+
continue;
|
|
1549
|
+
}
|
|
1550
|
+
started.push(job.role);
|
|
1551
|
+
if (
|
|
1552
|
+
(job.role === "ui" || job.role === "app") &&
|
|
1553
|
+
(!ws.appUrl || isLocalAppUrl(ws.appUrl))
|
|
1554
|
+
) {
|
|
1555
|
+
ws.appUrl = urlWithPort(ws.appUrl || `http://localhost:${port}`, port);
|
|
1556
|
+
persisted = true;
|
|
1557
|
+
}
|
|
1558
|
+
await sleep(400);
|
|
1559
|
+
}
|
|
1560
|
+
if (persisted) persistWorkspaceEntry(cfg, ws);
|
|
1561
|
+
return started;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
async function inspectHostJobs(ws) {
|
|
1565
|
+
const folder = path.resolve(ws.folderPath || "");
|
|
1566
|
+
const jobs = planHostJobs(folder, ws.appUrl || null, ws.projectInfo);
|
|
1567
|
+
const label = ws.sandboxName || "this sandbox";
|
|
1568
|
+
const hosts = [];
|
|
1569
|
+
for (const job of jobs) {
|
|
1570
|
+
const preferred = Number(job.preferredPort) || 3000;
|
|
1571
|
+
const probe = (job.probeUrl || `http://127.0.0.1:${preferred}`).replace(
|
|
1572
|
+
"localhost",
|
|
1573
|
+
"127.0.0.1"
|
|
1574
|
+
);
|
|
1575
|
+
const up = await probeUrl(probe);
|
|
1576
|
+
const launchKey = `${ws.sandboxId}:${folder}:${job.script}`;
|
|
1577
|
+
const starting = recentlyLaunched(launchKey);
|
|
1578
|
+
hosts.push({
|
|
1579
|
+
role: job.role,
|
|
1580
|
+
script: job.script,
|
|
1581
|
+
probeUrl: job.probeUrl || probe,
|
|
1582
|
+
up,
|
|
1583
|
+
starting,
|
|
1584
|
+
});
|
|
1585
|
+
if (up) {
|
|
1586
|
+
clearProcessProblem(ws.sandboxId, "host_process_launch", job.role);
|
|
1587
|
+
clearProcessProblem(ws.sandboxId, "host_process_down", job.role);
|
|
1588
|
+
continue;
|
|
1589
|
+
}
|
|
1590
|
+
if (starting) continue;
|
|
1591
|
+
const tried = launchedAt.has(launchKey);
|
|
1592
|
+
recordProcessProblem({
|
|
1593
|
+
sandboxId: ws.sandboxId,
|
|
1594
|
+
code: "host_process_down",
|
|
1595
|
+
role: job.role,
|
|
1596
|
+
title: `The ${processRoleLabel(job.role)} is not running (${label})`,
|
|
1597
|
+
message: tried
|
|
1598
|
+
? `Started "${job.script}" in a separate terminal, but nothing answered at ${probe}. Open that window and read the error.`
|
|
1599
|
+
: `Nothing is running at ${probe} for "${job.script}".`,
|
|
1600
|
+
resolution:
|
|
1601
|
+
"Fix the error in that terminal, then use Start chat server to try again.",
|
|
1602
|
+
});
|
|
1603
|
+
}
|
|
1604
|
+
return hosts;
|
|
734
1605
|
}
|
|
735
1606
|
|
|
736
1607
|
async function setupWorkspace(cfg, action) {
|
|
@@ -738,7 +1609,22 @@ async function setupWorkspace(cfg, action) {
|
|
|
738
1609
|
const sandboxId = String(
|
|
739
1610
|
action.sandboxId || action.payload?.sandboxId || ""
|
|
740
1611
|
);
|
|
741
|
-
const
|
|
1612
|
+
const requestedPort = Number(action.payload?.port) || 3100;
|
|
1613
|
+
const reserved = new Set();
|
|
1614
|
+
for (const other of cfg.workspaces || []) {
|
|
1615
|
+
if (other.sandboxId !== sandboxId && other.port) {
|
|
1616
|
+
reserved.add(Number(other.port));
|
|
1617
|
+
}
|
|
1618
|
+
}
|
|
1619
|
+
let port = requestedPort;
|
|
1620
|
+
if (await probeUrl(`http://127.0.0.1:${requestedPort}/embed-config.js`)) {
|
|
1621
|
+
reserved.add(requestedPort);
|
|
1622
|
+
} else {
|
|
1623
|
+
port = await findFreePort(requestedPort, reserved);
|
|
1624
|
+
if (port !== requestedPort) {
|
|
1625
|
+
log(`port ${requestedPort} busy; using ${port} for ai-server`);
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
742
1628
|
const clientMode = String(action.payload?.clientMode || "auto"); // auto|empty|existing|skip
|
|
743
1629
|
const hostAppUrl =
|
|
744
1630
|
typeof action.payload?.hostAppUrl === "string" &&
|
|
@@ -799,6 +1685,8 @@ async function setupWorkspace(cfg, action) {
|
|
|
799
1685
|
sandboxName: config.sandbox?.name,
|
|
800
1686
|
applicationName: config.sandbox?.applicationName,
|
|
801
1687
|
clientKind: client.kind,
|
|
1688
|
+
appUrl,
|
|
1689
|
+
sameOrigin: Boolean(client.sameOrigin),
|
|
802
1690
|
};
|
|
803
1691
|
if (existing >= 0) cfg.workspaces[existing] = entry;
|
|
804
1692
|
else cfg.workspaces.push(entry);
|
|
@@ -806,27 +1694,66 @@ async function setupWorkspace(cfg, action) {
|
|
|
806
1694
|
if (!cfg.offeredFolders.includes(resolved)) cfg.offeredFolders.push(resolved);
|
|
807
1695
|
saveConfig(cfg);
|
|
808
1696
|
|
|
1697
|
+
const projectInfo = await inspectProjectWithAiCli(entry, {
|
|
1698
|
+
cfg,
|
|
1699
|
+
extraContext: [
|
|
1700
|
+
`Scaffold kind: ${client.kind}`,
|
|
1701
|
+
`Chat script port: ${port}`,
|
|
1702
|
+
client.notes.join(" "),
|
|
1703
|
+
]
|
|
1704
|
+
.filter(Boolean)
|
|
1705
|
+
.join("\n"),
|
|
1706
|
+
});
|
|
1707
|
+
|
|
809
1708
|
if (!cfg.noAiServer) {
|
|
810
|
-
startAiServerForWorkspace(entry);
|
|
811
|
-
await
|
|
1709
|
+
await startAiServerForWorkspace(entry, { reserved, cfg });
|
|
1710
|
+
await sleep(1500);
|
|
1711
|
+
}
|
|
1712
|
+
const startedHosts = await ensureHostProcesses(entry, { reserved, cfg });
|
|
1713
|
+
await sleep(800);
|
|
1714
|
+
await inspectHostJobs(entry);
|
|
1715
|
+
|
|
1716
|
+
const openUrl = client.sameOrigin
|
|
1717
|
+
? `http://localhost:${entry.port}`
|
|
1718
|
+
: entry.appUrl || appUrl;
|
|
1719
|
+
const aiServerUp = await probeUrl(`http://127.0.0.1:${entry.port}/embed-config.js`);
|
|
1720
|
+
if (aiServerUp) {
|
|
1721
|
+
clearProcessProblem(sandboxId, "ai_server_launch", "ai");
|
|
812
1722
|
}
|
|
813
1723
|
|
|
814
|
-
const
|
|
1724
|
+
const processIssues = issuesForSandbox(sandboxId).map(
|
|
1725
|
+
({ role: _role, ...issue }) => issue
|
|
1726
|
+
);
|
|
1727
|
+
const warning = processIssues[0]?.message || null;
|
|
815
1728
|
|
|
816
1729
|
for (const note of client.notes) log(note);
|
|
1730
|
+
if (startedHosts.length) {
|
|
1731
|
+
log(`started ${startedHosts.join(" + ")} in separate terminals`);
|
|
1732
|
+
}
|
|
1733
|
+
if (aiServerUp) {
|
|
1734
|
+
log(`ai-server up — open ${openUrl}`);
|
|
1735
|
+
} else if (warning) {
|
|
1736
|
+
log(`process issue: ${warning}`);
|
|
1737
|
+
} else {
|
|
1738
|
+
log("ai-server not reachable yet; it may still be starting");
|
|
1739
|
+
}
|
|
817
1740
|
|
|
818
1741
|
return {
|
|
819
1742
|
sandboxId,
|
|
820
1743
|
folderPath: resolved,
|
|
821
|
-
port,
|
|
822
|
-
appUrl,
|
|
823
|
-
origins: [corsOrigin, aiOrigin].filter(Boolean),
|
|
1744
|
+
port: entry.port,
|
|
1745
|
+
appUrl: entry.appUrl || appUrl,
|
|
1746
|
+
origins: [...new Set([corsOrigin, aiOrigin, entry.appUrl].filter(Boolean))],
|
|
824
1747
|
wroteEnv: true,
|
|
825
1748
|
clientKind: client.kind,
|
|
826
1749
|
clientFiles: client.filesWritten,
|
|
827
1750
|
clientNotes: client.notes,
|
|
828
1751
|
aiServerUp,
|
|
829
|
-
|
|
1752
|
+
startedHosts,
|
|
1753
|
+
openUrl,
|
|
1754
|
+
processIssues,
|
|
1755
|
+
warning,
|
|
1756
|
+
projectInfo,
|
|
830
1757
|
};
|
|
831
1758
|
}
|
|
832
1759
|
|
|
@@ -843,7 +1770,15 @@ async function runActions(cfg, actions) {
|
|
|
843
1770
|
} else if (action.code === "setup_workspace") {
|
|
844
1771
|
result = await setupWorkspace(cfg, action);
|
|
845
1772
|
} else if (action.code === "recheck") {
|
|
846
|
-
|
|
1773
|
+
const sandboxId = action.sandboxId || action.payload?.sandboxId;
|
|
1774
|
+
const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
|
|
1775
|
+
const projectInfo = ws
|
|
1776
|
+
? await inspectProjectWithAiCli(ws, { cfg })
|
|
1777
|
+
: null;
|
|
1778
|
+
result = {
|
|
1779
|
+
recheckedAt: new Date().toISOString(),
|
|
1780
|
+
projectInfo,
|
|
1781
|
+
};
|
|
847
1782
|
} else if (action.code === "start_ai_server") {
|
|
848
1783
|
const sandboxId = action.sandboxId || action.payload?.sandboxId;
|
|
849
1784
|
const ws =
|
|
@@ -859,23 +1794,88 @@ async function runActions(cfg, actions) {
|
|
|
859
1794
|
ok = false;
|
|
860
1795
|
result = { error: "No workspace or --no-ai-server" };
|
|
861
1796
|
} else {
|
|
862
|
-
|
|
863
|
-
|
|
1797
|
+
const reserved = new Set();
|
|
1798
|
+
for (const other of cfg.workspaces || []) {
|
|
1799
|
+
if (other.sandboxId !== ws.sandboxId && other.port) {
|
|
1800
|
+
reserved.add(Number(other.port));
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
const problem = issuesForSandbox(ws.sandboxId)
|
|
1804
|
+
.map((issue) => issue.message)
|
|
1805
|
+
.join("\n");
|
|
1806
|
+
const projectInfo = await inspectProjectWithAiCli(ws, {
|
|
1807
|
+
cfg,
|
|
1808
|
+
problem:
|
|
1809
|
+
problem ||
|
|
1810
|
+
"Local processes are not running or the project setup looks incomplete.",
|
|
1811
|
+
});
|
|
1812
|
+
await startAiServerForWorkspace(ws, { reserved, cfg });
|
|
1813
|
+
await sleep(1500);
|
|
1814
|
+
const startedHosts = await ensureHostProcesses(ws, { reserved, cfg });
|
|
1815
|
+
await sleep(800);
|
|
1816
|
+
await inspectHostJobs(ws);
|
|
1817
|
+
const up = await probeUrl(
|
|
1818
|
+
`http://127.0.0.1:${ws.port}/embed-config.js`
|
|
1819
|
+
);
|
|
1820
|
+
if (up) clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
|
|
1821
|
+
const processIssues = issuesForSandbox(ws.sandboxId).map(
|
|
1822
|
+
({ role: _role, ...issue }) => issue
|
|
1823
|
+
);
|
|
1824
|
+
const warning = processIssues[0]?.message || null;
|
|
864
1825
|
result = {
|
|
865
|
-
up
|
|
866
|
-
|
|
867
|
-
|
|
1826
|
+
up,
|
|
1827
|
+
startedHosts,
|
|
1828
|
+
sandboxId: ws.sandboxId,
|
|
1829
|
+
folderPath: ws.folderPath,
|
|
1830
|
+
port: ws.port,
|
|
1831
|
+
appUrl: ws.appUrl,
|
|
1832
|
+
processIssues,
|
|
1833
|
+
warning,
|
|
1834
|
+
projectInfo,
|
|
868
1835
|
};
|
|
1836
|
+
if (processIssues.some((issue) => issue.code === "ai_server_launch")) {
|
|
1837
|
+
result.error = warning;
|
|
1838
|
+
ok = false;
|
|
1839
|
+
}
|
|
869
1840
|
}
|
|
870
1841
|
} else if (action.code === "refresh_public_url") {
|
|
1842
|
+
const sandboxId = action.sandboxId || action.payload?.sandboxId;
|
|
1843
|
+
const ws = (cfg.workspaces || []).find((w) => w.sandboxId === sandboxId);
|
|
871
1844
|
result = {
|
|
872
|
-
appUrl:
|
|
1845
|
+
appUrl:
|
|
1846
|
+
ws?.cloudflareUrl ||
|
|
1847
|
+
ws?.appUrl ||
|
|
1848
|
+
process.env.APP_URL ||
|
|
1849
|
+
process.env.PUBLIC_URL ||
|
|
1850
|
+
null,
|
|
873
1851
|
};
|
|
1852
|
+
} else if (action.code === "configure_cloudflare") {
|
|
1853
|
+
const sandboxId = action.sandboxId || action.payload?.sandboxId;
|
|
1854
|
+
const ws =
|
|
1855
|
+
(cfg.workspaces || []).find((w) => w.sandboxId === sandboxId) ||
|
|
1856
|
+
(action.payload?.folderPath
|
|
1857
|
+
? {
|
|
1858
|
+
sandboxId,
|
|
1859
|
+
folderPath: String(action.payload.folderPath),
|
|
1860
|
+
port: Number(action.payload.port) || 3100,
|
|
1861
|
+
appUrl:
|
|
1862
|
+
typeof action.payload.appUrl === "string"
|
|
1863
|
+
? action.payload.appUrl
|
|
1864
|
+
: null,
|
|
1865
|
+
sandboxName: action.payload.sandboxName,
|
|
1866
|
+
}
|
|
1867
|
+
: null);
|
|
1868
|
+
if (!ws) {
|
|
1869
|
+
ok = false;
|
|
1870
|
+
result = { error: "No folder is attached for this sandbox" };
|
|
1871
|
+
} else {
|
|
1872
|
+
result = await configureCloudflareForWorkspace(ws, cfg);
|
|
1873
|
+
}
|
|
874
1874
|
} else if (action.code === "remove_workspace") {
|
|
875
1875
|
const sandboxId = String(
|
|
876
1876
|
action.sandboxId || action.payload?.sandboxId || ""
|
|
877
1877
|
);
|
|
878
|
-
|
|
1878
|
+
forgetLaunch(sandboxId);
|
|
879
1879
|
cfg.workspaces = (cfg.workspaces || []).filter(
|
|
880
1880
|
(w) => w.sandboxId !== sandboxId
|
|
881
1881
|
);
|
|
@@ -908,10 +1908,53 @@ async function runActions(cfg, actions) {
|
|
|
908
1908
|
}
|
|
909
1909
|
}
|
|
910
1910
|
|
|
911
|
-
function
|
|
1911
|
+
async function collectWorkspaceStates(cfg) {
|
|
1912
|
+
/** @type {Array<{sandboxId:string,sandboxName?:string,port:number,aiServerUp:boolean,startingAi:boolean,folderPath:string,appUrl?:string|null}>} */
|
|
1913
|
+
const localStates = [];
|
|
1914
|
+
for (const ws of cfg.workspaces || []) {
|
|
1915
|
+
const folder = path.resolve(ws.folderPath || "");
|
|
1916
|
+
const up = await probeUrl(`http://127.0.0.1:${ws.port}/embed-config.js`);
|
|
1917
|
+
if (up) clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
|
|
1918
|
+
await inspectHostJobs(ws);
|
|
1919
|
+
localStates.push({
|
|
1920
|
+
sandboxId: ws.sandboxId,
|
|
1921
|
+
sandboxName: ws.sandboxName,
|
|
1922
|
+
port: ws.port,
|
|
1923
|
+
folderPath: ws.folderPath,
|
|
1924
|
+
aiServerUp: up,
|
|
1925
|
+
startingAi: recentlyLaunched(`${ws.sandboxId}:${folder}:ai`),
|
|
1926
|
+
appUrl: ws.appUrl || null,
|
|
1927
|
+
});
|
|
1928
|
+
}
|
|
1929
|
+
return localStates;
|
|
1930
|
+
}
|
|
1931
|
+
|
|
1932
|
+
async function sendHeartbeat(cfg, folders, localStates) {
|
|
1933
|
+
return api(
|
|
1934
|
+
cfg.adminUrl,
|
|
1935
|
+
cfg.token,
|
|
1936
|
+
"POST",
|
|
1937
|
+
"/api/v1/bridge/machine/heartbeat",
|
|
1938
|
+
{
|
|
1939
|
+
hostname: os.hostname(),
|
|
1940
|
+
platform: `${os.platform()}-${os.arch()}`,
|
|
1941
|
+
bridgeVersion: PACKAGE_VERSION,
|
|
1942
|
+
folders,
|
|
1943
|
+
issues: await buildIssues(cfg, localStates),
|
|
1944
|
+
workspaces: localStates.map((st) => ({
|
|
1945
|
+
sandboxId: st.sandboxId,
|
|
1946
|
+
aiServerUp: st.aiServerUp,
|
|
1947
|
+
port: st.port,
|
|
1948
|
+
appUrl: st.appUrl || undefined,
|
|
1949
|
+
})),
|
|
1950
|
+
}
|
|
1951
|
+
);
|
|
1952
|
+
}
|
|
1953
|
+
|
|
1954
|
+
async function buildIssues(cfg, workspaceStates) {
|
|
912
1955
|
/** @type {Array<Record<string, unknown>>} */
|
|
913
1956
|
const issues = [];
|
|
914
|
-
const cli = detectCliProviders();
|
|
1957
|
+
const cli = await detectCliProviders();
|
|
915
1958
|
if (!cli.length) {
|
|
916
1959
|
issues.push({
|
|
917
1960
|
code: "missing_cli",
|
|
@@ -925,17 +1968,54 @@ function buildIssues(cfg, workspaceStates) {
|
|
|
925
1968
|
});
|
|
926
1969
|
}
|
|
927
1970
|
for (const st of workspaceStates) {
|
|
928
|
-
if (
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
1971
|
+
if (st.aiServerUp || st.startingAi) continue;
|
|
1972
|
+
const launch = [...processProblems.values()].find(
|
|
1973
|
+
(issue) =>
|
|
1974
|
+
issue.sandboxId === st.sandboxId && issue.code === "ai_server_launch"
|
|
1975
|
+
);
|
|
1976
|
+
issues.push({
|
|
1977
|
+
code: "ai_server_down",
|
|
1978
|
+
severity: "error",
|
|
1979
|
+
title:
|
|
1980
|
+
launch?.title ||
|
|
1981
|
+
`Local script is not running (${st.sandboxName || "sandbox"})`,
|
|
1982
|
+
message:
|
|
1983
|
+
launch?.message ||
|
|
1984
|
+
`Cannot reach http://localhost:${st.port}. Check the MP-ai terminal on that computer for the error.`,
|
|
1985
|
+
resolution:
|
|
1986
|
+
launch?.resolution ||
|
|
1987
|
+
"Read the error in that terminal, then use Start chat server.",
|
|
1988
|
+
actionCode: "start_ai_server",
|
|
1989
|
+
sandboxId: st.sandboxId,
|
|
1990
|
+
});
|
|
1991
|
+
}
|
|
1992
|
+
for (const problem of processProblems.values()) {
|
|
1993
|
+
if (
|
|
1994
|
+
problem.code === "ai_server_launch" ||
|
|
1995
|
+
problem.code === "ai_server_down"
|
|
1996
|
+
) {
|
|
1997
|
+
continue;
|
|
1998
|
+
}
|
|
1999
|
+
if (
|
|
2000
|
+
problem.code === "host_process_down" &&
|
|
2001
|
+
[...processProblems.values()].some(
|
|
2002
|
+
(other) =>
|
|
2003
|
+
other.sandboxId === problem.sandboxId &&
|
|
2004
|
+
other.role === problem.role &&
|
|
2005
|
+
other.code === "host_process_launch"
|
|
2006
|
+
)
|
|
2007
|
+
) {
|
|
2008
|
+
continue;
|
|
938
2009
|
}
|
|
2010
|
+
issues.push({
|
|
2011
|
+
code: problem.code,
|
|
2012
|
+
severity: problem.severity,
|
|
2013
|
+
title: problem.title,
|
|
2014
|
+
message: problem.message,
|
|
2015
|
+
resolution: problem.resolution,
|
|
2016
|
+
actionCode: problem.actionCode,
|
|
2017
|
+
sandboxId: problem.sandboxId,
|
|
2018
|
+
});
|
|
939
2019
|
}
|
|
940
2020
|
return issues;
|
|
941
2021
|
}
|
|
@@ -975,6 +2055,8 @@ async function pairFlow(args) {
|
|
|
975
2055
|
saveConfig(cfg);
|
|
976
2056
|
log(`paired as ${result.machine?.name || cfg.machineId}`);
|
|
977
2057
|
log(`config ${configPath()}`);
|
|
2058
|
+
log("Keep this process running.");
|
|
2059
|
+
log("Next: Admin → Bridges → pick a folder → Setup sandbox.");
|
|
978
2060
|
return cfg;
|
|
979
2061
|
}
|
|
980
2062
|
|
|
@@ -1017,35 +2099,32 @@ async function main() {
|
|
|
1017
2099
|
/** @type {Array<{sandboxId:string,sandboxName?:string,port:number,aiServerUp:boolean,folderPath:string}>} */
|
|
1018
2100
|
const localStates = [];
|
|
1019
2101
|
|
|
2102
|
+
const reserved = new Set();
|
|
1020
2103
|
for (const ws of cfg.workspaces || []) {
|
|
2104
|
+
const folder = path.resolve(ws.folderPath || "");
|
|
1021
2105
|
const up = await probeUrl(
|
|
1022
2106
|
`http://127.0.0.1:${ws.port}/embed-config.js`
|
|
1023
2107
|
);
|
|
2108
|
+
if (!cfg.noAiServer && !up) {
|
|
2109
|
+
await startAiServerForWorkspace(ws, { reserved, cfg });
|
|
2110
|
+
} else if (ws.port) {
|
|
2111
|
+
reserved.add(Number(ws.port));
|
|
2112
|
+
if (up) clearProcessProblem(ws.sandboxId, "ai_server_launch", "ai");
|
|
2113
|
+
}
|
|
2114
|
+
await ensureHostProcesses(ws, { reserved, cfg });
|
|
2115
|
+
await inspectHostJobs(ws);
|
|
1024
2116
|
localStates.push({
|
|
1025
2117
|
sandboxId: ws.sandboxId,
|
|
1026
2118
|
sandboxName: ws.sandboxName,
|
|
1027
2119
|
port: ws.port,
|
|
1028
2120
|
folderPath: ws.folderPath,
|
|
1029
2121
|
aiServerUp: up,
|
|
2122
|
+
startingAi: recentlyLaunched(`${ws.sandboxId}:${folder}:ai`),
|
|
2123
|
+
appUrl: ws.appUrl || null,
|
|
1030
2124
|
});
|
|
1031
|
-
if (!cfg.noAiServer && !up) {
|
|
1032
|
-
startAiServerForWorkspace(ws);
|
|
1033
|
-
}
|
|
1034
2125
|
}
|
|
1035
2126
|
|
|
1036
|
-
const hb = await
|
|
1037
|
-
cfg.adminUrl,
|
|
1038
|
-
cfg.token,
|
|
1039
|
-
"POST",
|
|
1040
|
-
"/api/v1/bridge/machine/heartbeat",
|
|
1041
|
-
{
|
|
1042
|
-
hostname: os.hostname(),
|
|
1043
|
-
platform: `${os.platform()}-${os.arch()}`,
|
|
1044
|
-
bridgeVersion: PACKAGE_VERSION,
|
|
1045
|
-
folders,
|
|
1046
|
-
issues: buildIssues(cfg, localStates),
|
|
1047
|
-
}
|
|
1048
|
-
);
|
|
2127
|
+
const hb = await sendHeartbeat(cfg, folders, localStates);
|
|
1049
2128
|
|
|
1050
2129
|
// Sync local workspace list from server assignments
|
|
1051
2130
|
if (Array.isArray(hb.workspaces)) {
|
|
@@ -1074,20 +2153,45 @@ async function main() {
|
|
|
1074
2153
|
|
|
1075
2154
|
if (Array.isArray(hb.actions) && hb.actions.length > 0) {
|
|
1076
2155
|
await runActions(cfg, hb.actions);
|
|
2156
|
+
await sendHeartbeat(cfg, folders, await collectWorkspaceStates(cfg));
|
|
1077
2157
|
}
|
|
1078
2158
|
} catch (err) {
|
|
1079
2159
|
warn(err instanceof Error ? err.message : String(err));
|
|
1080
2160
|
}
|
|
1081
2161
|
};
|
|
1082
2162
|
|
|
1083
|
-
|
|
2163
|
+
let cycleBusy = false;
|
|
2164
|
+
const runLocked = async (fn) => {
|
|
2165
|
+
if (cycleBusy) return;
|
|
2166
|
+
cycleBusy = true;
|
|
2167
|
+
try {
|
|
2168
|
+
await fn();
|
|
2169
|
+
} finally {
|
|
2170
|
+
cycleBusy = false;
|
|
2171
|
+
}
|
|
2172
|
+
};
|
|
2173
|
+
|
|
2174
|
+
await runLocked(tick);
|
|
1084
2175
|
setInterval(() => {
|
|
1085
|
-
void tick
|
|
2176
|
+
void runLocked(tick);
|
|
1086
2177
|
}, HEARTBEAT_MS);
|
|
2178
|
+
setInterval(() => {
|
|
2179
|
+
void runLocked(async () => {
|
|
2180
|
+
const pending = await api(
|
|
2181
|
+
cfg.adminUrl,
|
|
2182
|
+
cfg.token,
|
|
2183
|
+
"GET",
|
|
2184
|
+
"/api/v1/bridge/machine/actions"
|
|
2185
|
+
);
|
|
2186
|
+
if (Array.isArray(pending?.actions) && pending.actions.length > 0) {
|
|
2187
|
+
await runActions(cfg, pending.actions);
|
|
2188
|
+
}
|
|
2189
|
+
});
|
|
2190
|
+
}, ACTION_POLL_MS);
|
|
1087
2191
|
|
|
1088
2192
|
const shutdown = () => {
|
|
1089
|
-
|
|
1090
|
-
|
|
2193
|
+
stopAllCloudflare();
|
|
2194
|
+
log("shutting down (other terminals stay open)");
|
|
1091
2195
|
process.exit(0);
|
|
1092
2196
|
};
|
|
1093
2197
|
process.on("SIGINT", shutdown);
|