@proagentstore/cli 0.2.11 → 0.3.1
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/dist/browser-runner/runner.js +34 -7
- package/dist/index.js +18 -336
- package/package.json +1 -1
|
@@ -377,7 +377,9 @@ export class LocalRunner {
|
|
|
377
377
|
this.store.putTask(task);
|
|
378
378
|
this.addTaskEvent(task, "task.completed", `Task completed: ${task.type}`, output);
|
|
379
379
|
await this.endTakeover(taskId);
|
|
380
|
-
|
|
380
|
+
// This path only CLEARED a challenge — the form was NOT submitted (output.submitted
|
|
381
|
+
// is false). Report it accurately so the console doesn't show a false "submitted".
|
|
382
|
+
return { submitted: false, resumed: true, output };
|
|
381
383
|
}
|
|
382
384
|
/**
|
|
383
385
|
* Always attach files via Playwright, never a native OS dialog: intercept any
|
|
@@ -584,6 +586,19 @@ export class LocalRunner {
|
|
|
584
586
|
}
|
|
585
587
|
});
|
|
586
588
|
}
|
|
589
|
+
/** Close every page except the active one. ATS forms open popups/extra tabs that
|
|
590
|
+
* otherwise stay open in the persistent context until shutdown; over many
|
|
591
|
+
* applications they accumulate (memory/handles). Called when a task finishes. */
|
|
592
|
+
async closeStalePages() {
|
|
593
|
+
const ctx = this.browserContext;
|
|
594
|
+
if (!ctx)
|
|
595
|
+
return;
|
|
596
|
+
for (const p of ctx.pages()) {
|
|
597
|
+
if (p !== this.activePage && !p.isClosed()) {
|
|
598
|
+
await p.close().catch(() => undefined);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
587
602
|
/** The page the brain drives and the human takes over — created on demand. */
|
|
588
603
|
async getActivePage() {
|
|
589
604
|
const context = await this.getBrowserContext();
|
|
@@ -716,28 +731,39 @@ export class LocalRunner {
|
|
|
716
731
|
/** Whether the brain can resume: a solved captcha (auto), a human "Resume" for a stuck step, or a provided value. */
|
|
717
732
|
async browserHandoffStatus(taskId) {
|
|
718
733
|
const session = this.takeovers.get(taskId);
|
|
719
|
-
|
|
734
|
+
// No live takeover session (the runner restarted mid-handoff, or it expired): stay
|
|
735
|
+
// UNRESOLVED. The old code fell through and relaunched a blank page via
|
|
736
|
+
// getActivePage(), which detected no challenge and reported solved:true — so the
|
|
737
|
+
// brain resumed the application WITHOUT the human's captcha/value. A status poll
|
|
738
|
+
// must never relaunch the browser or claim a lost handoff is done.
|
|
739
|
+
if (!session)
|
|
740
|
+
return { solved: false, challenge: null };
|
|
741
|
+
const page = session.page;
|
|
720
742
|
if (page.isClosed())
|
|
721
743
|
return { solved: true, challenge: null };
|
|
722
744
|
// A needs_input handoff resumes once the user supplies the value.
|
|
723
|
-
if (session
|
|
745
|
+
if (session.reason === "needs_input")
|
|
724
746
|
return { solved: !!session.inputValue, challenge: null, value: session.inputValue };
|
|
725
747
|
// A stuck handoff resumes only when the human explicitly clicks Resume —
|
|
726
748
|
// there's nothing to auto-detect.
|
|
727
|
-
if (session
|
|
749
|
+
if (session.reason === "stuck")
|
|
728
750
|
return { solved: !!session.humanDone, challenge: null };
|
|
729
751
|
// A challenge resumes when the token/widget clears OR the human clicks Done —
|
|
730
752
|
// custom captchas (e.g. PageUp's "not a robot") have no detectable token, so
|
|
731
753
|
// the human's explicit Done is the authority; never strand them.
|
|
732
754
|
const challenge = await detectHumanChallenge(page);
|
|
733
|
-
const solved = !challenge || (await challengeSolved(page)) || !!session
|
|
755
|
+
const solved = !challenge || (await challengeSolved(page)) || !!session.humanDone;
|
|
734
756
|
return { solved, challenge };
|
|
735
757
|
}
|
|
736
758
|
/** The user supplied the value the agent asked for (ask-and-hold). */
|
|
737
759
|
browserSubmitInput(taskId, value) {
|
|
738
760
|
const session = this.takeovers.get(taskId);
|
|
739
|
-
|
|
740
|
-
|
|
761
|
+
// No live takeover (session expired / runner restarted): the value can't be
|
|
762
|
+
// delivered. Don't discard it silently AND resurrect the already-terminal task as
|
|
763
|
+
// "running" — report failure so the console can say "session expired, re-run".
|
|
764
|
+
if (!session)
|
|
765
|
+
return { ok: false };
|
|
766
|
+
session.inputValue = value;
|
|
741
767
|
const task = this.store.getTask(taskId);
|
|
742
768
|
if (task) {
|
|
743
769
|
task.status = "running";
|
|
@@ -777,6 +803,7 @@ export class LocalRunner {
|
|
|
777
803
|
this.store.putTask(task);
|
|
778
804
|
this.addTaskEvent(task, success ? "task.completed" : "task.failed", detail || `Application ${outcome}`, { outcome, detail });
|
|
779
805
|
}
|
|
806
|
+
await this.closeStalePages().catch(() => undefined);
|
|
780
807
|
return { ok: true };
|
|
781
808
|
}
|
|
782
809
|
ensureChromiumInstalled() {
|
package/dist/index.js
CHANGED
|
@@ -171,6 +171,7 @@ var initCommand = new Command2("init").description("Scaffold a new ProAgentStore
|
|
|
171
171
|
category: "general",
|
|
172
172
|
model: "@cf/meta/llama-3.2-3b-instruct",
|
|
173
173
|
template,
|
|
174
|
+
capabilities: { surfaces: [], runtime: null, workflow: null },
|
|
174
175
|
serverConfig: {
|
|
175
176
|
durableObject: template === "worker",
|
|
176
177
|
cron: template === "cron" ? "0 8 * * *" : void 0,
|
|
@@ -614,9 +615,7 @@ var publishCommand = new Command5("publish").description("Publish an agent to Pr
|
|
|
614
615
|
import { spawn as spawn2 } from "child_process";
|
|
615
616
|
import { randomUUID } from "crypto";
|
|
616
617
|
import { existsSync as existsSync5 } from "fs";
|
|
617
|
-
import {
|
|
618
|
-
import { gunzipSync } from "zlib";
|
|
619
|
-
import { arch, homedir as homedir2, hostname, platform } from "os";
|
|
618
|
+
import { hostname } from "os";
|
|
620
619
|
import { resolve as resolve4 } from "path";
|
|
621
620
|
import { createServer as createServer2 } from "net";
|
|
622
621
|
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
@@ -672,122 +671,6 @@ function buildRunnerArgs(opts) {
|
|
|
672
671
|
if (opts.headless) args.push("--headless");
|
|
673
672
|
return args;
|
|
674
673
|
}
|
|
675
|
-
function buildCloudflaredArgs(localUrl) {
|
|
676
|
-
return ["tunnel", "--url", localUrl];
|
|
677
|
-
}
|
|
678
|
-
function cloudflaredAssetForPlatform(os = platform(), cpu = arch()) {
|
|
679
|
-
if (os === "darwin") {
|
|
680
|
-
if (cpu === "arm64") {
|
|
681
|
-
return {
|
|
682
|
-
asset: "cloudflared-darwin-arm64.tgz",
|
|
683
|
-
executableName: "cloudflared",
|
|
684
|
-
archive: true
|
|
685
|
-
};
|
|
686
|
-
}
|
|
687
|
-
if (cpu === "x64") {
|
|
688
|
-
return {
|
|
689
|
-
asset: "cloudflared-darwin-amd64.tgz",
|
|
690
|
-
executableName: "cloudflared",
|
|
691
|
-
archive: true
|
|
692
|
-
};
|
|
693
|
-
}
|
|
694
|
-
}
|
|
695
|
-
if (os === "linux") {
|
|
696
|
-
if (cpu === "x64") {
|
|
697
|
-
return {
|
|
698
|
-
asset: "cloudflared-linux-amd64",
|
|
699
|
-
executableName: "cloudflared",
|
|
700
|
-
archive: false
|
|
701
|
-
};
|
|
702
|
-
}
|
|
703
|
-
if (cpu === "arm64") {
|
|
704
|
-
return {
|
|
705
|
-
asset: "cloudflared-linux-arm64",
|
|
706
|
-
executableName: "cloudflared",
|
|
707
|
-
archive: false
|
|
708
|
-
};
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
|
-
if (os === "win32" && cpu === "x64") {
|
|
712
|
-
return {
|
|
713
|
-
asset: "cloudflared-windows-amd64.exe",
|
|
714
|
-
executableName: "cloudflared.exe",
|
|
715
|
-
archive: false
|
|
716
|
-
};
|
|
717
|
-
}
|
|
718
|
-
throw new Error(
|
|
719
|
-
`Automatic cloudflared download is not supported on ${os}/${cpu}. Install cloudflared and pass --cloudflared <path>.`
|
|
720
|
-
);
|
|
721
|
-
}
|
|
722
|
-
function cloudflaredDownloadUrl(asset) {
|
|
723
|
-
return `https://github.com/cloudflare/cloudflared/releases/latest/download/${asset}`;
|
|
724
|
-
}
|
|
725
|
-
function extractCloudflaredBinary(asset, bytes) {
|
|
726
|
-
const buffer = Buffer.from(bytes);
|
|
727
|
-
if (!asset.archive) return buffer;
|
|
728
|
-
const tar = gunzipSync(buffer);
|
|
729
|
-
for (let offset = 0; offset + 512 <= tar.length; ) {
|
|
730
|
-
const header = tar.subarray(offset, offset + 512);
|
|
731
|
-
if (header.every((byte) => byte === 0)) break;
|
|
732
|
-
const rawName = header.subarray(0, 100).toString("utf8");
|
|
733
|
-
const name = rawName.slice(0, rawName.indexOf("\0") === -1 ? void 0 : rawName.indexOf("\0"));
|
|
734
|
-
const rawSize = header.subarray(124, 136).toString("utf8").replace(/\0.*$/, "").trim();
|
|
735
|
-
const size = Number.parseInt(rawSize || "0", 8);
|
|
736
|
-
const contentStart = offset + 512;
|
|
737
|
-
const contentEnd = contentStart + size;
|
|
738
|
-
if (name.split("/").pop() === asset.executableName) {
|
|
739
|
-
return tar.subarray(contentStart, contentEnd);
|
|
740
|
-
}
|
|
741
|
-
offset = contentStart + Math.ceil(size / 512) * 512;
|
|
742
|
-
}
|
|
743
|
-
throw new Error(`Downloaded ${asset.asset} did not contain ${asset.executableName}`);
|
|
744
|
-
}
|
|
745
|
-
function cloudflaredCacheDir() {
|
|
746
|
-
return resolve4(homedir2(), ".config", "proagentstore", "bin");
|
|
747
|
-
}
|
|
748
|
-
function cachedCloudflaredPath(asset) {
|
|
749
|
-
const suffix = asset.asset.replace(/[^a-zA-Z0-9._-]/g, "-");
|
|
750
|
-
return resolve4(cloudflaredCacheDir(), `${asset.executableName}-${suffix}`);
|
|
751
|
-
}
|
|
752
|
-
async function commandAvailable(command) {
|
|
753
|
-
return new Promise((resolvePromise) => {
|
|
754
|
-
const child = spawn2(command, ["--version"], {
|
|
755
|
-
stdio: "ignore",
|
|
756
|
-
shell: process.platform === "win32"
|
|
757
|
-
});
|
|
758
|
-
child.on("error", () => resolvePromise(false));
|
|
759
|
-
child.on("exit", (code) => resolvePromise(code === 0));
|
|
760
|
-
});
|
|
761
|
-
}
|
|
762
|
-
async function downloadCloudflared(asset) {
|
|
763
|
-
const target = cachedCloudflaredPath(asset);
|
|
764
|
-
if (existsSync5(target)) return target;
|
|
765
|
-
await mkdir(cloudflaredCacheDir(), { recursive: true });
|
|
766
|
-
const url = cloudflaredDownloadUrl(asset.asset);
|
|
767
|
-
writeLine(`cloudflared not found; downloading ${asset.asset}...`);
|
|
768
|
-
const res = await fetch(url);
|
|
769
|
-
if (!res.ok) {
|
|
770
|
-
throw new Error(`cloudflared download failed: ${res.status} ${res.statusText}`);
|
|
771
|
-
}
|
|
772
|
-
const binary = extractCloudflaredBinary(asset, new Uint8Array(await res.arrayBuffer()));
|
|
773
|
-
await writeFile(target, binary);
|
|
774
|
-
if (process.platform !== "win32") await chmod(target, 493);
|
|
775
|
-
writeLine(`cloudflared cached at ${target}`);
|
|
776
|
-
return target;
|
|
777
|
-
}
|
|
778
|
-
async function resolveCloudflaredCommand(requested) {
|
|
779
|
-
const explicit = clean(requested);
|
|
780
|
-
if (explicit && explicit !== "cloudflared") return explicit;
|
|
781
|
-
if (await commandAvailable("cloudflared")) return "cloudflared";
|
|
782
|
-
return downloadCloudflared(cloudflaredAssetForPlatform());
|
|
783
|
-
}
|
|
784
|
-
function parseCloudflaredTunnelUrl(text) {
|
|
785
|
-
const matches = text.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/gi) || [];
|
|
786
|
-
for (const m of matches) {
|
|
787
|
-
if (!/^https:\/\/api\.trycloudflare\.com/i.test(m)) return m;
|
|
788
|
-
}
|
|
789
|
-
return null;
|
|
790
|
-
}
|
|
791
674
|
function buildRuntimeRegistrationBody(opts, capabilities = []) {
|
|
792
675
|
return {
|
|
793
676
|
endpointUrl: clean(opts.endpointUrl) || opts.endpointUrl,
|
|
@@ -1043,18 +926,13 @@ function createRunnerCommand() {
|
|
|
1043
926
|
command.command("start").description("Start the local FAGS browser runtime in the foreground").option("--host <host>", "Host to bind", "127.0.0.1").option("--port <port>", "Port to bind", "49171").option("--data-dir <path>", "Runner data directory").option("--token <token>", "Require this bearer token").option("--instance-id <id>", "Bind runner requests to a PAGS instance id").option("--headless", "Run Playwright headless").action(async (opts) => {
|
|
1044
927
|
await startRunnerForeground(opts);
|
|
1045
928
|
});
|
|
1046
|
-
command.command("connect <instanceIds...>").description("Start ONE local runtime,
|
|
929
|
+
command.command("connect <instanceIds...>").description("Start ONE local runtime, connect via WebSocket relay, and register it for every given PAGS instance").option("--host <host>", "Host to bind", "127.0.0.1").option("--port <port>", "Port to bind", "49171").option("--data-dir <path>", "Runner data directory").option("--token <token>", "Runner bearer token. Defaults to PAGS_RUNNER_TOKEN or a generated token").option("--headless", "Run Playwright headless").option("--api-base <url>", "PAGS API base URL").option("--pags-token <token>", "PAGS session token. Defaults to PAGS_TOKEN").option("--runner-version <version>", "Runner version").option("--force", "Take over from another connected machine").action(async (instanceIds, opts) => {
|
|
1047
930
|
const runnerToken = clean(opts.token) || clean(process.env.PAGS_RUNNER_TOKEN) || `pags_runner_${randomUUID()}`;
|
|
1048
931
|
const host = clean(opts.host) || "127.0.0.1";
|
|
1049
932
|
const port = clean(opts.port) || String(await findFreePort2(49171));
|
|
1050
933
|
const localUrl = `http://${host}:${port}`;
|
|
1051
934
|
const primary = instanceIds[0];
|
|
1052
|
-
const runnerOpts = {
|
|
1053
|
-
...opts,
|
|
1054
|
-
host,
|
|
1055
|
-
port,
|
|
1056
|
-
token: runnerToken
|
|
1057
|
-
};
|
|
935
|
+
const runnerOpts = { ...opts, host, port, token: runnerToken };
|
|
1058
936
|
const spec = runnerSpawnSpec(runnerOpts);
|
|
1059
937
|
const runner = spawn2(spec.command, spec.args, {
|
|
1060
938
|
cwd: spec.cwd,
|
|
@@ -1066,9 +944,7 @@ function createRunnerCommand() {
|
|
|
1066
944
|
runner.on("exit", (code) => {
|
|
1067
945
|
if (code && code !== 0) writeError(`runner exited with code ${code}`);
|
|
1068
946
|
});
|
|
1069
|
-
let tunnel = null;
|
|
1070
947
|
const shutdown = () => {
|
|
1071
|
-
if (tunnel && !tunnel.killed) tunnel.kill("SIGTERM");
|
|
1072
948
|
if (!runner.killed) runner.kill("SIGTERM");
|
|
1073
949
|
};
|
|
1074
950
|
process.once("SIGINT", () => {
|
|
@@ -1082,193 +958,10 @@ function createRunnerCommand() {
|
|
|
1082
958
|
try {
|
|
1083
959
|
await waitForLocalRunner({ url: localUrl, token: runnerToken, instanceId: primary });
|
|
1084
960
|
writeLine(`Local FAGS runtime healthy at ${localUrl}`);
|
|
1085
|
-
|
|
1086
|
-
await connectViaRelay(instanceIds, localUrl, runnerToken, opts, Boolean(opts.force));
|
|
1087
|
-
await new Promise((resolvePromise) => {
|
|
1088
|
-
runner.on("exit", () => resolvePromise());
|
|
1089
|
-
});
|
|
1090
|
-
return;
|
|
1091
|
-
}
|
|
1092
|
-
if (opts.tunnel === "named") {
|
|
1093
|
-
writeLine("Provisioning named tunnel\u2026");
|
|
1094
|
-
const provRes = await requestPags("POST", "/v1/tunnel/provision", opts, {});
|
|
1095
|
-
const prov = provRes;
|
|
1096
|
-
if (!prov.connectorToken) {
|
|
1097
|
-
writeError(`Named tunnel provisioning failed: ${prov.error || "no token returned"}. Falling back to quick tunnel.`);
|
|
1098
|
-
} else {
|
|
1099
|
-
const tunnelUrl2 = prov.endpointUrl || `https://${prov.hostname}`;
|
|
1100
|
-
writeLine(`Named tunnel: ${tunnelUrl2}`);
|
|
1101
|
-
const cloudflaredPath2 = await resolveCloudflaredCommand(opts.cloudflared);
|
|
1102
|
-
const tunnelProc = spawn2(cloudflaredPath2, ["tunnel", "run", "--token", prov.connectorToken], {
|
|
1103
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
1104
|
-
shell: process.platform === "win32"
|
|
1105
|
-
});
|
|
1106
|
-
tunnel = tunnelProc;
|
|
1107
|
-
tunnelProc.stdout?.on("data", (d2) => {
|
|
1108
|
-
const t = d2.toString("utf-8");
|
|
1109
|
-
if (/registered/.test(t) || /CONNECTED/.test(t)) writeLine("Named tunnel connected \u2713");
|
|
1110
|
-
});
|
|
1111
|
-
tunnelProc.stderr?.on("data", (d2) => {
|
|
1112
|
-
const t = d2.toString("utf-8");
|
|
1113
|
-
if (/registered/.test(t) || /CONNECTED/.test(t)) writeLine("Named tunnel connected \u2713");
|
|
1114
|
-
});
|
|
1115
|
-
await new Promise((r) => setTimeout(r, 4e3));
|
|
1116
|
-
const capabilities = await requestRunner("GET", "/capabilities", { url: localUrl, token: runnerToken, instanceId: primary });
|
|
1117
|
-
const caps = Array.isArray(capabilities.capabilities) ? capabilities.capabilities.filter((item) => typeof item === "string") : [];
|
|
1118
|
-
for (const id of instanceIds) {
|
|
1119
|
-
try {
|
|
1120
|
-
await requestPags("POST", `/v1/instances/${apiPathSegment(id)}/runtime`, opts, {
|
|
1121
|
-
endpointUrl: tunnelUrl2,
|
|
1122
|
-
token: runnerToken,
|
|
1123
|
-
placement: "local",
|
|
1124
|
-
capabilities: caps,
|
|
1125
|
-
runnerVersion: clean(opts.runnerVersion) || "",
|
|
1126
|
-
runnerNode: hostname()
|
|
1127
|
-
});
|
|
1128
|
-
} catch (error) {
|
|
1129
|
-
writeError(`register ${id.slice(0, 8)}\u2026 failed (${error instanceof Error ? error.message : String(error)})`);
|
|
1130
|
-
}
|
|
1131
|
-
}
|
|
1132
|
-
writeLine("Runtime registered with PAGS \u2713");
|
|
1133
|
-
writeLine("");
|
|
1134
|
-
writeLine("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
1135
|
-
writeLine(" \u2705 CONNECTED \u2014 named tunnel (production)");
|
|
1136
|
-
writeLine(` Agents: ${instanceIds.length} instance${instanceIds.length === 1 ? "" : "s"}`);
|
|
1137
|
-
writeLine(` Endpoint: ${tunnelUrl2}`);
|
|
1138
|
-
writeLine(" Stable URL \u2014 no reconnect churn. Ctrl+C to disconnect.");
|
|
1139
|
-
writeLine("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
1140
|
-
let stopped2 = false;
|
|
1141
|
-
void (async () => {
|
|
1142
|
-
while (!stopped2) {
|
|
1143
|
-
await new Promise((r) => setTimeout(r, 3e4));
|
|
1144
|
-
if (stopped2) break;
|
|
1145
|
-
for (const id of instanceIds) {
|
|
1146
|
-
await requestPags("POST", `/v1/instances/${apiPathSegment(id)}/runtime/heartbeat`, opts, {}).catch(() => void 0);
|
|
1147
|
-
}
|
|
1148
|
-
}
|
|
1149
|
-
})();
|
|
1150
|
-
await new Promise((resolvePromise) => {
|
|
1151
|
-
runner.on("exit", () => resolvePromise());
|
|
1152
|
-
});
|
|
1153
|
-
stopped2 = true;
|
|
1154
|
-
return;
|
|
1155
|
-
}
|
|
1156
|
-
}
|
|
1157
|
-
const cloudflaredPath = await resolveCloudflaredCommand(opts.cloudflared);
|
|
1158
|
-
const openTunnel = () => new Promise((resolveTunnel, reject) => {
|
|
1159
|
-
let output = "";
|
|
1160
|
-
const proc = spawn2(cloudflaredPath, buildCloudflaredArgs(localUrl), {
|
|
1161
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
1162
|
-
shell: process.platform === "win32"
|
|
1163
|
-
});
|
|
1164
|
-
const onData = (data) => {
|
|
1165
|
-
output += data.toString("utf-8");
|
|
1166
|
-
const parsed = parseCloudflaredTunnelUrl(output);
|
|
1167
|
-
if (parsed) resolveTunnel({ url: parsed, proc });
|
|
1168
|
-
};
|
|
1169
|
-
proc.stdout?.on("data", onData);
|
|
1170
|
-
proc.stderr?.on("data", onData);
|
|
1171
|
-
proc.on("error", reject);
|
|
1172
|
-
setTimeout(() => reject(new Error("timed out waiting for cloudflared tunnel URL")), 45e3).unref();
|
|
1173
|
-
});
|
|
1174
|
-
const registerRuntime = async (url) => {
|
|
1175
|
-
const capabilities = await requestRunner("GET", "/capabilities", { url: localUrl, token: runnerToken, instanceId: primary });
|
|
1176
|
-
const caps = Array.isArray(capabilities.capabilities) ? capabilities.capabilities.filter((item) => typeof item === "string") : [];
|
|
1177
|
-
for (const id of instanceIds) {
|
|
1178
|
-
try {
|
|
1179
|
-
await requestPags("POST", `/v1/instances/${apiPathSegment(id)}/runtime`, opts, {
|
|
1180
|
-
endpointUrl: url,
|
|
1181
|
-
token: runnerToken,
|
|
1182
|
-
placement: "local",
|
|
1183
|
-
capabilities: caps,
|
|
1184
|
-
runnerVersion: clean(opts.runnerVersion) || "",
|
|
1185
|
-
runnerNode: hostname()
|
|
1186
|
-
});
|
|
1187
|
-
} catch (error) {
|
|
1188
|
-
writeError(`register ${id.slice(0, 8)}\u2026 failed (${error instanceof Error ? error.message : String(error)}); will retry`);
|
|
1189
|
-
}
|
|
1190
|
-
}
|
|
1191
|
-
};
|
|
1192
|
-
const first = await openTunnel();
|
|
1193
|
-
tunnel = first.proc;
|
|
1194
|
-
let tunnelUrl = first.url;
|
|
1195
|
-
await registerRuntime(tunnelUrl).catch((error) => {
|
|
1196
|
-
writeError(`registration will retry (${error instanceof Error ? error.message : String(error)})`);
|
|
1197
|
-
});
|
|
1198
|
-
writeLine("Runtime registered with PAGS \u2713");
|
|
1199
|
-
writeLine("");
|
|
1200
|
-
writeLine("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
1201
|
-
writeLine(" \u2705 CONNECTED \u2014 runner is live");
|
|
1202
|
-
writeLine(` Agents: ${instanceIds.length} instance${instanceIds.length === 1 ? "" : "s"} served by this runner`);
|
|
1203
|
-
writeLine(` Tunnel: ${tunnelUrl}`);
|
|
1204
|
-
writeLine(" Auto-reconnect is on \u2014 keep this terminal open. Ctrl+C to disconnect.");
|
|
1205
|
-
writeLine("\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550");
|
|
1206
|
-
let stopped = false;
|
|
1207
|
-
let consecutiveFailures = 0;
|
|
1208
|
-
let respawnBackoffMs = 3e4;
|
|
1209
|
-
const PROBE_FAIL_THRESHOLD = 3;
|
|
1210
|
-
const MAX_BACKOFF_MS = 5 * 6e4;
|
|
1211
|
-
const probeTunnel = async (url) => {
|
|
1212
|
-
try {
|
|
1213
|
-
const res = await fetch(`${url.replace(/\/$/, "")}/health`, {
|
|
1214
|
-
headers: { Authorization: `Bearer ${runnerToken}`, "X-PAGS-Instance-Id": primary },
|
|
1215
|
-
signal: AbortSignal.timeout(1e4)
|
|
1216
|
-
});
|
|
1217
|
-
return res.ok;
|
|
1218
|
-
} catch {
|
|
1219
|
-
return false;
|
|
1220
|
-
}
|
|
1221
|
-
};
|
|
1222
|
-
const heartbeatAll = async () => {
|
|
1223
|
-
let allOk = true;
|
|
1224
|
-
for (const id of instanceIds) {
|
|
1225
|
-
const ok = await requestPags("POST", `/v1/instances/${apiPathSegment(id)}/runtime/heartbeat`, opts, {}).then(() => true).catch(() => false);
|
|
1226
|
-
if (!ok) allOk = false;
|
|
1227
|
-
}
|
|
1228
|
-
return allOk;
|
|
1229
|
-
};
|
|
1230
|
-
void (async () => {
|
|
1231
|
-
while (!stopped) {
|
|
1232
|
-
await new Promise((r) => setTimeout(r, 3e4));
|
|
1233
|
-
if (stopped) break;
|
|
1234
|
-
if (await probeTunnel(tunnelUrl)) {
|
|
1235
|
-
consecutiveFailures = 0;
|
|
1236
|
-
respawnBackoffMs = 3e4;
|
|
1237
|
-
const beat = await heartbeatAll();
|
|
1238
|
-
if (!beat) {
|
|
1239
|
-
await registerRuntime(tunnelUrl).then(() => writeLine("\u2705 Re-registered with PAGS")).catch(() => void 0);
|
|
1240
|
-
}
|
|
1241
|
-
continue;
|
|
1242
|
-
}
|
|
1243
|
-
consecutiveFailures++;
|
|
1244
|
-
if (consecutiveFailures < PROBE_FAIL_THRESHOLD) {
|
|
1245
|
-
writeLine(`\u26A0 Tunnel probe failed (${consecutiveFailures}/${PROBE_FAIL_THRESHOLD}) \u2014 will retry`);
|
|
1246
|
-
continue;
|
|
1247
|
-
}
|
|
1248
|
-
writeLine("\u26A0 Tunnel unreachable \u2014 reconnecting\u2026");
|
|
1249
|
-
consecutiveFailures = 0;
|
|
1250
|
-
try {
|
|
1251
|
-
if (tunnel && !tunnel.killed) tunnel.kill("SIGTERM");
|
|
1252
|
-
} catch {
|
|
1253
|
-
}
|
|
1254
|
-
try {
|
|
1255
|
-
const next = await openTunnel();
|
|
1256
|
-
tunnel = next.proc;
|
|
1257
|
-
tunnelUrl = next.url;
|
|
1258
|
-
await registerRuntime(tunnelUrl);
|
|
1259
|
-
respawnBackoffMs = 3e4;
|
|
1260
|
-
writeLine(`\u2705 Reconnected: ${tunnelUrl}`);
|
|
1261
|
-
} catch (error) {
|
|
1262
|
-
writeError(`reconnect failed (${error instanceof Error ? error.message : String(error)}); retrying in ${Math.round(respawnBackoffMs / 1e3)}s`);
|
|
1263
|
-
await new Promise((r) => setTimeout(r, respawnBackoffMs));
|
|
1264
|
-
respawnBackoffMs = Math.min(respawnBackoffMs * 2, MAX_BACKOFF_MS);
|
|
1265
|
-
}
|
|
1266
|
-
}
|
|
1267
|
-
})();
|
|
961
|
+
await connectViaRelay(instanceIds, localUrl, runnerToken, opts, Boolean(opts.force));
|
|
1268
962
|
await new Promise((resolvePromise) => {
|
|
1269
963
|
runner.on("exit", () => resolvePromise());
|
|
1270
964
|
});
|
|
1271
|
-
stopped = true;
|
|
1272
965
|
} catch (error) {
|
|
1273
966
|
shutdown();
|
|
1274
967
|
throw error;
|
|
@@ -1498,8 +1191,7 @@ async function stopRunnerProcesses() {
|
|
|
1498
1191
|
const patterns = [
|
|
1499
1192
|
"dist/browser-runner/index.js",
|
|
1500
1193
|
"browser-runner/src/index",
|
|
1501
|
-
"runner connect"
|
|
1502
|
-
"tunnel --url http://127.0.0.1"
|
|
1194
|
+
"runner connect"
|
|
1503
1195
|
];
|
|
1504
1196
|
let stopped = false;
|
|
1505
1197
|
for (const p of patterns) {
|
|
@@ -1511,7 +1203,7 @@ async function stopRunnerProcesses() {
|
|
|
1511
1203
|
}
|
|
1512
1204
|
return stopped;
|
|
1513
1205
|
}
|
|
1514
|
-
var upCommand = new Command7("up").description("Start the browser runner for all your agent instances").option("--headless", "Run browser in headless mode").option("--instance <id>", "Connect to a specific instance only").option("--
|
|
1206
|
+
var upCommand = new Command7("up").description("Start the browser runner for all your agent instances").option("--headless", "Run browser in headless mode").option("--instance <id>", "Connect to a specific instance only").option("--force", "Take over from another connected machine").action(async (opts) => {
|
|
1515
1207
|
const session = requireSession();
|
|
1516
1208
|
const state = {
|
|
1517
1209
|
user: session.user.login,
|
|
@@ -1558,8 +1250,6 @@ var upCommand = new Command7("up").description("Start the browser runner for all
|
|
|
1558
1250
|
const cliPath = process.argv[1];
|
|
1559
1251
|
const args = [cliPath, "runner", "connect", ...instances.map((i) => i.id)];
|
|
1560
1252
|
if (opts.headless) args.push("--headless");
|
|
1561
|
-
if (opts.tunnel === "named") args.push("--tunnel", "named");
|
|
1562
|
-
if (opts.tunnel === "ws") args.push("--tunnel", "ws");
|
|
1563
1253
|
if (opts.force) args.push("--force");
|
|
1564
1254
|
const child = spawn3(process.execPath, args, {
|
|
1565
1255
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -1573,14 +1263,6 @@ var upCommand = new Command7("up").description("Start the browser runner for all
|
|
|
1573
1263
|
if (!trimmed) continue;
|
|
1574
1264
|
logs.push(trimmed);
|
|
1575
1265
|
if (logs.length > 200) logs.shift();
|
|
1576
|
-
const tunnelMatch = trimmed.match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/i);
|
|
1577
|
-
if (tunnelMatch && !/^https:\/\/api\.trycloudflare\.com/i.test(tunnelMatch[0])) {
|
|
1578
|
-
state.tunnel = "online";
|
|
1579
|
-
state.tunnelUrl = tunnelMatch[0];
|
|
1580
|
-
state.lastEvent = "Tunnel created";
|
|
1581
|
-
printStatus(state);
|
|
1582
|
-
continue;
|
|
1583
|
-
}
|
|
1584
1266
|
if (trimmed.includes("Relay connected:")) {
|
|
1585
1267
|
state.tunnel = "online";
|
|
1586
1268
|
state.tunnelUrl = "WebSocket relay";
|
|
@@ -1588,7 +1270,7 @@ var upCommand = new Command7("up").description("Start the browser runner for all
|
|
|
1588
1270
|
printStatus(state);
|
|
1589
1271
|
continue;
|
|
1590
1272
|
}
|
|
1591
|
-
if (trimmed.includes("WebSocket relay
|
|
1273
|
+
if (trimmed.includes("WebSocket relay")) {
|
|
1592
1274
|
state.tunnel = "online";
|
|
1593
1275
|
state.tunnelUrl = "WebSocket relay";
|
|
1594
1276
|
state.registration = "registered";
|
|
@@ -1608,13 +1290,18 @@ var upCommand = new Command7("up").description("Start the browser runner for all
|
|
|
1608
1290
|
printStatus(state);
|
|
1609
1291
|
continue;
|
|
1610
1292
|
}
|
|
1293
|
+
if (trimmed.includes("Another machine")) {
|
|
1294
|
+
state.registration = "failed";
|
|
1295
|
+
state.lastEvent = trimmed.slice(0, 80);
|
|
1296
|
+
printStatus(state);
|
|
1297
|
+
continue;
|
|
1298
|
+
}
|
|
1611
1299
|
if (trimmed.includes("fetch failed")) {
|
|
1612
1300
|
state.registration = "failed";
|
|
1613
|
-
state.lastEvent = "PAGS registration failed
|
|
1301
|
+
state.lastEvent = "PAGS registration failed";
|
|
1614
1302
|
printStatus(state);
|
|
1615
1303
|
continue;
|
|
1616
1304
|
}
|
|
1617
|
-
if (trimmed.includes("INF ")) continue;
|
|
1618
1305
|
if (/error|Error|EADDRINUSE|ECONNREFUSED|failed/i.test(trimmed)) {
|
|
1619
1306
|
state.lastEvent = trimmed.slice(0, 80);
|
|
1620
1307
|
printStatus(state);
|
|
@@ -1629,7 +1316,7 @@ var upCommand = new Command7("up").description("Start the browser runner for all
|
|
|
1629
1316
|
if (code && code !== 0) {
|
|
1630
1317
|
state.runner = "error";
|
|
1631
1318
|
state.lastEvent = `Runner exited (code ${code})`;
|
|
1632
|
-
const recent = logs.slice(-5)
|
|
1319
|
+
const recent = logs.slice(-5);
|
|
1633
1320
|
if (recent.length) state.lastEvent += ": " + recent[recent.length - 1].slice(0, 60);
|
|
1634
1321
|
printStatus(state);
|
|
1635
1322
|
}
|
|
@@ -1663,14 +1350,9 @@ var upCommand = new Command7("up").description("Start the browser runner for all
|
|
|
1663
1350
|
if (key === "r") {
|
|
1664
1351
|
if (childDead) {
|
|
1665
1352
|
writeLine(" Restarting runner...");
|
|
1666
|
-
state.runner = "starting";
|
|
1667
|
-
state.tunnel = "offline";
|
|
1668
|
-
state.tunnelUrl = "";
|
|
1669
|
-
state.registration = "pending";
|
|
1670
|
-
state.lastEvent = "Restarting...";
|
|
1671
1353
|
const { execSync } = await import("child_process");
|
|
1672
1354
|
try {
|
|
1673
|
-
execSync(`${process.execPath} ${process.argv[1]} up${opts.headless ? " --headless" : ""}`, {
|
|
1355
|
+
execSync(`${process.execPath} ${process.argv[1]} up${opts.headless ? " --headless" : ""}${opts.force ? " --force" : ""}`, {
|
|
1674
1356
|
stdio: "inherit",
|
|
1675
1357
|
env: process.env
|
|
1676
1358
|
});
|
|
@@ -1682,7 +1364,7 @@ var upCommand = new Command7("up").description("Start the browser runner for all
|
|
|
1682
1364
|
}
|
|
1683
1365
|
}
|
|
1684
1366
|
});
|
|
1685
|
-
var downCommand = new Command7("down").description("Stop the browser runner and disconnect
|
|
1367
|
+
var downCommand = new Command7("down").description("Stop the browser runner and disconnect").action(async () => {
|
|
1686
1368
|
clearScreen();
|
|
1687
1369
|
printLogo(CLI_VERSION2);
|
|
1688
1370
|
if (process.platform === "win32") {
|