@proagentstore/cli 0.4.61 → 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 +205 -35
- 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;
|
|
@@ -1236,6 +1286,20 @@ function instanceLabel(inst) {
|
|
|
1236
1286
|
const short = `${inst.id.slice(0, 8)}\u2026`;
|
|
1237
1287
|
return inst.name ? `${inst.name} (${short})` : short;
|
|
1238
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
|
+
}
|
|
1239
1303
|
|
|
1240
1304
|
// src/commands/runner/status-line.ts
|
|
1241
1305
|
var STATUS_PREFIX = "PAGS-STATUS";
|
|
@@ -1268,6 +1332,8 @@ function parseStatusLine(line) {
|
|
|
1268
1332
|
}
|
|
1269
1333
|
|
|
1270
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]);
|
|
1271
1337
|
async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force = false, watchInstances = false) {
|
|
1272
1338
|
const apiBase = pagsApiBase(opts.apiBase).replace(/^http/, "ws");
|
|
1273
1339
|
const pagsToken = clean(opts.pagsToken) || clean(process.env.PAGS_TOKEN) || clean(loadSession()?.token);
|
|
@@ -1303,11 +1369,86 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1303
1369
|
};
|
|
1304
1370
|
const attached = /* @__PURE__ */ new Map();
|
|
1305
1371
|
const blocked = /* @__PURE__ */ new Set();
|
|
1372
|
+
let syncing = Promise.resolve();
|
|
1373
|
+
const forceNext = /* @__PURE__ */ new Set();
|
|
1306
1374
|
const reportRegistration = () => {
|
|
1307
1375
|
const { agents, state } = registrationStatus(attached.keys(), registered);
|
|
1308
1376
|
writeLine(formatStatusLine({ registration: state, agents, reason: state === "ok" ? void 0 : lastRegisterError }));
|
|
1309
1377
|
};
|
|
1310
1378
|
for (const id of instanceIds) await registerRuntime(id);
|
|
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
|
+
};
|
|
1311
1452
|
const attach = (id, label = `${id.slice(0, 8)}\u2026`) => {
|
|
1312
1453
|
if (attached.has(id)) return;
|
|
1313
1454
|
const mintToken = () => requestPags("POST", `/v1/relay/${apiPathSegment(id)}/token`, { ...opts, pagsToken }, {}).then((r) => r.token);
|
|
@@ -1319,7 +1460,8 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1319
1460
|
mintToken,
|
|
1320
1461
|
localUrl,
|
|
1321
1462
|
runnerToken,
|
|
1322
|
-
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),
|
|
1323
1465
|
(conflicted) => {
|
|
1324
1466
|
blocked.add(conflicted);
|
|
1325
1467
|
attached.delete(conflicted);
|
|
@@ -1336,7 +1478,8 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1336
1478
|
if (!shouldRegisterOnOpen(reconnect, registered.has(openedId))) return;
|
|
1337
1479
|
await registerRuntime(openedId, reconnect ? false : force);
|
|
1338
1480
|
reportRegistration();
|
|
1339
|
-
}
|
|
1481
|
+
},
|
|
1482
|
+
answerControl
|
|
1340
1483
|
)
|
|
1341
1484
|
);
|
|
1342
1485
|
if (label) writeLine(`Attached agent: ${label}`);
|
|
@@ -1396,36 +1539,42 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1396
1539
|
writeLine(`Relay conflict cleared: ${id.slice(0, 8)}\u2026 \u2014 the other runner is gone; reattaching.`);
|
|
1397
1540
|
}
|
|
1398
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
|
+
}
|
|
1399
1573
|
function startDiscovery() {
|
|
1400
1574
|
const tick = () => {
|
|
1401
1575
|
const timer = setTimeout(async () => {
|
|
1402
1576
|
try {
|
|
1403
|
-
await
|
|
1404
|
-
const res = await requestPags(
|
|
1405
|
-
"GET",
|
|
1406
|
-
"/v1/instances/my/instances",
|
|
1407
|
-
{ ...opts, pagsToken }
|
|
1408
|
-
);
|
|
1409
|
-
const { attach: toAttach, detach: toDetach } = diffMembership(
|
|
1410
|
-
attached.keys(),
|
|
1411
|
-
res.instances ?? [],
|
|
1412
|
-
runnerNode,
|
|
1413
|
-
blocked,
|
|
1414
|
-
// The names this machine has also worn. Without them a pin made under a
|
|
1415
|
-
// previous hostname reads as "pinned to another machine", and this poll
|
|
1416
|
-
// detaches the agent twenty seconds after startup attached it (#379).
|
|
1417
|
-
machine.names
|
|
1418
|
-
);
|
|
1419
|
-
for (const inst of toAttach) {
|
|
1420
|
-
await registerRuntime(inst.id);
|
|
1421
|
-
attach(inst.id, instanceLabel(inst));
|
|
1422
|
-
}
|
|
1423
|
-
for (const id of toDetach) detach(id);
|
|
1424
|
-
const pending = pendingRegistrations(attached.keys(), registered);
|
|
1425
|
-
if (pending.length) {
|
|
1426
|
-
for (const id of pending) await registerRuntime(id);
|
|
1427
|
-
reportRegistration();
|
|
1428
|
-
}
|
|
1577
|
+
await syncMembership();
|
|
1429
1578
|
} catch {
|
|
1430
1579
|
}
|
|
1431
1580
|
tick();
|
|
@@ -1435,7 +1584,7 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1435
1584
|
tick();
|
|
1436
1585
|
}
|
|
1437
1586
|
}
|
|
1438
|
-
function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, force = false, onConflict, onOpen) {
|
|
1587
|
+
function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, force = false, onConflict, onOpen, onControl) {
|
|
1439
1588
|
let backoffMs = 1e3;
|
|
1440
1589
|
let reconnecting = false;
|
|
1441
1590
|
let closed = false;
|
|
@@ -1489,6 +1638,14 @@ function openRelaySocket(instanceId, wsBase, mintToken, localUrl, runnerToken, f
|
|
|
1489
1638
|
return;
|
|
1490
1639
|
}
|
|
1491
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
|
+
}
|
|
1492
1649
|
const method = (cmd.method || "POST").toUpperCase();
|
|
1493
1650
|
const hasBody = method !== "GET" && method !== "HEAD" && cmd.body !== void 0;
|
|
1494
1651
|
try {
|
|
@@ -1594,6 +1751,7 @@ function createRunnerCommand() {
|
|
|
1594
1751
|
shuttingDown = true;
|
|
1595
1752
|
if (!runner.killed) runner.kill("SIGTERM");
|
|
1596
1753
|
};
|
|
1754
|
+
process.once("exit", shutdown);
|
|
1597
1755
|
process.once("SIGINT", () => {
|
|
1598
1756
|
shutdown();
|
|
1599
1757
|
process.exit(0);
|
|
@@ -1952,10 +2110,11 @@ var upCommand = new Command8("up").description("Start the browser runner for all
|
|
|
1952
2110
|
if (opts.headless) args.push("--headless");
|
|
1953
2111
|
if (opts.force) args.push("--force");
|
|
1954
2112
|
if (!opts.instance) args.push("--watch-instances");
|
|
1955
|
-
const
|
|
2113
|
+
const spawnChild = () => spawn4(process.execPath, args, {
|
|
1956
2114
|
stdio: ["ignore", "pipe", "pipe"],
|
|
1957
|
-
env: { ...process.env, PAGS_TOKEN: session.token }
|
|
2115
|
+
env: { ...process.env, PAGS_TOKEN: session.token, [SUPERVISED_ENV]: "1" }
|
|
1958
2116
|
});
|
|
2117
|
+
let child = spawnChild();
|
|
1959
2118
|
const logs = [];
|
|
1960
2119
|
const handleOutput = (data2) => {
|
|
1961
2120
|
const text = data2.toString("utf-8");
|
|
@@ -2013,10 +2172,20 @@ var upCommand = new Command8("up").description("Start the browser runner for all
|
|
|
2013
2172
|
}
|
|
2014
2173
|
}
|
|
2015
2174
|
};
|
|
2016
|
-
child.stdout?.on("data", handleOutput);
|
|
2017
|
-
child.stderr?.on("data", handleOutput);
|
|
2018
2175
|
let childDead = false;
|
|
2019
|
-
|
|
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
|
+
}
|
|
2020
2189
|
childDead = true;
|
|
2021
2190
|
if (code && code !== 0) {
|
|
2022
2191
|
state.runner = "error";
|
|
@@ -2025,7 +2194,8 @@ var upCommand = new Command8("up").description("Start the browser runner for all
|
|
|
2025
2194
|
if (recent.length) state.lastEvent += ": " + recent[recent.length - 1].slice(0, 60);
|
|
2026
2195
|
printStatus(state);
|
|
2027
2196
|
}
|
|
2028
|
-
}
|
|
2197
|
+
}
|
|
2198
|
+
wire();
|
|
2029
2199
|
const shutdown = () => {
|
|
2030
2200
|
child.kill();
|
|
2031
2201
|
clearScreen();
|