@proagentstore/cli 0.4.82 → 0.4.83
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/local-apply/bridge.js +118 -3
- package/dist/browser-runner/local-apply/contract.js +25 -1
- package/dist/browser-runner/local-apply/engine.js +2 -0
- package/dist/browser-runner/local-apply/runtime.js +67 -1
- package/dist/browser-runner/server.js +3 -0
- package/package.json +1 -1
|
@@ -29,7 +29,7 @@ import { normalize } from "../local-artifact/engine.js";
|
|
|
29
29
|
import { evaluateResult, hostOf, refRole } from "../local-browser/bridge.js";
|
|
30
30
|
const READ_TOOLS = new Set(["browser_navigate", "browser_navigate_back", "browser_snapshot", "browser_wait_for"]);
|
|
31
31
|
const FILL_TOOLS = new Set(["browser_type", "browser_select_option"]);
|
|
32
|
-
const BRIDGE_TOOLS = ["upload_artifact", "request_answer", "ready_for_review"];
|
|
32
|
+
const BRIDGE_TOOLS = ["upload_artifact", "request_answer", "ready_for_review", "supervisor_checkpoint"];
|
|
33
33
|
const REFUSED = new Set([
|
|
34
34
|
"browser_fill_form",
|
|
35
35
|
"browser_file_upload",
|
|
@@ -70,6 +70,9 @@ const INSPECT_PAGE = `() => {
|
|
|
70
70
|
duplicate: /you('ve| have) already applied|already applied (for|to) this|application (already exists|has already been (submitted|received))|duplicate application/.test(text),
|
|
71
71
|
antiBot: /unusual traffic|access (is )?denied|request (has been )?blocked|bot detected|checking (if the site connection is secure|your browser)/.test(text),
|
|
72
72
|
confirmed: /thank you for (your )?appl|application (has been |was )?(received|submitted)|we('ve| have) received your application|successfully (applied|submitted)/.test(text),
|
|
73
|
+
unavailable: /(?:this |the )?(?:job|position|posting|role|opportunity).{0,120}(?:has )?(?:expired|closed|been filled|is no longer available|is no longer accepting applications)|(?:expired|closed|no longer available|no longer accepting applications).{0,120}(?:job|position|posting|role|opportunity)/.test(text)
|
|
74
|
+
? (/expired/.test(text) ? "expired" : "unavailable")
|
|
75
|
+
: null,
|
|
73
76
|
};
|
|
74
77
|
}`;
|
|
75
78
|
const text = (t, isError = false) => ({ content: [{ type: "text", text: t }], ...(isError ? { isError: true } : {}) });
|
|
@@ -106,10 +109,14 @@ export class ApplyBridge {
|
|
|
106
109
|
pages = 0;
|
|
107
110
|
lastSnapshot = "";
|
|
108
111
|
admitted = new Set();
|
|
112
|
+
/** Fill and submit are disabled until the cloud supervisor releases the current page checkpoint. */
|
|
113
|
+
supervisorApproved = false;
|
|
109
114
|
filled = 0;
|
|
110
115
|
uploaded = new Set();
|
|
111
116
|
reviewReady = false;
|
|
112
117
|
summary = null;
|
|
118
|
+
/** Present only after the CLI explicitly reports a notice that the runner independently saw. */
|
|
119
|
+
unavailable = null;
|
|
113
120
|
submitAttempted = false;
|
|
114
121
|
submitted = null;
|
|
115
122
|
/** The last thing that stopped the work and was not resolved — the block reason if the run ends here. */
|
|
@@ -119,7 +126,7 @@ export class ApplyBridge {
|
|
|
119
126
|
this.host = host;
|
|
120
127
|
}
|
|
121
128
|
get done() {
|
|
122
|
-
return this.reviewReady || this.submitAttempted;
|
|
129
|
+
return this.reviewReady || this.submitAttempted || this.unavailable !== null;
|
|
123
130
|
}
|
|
124
131
|
async listTools() {
|
|
125
132
|
const own = (await this.browser.listTools()).filter((t) => READ_TOOLS.has(t.name) || FILL_TOOLS.has(t.name) || t.name === "browser_click" || t.name === "browser_press_key");
|
|
@@ -149,6 +156,16 @@ export class ApplyBridge {
|
|
|
149
156
|
description: "The form is filled as far as the sources allow. Stop here; the owner reviews it. Call once, last.",
|
|
150
157
|
inputSchema: { type: "object", properties: { summary: { type: "string" } }, required: ["summary"] },
|
|
151
158
|
},
|
|
159
|
+
{
|
|
160
|
+
name: "supervisor_checkpoint",
|
|
161
|
+
description: "Pause safely for the cloud supervisor. Use the exact checkpointId supplied in your task; do not continue until this returns continue. request_review and stop are terminally handled by the runner.",
|
|
162
|
+
inputSchema: { type: "object", properties: { checkpointId: { type: "string", minLength: 1, maxLength: 300 }, phase: { type: "string", enum: ["initial", "post_navigation", "before_submit", "uncertain"] } }, required: ["checkpointId", "phase"], additionalProperties: false },
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
name: "report_job_unavailable",
|
|
166
|
+
description: "End the run when a fresh browser snapshot shows that this job is expired, closed or unavailable. The runner independently verifies the page notice; it refuses unsupported reports.",
|
|
167
|
+
inputSchema: { type: "object", properties: { reason: { type: "string", enum: ["expired", "unavailable"] } }, required: ["reason"] },
|
|
168
|
+
},
|
|
152
169
|
];
|
|
153
170
|
}
|
|
154
171
|
async callTool(name, args = {}) {
|
|
@@ -158,6 +175,10 @@ export class ApplyBridge {
|
|
|
158
175
|
return this.review(typeof args.summary === "string" ? args.summary : "");
|
|
159
176
|
if (name === "request_answer")
|
|
160
177
|
return this.requestAnswer(args);
|
|
178
|
+
if (name === "supervisor_checkpoint")
|
|
179
|
+
return this.supervisorCheckpoint(args);
|
|
180
|
+
if (name === "report_job_unavailable")
|
|
181
|
+
return this.reportUnavailable(args);
|
|
161
182
|
if (REFUSED.has(name))
|
|
162
183
|
return this.refuse(name, "read", `${name} is not available to an application run.`);
|
|
163
184
|
if (!READ_TOOLS.has(name) && !FILL_TOOLS.has(name) && name !== "browser_click" && name !== "browser_press_key" && name !== "upload_artifact")
|
|
@@ -200,7 +221,8 @@ export class ApplyBridge {
|
|
|
200
221
|
const v = res && !res.isError ? evaluateResult(textOf(res)) : null;
|
|
201
222
|
if (!v || typeof v.url !== "string")
|
|
202
223
|
return null;
|
|
203
|
-
|
|
224
|
+
const unavailable = v.unavailable === "expired" || v.unavailable === "unavailable" ? v.unavailable : null;
|
|
225
|
+
return { url: v.url, title: String(v.title ?? ""), captcha: !!v.captcha, login: !!v.login, duplicate: !!v.duplicate, antiBot: !!v.antiBot, confirmed: !!v.confirmed, unavailable };
|
|
204
226
|
}
|
|
205
227
|
blockerOf(s) {
|
|
206
228
|
return s.captcha ? "captcha" : s.antiBot ? "anti_bot" : s.duplicate ? "duplicate_application" : s.login ? "login_required" : null;
|
|
@@ -213,12 +235,16 @@ export class ApplyBridge {
|
|
|
213
235
|
return false;
|
|
214
236
|
if (recheck && !(await recheck()))
|
|
215
237
|
return false;
|
|
238
|
+
// A person may have changed the page or its answers while handling the pause.
|
|
239
|
+
this.supervisorApproved = false;
|
|
216
240
|
this.blocked = null;
|
|
217
241
|
return true;
|
|
218
242
|
}
|
|
219
243
|
/** After a page change: count it, trace it, and stop for a person when the page needs one. */
|
|
220
244
|
async landed(toolText, known) {
|
|
221
245
|
const state = known ?? (await this.inspect());
|
|
246
|
+
// A navigation can put the CLI on a materially different form, even on the same origin.
|
|
247
|
+
this.supervisorApproved = false;
|
|
222
248
|
if (!state)
|
|
223
249
|
return text(toolText);
|
|
224
250
|
this.pages++;
|
|
@@ -316,13 +342,21 @@ export class ApplyBridge {
|
|
|
316
342
|
// A native file input opens the file chooser for upload_artifact — a DOM fact, whatever its label.
|
|
317
343
|
const fileInput = facts.tag === "input" && facts.type === "file";
|
|
318
344
|
if (fileInput || (t.role === "button" && STEP_NAME.test(t.name.trim()) && !facts.submits)) {
|
|
345
|
+
const checkpoint = this.requireSupervisor("browser_click", "fill");
|
|
346
|
+
if (checkpoint)
|
|
347
|
+
return checkpoint;
|
|
319
348
|
this.allow("browser_click", "fill");
|
|
320
349
|
const res = await this.browser.callTool("browser_click", forward);
|
|
350
|
+
if (!res.isError && !fileInput)
|
|
351
|
+
this.supervisorApproved = false;
|
|
321
352
|
return { content: [{ type: "text", text: textOf(res) }], ...(res.isError ? { isError: true } : {}) };
|
|
322
353
|
}
|
|
323
354
|
return this.refuse("browser_click", "read", `"${t.name || t.role}" is not a control an application run may press. Links, form-step buttons (Next, Upload, Add) and answers are allowed.`);
|
|
324
355
|
}
|
|
325
356
|
async fill(tool, role, forward) {
|
|
357
|
+
const checkpoint = this.requireSupervisor(tool, "fill");
|
|
358
|
+
if (checkpoint)
|
|
359
|
+
return checkpoint;
|
|
326
360
|
const res = await this.browser.callTool(tool, forward);
|
|
327
361
|
if (res.isError)
|
|
328
362
|
return { content: [{ type: "text", text: textOf(res) }], isError: true };
|
|
@@ -368,6 +402,9 @@ export class ApplyBridge {
|
|
|
368
402
|
return { content: [{ type: "text", text: textOf(res) }], ...(res.isError ? { isError: true } : {}) };
|
|
369
403
|
}
|
|
370
404
|
async upload(args) {
|
|
405
|
+
const checkpoint = this.requireSupervisor("upload_artifact", "fill");
|
|
406
|
+
if (checkpoint)
|
|
407
|
+
return checkpoint;
|
|
371
408
|
const kind = args.kind === "resume" || args.kind === "cover_letter" ? args.kind : null;
|
|
372
409
|
if (!kind)
|
|
373
410
|
return this.refuse("upload_artifact", "fill", "kind must be resume or cover_letter.");
|
|
@@ -401,6 +438,74 @@ export class ApplyBridge {
|
|
|
401
438
|
return text("The owner resumed without answering: leave that field empty and continue with the others.");
|
|
402
439
|
return text("The owner answered. Their answer is now part of your sources — quote it as source_quote. Take a browser_snapshot and continue.");
|
|
403
440
|
}
|
|
441
|
+
/**
|
|
442
|
+
* The CLI may ask to stop at a named checkpoint, but it cannot smuggle a decision through this
|
|
443
|
+
* tool. The runner waits for a separately persisted, typed directive and terminal directives are
|
|
444
|
+
* resolved by the runtime before this call returns.
|
|
445
|
+
*/
|
|
446
|
+
async supervisorCheckpoint(args) {
|
|
447
|
+
const checkpointId = typeof args.checkpointId === "string" ? args.checkpointId.trim() : "";
|
|
448
|
+
if (!/^[A-Za-z0-9_.:-]{1,300}$/.test(checkpointId))
|
|
449
|
+
return text("supervisor_checkpoint needs a safe checkpointId (letters, numbers, dot, underscore, colon or hyphen).", true);
|
|
450
|
+
const phase = args.phase === "initial" || args.phase === "post_navigation" || args.phase === "before_submit" || args.phase === "uncertain" ? args.phase : null;
|
|
451
|
+
if (!phase)
|
|
452
|
+
return text("supervisor_checkpoint needs phase initial, post_navigation, before_submit or uncertain.", true);
|
|
453
|
+
const state = await this.inspect();
|
|
454
|
+
const blockers = new Set();
|
|
455
|
+
if (state?.captcha)
|
|
456
|
+
blockers.add("captcha");
|
|
457
|
+
if (state?.login)
|
|
458
|
+
blockers.add("login_required");
|
|
459
|
+
if (state?.antiBot)
|
|
460
|
+
blockers.add("anti_bot");
|
|
461
|
+
if (state?.duplicate)
|
|
462
|
+
blockers.add("duplicate_application");
|
|
463
|
+
if (this.blocked && ["missing_answer", "screening_ambiguity", "external_redirect", "duplicate_application", "anti_bot", "login_required", "captcha"].includes(this.blocked.reason))
|
|
464
|
+
blockers.add(this.blocked.reason);
|
|
465
|
+
const url = state?.url;
|
|
466
|
+
const domain = url ? hostOf(url) ?? undefined : undefined;
|
|
467
|
+
const directive = await this.host.supervisorCheckpoint({
|
|
468
|
+
schemaVersion: 1,
|
|
469
|
+
checkpointId,
|
|
470
|
+
facts: {
|
|
471
|
+
phase,
|
|
472
|
+
actions: this.actions,
|
|
473
|
+
filled: this.filled,
|
|
474
|
+
uploaded: this.uploaded.size,
|
|
475
|
+
blockers: [...blockers],
|
|
476
|
+
...(url ? { url } : {}),
|
|
477
|
+
...(domain ? { domain } : {}),
|
|
478
|
+
...(state?.title ? { title: state.title.slice(0, 300) } : {}),
|
|
479
|
+
},
|
|
480
|
+
});
|
|
481
|
+
if (directive === "continue") {
|
|
482
|
+
this.supervisorApproved = true;
|
|
483
|
+
return text("The persisted supervisor directive is continue. You may continue using only the application bridge tools.");
|
|
484
|
+
}
|
|
485
|
+
return text(`The persisted supervisor directive is ${directive}. The runner has ended this application run locally; do not take any further action.`, true);
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* A terminal result needs the runner's own fresh observation, not the CLI's reading of a page.
|
|
489
|
+
* The snapshot requirement makes the notice visible to the CLI; the static page probe verifies
|
|
490
|
+
* its category and current URL before any evidence is retained.
|
|
491
|
+
*/
|
|
492
|
+
async reportUnavailable(args) {
|
|
493
|
+
const reason = args.reason === "expired" || args.reason === "unavailable" ? args.reason : null;
|
|
494
|
+
if (!reason)
|
|
495
|
+
return text("reason must be expired or unavailable.", true);
|
|
496
|
+
if (!this.lastSnapshot)
|
|
497
|
+
return text("Take a fresh browser_snapshot that shows the listing notice before reporting it unavailable.", true);
|
|
498
|
+
const state = await this.inspect();
|
|
499
|
+
if (!state?.unavailable)
|
|
500
|
+
return text("The runner could not verify an expired or unavailable listing notice on the current page. Continue only if the page is still actionable, or take a fresh snapshot.", true);
|
|
501
|
+
if (state.unavailable !== reason)
|
|
502
|
+
return text(`The runner verified this listing as ${state.unavailable}, not ${reason}. Report that reason or take a fresh snapshot.`, true);
|
|
503
|
+
const url = state.url;
|
|
504
|
+
const domain = hostOf(url) ?? "";
|
|
505
|
+
this.unavailable = { reason, url, observedAt: new Date(this.host.now()).toISOString(), source: "page_notice" };
|
|
506
|
+
this.host.emit({ type: "job.unavailable", url, domain, detail: { reason, source: "page_notice" } });
|
|
507
|
+
return text(`Recorded the runner-verified ${reason} listing notice. Stop now.`);
|
|
508
|
+
}
|
|
404
509
|
review(summary) {
|
|
405
510
|
this.reviewReady = true;
|
|
406
511
|
this.summary = summary; // bounded where the result is built (runtime.ts `end`)
|
|
@@ -409,6 +514,9 @@ export class ApplyBridge {
|
|
|
409
514
|
}
|
|
410
515
|
/** The final Submit. Never under fill_and_review; once, gated and traced, under auto_submit. */
|
|
411
516
|
async submit(forward) {
|
|
517
|
+
const checkpoint = this.requireSupervisor("browser_click", "submit");
|
|
518
|
+
if (checkpoint)
|
|
519
|
+
return checkpoint;
|
|
412
520
|
if (this.host.mode !== "auto_submit" || !this.host.gateId) {
|
|
413
521
|
this.host.emit({ type: "policy.decision", detail: { tool: "browser_click", class: "submit", decision: "refused", reason: "fill_and_review" } });
|
|
414
522
|
this.review("Stopped at the final submit: this run fills and waits for review.");
|
|
@@ -439,4 +547,11 @@ export class ApplyBridge {
|
|
|
439
547
|
this.host.emit({ type: "submit.unconfirmed", ...(after ? { url: after.url } : {}), domain, detail: { gateId: this.host.gateId } });
|
|
440
548
|
return text("The submit was pressed but no confirmation was seen. Do not press it again. Stop now.", true);
|
|
441
549
|
}
|
|
550
|
+
/** A cloud-persisted checkpoint, not a prompt instruction, authorizes each page's writes. */
|
|
551
|
+
requireSupervisor(tool, actionClass) {
|
|
552
|
+
if (this.supervisorApproved)
|
|
553
|
+
return null;
|
|
554
|
+
this.host.emit({ type: "policy.decision", detail: { tool, class: actionClass, decision: "refused", reason: "supervisor_checkpoint" } });
|
|
555
|
+
return text(`A persisted supervisor_checkpoint must return continue before ${actionClass === "submit" ? "the final submit" : "any fill or upload"}. Take a browser_snapshot if needed, then call supervisor_checkpoint.`, true);
|
|
556
|
+
}
|
|
442
557
|
}
|
|
@@ -31,6 +31,8 @@ export const LOCAL_APPLY_RUN_PATH = "/local-apply/run";
|
|
|
31
31
|
export const LOCAL_APPLY_STATUS_PATH = "/local-apply/status";
|
|
32
32
|
export const LOCAL_APPLY_RESUME_PATH = "/local-apply/resume";
|
|
33
33
|
export const LOCAL_APPLY_CANCEL_PATH = "/local-apply/cancel";
|
|
34
|
+
/** Delivers a cloud-persisted supervisory decision to one paused checkpoint. */
|
|
35
|
+
export const LOCAL_APPLY_DIRECTIVE_PATH = "/local-apply/directive";
|
|
34
36
|
export const LOCAL_APPLY_ENGINES = ["claude", "codex"];
|
|
35
37
|
export const LOCAL_APPLY_AUTH_MODES = ["machine", "subscription"];
|
|
36
38
|
export const LOCAL_APPLY_MODES = ["fill_and_review", "auto_submit"];
|
|
@@ -43,6 +45,7 @@ export const LOCAL_APPLY_PAUSE_REASONS = [
|
|
|
43
45
|
"external_redirect",
|
|
44
46
|
"duplicate_application",
|
|
45
47
|
"anti_bot",
|
|
48
|
+
"supervisor_checkpoint",
|
|
46
49
|
];
|
|
47
50
|
export const LOCAL_APPLY_BLOCK_REASONS = [
|
|
48
51
|
...LOCAL_APPLY_PAUSE_REASONS,
|
|
@@ -52,7 +55,9 @@ export const LOCAL_APPLY_BLOCK_REASONS = [
|
|
|
52
55
|
"api_key_refused",
|
|
53
56
|
"source_unavailable",
|
|
54
57
|
"artifact_changed",
|
|
58
|
+
"job_unavailable",
|
|
55
59
|
];
|
|
60
|
+
export const LOCAL_APPLY_UNAVAILABLE_REASONS = ["expired", "unavailable"];
|
|
56
61
|
export const LOCAL_APPLY_EVENT_TYPES = [
|
|
57
62
|
"engine.auth_checked",
|
|
58
63
|
"engine.started",
|
|
@@ -67,16 +72,21 @@ export const LOCAL_APPLY_EVENT_TYPES = [
|
|
|
67
72
|
"submit.attempted",
|
|
68
73
|
"submit.confirmed",
|
|
69
74
|
"submit.unconfirmed",
|
|
75
|
+
"job.unavailable",
|
|
76
|
+
"supervisor.checkpoint",
|
|
77
|
+
"supervisor.directive",
|
|
70
78
|
"run.paused",
|
|
71
79
|
"run.resumed",
|
|
72
80
|
"note",
|
|
73
81
|
];
|
|
74
82
|
export const LOCAL_APPLY_ENGINE_AUTHS = ["machine-login", "subscription", "api-key", "missing_login", "unknown"];
|
|
83
|
+
export const LOCAL_APPLY_SUPERVISOR_DIRECTIVES = ["continue", "request_review", "stop"];
|
|
84
|
+
export const LOCAL_APPLY_SUPERVISOR_PHASES = ["initial", "before_submit", "post_navigation", "uncertain"];
|
|
75
85
|
export const LOCAL_APPLY_CAPS = { questions: 20, questionChars: 300, answers: 20, answerChars: 2000, summary: 2000 };
|
|
76
86
|
const str = (v, max) => (typeof v === "string" && v.trim() ? v.trim().slice(0, max) : null);
|
|
77
87
|
const oneOf = (list, v) => (list.includes(v) ? v : null);
|
|
78
88
|
const isHttpUrl = (v) => /^https?:\/\/[^\s/]+/i.test(v);
|
|
79
|
-
const DETAIL_KEYS = new Set(["engine", "authMode", "engineAuth", "mode", "class", "tool", "decision", "reason", "role", "kind", "path", "sha256", "bytes", "gateId", "exitCode", "count", "status", "basis"]);
|
|
89
|
+
const DETAIL_KEYS = new Set(["engine", "authMode", "engineAuth", "mode", "class", "tool", "decision", "reason", "role", "kind", "path", "sha256", "bytes", "gateId", "exitCode", "count", "status", "basis", "source", "checkpointId", "directive", "phase", "actions", "filled", "uploaded"]);
|
|
80
90
|
/** A validated event, or null. Detail keeps only whitelisted, primitive, bounded values. */
|
|
81
91
|
export function parseLocalApplyEvent(raw) {
|
|
82
92
|
if (!raw || typeof raw !== "object")
|
|
@@ -158,7 +168,21 @@ export function parseLocalApplyResult(raw) {
|
|
|
158
168
|
.filter((q) => typeof q === "string" && q.trim() !== "")
|
|
159
169
|
.slice(0, LOCAL_APPLY_CAPS.questions)
|
|
160
170
|
.map((q) => q.trim().slice(0, LOCAL_APPLY_CAPS.questionChars));
|
|
171
|
+
if (out.blockReason === "job_unavailable") {
|
|
172
|
+
const evidence = (o.unavailable && typeof o.unavailable === "object" && !Array.isArray(o.unavailable) ? o.unavailable : {});
|
|
173
|
+
const reason = oneOf(LOCAL_APPLY_UNAVAILABLE_REASONS, evidence.reason);
|
|
174
|
+
const url = str(evidence.url, 2000);
|
|
175
|
+
const observedAt = str(evidence.observedAt, 40);
|
|
176
|
+
if (!reason || !url || !isHttpUrl(url) || !observedAt || Number.isNaN(Date.parse(observedAt)) || evidence.source !== "page_notice") {
|
|
177
|
+
return { error: "a job_unavailable result needs runner-verified unavailable evidence (reason, url, observedAt, source)" };
|
|
178
|
+
}
|
|
179
|
+
out.unavailable = { reason, url, observedAt, source: "page_notice" };
|
|
180
|
+
}
|
|
181
|
+
else if (o.unavailable !== undefined)
|
|
182
|
+
return { error: "unavailable evidence is only valid for a job_unavailable result" };
|
|
161
183
|
}
|
|
184
|
+
else if (o.unavailable !== undefined)
|
|
185
|
+
return { error: "unavailable evidence is only valid for a job_unavailable result" };
|
|
162
186
|
if (outcome === "failed")
|
|
163
187
|
out.error = str(o.error, 1000) ?? "The run failed without a reason.";
|
|
164
188
|
return { result: out };
|
|
@@ -40,10 +40,12 @@ export function applyPrompt(e, sources) {
|
|
|
40
40
|
...sources.map((s) => `<source name="${s.label}">\n${s.text}\n</source>\n`),
|
|
41
41
|
"How to work:",
|
|
42
42
|
`- Your only tools are from the ${BRIDGE_SERVER_NAME} server. Open the application page with ${t("browser_navigate")}, read it with ${t("browser_snapshot")}, and act on elements by their ref.`,
|
|
43
|
+
`- Before any fill, upload, form-step click, or final submit, call ${t("supervisor_checkpoint")} and wait for its persisted continue directive. Call it after the initial snapshot (phase initial), after every page-changing move (phase post_navigation), and immediately before a final submit (phase before_submit). Use a stable safe checkpointId for each point. A page change clears the previous approval; request_review and stop end the run locally.`,
|
|
43
44
|
`- Every answer — ${t("browser_type")}, ${t("browser_select_option")}, or a click on a checkbox, radio or option — must carry source_quote: the exact text from a source above that the value comes from. Type values exactly as the source writes them.`,
|
|
44
45
|
`- If a question has no answer in the sources, or is ambiguous, call ${t("request_answer")}. Never guess, never invent, never pick a "safe" default.`,
|
|
45
46
|
`- To attach a document, click the field's upload control, then call ${t("upload_artifact")} with kind resume or cover_letter.`,
|
|
46
47
|
"- Never sign in, solve a captcha, accept terms or work around any block; the run pauses for the owner by itself.",
|
|
48
|
+
`- If a fresh snapshot shows the job itself is expired, closed or unavailable, call ${t("report_job_unavailable")} with reason expired or unavailable. The runner will independently verify the page notice; do not report it from inference alone.`,
|
|
47
49
|
`- Stay on the application's site. Limits: ${e.limits.maxPages} pages, ${e.limits.maxActions} browser actions, ${e.limits.maxMinutes} minutes.`,
|
|
48
50
|
finish,
|
|
49
51
|
].join("\n");
|
|
@@ -194,6 +194,7 @@ export class LocalApplyRuntime {
|
|
|
194
194
|
engineAuth: "unknown",
|
|
195
195
|
cancelled: false,
|
|
196
196
|
timedOut: false,
|
|
197
|
+
checkpoints: new Map(),
|
|
197
198
|
};
|
|
198
199
|
this.runs.set(envelope.runId, run);
|
|
199
200
|
void this.launch(run, selfUrl).catch((err) => {
|
|
@@ -252,6 +253,7 @@ export class LocalApplyRuntime {
|
|
|
252
253
|
run.bridge = new ApplyBridge(run.browser.tools, {
|
|
253
254
|
emit: (ev) => this.emit(run, ev),
|
|
254
255
|
pause: (p) => this.pause(run, p),
|
|
256
|
+
supervisorCheckpoint: (checkpointId) => this.supervisorCheckpoint(run, checkpointId),
|
|
255
257
|
isAllowed: (host) => [...run.allow].some((d) => domainWithin(host, d)),
|
|
256
258
|
grounding: () => run.grounding.join("\n"),
|
|
257
259
|
artifactPath: (kind) => {
|
|
@@ -311,6 +313,8 @@ export class LocalApplyRuntime {
|
|
|
311
313
|
return { outcome: "failed", summary, error: "Cancelled by the owner" };
|
|
312
314
|
if (b?.submitted)
|
|
313
315
|
return { outcome: "submitted", summary, submitted: b.submitted };
|
|
316
|
+
if (b?.unavailable)
|
|
317
|
+
return { outcome: "blocked", summary, blockReason: "job_unavailable", unavailable: b.unavailable };
|
|
314
318
|
if (b?.submitAttempted)
|
|
315
319
|
return { outcome: "blocked", summary, blockReason: "submit_unconfirmed", questions: b.blocked?.questions ?? [] };
|
|
316
320
|
if (b?.reviewReady)
|
|
@@ -343,6 +347,31 @@ export class LocalApplyRuntime {
|
|
|
343
347
|
}
|
|
344
348
|
return new Promise((done) => run.waiters.push(done));
|
|
345
349
|
}
|
|
350
|
+
/**
|
|
351
|
+
* Wait for a cloud-brain decision without accepting a model's prose as permission. A directive
|
|
352
|
+
* can be stored before this call reaches its pause (the delivery race), but a normal `resume`
|
|
353
|
+
* can never release this pause.
|
|
354
|
+
*/
|
|
355
|
+
supervisorCheckpoint(run, supervisor) {
|
|
356
|
+
const { checkpointId } = supervisor;
|
|
357
|
+
let checkpoint = run.checkpoints.get(checkpointId);
|
|
358
|
+
if (!checkpoint) {
|
|
359
|
+
checkpoint = {};
|
|
360
|
+
run.checkpoints.set(checkpointId, checkpoint);
|
|
361
|
+
}
|
|
362
|
+
if (!checkpoint.checkpoint) {
|
|
363
|
+
checkpoint.checkpoint = supervisor;
|
|
364
|
+
this.emit(run, {
|
|
365
|
+
type: "supervisor.checkpoint",
|
|
366
|
+
...(supervisor.facts.url ? { url: supervisor.facts.url } : {}),
|
|
367
|
+
...(supervisor.facts.domain ? { domain: supervisor.facts.domain } : {}),
|
|
368
|
+
detail: { checkpointId, phase: supervisor.facts.phase, actions: supervisor.facts.actions, filled: supervisor.facts.filled, uploaded: supervisor.facts.uploaded },
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
if (checkpoint.directive)
|
|
372
|
+
return Promise.resolve(checkpoint.directive);
|
|
373
|
+
return this.pause(run, { reason: "supervisor_checkpoint", checkpoint: supervisor }).then(() => run.checkpoints.get(checkpointId)?.directive ?? "stop");
|
|
374
|
+
}
|
|
346
375
|
release(run, outcome) {
|
|
347
376
|
if (run.state === "paused") {
|
|
348
377
|
run.state = "running";
|
|
@@ -360,6 +389,8 @@ export class LocalApplyRuntime {
|
|
|
360
389
|
const run = this.get(String(o.runId ?? ""));
|
|
361
390
|
if (run.state === "ended")
|
|
362
391
|
throw new RunnerInputError("The run has ended", 409);
|
|
392
|
+
if (run.pause?.reason === "supervisor_checkpoint")
|
|
393
|
+
throw new RunnerInputError("This run is waiting for a persisted supervisor directive, not an owner resume", 409);
|
|
363
394
|
for (const a of (Array.isArray(o.answers) ? o.answers : []).slice(0, LOCAL_APPLY_CAPS.answers)) {
|
|
364
395
|
const r = (a && typeof a === "object" ? a : {});
|
|
365
396
|
if (typeof r.question === "string" && typeof r.answer === "string" && r.answer.trim())
|
|
@@ -370,6 +401,41 @@ export class LocalApplyRuntime {
|
|
|
370
401
|
this.release(run, "resumed");
|
|
371
402
|
return this.status({ runId: run.envelope.runId, afterSeq: run.seq });
|
|
372
403
|
}
|
|
404
|
+
/**
|
|
405
|
+
* Store then apply an immutable cloud directive. The caller persists it before this runner
|
|
406
|
+
* endpoint is reached; this in-process record makes delivery idempotent and closes the race
|
|
407
|
+
* where a directive arrives just before the bridge begins waiting.
|
|
408
|
+
*/
|
|
409
|
+
directive(raw) {
|
|
410
|
+
const o = (raw && typeof raw === "object" ? raw : {});
|
|
411
|
+
const run = this.get(typeof o.runId === "string" ? o.runId : "");
|
|
412
|
+
const checkpointId = typeof o.checkpointId === "string" && SAFE_ID.test(o.checkpointId) ? o.checkpointId : null;
|
|
413
|
+
const directive = o.directive === "continue" || o.directive === "request_review" || o.directive === "stop" ? o.directive : null;
|
|
414
|
+
if (o.schemaVersion !== 1 || !checkpointId || !directive)
|
|
415
|
+
throw new RunnerInputError("directive needs schemaVersion: 1, a safe checkpointId, and directive continue, request_review or stop");
|
|
416
|
+
if (run.state === "ended")
|
|
417
|
+
throw new RunnerInputError("The run has ended", 409);
|
|
418
|
+
let checkpoint = run.checkpoints.get(checkpointId);
|
|
419
|
+
if (checkpoint?.directive) {
|
|
420
|
+
if (checkpoint.directive !== directive)
|
|
421
|
+
throw new RunnerInputError("A different directive is already recorded for this checkpoint", 409);
|
|
422
|
+
return this.status({ runId: run.envelope.runId, afterSeq: run.seq });
|
|
423
|
+
}
|
|
424
|
+
checkpoint ??= {};
|
|
425
|
+
checkpoint.directive = directive;
|
|
426
|
+
run.checkpoints.set(checkpointId, checkpoint);
|
|
427
|
+
this.emit(run, { type: "supervisor.directive", detail: { checkpointId, directive } });
|
|
428
|
+
const waitingForThis = run.state === "paused" && run.pause?.reason === "supervisor_checkpoint" && run.pause.checkpoint?.checkpointId === checkpointId;
|
|
429
|
+
if (waitingForThis && directive === "continue")
|
|
430
|
+
this.release(run, "resumed");
|
|
431
|
+
else if (waitingForThis && directive === "request_review") {
|
|
432
|
+
this.end(run, { outcome: "awaiting_review", summary: "The supervisor requested an owner review." });
|
|
433
|
+
}
|
|
434
|
+
else if (waitingForThis && directive === "stop") {
|
|
435
|
+
this.end(run, { outcome: "blocked", blockReason: "incomplete", questions: ["The supervisor stopped this run before it could continue."] });
|
|
436
|
+
}
|
|
437
|
+
return this.status({ runId: run.envelope.runId, afterSeq: run.seq });
|
|
438
|
+
}
|
|
373
439
|
cancel(raw) {
|
|
374
440
|
const runId = String(raw?.runId ?? "");
|
|
375
441
|
const run = this.get(runId);
|
|
@@ -456,7 +522,7 @@ export class LocalApplyRuntime {
|
|
|
456
522
|
submitAttempted: b?.submitAttempted ?? false,
|
|
457
523
|
summary: redactText((r.summary ?? "").slice(0, LOCAL_APPLY_CAPS.summary), run.secrets),
|
|
458
524
|
...(r.outcome === "submitted" && r.submitted ? { submitted: r.submitted } : {}),
|
|
459
|
-
...(r.outcome === "blocked" ? { blockReason: r.blockReason ?? "incomplete", questions: (r.questions ?? []).map((q) => redactText(q, run.secrets)) } : {}),
|
|
525
|
+
...(r.outcome === "blocked" ? { blockReason: r.blockReason ?? "incomplete", questions: (r.questions ?? []).map((q) => redactText(q, run.secrets)), ...(r.blockReason === "job_unavailable" && r.unavailable ? { unavailable: r.unavailable } : {}) } : {}),
|
|
460
526
|
...(r.outcome === "failed" ? { error: redactText((r.error ?? "The run failed without a reason.").slice(0, 1000), run.secrets) } : {}),
|
|
461
527
|
};
|
|
462
528
|
if (run.child && run.child.exitCode === null)
|
|
@@ -201,6 +201,9 @@ async function route(runner, req, res) {
|
|
|
201
201
|
if (req.method === "POST" && path === "/local-apply/resume") {
|
|
202
202
|
return json(res, 200, runner.localApply.resume(await readJson(req)));
|
|
203
203
|
}
|
|
204
|
+
if (req.method === "POST" && path === "/local-apply/directive") {
|
|
205
|
+
return json(res, 200, runner.localApply.directive(await readJson(req)));
|
|
206
|
+
}
|
|
204
207
|
if (req.method === "POST" && path === "/local-apply/cancel") {
|
|
205
208
|
return json(res, 200, runner.localApply.cancel(await readJson(req)));
|
|
206
209
|
}
|