@proagentstore/cli 0.4.83 → 0.4.85
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.
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
*
|
|
25
25
|
* The trace carries classes, domains, decisions and artifact handles — never a typed value.
|
|
26
26
|
*/
|
|
27
|
-
import {
|
|
27
|
+
import { classifyApplyClick } from "./contract.js";
|
|
28
|
+
import { ELEMENT_PROBE_FN } from "../commit-guard.js";
|
|
28
29
|
import { normalize } from "../local-artifact/engine.js";
|
|
29
30
|
import { evaluateResult, hostOf, refRole } from "../local-browser/bridge.js";
|
|
30
31
|
const READ_TOOLS = new Set(["browser_navigate", "browser_navigate_back", "browser_snapshot", "browser_wait_for"]);
|
|
@@ -105,6 +106,7 @@ function withQuote(schema, required) {
|
|
|
105
106
|
export class ApplyBridge {
|
|
106
107
|
browser;
|
|
107
108
|
host;
|
|
109
|
+
/** Read by the runtime for the #975 diagnostic: 0 calls is the finding, not an internal detail. */
|
|
108
110
|
actions = 0;
|
|
109
111
|
pages = 0;
|
|
110
112
|
lastSnapshot = "";
|
|
@@ -328,9 +330,24 @@ export class ApplyBridge {
|
|
|
328
330
|
const facts = await this.probe(t.ref, t.name);
|
|
329
331
|
if (!facts)
|
|
330
332
|
return this.refuse("browser_click", "read", "That element could not be read from the page, so the click cannot be shown to be safe. Take a fresh browser_snapshot.");
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
333
|
+
// WHICH control is this? (#985) The rule is in the shared contract, which states the three
|
|
334
|
+
// families and why the order of the tests is the safety property. It replaced a test against
|
|
335
|
+
// `FALLBACK_COMMIT_RE` — the READ-ONLY floor, which matches bare `apply` on purpose — under
|
|
336
|
+
// which the press that OPENS a SEEK application was the final submit, and four live
|
|
337
|
+
// fill-and-review runs ended `awaiting_review` with `filled: 0` seconds after the cloud had
|
|
338
|
+
// let them through their initial checkpoint.
|
|
339
|
+
const klass = classifyApplyClick({
|
|
340
|
+
role: t.role,
|
|
341
|
+
names: [facts.name, t.name, String(forward.element)],
|
|
342
|
+
submits: facts.submits,
|
|
343
|
+
method: facts.method,
|
|
344
|
+
filled: this.filled,
|
|
345
|
+
uploaded: this.uploaded.size,
|
|
346
|
+
});
|
|
347
|
+
if (klass.klass === "submit")
|
|
348
|
+
return this.submit(forward, klass.reason);
|
|
349
|
+
if (klass.klass === "entry")
|
|
350
|
+
return this.entry(forward, t, klass.reason);
|
|
334
351
|
if (NAV_ROLES.has(t.role) || (t.role === "button" && PAGINATION_NAME.test(t.name.trim()))) {
|
|
335
352
|
if (this.pages >= this.host.limits.maxPages)
|
|
336
353
|
return this.refuse("browser_click", "read", `The run's limit of ${this.host.limits.maxPages} pages is reached. Call ready_for_review now.`);
|
|
@@ -341,7 +358,7 @@ export class ApplyBridge {
|
|
|
341
358
|
}
|
|
342
359
|
// A native file input opens the file chooser for upload_artifact — a DOM fact, whatever its label.
|
|
343
360
|
const fileInput = facts.tag === "input" && facts.type === "file";
|
|
344
|
-
if (fileInput || (t.role === "button" && STEP_NAME.test(t.name.trim()) && !facts.submits)) {
|
|
361
|
+
if (fileInput || klass.klass === "step" || (t.role === "button" && STEP_NAME.test(t.name.trim()) && !facts.submits)) {
|
|
345
362
|
const checkpoint = this.requireSupervisor("browser_click", "fill");
|
|
346
363
|
if (checkpoint)
|
|
347
364
|
return checkpoint;
|
|
@@ -507,20 +524,76 @@ export class ApplyBridge {
|
|
|
507
524
|
return text(`Recorded the runner-verified ${reason} listing notice. Stop now.`);
|
|
508
525
|
}
|
|
509
526
|
review(summary) {
|
|
510
|
-
this.reviewReady = true;
|
|
511
527
|
this.summary = summary; // bounded where the result is built (runtime.ts `end`)
|
|
528
|
+
// #989: "ready for review" with nothing entered is not a review state.
|
|
529
|
+
//
|
|
530
|
+
// The live run ended `awaiting_review` with `filled: 0, uploaded: 0` seconds after the cloud
|
|
531
|
+
// had let it through its initial checkpoint, because a refused submit-class click called
|
|
532
|
+
// this. An owner reading that is told a form is populated and waiting for them — and the one
|
|
533
|
+
// action it invites (#981's approve-and-continue) would send an empty application. There is
|
|
534
|
+
// no form to review, so the honest terminal outcome is the recorded blocker and its remedy
|
|
535
|
+
// (`runtime.ts` `outcomeOf` reads `blocked` once `reviewReady` is false).
|
|
536
|
+
if (!this.filled && !this.uploaded.size && this.blocked) {
|
|
537
|
+
this.host.emit({ type: "policy.decision", detail: { tool: "ready_for_review", class: "review", decision: "refused", reason: this.blocked.reason, count: 0 } });
|
|
538
|
+
return text(`Nothing was entered on this application, so there is nothing to review: the run is recorded as ${this.blocked.reason.replace(/_/g, " ")}. Stop now.`, true);
|
|
539
|
+
}
|
|
540
|
+
this.reviewReady = true;
|
|
512
541
|
this.host.emit({ type: "review.ready", detail: { class: "review", count: this.filled } });
|
|
513
542
|
return text("Recorded. The application waits for the owner's review; nothing was submitted. Stop now.");
|
|
514
543
|
}
|
|
544
|
+
/**
|
|
545
|
+
* The control that OPENS the application (#985) — pressed, never submitted.
|
|
546
|
+
*
|
|
547
|
+
* It needs the supervisor's live `continue` exactly as a fill does: it changes the page, and the
|
|
548
|
+
* cloud is the party that decides whether this page may be worked on. `afterMove` then counts the
|
|
549
|
+
* page, traces it and clears the approval, so the form that opens gets its own checkpoint — which
|
|
550
|
+
* is what keeps a multi-page ATS supervised page by page rather than once at the start.
|
|
551
|
+
*/
|
|
552
|
+
async entry(forward, t, reason) {
|
|
553
|
+
const checkpoint = this.requireSupervisor("browser_click", "fill");
|
|
554
|
+
if (checkpoint)
|
|
555
|
+
return checkpoint;
|
|
556
|
+
if (this.pages >= this.host.limits.maxPages)
|
|
557
|
+
return this.refuse("browser_click", "read", `The run's limit of ${this.host.limits.maxPages} pages is reached. Call ready_for_review now.`);
|
|
558
|
+
this.host.emit({ type: "policy.decision", detail: { tool: "browser_click", class: "entry", decision: "allowed", reason, role: t.role } });
|
|
559
|
+
const res = await this.browser.callTool("browser_click", forward);
|
|
560
|
+
if (res.isError)
|
|
561
|
+
return { content: [{ type: "text", text: textOf(res) }], isError: true };
|
|
562
|
+
return this.afterMove("browser_click", textOf(res));
|
|
563
|
+
}
|
|
515
564
|
/** The final Submit. Never under fill_and_review; once, gated and traced, under auto_submit. */
|
|
516
|
-
async submit(forward) {
|
|
565
|
+
async submit(forward, why = "terminal_label") {
|
|
517
566
|
const checkpoint = this.requireSupervisor("browser_click", "submit");
|
|
518
567
|
if (checkpoint)
|
|
519
568
|
return checkpoint;
|
|
520
569
|
if (this.host.mode !== "auto_submit" || !this.host.gateId) {
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
570
|
+
// `reason` carries WHICH rule classified this as the submit (#985). "fill_and_review"
|
|
571
|
+
// alone left an owner with a run that stopped at `filled: 0` and no way to tell whether it
|
|
572
|
+
// had reached the end of the form or refused the button that opens it.
|
|
573
|
+
// The RULE, not the control's label: the label is the page's own prose and belongs to the
|
|
574
|
+
// machine (the contract keeps page text off the cloud trace). The rule is what an owner
|
|
575
|
+
// needs — "this was the one-click control", not "this was a submit".
|
|
576
|
+
this.host.emit({ type: "policy.decision", detail: { tool: "browser_click", class: "submit", decision: "refused", reason: "fill_and_review", rule: why } });
|
|
577
|
+
const oneClick = why === "one_click_apply";
|
|
578
|
+
// #989: a refusal with NOTHING entered is not a review state, so it is recorded as the
|
|
579
|
+
// blocker it is. `review` then declines to mark the run ready (see there), and the owner
|
|
580
|
+
// gets a terminal outcome they can act on — approve this application, or open it
|
|
581
|
+
// themselves — instead of an empty form presented as waiting for their approval. With
|
|
582
|
+
// work on the page the old reading holds: there IS a filled form to look at.
|
|
583
|
+
if (!this.filled && !this.uploaded.size) {
|
|
584
|
+
this.blocked = {
|
|
585
|
+
reason: "incomplete",
|
|
586
|
+
questions: [
|
|
587
|
+
oneClick
|
|
588
|
+
? "This listing's apply control can send the application in one click, so a fill-and-review run may not press it, and nothing was entered. Approve this application to let it be sent, or apply on the site yourself."
|
|
589
|
+
: "The run reached the control that submits this application without entering anything — the form may already be complete from your profile, or it may not have opened. Approve this application to let it be sent, or open it yourself.",
|
|
590
|
+
],
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
this.review(oneClick ? "Stopped at a one-click apply control: it can send the application outright, and this run fills and waits for review." : "Stopped at the final submit: this run fills and waits for review.");
|
|
594
|
+
return text(oneClick
|
|
595
|
+
? 'That control ("' + String(forward.element ?? "") + '") can send the application in one click, and this run is fill-and-review: it was NOT pressed. Approve this application if you want it sent. Stop now.'
|
|
596
|
+
: "That control submits the application, and this run is fill-and-review: it was NOT pressed. The application waits for the owner's review. Stop now.");
|
|
524
597
|
}
|
|
525
598
|
const before = await this.inspect();
|
|
526
599
|
if (!before)
|
|
@@ -56,7 +56,57 @@ export const LOCAL_APPLY_BLOCK_REASONS = [
|
|
|
56
56
|
"source_unavailable",
|
|
57
57
|
"artifact_changed",
|
|
58
58
|
"job_unavailable",
|
|
59
|
+
"bridge_unused",
|
|
59
60
|
];
|
|
61
|
+
/**
|
|
62
|
+
* Why a run produced nothing on the page (#975) — a CLOSED vocabulary, never free text.
|
|
63
|
+
*
|
|
64
|
+
* The production case: the CLI authenticated, exited 0 after 51 seconds, and emitted no browser,
|
|
65
|
+
* checkpoint, fill or review event. The outcome said `incomplete`, which is also what a half-filled
|
|
66
|
+
* form says, so the operator had no way to tell "it did nothing" from "it stopped partway" — and no
|
|
67
|
+
* way at all to learn WHY without reading a terminal on the machine.
|
|
68
|
+
*
|
|
69
|
+
* Deliberately NOT an output tail, redacted or otherwise. `redactText` removes credential SHAPES; it
|
|
70
|
+
* cannot remove the owner's own résumé prose, their typed answers or a quoted source document, all
|
|
71
|
+
* of which a CLI legitimately prints while it works. So nothing free-form is added here: every field
|
|
72
|
+
* is a count, a code, or an id from {@link LOCAL_APPLY_SIGNALS} that the RUNNER chose by matching
|
|
73
|
+
* its own output. The engine's closing sentence continues to cross as `summary`, where it already
|
|
74
|
+
* did — redacted and capped — so this adds diagnosis without adding a new channel for content.
|
|
75
|
+
*/
|
|
76
|
+
/**
|
|
77
|
+
* The first published CLI whose runner executes THIS contract (#977, raised at #989).
|
|
78
|
+
*
|
|
79
|
+
* An older runner is not broken; it simply predates the vocabulary. Left to run, it reports the
|
|
80
|
+
* same failure in the PREVIOUS shape, which is indistinguishable from a run under the new contract
|
|
81
|
+
* — exactly the live confusion #977 was filed from. So a machine below this is refused BEFORE
|
|
82
|
+
* dispatch, naming the update, rather than silently producing the older behaviour (`apply.ts`
|
|
83
|
+
* `runnerContractProblem`).
|
|
84
|
+
*
|
|
85
|
+
* ── Why it moved to 0.4.85 (#989)
|
|
86
|
+
*
|
|
87
|
+
* #985 fixed the runner's click classification — the press that OPENS a SEEK application stopped
|
|
88
|
+
* being read as the final submit ({@link classifyApplyClick}) — and that fix lives in
|
|
89
|
+
* `packages/browser-runner`, which ships INSIDE the published CLI. The commit did not bump
|
|
90
|
+
* `packages/cli/package.json`, and `publish-npm.yml` publishes only a version that is new, so
|
|
91
|
+
* nothing was published: the live machine kept running 0.4.84, whose bundled bridge still tests
|
|
92
|
+
* `FALLBACK_COMMIT_RE`. The next live run therefore reproduced the defect the fix had already
|
|
93
|
+
* removed from `main` — `browser_click → class: submit → refused: fill_and_review`, no `rule`
|
|
94
|
+
* field, three seconds after the cloud's own `continue` — and the only durable evidence that the
|
|
95
|
+
* code was old was the absence of a field nobody looks for.
|
|
96
|
+
*
|
|
97
|
+
* A behaviour change in the runner is a CONTRACT change, because the cloud's decisions assume it.
|
|
98
|
+
* So this floor moves with it: a machine that cannot tell the entry control from the final submit
|
|
99
|
+
* is refused with a sentence that says so, instead of ending `awaiting_review` having typed
|
|
100
|
+
* nothing.
|
|
101
|
+
*
|
|
102
|
+
* Bump this when the runner's half of the contract changes again, together with the CLI version
|
|
103
|
+
* that ships it — `policy.test.ts` pins the pair, so the floor cannot name a release that has not
|
|
104
|
+
* been published. `cliAtLeast` treats an unreported version as capable, which is the convention
|
|
105
|
+
* every MIN_CLI gate here follows.
|
|
106
|
+
*/
|
|
107
|
+
export const LOCAL_APPLY_CONTRACT_MIN_CLI = "0.4.85";
|
|
108
|
+
export const LOCAL_APPLY_DIAGNOSTIC_CAUSES = ["bridge_unused", "engine_exited_nonzero", "timed_out", "no_engine_output"];
|
|
109
|
+
export const LOCAL_APPLY_SIGNALS = ["approval_policy_blocked", "bridge_tools_missing", "auth_prompt", "no_output", "engine_refused_task"];
|
|
60
110
|
export const LOCAL_APPLY_UNAVAILABLE_REASONS = ["expired", "unavailable"];
|
|
61
111
|
export const LOCAL_APPLY_EVENT_TYPES = [
|
|
62
112
|
"engine.auth_checked",
|
|
@@ -86,7 +136,11 @@ export const LOCAL_APPLY_CAPS = { questions: 20, questionChars: 300, answers: 20
|
|
|
86
136
|
const str = (v, max) => (typeof v === "string" && v.trim() ? v.trim().slice(0, max) : null);
|
|
87
137
|
const oneOf = (list, v) => (list.includes(v) ? v : null);
|
|
88
138
|
const isHttpUrl = (v) => /^https?:\/\/[^\s/]+/i.test(v);
|
|
89
|
-
|
|
139
|
+
// `rule` is on this list from #989: the runner has emitted WHICH rule classified a click as the
|
|
140
|
+
// submit since #985, and this whitelist silently dropped it — so the one fact that tells "it
|
|
141
|
+
// refused the control that opens the form" from "it refused the real submit" never reached the
|
|
142
|
+
// cloud trace, which is where the live investigation needed it.
|
|
143
|
+
const DETAIL_KEYS = new Set(["engine", "authMode", "engineAuth", "mode", "class", "tool", "decision", "reason", "rule", "role", "kind", "path", "sha256", "bytes", "gateId", "exitCode", "count", "status", "basis", "source", "checkpointId", "directive", "phase", "actions", "filled", "uploaded"]);
|
|
90
144
|
/** A validated event, or null. Detail keeps only whitelisted, primitive, bounded values. */
|
|
91
145
|
export function parseLocalApplyEvent(raw) {
|
|
92
146
|
if (!raw || typeof raw !== "object")
|
|
@@ -162,6 +216,24 @@ export function parseLocalApplyResult(raw) {
|
|
|
162
216
|
out.submitted = { url, at, gateId };
|
|
163
217
|
out.submitAttempted = true;
|
|
164
218
|
}
|
|
219
|
+
// #975 — accepted ONLY as the closed vocabulary it declares. A runner that sends prose here gets
|
|
220
|
+
// it dropped rather than stored: the whole point of a structured diagnostic is that no new
|
|
221
|
+
// free-text channel opens between the machine and the owner's records.
|
|
222
|
+
const d = o.diagnostic && typeof o.diagnostic === "object" && !Array.isArray(o.diagnostic) ? o.diagnostic : null;
|
|
223
|
+
const cause = d ? oneOf(LOCAL_APPLY_DIAGNOSTIC_CAUSES, d.cause) : null;
|
|
224
|
+
if (d && cause) {
|
|
225
|
+
const count = (v) => (typeof v === "number" && Number.isInteger(v) && v >= 0 && v <= 1_000_000 ? v : 0);
|
|
226
|
+
out.diagnostic = {
|
|
227
|
+
cause,
|
|
228
|
+
bridgeCalls: count(d.bridgeCalls),
|
|
229
|
+
engineExit: typeof d.engineExit === "number" && Number.isInteger(d.engineExit) && d.engineExit >= -1 && d.engineExit <= 255 ? d.engineExit : 0,
|
|
230
|
+
activeMs: count(d.activeMs),
|
|
231
|
+
pages: count(d.pages),
|
|
232
|
+
filled: count(d.filled),
|
|
233
|
+
// Deduped from a closed 5-value vocabulary, so it is bounded by the enum itself — no cap needed.
|
|
234
|
+
signals: [...new Set((Array.isArray(d.signals) ? d.signals : []).filter((x) => LOCAL_APPLY_SIGNALS.includes(x)))],
|
|
235
|
+
};
|
|
236
|
+
}
|
|
165
237
|
if (outcome === "blocked") {
|
|
166
238
|
out.blockReason = oneOf(LOCAL_APPLY_BLOCK_REASONS, o.blockReason) ?? "incomplete";
|
|
167
239
|
out.questions = (Array.isArray(o.questions) ? o.questions : [])
|
|
@@ -187,3 +259,104 @@ export function parseLocalApplyResult(raw) {
|
|
|
187
259
|
out.error = str(o.error, 1000) ?? "The run failed without a reason.";
|
|
188
260
|
return { result: out };
|
|
189
261
|
}
|
|
262
|
+
// ── Which control is the FINAL submit, and which one opens the form (#985) ───────────────────
|
|
263
|
+
/**
|
|
264
|
+
* ── The live failure
|
|
265
|
+
*
|
|
266
|
+
* Four consecutive fill-and-review runs on one SEEK posting ended `awaiting_review` with
|
|
267
|
+
* `filled: 0`. The last of them (`286bbb8a…`, 2026-10-08) got everything right up to the last
|
|
268
|
+
* step: the runner reported its `initial` checkpoint, the cloud's deterministic policy decided
|
|
269
|
+
* `continue` (#982), the directive was delivered and acknowledged and the CLI resumed — and then,
|
|
270
|
+
* three seconds later, the press that OPENS the application was classified as the final submit,
|
|
271
|
+
* refused under `fill_and_review`, and the run ended at review having typed nothing.
|
|
272
|
+
*
|
|
273
|
+
* The cause was a borrowed vocabulary. The bridge tested the control's label against the runner's
|
|
274
|
+
* `FALLBACK_COMMIT_RE` — the READ-ONLY floor, which matches bare `apply` deliberately, because for
|
|
275
|
+
* an agent that may never act, refusing a filter button called "Apply" is the cheap error. For a
|
|
276
|
+
* run whose entire capability is to fill the form it is the expensive one. The cloud's own
|
|
277
|
+
* `commit-guard.ts` had already written the distinction down twice ("Apply/Next/Continue … are the
|
|
278
|
+
* ENTRY button on most multi-page ATS"; the APPLY family is terminal "only after something has
|
|
279
|
+
* been typed") — the apply bridge just had no list of its own to reach for.
|
|
280
|
+
*
|
|
281
|
+
* ── The three families, and why the order of the tests is the safety property
|
|
282
|
+
*
|
|
283
|
+
* 1. ONE-CLICK ({@link ONE_CLICK_SUBMIT_RE}) — "Quick apply", "Easy Apply", "Apply with your
|
|
284
|
+
* profile". On LinkedIn, and on a profile-apply SEEK listing, that control can send the
|
|
285
|
+
* application outright. Tested FIRST and always terminal, because the two families overlap in
|
|
286
|
+
* English and the overlap is only dangerous in one direction: reading "Quick apply" as an entry
|
|
287
|
+
* control is how an unapproved application reaches an employer.
|
|
288
|
+
* 2. TERMINAL ({@link TERMINAL_APPLY_SUBMIT_RE}) — "Submit", "Send application", "Finish", in
|
|
289
|
+
* twenty languages: the control that sends what has been filled.
|
|
290
|
+
* 3. ENTRY ({@link APPLY_ENTRY_RE}) — "Apply", "Apply for this job", "Next", "Continue", and their
|
|
291
|
+
* equivalents: the control that opens or advances the form. Only ever an entry while nothing has
|
|
292
|
+
* been entered yet and the control is not a POST submit; once a field is filled, the APPLY
|
|
293
|
+
* family becomes terminal again, which is the rule the cloud guard already states.
|
|
294
|
+
*
|
|
295
|
+
* Everything outside the three families is decided by the DOM, as before: a native POST submit is
|
|
296
|
+
* a submit. The one case that changed is a POST submit labelled as a STEP with nothing filled yet
|
|
297
|
+
* ("Save and continue" on page 1) — that stays walkable, because a multi-page ATS cannot be filled
|
|
298
|
+
* otherwise, and the final page's control says SUBMIT.
|
|
299
|
+
*/
|
|
300
|
+
const applyWordish = (token) => `(?<![\\p{L}\\p{N}])${token}(?![\\p{L}\\p{N}])`;
|
|
301
|
+
/** A control that can SEND the application on its own. Terminal, whatever else it looks like. */
|
|
302
|
+
export const ONE_CLICK_SUBMIT_RE = new RegExp([
|
|
303
|
+
"quick apply|easy apply|instant apply|one[- ]?click|1[- ]?click|apply with (your )?(profile|r[ée]sum[ée]|cv|linkedin|indeed|seek)|apply using (your )?profile",
|
|
304
|
+
...["schnellbewerbung", "postulation rapide", "candidatura rapida", "snelle sollicitatie"].map(applyWordish),
|
|
305
|
+
...["快速申请", "簡単応募", "간편지원"],
|
|
306
|
+
].join("|"), "iu");
|
|
307
|
+
/** The control that SENDS a filled application. Mirrors the cloud guard's terminal English set. */
|
|
308
|
+
export const TERMINAL_APPLY_SUBMIT_RE = new RegExp([
|
|
309
|
+
"\\b(submit|finish|done|complete|confirm)\\b|send application|submit application|send my application",
|
|
310
|
+
...[
|
|
311
|
+
"envoyer", "soumettre", "valider", "finaliser", "terminer", "confirmer",
|
|
312
|
+
"absenden", "abschicken", "senden", "einreichen", "best[äa]tigen", "abschlie[sß]en", "fertigstellen",
|
|
313
|
+
"enviar", "confirmar", "finalizar", "completar", "submeter", "concluir",
|
|
314
|
+
"invia", "inviare", "conferma", "confermare", "completa", "termina", "finalizza",
|
|
315
|
+
"verstuur", "versturen", "verzenden", "indienen", "bevestig", "bevestigen", "voltooien", "afronden",
|
|
316
|
+
"skicka", "sende", "bekr[äae]fta", "bekreft", "bekr[æa]ft", "slutf[öo]r", "fullf[øo]r", "afslut",
|
|
317
|
+
"wy[śs]lij", "z[łl]ó[żz]", "potwierd[źz]", "zako[ńn]cz", "odeslat", "potvrdit", "dokon[čc]it",
|
|
318
|
+
"g[öo]nder", "onayla", "tamamla", "kirim", "kirimkan", "ajukan", "konfirmasi",
|
|
319
|
+
"g[ửu]i", "n[ộo]p", "отправить", "подтвердить", "завершить", "подать",
|
|
320
|
+
].map(applyWordish),
|
|
321
|
+
...["提交", "送出", "确认", "確認", "完成", "送信", "提出", "完了", "제출", "보내기", "확인", "완료", "إرسال", "تقديم", "تأكيد"],
|
|
322
|
+
].join("|"), "iu");
|
|
323
|
+
/** The control that OPENS or ADVANCES the form — never the one that sends it. */
|
|
324
|
+
export const APPLY_ENTRY_RE = new RegExp([
|
|
325
|
+
"\\b(apply|apply now|apply for this job|apply for this role|view (and )?apply|start( your)? application|begin( your)? application|next|continue|save and continue|start)\\b",
|
|
326
|
+
...["postuler", "candidater", "bewerben", "candidati", "solliciteer", "aplikuj", "aplicar", "postular", "candidatar", "ans[øo]g", "başvur", "lamar", "откликнуться"].map(applyWordish),
|
|
327
|
+
...["申请", "应聘", "応募する", "応募", "지원하기", "التقديم"],
|
|
328
|
+
].join("|"), "iu");
|
|
329
|
+
/**
|
|
330
|
+
* Classify one click. PURE, so the rule that decides whether a real application is sent is
|
|
331
|
+
* assertable without a browser — and identical in both copies of this file.
|
|
332
|
+
*
|
|
333
|
+
* Order is the specification: one-click first (it can send), then terminal, then the DOM's own
|
|
334
|
+
* verdict once anything has been entered, then entry/step.
|
|
335
|
+
*/
|
|
336
|
+
export function classifyApplyClick(input) {
|
|
337
|
+
const names = input.names.map((n) => (n ?? "").trim()).filter((n) => n.length > 0);
|
|
338
|
+
const hit = (re) => names.find((n) => re.test(n));
|
|
339
|
+
const entered = input.filled > 0 || input.uploaded > 0;
|
|
340
|
+
const oneClick = hit(ONE_CLICK_SUBMIT_RE);
|
|
341
|
+
if (oneClick)
|
|
342
|
+
return { klass: "submit", reason: "one_click_apply" };
|
|
343
|
+
const terminal = hit(TERMINAL_APPLY_SUBMIT_RE);
|
|
344
|
+
if (terminal)
|
|
345
|
+
return { klass: "submit", reason: "terminal_label" };
|
|
346
|
+
// A native POST submit, once something HAS been entered: there is an application to send, so
|
|
347
|
+
// the benefit of the doubt goes to the employer rather than to the run.
|
|
348
|
+
if (input.submits && input.method === "post" && entered)
|
|
349
|
+
return { klass: "submit", reason: "post_submit_after_fill" };
|
|
350
|
+
const entry = hit(APPLY_ENTRY_RE);
|
|
351
|
+
// Nothing entered yet: an APPLY/NEXT control opens the form. This is the press that ended four
|
|
352
|
+
// live runs at `filled: 0` — and it cannot be the final submit, because nothing has been typed.
|
|
353
|
+
if (entry && !entered)
|
|
354
|
+
return { klass: "entry", reason: input.submits ? "entry_post_form_nothing_filled" : "entry_label_nothing_filled" };
|
|
355
|
+
if (entry)
|
|
356
|
+
return { klass: "step", reason: "step_after_fill" };
|
|
357
|
+
// A POST submit with no recognised label and nothing entered is a page-advance on a multi-page
|
|
358
|
+
// form; with something entered it was caught above.
|
|
359
|
+
if (input.submits && input.method === "post")
|
|
360
|
+
return { klass: "step", reason: "post_submit_nothing_filled" };
|
|
361
|
+
return { klass: "other", reason: "unrecognised" };
|
|
362
|
+
}
|
|
@@ -147,6 +147,23 @@ export function verifyArtifact(workspace, home, a) {
|
|
|
147
147
|
return { error: `The approved ${label} at ${a.path} could not be read.` };
|
|
148
148
|
}
|
|
149
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* What to tell the owner when the CLI put nothing on the page (#975) — built from the diagnostic's
|
|
152
|
+
* own ids, so the advice follows the observation rather than guessing at it.
|
|
153
|
+
*/
|
|
154
|
+
function bridgeUnusedHelp(engine, d) {
|
|
155
|
+
if (d.signals.includes("approval_policy_blocked"))
|
|
156
|
+
return `The ${engine} CLI would not use the browser tools because its own approval policy refused them. Allow the PAGS bridge toolset for this session, then retry.`;
|
|
157
|
+
if (d.signals.includes("bridge_tools_missing"))
|
|
158
|
+
return `The ${engine} CLI never saw the browser tools. Update the CLI (npm i -g @proagentstore/cli), run \`pags up\` again, then retry.`;
|
|
159
|
+
if (d.signals.includes("auth_prompt"))
|
|
160
|
+
return `The ${engine} CLI stopped on a sign-in prompt before opening the page. Sign it in on this machine, then retry.`;
|
|
161
|
+
if (d.signals.includes("no_output"))
|
|
162
|
+
return `The ${engine} CLI produced no output at all before exiting. Check that it runs on this machine, then retry.`;
|
|
163
|
+
if (d.signals.includes("engine_refused_task"))
|
|
164
|
+
return `The ${engine} CLI declined the task and never opened the page. Its own closing message is on this run's summary.`;
|
|
165
|
+
return `The ${engine} CLI ran for ${Math.round(d.activeMs / 1000)}s and exited (code ${d.engineExit}) without opening the application page — no navigation, no checkpoint, no fill. Nothing was submitted. Retry, or run the CLI by hand on this machine to see why it stops.`;
|
|
166
|
+
}
|
|
150
167
|
export class LocalApplyRuntime {
|
|
151
168
|
deps;
|
|
152
169
|
runs = new Map();
|
|
@@ -304,6 +321,42 @@ export class LocalApplyRuntime {
|
|
|
304
321
|
}, 5_000);
|
|
305
322
|
run.timer.unref?.();
|
|
306
323
|
}
|
|
324
|
+
/**
|
|
325
|
+
* What the runner OBSERVED about its own CLI (#975), as ids this platform defines.
|
|
326
|
+
*
|
|
327
|
+
* Matched against the engine's structured output on the machine; only the matching signal's ID
|
|
328
|
+
* crosses the contract, never the line that matched it. That is what makes a diagnostic safe to
|
|
329
|
+
* persist: a CLI prints the owner's résumé prose and their typed answers while it works, and no
|
|
330
|
+
* redaction can reliably tell those from the rest of its chatter.
|
|
331
|
+
*/
|
|
332
|
+
signalsOf(run) {
|
|
333
|
+
const text = run.output.join("\n");
|
|
334
|
+
const out = [];
|
|
335
|
+
if (!text.trim())
|
|
336
|
+
out.push("no_output");
|
|
337
|
+
// #952's live failure, invisible from the cloud and fixable by configuration.
|
|
338
|
+
if (/approval policy is\s*[`'"]?never|requires? approval|permission denied for tool|not allowed to use/i.test(text))
|
|
339
|
+
out.push("approval_policy_blocked");
|
|
340
|
+
if (/browser_navigate.{0,40}(not found|unknown tool|unavailable)|no such tool|tool .{0,40}is not available/i.test(text))
|
|
341
|
+
out.push("bridge_tools_missing");
|
|
342
|
+
if (missingLogin(run.envelope.engine, text))
|
|
343
|
+
out.push("auth_prompt");
|
|
344
|
+
if (/\b(i (cannot|can't|won't)|unable to) (help|assist|complete|do that|proceed)/i.test(text))
|
|
345
|
+
out.push("engine_refused_task");
|
|
346
|
+
return [...new Set(out)];
|
|
347
|
+
}
|
|
348
|
+
/** The bounded, closed-vocabulary diagnostic for a run that put nothing on the page (#975). */
|
|
349
|
+
diagnosticOf(run, code, cause) {
|
|
350
|
+
return {
|
|
351
|
+
cause,
|
|
352
|
+
bridgeCalls: run.bridge?.actions ?? 0,
|
|
353
|
+
engineExit: Number.isInteger(code) && code >= -1 && code <= 255 ? code : 0,
|
|
354
|
+
activeMs: this.activeMs(run),
|
|
355
|
+
pages: run.bridge?.pages ?? 0,
|
|
356
|
+
filled: run.bridge?.filled ?? 0,
|
|
357
|
+
signals: this.signalsOf(run),
|
|
358
|
+
};
|
|
359
|
+
}
|
|
307
360
|
/** What the run amounts to when the CLI exits — decided by what the bridge did, not what the CLI says. */
|
|
308
361
|
outcomeOf(run, code) {
|
|
309
362
|
const e = run.envelope;
|
|
@@ -323,10 +376,26 @@ export class LocalApplyRuntime {
|
|
|
323
376
|
return { outcome: "blocked", summary, blockReason: "engine_not_signed_in", questions: [signInHelp(e.engine)], engineAuth: "missing_login" };
|
|
324
377
|
if (b?.blocked)
|
|
325
378
|
return { outcome: "blocked", summary, blockReason: b.blocked.reason, questions: b.blocked.questions };
|
|
326
|
-
if (run.timedOut)
|
|
327
|
-
return { outcome: "blocked", summary, blockReason: "incomplete", questions: [`Stopped at the ${e.limits.maxMinutes}-minute limit before the form was ready for review.`] };
|
|
379
|
+
if (run.timedOut) {
|
|
380
|
+
return { outcome: "blocked", summary, blockReason: "incomplete", questions: [`Stopped at the ${e.limits.maxMinutes}-minute limit before the form was ready for review.`], diagnostic: this.diagnosticOf(run, code, "timed_out") };
|
|
381
|
+
}
|
|
382
|
+
// #975: a CLI that never called the bridge did NOTHING on the page, which is a different
|
|
383
|
+
// failure from stopping partway — different cause, different fix — and saying `incomplete`
|
|
384
|
+
// for both left the owner with a 51-second run they could not explain. The diagnostic says
|
|
385
|
+
// how long it ran, how many bridge calls it made (0) and what the runner saw it print.
|
|
386
|
+
const calls = b?.actions ?? 0;
|
|
387
|
+
if (calls === 0) {
|
|
388
|
+
const diagnostic = this.diagnosticOf(run, code, !run.output.join("").trim() ? "no_engine_output" : code !== 0 ? "engine_exited_nonzero" : "bridge_unused");
|
|
389
|
+
return { outcome: "blocked", summary, blockReason: "bridge_unused", questions: [bridgeUnusedHelp(e.engine, diagnostic)], diagnostic };
|
|
390
|
+
}
|
|
328
391
|
const tail = code !== 0 ? ` (the ${e.engine} CLI exited with code ${code})` : "";
|
|
329
|
-
return {
|
|
392
|
+
return {
|
|
393
|
+
outcome: "blocked",
|
|
394
|
+
summary,
|
|
395
|
+
blockReason: "incomplete",
|
|
396
|
+
questions: [`The CLI stopped before marking the form ready for review${tail}. Review the form yourself, or retry.`],
|
|
397
|
+
diagnostic: this.diagnosticOf(run, code, code !== 0 ? "engine_exited_nonzero" : "bridge_unused"),
|
|
398
|
+
};
|
|
330
399
|
}
|
|
331
400
|
activeMs(run) {
|
|
332
401
|
return run.activeMs + (run.state === "running" ? this.now() - run.activeSince : 0);
|
|
@@ -524,6 +593,9 @@ export class LocalApplyRuntime {
|
|
|
524
593
|
...(r.outcome === "submitted" && r.submitted ? { submitted: r.submitted } : {}),
|
|
525
594
|
...(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 } : {}) } : {}),
|
|
526
595
|
...(r.outcome === "failed" ? { error: redactText((r.error ?? "The run failed without a reason.").slice(0, 1000), run.secrets) } : {}),
|
|
596
|
+
// #975 — carried through as the closed vocabulary it is. Nothing here is redacted because
|
|
597
|
+
// nothing here is text: counts, an exit code, and signal ids this platform defines.
|
|
598
|
+
...(r.diagnostic ? { diagnostic: r.diagnostic } : {}),
|
|
527
599
|
};
|
|
528
600
|
if (run.child && run.child.exitCode === null)
|
|
529
601
|
this.kill(run);
|