@proagentstore/cli 0.4.60 → 0.4.62
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/coding/engine-adapter.js +39 -1
- package/dist/browser-runner/coding/headless-state.js +50 -0
- package/dist/browser-runner/coding/headless.js +71 -50
- package/dist/browser-runner/coding/repo-clone-job.js +109 -0
- package/dist/browser-runner/coding/repo.js +48 -2
- package/dist/browser-runner/coding/runtime.js +30 -2
- package/dist/browser-runner/runner.js +34 -1
- package/dist/browser-runner/server.js +27 -0
- package/dist/browser-runner/task-types.js +2 -0
- package/dist/index.js +244 -45
- package/package.json +1 -1
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { stripAnsi } from "./transcript-lines.js";
|
|
1
2
|
export function engineInvocationModeFromAdapter(mode) {
|
|
2
3
|
return mode === "stream-json" ? "structured" : "raw";
|
|
3
4
|
}
|
|
@@ -5,7 +6,7 @@ export function structuredCapableEngine(clientType) {
|
|
|
5
6
|
return clientType === "claude" || clientType === "codex";
|
|
6
7
|
}
|
|
7
8
|
export function engineInvocationWarning(clientType, mode) {
|
|
8
|
-
if (mode !== "raw" || clientType
|
|
9
|
+
if (mode !== "raw" || !structuredCapableEngine(clientType))
|
|
9
10
|
return null;
|
|
10
11
|
return `running raw — structured not available on this machine's ${clientType} CLI`;
|
|
11
12
|
}
|
|
@@ -113,6 +114,31 @@ function buildCodexExecArgs(userArgs, turnText) {
|
|
|
113
114
|
args.push(turnText);
|
|
114
115
|
return args;
|
|
115
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* The only supported runner-owned Codex continuity form (#848).
|
|
119
|
+
*
|
|
120
|
+
* `exec resume` takes the thread id between the subcommand and the prompt, so it cannot use the
|
|
121
|
+
* normal preset-prefix-plus-final-prompt contract. Its write flag is deliberately not inherited
|
|
122
|
+
* from a fresh `exec`: resume accepts the bypass flag but does not accept `--sandbox <mode>`.
|
|
123
|
+
* This remains an opaque, machine-local optimisation until #693's platform timeline owns the
|
|
124
|
+
* conversation; it must never select a conversation with `--last`.
|
|
125
|
+
*/
|
|
126
|
+
export function buildCodexResumeArgs(userArgs, threadId, turnText) {
|
|
127
|
+
const extras = [];
|
|
128
|
+
for (let i = 1; i < userArgs.length; i++) {
|
|
129
|
+
const arg = userArgs[i];
|
|
130
|
+
if (arg === "--json" || arg === "--dangerously-bypass-approvals-and-sandbox")
|
|
131
|
+
continue;
|
|
132
|
+
if (arg === "--sandbox") {
|
|
133
|
+
i++;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (arg.startsWith("--sandbox="))
|
|
137
|
+
continue;
|
|
138
|
+
extras.push(arg);
|
|
139
|
+
}
|
|
140
|
+
return ["exec", "resume", threadId, "--json", "--dangerously-bypass-approvals-and-sandbox", ...extras, turnText];
|
|
141
|
+
}
|
|
116
142
|
function parseCodexLine(line) {
|
|
117
143
|
let ev;
|
|
118
144
|
try {
|
|
@@ -163,12 +189,24 @@ function parseCodexLine(line) {
|
|
|
163
189
|
return [{ kind: "tool_use", block, id, name: "Bash", input: { command } }];
|
|
164
190
|
return [];
|
|
165
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* An older Codex CLI exits before doing work when it does not know `--json`. Do not treat every
|
|
194
|
+
* plain line as a downgrade signal: current Codex can interleave malformed/tool stderr with valid
|
|
195
|
+
* JSONL, and retrying after that could run a real instruction twice.
|
|
196
|
+
*/
|
|
197
|
+
function codexRejectsJson(line) {
|
|
198
|
+
const plain = stripAnsi(line).toLowerCase();
|
|
199
|
+
if (!plain.includes("--json"))
|
|
200
|
+
return false;
|
|
201
|
+
return /(?:unexpected argument|unknown (?:argument|option)|unrecognized (?:argument|option)|invalid option)/.test(plain);
|
|
202
|
+
}
|
|
166
203
|
export const codexEngineAdapter = {
|
|
167
204
|
mode: "stream-json",
|
|
168
205
|
persistent: false,
|
|
169
206
|
buildLaunchArgs: (userArgs) => [...userArgs],
|
|
170
207
|
buildTurnArgs: buildCodexExecArgs,
|
|
171
208
|
parseLine: parseCodexLine,
|
|
209
|
+
rejectsStructuredOutput: codexRejectsJson,
|
|
172
210
|
};
|
|
173
211
|
export const genericRawEngineAdapter = {
|
|
174
212
|
mode: "raw",
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
/** Codex CLI emitted UUID-shaped `thread_id`s in the #730 proof; reject anything unsafe to argv. */
|
|
4
|
+
export function isCodexThreadId(value) {
|
|
5
|
+
return typeof value === "string" && /^[0-9a-f]{8}-(?:[0-9a-f]{4}-){3}[0-9a-f]{12}$/i.test(value);
|
|
6
|
+
}
|
|
7
|
+
function stateKey(id, engine) {
|
|
8
|
+
return engine === "codex" ? `codex:${id}` : id;
|
|
9
|
+
}
|
|
10
|
+
function loadFile(path) {
|
|
11
|
+
if (!path || !existsSync(path))
|
|
12
|
+
return {};
|
|
13
|
+
try {
|
|
14
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
15
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
16
|
+
return {};
|
|
17
|
+
return Object.fromEntries(Object.entries(parsed).flatMap(([key, value]) => (typeof value === "string" && value.trim() ? [[key, value]] : [])));
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return {};
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function readState(path, id, engine) {
|
|
24
|
+
return loadFile(path)[stateKey(id, engine)] ?? null;
|
|
25
|
+
}
|
|
26
|
+
export function readCodexState(path, id) {
|
|
27
|
+
const threadId = readState(path, id, "codex");
|
|
28
|
+
return isCodexThreadId(threadId) ? threadId : null;
|
|
29
|
+
}
|
|
30
|
+
export function writeState(path, id, sessionId, engine) {
|
|
31
|
+
if (!path)
|
|
32
|
+
return;
|
|
33
|
+
try {
|
|
34
|
+
const data = loadFile(path);
|
|
35
|
+
const key = stateKey(id, engine);
|
|
36
|
+
if (sessionId)
|
|
37
|
+
data[key] = sessionId;
|
|
38
|
+
else
|
|
39
|
+
delete data[key];
|
|
40
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
41
|
+
writeFileSync(path, JSON.stringify(data));
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
/* best-effort persistence */
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Default location for the resume-id store, under the repos base dir. */
|
|
48
|
+
export function defaultStatePath(reposBaseDir) {
|
|
49
|
+
return join(reposBaseDir, "headless-sessions.json");
|
|
50
|
+
}
|
|
@@ -6,12 +6,12 @@ import { renderToolResult, shortInput, stripAnsi } from "./transcript-lines.js";
|
|
|
6
6
|
import { authoredTurn, authorTag } from "./turn-author.js";
|
|
7
7
|
import { engineSpawnEnv, mergeEnv } from "./engine-env.js";
|
|
8
8
|
import { ghGuardStatus } from "./gh-guard.js";
|
|
9
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
10
|
-
import { dirname, join } from "node:path";
|
|
11
9
|
import { handlerFor } from "./handlers.js";
|
|
12
10
|
import { resolveEngineAuth } from "./engine-auth.js";
|
|
13
|
-
import { engineAdapterFor, engineInvocationModeFromAdapter, engineInvocationWarning } from "./engine-adapter.js";
|
|
11
|
+
import { buildCodexResumeArgs, engineAdapterFor, engineInvocationModeFromAdapter, engineInvocationWarning, genericRawEngineAdapter } from "./engine-adapter.js";
|
|
12
|
+
import { isCodexThreadId, readCodexState, readState, writeState } from "./headless-state.js";
|
|
14
13
|
export { buildClaudeArgs } from "./engine-adapter.js";
|
|
14
|
+
export { defaultStatePath } from "./headless-state.js";
|
|
15
15
|
/**
|
|
16
16
|
* How many un-drained usage records a session holds (#267).
|
|
17
17
|
*
|
|
@@ -62,6 +62,8 @@ export class HeadlessSession {
|
|
|
62
62
|
run = "idle";
|
|
63
63
|
/** Claude Code's own session id (from the init event) — used to --resume. */
|
|
64
64
|
claudeSessionId = null;
|
|
65
|
+
/** Codex's thread id from `thread.started` — used only by the explicit-ID resume path (#848). */
|
|
66
|
+
codexThreadId = null;
|
|
65
67
|
/**
|
|
66
68
|
* The cloud's context brief, until the first turn spends it (ADR 0005, #693). Null once
|
|
67
69
|
* delivered, and null from the start when the engine resumed its own conversation.
|
|
@@ -96,6 +98,12 @@ export class HeadlessSession {
|
|
|
96
98
|
* carry a previous turn's line.
|
|
97
99
|
*/
|
|
98
100
|
turnLastLine = "";
|
|
101
|
+
/** True once this session has retried an old Codex CLI without its unsupported `--json` flag. */
|
|
102
|
+
fellBackToRaw = false;
|
|
103
|
+
/** Structured events observed in the current one-shot process. */
|
|
104
|
+
sawStructuredEvent = false;
|
|
105
|
+
/** A Codex CLI explicitly rejected the `--json` flag in the current one-shot process. */
|
|
106
|
+
structuredOutputRejected = false;
|
|
99
107
|
/** Measured engine spend not yet handed to the cloud (#267). Drained by {@link takeUsage}. */
|
|
100
108
|
pendingUsage = [];
|
|
101
109
|
/**
|
|
@@ -196,11 +204,13 @@ export class HeadlessSession {
|
|
|
196
204
|
* clean, so a cloud that announced "resumed where we left off" on its own intent would be
|
|
197
205
|
* telling most of the fleet's users the opposite of what happened.
|
|
198
206
|
*
|
|
199
|
-
* False for
|
|
200
|
-
*
|
|
207
|
+
* False for raw engines. Claude has a persistent protocol-level resume; supported structured
|
|
208
|
+
* `codex exec` uses the separately proven explicit-ID one-shot path (#848), only as the
|
|
209
|
+
* machine-local stopgap before #693's platform-owned timeline becomes authoritative.
|
|
201
210
|
*/
|
|
202
211
|
get resumedConversation() {
|
|
203
|
-
return this.config.clientType === "claude" && this.mode === "stream-json" && this.claudeSessionId !== null
|
|
212
|
+
return ((this.config.clientType === "claude" && this.mode === "stream-json" && this.claudeSessionId !== null) ||
|
|
213
|
+
(this.config.clientType === "codex" && this.mode === "stream-json" && this.codexThreadId !== null));
|
|
204
214
|
}
|
|
205
215
|
/**
|
|
206
216
|
* Did this engine come up cold AND with a brief to lead its first turn (ADR 0005, #693)?
|
|
@@ -216,11 +226,6 @@ export class HeadlessSession {
|
|
|
216
226
|
constructor(config) {
|
|
217
227
|
this.config = config;
|
|
218
228
|
this.engineLabel = `${config.clientType}:${config.id}`;
|
|
219
|
-
// Our own key first, the cloud's nominated predecessor second. See `resumeFrom`.
|
|
220
|
-
this.claudeSessionId =
|
|
221
|
-
config.clientType === "claude"
|
|
222
|
-
? readState(config.statePath, config.id) ?? (config.resumeFrom ? readState(config.statePath, config.resumeFrom) : null)
|
|
223
|
-
: null;
|
|
224
229
|
const { bin, args } = parseCommand(config.command);
|
|
225
230
|
// When no explicit command is configured, fall back to THIS engine's default
|
|
226
231
|
// command (codex/gemini/grok/…) — not a hard-coded "claude", which would drive a
|
|
@@ -233,6 +238,16 @@ export class HeadlessSession {
|
|
|
233
238
|
this.cmdArgs = bin ? args : fallback.args;
|
|
234
239
|
this.adapter = engineAdapterFor(config.clientType, this.cmdArgs);
|
|
235
240
|
this.mode = this.adapter.mode;
|
|
241
|
+
// Our own key first, the cloud's nominated predecessor second. See `resumeFrom`. Codex uses
|
|
242
|
+
// a distinct key namespace: a vendor thread id is not interchangeable with Claude's id.
|
|
243
|
+
this.claudeSessionId =
|
|
244
|
+
config.clientType === "claude"
|
|
245
|
+
? readState(config.statePath, config.id, "claude") ?? (config.resumeFrom ? readState(config.statePath, config.resumeFrom, "claude") : null)
|
|
246
|
+
: null;
|
|
247
|
+
this.codexThreadId =
|
|
248
|
+
config.clientType === "codex" && this.mode === "stream-json"
|
|
249
|
+
? readCodexState(config.statePath, config.id) ?? (config.resumeFrom ? readCodexState(config.statePath, config.resumeFrom) : null)
|
|
250
|
+
: null;
|
|
236
251
|
// AFTER both lines above, because `resumedConversation` reads them: the brief is the fallback,
|
|
237
252
|
// so an engine that found its own conversation drops it unread rather than being handed a
|
|
238
253
|
// summary of the conversation it is already in.
|
|
@@ -409,7 +424,7 @@ export class HeadlessSession {
|
|
|
409
424
|
// start is a clean session rather than looping on a dead id.
|
|
410
425
|
if (code && code !== 0 && this.claudeSessionId) {
|
|
411
426
|
this.claudeSessionId = null;
|
|
412
|
-
writeState(this.config.statePath, this.config.id, null);
|
|
427
|
+
writeState(this.config.statePath, this.config.id, null, "claude");
|
|
413
428
|
}
|
|
414
429
|
});
|
|
415
430
|
}
|
|
@@ -473,7 +488,13 @@ export class HeadlessSession {
|
|
|
473
488
|
// Arm the per-turn line capture BEFORE the spawn, so a report can only ever carry a line
|
|
474
489
|
// this turn produced (#545).
|
|
475
490
|
this.turnLastLine = "";
|
|
476
|
-
|
|
491
|
+
this.sawStructuredEvent = false;
|
|
492
|
+
this.structuredOutputRejected = false;
|
|
493
|
+
const resumedCodexThreadId = this.codexResumeThreadId;
|
|
494
|
+
const turnArgs = resumedCodexThreadId
|
|
495
|
+
? buildCodexResumeArgs(this.cmdArgs, resumedCodexThreadId, text)
|
|
496
|
+
: this.adapter.buildTurnArgs(this.cmdArgs, text);
|
|
497
|
+
const proc = spawn(this.cmdBin, turnArgs, {
|
|
477
498
|
cwd: this.config.workDir,
|
|
478
499
|
env: this.spawnEnv,
|
|
479
500
|
stdio: ["ignore", "pipe", "pipe"],
|
|
@@ -541,6 +562,25 @@ export class HeadlessSession {
|
|
|
541
562
|
// kill-tmux.
|
|
542
563
|
if (this.proc !== proc)
|
|
543
564
|
return;
|
|
565
|
+
// A runner cannot know the Codex version before spawning it. An old binary reliably
|
|
566
|
+
// rejects the flag before it executes the prompt, so retrying only this precise failure
|
|
567
|
+
// is safe. Do NOT downgrade merely because a line failed JSON parsing: current Codex can
|
|
568
|
+
// interleave tool/MCP stderr with valid JSONL, and a second run could repeat real work.
|
|
569
|
+
if (!this.fellBackToRaw && code && !this.sawStructuredEvent && this.structuredOutputRejected) {
|
|
570
|
+
this.fellBackToRaw = true;
|
|
571
|
+
this.adapter = genericRawEngineAdapter;
|
|
572
|
+
this.mode = this.adapter.mode;
|
|
573
|
+
this.proc = null;
|
|
574
|
+
this.push(`[${this.config.clientType} CLI rejected --json; retrying this turn with raw output]`);
|
|
575
|
+
this.runOneShot(text);
|
|
576
|
+
return;
|
|
577
|
+
}
|
|
578
|
+
// A rejected/expired explicit ID is not retried as the same turn: the CLI may have started
|
|
579
|
+
// work before failing. Drop only that known-bad local key so the NEXT turn is safely fresh.
|
|
580
|
+
if (code && resumedCodexThreadId && this.codexThreadId === resumedCodexThreadId) {
|
|
581
|
+
this.codexThreadId = null;
|
|
582
|
+
writeState(this.config.statePath, this.config.id, null, "codex");
|
|
583
|
+
}
|
|
544
584
|
// THE EXIT CODE STOPS BEING ONLY PROSE HERE (#545). Recorded after the staleness guard
|
|
545
585
|
// on purpose: a turn aborted by its successor (see the kill above) must not overwrite
|
|
546
586
|
// the report of the turn that replaced it — the loser's outcome is about a turn nobody
|
|
@@ -599,8 +639,12 @@ export class HeadlessSession {
|
|
|
599
639
|
this.buf = this.buf.slice(nl + 1);
|
|
600
640
|
if (!line)
|
|
601
641
|
continue;
|
|
602
|
-
if (this.mode === "stream-json")
|
|
603
|
-
this.handle(line)
|
|
642
|
+
if (this.mode === "stream-json") {
|
|
643
|
+
if (this.handle(line))
|
|
644
|
+
this.sawStructuredEvent = true;
|
|
645
|
+
else if (this.adapter.rejectsStructuredOutput?.(line))
|
|
646
|
+
this.structuredOutputRejected = true;
|
|
647
|
+
}
|
|
604
648
|
else
|
|
605
649
|
this.pushRaw(line); // raw engine — the line IS the terminal output
|
|
606
650
|
}
|
|
@@ -617,6 +661,10 @@ export class HeadlessSession {
|
|
|
617
661
|
this.buf = "";
|
|
618
662
|
}
|
|
619
663
|
}
|
|
664
|
+
/** A stored id is used only for the supported structured `codex exec` shape. */
|
|
665
|
+
get codexResumeThreadId() {
|
|
666
|
+
return this.config.clientType === "codex" && this.mode === "stream-json" && isCodexThreadId(this.codexThreadId) ? this.codexThreadId : null;
|
|
667
|
+
}
|
|
620
668
|
/** Raw-engine stdout: strip ANSI control codes and append to the transcript. */
|
|
621
669
|
pushRaw(line) {
|
|
622
670
|
const clean = stripAnsi(line);
|
|
@@ -630,12 +678,17 @@ export class HeadlessSession {
|
|
|
630
678
|
this.transcript = this.transcript.slice(-3000);
|
|
631
679
|
}
|
|
632
680
|
handle(line) {
|
|
633
|
-
|
|
681
|
+
const events = this.adapter.parseLine(line);
|
|
682
|
+
for (const ev of events) {
|
|
634
683
|
switch (ev.kind) {
|
|
635
684
|
case "session":
|
|
636
685
|
if (this.config.clientType === "claude") {
|
|
637
686
|
this.claudeSessionId = ev.sessionId;
|
|
638
|
-
writeState(this.config.statePath, this.config.id, ev.sessionId);
|
|
687
|
+
writeState(this.config.statePath, this.config.id, ev.sessionId, "claude");
|
|
688
|
+
}
|
|
689
|
+
else if (this.config.clientType === "codex" && isCodexThreadId(ev.sessionId)) {
|
|
690
|
+
this.codexThreadId = ev.sessionId;
|
|
691
|
+
writeState(this.config.statePath, this.config.id, ev.sessionId, "codex");
|
|
639
692
|
}
|
|
640
693
|
break;
|
|
641
694
|
case "assistant_text":
|
|
@@ -695,6 +748,7 @@ export class HeadlessSession {
|
|
|
695
748
|
// character bound that matters is `MAX_PANE` in runtime.ts, applied on the way out.
|
|
696
749
|
if (this.transcript.length > 4000)
|
|
697
750
|
this.transcript = this.transcript.slice(-3000);
|
|
751
|
+
return events.length > 0;
|
|
698
752
|
}
|
|
699
753
|
push(line) {
|
|
700
754
|
this.transcript.push(line);
|
|
@@ -842,36 +896,3 @@ export function parseCommand(command) {
|
|
|
842
896
|
}
|
|
843
897
|
return { bin: tokens[0] ?? "", args: tokens.slice(1) };
|
|
844
898
|
}
|
|
845
|
-
function loadFile(path) {
|
|
846
|
-
if (!path || !existsSync(path))
|
|
847
|
-
return {};
|
|
848
|
-
try {
|
|
849
|
-
return JSON.parse(readFileSync(path, "utf8"));
|
|
850
|
-
}
|
|
851
|
-
catch {
|
|
852
|
-
return {};
|
|
853
|
-
}
|
|
854
|
-
}
|
|
855
|
-
function readState(path, id) {
|
|
856
|
-
return loadFile(path)[id] ?? null;
|
|
857
|
-
}
|
|
858
|
-
function writeState(path, id, claudeSessionId) {
|
|
859
|
-
if (!path)
|
|
860
|
-
return;
|
|
861
|
-
try {
|
|
862
|
-
const data = loadFile(path);
|
|
863
|
-
if (claudeSessionId)
|
|
864
|
-
data[id] = claudeSessionId;
|
|
865
|
-
else
|
|
866
|
-
delete data[id];
|
|
867
|
-
mkdirSync(dirname(path), { recursive: true });
|
|
868
|
-
writeFileSync(path, JSON.stringify(data));
|
|
869
|
-
}
|
|
870
|
-
catch {
|
|
871
|
-
/* best-effort persistence */
|
|
872
|
-
}
|
|
873
|
-
}
|
|
874
|
-
/** Default location for the resume-id store, under the repos base dir. */
|
|
875
|
-
export function defaultStatePath(reposBaseDir) {
|
|
876
|
-
return join(reposBaseDir, "headless-sessions.json");
|
|
877
|
-
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cold-start clones as BACKGROUND jobs, over https or SSH (#858).
|
|
3
|
+
*
|
|
4
|
+
* #857's clone was one synchronous `git clone` inside a relay command: a repository that took longer
|
|
5
|
+
* than the relay's two-minute ceiling failed the call while git kept going, and `execFileSync` held the
|
|
6
|
+
* runner's event loop — its relay socket included — for as long as git ran. Here the clone is a job:
|
|
7
|
+
* started, answered at once, and run with an async `git` so the runner keeps serving everything else.
|
|
8
|
+
* The cloud reads the job back until it is `done` or `failed`; one job per folder, so asking again
|
|
9
|
+
* joins the clone in flight instead of starting a second one.
|
|
10
|
+
*
|
|
11
|
+
* Which URL: the machine's own credentials decide. https first (its credential helper — what
|
|
12
|
+
* `gh auth login` configures). If that is refused and the machine holds an SSH identity for github.com,
|
|
13
|
+
* `git@github.com:<owner>/<repo>.git` next — a machine that reaches GitHub only through a key used to
|
|
14
|
+
* fail a private repository with "could not read Username". `protocol` pins one or the other.
|
|
15
|
+
*
|
|
16
|
+
* The folder guards are {@link ensureRepo}'s owner-folder rules: an absent or empty folder is cloned
|
|
17
|
+
* into, a folder with anything in it never is, an empty folder inside another checkout is refused.
|
|
18
|
+
*/
|
|
19
|
+
import { execFile } from "node:child_process";
|
|
20
|
+
import { rmSync } from "node:fs";
|
|
21
|
+
import { promisify } from "node:util";
|
|
22
|
+
import { checkWorkdir, probeGitSshIdentity } from "./repo.js";
|
|
23
|
+
const run = promisify(execFile);
|
|
24
|
+
export const cloneUrlFor = (slug, via) => (via === "ssh" ? `git@github.com:${slug}.git` : `https://github.com/${slug}.git`);
|
|
25
|
+
/**
|
|
26
|
+
* Run a job to its end. Never rejects: every outcome is written onto `job`.
|
|
27
|
+
*
|
|
28
|
+
* `auto` tries https, then SSH only when https was refused AND the machine has an SSH identity — the
|
|
29
|
+
* probe runs only then, because it is a network round trip a clone that already worked does not need.
|
|
30
|
+
*/
|
|
31
|
+
export async function runCloneJob(job, protocol, deps) {
|
|
32
|
+
const order = protocol === "ssh" ? ["ssh"] : protocol === "https" ? ["https"] : ["https", "ssh"];
|
|
33
|
+
for (const via of order) {
|
|
34
|
+
if (via === "ssh" && protocol === "auto") {
|
|
35
|
+
const identity = deps.sshIdentity();
|
|
36
|
+
if (!identity) {
|
|
37
|
+
job.attempts.push("ssh: not tried — this machine has no SSH key that github.com accepts");
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
await deps.clone(job.path, cloneUrlFor(job.slug, via));
|
|
43
|
+
return Object.assign(job, { state: "done", via, finishedAt: deps.now() });
|
|
44
|
+
}
|
|
45
|
+
catch (e) {
|
|
46
|
+
job.attempts.push(`${via}: ${e instanceof Error ? e.message : String(e)}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return Object.assign(job, {
|
|
50
|
+
state: "failed",
|
|
51
|
+
finishedAt: deps.now(),
|
|
52
|
+
error: `${job.attempts.join(" | ")} — the machine clones with its OWN git credentials: sign in over https there (\`gh auth login\`), or add an SSH key that github.com accepts.`,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Clone `url` into the owner's folder `dir` — async, never prompting, with the owner-folder guards.
|
|
57
|
+
* Rejects with git's reason (scrubbed of nothing: these URLs carry no token).
|
|
58
|
+
*/
|
|
59
|
+
export async function cloneIntoOwnFolder(dir, url) {
|
|
60
|
+
const at = checkWorkdir(dir);
|
|
61
|
+
if (at.exists && at.isDirectory && at.entryCount > 0)
|
|
62
|
+
throw new Error(`"${dir}" is not empty — never cloned into`);
|
|
63
|
+
if (at.exists && !at.isDirectory)
|
|
64
|
+
throw new Error(`"${dir}" is a file, not a folder`);
|
|
65
|
+
if (at.exists && at.insideWorkTree)
|
|
66
|
+
throw new Error(`"${dir}" is an empty folder inside another git checkout — not cloning a second repository into it`);
|
|
67
|
+
if (at.exists)
|
|
68
|
+
rmSync(dir, { recursive: true, force: true });
|
|
69
|
+
try {
|
|
70
|
+
await run("git", ["clone", url, dir], {
|
|
71
|
+
timeout: 60 * 60_000,
|
|
72
|
+
// No prompt, ever: a refusal must fail with git's reason, not wait on input nobody will give.
|
|
73
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_SSH_COMMAND: "ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new" },
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
const stderr = String(e.stderr ?? "").trim() || (e instanceof Error ? e.message : "git clone failed");
|
|
78
|
+
throw new Error(stderr.slice(0, 300));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
/** The production deps: real git, the real SSH probe. */
|
|
82
|
+
export const liveCloneDeps = {
|
|
83
|
+
clone: cloneIntoOwnFolder,
|
|
84
|
+
sshIdentity: () => probeGitSshIdentity("github.com").identity,
|
|
85
|
+
now: () => Date.now(),
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* The runner's clone jobs, one per folder. `start` joins a clone in flight rather than starting a
|
|
89
|
+
* second; a finished or failed job is replaced by a new start, which is how a failed clone is retried.
|
|
90
|
+
*/
|
|
91
|
+
export class CloneJobs {
|
|
92
|
+
deps;
|
|
93
|
+
jobs = new Map();
|
|
94
|
+
constructor(deps = liveCloneDeps) {
|
|
95
|
+
this.deps = deps;
|
|
96
|
+
}
|
|
97
|
+
start(path, slug, protocol = "auto") {
|
|
98
|
+
const current = this.jobs.get(path);
|
|
99
|
+
if (current?.state === "cloning")
|
|
100
|
+
return current;
|
|
101
|
+
const job = { path, slug, state: "cloning", attempts: [], startedAt: this.deps.now() };
|
|
102
|
+
this.jobs.set(path, job);
|
|
103
|
+
void runCloneJob(job, protocol, this.deps);
|
|
104
|
+
return job;
|
|
105
|
+
}
|
|
106
|
+
status(path) {
|
|
107
|
+
return this.jobs.get(path) ?? { path, state: "none" };
|
|
108
|
+
}
|
|
109
|
+
}
|
|
@@ -115,6 +115,23 @@ export function probeGitSshIdentity(host) {
|
|
|
115
115
|
const isDeployKey = identity === null ? null : identity.includes("/");
|
|
116
116
|
return { checked: true, host, identity, isDeployKey, raw };
|
|
117
117
|
}
|
|
118
|
+
/**
|
|
119
|
+
* Clone `cloneUrl` into the owner's folder `dir` — only when there is nothing there yet (#857).
|
|
120
|
+
*
|
|
121
|
+
* The cold-start step `coding_repo_add … clone:true` asks for. It is {@link ensureRepo}'s owner-folder
|
|
122
|
+
* clone and inherits every one of its guards: an existing checkout is left untouched, a folder with
|
|
123
|
+
* anything in it is never cloned into, an empty folder inside another checkout is refused, and the
|
|
124
|
+
* clone is a full one whose `origin` is the plain URL. Authentication is the MACHINE's own — its git
|
|
125
|
+
* credential helper for https — and a repository it cannot read fails with git's reason.
|
|
126
|
+
*
|
|
127
|
+
* `cloned` says whether this call cloned; false means the folder was already there and was not touched.
|
|
128
|
+
*/
|
|
129
|
+
export function cloneIntoWorkdir(dir, cloneUrl) {
|
|
130
|
+
const before = checkWorkdir(dir);
|
|
131
|
+
const empty = !before.exists || (before.isDirectory && before.entryCount === 0);
|
|
132
|
+
ensureRepo(dir, { cloneUrl, ownFolder: true });
|
|
133
|
+
return { cloned: empty, path: dir };
|
|
134
|
+
}
|
|
118
135
|
/**
|
|
119
136
|
* Ensure a repo is present at `dir`, cloning it from `cloneUrl` if not. Idempotent
|
|
120
137
|
* — an existing checkout is left alone (no clobber). For private repos the cloud
|
|
@@ -126,6 +143,14 @@ export function probeGitSshIdentity(host) {
|
|
|
126
143
|
* `x-access-token` — the value this function used to hardcode — so an older cloud that
|
|
127
144
|
* sends only `token` behaves exactly as before.
|
|
128
145
|
*
|
|
146
|
+
* `ownFolder` marks a path the OWNER configured (a local checkout) rather than a managed dir
|
|
147
|
+
* (#828). Such a path is cloned into only when there is nothing there yet — absent, or an empty
|
|
148
|
+
* folder that is not inside another checkout — which is the normal state of an unpinned
|
|
149
|
+
* instance landing on a machine that has never cloned this repo. A folder with anything in it
|
|
150
|
+
* is run in exactly as it is, the behaviour a local path always had. The clone is a full one
|
|
151
|
+
* (the owner's own checkout must be able to rebase and read history), and its `origin` is left
|
|
152
|
+
* without the short-lived token, which would otherwise break every later push once it expired.
|
|
153
|
+
*
|
|
129
154
|
* Returns the absolute working directory. Throws on clone failure so the caller
|
|
130
155
|
* can surface it (a session can't start without its repo).
|
|
131
156
|
*/
|
|
@@ -147,15 +172,36 @@ export function ensureRepo(dir, opts = {}) {
|
|
|
147
172
|
if (existsSync(dir)) {
|
|
148
173
|
const entries = readdirSync(dir);
|
|
149
174
|
if (entries.length > 0) {
|
|
175
|
+
// The owner's own folder (a monorepo subfolder, a plain project) — run in it untouched.
|
|
176
|
+
if (opts.ownFolder)
|
|
177
|
+
return dir;
|
|
150
178
|
throw new Error(`Refusing to clone into non-empty directory "${dir}" (no .git found) — move it aside or point at an empty path.`);
|
|
151
179
|
}
|
|
180
|
+
if (opts.ownFolder && checkWorkdir(dir).insideWorkTree) {
|
|
181
|
+
throw new Error(`"${dir}" is an empty folder inside another git checkout — not cloning a second repository into it. Point this repo at the checkout itself, or remove the folder.`);
|
|
182
|
+
}
|
|
152
183
|
rmSync(dir, { recursive: true, force: true });
|
|
153
184
|
}
|
|
154
185
|
const url = authenticatedCloneUrl(opts.cloneUrl, opts.token, opts.tokenUsername);
|
|
155
|
-
const args = ["clone"
|
|
186
|
+
const args = ["clone"];
|
|
187
|
+
if (!opts.ownFolder)
|
|
188
|
+
args.push("--depth", "1");
|
|
156
189
|
if (opts.branch)
|
|
157
190
|
args.push("--branch", opts.branch);
|
|
158
191
|
args.push(url, dir);
|
|
159
|
-
|
|
192
|
+
try {
|
|
193
|
+
// No prompt, ever (#857): a clone the machine's credentials cannot authorise must FAIL, with git's
|
|
194
|
+
// own reason, rather than wait on a username prompt no one will ever see until the timeout.
|
|
195
|
+
execFileSync("git", args, { stdio: "pipe", timeout: 180_000, env: { ...process.env, GIT_TERMINAL_PROMPT: "0" } });
|
|
196
|
+
if (opts.ownFolder && url !== opts.cloneUrl)
|
|
197
|
+
execFileSync("git", ["remote", "set-url", "origin", opts.cloneUrl], { cwd: dir, stdio: "pipe" });
|
|
198
|
+
}
|
|
199
|
+
catch (e) {
|
|
200
|
+
// `e.message` carries the whole command line, token included — the reason git's own
|
|
201
|
+
// stderr is used instead, and the token scrubbed from that too in case git echoed the URL.
|
|
202
|
+
const stderr = String(e.stderr ?? "").trim() || "git clone failed";
|
|
203
|
+
const why = opts.token ? stderr.split(opts.token).join("***") : stderr;
|
|
204
|
+
throw new Error(`Could not clone ${opts.cloneUrl} into "${dir}": ${why.slice(0, 400)}`);
|
|
205
|
+
}
|
|
160
206
|
return dir;
|
|
161
207
|
}
|
|
@@ -4,13 +4,16 @@ import { RunnerInputError } from "../errors.js";
|
|
|
4
4
|
import { defaultStatePath, HeadlessSession } from "./headless.js";
|
|
5
5
|
import { InspectError, readGitRemoteOrigin, readRepoFile, repoSearch, repoSync, repoTree, runRepoGit } from "./inspect.js";
|
|
6
6
|
import { fastForwardRepo, switchRepoBranch } from "./repo-write.js";
|
|
7
|
-
import { checkWorkdir, ensureRepo, sanitizeSessionName } from "./repo.js";
|
|
7
|
+
import { checkWorkdir, cloneIntoWorkdir, ensureRepo, sanitizeSessionName } from "./repo.js";
|
|
8
|
+
import { CloneJobs } from "./repo-clone-job.js";
|
|
8
9
|
import { asTurnAuthor } from "./turn-author.js";
|
|
9
10
|
/** Hard cap on a pane returned to the brain/console (matches the worker MAX_PANE_CHARS). */
|
|
10
11
|
const MAX_PANE = 64 * 1024;
|
|
11
12
|
export class CodingRuntime {
|
|
12
13
|
reposBaseDir;
|
|
13
14
|
sessions = new Map();
|
|
15
|
+
/** Background cold-start clones, one per folder (#858). */
|
|
16
|
+
cloneJobs = new CloneJobs();
|
|
14
17
|
/**
|
|
15
18
|
* Active human handoffs keyed by session id. `resolved` flips when the human
|
|
16
19
|
* finishes (console "Resume" / submits a value); the brain workflow polls
|
|
@@ -111,6 +114,28 @@ export class CodingRuntime {
|
|
|
111
114
|
checkRepo(input) {
|
|
112
115
|
return checkWorkdir(this.resolveWorkDir(input));
|
|
113
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* Start — or join — a background clone of GitHub `slug` into an absent or empty owner folder (#858).
|
|
119
|
+
* Answers at once with the job; `cloneStatus` reads it back. See `repo-clone-job.ts`.
|
|
120
|
+
*/
|
|
121
|
+
startClone(input) {
|
|
122
|
+
if (!input.workDir || !input.slug || !/^[\w.-]+\/[\w.-]+$/.test(input.slug))
|
|
123
|
+
throw new InspectError("workDir and an owner/repo slug are required");
|
|
124
|
+
const protocol = input.protocol === "https" || input.protocol === "ssh" ? input.protocol : "auto";
|
|
125
|
+
return this.cloneJobs.start(this.resolveWorkDir({ workDir: input.workDir }), input.slug, protocol);
|
|
126
|
+
}
|
|
127
|
+
/** The background clone for this folder, or `state: "none"` (#858). */
|
|
128
|
+
cloneStatus(input) {
|
|
129
|
+
if (!input.workDir)
|
|
130
|
+
throw new InspectError("workDir is required");
|
|
131
|
+
return this.cloneJobs.status(this.resolveWorkDir({ workDir: input.workDir }));
|
|
132
|
+
}
|
|
133
|
+
/** Clone a repository into an absent or empty owner folder (#857) — see `cloneIntoWorkdir`. */
|
|
134
|
+
cloneRepo(input) {
|
|
135
|
+
if (!input.workDir || !input.cloneUrl)
|
|
136
|
+
throw new InspectError("workDir and cloneUrl are required");
|
|
137
|
+
return cloneIntoWorkdir(this.resolveWorkDir({ workDir: input.workDir }), input.cloneUrl);
|
|
138
|
+
}
|
|
114
139
|
static taskTypes() {
|
|
115
140
|
return ["coding.session"];
|
|
116
141
|
}
|
|
@@ -121,10 +146,11 @@ export class CodingRuntime {
|
|
|
121
146
|
// Resolve the working dir and ensure the repo is present (clone on first
|
|
122
147
|
// start). A user-supplied local path may use ~ — expand it; otherwise
|
|
123
148
|
// clone into a managed dir. Without this the CLI would launch nowhere.
|
|
149
|
+
// A local path is cloned into only when absent or empty (#828).
|
|
124
150
|
const workDir = input.workDir
|
|
125
151
|
? resolve(input.workDir.replace(/^~(?=$|\/)/, homedir()))
|
|
126
152
|
: join(this.reposBaseDir, sanitizeSessionName(input.repoId));
|
|
127
|
-
ensureRepo(workDir, { cloneUrl: input.cloneUrl, branch: input.branch, token: input.token, tokenUsername: input.tokenUsername });
|
|
153
|
+
ensureRepo(workDir, { cloneUrl: input.cloneUrl ?? input.emptyCheckoutCloneUrl, branch: input.branch, token: input.token, tokenUsername: input.tokenUsername, ownFolder: Boolean(input.workDir) });
|
|
128
154
|
session = new HeadlessSession({
|
|
129
155
|
id: input.sessionId,
|
|
130
156
|
workDir,
|
|
@@ -254,6 +280,8 @@ export class CodingRuntime {
|
|
|
254
280
|
sessionId,
|
|
255
281
|
alive: s.alive,
|
|
256
282
|
engineLabel: s.engineLabel,
|
|
283
|
+
// Whether an engine is mid-turn — what `runner_update` waits on before restarting (#859).
|
|
284
|
+
runState: s.runState(),
|
|
257
285
|
}));
|
|
258
286
|
}
|
|
259
287
|
/** Rich diagnostics for every tracked session — the console's transparency view. */
|
|
@@ -94,7 +94,7 @@ export class LocalRunner {
|
|
|
94
94
|
runtimePlane: "pags",
|
|
95
95
|
runnerRole: "tool-executor",
|
|
96
96
|
capabilities: [...CAPABILITIES, ...CodingRuntime.capabilities()],
|
|
97
|
-
taskTypes: ["echo", "browser.open", "job.apply_agent", ...CodingRuntime.taskTypes()],
|
|
97
|
+
taskTypes: ["echo", "browser.open", "job.apply_agent", "site_builder_runtime", ...CodingRuntime.taskTypes()],
|
|
98
98
|
approvalRequiredFor: [...APPROVAL_REQUIRED_TASKS],
|
|
99
99
|
};
|
|
100
100
|
}
|
|
@@ -187,6 +187,39 @@ export class LocalRunner {
|
|
|
187
187
|
void this.endTakeover(id).catch(() => undefined);
|
|
188
188
|
return task;
|
|
189
189
|
}
|
|
190
|
+
/**
|
|
191
|
+
* Receive a bounded FWS capture manifest from the PAGS broker. The pixels remain behind the
|
|
192
|
+
* job-scoped signed URLs; keeping the manifest on the durable local task lets Claude/Codex
|
|
193
|
+
* inspect both layouts after a relay reconnect without ever receiving FWS OAuth material.
|
|
194
|
+
*/
|
|
195
|
+
appendCaptureArtifacts(id, artifacts) {
|
|
196
|
+
const task = this.requireTask(id);
|
|
197
|
+
if (task.type !== "site_builder_runtime")
|
|
198
|
+
throw new RunnerInputError("Capture artifacts are only valid for Website Builder tasks");
|
|
199
|
+
if (!Array.isArray(artifacts) || artifacts.length < 1 || artifacts.length > 2)
|
|
200
|
+
throw new RunnerInputError("Expected one or two capture artifacts");
|
|
201
|
+
const valid = artifacts.map((item) => {
|
|
202
|
+
if (!item || typeof item !== "object" || Array.isArray(item))
|
|
203
|
+
throw new RunnerInputError("Invalid capture artifact");
|
|
204
|
+
const a = item;
|
|
205
|
+
if (typeof a.id !== "string" || !/^[a-f0-9]{64}$/.test(a.id) || (a.device !== "desktop" && a.device !== "mobile") ||
|
|
206
|
+
typeof a.contentType !== "string" || typeof a.bytes !== "number" || !Number.isInteger(a.bytes) || a.bytes <= 0 || a.bytes > 4 * 1024 * 1024 ||
|
|
207
|
+
typeof a.url !== "string" || a.url.length > 4_000 || !a.url.startsWith("https://api.proagentstore.online/")) {
|
|
208
|
+
throw new RunnerInputError("Invalid capture artifact");
|
|
209
|
+
}
|
|
210
|
+
return a;
|
|
211
|
+
});
|
|
212
|
+
const prior = Array.isArray(task.input.captureArtifacts) ? task.input.captureArtifacts.filter((item) => !!item && typeof item === "object" && !Array.isArray(item)) : [];
|
|
213
|
+
const merged = new Map();
|
|
214
|
+
for (const artifact of [...prior, ...valid])
|
|
215
|
+
if (typeof artifact.id === "string")
|
|
216
|
+
merged.set(artifact.id, artifact);
|
|
217
|
+
task.input = { ...task.input, captureArtifacts: [...merged.values()].slice(-8) };
|
|
218
|
+
task.updatedAt = new Date().toISOString();
|
|
219
|
+
this.store.putTask(task);
|
|
220
|
+
this.addTaskEvent(task, "site_builder.capture_received", `FWS ${valid.map((artifact) => artifact.device).join(" + ")} capture received`, { artifacts: valid.map(({ id, device, contentType, bytes }) => ({ id, device, contentType, bytes })) });
|
|
221
|
+
return task;
|
|
222
|
+
}
|
|
190
223
|
/**
|
|
191
224
|
* Tear everything down, and never let one failure strand the rest (#274).
|
|
192
225
|
*
|
|
@@ -82,6 +82,11 @@ async function route(runner, req, res) {
|
|
|
82
82
|
if (req.method === "POST" && cancelMatch) {
|
|
83
83
|
return json(res, 200, runner.cancelTask(cancelMatch[1]));
|
|
84
84
|
}
|
|
85
|
+
const artifactMatch = path.match(/^\/tasks\/([^/]+)\/artifacts$/);
|
|
86
|
+
if (req.method === "POST" && artifactMatch) {
|
|
87
|
+
const body = await readJson(req);
|
|
88
|
+
return json(res, 200, runner.appendCaptureArtifacts(artifactMatch[1], body.captureArtifacts ?? []));
|
|
89
|
+
}
|
|
85
90
|
if (req.method === "GET" && path === "/events") {
|
|
86
91
|
const limit = clampLimit(url.searchParams.get("limit"), 100, 500);
|
|
87
92
|
return json(res, 200, { events: runner.store.listEvents(limit) });
|
|
@@ -378,6 +383,28 @@ async function route(runner, req, res) {
|
|
|
378
383
|
return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
|
|
379
384
|
}
|
|
380
385
|
}
|
|
386
|
+
// Background cold-start clones (#858): start (or join) one, and read it back. An older runner 404s
|
|
387
|
+
// both, and the cloud falls back to the synchronous `/coding/clone` below.
|
|
388
|
+
if (req.method === "POST" && (path === "/coding/clone-start" || path === "/coding/clone-status")) {
|
|
389
|
+
const b = await readJson(req);
|
|
390
|
+
try {
|
|
391
|
+
return json(res, 200, path === "/coding/clone-start" ? runner.coding.startClone(b) : runner.coding.cloneStatus(b));
|
|
392
|
+
}
|
|
393
|
+
catch (e) {
|
|
394
|
+
return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
// Clone into an absent or empty owner folder (#857) — the cold-start half of `coding_repo_add`.
|
|
398
|
+
// An older runner 404s this, and the cloud says the CLI must be updated rather than guessing.
|
|
399
|
+
if (req.method === "POST" && path === "/coding/clone") {
|
|
400
|
+
const b = await readJson(req);
|
|
401
|
+
try {
|
|
402
|
+
return json(res, 200, runner.coding.cloneRepo(b));
|
|
403
|
+
}
|
|
404
|
+
catch (e) {
|
|
405
|
+
return json(res, 400, { error: e instanceof Error ? e.message : String(e) });
|
|
406
|
+
}
|
|
407
|
+
}
|
|
381
408
|
if (req.method === "POST" && path === "/coding/tree") {
|
|
382
409
|
const b = await readJson(req);
|
|
383
410
|
try {
|
package/dist/index.js
CHANGED
|
@@ -1140,6 +1140,9 @@ function findWorkspaceRoot() {
|
|
|
1140
1140
|
}
|
|
1141
1141
|
return process.cwd();
|
|
1142
1142
|
}
|
|
1143
|
+
function runsFromSource() {
|
|
1144
|
+
return existsSync6(resolve4(findWorkspaceRoot(), "packages", "browser-runner", "src", "index.ts"));
|
|
1145
|
+
}
|
|
1143
1146
|
function bundledRunnerPath() {
|
|
1144
1147
|
return fileURLToPath2(new URL("./browser-runner/index.js", import.meta.url));
|
|
1145
1148
|
}
|
|
@@ -1194,6 +1197,53 @@ async function waitForLocalRunner(opts, timeoutMs = 15e3) {
|
|
|
1194
1197
|
// src/commands/runner/relay.ts
|
|
1195
1198
|
import { hostname as hostname3 } from "os";
|
|
1196
1199
|
|
|
1200
|
+
// src/commands/runner/self-update.ts
|
|
1201
|
+
import { execFile } from "child_process";
|
|
1202
|
+
import { promisify } from "util";
|
|
1203
|
+
var run = promisify(execFile);
|
|
1204
|
+
var RUNNER_UPDATE_PATH = "/pags/runner/update";
|
|
1205
|
+
var RUNNER_RESTART_EXIT_CODE = 75;
|
|
1206
|
+
var SUPERVISED_ENV = "PAGS_UP_SUPERVISED";
|
|
1207
|
+
var CLI_PACKAGE = "@proagentstore/cli";
|
|
1208
|
+
function olderThan(a, b) {
|
|
1209
|
+
const parse = (v) => /^(\d+)\.(\d+)\.(\d+)/.exec(v.trim())?.slice(1).map(Number);
|
|
1210
|
+
const x = parse(a);
|
|
1211
|
+
const y = parse(b);
|
|
1212
|
+
if (!x || !y) return false;
|
|
1213
|
+
for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i];
|
|
1214
|
+
return false;
|
|
1215
|
+
}
|
|
1216
|
+
function planRunnerUpdate(f) {
|
|
1217
|
+
if (f.fromSource) return { action: "refused", current: f.current, reason: "This runner runs from a source checkout \u2014 update it with `git pull` there, not npm." };
|
|
1218
|
+
if (!f.latest) return { action: "refused", current: f.current, reason: `npm could not be asked for the latest ${CLI_PACKAGE} from this machine.` };
|
|
1219
|
+
if (!olderThan(f.current, f.latest)) return { action: "up-to-date", current: f.current };
|
|
1220
|
+
if (!f.supervised) {
|
|
1221
|
+
return {
|
|
1222
|
+
action: "refused",
|
|
1223
|
+
current: f.current,
|
|
1224
|
+
reason: `This runner was not started by a \`pags up\` that can restart it (\`pags runner connect\` directly, or a \`pags up\` older than the respawn). Update once at the machine: \`npm i -g ${CLI_PACKAGE}\` and restart \`pags up\` \u2014 later updates can then be done remotely.`
|
|
1225
|
+
};
|
|
1226
|
+
}
|
|
1227
|
+
if (f.busy.length > 0) return { action: "wait", current: f.current, latest: f.latest, waitingFor: f.busy };
|
|
1228
|
+
return { action: "update", current: f.current, latest: f.latest };
|
|
1229
|
+
}
|
|
1230
|
+
async function latestPublishedVersion() {
|
|
1231
|
+
try {
|
|
1232
|
+
const { stdout } = await run("npm", ["view", CLI_PACKAGE, "version"], { timeout: 3e4 });
|
|
1233
|
+
return stdout.trim() || null;
|
|
1234
|
+
} catch {
|
|
1235
|
+
return null;
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
1238
|
+
async function installVersion(version2) {
|
|
1239
|
+
try {
|
|
1240
|
+
await run("npm", ["i", "-g", `${CLI_PACKAGE}@${version2}`], { timeout: 5 * 6e4 });
|
|
1241
|
+
} catch (e) {
|
|
1242
|
+
const stderr = String(e.stderr ?? "").trim();
|
|
1243
|
+
throw new Error((stderr || (e instanceof Error ? e.message : String(e))).slice(-400));
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
|
|
1197
1247
|
// src/commands/runner/membership.ts
|
|
1198
1248
|
function isEligible(inst, thisNode, alsoKnownAs = []) {
|
|
1199
1249
|
if (inst.status !== "active") return false;
|
|
@@ -1220,10 +1270,36 @@ function shouldRegisterOnOpen(reconnect, alreadyRegistered) {
|
|
|
1220
1270
|
function pendingRegistrations(attached, registered) {
|
|
1221
1271
|
return [...attached].filter((id) => !registered.has(id));
|
|
1222
1272
|
}
|
|
1273
|
+
function partitionByPin(instances, thisNode, alsoKnownAs = []) {
|
|
1274
|
+
const here = [];
|
|
1275
|
+
const elsewhere = [];
|
|
1276
|
+
for (const inst of instances) (isEligible(inst, thisNode, alsoKnownAs) ? here : elsewhere).push(inst);
|
|
1277
|
+
return { here, elsewhere };
|
|
1278
|
+
}
|
|
1279
|
+
function registrationStatus(attached, registered) {
|
|
1280
|
+
const ids = [...attached];
|
|
1281
|
+
const have = ids.filter((id) => registered.has(id)).length;
|
|
1282
|
+
const total = ids.length;
|
|
1283
|
+
return { agents: `${have}/${total}`, state: have === total ? "ok" : have === 0 ? "fail" : "partial" };
|
|
1284
|
+
}
|
|
1223
1285
|
function instanceLabel(inst) {
|
|
1224
1286
|
const short = `${inst.id.slice(0, 8)}\u2026`;
|
|
1225
1287
|
return inst.name ? `${inst.name} (${short})` : short;
|
|
1226
1288
|
}
|
|
1289
|
+
function reattachPlan(request, state) {
|
|
1290
|
+
const target = typeof request?.attach === "string" && request.attach ? request.attach : null;
|
|
1291
|
+
const force = target !== null && request?.force === true;
|
|
1292
|
+
if (!state.watching && !(target && state.scope.includes(target))) {
|
|
1293
|
+
return {
|
|
1294
|
+
target,
|
|
1295
|
+
unblock: false,
|
|
1296
|
+
detach: false,
|
|
1297
|
+
force: false,
|
|
1298
|
+
refuse: "This machine's `pags up` was started with --instance, so it serves only that agent. Restart it without --instance to let it take repinned agents."
|
|
1299
|
+
};
|
|
1300
|
+
}
|
|
1301
|
+
return { target, unblock: target !== null && state.blocked, detach: target !== null && state.held, force };
|
|
1302
|
+
}
|
|
1227
1303
|
|
|
1228
1304
|
// src/commands/runner/status-line.ts
|
|
1229
1305
|
var STATUS_PREFIX = "PAGS-STATUS";
|
|
@@ -1256,6 +1332,8 @@ function parseStatusLine(line) {
|
|
|
1256
1332
|
}
|
|
1257
1333
|
|
|
1258
1334
|
// src/commands/runner/relay.ts
|
|
1335
|
+
var MEMBERSHIP_SYNC_PATH = "/pags/membership/sync";
|
|
1336
|
+
var CLI_CONTROL_PATHS = /* @__PURE__ */ new Set([MEMBERSHIP_SYNC_PATH, RUNNER_UPDATE_PATH]);
|
|
1259
1337
|
async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force = false, watchInstances = false) {
|
|
1260
1338
|
const apiBase = pagsApiBase(opts.apiBase).replace(/^http/, "ws");
|
|
1261
1339
|
const pagsToken = clean(opts.pagsToken) || clean(process.env.PAGS_TOKEN) || clean(loadSession()?.token);
|
|
@@ -1289,14 +1367,88 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1289
1367
|
return false;
|
|
1290
1368
|
}
|
|
1291
1369
|
};
|
|
1370
|
+
const attached = /* @__PURE__ */ new Map();
|
|
1371
|
+
const blocked = /* @__PURE__ */ new Set();
|
|
1372
|
+
let syncing = Promise.resolve();
|
|
1373
|
+
const forceNext = /* @__PURE__ */ new Set();
|
|
1292
1374
|
const reportRegistration = () => {
|
|
1293
|
-
const agents =
|
|
1294
|
-
const state = registered.size === instanceIds.length ? "ok" : registered.size === 0 ? "fail" : "partial";
|
|
1375
|
+
const { agents, state } = registrationStatus(attached.keys(), registered);
|
|
1295
1376
|
writeLine(formatStatusLine({ registration: state, agents, reason: state === "ok" ? void 0 : lastRegisterError }));
|
|
1296
1377
|
};
|
|
1297
1378
|
for (const id of instanceIds) await registerRuntime(id);
|
|
1298
|
-
const
|
|
1299
|
-
|
|
1379
|
+
const updateFacts = async () => {
|
|
1380
|
+
const sessions = await requestRunner("GET", "/coding/sessions", {
|
|
1381
|
+
url: localUrl,
|
|
1382
|
+
token: runnerToken,
|
|
1383
|
+
instanceId: instanceIds[0]
|
|
1384
|
+
}).catch(() => ({ sessions: [] }));
|
|
1385
|
+
return {
|
|
1386
|
+
current: CLI_VERSION,
|
|
1387
|
+
latest: await latestPublishedVersion(),
|
|
1388
|
+
fromSource: runsFromSource(),
|
|
1389
|
+
supervised: process.env[SUPERVISED_ENV] === "1",
|
|
1390
|
+
busy: (sessions.sessions ?? []).filter((s) => s.alive && s.runState && s.runState !== "idle").map((s) => s.sessionId)
|
|
1391
|
+
};
|
|
1392
|
+
};
|
|
1393
|
+
const installAndRestart = async (plan) => {
|
|
1394
|
+
writeLine(`Updating ${plan.current} \u2192 ${plan.latest} (runner_update)\u2026`);
|
|
1395
|
+
await installVersion(plan.latest);
|
|
1396
|
+
writeLine(`Installed ${plan.latest} \u2014 restarting; every agent re-attaches on the way back up.`);
|
|
1397
|
+
setTimeout(() => {
|
|
1398
|
+
for (const id of [...attached.keys()]) detach(id);
|
|
1399
|
+
process.exit(RUNNER_RESTART_EXIT_CODE);
|
|
1400
|
+
}, 500).unref();
|
|
1401
|
+
};
|
|
1402
|
+
let updateWaiting = false;
|
|
1403
|
+
const updateWhenIdle = () => {
|
|
1404
|
+
if (updateWaiting) return;
|
|
1405
|
+
updateWaiting = true;
|
|
1406
|
+
const until = Date.now() + 60 * 6e4;
|
|
1407
|
+
const tick = () => setTimeout(async () => {
|
|
1408
|
+
const plan = planRunnerUpdate(await updateFacts());
|
|
1409
|
+
if (plan.action === "update") {
|
|
1410
|
+
await installAndRestart(plan).catch((e) => writeError(`runner_update: install failed \u2014 ${e instanceof Error ? e.message : String(e)}`));
|
|
1411
|
+
updateWaiting = false;
|
|
1412
|
+
} else if (plan.action === "wait" && Date.now() < until) tick();
|
|
1413
|
+
else updateWaiting = false;
|
|
1414
|
+
}, 15e3).unref();
|
|
1415
|
+
tick();
|
|
1416
|
+
};
|
|
1417
|
+
const answerUpdate = async (body) => {
|
|
1418
|
+
const dryRun = body?.dryRun === true;
|
|
1419
|
+
const plan = planRunnerUpdate(await updateFacts());
|
|
1420
|
+
if (dryRun || plan.action === "up-to-date" || plan.action === "refused") return { status: 200, result: { ...plan, dryRun } };
|
|
1421
|
+
if (plan.action === "wait") {
|
|
1422
|
+
updateWhenIdle();
|
|
1423
|
+
return { status: 200, result: { ...plan, detail: "Restarts itself as soon as these engines finish their turns \u2014 no run is cut off." } };
|
|
1424
|
+
}
|
|
1425
|
+
try {
|
|
1426
|
+
await installAndRestart(plan);
|
|
1427
|
+
} catch (e) {
|
|
1428
|
+
return { status: 500, result: { error: `npm could not install ${plan.latest}: ${e instanceof Error ? e.message : String(e)}` } };
|
|
1429
|
+
}
|
|
1430
|
+
return { status: 200, result: { action: "restarting", current: plan.current, latest: plan.latest } };
|
|
1431
|
+
};
|
|
1432
|
+
const answerControl = async (path, body) => {
|
|
1433
|
+
if (path === RUNNER_UPDATE_PATH) return answerUpdate(body);
|
|
1434
|
+
if (path !== MEMBERSHIP_SYNC_PATH) return { status: 404, result: { error: `Unknown runner control ${path}` } };
|
|
1435
|
+
const request = body;
|
|
1436
|
+
const named = typeof request?.attach === "string" ? request.attach : "";
|
|
1437
|
+
const plan = reattachPlan(request, { held: attached.has(named), blocked: blocked.has(named), watching: watchInstances, scope: instanceIds });
|
|
1438
|
+
if (plan.refuse) return { status: 409, result: { error: plan.refuse } };
|
|
1439
|
+
if (plan.target) {
|
|
1440
|
+
if (plan.unblock) blocked.delete(plan.target);
|
|
1441
|
+
if (plan.detach) detach(plan.target);
|
|
1442
|
+
if (plan.force) forceNext.add(plan.target);
|
|
1443
|
+
}
|
|
1444
|
+
if (watchInstances) await syncMembership();
|
|
1445
|
+
else if (plan.target) {
|
|
1446
|
+
await registerRuntime(plan.target);
|
|
1447
|
+
attach(plan.target);
|
|
1448
|
+
}
|
|
1449
|
+
if (plan.target && !attached.has(plan.target)) forceNext.delete(plan.target);
|
|
1450
|
+
return { status: 200, result: { attached: [...attached.keys()], ...plan.target ? { target: plan.target, holding: attached.has(plan.target) } : {} } };
|
|
1451
|
+
};
|
|
1300
1452
|
const attach = (id, label = `${id.slice(0, 8)}\u2026`) => {
|
|
1301
1453
|
if (attached.has(id)) return;
|
|
1302
1454
|
const mintToken = () => requestPags("POST", `/v1/relay/${apiPathSegment(id)}/token`, { ...opts, pagsToken }, {}).then((r) => r.token);
|
|
@@ -1308,7 +1460,8 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1308
1460
|
mintToken,
|
|
1309
1461
|
localUrl,
|
|
1310
1462
|
runnerToken,
|
|
1311
|
-
force,
|
|
1463
|
+
// `pags up --force` for the whole process, or for this one agent at the cloud's request (#856).
|
|
1464
|
+
force || forceNext.delete(id),
|
|
1312
1465
|
(conflicted) => {
|
|
1313
1466
|
blocked.add(conflicted);
|
|
1314
1467
|
attached.delete(conflicted);
|
|
@@ -1325,7 +1478,8 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1325
1478
|
if (!shouldRegisterOnOpen(reconnect, registered.has(openedId))) return;
|
|
1326
1479
|
await registerRuntime(openedId, reconnect ? false : force);
|
|
1327
1480
|
reportRegistration();
|
|
1328
|
-
}
|
|
1481
|
+
},
|
|
1482
|
+
answerControl
|
|
1329
1483
|
)
|
|
1330
1484
|
);
|
|
1331
1485
|
if (label) writeLine(`Attached agent: ${label}`);
|
|
@@ -1339,7 +1493,8 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1339
1493
|
};
|
|
1340
1494
|
for (const id of instanceIds) attach(id, "");
|
|
1341
1495
|
reportRegistration();
|
|
1342
|
-
|
|
1496
|
+
const startup = registrationStatus(attached.keys(), registered);
|
|
1497
|
+
writeLine(startup.state === "ok" ? `Runtime registered with PAGS \u2713 (${startup.agents} agents)` : `Runtime registration incomplete: ${startup.agents} agents \u2014 retried on each relay (re)connect${watchInstances ? " and every 20s while this runs" : ""}.`);
|
|
1343
1498
|
writeLine("");
|
|
1344
1499
|
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");
|
|
1345
1500
|
writeLine(` \u2705 CONNECTED \u2014 WebSocket relay \xB7 ${hostname3()}`);
|
|
@@ -1384,36 +1539,42 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1384
1539
|
writeLine(`Relay conflict cleared: ${id.slice(0, 8)}\u2026 \u2014 the other runner is gone; reattaching.`);
|
|
1385
1540
|
}
|
|
1386
1541
|
}
|
|
1542
|
+
function syncMembership() {
|
|
1543
|
+
syncing = syncing.catch(() => void 0).then(async () => {
|
|
1544
|
+
await clearFinishedConflicts();
|
|
1545
|
+
const res = await requestPags(
|
|
1546
|
+
"GET",
|
|
1547
|
+
"/v1/instances/my/instances",
|
|
1548
|
+
{ ...opts, pagsToken }
|
|
1549
|
+
);
|
|
1550
|
+
const { attach: toAttach, detach: toDetach } = diffMembership(
|
|
1551
|
+
attached.keys(),
|
|
1552
|
+
res.instances ?? [],
|
|
1553
|
+
runnerNode,
|
|
1554
|
+
blocked,
|
|
1555
|
+
// The names this machine has also worn. Without them a pin made under a
|
|
1556
|
+
// previous hostname reads as "pinned to another machine", and this poll
|
|
1557
|
+
// detaches the agent twenty seconds after startup attached it (#379).
|
|
1558
|
+
machine.names
|
|
1559
|
+
);
|
|
1560
|
+
for (const inst of toAttach) {
|
|
1561
|
+
await registerRuntime(inst.id);
|
|
1562
|
+
attach(inst.id, instanceLabel(inst));
|
|
1563
|
+
}
|
|
1564
|
+
for (const id of toDetach) detach(id);
|
|
1565
|
+
const pending = pendingRegistrations(attached.keys(), registered);
|
|
1566
|
+
if (pending.length) {
|
|
1567
|
+
for (const id of pending) await registerRuntime(id);
|
|
1568
|
+
reportRegistration();
|
|
1569
|
+
}
|
|
1570
|
+
});
|
|
1571
|
+
return syncing;
|
|
1572
|
+
}
|
|
1387
1573
|
function startDiscovery() {
|
|
1388
1574
|
const tick = () => {
|
|
1389
1575
|
const timer = setTimeout(async () => {
|
|
1390
1576
|
try {
|
|
1391
|
-
await
|
|
1392
|
-
const res = await requestPags(
|
|
1393
|
-
"GET",
|
|
1394
|
-
"/v1/instances/my/instances",
|
|
1395
|
-
{ ...opts, pagsToken }
|
|
1396
|
-
);
|
|
1397
|
-
const { attach: toAttach, detach: toDetach } = diffMembership(
|
|
1398
|
-
attached.keys(),
|
|
1399
|
-
res.instances ?? [],
|
|
1400
|
-
runnerNode,
|
|
1401
|
-
blocked,
|
|
1402
|
-
// The names this machine has also worn. Without them a pin made under a
|
|
1403
|
-
// previous hostname reads as "pinned to another machine", and this poll
|
|
1404
|
-
// detaches the agent twenty seconds after startup attached it (#379).
|
|
1405
|
-
machine.names
|
|
1406
|
-
);
|
|
1407
|
-
for (const inst of toAttach) {
|
|
1408
|
-
await registerRuntime(inst.id);
|
|
1409
|
-
attach(inst.id, instanceLabel(inst));
|
|
1410
|
-
}
|
|
1411
|
-
for (const id of toDetach) detach(id);
|
|
1412
|
-
const pending = pendingRegistrations(attached.keys(), registered);
|
|
1413
|
-
if (pending.length) {
|
|
1414
|
-
for (const id of pending) await registerRuntime(id);
|
|
1415
|
-
reportRegistration();
|
|
1416
|
-
}
|
|
1577
|
+
await syncMembership();
|
|
1417
1578
|
} catch {
|
|
1418
1579
|
}
|
|
1419
1580
|
tick();
|
|
@@ -1423,7 +1584,7 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1423
1584
|
tick();
|
|
1424
1585
|
}
|
|
1425
1586
|
}
|
|
1426
|
-
function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, force = false, onConflict, onOpen) {
|
|
1587
|
+
function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, force = false, onConflict, onOpen, onControl) {
|
|
1427
1588
|
let backoffMs = 1e3;
|
|
1428
1589
|
let reconnecting = false;
|
|
1429
1590
|
let closed = false;
|
|
@@ -1477,6 +1638,14 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
|
|
|
1477
1638
|
return;
|
|
1478
1639
|
}
|
|
1479
1640
|
if (!cmd.id || !cmd.path) return;
|
|
1641
|
+
if (onControl && CLI_CONTROL_PATHS.has(cmd.path)) {
|
|
1642
|
+
const reply = await onControl(cmd.path, cmd.body).catch((err) => ({ status: 500, result: { error: err instanceof Error ? err.message : String(err) } }));
|
|
1643
|
+
try {
|
|
1644
|
+
ws.send(JSON.stringify({ id: cmd.id, ...reply }));
|
|
1645
|
+
} catch {
|
|
1646
|
+
}
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1480
1649
|
const method = (cmd.method || "POST").toUpperCase();
|
|
1481
1650
|
const hasBody = method !== "GET" && method !== "HEAD" && cmd.body !== void 0;
|
|
1482
1651
|
try {
|
|
@@ -1582,6 +1751,7 @@ function createRunnerCommand() {
|
|
|
1582
1751
|
shuttingDown = true;
|
|
1583
1752
|
if (!runner.killed) runner.kill("SIGTERM");
|
|
1584
1753
|
};
|
|
1754
|
+
process.once("exit", shutdown);
|
|
1585
1755
|
process.once("SIGINT", () => {
|
|
1586
1756
|
shutdown();
|
|
1587
1757
|
process.exit(0);
|
|
@@ -1717,6 +1887,7 @@ var runnerCommand = createRunnerCommand();
|
|
|
1717
1887
|
// src/commands/up.ts
|
|
1718
1888
|
import { createRequire as createRequire2 } from "module";
|
|
1719
1889
|
import { Command as Command8 } from "commander";
|
|
1890
|
+
import { hostname as hostname5 } from "os";
|
|
1720
1891
|
|
|
1721
1892
|
// src/tui.ts
|
|
1722
1893
|
import chalk from "chalk";
|
|
@@ -1888,6 +2059,7 @@ var upCommand = new Command8("up").description("Start the browser runner for all
|
|
|
1888
2059
|
const data = await res.json();
|
|
1889
2060
|
const active = (data.instances || []).filter((i) => i.status === "active");
|
|
1890
2061
|
let instances = active;
|
|
2062
|
+
let elsewhere = [];
|
|
1891
2063
|
if (opts.instance) {
|
|
1892
2064
|
instances = instances.filter((i) => i.id === opts.instance || i.slug === opts.instance);
|
|
1893
2065
|
} else {
|
|
@@ -1904,16 +2076,31 @@ var upCommand = new Command8("up").description("Start the browser runner for all
|
|
|
1904
2076
|
writeLine(" Subscribe to an agent at https://proagentstore.online");
|
|
1905
2077
|
process.exit(1);
|
|
1906
2078
|
}
|
|
1907
|
-
state.instances = instances.map((i) => ({ id: i.id, name: i.name || i.slug || i.id.slice(0, 8) }));
|
|
1908
|
-
printStep(`Found ${instances.length} instance${instances.length === 1 ? "" : "s"}`, "ok");
|
|
1909
|
-
for (const inst of state.instances) {
|
|
1910
|
-
writeLine(` ${inst.name} (${inst.id.slice(0, 8)}...)`);
|
|
1911
|
-
}
|
|
1912
2079
|
await maybeClaimMachineNames({
|
|
1913
2080
|
token: session.token,
|
|
1914
2081
|
apiBase: API_BASE3,
|
|
1915
2082
|
headless: opts.headless
|
|
1916
2083
|
}).catch(() => void 0);
|
|
2084
|
+
if (!opts.instance) {
|
|
2085
|
+
const split = partitionByPin(instances, hostname5(), loadMachineIdentity(hostname5()).names);
|
|
2086
|
+
instances = split.here;
|
|
2087
|
+
elsewhere = split.elsewhere;
|
|
2088
|
+
}
|
|
2089
|
+
if (instances.length === 0) {
|
|
2090
|
+
printStep(`All ${elsewhere.length} of your runtime agents are pinned to other machines`, "ok");
|
|
2091
|
+
for (const inst of elsewhere) writeLine(` ${inst.name || inst.slug || inst.id.slice(0, 8)} \u2192 ${inst.config?.runnerNode}`);
|
|
2092
|
+
writeLine(' They are served there. To run one from this machine, change its "Runs on" pin in the console, then `pags up` again.');
|
|
2093
|
+
process.exit(0);
|
|
2094
|
+
}
|
|
2095
|
+
state.instances = instances.map((i) => ({ id: i.id, name: i.name || i.slug || i.id.slice(0, 8) }));
|
|
2096
|
+
printStep(`Found ${instances.length} instance${instances.length === 1 ? "" : "s"}`, "ok");
|
|
2097
|
+
for (const inst of state.instances) {
|
|
2098
|
+
writeLine(` ${inst.name} (${inst.id.slice(0, 8)}...)`);
|
|
2099
|
+
}
|
|
2100
|
+
if (elsewhere.length) {
|
|
2101
|
+
printStep(`${elsewhere.length} more pinned to other machines \u2014 served there, not attached here`, "ok");
|
|
2102
|
+
for (const inst of elsewhere) writeLine(` ${inst.name || inst.slug || inst.id.slice(0, 8)} \u2192 ${inst.config?.runnerNode}`);
|
|
2103
|
+
}
|
|
1917
2104
|
state.activeInstance = instances.length === 1 ? instances[0].name || instances[0].slug || instances[0].id.slice(0, 8) : `${instances.length} agents`;
|
|
1918
2105
|
printStep(`Connecting ${state.activeInstance}\u2026`, "wait");
|
|
1919
2106
|
await stopRunnerProcesses();
|
|
@@ -1923,10 +2110,11 @@ var upCommand = new Command8("up").description("Start the browser runner for all
|
|
|
1923
2110
|
if (opts.headless) args.push("--headless");
|
|
1924
2111
|
if (opts.force) args.push("--force");
|
|
1925
2112
|
if (!opts.instance) args.push("--watch-instances");
|
|
1926
|
-
const
|
|
2113
|
+
const spawnChild = () => spawn4(process.execPath, args, {
|
|
1927
2114
|
stdio: ["ignore", "pipe", "pipe"],
|
|
1928
|
-
env: { ...process.env, PAGS_TOKEN: session.token }
|
|
2115
|
+
env: { ...process.env, PAGS_TOKEN: session.token, [SUPERVISED_ENV]: "1" }
|
|
1929
2116
|
});
|
|
2117
|
+
let child = spawnChild();
|
|
1930
2118
|
const logs = [];
|
|
1931
2119
|
const handleOutput = (data2) => {
|
|
1932
2120
|
const text = data2.toString("utf-8");
|
|
@@ -1984,10 +2172,20 @@ var upCommand = new Command8("up").description("Start the browser runner for all
|
|
|
1984
2172
|
}
|
|
1985
2173
|
}
|
|
1986
2174
|
};
|
|
1987
|
-
child.stdout?.on("data", handleOutput);
|
|
1988
|
-
child.stderr?.on("data", handleOutput);
|
|
1989
2175
|
let childDead = false;
|
|
1990
|
-
|
|
2176
|
+
const wire = () => {
|
|
2177
|
+
child.stdout?.on("data", handleOutput);
|
|
2178
|
+
child.stderr?.on("data", handleOutput);
|
|
2179
|
+
child.on("exit", onChildExit);
|
|
2180
|
+
};
|
|
2181
|
+
function onChildExit(code) {
|
|
2182
|
+
if (code === RUNNER_RESTART_EXIT_CODE) {
|
|
2183
|
+
state.lastEvent = "Runner updated remotely \u2014 restarting on the new version";
|
|
2184
|
+
printStatus(state);
|
|
2185
|
+
child = spawnChild();
|
|
2186
|
+
wire();
|
|
2187
|
+
return;
|
|
2188
|
+
}
|
|
1991
2189
|
childDead = true;
|
|
1992
2190
|
if (code && code !== 0) {
|
|
1993
2191
|
state.runner = "error";
|
|
@@ -1996,7 +2194,8 @@ var upCommand = new Command8("up").description("Start the browser runner for all
|
|
|
1996
2194
|
if (recent.length) state.lastEvent += ": " + recent[recent.length - 1].slice(0, 60);
|
|
1997
2195
|
printStatus(state);
|
|
1998
2196
|
}
|
|
1999
|
-
}
|
|
2197
|
+
}
|
|
2198
|
+
wire();
|
|
2000
2199
|
const shutdown = () => {
|
|
2001
2200
|
child.kill();
|
|
2002
2201
|
clearScreen();
|