@proagentstore/cli 0.4.52 → 0.4.54
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.
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The commit guard AT THE ACT BOUNDARY — the runner half of #627 / #629.
|
|
3
|
+
*
|
|
4
|
+
* ── Why it has to be here
|
|
5
|
+
*
|
|
6
|
+
* `dryRun` and `readOnly` were enforced entirely in the cloud, on `action.name`: a string the MODEL
|
|
7
|
+
* wrote ABOUT the element. This process is the one that actually clicks, and it clicks by `ref`
|
|
8
|
+
* ({@link https://github.com/microsoft/playwright-mcp} `browser_click({ element, target })` — only
|
|
9
|
+
* `target` locates; `element` is a human-readable description). So the guard tested a story about
|
|
10
|
+
* the act while the act went somewhere else entirely, and the runner — the only party holding the
|
|
11
|
+
* DOM, and therefore the only one that can KNOW whether a control submits — had never been told
|
|
12
|
+
* that a run was a rehearsal at all: `grep -rn dryRun packages/browser-runner/src` returned eight
|
|
13
|
+
* hits and every one of them was the orphaned-browser reaper's unrelated `--dry-run` flag.
|
|
14
|
+
*
|
|
15
|
+
* A nameless click had already submitted a real application during a run the owner asked to be a
|
|
16
|
+
* test. The fix at the time made an EMPTY name a refusal, which left a WRONG name — a paraphrase,
|
|
17
|
+
* an `aria-label` the model rewrote, a page in French — behaving exactly as before.
|
|
18
|
+
*
|
|
19
|
+
* ── What is a fact here and what is still a guess
|
|
20
|
+
*
|
|
21
|
+
* FACT (`read_only`): whether the targeted control submits a form, and whether that form is a POST.
|
|
22
|
+
* Language-independent, label-independent, and not something the brain can talk its way past. A
|
|
23
|
+
* GET form is a search — the read-only prompt explicitly allows finding things — so only POST is
|
|
24
|
+
* refused. Enter and Space are checked against the FOCUSED element the same way.
|
|
25
|
+
*
|
|
26
|
+
* GUESS (both rehearsal modes): which of several POST submits on a multi-page ATS is the FINAL one.
|
|
27
|
+
* Nothing in the DOM says so — "Save and Continue" on page 3 and "Submit application" on page 6 are
|
|
28
|
+
* the same kind of control — and a rehearsal must be able to walk the whole form. So a rehearsal
|
|
29
|
+
* still decides on a LABEL. What changed is whose label: the element's own accessible name, read
|
|
30
|
+
* back out of the live DOM, instead of the model's claim about it, and a vocabulary that is not
|
|
31
|
+
* English-only. That residual is recorded on the issue rather than papered over.
|
|
32
|
+
*/
|
|
33
|
+
/**
|
|
34
|
+
* The FLOOR vocabulary, used only when the cloud sent none. It is deliberately the read-only
|
|
35
|
+
* (widest) set: a runner that has been told "read_only" but not told the words must not fail open.
|
|
36
|
+
* The authoritative list is `workers/api/src/lib/commit-guard.ts`, and `commit-guard.test.ts` there
|
|
37
|
+
* asserts this file still parses as a regex, so a copy that rots is a red build rather than a
|
|
38
|
+
* silent downgrade.
|
|
39
|
+
*/
|
|
40
|
+
export const FALLBACK_COMMIT_RE = /\b(confirm|accept|submit|send|post|publish|delete|remove|pay|purchase|buy|apply|approve|agree|save)\b|(?<![\p{L}\p{N}])(envoyer|soumettre|valider|absenden|abschicken|senden|einreichen|enviar|invia|inviare|verstuur|versturen|verzenden|indienen|skicka|wy[śs]lij|g[öo]nder|kirim|отправить)(?![\p{L}\p{N}])|提交|送出|确认|確認|送信|提出|제출|보내기|إرسال|تقديم/iu;
|
|
41
|
+
/** Compile the policy the cloud sent, falling back to the floor above. A malformed pattern must
|
|
42
|
+
* NOT disarm the guard — it falls back rather than throwing, because the caller is about to act. */
|
|
43
|
+
export function commitLabelRe(spec) {
|
|
44
|
+
if (!spec?.labels)
|
|
45
|
+
return FALLBACK_COMMIT_RE;
|
|
46
|
+
try {
|
|
47
|
+
return new RegExp(spec.labels, spec.flags || "iu");
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
return FALLBACK_COMMIT_RE;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
/** Keys that submit a focused form. `NumpadEnter` submits identically and was matched by nothing. */
|
|
54
|
+
export const SUBMIT_KEY_RE = /^(enter|return|numpadenter)$/i;
|
|
55
|
+
/** Space activates the focused control, including a submit button. */
|
|
56
|
+
export const ACTIVATE_KEY_RE = /^(space|spacebar|\s)$/i;
|
|
57
|
+
/** The probe evaluated ON the targeted element. Kept as source text because it crosses into the
|
|
58
|
+
* page through the standard `browser_evaluate` tool, which takes a function expression. */
|
|
59
|
+
export const ELEMENT_PROBE_FN = `el => {
|
|
60
|
+
var btn = (el.closest && el.closest('button,input,a,[role=button]')) || el;
|
|
61
|
+
var tag = (btn.tagName || '').toLowerCase();
|
|
62
|
+
var type = ((btn.getAttribute && btn.getAttribute('type')) || '').toLowerCase();
|
|
63
|
+
var form = btn.form || (btn.closest && btn.closest('form')) || null;
|
|
64
|
+
var submits = !!form && ((tag === 'button' && type !== 'button' && type !== 'reset') || (tag === 'input' && (type === 'submit' || type === 'image')));
|
|
65
|
+
var name = (btn.getAttribute && btn.getAttribute('aria-label')) || btn.value || btn.innerText || btn.textContent || '';
|
|
66
|
+
return { submits: submits, method: form ? ((form.getAttribute('method') || 'get').toLowerCase()) : '', tag: tag, type: type, name: String(name).replace(/\\s+/g, ' ').trim().slice(0, 160) };
|
|
67
|
+
}`;
|
|
68
|
+
/** The probe for a keypress: there is no ref, so it reads whatever has focus. */
|
|
69
|
+
export const FOCUS_PROBE_FN = `(() => {
|
|
70
|
+
var el = document.activeElement;
|
|
71
|
+
if (!el) return { inForm: false, method: '', tag: '', type: '', name: '' };
|
|
72
|
+
var form = el.form || (el.closest && el.closest('form')) || null;
|
|
73
|
+
var name = (el.getAttribute && el.getAttribute('aria-label')) || el.value || el.innerText || el.textContent || '';
|
|
74
|
+
return { inForm: !!form, method: form ? ((form.getAttribute('method') || 'get').toLowerCase()) : '', tag: (el.tagName || '').toLowerCase(), type: ((el.getAttribute && el.getAttribute('type')) || '').toLowerCase(), name: String(name).replace(/\\s+/g, ' ').trim().slice(0, 160) };
|
|
75
|
+
})()`;
|
|
76
|
+
/**
|
|
77
|
+
* May this click reach the page? Returns the refusal to hand back to the brain, or null.
|
|
78
|
+
*
|
|
79
|
+
* `facts` is null when the element could not be probed (an evaluate that failed, a ref the page no
|
|
80
|
+
* longer has). That is not treated as permission: in read-only it is refused outright, and in a
|
|
81
|
+
* rehearsal it falls back to the claimed name, which is the behaviour that shipped.
|
|
82
|
+
*/
|
|
83
|
+
export function refuseClick(spec, facts, claimedName, re) {
|
|
84
|
+
const claimed = (claimedName ?? "").trim();
|
|
85
|
+
const real = (facts?.name ?? "").trim();
|
|
86
|
+
if (spec.mode === "read_only") {
|
|
87
|
+
if (!facts) {
|
|
88
|
+
return "BLOCKED by the runner — this agent is READ-ONLY and that element could not be read from the page, so the click cannot be shown to be safe. Re-read the snapshot and target an element from it.";
|
|
89
|
+
}
|
|
90
|
+
if (facts.submits && facts.method === "post") {
|
|
91
|
+
return `BLOCKED by the runner — this agent is READ-ONLY and "${real || claimed || facts.tag}" submits a form (POST) on this page. That is a change, whatever the control is called. Report what you can already see with finish.`;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
const hit = [real, claimed].find((n) => n && re.test(n));
|
|
95
|
+
if (!hit)
|
|
96
|
+
return null;
|
|
97
|
+
return spec.mode === "read_only"
|
|
98
|
+
? `BLOCKED by the runner — this agent is READ-ONLY and can never perform "${hit}". Report what you can already see with finish.`
|
|
99
|
+
: `BLOCKED by the runner — this is a REHEARSAL and "${hit}" commits. The page never received the click. Call finish now instead of retrying.`;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* May this keypress reach the page?
|
|
103
|
+
*
|
|
104
|
+
* Read-only only. A rehearsal's Enter is decided by the cloud loop, which holds the one piece of
|
|
105
|
+
* state that makes the call — an Enter immediately after an arrow key is an autocomplete ACCEPT,
|
|
106
|
+
* not a submit — and this process cannot tell a JS listbox that will swallow the key from a form
|
|
107
|
+
* that will take it.
|
|
108
|
+
*/
|
|
109
|
+
export function refuseKey(spec, key, focus) {
|
|
110
|
+
if (spec.mode !== "read_only")
|
|
111
|
+
return null;
|
|
112
|
+
const k = (key ?? "").trim();
|
|
113
|
+
const submitKey = SUBMIT_KEY_RE.test(k);
|
|
114
|
+
const activateKey = ACTIVATE_KEY_RE.test(k);
|
|
115
|
+
if (!submitKey && !activateKey)
|
|
116
|
+
return null;
|
|
117
|
+
if (!focus) {
|
|
118
|
+
return `BLOCKED by the runner — this agent is READ-ONLY and the focused element could not be read, so pressing ${k || "Enter"} cannot be shown to be safe.`;
|
|
119
|
+
}
|
|
120
|
+
if (submitKey && focus.inForm && focus.method === "post") {
|
|
121
|
+
return `BLOCKED by the runner — this agent is READ-ONLY and pressing ${k} submits the form this field belongs to (POST). A search or filter that only reads (GET) is fine; this one writes. Report what you can already see with finish.`;
|
|
122
|
+
}
|
|
123
|
+
if (activateKey && (focus.tag === "button" || focus.type === "submit") && focus.method === "post") {
|
|
124
|
+
return `BLOCKED by the runner — this agent is READ-ONLY and Space activates "${focus.name || "the focused button"}", which submits a form (POST).`;
|
|
125
|
+
}
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
@@ -6,6 +6,7 @@ import { pathToFileURL } from "node:url";
|
|
|
6
6
|
import { captureScreenshotDataUrl, challengeSolved, detectHumanChallenge } from "./challenge.js";
|
|
7
7
|
import { resolveRealChromeProfileDir, seedProfileCopy } from "./browser-profile.js";
|
|
8
8
|
import { McpRuntime } from "./mcp-runtime.js";
|
|
9
|
+
import { commitLabelRe, ELEMENT_PROBE_FN, FOCUS_PROBE_FN, refuseClick, refuseKey } from "./commit-guard.js";
|
|
9
10
|
import { HumanHandoffError, RunnerInputError } from "./errors.js";
|
|
10
11
|
import { RunnerStore } from "./store.js";
|
|
11
12
|
import { CodingRuntime } from "./coding/runtime.js";
|
|
@@ -837,6 +838,46 @@ export class LocalRunner {
|
|
|
837
838
|
return false;
|
|
838
839
|
return null;
|
|
839
840
|
}
|
|
841
|
+
/** Evaluate a function ON an element (by snapshot ref) and parse its JSON result. Never throws
|
|
842
|
+
* — a null return means "the page could not be asked", which callers must not read as a yes. */
|
|
843
|
+
async evalJson(mcp, ref, label, fn) {
|
|
844
|
+
const res = await mcp.callTool("browser_evaluate", { element: label, target: ref, function: fn }).catch(() => null);
|
|
845
|
+
if (!res || res.isError)
|
|
846
|
+
return null;
|
|
847
|
+
const txt = mcp.textOf(res);
|
|
848
|
+
const i = txt.indexOf("### Result");
|
|
849
|
+
if (i < 0)
|
|
850
|
+
return null;
|
|
851
|
+
const after = txt.slice(i + "### Result".length).trim();
|
|
852
|
+
const end = after.indexOf("\n###");
|
|
853
|
+
const block = (end >= 0 ? after.slice(0, end) : after).trim();
|
|
854
|
+
try {
|
|
855
|
+
return JSON.parse(block);
|
|
856
|
+
}
|
|
857
|
+
catch {
|
|
858
|
+
return null;
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
/**
|
|
862
|
+
* The commit guard, enforced HERE because this is the process that clicks (#627, #629).
|
|
863
|
+
*
|
|
864
|
+
* The cloud's pre-filter reads a label the model wrote; this one reads the element. For a
|
|
865
|
+
* read-only agent that is a DOM fact — does this control submit a POST form — which no label,
|
|
866
|
+
* in any language, can talk its way past. Returns the refusal to hand back to the brain.
|
|
867
|
+
*/
|
|
868
|
+
async commitRefusal(mcp, page, action, guard) {
|
|
869
|
+
const re = commitLabelRe(guard);
|
|
870
|
+
if (action.action === "click") {
|
|
871
|
+
const ref = (action.ref || "").trim();
|
|
872
|
+
const facts = ref ? await this.evalJson(mcp, ref, action.name || action.role || "control", ELEMENT_PROBE_FN) : null;
|
|
873
|
+
return refuseClick(guard, facts, action.name, re);
|
|
874
|
+
}
|
|
875
|
+
if (action.action === "key") {
|
|
876
|
+
const focus = (await page.evaluate(FOCUS_PROBE_FN).catch(() => null));
|
|
877
|
+
return refuseKey(guard, action.key, focus);
|
|
878
|
+
}
|
|
879
|
+
return null;
|
|
880
|
+
}
|
|
840
881
|
/** The snapshot ref the brain must target the element by (standard-tool `target`). */
|
|
841
882
|
refOf(action) {
|
|
842
883
|
const ref = (action.ref || "").trim();
|
|
@@ -943,7 +984,7 @@ export class LocalRunner {
|
|
|
943
984
|
* element by its snapshot ref. A tool-level failure is thrown so the workflow
|
|
944
985
|
* surfaces it to the brain as an `error` (which drives its self-correction).
|
|
945
986
|
*/
|
|
946
|
-
async browserAct(action, resumePath) {
|
|
987
|
+
async browserAct(action, resumePath, guard) {
|
|
947
988
|
const page = await this.getActivePage();
|
|
948
989
|
// Arm résumé auto-attach so a file chooser never blocks the flow (see method).
|
|
949
990
|
// resumePath may be a signed URL (remote runner) or a local path — resolve to
|
|
@@ -970,9 +1011,19 @@ export class LocalRunner {
|
|
|
970
1011
|
title: await active.title().catch(() => ""),
|
|
971
1012
|
challenge: await detectHumanChallenge(active),
|
|
972
1013
|
screenshot: await this.shot(active),
|
|
1014
|
+
commitGuard: { supported: true, mode: guard?.mode },
|
|
973
1015
|
};
|
|
974
1016
|
}
|
|
975
1017
|
const mcp = await this.getMcp();
|
|
1018
|
+
// BEFORE the tool call, never after: this is the last point at which the action is still
|
|
1019
|
+
// only a proposal. `commitGuard.supported` on every reply is how the cloud learns that this
|
|
1020
|
+
// enforcement exists here at all — measured from the runner's own answer, not guessed from
|
|
1021
|
+
// a version number, because the published CLI upgrades on the field's schedule.
|
|
1022
|
+
if (guard) {
|
|
1023
|
+
const refusal = await this.commitRefusal(mcp, page, action, guard);
|
|
1024
|
+
if (refusal)
|
|
1025
|
+
throw new RunnerInputError(refusal);
|
|
1026
|
+
}
|
|
976
1027
|
const res = await this.callBrowserTool(mcp, action);
|
|
977
1028
|
const text = mcp.textOf(res).trim();
|
|
978
1029
|
// A native page dialog (alert/confirm/beforeunload) puts the standard server
|
|
@@ -994,6 +1045,7 @@ export class LocalRunner {
|
|
|
994
1045
|
challenge: await detectHumanChallenge(settled),
|
|
995
1046
|
feedback: dialogMsg ? `a native dialog was accepted: "${dialogMsg}"` : "a native dialog was accepted",
|
|
996
1047
|
screenshot: await this.shot(settled),
|
|
1048
|
+
commitGuard: { supported: true, mode: guard?.mode },
|
|
997
1049
|
};
|
|
998
1050
|
}
|
|
999
1051
|
if (res.isError)
|
|
@@ -1027,6 +1079,7 @@ export class LocalRunner {
|
|
|
1027
1079
|
challenge: await detectHumanChallenge(active),
|
|
1028
1080
|
feedback: feedback || undefined,
|
|
1029
1081
|
screenshot: await this.shot(active),
|
|
1082
|
+
commitGuard: { supported: true, mode: guard?.mode },
|
|
1030
1083
|
};
|
|
1031
1084
|
}
|
|
1032
1085
|
// ── Agent-driven application lifecycle (called by the remote Workflow brain) ──
|
|
@@ -90,8 +90,12 @@ async function route(runner, req, res) {
|
|
|
90
90
|
return json(res, 200, await runner.browserSnapshot(b.taskId));
|
|
91
91
|
}
|
|
92
92
|
if (req.method === "POST" && path === "/browser/act") {
|
|
93
|
+
// `guard` is the commit policy for THIS run (#627, #629) — a rehearsal or a read-only
|
|
94
|
+
// agent. It travels with every action rather than being registered once, because the
|
|
95
|
+
// runner serves many instances at once and a per-connection mode would be a second piece
|
|
96
|
+
// of state to get wrong.
|
|
93
97
|
const body = await readJson(req);
|
|
94
|
-
return json(res, 200, await runner.browserAct(body, body.resumePath));
|
|
98
|
+
return json(res, 200, await runner.browserAct(body, body.resumePath, body.guard));
|
|
95
99
|
}
|
|
96
100
|
if (req.method === "POST" && path === "/browser/event") {
|
|
97
101
|
const b = await readJson(req);
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { createServer, } from "node:http";
|
|
2
2
|
export async function startTestJobServer(port = 0) {
|
|
3
3
|
const submissions = [];
|
|
4
|
+
const searches = [];
|
|
4
5
|
const server = createServer(async (req, res) => {
|
|
5
6
|
try {
|
|
6
|
-
await route(req, res, submissions);
|
|
7
|
+
await route(req, res, submissions, searches);
|
|
7
8
|
}
|
|
8
9
|
catch (error) {
|
|
9
10
|
html(res, 500, `<h1>Server Error</h1><pre>${escapeHtml(String(error))}</pre>`);
|
|
@@ -17,7 +18,10 @@ export async function startTestJobServer(port = 0) {
|
|
|
17
18
|
return {
|
|
18
19
|
url,
|
|
19
20
|
jobUrl: `${url}/jobs/software-engineer`,
|
|
21
|
+
quickApplyUrl: `${url}/jobs/quick-apply`,
|
|
22
|
+
searchUrl: `${url}/search`,
|
|
20
23
|
submissions,
|
|
24
|
+
searches,
|
|
21
25
|
async close() {
|
|
22
26
|
await new Promise((resolve, reject) => {
|
|
23
27
|
server.close((error) => (error ? reject(error) : resolve()));
|
|
@@ -36,7 +40,7 @@ export async function startTestJobServer(port = 0) {
|
|
|
36
40
|
},
|
|
37
41
|
};
|
|
38
42
|
}
|
|
39
|
-
async function route(req, res, submissions) {
|
|
43
|
+
async function route(req, res, submissions, searches) {
|
|
40
44
|
const url = new URL(req.url || "/", "http://127.0.0.1");
|
|
41
45
|
if (req.method === "GET" && url.pathname === "/") {
|
|
42
46
|
redirect(res, "/jobs/software-engineer");
|
|
@@ -51,6 +55,17 @@ async function route(req, res, submissions) {
|
|
|
51
55
|
}));
|
|
52
56
|
return;
|
|
53
57
|
}
|
|
58
|
+
if (req.method === "GET" && url.pathname === "/jobs/quick-apply") {
|
|
59
|
+
html(res, 200, quickApplyPage());
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (req.method === "GET" && url.pathname === "/search") {
|
|
63
|
+
const q = url.searchParams.get("q");
|
|
64
|
+
if (q !== null)
|
|
65
|
+
searches.push(q);
|
|
66
|
+
html(res, 200, searchPage(q));
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
54
69
|
if (req.method === "POST" && url.pathname === "/apply") {
|
|
55
70
|
const submission = await parseApplication(req);
|
|
56
71
|
submissions.unshift(submission);
|
|
@@ -125,6 +140,33 @@ function jobPage(opts = {}) {
|
|
|
125
140
|
</main>
|
|
126
141
|
`);
|
|
127
142
|
}
|
|
143
|
+
/** One click = one real application. No `required` attribute anywhere, so nothing but the guard
|
|
144
|
+
* can stop the POST — a fixture where HTML validation did the stopping would prove nothing. */
|
|
145
|
+
function quickApplyPage() {
|
|
146
|
+
return page("Postuler en 1 clic", `
|
|
147
|
+
<main>
|
|
148
|
+
<section class="job"><h1>Ingénieur logiciel</h1><p class="company">Fixture Labs</p></section>
|
|
149
|
+
<form action="/apply" method="post" enctype="multipart/form-data" class="application-form">
|
|
150
|
+
<input type="hidden" name="fullName" value="Sergey Ivochkin" />
|
|
151
|
+
<input type="hidden" name="email" value="sergey@example.com" />
|
|
152
|
+
<label>Recherche <input name="coverNote" aria-label="Recherche" /></label>
|
|
153
|
+
<button type="submit">Envoyer ma candidature</button>
|
|
154
|
+
</form>
|
|
155
|
+
</main>
|
|
156
|
+
`);
|
|
157
|
+
}
|
|
158
|
+
/** A GET search form: submitting it only READS. */
|
|
159
|
+
function searchPage(q) {
|
|
160
|
+
return page("Recherche", `
|
|
161
|
+
<main>
|
|
162
|
+
<form action="/search" method="get">
|
|
163
|
+
<label>Search <input name="q" aria-label="Search" /></label>
|
|
164
|
+
<button type="submit">Search</button>
|
|
165
|
+
</form>
|
|
166
|
+
<p id="result">${q === null ? "no query" : `searched: ${escapeHtml(q)}`}</p>
|
|
167
|
+
</main>
|
|
168
|
+
`);
|
|
169
|
+
}
|
|
128
170
|
function successPage(submission) {
|
|
129
171
|
return page("Application Received", `
|
|
130
172
|
<main>
|
package/dist/index.js
CHANGED
|
@@ -1217,6 +1217,9 @@ function diffMembership(attached, eligible, thisNode, blocked = /* @__PURE__ */
|
|
|
1217
1217
|
function shouldRegisterOnOpen(reconnect, alreadyRegistered) {
|
|
1218
1218
|
return reconnect || !alreadyRegistered;
|
|
1219
1219
|
}
|
|
1220
|
+
function pendingRegistrations(attached, registered) {
|
|
1221
|
+
return [...attached].filter((id) => !registered.has(id));
|
|
1222
|
+
}
|
|
1220
1223
|
function instanceLabel(inst) {
|
|
1221
1224
|
const short = `${inst.id.slice(0, 8)}\u2026`;
|
|
1222
1225
|
return inst.name ? `${inst.name} (${short})` : short;
|
|
@@ -1336,7 +1339,7 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1336
1339
|
};
|
|
1337
1340
|
for (const id of instanceIds) attach(id, "");
|
|
1338
1341
|
reportRegistration();
|
|
1339
|
-
writeLine(registered.size === instanceIds.length ? `Runtime registered with PAGS \u2713 (${registered.size}/${instanceIds.length} agents)` : `Runtime registration incomplete: ${registered.size}/${instanceIds.length} agents \u2014 retried on each relay (re)connect.`);
|
|
1342
|
+
writeLine(registered.size === instanceIds.length ? `Runtime registered with PAGS \u2713 (${registered.size}/${instanceIds.length} agents)` : `Runtime registration incomplete: ${registered.size}/${instanceIds.length} agents \u2014 retried on each relay (re)connect${watchInstances ? " and every 20s while this runs" : ""}.`);
|
|
1340
1343
|
writeLine("");
|
|
1341
1344
|
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");
|
|
1342
1345
|
writeLine(` \u2705 CONNECTED \u2014 WebSocket relay \xB7 ${hostname3()}`);
|
|
@@ -1406,6 +1409,11 @@ async function connectViaRelay(instanceIds, localUrl, runnerToken, opts, force =
|
|
|
1406
1409
|
attach(inst.id, instanceLabel(inst));
|
|
1407
1410
|
}
|
|
1408
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
|
+
}
|
|
1409
1417
|
} catch {
|
|
1410
1418
|
}
|
|
1411
1419
|
tick();
|